diff --git a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts index 18c4a58ee1..9576f4c331 100644 --- a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts +++ b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts @@ -1,6 +1,14 @@ import { z } from "zod"; import { parseStatusPayload, type StatusPayload } from "./status"; +/** The source facts a discovery read can state without opening runtime state; older services omit it. */ +const directoryContractSchema = z.object({ + ok: z.boolean(), + summary: z.object({ errors: z.number(), warnings: z.number(), checks: z.number() }), + errors: z.array(z.string()), + warnings: z.array(z.string()), + checks: z.array(z.string()).optional().default([]), +}); const directorySchema = z.object({ ok: z.literal(true), schema_version: z.literal("loopx_workspace_directory_v1"), @@ -11,6 +19,7 @@ const directorySchema = z.object({ activation_state: z.enum(["active", "stopped"]), registry_member: z.literal(true), })), + contract: directoryContractSchema.optional(), }); export type WorkspaceDirectory = z.infer; export type WorkspaceLoadError = "timeout" | "network" | "service" | "access" | "revision" | "scope" | "invalid"; @@ -87,7 +96,8 @@ export function directoryStatusPayload(directory: WorkspaceDirectory): StatusPay return parseStatusPayload({ ok: true, registry: "", runtime_root: "", goal_count: directory.goals.length, run_count: 0, local_dashboard_api: {}, - contract: { ok: true, summary: { errors: 0, warnings: 0, checks: 0 }, errors: [], warnings: [] }, + // A fresh install's directory names its missing registry; the first-Goal home shows that reason. + contract: directory.contract ?? { ok: true, summary: { errors: 0, warnings: 0, checks: 0 }, errors: [], warnings: [] }, attention_queue: { available: false, item_count: 0, needs_user_or_controller: 0, needs_codex: 0, watching_external_evidence: 0, items: [] }, run_history: { available: false, goal_count: directory.goals.length, run_count: 0, diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 80d6a4f4f9..cf9400de8a 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -8,6 +8,7 @@ import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal- import {sinceYouLookedScenario} from "./personal-workspace-browser/since-you-looked.mjs"; import {sinceYouLookedLiveScenario} from "./personal-workspace-browser/since-you-looked-live.mjs"; import {firstGoalHomeScenario} from "./personal-workspace-browser/first-goal-home.mjs"; +import {firstGoalHomeLiveScenario} from "./personal-workspace-browser/first-goal-home-live.mjs"; import {goalSuggestionsScenario} from "./personal-workspace-browser/goal-suggestions.mjs"; import {goalSuggestionsLiveScenario} from "./personal-workspace-browser/goal-suggestions-live.mjs"; import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; @@ -117,6 +118,7 @@ scenarioCatalog.push(goalWorkspaceSelectionScenario); scenarioCatalog.push(sinceYouLookedScenario); scenarioCatalog.push(sinceYouLookedLiveScenario); scenarioCatalog.push(firstGoalHomeScenario); +scenarioCatalog.push(firstGoalHomeLiveScenario); scenarioCatalog.push(goalSuggestionsScenario); scenarioCatalog.push(goalSuggestionsLiveScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; diff --git a/examples/personal-workspace-browser/first-goal-home-live.mjs b/examples/personal-workspace-browser/first-goal-home-live.mjs new file mode 100644 index 0000000000..be511050f2 --- /dev/null +++ b/examples/personal-workspace-browser/first-goal-home-live.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { resolveTestPython } from "../../scripts/test-python.mjs"; +import { outputDir, packaged, repoRoot } from "./fixture.mjs"; +import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; + +// End to end against the real service: a disposable `loopx chat` on a fresh +// runtime with no registry at all, as an installed LoopX opens for the first +// time. The page reads the real discovery view, which states its source facts: +// zero Goals and the service's own missing-registry warning. The home must +// open to the first-Goal state, not a health banner, keep that warning in the +// collapsed diagnostics without a local path, let an example fill the composer +// without sending, and open the typed Goal form fed by the real project catalog. +// Confirming the first Goal goes through the real action owner. The only Agent +// is a stub that exits at once, so no model ever runs: the service registers +// the Goal and its first task, reports the first Turn it could not start, and +// offers the original operation to retry; a false success would be the defect. + +export const firstGoalHomeLiveScenario = { + id: "first-goal-home-live", + async run({ browser, collectCoverage, url }) { + // The real service serves the packaged bundle; the development lane has none to serve. + if (!packaged) return { coverageEntries: [], note: "live first-Goal home runs against the packaged bundle only" }; + const root = await mkdtemp(join(tmpdir(), "loopx-first-goal-live-")); + const python = resolveTestPython({ repoRoot }); + const child = spawn(python, ["-u", resolve(repoRoot, "examples/personal-workspace-browser/fresh-install-backend.py"), root], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); + const closed = once(child, "close"); + const lines = createInterface({ input: child.stdout }); + let diagnostics = "", session; + child.stderr.on("data", (chunk) => { diagnostics = (diagnostics + chunk).slice(-4000); }); + try { + const ready = await Promise.race([once(lines, "line").then(([line]) => JSON.parse(line)), closed.then(() => { throw new Error(`fresh-install backend exited: ${diagnostics}`); })]); + const backend = `http://127.0.0.1:${ready.port}`; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { if ((await fetch(`${backend}/status.json?view=workspace-directory`)).ok) break; } catch { /* not listening yet */ } + await new Promise((resolveWait) => setTimeout(resolveWait, 200)); + } + const directory = await (await fetch(`${backend}/status.json?view=workspace-directory`)).json(); + assert.equal(directory.ok, true); + assert.deepEqual(directory.goals, []); + assert.ok(directory.contract?.warnings.some((row) => /^registry file does not exist/.test(row)), `the real discovery view names the missing registry: ${JSON.stringify(directory.contract)}`); + assert.ok(!JSON.stringify(directory.contract).includes(root), "no local path leaves the service"); + session = await openWorkspacePage(browser, url, { collectCoverage, viewport: { width: 1440, height: 900 }, beforeGoto: async (_api, page) => { + // The document URL also ends in "status.json" (its statusUrl query), so match paths, not a glob. + const proxied = new Set(["/status.json", "/api/chat/capabilities", "/api/chat/projects"]); + await page.route((target) => proxied.has(target.pathname) || target.pathname.startsWith("/api/actions"), async (route) => { + try { const response = await fetchWorkspaceBackend(route, backend); await route.fulfill({ response }); } + catch { await route.abort().catch(() => {}); } + }); + } }); + const { page } = session; + const turnRequests = []; + page.on("request", (request) => { if (request.method() === "POST" && /\/api\/chat\/sessions\/[^/]+\/turns/.test(request.url())) turnRequests.push(request.url()); }); + const firstGoal = page.getByTestId("personal-first-goal"); + await firstGoal.waitFor({ state: "visible", timeout: 20_000 }); + assert.equal(await page.locator(".personal-system-health-banner").count(), 0, "The real missing registry is not shown as a health concern"); + assert.match(await firstGoal.locator("h2").innerText(), /还没有 Goal/); + const details = firstGoal.locator("details.personal-first-goal-diagnostics"); + await details.waitFor({ state: "visible", timeout: 10_000 }); + assert.equal(await details.getAttribute("open"), null, "Diagnostics stay collapsed"); + // The disclosure stays closed, so read its lines, not the visible text. + const reasons = await details.locator("li").allTextContents(); + assert.ok(reasons.some((row) => /registry file does not exist/.test(row)), `the diagnostics carry the service's own warning: ${reasons.join(" | ")}`); + assert.ok(reasons.every((row) => !row.includes(root)), "the diagnostics show no local path"); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-live.png"), animations: "disabled" }); + + // An example only fills the composer; nothing is sent to the real service. + const composer = page.locator(".personal-channel-composer textarea"); + const example = firstGoal.locator(".personal-first-goal-examples button").first(); + const exampleText = (await example.innerText()).trim(); + await example.click(); + assert.equal((await composer.inputValue()).trim(), exampleText); + assert.equal(turnRequests.length, 0, `an example sends nothing: ${turnRequests.join(", ")}`); + await page.keyboard.press("Escape"); + + // Create a Goal opens the typed form; its working directories come from the real project catalog. + await firstGoal.locator(".personal-first-goal-create").click(); + const form = page.locator("dialog.personal-action-form"); + await form.waitFor({ state: "visible", timeout: 10_000 }); + const projects = await (await fetch(`${backend}/api/chat/projects`)).json(); + assert.ok(projects.projects.length >= 1, "the real service registers the scan root as a project"); + await form.getByLabel("工作目录").locator("option", { hasText: projects.projects[0].title }).waitFor({ state: "attached", timeout: 10_000 }); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-create.png"), animations: "disabled" }); + + // The first Goal is created from this page through the real action + // owner: review, confirm, readback. No terminal, no pre-existing registry. + const title = "Track the weekly release checklist"; + await form.getByLabel("目标").fill(title); + await form.getByLabel("完成标准").fill("A checklist exists and is reviewed every week"); + await form.getByLabel("工作目录").selectOption({ label: projects.projects[0].title }); + await form.locator("button[type=submit]").click(); + const drawer = page.locator(".personal-context-drawer"); + await drawer.waitFor({ state: "visible", timeout: 20_000 }); + await drawer.getByText("新 Goal · 待你确认", { exact: true }).waitFor({ state: "visible", timeout: 20_000 }); + // The snapshot readback completes shortly after the card appears; a stale-card notice must not remain. + await drawer.getByText("提案状态刷新失败", { exact: false }).waitFor({ state: "hidden", timeout: 15_000 }); + await drawer.locator(".personal-primary-action").click(); + const applyButton = drawer.getByRole("button", { name: "确认并应用", exact: true }); + if (await applyButton.count()) await applyButton.click(); + await drawer.getByText("新 Goal · 出错", { exact: true }).waitFor({ state: "visible", timeout: 60_000 }); + const outcome = (await drawer.innerText()).replace(/\s+/g, " "); + assert.match(outcome, /Goal 初始化尚未完成/, `the service names what is not finished: ${outcome.slice(0, 300)}`); + assert.match(outcome, /Goal ID: goal-[0-9a-f-]+ · 已应用/, "the Goal itself was registered before the first Turn failed"); + assert.ok(await drawer.getByRole("button", { name: "重试原操作", exact: true }).count(), "the original operation can be retried"); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-applied.png"), animations: "disabled" }); + + // The real service now has a registry with exactly this Goal, and the + // home shows it instead of the first-Goal state. + const { existsSync, readFileSync, readdirSync } = await import("node:fs"); + const registryPath = join(ready.project, ".loopx", "registry.json"); + assert.ok(existsSync(registryPath), "the first creation writes the registry"); + // The Goal persists where the serving Chat keeps its own state (its + // selected runtime root), never in the implicit default beside it. + const registry = JSON.parse(readFileSync(registryPath, "utf8")); + assert.equal(resolve(String(registry.common_runtime_root)), resolve(ready.runtime), `the registry records the service's runtime root: ${registry.common_runtime_root}`); + const goalId = registry.goals[0].id; + const underRuntime = (dir) => existsSync(dir) ? readdirSync(dir, { recursive: true }).map(String) : []; + assert.ok(underRuntime(ready.runtime).some((entry) => entry.includes(goalId)), "the Goal's canonical state lives under the service's runtime root"); + assert.ok(!underRuntime(ready.default_runtime).some((entry) => entry.includes(goalId)), "nothing of the Goal lands in the implicit default runtime root"); + const after = await (await fetch(`${backend}/status.json?view=workspace-directory`)).json(); + assert.deepEqual(after.goals.map((goal) => goal.display_name), [title], "the real discovery view lists the created Goal"); + assert.deepEqual(after.contract.warnings, [], "no missing-registry warning remains"); + await page.locator(".personal-drawer-close").click(); + await page.getByRole("button", { name: "刷新状态", exact: true }).click(); + await page.locator(".personal-goal-row", { hasText: title }).first().waitFor({ state: "visible", timeout: 20_000 }); + await firstGoal.waitFor({ state: "hidden", timeout: 20_000 }); + await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-home.png"), animations: "disabled" }); + return { coverageEntries: await session.close(), note: "real loopx chat on a fresh runtime: zero-Goal discovery with its own warning; first Goal registered from the page through the real action owner with the stub Agent's failed first Turn reported honestly; registry written, directory lists it" }; + } catch (error) { + if (diagnostics) process.stderr.write(diagnostics); + await session?.close().catch(() => {}); + throw error; + } finally { + lines.close(); + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGTERM"); const kill = setTimeout(() => child.kill("SIGKILL"), 5000); try { await closed; } finally { clearTimeout(kill); } } + } + }, +}; diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 117aa6a8b8..059138f574 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -498,6 +498,7 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu hostThreadActivity: {}, /** Scenario hook applied to every status payload after the shared fixture rules. */ transformStatus: null, + directoryContract: null, answerForMessage: null, loopxModeRequests: [], get larkConnections() { return runtime.larkConnections; }, @@ -523,6 +524,8 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu goals: [...state.goalActivationStates.entries()].map(([id, activation_state]) => ({ activation_state, display_name: registeredNames.get(id) ?? id, id, registry_member: true, })), + // The real service states its source facts here too (a missing registry is a warning). + contract: state.directoryContract ?? { ok: true, summary: { errors: 0, warnings: 0, checks: 0 }, errors: [], warnings: [], checks: [] }, }, status: 200 }); return; } diff --git a/examples/personal-workspace-browser/fresh-install-backend.py b/examples/personal-workspace-browser/fresh-install-backend.py new file mode 100644 index 0000000000..6fac32c3e8 --- /dev/null +++ b/examples/personal-workspace-browser/fresh-install-backend.py @@ -0,0 +1,56 @@ +"""Real LoopX Chat service on a fresh runtime: no registry, Goal, model or credential exists. + +The scan root is an empty project directory, exactly the state an installed +LoopX opens for the first time. The only Agent executable is a stub that +exits at once, so the service sees an installed Codex CLI without any model +ever running; the machine's real CLIs and API keys are never consulted. +""" +import json +import os +import signal +import socket +import sys +from pathlib import Path + +root = Path(sys.argv[1]).resolve() +# The implicit default runtime root lives under HOME; point it inside this +# disposable root before LoopX is imported so a creation that ignored the +# service's selected root would land somewhere the scenario can observe, and +# never in the tester's real state. +home = root / "home" +home.mkdir(parents=True, exist_ok=True) +os.environ["HOME"] = str(home) + +from loopx.chat_server import default_chat_assets_dir, serve_chat # noqa: E402 + +(root / "project").mkdir(parents=True, exist_ok=True) +bins = root / "bin" +bins.mkdir(exist_ok=True) +(bins / "codex").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") +(bins / "codex").chmod(0o755) +for variable in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY"): + os.environ.pop(variable, None) +os.environ["LOOPX_USAGE_PING"] = "0" +os.environ["DO_NOT_TRACK"] = "1" +with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] +print(json.dumps({ + "port": port, + "project": str(root / "project"), + "runtime": str(root / "runtime"), + "default_runtime": str(home / ".loopx"), +}), flush=True) +signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) +serve_chat( + registry_path=root / "project" / ".loopx" / "registry.json", + runtime_root_override=str(root / "runtime"), + scan_roots=[root / "project"], + host="127.0.0.1", + port=port, + codex_bin=str(bins / "codex"), + claude_bin=str(bins / "claude"), + kiro_cli_bin=str(bins / "kiro-cli"), + assets_dir=default_chat_assets_dir(), + open_browser=False, +) diff --git a/examples/personal-workspace-browser/goal-workspace-todo-backend.py b/examples/personal-workspace-browser/goal-workspace-todo-backend.py index 508bdf46cf..1f875610f6 100644 --- a/examples/personal-workspace-browser/goal-workspace-todo-backend.py +++ b/examples/personal-workspace-browser/goal-workspace-todo-backend.py @@ -26,7 +26,7 @@ server.registry_path, server.runtime_root, server.runtime_root_override = registry, runtime, str(runtime) server.selected_goal_id, server.scan_roots, server.limit = "progress-projection", [], 20 server.action_store = ChatActionStore(root / "actions") -server.action_service = ChatActionService(store=server.action_store, registry_path=registry) +server.action_service = ChatActionService(store=server.action_store, registry_path=registry, runtime_root_override=str(runtime)) server.verbose = False signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) print(json.dumps({"port": server.server_port, "registry": str(registry)}), flush=True) diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 6b97a3bf34..d8efa87704 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -232,11 +232,19 @@ def __init__( chat_store: ChatSessionStore | None = None, runtime_controller: Any | None = None, workspace_roots: Sequence[Path] = (), + runtime_root_override: str | Path | None = None, ) -> None: self.store = store self.registry_path = Path(registry_path).expanduser().resolve() self.chat_store = chat_store self.runtime_controller = runtime_controller + # The runtime root the serving Chat selected (its --runtime-root). A + # creation on a registry that does not exist yet has no recorded root + # to follow, so the first Goal must persist where the service already + # keeps its own state, never in an implicit default beside it. + self.runtime_root_override = ( + str(runtime_root_override) if runtime_root_override else None + ) self.workspace_roots = tuple( Path(root).expanduser().resolve() for root in workspace_roots ) @@ -295,8 +303,18 @@ def _goal(self, goal_id: str) -> dict[str, Any]: return goal def _registry_fingerprint(self) -> str: + """Identity of the registry a proposal was previewed against. + + A fresh install has no registry until its first Goal is created. That + absence is a stable fact to fingerprint, so the first creation can be + reviewed and confirmed from the page; a registry that appears between + preview and apply still reads as a change and asks for a new review. + """ + try: content = self.registry_path.read_bytes() + except FileNotFoundError: + return "registry-absent" except OSError as exc: raise ValueError("the active LoopX registry is unavailable") from exc return hashlib.sha256(content).hexdigest() @@ -654,7 +672,9 @@ def require_first_turn_readback(turn: dict[str, Any]) -> None: }, ) registry = self._registry() - runtime_root = resolve_runtime_root(registry, registry_path=self.registry_path).resolve() + runtime_root = resolve_runtime_root( + registry, self.runtime_root_override, registry_path=self.registry_path + ).resolve() objective = str(parameters.get("objective") or parameters["title"]) if existing_goal is not None: # Registry publication precedes storage initialization. Resume the diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 488818eb06..5d1b8d70b3 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1763,6 +1763,7 @@ def _lark_snapshot(): chat_store=server.chat_store, runtime_controller=server.runtime_controller, workspace_roots=resolved_scan_roots, + runtime_root_override=server.runtime_root_override, ) private_transport.core.actions = server.action_service # An admitted steward team preview is projected into the typed action store, diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py index 9f7dbb3276..8a1fa13a28 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -89,6 +89,27 @@ def _discovery_registry(registry_path: Path) -> dict[str, Any]: return {} +def _discovery_contract(registry_path: Path) -> dict[str, Any]: + """The source facts a discovery read can state without opening runtime state. + + A fresh install has no registry until the first Goal is created. The full + status reports that as a warning (allow_missing_registry); the directory + states the same fact in the same words, so the first-Goal home can show why + the workspace is empty. The caller redacts paths like every status response. + """ + + warnings: list[str] = [] + if not registry_path.exists(): + warnings.append(f"registry file does not exist: {registry_path}") + return { + "ok": True, + "summary": {"errors": 0, "warnings": len(warnings), "checks": 0}, + "errors": [], + "warnings": warnings, + "checks": [], + } + + def _status_access_denied(error: BaseException) -> bool: """Recognize permission causes without reclassifying unrelated I/O failures.""" current: BaseException | None = error @@ -185,6 +206,20 @@ def _status(self, *, delivery_review: bool = False) -> None: registry, selected_goal_id=goal_id ) if views == ["workspace-directory"]: + directory["contract"] = _discovery_contract(self.server.registry_path) + # Discovery stays metadata-only: redact with the roots this + # server knows, without requiring the full runtime wiring. + runtime_root = getattr(self.server, "runtime_root", None) + directory = json.loads( + redact_local_paths( + json.dumps(directory, ensure_ascii=False), + protected_paths=[ + self.server.registry_path, + *(getattr(self.server, "scan_roots", None) or []), + *([runtime_root] if runtime_root else []), + ], + ) + ) self._send_json(directory) return projection = collect_status( diff --git a/tests/test_chat_goal_workspace_selection.py b/tests/test_chat_goal_workspace_selection.py index b7b8fec54d..49ed2b8d73 100644 --- a/tests/test_chat_goal_workspace_selection.py +++ b/tests/test_chat_goal_workspace_selection.py @@ -240,3 +240,125 @@ def request(path, body=None): server.shutdown() server.server_close() thread.join(timeout=5) + + +def test_fresh_install_previews_its_first_goal_before_any_registry_exists(tmp_path): + # An installed LoopX has no registry until the first Goal is created, and + # the first-Goal home invites exactly that creation. The proposal must be + # reviewable then: the absent registry is a stable fact to fingerprint, a + # registry that appears afterwards reads as a change, and an unreadable + # registry still refuses. + project = tmp_path / "project" + project.mkdir() + registry = project / ".loopx" / "registry.json" + service = ChatActionService(store=ChatActionStore(tmp_path / "actions"), + registry_path=registry, workspace_roots=[project]) + + proposal = preview(service, "current", key="first") + + assert proposal["action_kind"] == "goal.create" + assert proposal["workspace_binding"]["label"] == "project" + assert proposal["expected_state_fingerprint"] == "registry-absent" + assert service._registry_fingerprint() == "registry-absent", "still absent at apply time: the review stands" + registry.parent.mkdir() + registry.write_text(json.dumps({"schema_version": 1, "goals": []})) + assert service._registry_fingerprint() != "registry-absent", "a registry that appeared in between is a change" + service.registry_path = registry.parent # a directory, not a file: unreadable, not absent + with pytest.raises(ValueError, match="registry is unavailable"): + service._registry_fingerprint() + + +def test_first_creation_persists_under_the_serving_chat_runtime_root(tmp_path, monkeypatch): + # A fresh install has no registry, so a creation has no recorded runtime + # root to follow. It must persist where the serving Chat keeps its own + # state (its selected runtime root), never in the implicit default beside + # it; replaying the original operation keeps that root, and a registry + # that appears between preview and apply goes stale instead of applying. + import http.client + import runpy + import threading + from pathlib import Path + + from loopx import paths + from loopx.capabilities.native_chat.project_context import ChatProjectContexts + from loopx.chat_actions import goal_workspace_ref + from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + from loopx.chat_store import ChatSessionStore + + implicit_default = tmp_path / "implicit-default" + monkeypatch.setattr(paths, "select_default_runtime_root", lambda: implicit_default) + selected = tmp_path / "selected-runtime" + project = tmp_path / "project" + project.mkdir() + registry = project / ".loopx" / "registry.json" + fixture = runpy.run_path(str(Path(__file__).resolve().parents[1] / "examples/loopx-chat-actions-smoke.py")) + sessions = ChatSessionStore(selected) + controller = fixture["FakeRuntimeController"](sessions) + controller.project_contexts = ChatProjectContexts([project]) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path, server.runtime_root, server.runtime_root_override = registry, selected, str(selected) + server.action_store = ChatActionStore(selected / "chat/actions") + server.action_service = ChatActionService(store=server.action_store, registry_path=registry, + chat_store=sessions, runtime_controller=controller, workspace_roots=[project], + runtime_root_override=str(selected)) + server.chat_store, server.runtime_controller, server.verbose = sessions, controller, False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def request(path, body=None): + connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=30) + try: + connection.request("GET" if body is None else "POST", path, + None if body is None else json.dumps(body), {"Content-Type": "application/json"}) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + def create(key, goal_id): + return {"action_kind": "goal.create", "summary": "Create a Goal", "idempotency_key": key, + "context": {"kind": "manager", "goal_id": None}, + "normalized_parameters": {"goal_id": goal_id, "title": "First Goal", "objective": "Start here", + "agent_id": "codex", "workspace_ref": goal_workspace_ref(project)}} + + def entries(root): + return sorted(str(path.relative_to(root)) for path in root.rglob("*")) if root.exists() else [] + + try: + assert not registry.exists() + status, previewed = request("/api/actions/preview", create("first", "goal-first")) + assert status == 201, previewed + assert previewed["proposal"]["expected_state_fingerprint"] == "registry-absent" + proposal_id = previewed["proposal"]["proposal_id"] + status, applied = request(f"/api/actions/{proposal_id}/apply", {}) + assert status in {200, 202}, applied + + recorded = json.loads(registry.read_text()) + assert [goal["id"] for goal in recorded["goals"]] == ["goal-first"] + assert Path(str(recorded["common_runtime_root"])).resolve() == selected.resolve(), recorded + assert any("goal-first" in entry for entry in entries(selected)), "canonical Goal state lives under the selected root" + assert entries(implicit_default) == [], "nothing lands in the implicit default runtime root" + + # Replaying the original operation keeps one Goal under the same root. + status, replayed = request(f"/api/actions/{proposal_id}/apply", {}) + assert status in {200, 202, 409}, replayed + again = json.loads(registry.read_text()) + assert [goal["id"] for goal in again["goals"]] == ["goal-first"] + assert Path(str(again["common_runtime_root"])).resolve() == selected.resolve() + assert entries(implicit_default) == [] + + # A registry that appears between preview and apply is a change: the + # proposal goes stale rather than creating against stale facts. + registry.unlink() + status, second = request("/api/actions/preview", create("second", "goal-second")) + assert status == 201, second + assert second["proposal"]["expected_state_fingerprint"] == "registry-absent" + registry.write_text(json.dumps(recorded), encoding="utf-8") + status, stale = request(f"/api/actions/{second['proposal']['proposal_id']}/apply", {}) + assert stale["proposal"]["status"] != "applied", stale + assert [goal["id"] for goal in json.loads(registry.read_text())["goals"]] == ["goal-first"] + assert entries(implicit_default) == [] + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 9a13d17088..5cf8c447db 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -500,6 +500,14 @@ def test_workspace_directory_reports_a_missing_registry_as_an_empty_workspace(tm assert payload["ok"] is True assert payload["schema_version"] == "loopx_workspace_directory_v1" assert payload["goals"] == [] + # The discovery view states the source fact in the full status's own + # words, with the registry path redacted, so the first-Goal home can + # show why the workspace is empty without a local path. + assert payload["contract"]["ok"] is True + assert payload["contract"]["warnings"] == ["registry file does not exist: [project]"] + assert payload["contract"]["summary"] == {"errors": 0, "warnings": 1, "checks": 0} + assert payload["contract"]["errors"] == [] + assert str(tmp_path) not in json.dumps(payload) finally: server.shutdown() thread.join(timeout=5) @@ -681,3 +689,22 @@ def fail(*_args, **_kwargs): server.shutdown() thread.join(timeout=5) server.server_close() + + +def test_workspace_directory_states_no_warning_for_a_present_registry(tmp_path) -> None: + registry_path = tmp_path / ".loopx" / "registry.json" + registry_path.parent.mkdir(parents=True) + registry_path.write_text(json.dumps({"goals": []}), encoding="utf-8") + server, thread = _start_server() + server.registry_path = registry_path + try: + response = _request(server.server_address[1], method="GET", origin=None, path="/status.json?view=workspace-directory") + payload = json.loads(response.read().decode("utf-8")) + assert response.status == 200 + assert payload["goals"] == [] + assert payload["contract"]["warnings"] == [] + assert payload["contract"]["summary"]["warnings"] == 0 + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close()