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