Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -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"),
Expand All @@ -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<typeof directorySchema>;
export type WorkspaceLoadError = "timeout" | "network" | "service" | "access" | "revision" | "scope" | "invalid";
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
143 changes: 143 additions & 0 deletions examples/personal-workspace-browser/first-goal-home-live.mjs
Original file line number Diff line number Diff line change
@@ -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); } }
}
},
};
3 changes: 3 additions & 0 deletions examples/personal-workspace-browser/fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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; },
Expand All @@ -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;
}
Expand Down
56 changes: 56 additions & 0 deletions examples/personal-workspace-browser/fresh-install-backend.py
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading