diff --git a/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts b/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts index 21c33299e5..e783f20df5 100644 --- a/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts +++ b/apps/presentation/dashboard/smoke/status-source-catalog-smoke.ts @@ -18,6 +18,7 @@ import { defaultConfiguredSshHostsUrl, parseConfiguredSshHostCatalog, } from "../src/data/ssh-host-catalog"; +import {sourceReadApiUrl} from "../src/data/local-status-query"; function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); @@ -139,6 +140,20 @@ equal(runtimeStatusSourceCatalog(undefined, runtimeUrl, baseHref), null, "existi 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 namespace = "a".repeat(64), changedNamespace = "b".repeat(64); +const pinnedUrl = `${runtimeUrl}?source_namespace=${namespace}`; +const pinnedEntries = [{...runtimeEntries[0], status_url: pinnedUrl}]; +const pinned = runtimeStatusSourceCatalog(pinnedEntries, "/status.json", "http://127.0.0.1:19801/chat/"); +assert(pinned, "an initial relative entry adopts only its own origin's source identity"); +equal(statusSourceForUrl(pinned, "/status.json", "http://127.0.0.1:19801/chat/")?.statusUrl, pinnedUrl, "the prepared canonical URL retains its namespace"); +equal(runtimeStatusSourceCatalog(pinnedEntries, `${runtimeUrl}?source_namespace=${changedNamespace}`, baseHref), null, "the same port cannot replace a pinned source"); +const historyUrl = new URL(sourceReadApiUrl(pinnedUrl, "/api/chat/explore-results?goal_id=same&cursor=page", baseHref)); +equal(historyUrl.searchParams.get("source_namespace"), namespace, "history requests retain physical source identity"); +equal(historyUrl.searchParams.get("cursor"), "page", "source binding preserves history pagination"); +equal(sourceReadApiUrl(undefined, "/api/chat/research-analysis?goal_id=same", baseHref), "/api/chat/research-analysis?goal_id=same", "ordinary local analysis is unchanged"); +let escaped = false; +try {sourceReadApiUrl(pinnedUrl, "http://127.0.0.1:19802/api/chat/explore-results", baseHref);} catch {escaped = true;} +assert(escaped, "a Goal read cannot escape to another source's origin"); 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"); diff --git a/apps/presentation/dashboard/src/data/local-status-query.ts b/apps/presentation/dashboard/src/data/local-status-query.ts index a642f68090..3a499611a6 100644 --- a/apps/presentation/dashboard/src/data/local-status-query.ts +++ b/apps/presentation/dashboard/src/data/local-status-query.ts @@ -101,6 +101,22 @@ export function scopedStatusUrl( return url.toString(); } +/** Keep prepared source identity on every Goal read, including paged history. */ +export function sourceReadApiUrl(statusUrl: string | undefined, path: string, baseHref?: string): string { + if (!statusUrl) return path; + const source = new URL(statusUrl, baseHref ?? window.location.href); + const target = new URL(path, source); + if (!/^https?:$/.test(source.protocol) || !isLoopbackHostname(source.hostname) || target.origin !== source.origin) { + throw new Error("Goal reads must stay on the selected source"); + } + const namespaces = source.searchParams.getAll("source_namespace"); + if (namespaces.length > 1 || (namespaces.length === 1 && !/^[0-9a-f]{64}$/.test(namespaces[0]))) { + throw new Error("Invalid Goal source identity"); + } + if (namespaces.length) target.searchParams.set("source_namespace", namespaces[0]); + return target.toString(); +} + export function statusContractFreshnessIssue( payload: StatusPayload, source: ResolvedFrontstageStatusUrl, diff --git a/apps/presentation/dashboard/src/data/status-source-catalog.ts b/apps/presentation/dashboard/src/data/status-source-catalog.ts index c380cd5504..53e8a77ebb 100644 --- a/apps/presentation/dashboard/src/data/status-source-catalog.ts +++ b/apps/presentation/dashboard/src/data/status-source-catalog.ts @@ -20,6 +20,16 @@ export type StatusSourceCatalog = { export type StatusSourceStorage = Pick; +function matchesRuntimeUrl(candidate: string, requested: string, baseHref: string): boolean { + const expected = new URL(candidate, baseHref), actual = new URL(requested, baseHref); + if (expected.toString() === actual.toString()) return true; + // An unbound entry may adopt this origin's first identity. A previously + // pinned identity must never be replaced, even on the same port. + if (actual.searchParams.has("source_namespace")) return false; + expected.searchParams.delete("source_namespace"); + return expected.toString() === actual.toString(); +} + /** Runtime choices describe physical readers, not new execution Goals. */ export function runtimeStatusSourceCatalog( entries: {id: string; label: string; status_url: string}[] | undefined, @@ -35,7 +45,7 @@ export function runtimeStatusSourceCatalog( } 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; + return sources.some((s) => matchesRuntimeUrl(s.statusUrl, current, baseHref)) ? {schemaVersion: 1, sources} : null; } export const localStatusSource: StatusSource = { @@ -191,10 +201,10 @@ export function statusSourceForUrl(catalog: StatusSourceCatalog, statusUrl: stri } catch { return null; } - if (resolved.source?.isRelative) { - return catalog.sources.find((source) => source.kind === "runtime" && source.statusUrl === normalized) - ?? localStatusSource; - } + const runtime = catalog.sources.find((source) => source.kind === "runtime" + && matchesRuntimeUrl(source.statusUrl, normalized, baseHref)); + if (runtime) return runtime; + if (resolved.source?.isRelative) return localStatusSource; return catalog.sources.find((source) => source.statusUrl === normalized) ?? null; } 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 6548c9447f..ccece53190 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 @@ -3,6 +3,7 @@ import {ArrowUpRight, LoaderCircle, RefreshCw, Sparkles, X} from "lucide-react"; import type {AssessmentCitation, TrajectoryEpisode} from "../../../../../../loopx/control_plane/capabilities/trajectory_assessment_types.js"; import {ResearchProgress, metricCopy, verdictLabel, type ResearchProgressPack} from "./goal-research-progress"; import {requestJson} from "../../data/chat"; +import {sourceReadApiUrl} from "../../data/local-status-query"; import {ContextHelp} from "./context-help"; import "./goal-research-analysis.css"; @@ -24,7 +25,7 @@ const locationFor = (goal: string) => { }; 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 endpoint = "/api/chat/research-analysis"; const [citation, setCitation] = useState(null); const [citationError, setCitationError] = useState(""); const [citationBusy, setCitationBusy] = useState(false); @@ -49,7 +50,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean const current = generation.current, sequence = ++readSequence.current; const query = new URLSearchParams({goal_id: goalId, ...(packId ? {pack_id: packId} : {})}); let value: Snapshot; - try {value = await requestJson(`${endpoint}?${query}`, {signal});} + try {value = await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}?${query}`), {signal});} catch (e) {if (current === generation.current && sequence === readSequence.current && !signal?.aborted) setReadError(errorText(e)); throw e;} if (!value?.ok || !Array.isArray(value.catalog?.packs) || (value.pack && value.pack.pack_id !== (packId ?? value.catalog.default_pack_id))) { const failure = new Error(zh ? "分析来源读回不一致。" : "Analysis source readback does not match."); @@ -58,7 +59,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, endpoint]); + }, [goalId, location.packId, zh, readSourceUrl]); 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. @@ -77,7 +78,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean const pop = () => applyLocation(locationFor(goalId)); window.addEventListener("popstate", pop); return () => {generation.current++; citeGeneration.current++; window.removeEventListener("popstate", pop);}; - }, [goalId, applyLocation]); + }, [goalId, applyLocation, readSourceUrl]); useEffect(() => { if (!active) return; const controller = new AbortController(); setLoading(true); @@ -85,7 +86,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean return () => controller.abort(); }, [active, read, location.packId]); const pack = snapshot?.pack ?? null; - useEffect(() => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false);}, [goalId, snapshot?.pack?.pack_id, snapshot?.pack?.analysis_key]); + useEffect(() => {citeGeneration.current++; setCitation(null); setCitationError(""); setCitationBusy(false);}, [goalId, readSourceUrl, snapshot?.pack?.pack_id, snapshot?.pack?.analysis_key]); // A pending request is observed here. A persisted running receipt alone is // not proof that the model is currently executing; old receipts stop polling. @@ -144,7 +145,7 @@ export function useResearchAnalysis(goalId: string, active: boolean, zh: boolean show(); const current = ++citeGeneration.current; setCitationBusy(true); setCitationError(""); const query = new URLSearchParams({goal_id: goalId, pack_id: p.pack_id, analysis_key: p.analysis_key, source_id: c.source_id, first_line: String(c.first_line), last_line: String(c.last_line)}); - try {const value = await requestJson(`${endpoint}/citation?${query}`); if (current === citeGeneration.current) {setCitation(value); requestAnimationFrame(() => {sourcePanel.current?.focus({preventScroll: true}); sourcePanel.current?.scrollIntoView({block: "nearest"});});}} + try {const value = await requestJson(sourceReadApiUrl(readSourceUrl, `${endpoint}/citation?${query}`)); if (current === citeGeneration.current) {setCitation(value); requestAnimationFrame(() => {sourcePanel.current?.focus({preventScroll: true}); sourcePanel.current?.scrollIntoView({block: "nearest"});});}} catch (e) {if (current === citeGeneration.current) setCitationError(errorText(e));} finally {if (current === citeGeneration.current) setCitationBusy(false);} }; 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 09031d9779..e06ec8d728 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 @@ -5,6 +5,7 @@ import {ResearchAnalysisButton, ResearchAnalysisPanel, ResearchAnalysisStatus, R import {buildExploreTimeline, parseExploreFindingSections} from "../../../../../../loopx/control_plane/presentation/explore_timeline.js"; import {GoalResearchTimeline} from "./goal-research-timeline"; import {requestJson} from "../../data/chat"; +import {sourceReadApiUrl} from "../../data/local-status-query"; import {ContextHelp} from "./context-help"; import {groupUnlinkedHistoryQuestions, layoutExploreHistory, parseExploreHistoryPage, projectExploreHistory, type ExploreHistoryEvent} from "../../../../../../loopx/control_plane/presentation/explore_history.js"; import "./goal-research-history.css"; @@ -107,7 +108,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re let cursor = ""; do { const query = new URLSearchParams({goal_id: goalId, view: "history", ...(cursor ? {cursor} : {})}); - const page = parseExploreHistoryPage(await requestJson(readSourceUrl ? new URL(`/api/chat/explore-results?${query}`, readSourceUrl).toString() : `/api/chat/explore-results?${query}`, {signal: controller.signal}), goalId); + const page = parseExploreHistoryPage(await requestJson(sourceReadApiUrl(readSourceUrl, `/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"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 59e7052d87..b0ccfa181f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -1122,6 +1122,8 @@ const en = { "source.notAvailable": "Unavailable", "source.readOnly": "SSH tunnel · read only", "source.runtimeReadOnly": "Runtime · read only", + "source.runtimeUnavailable": "Cannot read {source}. Your selected source is unchanged.", + "source.failureDetails": "Read details", "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.", @@ -2708,6 +2710,8 @@ const zhCN: Record = { "source.notAvailable": "不可用", "source.readOnly": "SSH 隧道 · 只读", "source.runtimeReadOnly": "运行来源 · 只读", + "source.runtimeUnavailable": "暂时读不到 {source},已保留所选来源。", + "source.failureDetails": "读取详情", "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 05f1a25f16..fbb5146934 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 @@ -965,6 +965,8 @@ export function PersonalWorkspacePage({ const [localGoalId, setLocalGoalId] = useState(controlledGoalId ?? null); const [localAgentId, setLocalAgentId] = useState(controlledAgentId ?? agents.find((agent) => agent.available)?.agentId ?? "codex"); const selectionScope = `${statusSourceControl?.activeSource.statusUrl ?? "/status.json"}:${controlledGoalId === undefined ? localGoalId : controlledGoalId}`; + const runtimeSourceUnavailable = statusSourceControl?.activeSource.kind === "runtime" + && statusSourceControl.connectionState === "error"; const [selectionState, setSelectionState] = useState<{scope: string; value: WorkspaceDrawerSelection | null} | null>(null); const selection = selectionState?.scope === selectionScope ? selectionState.value : null; const currentSelectionScope = useRef({scope: selectionScope, generation: 0}); @@ -2700,7 +2702,7 @@ export function PersonalWorkspacePage({ onOpenGoalCapabilities={selectedGoal && !readOnly ? () => openSettings({ goalId: selectedGoal.goalId, kind: "settings", tab: "capabilities" }) : undefined} onOpenManagerSettings={!readOnly ? () => openSettings({ kind: "settings", tab: "steward" }) : undefined} onOpenConversationSettings={!readOnly ? () => openSettings({kind: "settings", tab: "conversation"}, true) : undefined} - onRefresh={callbacks.onRefresh ? () => void refreshWorkspace() : undefined} + onRefresh={callbacks.onRefresh && !runtimeSourceUnavailable ? () => void refreshWorkspace() : undefined} onOpenNavigation={() => setMobileSidebarOpen(true)} onOpenManagerChat={() => { setManagerConversationReceiptVisible(false); @@ -2725,7 +2727,7 @@ export function PersonalWorkspacePage({ selectedAgentReasoningEffort={selectedAgentReasoningEffort} refreshState={refreshState} readOnlySourceLabel={readOnly ? statusSourceControl?.activeSource.label : undefined} - selectedGoal={selectedGoal} + selectedGoal={runtimeSourceUnavailable ? null : selectedGoal} selectedGoalTab={selectedGoalTab} goalLifecycleBusy={selectedGoal ? lifecycleBusyGoalIds.has(selectedGoal.goalId) : false} onOpenWorkspace={() => selectGoal(null)} @@ -2734,8 +2736,13 @@ export function PersonalWorkspacePage({ : undefined} resumeReviewed={!callbacks.onExecuteGoalLifecycle} /> - {selectedGoal && !selectedGoal.loadState && !agentWorkActive ? - : selectedGoal?.loadState === "loading" ? : null} + {runtimeSourceUnavailable ?
+
{t("source.runtimeUnavailable", {source: statusSourceControl.activeSource.label})} + {statusSourceControl.errorMessage ?
{t("source.failureDetails")}

{statusSourceControl.errorMessage}

: null}
+ {callbacks.onRefresh ? : null} +
: null} + {!runtimeSourceUnavailable && selectedGoal && !selectedGoal.loadState && !agentWorkActive ? + : !runtimeSourceUnavailable && selectedGoal?.loadState === "loading" ? : null} {!readOnly ? openSettings({ kind: "settings", tab: "machine" })} /> : null} {selectedGoalId && selectedGoalTab === "chat" && !agentWorkActive && !readOnly && selectedAgentId === "codex" && callbacks.onStartLoopX ? - {!rootUnavailable ? <> + {!rootUnavailable && !runtimeSourceUnavailable ? <> {selectedGoalId && selectedGoalTab === "chat" && !agentWorkActive && callbacks.onSelectWorkAgent ?
: null} {selectedGoalId && selectedGoalTab === "chat" && !agentWorkActive && !readOnly && selectedAgentId === "codex" && conversationSessionId && loopxMode?.settings.execution_config && loopxMode.settings.agent_id ? div { min-width: 0; } +.personal-runtime-source-error strong { font-weight: 600; } +.personal-runtime-source-error details { margin-top: 8px; color: var(--pw-muted); overflow-wrap: anywhere; } +.personal-runtime-source-error summary { cursor: pointer; } +.personal-runtime-source-error p { margin: 8px 0 0; } +.personal-runtime-source-error > button { flex-shrink: 0; min-height: 44px; padding: 0 16px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-card); color: var(--pw-text); cursor: pointer; } .personal-status-source-form { position: absolute; z-index: 12; top: calc(100% - 4px); left: 12px; right: 12px; display: grid; gap: 10px; padding: 13px; border: 1px solid var(--pw-line-strong); border-radius: 12px; background: #fff; box-shadow: 0 12px 32px rgb(30 28 20 / 14%); } .personal-status-source-form > header { display: flex; align-items: center; justify-content: space-between; font-size: var(--text-body); line-height: var(--leading-body); } .personal-status-source-form label { display: grid; gap: 5px; color: var(--pw-muted); font-size: var(--text-caption); line-height: var(--leading-caption); } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/status-source-switcher.tsx b/apps/presentation/dashboard/src/features/personal-workspace/status-source-switcher.tsx index f1491b5f82..6c1c2841c4 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/status-source-switcher.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/status-source-switcher.tsx @@ -196,7 +196,7 @@ export function StatusSourceSwitcher({ ) : null} - {errorMessage ?

{errorMessage}

: null} + {errorMessage && !runtimeSources ?

{errorMessage}

: null} {adding ? (
{t("source.addSsh")}
diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index dea8530a12..dd9e8c15fe 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -190,6 +190,7 @@ import { activeStatusSourceForUrl, removeStatusSource, runtimeStatusSourceCatalog, + statusSourceForUrl, saveStatusSourceCatalog, type StatusSource, } from "../data/status-source-catalog"; @@ -3356,6 +3357,11 @@ export function DashboardPage() { setPayload((current) => mergeScopedStatusProjections(current, nextPayload)); return true; } + const prepared = runtimeStatusSourceCatalog(nextPayload.local_dashboard_api?.read_only_sources, + url, window.location.href); + const bound = prepared ? statusSourceForUrl(prepared, url, window.location.href) : null; + const bindingInitialIdentity = bound?.kind === "runtime" && bound.statusUrl !== url; + if (bindingInitialIdentity) url = bound.statusUrl; const nextSource: DataSource = { kind: "url", label: url }; statusRequestFenceRef.current.loadedUrl = url; setPayload(nextPayload); @@ -3365,6 +3371,7 @@ export function DashboardPage() { // entry: Back should return to the user's previous workspace view. if (search.statusUrl !== url) { await navigate({ + replace: bindingInitialIdentity, search: (current) => { const runtimeSourceChanged = Boolean(nextPayload.local_dashboard_api?.read_only_sources?.length && "statusUrl" in current && current.statusUrl && current.statusUrl !== url); @@ -3701,7 +3708,7 @@ export function DashboardPage() { onRetryGoalArchive={retryGoalArchive} onRefresh={async (readScope = "all") => { try { - await loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { readScope, rejectOnError: true }); + await loadFromUrl(activeStatusRequestUrl || (source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl)), { readScope, rejectOnError: true }); return { ok: true } as const; } catch (error) { return { ok: false, error: formatStatusError(error) } as const; diff --git a/docs/architecture/rfcs/goal-workspace-surface-v0.md b/docs/architecture/rfcs/goal-workspace-surface-v0.md index 5a299fbac9..c40aa5dc8b 100644 --- a/docs/architecture/rfcs/goal-workspace-surface-v0.md +++ b/docs/architecture/rfcs/goal-workspace-surface-v0.md @@ -747,6 +747,23 @@ solver usage and the current analysis attempt's receipt stay separate; full-stud cost and causal benefit remain unknown. No first-viewport/hero/CTA change is introduced; six uncalibrated process judgments retain their existing role. +**Checkpoint 2026-10-18 (shared Goal source reading, proposed).** Source IO +now lives below presentation in `control_plane/status/goal_source.py`, reusing +native status, strict Explore validation and the shared history projection. +Local/SSH connection and host/container environment compose independently; +prepared scope selects one original Goal or all registered Goals. App, CLI and +Agents share source description, Goal listing, status and paged-history reads. +The thin workspace adapter retains existing UI endpoints. Configuration-derived +source identity binds requests, cursors and selection. Source manifests require +one explicit connection/environment/scope model. A failed source is visible in the main +view on narrow screens, cached contents are withheld and retry keeps that source. +Disposable native-host and real local-container reads, HTTP isolation/recovery +and packaged desktop/narrow journeys are verified; SSH composition has command +tests, not a live remote-host/container qualification. One local reader process +serves multiple source listeners and invokes native IO on cache misses; no CPU or +IO saving is established. This is a read-only increment: no management grant, +new execution Goal/registry, automatic analysis-pack binding or V12 completion. + ## 12. Open decisions | # | Decision | Owner | Options | Recommendation | Needed before | diff --git a/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md b/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md index 039b9dd0aa..ec3d09c9b3 100644 --- a/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md @@ -529,6 +529,17 @@ CLI 准备复用 producer 既有接口;本切片未验收新增飞书交互入 自动 solver 回灌。求解捕获与本次分析尝试的回执开销分别保留,完整研究成本及因果收益 仍未知;首屏、hero、CTA 不变,六项未校准的过程判断维持原有角色。 +**检查点 2026-10-18(共用 Goal 来源读取,提案)。** 来源 IO 下沉至展示层之下的 +`control_plane/status/goal_source.py`,复用原生 status、严格 Explore 校验和共用历史 +投影。local/SSH 连接与 host/container 环境独立组合;准备好的作用域可选择一个原始 +Goal 或全部已注册 Goal。App、CLI 和 Agent 共用来源说明、Goal 列举、状态及分页历史 +读取;薄工作台适配保留既有 UI 端点。由配置确定的来源身份绑定请求、游标和选择, +来源清单只接受显式的连接/环境/作用域模型。来源失败在窄屏主视图直接可见,撤下缓存 +内容,重试保持该来源。已验证临时原生宿主、本机真实容器读取、HTTP 隔离与恢复以及 +打包桌面/窄屏旅程;SSH 组合仅有命令测试,不代表真实远端宿主/容器验收。一个本地 +reader 进程提供多个来源监听,缓存未命中时调用原生 IO;未证明 CPU 或 IO 更省。 +本切片只读,不授予管理权限,不新建执行 Goal/registry,不自动绑定分析包,不完成 V12。 + ## 12. 未决事项 | # | 事项 | Owner | 选项 | 建议 | 需在何时前决定 | diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 14d20f00b5..b2bccb766c 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -6,29 +6,86 @@ 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, +For owner-authorized reads of existing runtimes, shared access lives in +`loopx.control_plane.status.goal_source`. Native status and Explore owners retain +their semantics; this Python IO provider adds neither decisions nor lifecycle +authority. Connection (`local` or a configured `ssh` alias) is independent of +environment (`host` or a container pinned by its full physical id), so a remote +container uses the same reader. It never starts a container, tunnel or worker. + +The thin presentation adapter +`python -m loopx.presentation.runtime_workspace --sources ` serves the +packaged Goal workspace. The Agent prepares a private JSON array of sources: + +```json +[ + { + "id": "research", "label": "Research", + "connection": {"kind": "local"}, + "environment": {"kind": "host"}, + "scope": {"all_registered": true}, + "registry": "/runtime/registry.json", "runtime_root": "/runtime/state", + "cli": "/runtime/bin/loopx", "python": "/runtime/bin/python", + "path": "/runtime/bin:/usr/bin:/bin" + } +] +``` + +Use `connection: {"kind": "ssh", "host_alias": "configured-alias"}` for SSH; +the alias must remain in the existing SSH catalog. Use +`environment: {"kind": "container", "container_id": ""}` +for a container. `scope: {"goal_id": "original-goal"}` restricts the source to +one Goal; `all_registered` allows the native registered Goal list without copying +its registry. Source paths are absolute in the selected environment. The reader +requires this explicit connection/environment/scope model; there is no alternate +manifest format or implicit scope conversion. + +Startup JSON returns working workspace URLs, `access_url` and a source namespace. +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. +App, CLI and Agents can use the same loopback read API: `GET /api/goal-source` +describes identity, scope and supported operations; `/goals`, `/status` and +`/history` below it require `source_namespace`, with optional `goal_id` and history +`cursor`. Status is wrapped as `status`; Goal lists and history use `items`. +The CLI client shares this transport: + +```sh +python -m loopx.control_plane.status.goal_source goals --endpoint +python -m loopx.control_plane.status.goal_source history --endpoint --goal-id +``` + +Retain the returned pinned status URL/namespace on subsequent reads, including +history cursors. A stale identity returns an error even if a new reader reuses +the port. The App binds an initial relative `/status.json` entry to its prepared +identity once, preserving ordinary local/SSH behavior outside these readers. +Its existing status/history aliases use this same reader, not separate IO. + 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. +Native status and complete validated Explore history stay scoped to the physical +source, including identical Goal ids in different containers. Status has a +two-second per-reader cache; history uses bounded server-local snapshots. A failed +read or removed SSH alias invalidates saved status/pages. Unavailable sources +fail visibly without replacement; refreshing retries. +The main view keeps the selected source's failure and retry visible on narrow +screens, with technical detail collapsed. Other sources' cached Goal contents +are withheld on failure; retry targets the failed selection, not the last +successful reader. Switching prepared readers clears source-local question and analysis selections; Back restores the previous reader's URL and its own selection. 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. +The adapter uses one local Python process with one HTTP listener/thread per +prepared source. Cache misses invoke the source's native status CLI or bounded +log read; no lower CPU, process or IO cost is implied. Local host/container +readback and SSH command composition are separate validation boundaries; a +composed command test does not prove a live remote host/container journey. ## Task-first delivery / 从用户任务开始 diff --git a/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png b/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png index f9c9a1e357..bdbb1f3760 100644 Binary files a/examples/personal-workspace-browser/evidence/runtime-source-workspace/desktop.png 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 index ac6fb17a38..30e9eec47e 100644 Binary files a/examples/personal-workspace-browser/evidence/runtime-source-workspace/phone.png and b/examples/personal-workspace-browser/evidence/runtime-source-workspace/phone.png differ diff --git a/examples/personal-workspace-browser/evidence/runtime-source-workspace/unavailable-phone.png b/examples/personal-workspace-browser/evidence/runtime-source-workspace/unavailable-phone.png new file mode 100644 index 0000000000..ba00b57abc Binary files /dev/null and b/examples/personal-workspace-browser/evidence/runtime-source-workspace/unavailable-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 index 256a4e799b..80aec64612 100644 --- a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs @@ -15,24 +15,27 @@ 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.control_plane.status.goal_source import GoalSource 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): +class Source(GoalSource): + def __init__(self, name): + super().__init__(name,name,'/registry','/runtime','/cli','/python','/bin','local','host','same-goal') + def read_status(self, goal_id=None): + self.check_access(goal_id) 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, + return 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')])) + def read_events(self, goal_id): + self.check_access(goal_id) 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) + return 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] @@ -52,7 +55,7 @@ try { }); await mkdir(output, { recursive: true }); browser = await launchBrowser(loadPlaywright().chromium); - const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1512, height: 982 } }); + const page = await browser.newPage({ locale: "zh-CN", reducedMotion: "reduce", viewport: { width: 1512, height: 982 } }); const errors = []; page.on("pageerror", error => errors.push(String(error))); const url = new URL("/chat/", catalog[0].status_url); @@ -63,6 +66,7 @@ try { await page.screenshot({path:resolve(output,"before-desktop.png")}); } else { await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); + assert.equal(new URL(new URL(page.url()).searchParams.get("statusUrl")).searchParams.get("source_namespace"),new URL(catalog[0].status_url).searchParams.get("source_namespace"),"the initial relative entry binds its physical source before subsequent navigation"); 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); @@ -86,17 +90,32 @@ try { 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 page.goBack(); + await page.getByRole("heading",{name:"Retained research",exact:true}).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(); + await page.getByLabel("选择控制面来源",{exact:true}).click(); + await page.getByRole("option",{name:"Fresh",exact:true}).click(); + await page.locator(".personal-runtime-source-error").waitFor(); + assert.equal(await page.locator(".research-history-node").count(),0,"a failed selected source must not keep another source's graph visible"); assert.match(await page.getByLabel("选择控制面来源",{exact:true}).innerText(),/Fresh/); assert.equal(await page.getByRole("heading",{name:"Retained research",exact:true}).count(),0); + await page.setViewportSize({width:390,height:844}); + assert.match(await page.getByRole("alert").innerText(),/Fresh/); + await page.getByRole("button",{name:"重试",exact:true}).waitFor({state:"visible"}); + await page.screenshot({path:resolve(output,"unavailable-phone.png")}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + const failedRetry=page.waitForResponse(response=>new URL(response.url()).origin===new URL(catalog[1].status_url).origin && response.status()===503); + await page.getByRole("button",{name:"重试",exact:true}).click(); + await failedRetry; + await page.locator(".personal-runtime-source-error > button:enabled").waitFor(); + assert.match(await page.getByRole("alert").innerText(),/Fresh/); + assert.equal(await page.getByRole("heading",{name:"Retained research",exact:true}).count(),0,"retry cannot fall back to the previously loaded source"); await rm(failFile); - await page.getByRole("button",{name:"刷新状态",exact:true}).click(); + 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}); diff --git a/loopx/capabilities/explore/result_log.py b/loopx/capabilities/explore/result_log.py index 2b9cf5bea3..903026f458 100644 --- a/loopx/capabilities/explore/result_log.py +++ b/loopx/capabilities/explore/result_log.py @@ -711,6 +711,19 @@ def load_explore_result_events_strict( return events +def explore_history_rows(events, *, goal_id=None): + """Shared read projection: preserve history without exposing arbitrary payloads.""" + if goal_id is not None: + events = [validate_explore_result_event(event, expected_goal_id=goal_id) for event in events] + fields = ( + "schema_version", "event_id", "goal_id", "event_kind", "result_id", + "recorded_at", "title", "summary", "status", "node_kind", + "blocked_reason", "parent_id", "node_id", "from_node", "to_node", + "edge_type", "agent_id", "run_id", "evidence_refs", "supersedes", "confidence", + ) + return [{key: event[key] for key in fields if key in event} for event in events] + + def _fold_by_result_id( events: Sequence[Mapping[str, Any]], *, event_kind: str ) -> list[dict[str, Any]]: diff --git a/loopx/control_plane/status/goal_source.py b/loopx/control_plane/status/goal_source.py new file mode 100644 index 0000000000..1400118ba0 --- /dev/null +++ b/loopx/control_plane/status/goal_source.py @@ -0,0 +1,356 @@ +"""Read existing Goal authorities through local/SSH host/container IO. + +This is a read transport, not a registry, lifecycle or analysis authority. +The native CLI and Explore owner retain their semantics. A prepared source +scope never grants management or starts a worker, container, tunnel or daemon. +""" + +from __future__ import annotations + +import argparse +from copy import deepcopy +from dataclasses import asdict, dataclass +from hashlib import sha256 +import json +import os +from pathlib import Path, PurePosixPath +import re +import shlex +import subprocess +from threading import RLock +from time import monotonic +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qs, urlencode, urlparse +from urllib.request import HTTPRedirectHandler, Request, build_opener + +from ...chat_completed_todos import CompletedTodoPages +from ...status_server import is_loopback_host +from .ssh_host_catalog import configured_ssh_host_aliases + + +_GOAL_ID = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,159}") +_MAX_BYTES = 16 * 1024 * 1024 +_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) +""" + + +class SourceScopeError(ValueError): + """A request is outside this prepared source, before any IO.""" + + +def _text(value, field, limit=4096): + if (not isinstance(value, str) or not value.strip() or len(value) > limit + or any(char in value for char in "\x00\r\n")): + raise ValueError(f"Invalid source {field}") + return value + + +def _run(argv, *, env=None): + # Diagnostics can contain private paths; callers must not return stderr. + result = subprocess.run(argv, env=env, capture_output=True, check=True, timeout=25) + if len(result.stdout) > _MAX_BYTES: + raise ValueError("Source read exceeds the display limit") + return result.stdout.decode("utf-8") + + +@dataclass(frozen=True) +class GoalSource: + id: str + label: str + registry: str + runtime_root: str + cli: str + python: str + path: str + connection: str + environment: str + goal_id: str | None + host_alias: str | None = None + container_id: str | None = None + + def __post_init__(self): + if self.connection not in {"local", "ssh"} or self.environment not in {"host", "container"}: + raise ValueError("Invalid source connection or environment") + if self.connection == "ssh": + _text(self.host_alias, "SSH alias", 255) + elif self.host_alias is not None: + raise ValueError("Local connections do not have an SSH alias") + if self.environment == "container": + if not isinstance(self.container_id, str) or not re.fullmatch(r"[0-9a-f]{64}", self.container_id): + raise ValueError("A full pinned container id is required") + elif self.container_id is not None: + raise ValueError("Host environments do not have a container id") + if self.goal_id is not None and (not isinstance(self.goal_id, str) or not _GOAL_ID.fullmatch(self.goal_id)): + raise ValueError("Invalid source Goal id") + for key in ("id", "label", "registry", "runtime_root", "cli", "python", "path"): + _text(getattr(self, key), key, 48 if key == "label" else 4096) + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}", self.id): + raise ValueError("Invalid source id") + path_type = Path if self.connection == "local" and self.container_id is None else PurePosixPath + if any(not path_type(getattr(self, key)).is_absolute() for key in ("registry", "runtime_root", "cli", "python")): + raise ValueError("Source paths must be absolute") + + @classmethod + def parse(cls, raw): + if not isinstance(raw, dict): + raise ValueError("A source must be an object") + raw = dict(raw) + connection = raw.pop("connection", None) + environment = raw.pop("environment", None) + scope = raw.pop("scope", None) + if not isinstance(connection, dict) or not isinstance(environment, dict): + raise ValueError("Declare a connection and an execution environment") + if connection.get("kind") == "local" and set(connection) == {"kind"}: + host_alias = None + elif connection.get("kind") == "ssh" and set(connection) == {"kind", "host_alias"}: + host_alias = _text(connection["host_alias"], "SSH alias", 255) + else: + raise ValueError("Use a local connection or a configured SSH host alias") + if environment.get("kind") == "host" and set(environment) == {"kind"}: + container_id = None + elif environment.get("kind") == "container" and set(environment) == {"kind", "container_id"}: + container_id = environment["container_id"] + if not isinstance(container_id, str) or not re.fullmatch(r"[0-9a-f]{64}", container_id): + raise ValueError("A full pinned container id is required") + else: + raise ValueError("Use a host or a pinned container environment") + if isinstance(scope, dict) and set(scope) == {"all_registered"} and scope["all_registered"] is True: + goal_id = None + elif isinstance(scope, dict) and set(scope) == {"goal_id"}: + goal_id = scope["goal_id"] + if not isinstance(goal_id, str) or not _GOAL_ID.fullmatch(goal_id): + raise ValueError("Invalid source Goal id") + else: + raise ValueError("Explicitly scope one Goal or all registered Goals") + expected = {"id", "label", "registry", "runtime_root", "cli", "python", "path"} + if set(raw) != expected: + raise ValueError("Invalid or missing source manifest fields") + return cls(**raw, connection=connection["kind"], environment=environment["kind"], + goal_id=goal_id, host_alias=host_alias, container_id=container_id) + + @property + def namespace(self): + # A reused display id cannot retain another authority's cache or cursor. + return sha256(json.dumps(asdict(self), sort_keys=True).encode()).hexdigest() + + def check_access(self, goal_id=None): + selected = self.goal_id if goal_id is None else goal_id + if selected is not None and (not isinstance(selected, str) or not _GOAL_ID.fullmatch(selected)): + raise SourceScopeError("Invalid source Goal id") + if self.goal_id is not None and selected != self.goal_id: + raise SourceScopeError("Goal is not in this source") + # Recheck even on cached reads; a removed alias is not a retained grant. + if self.connection == "ssh" and self.host_alias not in configured_ssh_host_aliases(): + raise SourceScopeError("The configured SSH source is unavailable") + return selected + + def describe(self): + return {"id": self.id, "namespace": self.namespace, "label": self.label, + "connection": self.connection, "environment": self.environment} + + def _execute(self, argv): + if self.environment == "container": + argv = ["docker", "exec", "-e", "LOOPX_USAGE_PING=0", "-e", f"PATH={self.path}", + self.container_id, *argv] + if self.connection == "ssh": + if self.environment == "host": + argv = ["env", "LOOPX_USAGE_PING=0", f"PATH={self.path}", *argv] + return _run(["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", + self.host_alias, shlex.join(argv)]) + env = {**os.environ, "LOOPX_USAGE_PING": "0"} + if self.environment == "host": + env["PATH"] = self.path + return _run(argv, env=env) + + def read_status(self, goal_id=None): + selected = self.check_access(goal_id) + args = [self.cli, "--registry", self.registry, "--runtime-root", self.runtime_root, + "--format", "json", "status"] + if selected is not None: + args += ["--goal-id", selected] + status = json.loads(self._execute(args)) + if not isinstance(status, dict): + raise ValueError("Invalid native source status") + history = status.get("run_history") + goals = history.get("goals") if isinstance(history, dict) else None + if (status.get("ok") is not True or not isinstance(goals, list) + or any(not isinstance(goal, dict) or not isinstance(goal.get("id"), str) + or not _GOAL_ID.fullmatch(goal["id"]) for goal in goals)): + raise ValueError("Invalid native source status") + if selected is not None and (len(goals) != 1 or goals[0]["id"] != selected): + raise SourceScopeError("The source no longer exposes the selected Goal") + if self.goal_id is None and (any(goal.get("registry_member") is not True for goal in goals)): + raise SourceScopeError("Only registered Goals are in this source scope") + return status + + def read_events(self, goal_id): + """Read JSON objects; the capability-owned projector verifies semantics.""" + selected = self.check_access(goal_id) + if selected is None: + raise SourceScopeError("Choose a Goal before reading history") + log = self._execute([self.python, "-c", _READ_LOG, self.runtime_root, selected]) + 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(event) + return events + + +class GoalSourceReader: + """One source's bounded read cache and source-bound history snapshots.""" + + def __init__(self, source, *, history_projector): + self.source = source + self._history_projector = history_projector + self._lock = RLock() + self._status = None + self._read_at = 0.0 + self._selected = None + self._pages = CompletedTodoPages() + + def _invalidate(self): + self._status = None + self._pages = CompletedTodoPages() + + def status(self, goal_id=None): + with self._lock: + try: + selected = self.source.check_access(goal_id) + if (self._status is None or selected != self._selected + or monotonic() - self._read_at >= 2): + self._status = None + self._status = self.source.read_status(selected) + self._selected, self._read_at = selected, monotonic() + return deepcopy(self._status) + except (ValueError, OSError, subprocess.SubprocessError): + self._invalidate() + raise + + def describe(self): + return {"ok": True, "source": self.source.describe(), "read_only": True, + "scope": {"goal_id": self.source.goal_id} if self.source.goal_id else {"all_registered": True}, + "operations": {"goals": True, "status": True, "history": True, + "lifecycle": False, "conversation": False, "analysis": False}} + + def goals(self, goal_id=None): + status = self.status(goal_id) + return {"ok": True, "source": self.source.describe(), + "items": status["run_history"]["goals"]} + + def history(self, goal_id, cursor=""): + with self._lock: + if goal_id is None: + raise SourceScopeError("Choose a Goal before reading history") + if not isinstance(cursor, str) or len(cursor) > 512: + raise ValueError("history_cursor_expired") + # Membership/SSH access is rechecked before any saved cursor is used. + self.status(goal_id) + try: + page = self._pages.page(scope=(self.source.namespace, goal_id), cursor=cursor, + load=lambda: self._history_projector(self.source.read_events(goal_id), goal_id=goal_id)) + return {**page, "source": self.source.describe(), "goal_id": goal_id} + except (ValueError, OSError, subprocess.SubprocessError) as exc: + if str(exc) != "history_cursor_expired": + self._invalidate() + raise + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + return None + + +def read_endpoint(endpoint, operation, *, goal_id=None, cursor="", source_namespace=None): + """CLI/Agent client for the same read endpoints consumed by the App adapter.""" + url = urlparse(endpoint) + if (url.scheme != "http" or not is_loopback_host(url.hostname or "") or url.username + or url.password or url.path not in {"", "/", "/status.json"} or url.fragment): + raise ValueError("Use the prepared reader's loopback HTTP endpoint") + # A pinned status URL is also a usable client endpoint. Reject unrelated + # query options instead of silently dropping caller scope. + endpoint_query = parse_qs(url.query, keep_blank_values=True) + if endpoint_query: + if set(endpoint_query) != {"source_namespace"} or len(endpoint_query["source_namespace"]) != 1: + raise ValueError("Use the prepared reader's loopback HTTP endpoint") + pinned = endpoint_query["source_namespace"][0] + if source_namespace is not None and source_namespace != pinned: + raise ValueError("Conflicting source identities") + source_namespace = pinned + if operation not in {"describe", "goals", "status", "history"}: + raise ValueError("Only describe, goals, status and history reads are supported") + base = f"{url.scheme}://{url.netloc}/api/goal-source" + opener = build_opener(_NoRedirect()) + + def read(path): + try: + with opener.open(Request(path), timeout=30) as response: + body = response.read(_MAX_BYTES + 1) + except HTTPError as exc: + try: + error = json.loads(exc.read(_MAX_BYTES + 1)) + except (ValueError, OSError): + error = {} + message = error.get("error") if isinstance(error, dict) else None + raise ValueError(message if isinstance(message, str) and len(message) <= 600 + else "Goal source read failed") from exc + except (OSError, URLError) as exc: + raise ValueError("Goal source read failed; keep the original source and retry") from exc + if len(body) > _MAX_BYTES: + raise ValueError("Source read exceeds the display limit") + payload = json.loads(body) + if not isinstance(payload, dict) or payload.get("ok") is not True: + raise ValueError("Invalid Goal source read response") + return payload + + description = read(base) + source = description.get("source") + namespace = source.get("namespace") if isinstance(source, dict) else None + if not isinstance(namespace, str) or not re.fullmatch(r"[0-9a-f]{64}", namespace): + raise ValueError("The reader did not expose a source identity") + if source_namespace is not None and source_namespace != namespace: + raise ValueError("Goal source changed; do not reuse the previous selection") + if operation == "describe": + return description + query = {"source_namespace": namespace} + if goal_id is not None: + query["goal_id"] = goal_id + if cursor: + query["cursor"] = cursor + payload = read(f"{base}/{operation}?{urlencode(query)}") + if not isinstance(payload.get("source"), dict) or payload["source"].get("namespace") != namespace: + raise ValueError("Goal source changed during the read") + return payload + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("describe", "goals", "status", "history")) + parser.add_argument("--endpoint", required=True, help="Prepared owner-local reader URL") + parser.add_argument("--goal-id") + parser.add_argument("--cursor", default="") + parser.add_argument("--source-namespace", help="Bind a previous source description") + args = parser.parse_args() + try: + result = read_endpoint(args.endpoint, args.operation, goal_id=args.goal_id, cursor=args.cursor, + source_namespace=args.source_namespace) + except (ValueError, TypeError) as exc: + print(json.dumps({"ok": False, "error": str(exc)})) + raise SystemExit(1) from None + print(json.dumps(result, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/loopx/presentation/explore_results_api.py b/loopx/presentation/explore_results_api.py index d841d0eaea..d1db22b5d3 100644 --- a/loopx/presentation/explore_results_api.py +++ b/loopx/presentation/explore_results_api.py @@ -6,6 +6,7 @@ from ..capabilities.explore.result_log import ( build_explore_result_projection, + explore_history_rows, explore_result_log_path, load_explore_result_events_strict, ) @@ -54,20 +55,6 @@ def _history_rows(runtime_root, 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", - "blocked_reason", "parent_id", "node_id", "from_node", "to_node", - "edge_type", "agent_id", "run_id", "evidence_refs", "supersedes", "confidence", - ) - return [ - {key: event[key] for key in fields if key in event} - for event in events - ] - - class ExploreResultsRequestMixin: def _explore_results(self): query = parse_qs(urlparse(self.path).query) diff --git a/loopx/presentation/runtime_workspace.py b/loopx/presentation/runtime_workspace.py index 9b62204528..f50683ff15 100644 --- a/loopx/presentation/runtime_workspace.py +++ b/loopx/presentation/runtime_workspace.py @@ -1,4 +1,4 @@ -"""Owner-only read transport from pinned containers to the existing Goal UI. +"""Existing Goal UI over prepared, read-only Goal source access. 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 @@ -9,98 +9,18 @@ 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 +from pathlib import Path import subprocess -from threading import Lock, Thread -from time import monotonic +from threading import Thread 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 ..capabilities.explore.result_log import explore_history_rows +from ..control_plane.status.goal_source import GoalSource, GoalSourceReader, SourceScopeError 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): @@ -109,25 +29,13 @@ class RuntimeWorkspaceServer(ThreadingHTTPServer): def __init__(self, source, assets_dir, *, port=0): super().__init__(("127.0.0.1", port), RuntimeWorkspaceHandler) self.source = source + self.reader = GoalSourceReader(source, history_projector=explore_history_rows) 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 + return f"http://127.0.0.1:{self.server_port}/status.json?source_namespace={self.source.namespace}" class RuntimeWorkspaceHandler(BaseHTTPRequestHandler): @@ -171,22 +79,39 @@ def do_GET(self): if not self._allowed(): return parsed = urlparse(self.path) - query = parse_qs(parsed.query) + query = parse_qs(parsed.query, keep_blank_values=True) 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) + if any(len(query.get(key, [])) > 1 for key in ("goal_id", "source_namespace", "cursor", "view")): + self._json({"ok": False, "error": "Source selectors must have one value."}, 400) return + if "source_namespace" in query and query["source_namespace"] != [self.server.source.namespace]: + self._json({"ok": False, "error": "Source identity changed; keep the original source and retry."}, 409) + return + goal_id = query.get("goal_id", [self.server.source.goal_id])[0] 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. + # Native status already supplies the scoped Goal list. The UI's + # existing full-status fallback preserves its metadata/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() + if parsed.path == "/api/goal-source": + self._json(self.server.reader.describe()) + elif parsed.path.startswith("/api/goal-source/"): + if query.get("source_namespace") != [self.server.source.namespace]: + self._json({"ok": False, "error": "Source identity changed; read its description again."}, 409) + elif parsed.path == "/api/goal-source/goals": + self._json(self.server.reader.goals(goal_id)) + elif parsed.path == "/api/goal-source/status": + self._json({"ok": True, "source": self.server.source.describe(), + "status": self.server.reader.status(goal_id)}) + elif parsed.path == "/api/goal-source/history": + self._json(self.server.reader.history(goal_id, query.get("cursor", [""])[0])) + else: + self._json({"ok": False, "error": "Read endpoint is not available for this source."}, 404) + elif parsed.path == "/status.json": + status = self.server.reader.status(goal_id) status = deepcopy(status) status["local_dashboard_api"] = {"source": "runtime-workspace", "status_url": self.server.status_url, "read_only_sources": self.server.catalog, @@ -194,13 +119,14 @@ def do_GET(self): 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}) + self._json(self.server.reader.history(goal_id, cursor)) elif parsed.path == "/api/chat/research-analysis": + self.server.source.check_access(goal_id) self._json({"ok": False, "error": "此运行来源尚未绑定分析包。"}, 503) else: self._json({"ok": False, "error": "Read endpoint is not available for this source."}, 404) + except SourceScopeError: + self._json({"ok": False, "error": "Goal or connection is not available in this source."}, 404) except ValueError as exc: expired = str(exc) == "history_cursor_expired" self._json({"ok": False, "error": "history_cursor_expired" if expired else @@ -226,7 +152,7 @@ def _asset(self, path): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--sources", type=Path, required=True, help="Owner-prepared container source manifest") + parser.add_argument("--sources", type=Path, required=True, help="Owner-prepared Goal 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() @@ -241,7 +167,10 @@ def main(): 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] + try: + sources = [GoalSource.parse(item) for item in raw] + except (ValueError, TypeError) as exc: + parser.error(str(exc)) 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): @@ -256,8 +185,11 @@ def main(): 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"})} + "namespace": server.source.namespace, + "access_url": f"http://127.0.0.1:{server.server_port}/api/goal-source", + "url": f"http://127.0.0.1:{server.server_port}/chat/?" + urlencode({ + **({"goalId": server.source.goal_id} if server.source.goal_id else {}), + "statusUrl": item["status_url"], "view": "overview"})} for item, server in zip(catalog, servers)]}), flush=True) try: threads[0].join() diff --git a/tests/presentation/test_goal_source.py b/tests/presentation/test_goal_source.py new file mode 100644 index 0000000000..2f635a26e9 --- /dev/null +++ b/tests/presentation/test_goal_source.py @@ -0,0 +1,127 @@ +"""Goal access composes transports without gaining execution authority.""" + +from dataclasses import replace +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys + +import pytest + +from loopx.capabilities.explore.result_log import build_explore_node_event, explore_history_rows +from loopx.control_plane.status.goal_source import GoalSource, GoalSourceReader, SourceScopeError + + +def manifest(*, connection=None, environment=None, scope=None): + return dict(id="source", label="Source", registry="/registry", runtime_root="/runtime", + cli="/bin/loopx", python="/bin/python", path="/bin", + connection=connection or {"kind": "local"}, environment=environment or {"kind": "host"}, + scope=scope or {"goal_id": "original"}) + + +def test_explicit_source_scope_is_required_and_unknown_fields_are_rejected(): + current = manifest(environment={"kind": "container", "container_id": "a" * 64}) + source = GoalSource.parse(current) + with pytest.raises(SourceScopeError): + source.check_access("another") + assert replace(source, runtime_root="/different").namespace != source.namespace + with pytest.raises(ValueError): + GoalSource.parse({**current, "scope": {}}) + with pytest.raises(ValueError): + GoalSource.parse({**current, "unexpected": True}) + with pytest.raises(ValueError): + GoalSource.parse({key: value for key, value in current.items() if key != "connection"}) + + +@pytest.mark.parametrize("connection", ["local", "ssh"]) +@pytest.mark.parametrize("environment", ["host", "container"]) +def test_connection_and_environment_compose_with_native_read_only_commands(monkeypatch, connection, environment): + raw = manifest(connection={"kind": connection, **({"host_alias": "lab"} if connection == "ssh" else {})}, + environment={"kind": environment, **({"container_id": "b" * 64} if environment == "container" else {})}) + raw["registry"] = "/registry with space" + source = GoalSource.parse(raw) + monkeypatch.setattr("loopx.control_plane.status.goal_source.configured_ssh_host_aliases", lambda: ["lab"]) + commands = [] + + def run(argv, *, env=None): + commands.append((argv, env)) + return json.dumps({"ok": True, "run_history": {"goals": [{"id": "original"}]}}) + + monkeypatch.setattr("loopx.control_plane.status.goal_source._run", run) + assert source.read_status()["ok"] + argv, env = commands[0] + if connection == "ssh": + assert argv[:2] == ["ssh", "-T"] and argv[-2] == "lab" + argv = shlex.split(argv[-1]) + assert env is None + else: + assert env["LOOPX_USAGE_PING"] == "0" + if environment == "container": + assert argv[:2] == ["docker", "exec"] and "b" * 64 in argv + assert argv[-4:] == ["json", "status", "--goal-id", "original"] + assert argv[argv.index("--registry") + 1] == "/registry with space" + + +def test_revoked_ssh_alias_invalidates_cached_status_and_pages(monkeypatch): + source = GoalSource.parse(manifest(connection={"kind": "ssh", "host_alias": "lab"})) + aliases = ["lab"] + monkeypatch.setattr("loopx.control_plane.status.goal_source.configured_ssh_host_aliases", lambda: aliases) + node = build_explore_node_event(goal_id="original", node_id="question", title="Recorded question") + + def run(argv, **_kwargs): + if " status " in argv[-1]: + return json.dumps({"ok": True, "run_history": {"goals": [{"id": "original"}]}}) + return json.dumps(node) + + monkeypatch.setattr("loopx.control_plane.status.goal_source._run", run) + reader = GoalSourceReader(source, history_projector=explore_history_rows) + assert reader.history("original")["total"] == 1 + aliases.clear() + with pytest.raises(SourceScopeError): + reader.status() + assert reader._status is None and not reader._pages._snapshots + + +def test_registered_scope_does_not_include_runtime_only_goals(monkeypatch): + source = GoalSource.parse(manifest(scope={"all_registered": True})) + monkeypatch.setattr("loopx.control_plane.status.goal_source._run", lambda *args, **kwargs: + json.dumps({"ok": True, "run_history": {"goals": [{"id": "runtime-only", "registry_member": False}]}})) + with pytest.raises(SourceScopeError, match="registered"): + source.read_status("runtime-only") + + +def test_real_native_host_lists_goals_and_reads_canonical_history(tmp_path): + """Disposable native registry/CLI, not a mock transport or active Goal.""" + project = tmp_path / "project" + project.mkdir() + runtime = tmp_path / "runtime" + registry = project / "registry.json" + goals = [] + for goal_id in ("first", "second"): + (project / f"{goal_id}.md").write_text("---\nstatus: active\n---\n\n# Research\n") + goals.append(dict(id=goal_id, status="active", domain="source-read", repo=str(project), + state_file=f"{goal_id}.md", adapter={"kind": "test_v0", "status": "connected-read-only"}, + authority_sources=[])) + registry.write_text(json.dumps(dict(schema_version=1, common_runtime_root=str(runtime), goals=goals))) + cli = Path(sys.executable).with_name("loopx") + assert cli.is_file(), "Use the source checkout's prepared Python environment" + source = GoalSource.parse({**manifest(scope={"all_registered": True}), "registry": str(registry), + "runtime_root": str(runtime), "cli": str(cli), "python": sys.executable, "path": os.environ["PATH"]}) + reader = GoalSourceReader(source, history_projector=explore_history_rows) + assert {goal["id"] for goal in reader.goals()["items"]} == {"first", "second"} + log = runtime / "goals" / "first" / "explore-result-log.jsonl" + log.parent.mkdir(parents=True, exist_ok=True) + events = [build_explore_node_event(goal_id="first", node_id=f"question{i}", title=f"Question {i}") + for i in range(43)] + log.write_text("\n".join(json.dumps(event) for event in events)) + page = reader.history("first") + assert page["total"] == 43 + second = reader.history("first", page["next_cursor"]) + assert len(page["items"] + second["items"]) == 43 + assert {row["event_id"] for row in page["items"] + second["items"]} == {event["event_id"] for event in events} + assert reader.history("second")["total"] == 0 + with pytest.raises((SourceScopeError, subprocess.SubprocessError)): + reader.status("unregistered") + assert reader.status("first")["run_history"]["goals"][0]["id"] == "first" diff --git a/tests/presentation/test_runtime_workspace.py b/tests/presentation/test_runtime_workspace.py index 2d422df7f0..99549c26f5 100644 --- a/tests/presentation/test_runtime_workspace.py +++ b/tests/presentation/test_runtime_workspace.py @@ -2,33 +2,42 @@ from contextlib import contextmanager import json +import os +from pathlib import Path +import subprocess +import sys from threading import Thread from urllib.error import HTTPError +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit 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.capabilities.explore.result_log import build_explore_node_event, explore_history_rows +from loopx.control_plane.status.goal_source import GoalSource, GoalSourceReader, read_endpoint +from loopx.presentation.runtime_workspace import RuntimeWorkspaceServer from loopx.presentation.runtime_workspace import main as runtime_workspace_main -class Source: - goal_id = "shared-goal" - +class Source(GoalSource): def __init__(self, identity): - self.id = identity - self.label = identity + super().__init__(identity, identity, "/registry", "/runtime", "/cli", "/python", + "/bin", "local", "host", "shared-goal") self.failed = False self.calls = 0 - def read(self): + def read_status(self, goal_id=None): + self.check_access(goal_id) 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)] + "display_name": self.id, "registry_goal_digest": self.id}], "run_count": 17}} + + def read_events(self, goal_id): + self.check_access(goal_id) + return [build_explore_node_event(goal_id=goal_id, node_id=f"question{i}", + title=self.id) for i in range(91)] @contextmanager @@ -44,10 +53,15 @@ def serving(source, assets): 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 {}) +def get(server, path="/status.json", *, method="GET", headers=None, namespace=True, timeout=5): + source_url, target = urlsplit(server.status_url), urlsplit(path) + query = parse_qsl(target.query, keep_blank_values=True) + if namespace and not any(key == "source_namespace" for key, _ in query): + query += [("source_namespace", server.source.namespace)] + url = urlunsplit((source_url.scheme, source_url.netloc, target.path, urlencode(query), "")) + request = Request(url, method=method, headers=headers or {}) try: - response = urlopen(request, timeout=5) + response = urlopen(request, timeout=timeout) except HTTPError as exc: response = exc with response: @@ -83,6 +97,10 @@ def test_scope_origin_and_mutation_are_rejected_before_source_read(tmp_path): for method in ("POST", "PUT", "PATCH", "DELETE"): assert get(server, method=method)[0] == 405 assert get(server, "/unavailable")[0] == 404 + assert get(server, "/api/goal-source/status", namespace=False)[0] == 409 + assert get(server, "/api/goal-source/status?source_namespace=" + "0" * 64)[0] == 409 + assert get(server, "/status.json?goal_id=shared-goal&goal_id=another")[0] == 400 + assert get(server, "/api/goal-source")[1]["operations"]["lifecycle"] is False assert source.calls == 0 @@ -90,42 +108,123 @@ 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 + _, page = get(server, "/api/chat/explore-results?view=history&goal_id=shared-goal") + cursor = page["next_cursor"] + server.reader._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 + assert get(server, f"/api/chat/explore-results?view=history&goal_id=shared-goal&cursor={cursor}")[0] == 409 + + +@pytest.mark.parametrize("invalid_kind", ["null", "event-pairs"]) +def test_invalid_native_history_clears_only_its_source_and_recovers(tmp_path, invalid_kind): + """Exercise actual native CLI/log IO through both shared HTTP consumers.""" + project, runtime = tmp_path / "project", tmp_path / "runtime" + project.mkdir() + (project / "goal.md").write_text("---\nstatus: active\n---\n\n# Research\n") + registry = project / "registry.json" + registry.write_text(json.dumps(dict(schema_version=1, common_runtime_root=str(runtime), goals=[dict( + id="shared-goal", status="active", domain="source-read", repo=str(project), + state_file="goal.md", adapter={"kind": "test_v0", "status": "connected-read-only"}, + authority_sources=[])]))) + cli = Path(sys.executable).with_name("loopx") + assert cli.is_file(), "Use the source checkout's prepared Python environment" + source = GoalSource("native", "Native", str(registry), str(runtime), str(cli), + sys.executable, os.environ["PATH"], "local", "host", "shared-goal") + events = [build_explore_node_event(goal_id="shared-goal", node_id=f"question{i}", + title=f"Question {i}") for i in range(43)] + log = runtime / "goals" / "shared-goal" / "explore-result-log.jsonl" + log.parent.mkdir(parents=True) + valid = "\n".join(json.dumps(event) for event in events).encode() + log.write_bytes(valid) + routes = ("/api/goal-source/history?goal_id=shared-goal", + "/api/chat/explore-results?view=history&goal_id=shared-goal") + with serving(source, tmp_path) as server, serving(Source("untouched"), tmp_path) as other: + # Match the shared client's budget for the native CLI's 25-second IO. + code, page = get(server, routes[0], timeout=30) + assert code == 200 and page["total"] == 43 and page["next_cursor"] + cursor = page["next_cursor"] + _, other_page = get(other, routes[0]) + other_status = other.reader._status + invalid = None if invalid_kind == "null" else list(events[20].items()) + corrupted = [*events[:20], invalid, *events[21:]] + log.write_text("\n".join(json.dumps(event) for event in corrupted)) + for route in routes: + code, error = get(server, route, timeout=30) + assert code == 503 and error["ok"] is False + assert str(project) not in json.dumps(error) + assert server.reader._status is None and not server.reader._pages._snapshots + with pytest.raises(ValueError): + read_endpoint(server.status_url, "history", goal_id="shared-goal") + assert other.reader._status is other_status + assert get(other, f'{routes[0]}&cursor={other_page["next_cursor"]}')[0] == 200 + log.write_bytes(valid) + assert get(server, f"{routes[0]}&cursor={cursor}", timeout=30)[0] == 409 + code, restored = get(server, routes[0], timeout=30) + assert code == 200 and restored["total"] == 43 + _, tail = get(server, f'{routes[0]}&cursor={restored["next_cursor"]}', timeout=30) + assert {row["event_id"] for row in restored["items"] + tail["items"]} == { + event["event_id"] for event in events} + assert log.read_bytes() == valid + + +def test_cli_agent_and_app_use_the_same_reader_and_source_binding(tmp_path): + source = Source("original") + with serving(source, tmp_path) as server: + description = read_endpoint(server.status_url, "describe") + assert description["read_only"] and not description["operations"]["analysis"] + cli = subprocess.run([sys.executable, "-m", "loopx.control_plane.status.goal_source", + "goals", "--endpoint", server.status_url], + capture_output=True, text=True, check=True, timeout=10) + assert json.loads(cli.stdout)["source"]["namespace"] == source.namespace + assert read_endpoint(server.status_url, "goals")["items"][0]["id"] == "shared-goal" + native = read_endpoint(server.status_url, "status")["status"] + assert native["run_history"] == get(server)[1]["run_history"] + page = read_endpoint(server.status_url, "history", goal_id="shared-goal") + assert page["total"] == 91 + assert read_endpoint(server.status_url, "history", goal_id="shared-goal", + cursor=page["next_cursor"])["items"] + with pytest.raises(ValueError, match="Conflicting source"): + read_endpoint(server.status_url, "status", source_namespace="0" * 64) + with pytest.raises(ValueError, match="not available"): + read_endpoint(server.status_url, "history", goal_id="another") 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") + source = GoalSource.parse(dict(id="native", label="Native", connection={"kind": "local"}, + environment={"kind": "container", "container_id": "a" * 64}, scope={"goal_id": "shared-goal"}, + registry="/registry.json", runtime_root="/runtime", + cli="/bin/loopx", python="/bin/python", path="/bin")) node = build_explore_node_event(goal_id="shared-goal", node_id="question", title="Actual question") commands = [] - def run(argv): + def run(argv, **_kwargs): 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() + monkeypatch.setattr("loopx.control_plane.status.goal_source._run", run) + assert source.read_status()["run_history"]["goals"][0]["id"] == "shared-goal" + events = source.read_events("shared-goal") 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() + GoalSourceReader(source, history_projector=explore_history_rows).history("shared-goal") def test_manifest_requires_physical_identity(): - raw = dict(id="source", label="Source", container_id="alias", goal_id="goal", + raw = dict(id="source", label="Source", connection={"kind": "local"}, + environment={"kind": "container", "container_id": "alias"}, scope={"goal_id": "goal"}, registry="/registry", runtime_root="/runtime", cli="/cli", python="/python", path="/bin") with pytest.raises(ValueError, match="pinned container"): - ContainerSource.parse(raw) + GoalSource.parse(raw) def test_default_entry_rejects_stale_bundle_before_opening_sources(monkeypatch, tmp_path, capsys):