Scope project refreshes and harden deploy script
This commit is contained in:
@@ -71,7 +71,7 @@ case "$BUILD_MODE" in
|
||||
;;
|
||||
esac
|
||||
|
||||
"${SSH_PREFIX[@]}" "$REMOTE_HOST" "sudo mkdir -p $REMOTE_DIR && sudo chown -R ${REMOTE_USER}:${REMOTE_USER} $REMOTE_DIR && sudo rm -rf $REMOTE_DIR/.next"
|
||||
"${SSH_PREFIX[@]}" "$REMOTE_HOST" "sudo mkdir -p $REMOTE_DIR $REMOTE_DIR/data $REMOTE_DIR/public/downloads && sudo chown -R ${REMOTE_USER}:${REMOTE_USER} $REMOTE_DIR && sudo rm -rf $REMOTE_DIR/.next"
|
||||
|
||||
RSYNC_EXCLUDES=(
|
||||
--exclude ".git"
|
||||
@@ -85,7 +85,7 @@ if [[ "$use_remote_build" == true ]]; then
|
||||
RSYNC_EXCLUDES+=(--exclude ".next")
|
||||
fi
|
||||
|
||||
rsync -az --delete \
|
||||
rsync -az --delete --rsync-path="sudo rsync" \
|
||||
"${RSYNC_EXCLUDES[@]}" \
|
||||
-e "$RSYNC_RSH" \
|
||||
"$ROOT_DIR/" "$REMOTE_HOST:$REMOTE_DIR/"
|
||||
@@ -99,6 +99,7 @@ fi
|
||||
POST_SYNC_REMOTE_CMD="
|
||||
sudo bash $REMOTE_DIR/scripts/bootstrap-server.sh &&
|
||||
sudo chown -R ${REMOTE_USER}:${REMOTE_USER} $REMOTE_DIR &&
|
||||
sudo chown -R ${REMOTE_USER}:${REMOTE_USER} $REMOTE_DIR/data $REMOTE_DIR/public/downloads &&
|
||||
cd $REMOTE_DIR &&
|
||||
$REMOTE_INSTALL_AND_BUILD_CMD &&
|
||||
sudo systemctl restart boss-web &&
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { RealtimeRefresh } from "@/components/app-runtime";
|
||||
import {
|
||||
AppShell,
|
||||
GoalChecklist,
|
||||
@@ -25,6 +26,7 @@ export default async function GoalsPage({
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<RealtimeRefresh projectId={projectId} events={["conversation.updated"]} />
|
||||
<StatusBar />
|
||||
<PageNav title="项目目标" backHref={`/conversations/${projectId}`} />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-6">
|
||||
|
||||
@@ -44,6 +44,7 @@ export default async function ProjectChatPage({
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<RealtimeRefresh
|
||||
projectId={detail.project.id}
|
||||
events={[
|
||||
"conversation.updated",
|
||||
"project.messages.updated",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { RealtimeRefresh } from "@/components/app-runtime";
|
||||
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
@@ -39,6 +40,7 @@ export default async function ThreadStatusPage({
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<RealtimeRefresh projectId={projectId} events={["conversation.updated", "project.messages.updated"]} />
|
||||
<StatusBar />
|
||||
<PageNav title="线程状态" backHref={`/conversations/${projectId}`} />
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { RealtimeRefresh } from "@/components/app-runtime";
|
||||
import {
|
||||
AppShell,
|
||||
PageNav,
|
||||
@@ -22,6 +23,10 @@ export default async function VersionsPage({
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<RealtimeRefresh
|
||||
projectId={projectId}
|
||||
events={["conversation.updated", "project.messages.updated", "ota.updated"]}
|
||||
/>
|
||||
<StatusBar />
|
||||
<PageNav title="版本迭代记录" backHref={`/conversations/${projectId}`} />
|
||||
<div className="flex flex-col gap-3 px-[18px] pb-6">
|
||||
|
||||
@@ -253,31 +253,60 @@ export function NativeAppBridge() {
|
||||
|
||||
export function RealtimeRefresh({
|
||||
events,
|
||||
projectId,
|
||||
}: {
|
||||
events: BossEventName[];
|
||||
projectId?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource("/api/v1/events");
|
||||
const refresh = () => router.refresh();
|
||||
const listeners = [
|
||||
const listeners = Array.from(new Set([
|
||||
"conversation.context_indicator.updated",
|
||||
"project.context_risk.updated",
|
||||
...events,
|
||||
];
|
||||
]));
|
||||
const shouldRefresh = (event: Event) => {
|
||||
if (!projectId || !("data" in event) || typeof event.data !== "string" || !event.data.trim()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as { projectId?: string };
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof payload.projectId !== "string" || !payload.projectId.trim()) {
|
||||
return true;
|
||||
}
|
||||
return payload.projectId === projectId;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const listenerMap = new Map<string, (event: Event) => void>();
|
||||
|
||||
for (const event of listeners) {
|
||||
const refresh = (nextEvent: Event) => {
|
||||
if (!shouldRefresh(nextEvent)) {
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
};
|
||||
listenerMap.set(event, refresh);
|
||||
source.addEventListener(event, refresh);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const event of listeners) {
|
||||
source.removeEventListener(event, refresh);
|
||||
const refresh = listenerMap.get(event);
|
||||
if (refresh) {
|
||||
source.removeEventListener(event, refresh);
|
||||
}
|
||||
}
|
||||
source.close();
|
||||
};
|
||||
}, [events, router]);
|
||||
}, [events, projectId, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -73,10 +73,18 @@ exec "$@"
|
||||
.split("\0")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const rsyncLog = await readFile(path.join(logDir, "rsync.log"), "utf8");
|
||||
const rsyncArgs = rsyncLog
|
||||
.split("\0")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
assert.equal(sshCalls.length, 2);
|
||||
assert.match(sshCalls[0] ?? "", /sudo mkdir -p \/opt\/boss/);
|
||||
assert.match(rsyncArgs, /--rsync-path=sudo rsync/);
|
||||
assert.match(sshCalls[1] ?? "", /bootstrap-server\.sh/);
|
||||
assert.match(sshCalls[1] ?? "", /sudo chown -R ubuntu:ubuntu \/opt\/boss\/data \/opt\/boss\/public\/downloads/);
|
||||
assert.match(sshCalls[1] ?? "", /npm install --omit=dev/);
|
||||
assert.match(sshCalls[1] ?? "", /systemctl restart boss-web/);
|
||||
assert.match(sshCalls[1] ?? "", /curl -fsS http:\/\/127\.0\.0\.1:3000\/api\/health/);
|
||||
@@ -159,6 +167,7 @@ exec "$@"
|
||||
.join(" ");
|
||||
|
||||
assert.match(rsyncArgs, /--exclude \.next/);
|
||||
assert.match(rsyncArgs, /--rsync-path=sudo rsync/);
|
||||
|
||||
const sshLog = await readFile(path.join(logDir, "ssh.log"), "utf8");
|
||||
const sshCalls = sshLog
|
||||
@@ -167,6 +176,7 @@ exec "$@"
|
||||
.filter(Boolean);
|
||||
|
||||
assert.equal(sshCalls.length, 2);
|
||||
assert.match(sshCalls[1] ?? "", /sudo chown -R ubuntu:ubuntu \/opt\/boss\/data \/opt\/boss\/public\/downloads/);
|
||||
assert.match(sshCalls[1] ?? "", /npm install && BOSS_RUNTIME_ROOT=\/opt\/boss BOSS_STATE_FILE=\/opt\/boss\/data\/boss-state\.json npm run build/);
|
||||
assert.match(sshCalls[1] ?? "", /npm prune --omit=dev/);
|
||||
assert.doesNotMatch(sshCalls[1] ?? "", /npm install --omit=dev/);
|
||||
|
||||
39
tests/project-scoped-realtime-refresh.test.ts
Normal file
39
tests/project-scoped-realtime-refresh.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const testsDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function readWorkspaceFile(relativePath: string) {
|
||||
return readFile(path.join(testsDir, "..", relativePath), "utf8");
|
||||
}
|
||||
|
||||
test("RealtimeRefresh supports project-scoped refresh filtering", async () => {
|
||||
const source = await readWorkspaceFile("src/components/app-runtime.tsx");
|
||||
|
||||
assert.match(source, /projectId\?: string/, "expected RealtimeRefresh to accept an optional projectId");
|
||||
assert.match(source, /payload\.projectId === projectId/, "expected RealtimeRefresh to filter by matching projectId");
|
||||
});
|
||||
|
||||
test("project conversation pages wire project-scoped realtime refresh", async () => {
|
||||
const [projectPage, goalsPage, versionsPage, threadStatusPage] = await Promise.all([
|
||||
readWorkspaceFile("src/app/conversations/[projectId]/page.tsx"),
|
||||
readWorkspaceFile("src/app/conversations/[projectId]/goals/page.tsx"),
|
||||
readWorkspaceFile("src/app/conversations/[projectId]/versions/page.tsx"),
|
||||
readWorkspaceFile("src/app/conversations/[projectId]/thread-status/page.tsx"),
|
||||
]);
|
||||
|
||||
assert.match(projectPage, /projectId=\{detail\.project\.id\}/, "expected project chat page to pass projectId into RealtimeRefresh");
|
||||
|
||||
for (const [label, source] of [
|
||||
["goals", goalsPage],
|
||||
["versions", versionsPage],
|
||||
["thread-status", threadStatusPage],
|
||||
] as const) {
|
||||
assert.match(source, /<RealtimeRefresh/, `expected ${label} page to render RealtimeRefresh`);
|
||||
assert.match(source, /projectId=\{projectId\}/, `expected ${label} page to scope refreshes to the current project`);
|
||||
assert.match(source, /"conversation\.updated"/, `expected ${label} page to listen to conversation updates`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user