diff --git a/scripts/deploy-server.sh b/scripts/deploy-server.sh index d368ed7..49286c4 100755 --- a/scripts/deploy-server.sh +++ b/scripts/deploy-server.sh @@ -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 && diff --git a/src/app/conversations/[projectId]/goals/page.tsx b/src/app/conversations/[projectId]/goals/page.tsx index 09564c4..4c27ad9 100644 --- a/src/app/conversations/[projectId]/goals/page.tsx +++ b/src/app/conversations/[projectId]/goals/page.tsx @@ -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 ( +
diff --git a/src/app/conversations/[projectId]/page.tsx b/src/app/conversations/[projectId]/page.tsx index 35cca72..2206075 100644 --- a/src/app/conversations/[projectId]/page.tsx +++ b/src/app/conversations/[projectId]/page.tsx @@ -44,6 +44,7 @@ export default async function ProjectChatPage({ return ( +
diff --git a/src/app/conversations/[projectId]/versions/page.tsx b/src/app/conversations/[projectId]/versions/page.tsx index 048d764..a4edcc4 100644 --- a/src/app/conversations/[projectId]/versions/page.tsx +++ b/src/app/conversations/[projectId]/versions/page.tsx @@ -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 ( +
diff --git a/src/components/app-runtime.tsx b/src/components/app-runtime.tsx index 8ba6224..081c388 100644 --- a/src/components/app-runtime.tsx +++ b/src/components/app-runtime.tsx @@ -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 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; } diff --git a/tests/deploy-server-script.test.ts b/tests/deploy-server-script.test.ts index 7c97710..1b8ad6c 100644 --- a/tests/deploy-server-script.test.ts +++ b/tests/deploy-server-script.test.ts @@ -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/); diff --git a/tests/project-scoped-realtime-refresh.test.ts b/tests/project-scoped-realtime-refresh.test.ts new file mode 100644 index 0000000..22eaca9 --- /dev/null +++ b/tests/project-scoped-realtime-refresh.test.ts @@ -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, /