diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index c21a9ccd98..4e418a7d49 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -58,6 +58,7 @@ "smoke:team-plan-proposal": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --jsx react-jsx --types node --skipLibCheck --strict --rootDir ../../.. --outDir node_modules/.cache/loopx-team-plan-smoke smoke/team-plan-proposal-smoke.ts src/data/chat.ts src/features/personal-workspace/team-plan-preview.ts src/vite-env.d.ts && node node_modules/.cache/loopx-team-plan-smoke/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.js", "smoke:status-source-switch-browser": "node ../../../examples/status-source-switch-browser-smoke.mjs", "smoke:status-source-switch-packaged": "LOOPX_STATUS_SOURCE_SWITCH_PACKAGED=1 LOOPX_STATUS_SOURCE_SWITCH_PORT=5198 node ../../../examples/status-source-switch-browser-smoke.mjs", + "smoke:runtime-source-workspace": "node ../../../examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs", "smoke:status-sources": "rm -rf /tmp/loopx-status-source-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-source-smoke smoke/status-source-catalog-smoke.ts src/data/ssh-host-catalog.ts src/data/status-source-catalog.ts src/data/local-status-query.ts src/data/status.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-source-smoke/apps/presentation/dashboard/smoke/status-source-catalog-smoke.js", "smoke:task-board-scroll": "node smoke/task-board-scroll-smoke.mjs", "smoke:usage-progress": "tsc --ignoreConfig --target ES2022 --module ESNext --moduleResolution Bundler --allowImportingTsExtensions --types vite/client --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --noEmit smoke/usage-progress-smoke.ts src/features/personal-workspace/personal-workspace-model.ts && vite build --ssr smoke/usage-progress-smoke.ts --outDir node_modules/.cache/loopx-usage-progress-smoke --emptyOutDir && node node_modules/.cache/loopx-usage-progress-smoke/usage-progress-smoke.js", diff --git a/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts b/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts index 3952f50db2..1d5e7f6edf 100644 --- a/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts +++ b/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts @@ -8,6 +8,7 @@ import { localStatusSource, projectedStatusSourceForUrl, removeStatusSource, + runtimeStatusSourceCatalog, saveStatusSourceCatalog, statusSourceCatalogStorageKey, statusSourceForUrl, @@ -124,4 +125,21 @@ equal(configuredDraft.command, "ssh -N -L 8876:127.0.0.1:8766 remote-lab", "the equal(configuredDraft.statusUrl, "http://127.0.0.1:8876/status.json", "the selected Host maps to the loopback-only status source"); assert("error" in configuredSshTunnelDraft("remote-lab", "22"), "privileged local ports fail closed"); +const runtimeUrl = "http://127.0.0.1:19801/status.json"; +const runtimeEntries = [ + {id: "retained", label: "Retained", status_url: runtimeUrl}, + {id: "fresh", label: "Fresh", status_url: "http://127.0.0.1:19802/status.json"}, +]; +const runtimes = runtimeStatusSourceCatalog(runtimeEntries, runtimeUrl, baseHref); +assert(runtimes, "an owner-prepared catalog recognizes its current source"); +assert(runtimes.sources.every((s) => s.readOnly && s.kind === "runtime" && !s.hostAlias), "runtime readers never acquire SSH or execution authority"); +equal(activeStatusSourceForUrl(runtimes, runtimeEntries[1].status_url, runtimeUrl, baseHref).id, "fresh", "a failed or pending same-Goal source selection does not bounce to the loaded runtime"); +equal(runtimeStatusSourceCatalog(undefined, runtimeUrl, baseHref), null, "existing services need no runtime catalog"); +equal(runtimeStatusSourceCatalog(runtimeEntries, "http://127.0.0.1:19803/status.json", baseHref), null, "an unrelated service cannot replace source choices"); +equal(runtimeStatusSourceCatalog([...runtimeEntries, runtimeEntries[0]], runtimeUrl, baseHref), null, "duplicate runtime ids or URLs fail closed"); +equal(runtimeStatusSourceCatalog([{...runtimeEntries[0], status_url: "https://external.example/status.json"}], runtimeUrl, baseHref), null, "runtime readers remain loopback-only"); +const runtimeStorage = new MemoryStorage(); +saveStatusSourceCatalog(runtimeStorage, runtimes); +equal(loadStatusSourceCatalog(runtimeStorage, baseHref).sources.length, 1, "ephemeral runtime choices are not persisted as execution or SSH configuration"); + console.log("status source catalog smoke: ok"); diff --git a/apps/presentation/dashboard/src/data/status-source-catalog.ts b/apps/presentation/dashboard/src/data/status-source-catalog.ts index 9c54843616..ae8acced2e 100644 --- a/apps/presentation/dashboard/src/data/status-source-catalog.ts +++ b/apps/presentation/dashboard/src/data/status-source-catalog.ts @@ -7,7 +7,7 @@ export const statusSourceCatalogStorageKey = "loopx-status-source-catalog-v1"; export type StatusSource = { hostAlias?: string; id: string; - kind: "local" | "ssh_tunnel"; + kind: "local" | "ssh_tunnel" | "runtime"; label: string; readOnly: boolean; statusUrl: string; @@ -20,6 +20,24 @@ export type StatusSourceCatalog = { export type StatusSourceStorage = Pick; +/** Runtime choices describe physical readers, not new execution Goals. */ +export function runtimeStatusSourceCatalog( + entries: {id: string; label: string; status_url: string}[] | undefined, + currentUrl: string, + baseHref: string, +): StatusSourceCatalog | null { + if (!entries?.length || entries.length > 12) return null; + const sources: StatusSource[] = []; + for (const entry of entries) { + const resolved = normalizedTunnelUrl(entry.status_url, baseHref); + if (!("url" in resolved) || sources.some((s) => s.id === entry.id || s.statusUrl === resolved.url)) return null; + sources.push({id: entry.id, label: entry.label, kind: "runtime", readOnly: true, statusUrl: resolved.url}); + } + const current = new URL(currentUrl, baseHref).toString(); + // A source cannot replace the selected origin with an unrelated catalog. + return sources.some((s) => s.statusUrl === current) ? {schemaVersion: 1, sources} : null; +} + export const localStatusSource: StatusSource = { id: "local", kind: "local", diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index f1d4aa45ac..3461e382a3 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -849,6 +849,12 @@ export const localDashboardApiSchema = z.object({ configure_goal_dry_run_url: z.string().optional().nullable(), configure_goal_apply_url: z.string().optional().nullable(), control_plane_write_enabled: z.boolean().optional().default(false), + // Ephemeral, owner-prepared read sources; never execution registry entries. + read_only_sources: z.array(z.object({ + id: z.string().min(1).max(96), + label: z.string().min(1).max(48), + status_url: z.string().min(1), + })).max(12).optional(), }).optional().nullable(); // Core keeps the presentation status contract provider-neutral. A ready/ diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx index 5640878b02..a2d86d26f2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-overview.tsx @@ -28,7 +28,8 @@ function relativeLookLabel(iso: string, locale: string): string { return new Intl.DateTimeFormat(locale === "zh-CN" ? "zh-CN" : "en", { month: "short", day: "numeric" }).format(then); } -export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, review, digest, railOpen, onCloseRail, onOpenAgentConversation, sinceYouLooked, onMarkSeen }: { +export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenDetails, onSelect, onView, selectedTodoId, board, results, proposals, onPreview, historyRefreshRevision, readSourceUrl, review, digest, railOpen, onCloseRail, onOpenAgentConversation, sinceYouLooked, onMarkSeen }: { + readSourceUrl?: string; onOpenAgentConversation?: (agentId: string) => void; /** Material changes since the owner's last acknowledged look; null when none. */ sinceYouLooked?: SinceYouLookedDelta | null; onMarkSeen?: () => void; @@ -104,7 +105,7 @@ export function GoalOverview({ active, goal, items, userTodos, readOnly, onOpenD {moreAttention ? : null} : null} - {!readOnly ? : null} + {!readOnly || readSourceUrl ? : null} {copy.remote}

: }/> diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 2097e1187c..dc039a9725 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -16,7 +16,6 @@ type Pack = ResearchProgressPack & {source_count: number; analysis_key: string; objective?: string; episodes?: TrajectoryEpisode[]; job?: Job | null}; type Snapshot = {ok: true; catalog: {availability: string; reason_code: string | null; default_pack_id: string | null; packs: Pack[]}; pack: Pack | null}; type Citation = AssessmentCitation & {artifact: string; text: string; applicability: string}; -const endpoint = "/api/chat/research-analysis"; const errorText = (error: unknown) => error instanceof Error ? error.message : String(error); const locationFor = (goal: string) => { const query = new URLSearchParams(window.location.search); @@ -24,7 +23,8 @@ const locationFor = (goal: string) => { return {open: query.get("research_analysis") === goal, packId: owner === goal ? query.get("research_pack") : null}; }; -export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean) { +export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean, readSourceUrl?: string) { + const endpoint = readSourceUrl ? new URL("/api/chat/research-analysis", readSourceUrl).toString() : "/api/chat/research-analysis"; const [citation, setCitation] = useState(null); const [citationError, setCitationError] = useState(""); const [citationBusy, setCitationBusy] = useState(false); @@ -58,7 +58,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean } if (current === generation.current && sequence === readSequence.current && scope.current.goalId === goalId && scope.current.packId === packId && !signal?.aborted) {setSnapshot(value); setReadError(""); if (value.pack?.job?.status === "succeeded" || value.pack?.job?.status === "cancelled") setError("");} return value; - }, [goalId, location.packId, zh]); + }, [goalId, location.packId, zh, endpoint]); const applyLocation = useCallback((next: typeof location) => { // Selection and browser history share one source transition. Invalidate // pending reads/actions before a new scope can display the old snapshot. @@ -116,6 +116,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean requestAnimationFrame(() => {panel.current?.focus({preventScroll: true}); panel.current?.scrollIntoView({block: "start"});}); }; const execute = async (retry = false) => { + if (readSourceUrl) return; if (!pack || pack.availability !== "available" || submitting.current) return; show(); submitting.current = true; setPending(true); setError(""); const current = generation.current; @@ -126,6 +127,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean finally {if (current === generation.current) {submitting.current = false; setPending(false);}} }; const cancel = async () => { + if (readSourceUrl) return; if (!pack || cancelPending) return; const current = generation.current; setCancelPending(true); @@ -147,7 +149,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean finally {if (current === citeGeneration.current) setCitationBusy(false);} }; const closeCitation = () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); requestAnimationFrame(() => sourceTrigger.current?.focus());}; - return {citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot, pack, readError, error: error || readError, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, + return {readOnly: Boolean(readSourceUrl), citation, citationBusy, citationError, sourcePanel, readCitation, closeCitation, goalId, zh, location, snapshot, pack, readError, error: error || readError, loading, pending, cancelPending, recordedRunning, panel, trigger, read, show, execute, cancel, close: () => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false); navigate(false); requestAnimationFrame(() => trigger.current?.focus());}, select: (id: string) => navigate(location.open, id)}; } type Controller = ReturnType; @@ -164,7 +166,7 @@ const reasonCopy = (reason: string | null | undefined, zh: boolean) => { }; export function ResearchAnalysisButton({analysis: a}: {analysis: Controller}) { - const unavailable = a.pack?.availability !== "available"; + const unavailable = a.readOnly || a.pack?.availability !== "available"; // The action keeps its separate-usage consequence visible. Further method // explanation is optional; cached results and unavailable states are reads. const readOnly = unavailable || a.pack?.result || a.pending || a.recordedRunning; @@ -179,6 +181,7 @@ export function ResearchAnalysisButton({analysis: a}: {analysis: Controller}) { export function ResearchAnalysisStatus({analysis: a}: {analysis: Controller}) { const p = a.pack; + if (a.readOnly && !p) return

{a.zh ? "此运行尚未接入深度分析。" : "Deep analysis is not connected for this runtime."}

; if (!p) return

{a.loading ? (a.zh ? "正在核对可分析来源;浏览不调用 Agent。" : "Checking analysis sources. Browsing never invokes an Agent.") : reasonCopy(a.snapshot?.catalog.reason_code, a.zh)}

; return !p.result && p.availability !== "available" ?

{reasonCopy(p.reason_code, a.zh)}

: null; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index c28e8f1492..09031d9779 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -26,8 +26,8 @@ const selectedFromUrl = (goal: string): Selection => { /** An effect-free history consumer. Its source is Explore, not raw sessions or * a judge. All recorded updates survive; unsupported analysis stays unavailable. */ -export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: {goalId: string; active: boolean; zh: boolean; refreshRevision?: number}) { - const analysis = useResearchAnalysis(goalId, active, zh); +export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, readSourceUrl}: {goalId: string; active: boolean; zh: boolean; refreshRevision?: number; readSourceUrl?: string}) { + const analysis = useResearchAnalysis(goalId, active, zh, readSourceUrl); const [read, setRead] = useState({events: [], total: null, complete: false}); const [busy, setBusy] = useState(true); const [error, setError] = useState(""); @@ -107,7 +107,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: { let cursor = ""; do { const query = new URLSearchParams({goal_id: goalId, view: "history", ...(cursor ? {cursor} : {})}); - const page = parseExploreHistoryPage(await requestJson(`/api/chat/explore-results?${query}`, {signal: controller.signal}), goalId); + const page = parseExploreHistoryPage(await requestJson(readSourceUrl ? new URL(`/api/chat/explore-results?${query}`, readSourceUrl).toString() : `/api/chat/explore-results?${query}`, {signal: controller.signal}), goalId); if (total !== -1 && total !== page.total) throw new Error("Explore history snapshot changed"); total = page.total; events.push(...page.items); if (events.length > total || (page.next_cursor && (!page.items.length || cursors.has(page.next_cursor)))) throw new Error("Invalid Explore history pagination"); @@ -129,7 +129,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0}: { } finally {if (current) setBusy(false);} })(); return () => {current = false; controller.abort();}; - }, [goalId, active, revision, refreshRevision]); + }, [goalId, active, revision, refreshRevision, readSourceUrl]); const history = useMemo(() => { try {return {value: projectExploreHistory(read.events, goalId), error: ""};} catch (failure) {return {value: null, error: String(failure)};} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx index 57616fe8d5..17ceba4e58 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-sidebar.tsx @@ -56,7 +56,7 @@ export function GoalSidebar({ const [sorting, setSorting] = useState(false); const tree = useMemo(() => goalHierarchy(goals), [goals]); const siblings = (from: string, to: string) => tree.parents.get(from) === tree.parents.get(to); - const ordering = useGoalOrder(goals.filter((goal) => goal.activationState !== "stopped"), statusSourceControl?.activeSource.statusUrl ?? "/status.json", siblings); + const ordering = useGoalOrder(goals.filter((goal) => goal.activationState !== "stopped"), statusSourceControl?.activeSource.statusUrl ?? "/status.json", siblings, statusSourceControl?.activeSource.kind === "runtime"); const activeGoals = ordering.sorted; const stoppedGoals = goals.filter((goal) => goal.activationState === "stopped"); const siblingGroups = new Map(); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 2296683256..59e7052d87 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1121,6 +1121,7 @@ const en = { "source.namePlaceholder": "Remote development machine", "source.notAvailable": "Unavailable", "source.readOnly": "SSH tunnel · read only", + "source.runtimeReadOnly": "Runtime · read only", "source.readOnlyNoticeDescription": "You can view Goals, Tasks, evidence, and run status. Writes, guidance, and Agent sessions remain on the source host.", "source.readOnlyNoticeTitle": "Remote read-only projection", "source.readOnlyWriteError": "Remote SSH tunnel sources are read-only projections and cannot perform control-plane writes.", @@ -2706,6 +2707,7 @@ const zhCN: Record = { "source.namePlaceholder": "远程开发机", "source.notAvailable": "不可用", "source.readOnly": "SSH 隧道 · 只读", + "source.runtimeReadOnly": "运行来源 · 只读", "source.readOnlyNoticeDescription": "你可以查看 Goal、Task、证据与运行状态;写入、纠偏和 Agent 会话仍留在来源主机。", "source.readOnlyNoticeTitle": "远端只读投影", "source.readOnlyWriteError": "远端 SSH 隧道来源是只读投影,不能执行控制面写入。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index cd150d66f3..a7bc74f1ef 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -2789,7 +2789,7 @@ export function PersonalWorkspacePage({ onOpenDetails={() => setSelection({ kind: "goal", item: selectedGoal })} onSelect={setSelection} onView={setSelectedGoalTab} selectedTodoId={drawerSelection?.kind === "todo" ? drawerSelection.item.todoId : null} board={taskBoard(!settingsOpen && selectedGoalTab === "overview")} results={goalResults(!settingsOpen && selectedGoalTab === "overview")} - historyRefreshRevision={historyRefreshRevision} proposals={proposals} onPreview={createPreview} />, + historyRefreshRevision={historyRefreshRevision} readSourceUrl={statusSourceControl?.activeSource.kind === "runtime" ? statusSourceControl.activeSource.statusUrl : undefined} proposals={proposals} onPreview={createPreview} />, tasks: taskBoard(!settingsOpen && selectedGoalTab === "tasks"), files: goalResults(selectedGoalTab === "files" && !settingsOpen), chat: agentWorkActive ? 0 && sources.every((source) => source.kind === "runtime"); const quickAddPrefix = "configured:"; const sourceOptions = [ ...sources.map((source) => ({ label: source.kind === "local" ? t("source.local") : source.label, value: source.id })), - ...configuredHosts + ...(runtimeSources ? [] : configuredHosts) .filter((host) => !sources.some((source) => source.label === host.alias)) .map((host) => ({ group: t("source.configuredGroup", { count: configuredHosts.length }), @@ -66,8 +67,9 @@ export function StatusSourceSwitcher({ }, [configuredHosts, hostAlias, localPort, t]); useEffect(() => { + if (runtimeSources) return; void loadConfiguredHosts(); - }, []); + }, [runtimeSources]); async function loadConfiguredHosts() { setConfiguredHostsLoading(true); @@ -171,7 +173,7 @@ export function StatusSourceSwitcher({
Control plane - + {!runtimeSources ? : null}
{connectionState === "loading" ? t("source.connecting") : connectionState === "error" ? t("source.notAvailable") : connectionState === "degraded" ? t("source.executionUnavailable") : t("source.connected")} - {activeSource.readOnly ? t("source.readOnly") : t("source.localInteractive")} + {activeSource.kind === "runtime" ? t("source.runtimeReadOnly") : activeSource.readOnly ? t("source.readOnly") : t("source.localInteractive")} {activeSource.kind === "ssh_tunnel" ? ( ) : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/use-goal-order.ts b/apps/presentation/dashboard/src/features/personal-workspace/use-goal-order.ts index 879c07336c..82f56c2676 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/use-goal-order.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/use-goal-order.ts @@ -3,7 +3,7 @@ import { decodeGoalOrder, goalOrderStorageKey, moveGoal, orderedGoals } from "./ import type { WorkspaceGoal } from "./personal-workspace-model"; // The sidebar is keyed by source, so neither gestures nor preferences cross machines. -export function useGoalOrder(goals: WorkspaceGoal[], source: string, canMove = (_from: string, _to: string) => true) { +export function useGoalOrder(goals: WorkspaceGoal[], source: string, canMove = (_from: string, _to: string) => true, browserOnly = false) { const key = goalOrderStorageKey(source); const [order, setOrder] = useState(() => { try { return decodeGoalOrder(localStorage.getItem(key)); } catch { return []; } @@ -13,6 +13,14 @@ export function useGoalOrder(goals: WorkspaceGoal[], source: string, canMove = ( const saver = useRef<((next: string[]) => void) | null>(null); const [saveFailed, setSaveFailed] = useState(false); useEffect(() => { + if (browserOnly) { + setSaveFailed(false); + saver.current = (next) => { + try { localStorage.setItem(key, JSON.stringify(next)); setSaveFailed(false); } + catch { setSaveFailed(true); } + }; + return () => { saver.current = null; }; + } let disposed = false; let loaded = false; let queue = Promise.resolve(); @@ -60,7 +68,7 @@ export function useGoalOrder(goals: WorkspaceGoal[], source: string, canMove = ( else setSaveFailed(false); }).catch(() => { if (!disposed) setSaveFailed(true); }); return () => { disposed = true; saver.current = null; }; - }, [key, source]); + }, [key, source, browserOnly]); const [lastMoved, setLastMoved] = useState<{ title: string; position: number } | null>(null); const [target, setTarget] = useState<{ id: string; after: boolean } | null>(null); const gesture = useRef<{ id: string; x: number; y: number; dragging: boolean } | null>(null); diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index e9071b946e..bad8fdccd7 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -189,6 +189,7 @@ import { localStatusSource, activeStatusSourceForUrl, removeStatusSource, + runtimeStatusSourceCatalog, saveStatusSourceCatalog, type StatusSource, } from "../data/status-source-catalog"; @@ -3290,9 +3291,14 @@ export function DashboardPage() { : ""; const activeStatusRequestUrl = requestedStatusUrl ?? routeStatusRequestUrl; const loadedStatusUrl = source.kind === "url" ? source.label : defaultGlobalStatusUrl; + const effectiveSourceCatalog = runtimeStatusSourceCatalog( + payload.local_dashboard_api?.read_only_sources, + loadedStatusUrl, + window.location.href, + ) ?? statusSourceCatalog; const requestFailed = Boolean(loadError && requestedStatusUrl); const activeStatusSource: StatusSource = activeStatusSourceForUrl( - statusSourceCatalog, + effectiveSourceCatalog, requestedStatusUrl, loadedStatusUrl, window.location.href, @@ -3538,13 +3544,13 @@ export function DashboardPage() { if (activeStatusSource.id === sourceId) selectStatusSource(localStatusSource); }, onSelect: (sourceId) => { - const nextSource = statusSourceCatalog.sources.find((candidate) => candidate.id === sourceId); + const nextSource = effectiveSourceCatalog.sources.find((candidate) => candidate.id === sourceId); if (!nextSource) return; selectStatusSource(nextSource, { ensureTunnel: nextSource.kind === "ssh_tunnel" }); }, sources: activeStatusSource.id === "temporary" - ? [...statusSourceCatalog.sources, activeStatusSource] - : statusSourceCatalog.sources, + ? [...effectiveSourceCatalog.sources, activeStatusSource] + : effectiveSourceCatalog.sources, }; useEffect(() => { diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 813631b912..442537e2c5 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -6,6 +6,28 @@ validation only; resolving a source merge followed by a build produces one coherent HTML/JavaScript/CSS set. Do not concatenate competing HTML entrypoints or asset-retention manifests when resolving an older PR. +For owner-authorized reads of existing container runtimes, the presentation +adapter `python -m loopx.presentation.runtime_workspace --sources ` +serves the packaged Goal workspace directly. The Agent prepares the manifest: +each entry names `id`, `label`, the full pinned `container_id`, the original +`goal_id`, absolute `registry`, `runtime_root`, `cli`, `python` paths and the +source's executable `path` (PATH). The startup JSON returns working preview +URLs and the existing source selector offers the prepared readers. Keep these +manifests and actual source captures private. Before staging or updating the PR, +compare every source, validation script and visual-evidence path with the +selected Todo's declared write scopes; place presentation evidence inside the +existing authorized surface rather than assuming all docs/examples are covered. +Build Chat first or supply +`--assets-dir`; stop the adapter to remove its temporary readers. + +These are read transports, not connected execution Goals or a second registry. +The native status and complete validated Explore history stay scoped to the +physical source, including identical Goal ids in different containers. An +unavailable container fails visibly without replacement; refreshing retries. +All mutation endpoints are denied. A read-only source does not imply an +analysis pack, model execution, adoption or validated benefit; analysis remains +unavailable until its existing owner binds the appropriate source pack. + ## Task-first delivery / 从用户任务开始 Use the existing task/PR delivery brief, not a second checklist or approval form. diff --git a/examples/personal-workspace-browser/evidence/runtime-source-workspace/before-desktop.png b/examples/personal-workspace-browser/evidence/runtime-source-workspace/before-desktop.png new file mode 100644 index 0000000000..dde925ccb2 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/runtime-source-workspace/before-desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png b/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png new file mode 100644 index 0000000000..c3cce58df9 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/runtime-source-workspace/phone.png b/examples/personal-workspace-browser/evidence/runtime-source-workspace/phone.png new file mode 100644 index 0000000000..2baac52c5b Binary files /dev/null and b/examples/personal-workspace-browser/evidence/runtime-source-workspace/phone.png differ diff --git a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs new file mode 100644 index 0000000000..066644af33 --- /dev/null +++ b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// Focused packaged-UI/real-HTTP proof. All native records here are synthetic. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { launchBrowser, loadPlaywright } from "../dashboard-browser-smoke-support.mjs"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; + +const root = resolve(import.meta.dirname, "../.."); +const output = resolve(root, "output/playwright/runtime-source-workspace"); +const temporary = await mkdtemp(resolve(tmpdir(), "loopx-runtime-reader-")); +const failFile = resolve(temporary, "unavailable"); +const backend = spawn(resolveTestPython(), ["-u", "-c", ` +import json, pathlib, sys, threading +from loopx.presentation.runtime_workspace import RuntimeWorkspaceServer +from loopx.capabilities.explore.result_log import build_explore_node_event, build_explore_edge_event +class Source: + goal_id = 'same-goal' + def __init__(self, name): self.id = self.label = name + def read(self): + if pathlib.Path(sys.argv[1]).exists() and self.id == 'Fresh': raise OSError('unavailable fixture') + status = dict(ok=True, registry='', runtime_root='', goal_count=1, run_count=0, + contract=dict(ok=True, summary=dict(errors=0,warnings=0,checks=0), errors=[],warnings=[]), + attention_queue=dict(available=True,item_count=0,needs_user_or_controller=0,needs_codex=0,watching_external_evidence=0,items=[]), + run_history=dict(available=True,goal_count=1,run_count=0,recent_runs=[],goals=[dict( + id=self.goal_id, display_name=self.id+' research',goal_objective='Compare two candidate routes against recorded evidence.', + registry_member=True,activation_state='active')])) + events = [build_explore_node_event(goal_id=self.goal_id,node_id='route'+str(i%9), + title=self.id+' route '+str(i%9),node_kind='question',status='exploring', + summary='Recorded comparison; native benefit is unknown.',recorded_at='2026-01-01T%02d:%02d:00Z'%(i//4,i%4*15)) for i in range(90)] + events += [build_explore_edge_event(goal_id=self.goal_id,from_node='route'+str(i),to_node='route'+str(i+1),edge_type='depends_on',recorded_at='2026-01-01T23:%02d:00Z'%(i*30)) for i in range(2)] + from loopx.presentation.explore_results_api import explore_history_rows + return status, explore_history_rows(events) +assets=pathlib.Path(sys.argv[2]) +servers=[RuntimeWorkspaceServer(Source(n),assets) for n in ('Retained','Fresh')] +catalog=[dict(id=s.source.id,label=s.source.label,status_url=s.status_url) for s in servers] +for s in servers: + s.catalog=catalog + threading.Thread(target=s.serve_forever,daemon=True).start() +print(json.dumps(catalog),flush=True) +threading.Event().wait() +`, failFile, process.env.LOOPX_RUNTIME_READER_ASSETS ?? resolve(root, "loopx/web/chat")], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); +let browser; +try { + const catalog = await new Promise((accept, reject) => { + const timer = setTimeout(() => reject(new Error("Reader startup timed out")), 15000); + backend.once("exit", code => { clearTimeout(timer); reject(new Error(`Reader exited ${code}`)); }); + backend.stdout.once("data", bytes => { clearTimeout(timer); accept(JSON.parse(bytes.toString().trim())); }); + backend.stderr.on("data", bytes => process.stderr.write(bytes)); + }); + await mkdir(output, { recursive: true }); + browser = await launchBrowser(loadPlaywright().chromium); + const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1512, height: 982 } }); + const errors = []; + page.on("pageerror", error => errors.push(String(error))); + const url = new URL("/chat/", catalog[0].status_url); + url.search = new URLSearchParams({ goalId: "same-goal", view: "overview", statusUrl: catalog[0].status_url }); + await page.goto(url.toString()); + await page.getByRole("heading", { name: "Retained research", exact: true }).waitFor(); + if (process.env.LOOPX_RUNTIME_READER_BEFORE === "1") { + await page.screenshot({path:resolve(output,"before-desktop.png")}); + } else { + await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); + assert.equal(await page.getByText("运行来源 · 只读", {exact:true}).count(), 1); + assert.equal(await page.getByRole("button", {name:"添加 SSH 隧道来源"}).count(),0); + assert.equal(await page.locator(".personal-goal-order-error").count(),0); + await page.screenshot({path:resolve(output,"desktop.png")}); + await page.getByLabel("研究历程", {exact:true}).getByRole("button", {name:"关系图", exact:true}).click(); + assert.equal(await page.locator(".research-history-edges > g > path").count(),2); + await page.getByLabel("选择控制面来源", {exact:true}).click(); + await page.getByRole("option", {name:"Fresh",exact:true}).click(); + await page.getByRole("heading",{name:"Fresh research",exact:true}).waitFor(); + await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); + await page.getByLabel("研究历程", {exact:true}).getByRole("button", {name:"关系图",exact:true}).click(); + await page.locator(".research-history-node").filter({hasText:"Fresh route 0"}).waitFor(); + assert.equal(await page.locator(".research-history-node").filter({hasText:"Retained route"}).count(),0); + await page.goBack(); + await page.getByRole("heading",{name:"Retained research",exact:true}).waitFor(); + await page.goForward(); + await page.getByRole("heading",{name:"Fresh research",exact:true}).waitFor(); + await page.reload(); + await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); + await writeFile(failFile, "unavailable"); + for (let attempt=0;attempt<40;attempt++) { + if ((await fetch(catalog[1].status_url)).status === 503) break; + await new Promise(resolve=>setTimeout(resolve,100)); + } + await page.getByRole("button",{name:"刷新状态",exact:true}).click(); + await page.locator(".personal-status-source-error").waitFor(); + assert.match(await page.getByLabel("选择控制面来源",{exact:true}).innerText(),/Fresh/); + assert.equal(await page.getByRole("heading",{name:"Retained research",exact:true}).count(),0); + await rm(failFile); + await page.getByRole("button",{name:"刷新状态",exact:true}).click(); + await page.getByRole("heading",{name:"Fresh research",exact:true}).waitFor(); + await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:resolve(output,"phone.png")}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + assert.deepEqual(errors,[]); + } + console.log("runtime source workspace browser smoke: ok"); +} finally { + await browser?.close(); + backend.kill("SIGTERM"); + await rm(temporary,{recursive:true,force:true}); +} diff --git a/loopx/presentation/explore_results_api.py b/loopx/presentation/explore_results_api.py index 53c95b6fbe..d841d0eaea 100644 --- a/loopx/presentation/explore_results_api.py +++ b/loopx/presentation/explore_results_api.py @@ -49,6 +49,13 @@ def _history_rows(runtime_root, goal_id): In particular, do not expose arbitrary observation payloads or materialize parent links as scientific supporting evidence. """ + return explore_history_rows(load_explore_result_events_strict( + explore_result_log_path(runtime_root, goal_id), goal_id=goal_id, + )) + + +def explore_history_rows(events): + """Use the same display boundary for local and external native records.""" fields = ( "schema_version", "event_id", "goal_id", "event_kind", "result_id", "recorded_at", "title", "summary", "status", "node_kind", @@ -57,9 +64,7 @@ def _history_rows(runtime_root, goal_id): ) return [ {key: event[key] for key in fields if key in event} - for event in load_explore_result_events_strict( - explore_result_log_path(runtime_root, goal_id), goal_id=goal_id, - ) + for event in events ] diff --git a/loopx/presentation/runtime_workspace.py b/loopx/presentation/runtime_workspace.py new file mode 100644 index 0000000000..9b62204528 --- /dev/null +++ b/loopx/presentation/runtime_workspace.py @@ -0,0 +1,271 @@ +"""Owner-only read transport from pinned containers to the existing Goal UI. + +The source's native CLI owns status; the Explore owner validates its log. This +adapter never connects, creates or copies an execution Goal/registry. Each +physical source has its own loopback origin, including when Goal ids coincide. +""" + +from __future__ import annotations + +import argparse +from copy import deepcopy +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import mimetypes +from pathlib import Path, PurePosixPath +import re +import subprocess +from threading import Lock, Thread +from time import monotonic +from urllib.parse import parse_qs, urlencode, urlparse + +from ..capabilities.explore.result_log import validate_explore_result_event +from ..chat_completed_todos import CompletedTodoPages +from ..status_server import cors_response_headers, is_loopback_host, is_loopback_origin +from .chat_bundle import validate_bundle +from .explore_results_api import explore_history_rows + + +# Only this specific log is read, using stdlib in the source's Python. Paths +# come from an owner-prepared manifest, never an HTTP query or browser script. +_READ_LOG = """ +import pathlib, sys +root = pathlib.Path(sys.argv[1]).resolve() +path = (root / 'goals' / sys.argv[2] / 'explore-result-log.jsonl').resolve() +path.relative_to(root) +if path.exists(): + with path.open('rb') as f: + data = f.read(16 * 1024 * 1024 + 1) + if len(data) > 16 * 1024 * 1024: raise ValueError('log too large') + sys.stdout.buffer.write(data) +""" + + +@dataclass(frozen=True) +class ContainerSource: + id: str + label: str + container_id: str + goal_id: str + registry: str + runtime_root: str + cli: str + python: str + path: str + + @classmethod + def parse(cls, raw): + source = cls(**raw) + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}", source.id): + raise ValueError("Invalid source id") + if not re.fullmatch(r"[0-9a-f]{64}", source.container_id): + raise ValueError("A full pinned container id is required") + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,159}", source.goal_id): + raise ValueError("Invalid source Goal id") + if not source.label.strip() or len(source.label) > 48: + raise ValueError("Source label must contain 1–48 characters") + if any(not PurePosixPath(value).is_absolute() for value in + (source.registry, source.runtime_root, source.cli, source.python)): + raise ValueError("Source paths must be absolute") + if not source.path or "\x00" in source.path: + raise ValueError("Source PATH is required") + return source + + def read(self): + prefix = ["docker", "exec", "-e", "LOOPX_USAGE_PING=0", "-e", f"PATH={self.path}", self.container_id] + status = json.loads(_run(prefix + [self.cli, "--registry", self.registry, + "--runtime-root", self.runtime_root, "--format", "json", "status", "--goal-id", self.goal_id])) + if not isinstance(status, dict) or not isinstance(status.get("run_history"), dict): + raise ValueError("Invalid native status") + goals = status.get("run_history", {}).get("goals", []) + if (status.get("ok") is not True or not isinstance(goals, list) or len(goals) != 1 + or not isinstance(goals[0], dict) or goals[0].get("id") != self.goal_id): + raise ValueError("The source no longer exposes the selected Goal") + log = _run(prefix + [self.python, "-c", _READ_LOG, self.runtime_root, self.goal_id]) + events = [] + for line in log.splitlines(): + if not line.strip(): + continue + event = json.loads(line) + if not isinstance(event, dict): + raise ValueError("Invalid native Explore record") + events.append(validate_explore_result_event(event, expected_goal_id=self.goal_id)) + return status, explore_history_rows(events) + + +def _run(argv): + # The timeout bounds dead/disconnected sources. Never return stderr, which + # may contain native private paths or command diagnostics, over HTTP. + result = subprocess.run(argv, capture_output=True, check=True, timeout=25) + if len(result.stdout) > 16 * 1024 * 1024: + raise ValueError("Source read exceeds the display limit") + return result.stdout.decode("utf-8") + + +class RuntimeWorkspaceServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, source, assets_dir, *, port=0): + super().__init__(("127.0.0.1", port), RuntimeWorkspaceHandler) + self.source = source + self.assets_dir = Path(assets_dir).resolve() + self.pages = CompletedTodoPages() + self.catalog = [] + self._lock = Lock() + self._snapshot = None + self._read_at = 0.0 + + @property + def status_url(self): + return f"http://127.0.0.1:{self.server_port}/status.json" + + def snapshot(self): + with self._lock: + if self._snapshot is None or monotonic() - self._read_at >= 2: + # A failed new read must never fall back to the older snapshot. + self._snapshot = None + self._snapshot = self.source.read() + self._read_at = monotonic() + return self._snapshot + + +class RuntimeWorkspaceHandler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def _json(self, payload, status=200): + body = json.dumps(payload, ensure_ascii=False).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + for key, value in cors_response_headers(self.headers.get("Origin")).items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def _allowed(self): + try: + host = urlparse("//" + self.headers.get("Host", "")).hostname or "" + except ValueError: + host = "" + if not is_loopback_host(host) or not is_loopback_origin(self.headers.get("Origin")): + self._json({"ok": False, "error": "Loopback owner access required."}, 403) + return False + return True + + def do_POST(self): + if self._allowed(): + self._json({"ok": False, "error": "This source is read-only."}, 405) + + do_PUT = do_POST + do_PATCH = do_POST + do_DELETE = do_POST + + def do_OPTIONS(self): + if self._allowed(): + self._json({"ok": True}) + + def do_GET(self): + if not self._allowed(): + return + parsed = urlparse(self.path) + query = parse_qs(parsed.query) + if parsed.path.startswith("/chat/") or parsed.path in {"/chat", "/"}: + self._asset(parsed.path) + return + goal_id = query.get("goal_id", [self.server.source.goal_id])[0] + if goal_id != self.server.source.goal_id: + self._json({"ok": False, "error": "Goal is not in this source."}, 404) + return + if parsed.path == "/status.json" and query.get("view") == ["workspace-directory"]: + # One pinned Goal needs no fan-out. Reuse the existing full-status + # compatibility path, preserving its native metadata and read APIs. + self._json({"ok": False, "error": "Use the scoped native status read."}, 404) + return + try: + if parsed.path == "/status.json": + status, _events = self.server.snapshot() + status = deepcopy(status) + status["local_dashboard_api"] = {"source": "runtime-workspace", + "status_url": self.server.status_url, "read_only_sources": self.server.catalog, + "control_plane_write_enabled": False, "reward_write_enabled": False} + self._json(status) + elif parsed.path == "/api/chat/explore-results" and query.get("view") == ["history"]: + cursor = query.get("cursor", [""])[0] + page = self.server.pages.page(scope=(self.server.source.id, goal_id), cursor=cursor, + load=lambda: self.server.snapshot()[1]) + self._json({**page, "goal_id": goal_id}) + elif parsed.path == "/api/chat/research-analysis": + self._json({"ok": False, "error": "此运行来源尚未绑定分析包。"}, 503) + else: + self._json({"ok": False, "error": "Read endpoint is not available for this source."}, 404) + except ValueError as exc: + expired = str(exc) == "history_cursor_expired" + self._json({"ok": False, "error": "history_cursor_expired" if expired else + "原运行来源暂不可读,请刷新重试;未替换来源。"}, 409 if expired else 503) + except (OSError, subprocess.SubprocessError): + self._json({"ok": False, "error": "原运行来源暂不可读,请刷新重试;未替换来源。"}, 503) + + def _asset(self, path): + relative = "index.html" if path in {"/", "/chat", "/chat/"} else path.removeprefix("/chat/") + target = (self.server.assets_dir / relative).resolve() + if not target.is_relative_to(self.server.assets_dir) or not target.is_file(): + self._json({"ok": False, "error": "Asset not found."}, 404) + return + body = target.read_bytes() + self.send_response(200) + self.send_header("Content-Type", mimetypes.guess_type(target.name)[0] or "application/octet-stream") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.send_header("X-Content-Type-Options", "nosniff") + self.end_headers() + self.wfile.write(body) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--sources", type=Path, required=True, help="Owner-prepared container source manifest") + parser.add_argument("--assets-dir", type=Path, help="Explicit custom/prototype assets, as in loopx chat") + parser.add_argument("--port", type=int, default=0, help="First loopback port; default allocates temporary ports") + args = parser.parse_args() + assets = (args.assets_dir or Path(__file__).resolve().parents[1] / "web" / "chat").expanduser().resolve() + if args.assets_dir is None: + try: + validate_bundle(assets, source_root=Path(__file__).resolve().parents[2]) + except RuntimeError as exc: + parser.error(str(exc)) + if not (assets / "index.html").is_file(): + parser.error("Build the packaged Chat assets first or supply --assets-dir") + raw = json.loads(args.sources.read_text()) + if not isinstance(raw, list) or not 1 <= len(raw) <= 12: + parser.error("Supply 1–12 scoped sources") + sources = [ContainerSource.parse(item) for item in raw] + if args.port != 0 and not 1024 <= args.port <= 65535 - len(sources) + 1: + parser.error("Choose unprivileged ports for all readers") + if len({s.id for s in sources}) != len(sources): + parser.error("Source ids must be unique") + servers = [RuntimeWorkspaceServer(source, assets, port=args.port + i if args.port else 0) + for i, source in enumerate(sources)] + catalog = [{"id": s.source.id, "label": s.source.label, "status_url": s.status_url} for s in servers] + threads = [] + for server in servers: + server.catalog = catalog + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + threads.append(thread) + print(json.dumps({"ok": True, "read_only": True, "sources": [{**item, + "url": item["status_url"].replace("/status.json", "/chat/?") + urlencode({ + "goalId": server.source.goal_id, "statusUrl": item["status_url"], "view": "overview"})} + for item, server in zip(catalog, servers)]}), flush=True) + try: + threads[0].join() + finally: + for server in servers: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/tests/presentation/test_runtime_workspace.py b/tests/presentation/test_runtime_workspace.py new file mode 100644 index 0000000000..2d422df7f0 --- /dev/null +++ b/tests/presentation/test_runtime_workspace.py @@ -0,0 +1,142 @@ +"""Real HTTP boundary: source identity, read-only access and full history.""" + +from contextlib import contextmanager +import json +from threading import Thread +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +import pytest + +from loopx.capabilities.explore.result_log import build_explore_node_event +from loopx.presentation.runtime_workspace import ContainerSource, RuntimeWorkspaceServer +from loopx.presentation.runtime_workspace import main as runtime_workspace_main + + +class Source: + goal_id = "shared-goal" + + def __init__(self, identity): + self.id = identity + self.label = identity + self.failed = False + self.calls = 0 + + def read(self): + self.calls += 1 + if self.failed: + raise OSError("private diagnostic must not reach the browser") + return {"ok": True, "run_history": {"goals": [{"id": self.goal_id, + "display_name": self.id, "registry_goal_digest": self.id}], "run_count": 17}}, [ + {"event_id": f"{self.id}-{i}", "title": self.id} for i in range(91)] + + +@contextmanager +def serving(source, assets): + server = RuntimeWorkspaceServer(source, assets) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join() + + +def get(server, path="/status.json", *, method="GET", headers=None): + request = Request(server.status_url.replace("/status.json", path), method=method, headers=headers or {}) + try: + response = urlopen(request, timeout=5) + except HTTPError as exc: + response = exc + with response: + return response.status, json.loads(response.read()) + + +def test_sources_with_same_goal_keep_separate_status_history_and_cursors(tmp_path): + a, b = Source("retained"), Source("fresh") + with serving(a, tmp_path) as first, serving(b, tmp_path) as second: + assert get(first)[1]["run_history"]["goals"][0]["display_name"] == "retained" + assert get(second)[1]["run_history"]["goals"][0]["display_name"] == "fresh" + assert get(first)[1]["run_history"]["run_count"] == 17 + path = "/api/chat/explore-results?view=history&goal_id=shared-goal" + _, page = get(first, path) + cursor = page["next_cursor"] + assert get(second, f"{path}&cursor={cursor}")[0] == 409 + rows = page["items"] + while page["next_cursor"]: + _, page = get(first, f'{path}&cursor={page["next_cursor"]}') + rows += page["items"] + assert len(rows) == 91 + assert len({row["event_id"] for row in rows}) == 91 + assert all(row["title"] == "retained" for row in rows) + assert get(second, path)[1]["items"][0]["title"] == "fresh" + + +def test_scope_origin_and_mutation_are_rejected_before_source_read(tmp_path): + source = Source("original") + with serving(source, tmp_path) as server: + assert get(server, "/status.json?goal_id=another")[0] == 404 + assert get(server, headers={"Host": "external.example"})[0] == 403 + assert get(server, headers={"Origin": "https://external.example"})[0] == 403 + for method in ("POST", "PUT", "PATCH", "DELETE"): + assert get(server, method=method)[0] == 405 + assert get(server, "/unavailable")[0] == 404 + assert source.calls == 0 + + +def test_failed_refresh_does_not_serve_old_success_and_recovers(tmp_path): + source = Source("original") + with serving(source, tmp_path) as server: + assert get(server)[0] == 200 + server._read_at = 0 + source.failed = True + code, error = get(server) + assert code == 503 and "private diagnostic" not in json.dumps(error) + source.failed = False + assert get(server)[0] == 200 + assert source.calls == 3 + + +def test_native_transport_preserves_valid_events_and_rejects_wrong_goal(monkeypatch): + source = ContainerSource("native", "Native", "a" * 64, "shared-goal", "/registry.json", + "/runtime", "/bin/loopx", "/bin/python", "/bin") + node = build_explore_node_event(goal_id="shared-goal", node_id="question", title="Actual question") + commands = [] + + def run(argv): + commands.append(argv) + if source.cli in argv: + return json.dumps({"ok": True, "run_history": {"goals": [{"id": source.goal_id}]}}) + return json.dumps(node) + + monkeypatch.setattr("loopx.presentation.runtime_workspace._run", run) + _, events = source.read() + assert events[0]["event_id"] == node["event_id"] + assert events[0]["title"] == "Actual question" + assert all(source.container_id in argv for argv in commands) + node["goal_id"] = "different" + with pytest.raises(ValueError): + source.read() + + +def test_manifest_requires_physical_identity(): + raw = dict(id="source", label="Source", container_id="alias", goal_id="goal", + registry="/registry", runtime_root="/runtime", cli="/cli", python="/python", path="/bin") + with pytest.raises(ValueError, match="pinned container"): + ContainerSource.parse(raw) + + +def test_default_entry_rejects_stale_bundle_before_opening_sources(monkeypatch, tmp_path, capsys): + monkeypatch.setattr("sys.argv", ["runtime-workspace", "--sources", str(tmp_path / "unused.json")]) + + def invalid_bundle(_assets, *, source_root): + assert (source_root / "apps/presentation/dashboard/package.json").is_file() + raise RuntimeError("stale or invalid: rebuild the chat bundle") + + monkeypatch.setattr("loopx.presentation.runtime_workspace.validate_bundle", invalid_bundle) + with pytest.raises(SystemExit) as stopped: + runtime_workspace_main() + assert stopped.value.code == 2 + assert "rebuild the chat bundle" in capsys.readouterr().err