From 4a4a01b730225f1bfd75b634673d322115e8a08b Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 22:48:26 +0900 Subject: [PATCH 01/11] feat: add repeatable Web UX inspection workbench --- .gitignore | 2 + devshell.nix | 4 + tools/web-ux/README.md | 172 ++++++ .../browser-tests/capture_smoke_test.ts | 99 ++++ tools/web-ux/browser-tests/fixture_server.ts | 17 + tools/web-ux/cli.ts | 163 ++++++ tools/web-ux/deno.json | 19 + tools/web-ux/deno.lock | 68 +++ tools/web-ux/scenarios/anonymous-entry.json | 28 + .../scenarios/workspace-control-plane.json | 70 +++ tools/web-ux/src/artifacts.ts | 78 +++ tools/web-ux/src/capture.ts | 540 ++++++++++++++++++ tools/web-ux/src/compare.ts | 173 ++++++ tools/web-ux/src/lifecycle.ts | 114 ++++ tools/web-ux/src/processes.ts | 175 ++++++ tools/web-ux/src/scenario.ts | 270 +++++++++ tools/web-ux/src/types.ts | 116 ++++ tools/web-ux/tests/cli_test.ts | 26 + tools/web-ux/tests/lifecycle_test.ts | 75 +++ tools/web-ux/tests/scenario_test.ts | 98 ++++ 20 files changed, 2307 insertions(+) create mode 100644 tools/web-ux/README.md create mode 100644 tools/web-ux/browser-tests/capture_smoke_test.ts create mode 100644 tools/web-ux/browser-tests/fixture_server.ts create mode 100644 tools/web-ux/cli.ts create mode 100644 tools/web-ux/deno.json create mode 100644 tools/web-ux/deno.lock create mode 100644 tools/web-ux/scenarios/anonymous-entry.json create mode 100644 tools/web-ux/scenarios/workspace-control-plane.json create mode 100644 tools/web-ux/src/artifacts.ts create mode 100644 tools/web-ux/src/capture.ts create mode 100644 tools/web-ux/src/compare.ts create mode 100644 tools/web-ux/src/lifecycle.ts create mode 100644 tools/web-ux/src/processes.ts create mode 100644 tools/web-ux/src/scenario.ts create mode 100644 tools/web-ux/src/types.ts create mode 100644 tools/web-ux/tests/cli_test.ts create mode 100644 tools/web-ux/tests/lifecycle_test.ts create mode 100644 tools/web-ux/tests/scenario_test.ts diff --git a/.gitignore b/.gitignore index fe1edfae..0b737840 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ .worktree *.local* .env +.web-ux/ +artifacts/web-ux/ diff --git a/devshell.nix b/devshell.nix index db8ec581..8364784b 100644 --- a/devshell.nix +++ b/devshell.nix @@ -4,11 +4,15 @@ pkgs.mkShell { nixfmt deno git + playwright-driver.browsers rustc cargo pkgs.sccache ]; + PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1"; + # sccache is additive to Cargo's shared build-dir, so keep its disk usage bounded. RUSTC_WRAPPER = "${pkgs.sccache}/bin/sccache"; SCCACHE_CACHE_SIZE = "5G"; diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md new file mode 100644 index 00000000..cabcf9ef --- /dev/null +++ b/tools/web-ux/README.md @@ -0,0 +1,172 @@ +# Web UX inspection workbench + +`tools/web-ux` is a development-only Playwright workbench for repeatable visual inspection of the +real Web Workspace. It does not add a Yoi product Skill, Flow, Runtime capability, or browser +automation route. + +The workbench produces a **review context bundle** rather than treating a screenshot as evidence by +itself. Every capture records the persona, route, viewport, theme, intended user goal, expected data +state, sanitized document URL/status, console/page/request failures, screenshot hashes, an +accessibility snapshot, source revision, and browser version. + +## Environment + +Enter the repository dev shell. The shell supplies the Nix-pinned Chromium build and sets +`PLAYWRIGHT_BROWSERS_PATH`; Playwright does not download a browser at runtime. + +```sh +nix develop +cd tools/web-ux +deno task check +deno task test +deno task test:browser +``` + +`test:browser` starts a deterministic fixture server owned by the test, captures distinct owner and +non-owner contexts, verifies the review bundle, and proves server/browser cleanup. It must run +inside `nix develop` so it uses the pinned browser. + +The npm Playwright version in `deno.json` must match `pkgs.playwright-driver.version` in the pinned +Nixpkgs input. Update both as one toolchain change. + +## Scenario contract + +Scenarios are reviewed JSON files under `scenarios/`. A scenario fixes: + +- personas and whether each uses an isolated anonymous context or a local Playwright storage-state + file; +- explicit routes and user goals; +- expected data state, viewports, theme, locale, timezone, and reduced-motion mode; +- an explicit readiness condition for every route and optional interaction/capture-point conditions; +- selectors and exact environment-derived text that must be redacted; +- optional processes owned by the capture command, including an HTTP readiness URL. + +`${UPPER_CASE_ENV}` values are expanded at runtime. URLs with embedded credentials are rejected. +Route readiness is bounded and retried twice; it never relies on a fixed sleep. `network-idle` is +available but should be used only for screens whose contract actually reaches idle. Prefer a stable +screen-owned selector. + +`workspace-control-plane.json` expects: + +```sh +export WEB_UX_BASE_URL='http://127.0.0.1:5173' +export WORKSPACE_ID='' +``` + +## Authentication fixtures + +Authentication state is local sensitive material. `.web-ux/` is gitignored, files are written with +mode `0600`, state contents are never copied into a review bundle, and the CLI never prints cookies +or credentials. + +For an interactive Passkey/browser login: + +```sh +deno task web-ux auth \ + --scenario scenarios/workspace-control-plane.json \ + --persona owner +``` + +The command opens Chromium at the configured login route, waits up to five minutes for the +scenario's success URL, saves `storageState`, and closes the browser in `finally`. Repeat for +`non-owner` using a real account with that permission projection. + +A test fixture may already provide Playwright-compatible `{ cookies, origins }` state. Import it +without putting its value on the command line: + +```sh +deno task web-ux auth \ + --scenario scenarios/workspace-control-plane.json \ + --persona owner \ + --import-state /private/path/owner-state.json +``` + +Do not place passwords, bearer tokens, private keys, WebAuthn material, or inline cookies in a +scenario, process arguments, a Repository URL, or `redact.text`. `redact.text` is only a final +defense for a secret already supplied through an environment-owned fixture; it is not a credential +transport. + +## Capture and inspect + +Capture a stable multi-persona bundle: + +```sh +deno task web-ux capture \ + --scenario scenarios/workspace-control-plane.json \ + --output ../../artifacts/web-ux \ + --run-id before-change +``` + +Use filters for a bounded feedback loop: + +```sh +deno task web-ux capture \ + --scenario scenarios/workspace-control-plane.json \ + --output ../../artifacts/web-ux \ + --run-id ticket-list-after \ + --personas owner,non-owner \ + --routes tickets \ + --viewports desktop +``` + +The command exits `2` when it produced evidence but observed UI/tool errors, and exits `1` when +capture itself failed. It continues other route/persona captures after a bounded route failure. +Inspect: + +- `review-context.json` for the exact context, hashes, HTTP status, and failures; +- `contact-sheet.png` with an image-capable reviewer for composition, hierarchy, density, clipping, + empty/error states, and permission-specific affordances; +- each `accessibility.md` for landmark/name/state evidence that a screenshot cannot prove; +- `process-logs/` when the scenario owns a server process. Logs are redacted before writing. + +The implementing agent must inspect the actual contact sheet (for example with `ViewImage`), record +concrete findings, fix them, recapture under the same persona/route/viewport filters, and inspect +the new evidence. Playwright success alone is not visual acceptance. + +## Compare before and after + +```sh +deno task web-ux compare \ + --before ../../artifacts/web-ux/before-change/review-context.json \ + --after ../../artifacts/web-ux/after-change/review-context.json \ + --output ../../artifacts/web-ux/before-vs-after +``` + +`comparison.html` and `comparison.png` show before, after, and pixel diff side by side. +`comparison.json` records changed-pixel counts, dimension mismatches, unmatched capture keys, and +diff hashes. Pixel differences are orientation evidence, not a correctness verdict; explain expected +animation/font/data changes and inspect the actual UI. + +Capture keys are stable across runs: `persona / route / viewport / capture-point`. Keep those +identities unchanged when comparing the same user task. + +## Process and artifact cleanup + +The capture command owns only processes declared in its scenario. It starts them without a shell, +records bounded/redacted output, and terminates the process and descendants on success, capture +failure, or interruption observed by the command. It never stops an existing Yoi Server or Runtime +that it did not start. + +Old complete review bundles can be removed without touching auth state or arbitrary directories: + +```sh +deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14 --dry-run +deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14 +``` + +Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and +`artifacts/web-ux/` are ignored by Git. Keep a bundle outside Git or publish it through the approved +immutable artifact channel when durable review evidence is required. + +## Adding a scenario + +1. Name the concrete user task and expected data state; do not write “looks correct”. +2. Use the smallest persona/route/viewport matrix that proves the intended contract, including + owner/non-owner/anonymous boundaries when permissions affect composition. +3. Choose a screen-owned readiness selector or response. Avoid arbitrary sleeps. +4. Add capture points only for meaningful visual states (initial, expanded detail, error, empty, and + so on). +5. Mark sensitive DOM regions with `[data-web-ux-redact]` or scenario selectors; never use review + artifacts to transport secrets. +6. Run `deno task check`, `deno task test`, one real capture, and inspect `contact-sheet.png` plus + `review-context.json`. diff --git a/tools/web-ux/browser-tests/capture_smoke_test.ts b/tools/web-ux/browser-tests/capture_smoke_test.ts new file mode 100644 index 00000000..8b655495 --- /dev/null +++ b/tools/web-ux/browser-tests/capture_smoke_test.ts @@ -0,0 +1,99 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { join } from "@std/path"; +import { capture } from "../src/capture.ts"; + +async function freePort(): Promise { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + return port; +} + +Deno.test("browser smoke captures distinct owner and non-owner evidence and cleans its server", async () => { + const directory = await Deno.makeTempDir(); + const port = await freePort(); + const baseUrl = `http://127.0.0.1:${port}`; + try { + const authDirectory = join(directory, "auth"); + await Deno.mkdir(authDirectory); + for (const persona of ["owner", "non-owner"]) { + await Deno.writeTextFile( + join(authDirectory, `${persona}.json`), + JSON.stringify({ + cookies: [{ + name: "persona", + value: persona, + domain: "127.0.0.1", + path: "/", + expires: -1, + httpOnly: true, + secure: false, + sameSite: "Lax", + }], + origins: [], + }), + ); + } + const scenarioPath = join(directory, "scenario.json"); + await Deno.writeTextFile( + scenarioPath, + JSON.stringify({ + schemaVersion: 1, + id: "browser-smoke", + title: "Browser smoke", + baseUrl, + personas: [ + { id: "owner", label: "Owner", auth: { kind: "storage-state", path: "auth/owner.json" } }, + { + id: "non-owner", + label: "Non-owner", + auth: { kind: "storage-state", path: "auth/non-owner.json" }, + }, + ], + viewports: [{ label: "desktop", width: 1000, height: 700 }], + routes: [{ + id: "repositories", + label: "Repositories", + path: "/screen", + goal: "Verify permission-specific composition", + dataState: "Deterministic fixture repository", + ready: { kind: "selector", selector: "main" }, + capturePoints: [{ id: "initial", label: "Initial" }], + }], + processes: [{ + id: "fixture-server", + command: Deno.execPath(), + args: [ + "run", + "--allow-net", + join(Deno.cwd(), "browser-tests/fixture_server.ts"), + String(port), + ], + readyUrl: `${baseUrl}/health`, + }], + }), + ); + const manifest = await capture({ + scenarioPath, + outputDirectory: join(directory, "artifacts"), + runId: "multi-persona", + }); + assertEquals(manifest.status, "completed"); + assertEquals(manifest.captures.map((item) => item.persona.id), ["owner", "non-owner"]); + assertEquals(manifest.captures.every((item) => item.screenshots.length === 1), true); + assertEquals(manifest.contactSheet.png, "contact-sheet.png"); + const runDirectory = join(directory, "artifacts", "multi-persona"); + const reviewContext = await Deno.readTextFile(join(runDirectory, "review-context.json")); + assertEquals(reviewContext.includes('"cookies"'), false); + if (Deno.build.os !== "windows") { + const screenshot = join(runDirectory, manifest.captures[0].screenshots[0].path); + assertEquals((await Deno.stat(screenshot)).mode! & 0o777, 0o600); + } + await assertRejects( + () => fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(500) }), + TypeError, + ); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); diff --git a/tools/web-ux/browser-tests/fixture_server.ts b/tools/web-ux/browser-tests/fixture_server.ts new file mode 100644 index 00000000..7aa607f6 --- /dev/null +++ b/tools/web-ux/browser-tests/fixture_server.ts @@ -0,0 +1,17 @@ +const port = Number(Deno.args[0]); +if (!Number.isInteger(port) || port <= 0) throw new Error("port is required"); + +Deno.serve({ hostname: "127.0.0.1", port }, (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") return new Response("ok"); + const cookie = request.headers.get("cookie") ?? ""; + const owner = cookie.includes("persona=owner"); + const title = owner ? "Owner repository settings" : "Repository settings"; + const action = owner + ? '' + : '

Ask a Workspace owner to change repository access.

'; + return new Response( + `${title}
Workspace

${title}

main

SSH repository access is configured.

${action}
`, + { headers: { "content-type": "text/html; charset=utf-8" } }, + ); +}); diff --git a/tools/web-ux/cli.ts b/tools/web-ux/cli.ts new file mode 100644 index 00000000..6351642a --- /dev/null +++ b/tools/web-ux/cli.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env -S deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys +import { authenticate, cleanup } from "./src/lifecycle.ts"; +import { capture, describeCapture } from "./src/capture.ts"; +import { compare } from "./src/compare.ts"; + +const HELP = `Web UX inspection workbench + +Usage: + deno task web-ux auth --scenario --persona [--base-url ] [--import-state ] [--headless] + deno task web-ux capture --scenario --output [--base-url ] [--run-id ] [--personas ] [--routes ] [--viewports ] [--headed] + deno task web-ux compare --before --after --output [--threshold <0..1>] + deno task web-ux cleanup --output [--keep ] [--older-than-days ] [--dry-run] + +Comma-separate persona, route, and viewport ids. Auth state is local, mode 0600, and must not be committed. +`; + +type Arguments = { command: string; values: Map; flags: Set }; + +export function parseArguments(args: string[]): Arguments { + const command = args.shift() ?? "help"; + const values = new Map(); + const flags = new Set(); + for (let index = 0; index < args.length; index++) { + const token = args[index]; + if (!token.startsWith("--")) throw new Error(`unexpected argument: ${token}`); + const name = token.slice(2); + const next = args[index + 1]; + if (next === undefined || next.startsWith("--")) { + flags.add(name); + } else { + const items = values.get(name) ?? []; + items.push(next); + values.set(name, items); + index++; + } + } + return { command, values, flags }; +} + +function optional(args: Arguments, name: string): string | undefined { + const values = args.values.get(name); + if (!values) return undefined; + if (values.length !== 1) throw new Error(`--${name} must be specified once`); + return values[0]; +} + +function required(args: Arguments, name: string): string { + const value = optional(args, name); + if (!value) throw new Error(`--${name} is required`); + return value; +} + +function integer(args: Arguments, name: string, fallback?: number): number | undefined { + const value = optional(args, name); + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`--${name} must be a non-negative integer`); + } + return parsed; +} + +function list(args: Arguments, name: string): string[] | undefined { + const value = optional(args, name); + return value?.split(",").map((item) => item.trim()).filter(Boolean); +} + +function rejectUnknown(args: Arguments, allowedValues: string[], allowedFlags: string[]): void { + for (const name of args.values.keys()) { + if (!allowedValues.includes(name)) throw new Error(`unsupported option: --${name}`); + } + for (const name of args.flags) { + if (!allowedFlags.includes(name)) throw new Error(`unsupported flag: --${name}`); + } +} + +export async function main(rawArgs: string[]): Promise { + const args = parseArguments([...rawArgs]); + if (args.command === "help" || args.flags.has("help")) { + console.log(HELP); + return 0; + } + if (args.command === "auth") { + rejectUnknown(args, ["scenario", "persona", "base-url", "import-state", "timeout-ms"], [ + "headless", + ]); + const path = await authenticate({ + scenarioPath: required(args, "scenario"), + personaId: required(args, "persona"), + baseUrl: optional(args, "base-url"), + importState: optional(args, "import-state"), + timeoutMs: integer(args, "timeout-ms"), + headless: args.flags.has("headless"), + }); + console.log(`auth state saved: ${path}`); + return 0; + } + if (args.command === "capture") { + rejectUnknown(args, [ + "scenario", + "output", + "base-url", + "run-id", + "personas", + "routes", + "viewports", + ], ["headed"]); + const outputDirectory = required(args, "output"); + const manifest = await capture({ + scenarioPath: required(args, "scenario"), + outputDirectory, + baseUrl: optional(args, "base-url"), + runId: optional(args, "run-id"), + personas: list(args, "personas"), + routes: list(args, "routes"), + viewports: list(args, "viewports"), + headed: args.flags.has("headed"), + }); + console.log(describeCapture(manifest, outputDirectory)); + return manifest.status === "completed" ? 0 : 2; + } + if (args.command === "compare") { + rejectUnknown(args, ["before", "after", "output", "threshold"], []); + const thresholdValue = optional(args, "threshold"); + const threshold = thresholdValue === undefined ? undefined : Number(thresholdValue); + if ( + threshold !== undefined && (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) + ) { + throw new Error("--threshold must be between 0 and 1"); + } + const report = await compare({ + before: required(args, "before"), + after: required(args, "after"), + outputDirectory: required(args, "output"), + threshold, + }); + console.log(`comparison saved: ${report}`); + return 0; + } + if (args.command === "cleanup") { + rejectUnknown(args, ["output", "keep", "older-than-days"], ["dry-run"]); + const removed = await cleanup({ + outputDirectory: required(args, "output"), + keep: integer(args, "keep", 5)!, + olderThanDays: integer(args, "older-than-days"), + dryRun: args.flags.has("dry-run"), + }); + for (const path of removed) { + console.log(`${args.flags.has("dry-run") ? "would remove" : "removed"}: ${path}`); + } + return 0; + } + throw new Error(`unknown command: ${args.command}\n\n${HELP}`); +} + +if (import.meta.main) { + try { + Deno.exit(await main(Deno.args)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + Deno.exit(1); + } +} diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json new file mode 100644 index 00000000..7bf2388b --- /dev/null +++ b/tools/web-ux/deno.json @@ -0,0 +1,19 @@ +{ + "lock": true, + "imports": { + "@std/assert": "jsr:@std/assert@1.0.19", + "@std/path": "jsr:@std/path@1.1.4", + "pixelmatch": "npm:pixelmatch@7.1.0", + "playwright": "npm:playwright@1.59.1", + "pngjs": "npm:pngjs@7.0.0" + }, + "tasks": { + "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", + "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", + "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts" + }, + "fmt": { + "lineWidth": 100 + } +} diff --git a/tools/web-ux/deno.lock b/tools/web-ux/deno.lock new file mode 100644 index 00000000..2dee45f5 --- /dev/null +++ b/tools/web-ux/deno.lock @@ -0,0 +1,68 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1.0.19": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/path@1.1.4": "1.1.4", + "npm:pixelmatch@7.1.0": "7.1.0", + "npm:playwright@1.59.1": "1.59.1", + "npm:pngjs@7.0.0": "7.0.0" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "npm": { + "fsevents@2.3.2": { + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "os": ["darwin"], + "scripts": true + }, + "pixelmatch@7.1.0": { + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dependencies": [ + "pngjs" + ], + "bin": true + }, + "playwright-core@1.59.1": { + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "bin": true + }, + "playwright@1.59.1": { + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dependencies": [ + "playwright-core" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, + "pngjs@7.0.0": { + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1.0.19", + "jsr:@std/path@1.1.4", + "npm:pixelmatch@7.1.0", + "npm:playwright@1.59.1", + "npm:pngjs@7.0.0" + ] + } +} diff --git a/tools/web-ux/scenarios/anonymous-entry.json b/tools/web-ux/scenarios/anonymous-entry.json new file mode 100644 index 00000000..5eda5fe6 --- /dev/null +++ b/tools/web-ux/scenarios/anonymous-entry.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "id": "anonymous-entry", + "title": "Anonymous entry and authentication review", + "baseUrl": "${WEB_UX_BASE_URL}", + "locale": "en-US", + "timezone": "UTC", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { "id": "anonymous", "label": "Anonymous visitor", "auth": { "kind": "anonymous" } } + ], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 1000, "deviceScaleFactor": 1 }, + { "label": "mobile", "width": 390, "height": 844, "deviceScaleFactor": 1 } + ], + "routes": [ + { + "id": "entry", + "label": "Authentication entry", + "path": "/", + "goal": "Understand the product and begin authentication without seeing Workspace-private content.", + "dataState": "Fresh browser context with no cookies, local storage, or session state.", + "ready": { "kind": "network-idle", "timeoutMs": 15000 }, + "capturePoints": [{ "id": "initial", "label": "Anonymous entry", "fullPage": true }] + } + ] +} diff --git a/tools/web-ux/scenarios/workspace-control-plane.json b/tools/web-ux/scenarios/workspace-control-plane.json new file mode 100644 index 00000000..81da7d09 --- /dev/null +++ b/tools/web-ux/scenarios/workspace-control-plane.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 1, + "id": "workspace-control-plane", + "title": "Workspace control-plane owner and non-owner review", + "baseUrl": "${WEB_UX_BASE_URL}", + "locale": "en-US", + "timezone": "UTC", + "colorScheme": "light", + "reducedMotion": "reduce", + "redact": { + "selectors": ["[data-web-ux-redact]", "input[type=password]"], + "text": [] + }, + "personas": [ + { + "id": "owner", + "label": "Workspace owner", + "auth": { "kind": "storage-state", "path": "../../../.web-ux/auth/owner.json" }, + "login": { "path": "/", "successUrl": "/w/" } + }, + { + "id": "non-owner", + "label": "Authenticated non-owner", + "auth": { "kind": "storage-state", "path": "../../../.web-ux/auth/non-owner.json" }, + "login": { "path": "/", "successUrl": "/w/" } + } + ], + "viewports": [ + { "label": "desktop", "width": 1440, "height": 1000, "deviceScaleFactor": 1 }, + { "label": "narrow", "width": 900, "height": 900, "deviceScaleFactor": 1 } + ], + "routes": [ + { + "id": "workspace-home", + "label": "Workspace overview", + "path": "/w/${WORKSPACE_ID}", + "goal": "Orient the user and expose the highest-value Workspace actions without internal authority noise.", + "dataState": "Dogfood Workspace with current Runtime and Ticket data.", + "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, + "capturePoints": [{ "id": "initial", "label": "Initial viewport", "fullPage": true }] + }, + { + "id": "tickets", + "label": "Ticket lanes", + "path": "/w/${WORKSPACE_ID}/tickets", + "goal": "Scan actionable Ticket lanes and reach the primary authoring action in the initial viewport.", + "dataState": "Planning, ready, queued, in-progress, and completed Tickets from the selected Workspace.", + "ready": { "kind": "selector", "selector": "main", "timeoutMs": 30000 }, + "capturePoints": [{ "id": "initial", "label": "Loaded Ticket lanes", "fullPage": true }] + }, + { + "id": "workers", + "label": "Workers", + "path": "/w/${WORKSPACE_ID}/workers", + "goal": "Find current Worker state and the new-Worker action without exposing transport internals as the primary content.", + "dataState": "Current Workspace Worker projection with mixed lifecycle states.", + "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, + "capturePoints": [{ "id": "initial", "label": "Worker list", "fullPage": true }] + }, + { + "id": "repositories", + "label": "Repository settings", + "path": "/w/${WORKSPACE_ID}/settings/repositories", + "goal": "Review repository access as an owner and verify non-owner composition does not expose unusable owner actions.", + "dataState": "Workspace repository catalog projected through current permissions.", + "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, + "capturePoints": [{ "id": "initial", "label": "Repository settings", "fullPage": true }] + } + ] +} diff --git a/tools/web-ux/src/artifacts.ts b/tools/web-ux/src/artifacts.ts new file mode 100644 index 00000000..ebfbc178 --- /dev/null +++ b/tools/web-ux/src/artifacts.ts @@ -0,0 +1,78 @@ +import { dirname, relative, resolve } from "@std/path"; + +const SECRET_PATTERNS: RegExp[] = [ + /\b(authorization|cookie|set-cookie|x-csrf-token)\b\s*[:=]\s*[^\s,;]+/gi, + /\b(bearer)\s+[a-z0-9._~+\/-]+=*/gi, + /\b(session|token|credential|password|passkey|private[_ -]?key)\b\s*[:=]\s*["']?[^\s,"'};]+/gi, +]; + +export function redactText(value: string, exactSecrets: string[] = []): string { + let result = value; + for (const secret of exactSecrets) { + if (secret) result = result.replaceAll(secret, "[REDACTED]"); + } + for (const pattern of SECRET_PATTERNS) result = result.replaceAll(pattern, "$1=[REDACTED]"); + return result; +} + +export function bounded(value: string, maximum = 1000): string { + const normalized = value.replaceAll(/\s+/g, " ").trim(); + return normalized.length <= maximum ? normalized : `${normalized.slice(0, maximum - 1)}…`; +} + +export function safeUrl(value: string, baseUrl?: string): string { + try { + const url = new URL(value, baseUrl); + url.username = ""; + url.password = ""; + for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, "[REDACTED]"); + url.hash = ""; + return url.toString(); + } catch { + return "[invalid-url]"; + } +} + +export function assertBundleIsSecretFree(serialized: string, exactSecrets: string[] = []): void { + const lower = serialized.toLowerCase(); + for (const forbidden of ["authorization:", "set-cookie:", "cookie:", "bearer "]) { + if (lower.includes(forbidden)) { + throw new Error(`review bundle contains forbidden secret marker: ${forbidden}`); + } + } + for (const secret of exactSecrets) { + if (secret && serialized.includes(secret)) { + throw new Error("review bundle contains configured secret text"); + } + } +} + +export async function ensurePrivateDirectory(path: string): Promise { + await Deno.mkdir(path, { recursive: true, mode: 0o700 }); + if (Deno.build.os !== "windows") await Deno.chmod(path, 0o700); +} + +export async function makePrivate(path: string): Promise { + if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600); +} + +export async function writePrivateJson(path: string, value: unknown): Promise { + await ensurePrivateDirectory(dirname(path)); + await Deno.writeTextFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + if (Deno.build.os !== "windows") await Deno.chmod(path, 0o600); +} + +export function logicalPath(repositoryRoot: string, path: string): string { + const absolute = resolve(path); + const logical = relative(repositoryRoot, absolute); + if (logical === "" || (!logical.startsWith("..") && !logical.startsWith("/"))) { + return logical || "."; + } + return absolute; +} + +export async function sha256File(path: string): Promise { + const bytes = await Deno.readFile(path); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/tools/web-ux/src/capture.ts b/tools/web-ux/src/capture.ts new file mode 100644 index 00000000..aed3c52d --- /dev/null +++ b/tools/web-ux/src/capture.ts @@ -0,0 +1,540 @@ +import { basename, dirname, join, relative, resolve } from "@std/path"; +import { type Browser, chromium, type Page, type Response } from "playwright"; +import { + assertBundleIsSecretFree, + bounded, + ensurePrivateDirectory, + makePrivate, + redactText, + safeUrl, + sha256File, +} from "./artifacts.ts"; +import { type RunningProcess, startOwnedProcesses, stopOwnedProcesses } from "./processes.ts"; +import { + interpolateEnvironment, + loadScenario, + resolveScenarioPath, + validateBaseUrl, +} from "./scenario.ts"; +import type { + CaptureError, + CaptureEvidence, + CapturePoint, + Interaction, + Persona, + ReadyCondition, + ReviewContext, + RouteScenario, + Scenario, + ScreenshotEvidence, + Viewport, +} from "./types.ts"; + +export type CaptureOptions = { + scenarioPath: string; + outputDirectory: string; + baseUrl?: string; + runId?: string; + personas?: string[]; + routes?: string[]; + viewports?: string[]; + headed?: boolean; +}; + +type SourceState = { revision: string | null; dirty: boolean | null }; + +function slug(value: string): string { + return value.replaceAll(/[^a-zA-Z0-9.-]+/g, "-").replaceAll(/^-+|-+$/g, "").toLowerCase(); +} + +function viewportId(viewport: Viewport): string { + return viewport.label ?? `${viewport.width}x${viewport.height}`; +} + +function timestampId(): string { + return new Date().toISOString().replaceAll(/[:.]/g, "-"); +} + +async function sourceState(): Promise { + try { + const [revision, status] = await Promise.all([ + new Deno.Command("git", { args: ["rev-parse", "HEAD"], stdout: "piped", stderr: "null" }) + .output(), + new Deno.Command("git", { args: ["status", "--porcelain"], stdout: "piped", stderr: "null" }) + .output(), + ]); + return { + revision: revision.success ? new TextDecoder().decode(revision.stdout).trim() : null, + dirty: status.success ? new TextDecoder().decode(status.stdout).trim().length > 0 : null, + }; + } catch { + return { revision: null, dirty: null }; + } +} + +function selectById( + values: T[], + requested: string[] | undefined, + kind: string, +): T[] { + if (!requested || requested.length === 0) return values; + const requestedSet = new Set(requested); + const selected = values.filter((value) => requestedSet.has(value.id)); + const missing = [...requestedSet].filter((id) => !selected.some((value) => value.id === id)); + if (missing.length > 0) throw new Error(`unknown ${kind}: ${missing.join(", ")}`); + return selected; +} + +function selectViewports(values: Viewport[], requested: string[] | undefined): Viewport[] { + if (!requested || requested.length === 0) return values; + const requestedSet = new Set(requested); + const selected = values.filter((value) => requestedSet.has(viewportId(value))); + const missing = [...requestedSet].filter((id) => + !selected.some((value) => viewportId(value) === id) + ); + if (missing.length > 0) throw new Error(`unknown viewports: ${missing.join(", ")}`); + return selected; +} + +function responseMatches( + response: Response, + ready: Extract, +): boolean { + const pattern = new RegExp(ready.urlPattern); + return pattern.test(response.url()) && + (ready.status === undefined || response.status() === ready.status); +} + +async function waitReady( + page: Page, + ready: ReadyCondition, + navigation?: Response | null, +): Promise { + const timeout = ready.timeoutMs ?? 15_000; + if (ready.kind === "selector") { + await page.locator(ready.selector).first().waitFor({ state: "visible", timeout }); + return; + } + if (ready.kind === "network-idle") { + await page.waitForLoadState("networkidle", { timeout }); + return; + } + if (navigation && responseMatches(navigation, ready)) return; + await page.waitForResponse((response) => responseMatches(response, ready), { timeout }); +} + +async function performInteraction(page: Page, interaction: Interaction): Promise { + if (interaction.action === "wait") return await waitReady(page, interaction.ready); + const locator = page.locator(interaction.selector).first(); + const timeout = interaction.timeoutMs ?? 10_000; + if (interaction.action === "click") return await locator.click({ timeout }); + if (interaction.action === "fill") { + return await locator.fill(interpolateEnvironment(interaction.value), { timeout }); + } + await locator.press(interaction.key, { timeout }); +} + +async function retry(label: string, operation: () => Promise): Promise { + let last: unknown; + for (let attempt = 1; attempt <= 2; attempt++) { + try { + return await operation(); + } catch (error) { + last = error; + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 350)); + } + } + throw new Error( + `${label} failed after 2 attempts: ${last instanceof Error ? last.message : String(last)}`, + ); +} + +async function hideRedactedSelectors(page: Page, selectors: string[]): Promise { + if (selectors.length === 0) return; + const escaped = selectors.join(",\n"); + await page.addStyleTag({ content: `${escaped} { visibility: hidden !important; }` }); +} + +export function isVisibleUiErrorText(content: string): boolean { + return /\b(error|failed|unauthorized|forbidden|not found)\b/i.test(content); +} + +async function collectVisibleUiErrors( + page: Page, + errors: CaptureError[], + secrets: string[], +): Promise { + const alerts = page.locator('[role="alert"], [aria-live="assertive"]'); + for (let index = 0; index < await alerts.count(); index++) { + const alert = alerts.nth(index); + if (!await alert.isVisible().catch(() => false)) continue; + const content = (await alert.innerText().catch(() => "")).trim(); + if (!isVisibleUiErrorText(content)) continue; + const message = `visible UI error: ${bounded(redactText(content, secrets), 500)}`; + if (!errors.some((error) => error.kind === "document" && error.message === message)) { + errors.push({ kind: "document", message }); + } + } +} + +async function capturePoint( + page: Page, + runDirectory: string, + persona: Persona, + route: RouteScenario, + viewport: Viewport, + point: CapturePoint, + documentResponse: Response | null, + errors: CaptureError[], + scenario: Scenario, +): Promise { + const startedAt = new Date().toISOString(); + for (const interaction of point.interaction ?? []) await performInteraction(page, interaction); + if (point.ready) await waitReady(page, point.ready); + await hideRedactedSelectors(page, scenario.redact?.selectors ?? []); + await collectVisibleUiErrors(page, errors, scenario.redact?.text ?? []); + const directory = join( + runDirectory, + "captures", + persona.id, + route.id, + viewportId(viewport), + point.id, + ); + await ensurePrivateDirectory(directory); + const viewportScreenshot = join(directory, "viewport.png"); + await page.screenshot({ path: viewportScreenshot, fullPage: false, animations: "disabled" }); + await makePrivate(viewportScreenshot); + const screenshots: ScreenshotEvidence[] = [{ + kind: "viewport", + path: relative(runDirectory, viewportScreenshot), + sha256: await sha256File(viewportScreenshot), + }]; + if (point.fullPage) { + const fullPageScreenshot = join(directory, "full-page.png"); + await page.screenshot({ path: fullPageScreenshot, fullPage: true, animations: "disabled" }); + await makePrivate(fullPageScreenshot); + screenshots.push({ + kind: "full-page", + path: relative(runDirectory, fullPageScreenshot), + sha256: await sha256File(fullPageScreenshot), + }); + } + let snapshotPath: string | null = null; + try { + const snapshot = await page.locator("body").ariaSnapshot({ timeout: 5_000 }); + const redacted = redactText(snapshot, scenario.redact?.text ?? []); + const target = join(directory, "accessibility.md"); + await Deno.writeTextFile(target, redacted, { mode: 0o600 }); + snapshotPath = relative(runDirectory, target); + } catch (error) { + errors.push({ + kind: "tool", + message: `accessibility snapshot failed: ${ + bounded(error instanceof Error ? error.message : String(error)) + }`, + }); + } + return { + persona: { id: persona.id, label: persona.label }, + route: { id: route.id, path: route.path, goal: route.goal, dataState: route.dataState }, + viewport, + theme: scenario.colorScheme ?? "light", + capturePoint: { id: point.id, label: point.label }, + document: { url: safeUrl(page.url()), status: documentResponse?.status() ?? null }, + screenshots, + snapshotPath, + errors: [...errors], + startedAt, + finishedAt: new Date().toISOString(), + }; +} + +function screenshotDataUrl(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return `data:image/png;base64,${btoa(binary)}`; +} + +function escapeHtml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll( + '"', + """, + ); +} + +async function createContactSheet( + browser: Browser, + runDirectory: string, + captures: CaptureEvidence[], +): Promise<{ html: string | null; png: string | null }> { + const cells: string[] = []; + for (const capture of captures) { + const screenshot = capture.screenshots.find((item) => item.kind === "viewport") ?? + capture.screenshots[0]; + if (!screenshot) continue; + const bytes = await Deno.readFile(join(runDirectory, screenshot.path)); + cells.push( + `
${ + escapeHtml(capture.persona.label) + } · ${escapeHtml(capture.route.id)}
${ + escapeHtml(viewportId(capture.viewport)) + } · ${escapeHtml(capture.capturePoint.label)}
${ + escapeHtml(capture.route.dataState) + }${ + capture.errors.length > 0 + ? `
${capture.errors.length} captured error(s)` + : "" + }
`, + ); + } + if (cells.length === 0) return { html: null, png: null }; + const html = + `Web UX review contact sheet
${ + cells.join("") + }
`; + const htmlPath = join(runDirectory, "contact-sheet.html"); + const pngPath = join(runDirectory, "contact-sheet.png"); + await Deno.writeTextFile(htmlPath, html, { mode: 0o600 }); + const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); + try { + await page.setContent(html, { waitUntil: "load" }); + await page.screenshot({ path: pngPath, fullPage: true, animations: "disabled" }); + await makePrivate(pngPath); + } finally { + await page.close(); + } + return { html: relative(runDirectory, htmlPath), png: relative(runDirectory, pngPath) }; +} + +export async function capture(options: CaptureOptions): Promise { + const scenarioPath = resolve(options.scenarioPath); + const scenario = await loadScenario(scenarioPath); + const baseUrl = validateBaseUrl( + interpolateEnvironment( + options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "", + ), + ); + const personas = selectById(scenario.personas, options.personas, "personas"); + const routes = selectById(scenario.routes, options.routes, "routes"); + const viewports = selectViewports(scenario.viewports, options.viewports); + const runId = slug(options.runId ?? `${scenario.id}-${timestampId()}`); + const runDirectory = resolve(options.outputDirectory, runId); + try { + await Deno.stat(runDirectory); + throw new Error(`run directory already exists: ${runDirectory}`); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + await ensurePrivateDirectory(runDirectory); + const secrets = scenario.redact?.text ?? []; + let browser: Browser | null = null; + let processes: RunningProcess[] = []; + const captures: CaptureEvidence[] = []; + const diagnostics: CaptureError[] = []; + let contactSheet = { html: null as string | null, png: null as string | null }; + let browserVersion = "unknown"; + let status: ReviewContext["status"] = "completed"; + try { + processes = await startOwnedProcesses( + scenario.processes ?? [], + scenarioPath, + join(runDirectory, "process-logs"), + secrets, + ); + browser = await chromium.launch({ headless: !options.headed }); + browserVersion = browser.version(); + for (const persona of personas) { + const storageState = persona.auth.kind === "storage-state" + ? resolveScenarioPath(scenarioPath, persona.auth.path) + : undefined; + if (storageState) await Deno.stat(storageState); + for (const viewport of viewports) { + const context = await browser.newContext({ + storageState, + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: viewport.deviceScaleFactor ?? 1, + locale: scenario.locale, + timezoneId: scenario.timezone, + colorScheme: scenario.colorScheme, + reducedMotion: scenario.reducedMotion, + }); + try { + for (const route of routes) { + const routeErrors: CaptureError[] = []; + const page = await context.newPage(); + page.on("console", (message) => { + if (message.type() === "error") { + routeErrors.push({ + kind: "console", + message: bounded(redactText(message.text(), secrets)), + }); + } + }); + page.on( + "pageerror", + (error) => + routeErrors.push({ + kind: "page", + message: bounded(redactText(error.message, secrets)), + }), + ); + page.on( + "requestfailed", + (request) => + routeErrors.push({ + kind: "request", + message: bounded( + redactText(request.failure()?.errorText ?? "request failed", secrets), + ), + url: safeUrl(request.url()), + }), + ); + page.on("response", (response) => { + if (response.status() >= 400) { + routeErrors.push({ + kind: "request", + message: `HTTP ${response.status()}`, + url: safeUrl(response.url()), + status: response.status(), + }); + } + }); + try { + const routePath = interpolateEnvironment(route.path); + const targetUrl = new URL(routePath, `${baseUrl}/`).toString(); + const response = await retry(`navigate ${route.id}`, async () => { + const ready = route.ready; + const responseReady = ready.kind === "response" + ? page.waitForResponse( + (candidate) => responseMatches(candidate, ready), + { timeout: ready.timeoutMs ?? 15_000 }, + ) + : null; + try { + const navigation = await page.goto(targetUrl, { + waitUntil: "domcontentloaded", + timeout: 20_000, + }); + if (responseReady) await responseReady; + else await waitReady(page, ready, navigation); + return navigation; + } catch (error) { + responseReady?.catch(() => undefined); + throw error; + } + }); + if (response && response.status() >= 400) { + routeErrors.push({ + kind: "document", + message: `document returned HTTP ${response.status()}`, + url: safeUrl(response.url()), + status: response.status(), + }); + } + for (const point of route.capturePoints) { + captures.push( + await capturePoint( + page, + runDirectory, + persona, + route, + viewport, + point, + response, + routeErrors, + scenario, + ), + ); + } + } catch (error) { + status = "completed-with-errors"; + routeErrors.push({ + kind: "tool", + message: bounded( + redactText(error instanceof Error ? error.message : String(error), secrets), + ), + }); + captures.push({ + persona: { id: persona.id, label: persona.label }, + route: { + id: route.id, + path: route.path, + goal: route.goal, + dataState: route.dataState, + }, + viewport, + theme: scenario.colorScheme ?? "light", + capturePoint: { id: "failed", label: "Capture failed" }, + document: { url: safeUrl(page.url()), status: null }, + screenshots: [], + snapshotPath: null, + errors: routeErrors, + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + }); + } finally { + await page.close(); + } + } + } finally { + await context.close(); + } + } + } + contactSheet = await createContactSheet(browser, runDirectory, captures); + if (captures.some((capture) => capture.errors.length > 0)) status = "completed-with-errors"; + } catch (error) { + status = "failed"; + diagnostics.push({ + kind: "tool", + message: bounded(redactText(error instanceof Error ? error.message : String(error), secrets)), + }); + } finally { + if (browser) { + await browser.close().catch((error) => + diagnostics.push({ + kind: "tool", + message: `browser cleanup failed: ${bounded(String(error))}`, + }) + ); + } + diagnostics.push(...await stopOwnedProcesses(processes)); + } + if (diagnostics.length > 0 && status === "completed") status = "completed-with-errors"; + const manifest: ReviewContext = { + schemaVersion: 1, + runId, + scenario: { + id: scenario.id, + title: scenario.title, + sourcePath: relative(Deno.cwd(), scenarioPath), + }, + source: await sourceState(), + baseUrl: safeUrl(baseUrl), + browser: { name: "chromium", version: browserVersion }, + createdAt: new Date().toISOString(), + status, + filters: { + personas: personas.map((item) => item.id), + routes: routes.map((item) => item.id), + viewports: viewports.map(viewportId), + }, + captures, + contactSheet, + diagnostics, + }; + const serialized = `${JSON.stringify(manifest, null, 2)}\n`; + assertBundleIsSecretFree(serialized, secrets); + await Deno.writeTextFile(join(runDirectory, "review-context.json"), serialized, { mode: 0o600 }); + if (status === "failed") { + throw new Error(`capture failed; inspect ${join(runDirectory, "review-context.json")}`); + } + return manifest; +} + +export function describeCapture(manifest: ReviewContext, outputDirectory: string): string { + return `${manifest.status}: ${manifest.captures.length} capture(s); ${ + join(outputDirectory, manifest.runId, "review-context.json") + }`; +} diff --git a/tools/web-ux/src/compare.ts b/tools/web-ux/src/compare.ts new file mode 100644 index 00000000..05f8d767 --- /dev/null +++ b/tools/web-ux/src/compare.ts @@ -0,0 +1,173 @@ +import { Buffer } from "node:buffer"; +import { basename, dirname, join, relative, resolve } from "@std/path"; +import { chromium } from "playwright"; +import pixelmatch from "pixelmatch"; +import { PNG } from "pngjs"; +import { ensurePrivateDirectory, makePrivate, sha256File } from "./artifacts.ts"; +import type { CaptureEvidence, ReviewContext } from "./types.ts"; + +export type CompareOptions = { + before: string; + after: string; + outputDirectory: string; + threshold?: number; +}; + +type Pair = { + key: string; + before: CaptureEvidence; + after: CaptureEvidence; + beforePath: string; + afterPath: string; + diffPath: string; + changedPixels: number; + totalPixels: number; + dimensionMismatch: boolean; +}; + +function key(capture: CaptureEvidence): string { + const viewport = capture.viewport.label ?? `${capture.viewport.width}x${capture.viewport.height}`; + return [capture.persona.id, capture.route.id, viewport, capture.capturePoint.id].join("/"); +} + +async function readManifest(path: string): Promise { + const parsed = JSON.parse(await Deno.readTextFile(path)); + if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.captures)) { + throw new Error(`not a web-ux review context: ${path}`); + } + return parsed; +} + +function viewportScreenshot(capture: CaptureEvidence): string | null { + return capture.screenshots.find((item) => item.kind === "viewport")?.path ?? + capture.screenshots[0]?.path ?? null; +} + +function dataUrl(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return `data:image/png;base64,${btoa(binary)}`; +} + +function escapeHtml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll( + '"', + """, + ); +} + +export async function compare(options: CompareOptions): Promise { + const beforeManifestPath = resolve(options.before); + const afterManifestPath = resolve(options.after); + const [before, after] = await Promise.all([ + readManifest(beforeManifestPath), + readManifest(afterManifestPath), + ]); + const outputDirectory = resolve(options.outputDirectory); + await ensurePrivateDirectory(outputDirectory); + const afterByKey = new Map(after.captures.map((capture) => [key(capture), capture])); + const pairs: Pair[] = []; + const unmatchedBefore: string[] = []; + for (const earlier of before.captures) { + const later = afterByKey.get(key(earlier)); + const earlierScreenshot = viewportScreenshot(earlier); + const laterScreenshot = later ? viewportScreenshot(later) : null; + if (!later || !earlierScreenshot || !laterScreenshot) { + unmatchedBefore.push(key(earlier)); + continue; + } + afterByKey.delete(key(earlier)); + const beforePath = resolve(dirname(beforeManifestPath), earlierScreenshot); + const afterPath = resolve(dirname(afterManifestPath), laterScreenshot); + const [beforeImage, afterImage] = [ + PNG.sync.read(Buffer.from(Deno.readFileSync(beforePath))), + PNG.sync.read(Buffer.from(Deno.readFileSync(afterPath))), + ]; + const dimensionMismatch = beforeImage.width !== afterImage.width || + beforeImage.height !== afterImage.height; + const width = Math.max(beforeImage.width, afterImage.width); + const height = Math.max(beforeImage.height, afterImage.height); + const diff = new PNG({ width, height, fill: true }); + let changedPixels = width * height; + if (!dimensionMismatch) { + changedPixels = pixelmatch(beforeImage.data, afterImage.data, diff.data, width, height, { + threshold: options.threshold ?? 0.1, + includeAA: false, + }); + } + const diffPath = join(outputDirectory, "diffs", `${key(earlier).replaceAll("/", "--")}.png`); + await ensurePrivateDirectory(dirname(diffPath)); + await Deno.writeFile(diffPath, PNG.sync.write(diff), { mode: 0o600 }); + await makePrivate(diffPath); + pairs.push({ + key: key(earlier), + before: earlier, + after: later, + beforePath, + afterPath, + diffPath, + changedPixels, + totalPixels: width * height, + dimensionMismatch, + }); + } + const cells: string[] = []; + for (const pair of pairs) { + const [beforeBytes, afterBytes, diffBytes] = await Promise.all([ + Deno.readFile(pair.beforePath), + Deno.readFile(pair.afterPath), + Deno.readFile(pair.diffPath), + ]); + cells.push( + `

${escapeHtml(pair.key)}

${ + pair.dimensionMismatch + ? "dimension mismatch" + : `${pair.changedPixels} / ${pair.totalPixels} pixels changed` + }

before
after
diff
`, + ); + } + const html = + `Web UX comparison${ + cells.join("") + }`; + const htmlPath = join(outputDirectory, "comparison.html"); + const pngPath = join(outputDirectory, "comparison.png"); + await Deno.writeTextFile(htmlPath, html, { mode: 0o600 }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1800, height: 1000 } }); + await page.setContent(html, { waitUntil: "load" }); + await page.screenshot({ path: pngPath, fullPage: true, animations: "disabled" }); + await makePrivate(pngPath); + } finally { + await browser.close(); + } + const report = { + schemaVersion: 1, + before: beforeManifestPath, + after: afterManifestPath, + createdAt: new Date().toISOString(), + threshold: options.threshold ?? 0.1, + pairs: await Promise.all(pairs.map(async (pair) => ({ + key: pair.key, + changedPixels: pair.changedPixels, + totalPixels: pair.totalPixels, + changedRatio: pair.totalPixels === 0 ? 0 : pair.changedPixels / pair.totalPixels, + dimensionMismatch: pair.dimensionMismatch, + diff: relative(outputDirectory, pair.diffPath), + diffSha256: await sha256File(pair.diffPath), + }))), + unmatchedBefore, + unmatchedAfter: [...afterByKey.keys()], + contactSheet: { html: basename(htmlPath), png: basename(pngPath) }, + }; + const reportPath = join(outputDirectory, "comparison.json"); + await Deno.writeTextFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + return reportPath; +} diff --git a/tools/web-ux/src/lifecycle.ts b/tools/web-ux/src/lifecycle.ts new file mode 100644 index 00000000..bdf0b783 --- /dev/null +++ b/tools/web-ux/src/lifecycle.ts @@ -0,0 +1,114 @@ +import { dirname, resolve } from "@std/path"; +import { chromium } from "playwright"; +import { ensurePrivateDirectory, writePrivateJson } from "./artifacts.ts"; +import { + interpolateEnvironment, + loadScenario, + resolveScenarioPath, + validateBaseUrl, +} from "./scenario.ts"; + +export type AuthOptions = { + scenarioPath: string; + personaId: string; + baseUrl?: string; + importState?: string; + timeoutMs?: number; + headless?: boolean; +}; + +function validateStorageState(value: unknown): { cookies: unknown[]; origins: unknown[] } { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("storage state must be an object"); + } + const source = value as Record; + if (!Array.isArray(source.cookies) || !Array.isArray(source.origins)) { + throw new Error("storage state must contain cookies and origins arrays"); + } + return { cookies: source.cookies, origins: source.origins }; +} + +export async function authenticate(options: AuthOptions): Promise { + const scenarioPath = resolve(options.scenarioPath); + const scenario = await loadScenario(scenarioPath); + const persona = scenario.personas.find((candidate) => candidate.id === options.personaId); + if (!persona) throw new Error(`unknown persona: ${options.personaId}`); + if (persona.auth.kind !== "storage-state") { + throw new Error(`persona ${persona.id} is anonymous and has no auth state`); + } + const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path); + await ensurePrivateDirectory(dirname(outputPath)); + if (options.importState) { + const imported = validateStorageState( + JSON.parse(await Deno.readTextFile(resolve(options.importState))), + ); + await writePrivateJson(outputPath, imported); + return outputPath; + } + if (!persona.login) { + throw new Error(`persona ${persona.id} needs login configuration or --import-state`); + } + const baseUrl = validateBaseUrl( + interpolateEnvironment( + options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "", + ), + ); + const browser = await chromium.launch({ headless: options.headless ?? false }); + try { + const context = await browser.newContext(); + const page = await context.newPage(); + const loginUrl = new URL(interpolateEnvironment(persona.login.path ?? "/"), `${baseUrl}/`) + .toString(); + await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 30_000 }); + const success = new RegExp(interpolateEnvironment(persona.login.successUrl)); + if (!success.test(page.url())) { + await page.waitForURL((url) => success.test(url.toString()), { + timeout: options.timeoutMs ?? 300_000, + }); + } + await context.storageState({ path: outputPath }); + if (Deno.build.os !== "windows") await Deno.chmod(outputPath, 0o600); + return outputPath; + } finally { + await browser.close(); + } +} + +export type CleanupOptions = { + outputDirectory: string; + keep: number; + olderThanDays?: number; + dryRun?: boolean; +}; + +export async function cleanup(options: CleanupOptions): Promise { + const outputDirectory = resolve(options.outputDirectory); + const candidates: { path: string; modified: number }[] = []; + try { + for await (const entry of Deno.readDir(outputDirectory)) { + if (!entry.isDirectory) continue; + const path = resolve(outputDirectory, entry.name); + try { + await Deno.stat(resolve(path, "review-context.json")); + const stat = await Deno.stat(path); + candidates.push({ path, modified: stat.mtime?.getTime() ?? 0 }); + } catch { + // Only complete review bundle directories are owned by cleanup. + } + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) return []; + throw error; + } + candidates.sort((left, right) => right.modified - left.modified); + const cutoff = options.olderThanDays === undefined + ? Number.POSITIVE_INFINITY + : Date.now() - options.olderThanDays * 24 * 60 * 60 * 1000; + const removed: string[] = []; + for (const [index, candidate] of candidates.entries()) { + if (index < options.keep || candidate.modified > cutoff) continue; + removed.push(candidate.path); + if (!options.dryRun) await Deno.remove(candidate.path, { recursive: true }); + } + return removed; +} diff --git a/tools/web-ux/src/processes.ts b/tools/web-ux/src/processes.ts new file mode 100644 index 00000000..dce99086 --- /dev/null +++ b/tools/web-ux/src/processes.ts @@ -0,0 +1,175 @@ +import { dirname, isAbsolute, resolve } from "@std/path"; +import { bounded, redactText } from "./artifacts.ts"; +import type { CaptureError, OwnedProcess } from "./types.ts"; + +export type RunningProcess = { + id: string; + pid: number; + child: Deno.ChildProcess; + output: Promise; +}; + +async function appendOutput( + stream: ReadableStream, + destination: string, + secrets: string[], +): Promise { + const file = await Deno.open(destination, { + create: true, + append: true, + write: true, + mode: 0o600, + }); + try { + const reader = stream.pipeThrough(new TextDecoderStream()).getReader(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + await file.write(new TextEncoder().encode(redactText(value, secrets))); + } + } finally { + file.close(); + } +} + +async function waitForReady(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError = "not attempted"; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) }); + if (response.status < 500) return; + lastError = `HTTP ${response.status}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`process readiness timed out for ${url}: ${bounded(lastError, 200)}`); +} + +export async function startOwnedProcesses( + specifications: OwnedProcess[], + scenarioPath: string, + logsDirectory: string, + secrets: string[], +): Promise { + const running: RunningProcess[] = []; + await Deno.mkdir(logsDirectory, { recursive: true }); + try { + for (const specification of specifications) { + const cwd = specification.cwd === undefined + ? dirname(resolve(scenarioPath)) + : isAbsolute(specification.cwd) + ? specification.cwd + : resolve(dirname(scenarioPath), specification.cwd); + const child = new Deno.Command(specification.command, { + args: specification.args ?? [], + cwd, + env: specification.env, + stdin: "null", + stdout: "piped", + stderr: "piped", + }).spawn(); + const stdout = appendOutput( + child.stdout, + `${logsDirectory}/${specification.id}.stdout.log`, + secrets, + ); + const stderr = appendOutput( + child.stderr, + `${logsDirectory}/${specification.id}.stderr.log`, + secrets, + ); + const process = { + id: specification.id, + pid: child.pid, + child, + output: Promise.all([stdout, stderr]).then(() => undefined), + }; + running.push(process); + if (specification.readyUrl) { + await Promise.race([ + waitForReady(specification.readyUrl, specification.readyTimeoutMs ?? 30_000), + child.status.then((status) => { + throw new Error( + `owned process ${specification.id} exited before readiness: ${status.code}`, + ); + }), + ]); + } + } + return running; + } catch (error) { + await stopOwnedProcesses(running); + throw error; + } +} + +async function descendantPids(parentPid: number): Promise { + if (Deno.build.os === "windows") return []; + try { + const result = await new Deno.Command("ps", { + args: ["-eo", "pid=,ppid="], + stdout: "piped", + stderr: "null", + }).output(); + if (!result.success) return []; + const rows = new TextDecoder().decode(result.stdout).trim().split("\n").map((line) => + line.trim().split(/\s+/).map(Number) + ); + const descendants: number[] = []; + const queue = [parentPid]; + while (queue.length > 0) { + const parent = queue.shift()!; + for (const [pid, ppid] of rows) { + if (ppid === parent && !descendants.includes(pid)) { + descendants.push(pid); + queue.push(pid); + } + } + } + return descendants.reverse(); + } catch { + return []; + } +} + +function tryKill(pid: number, signal: Deno.Signal): void { + try { + Deno.kill(pid, signal); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } +} + +export async function stopOwnedProcesses(processes: RunningProcess[]): Promise { + const diagnostics: CaptureError[] = []; + for (const process of [...processes].reverse()) { + try { + for (const pid of await descendantPids(process.pid)) tryKill(pid, "SIGTERM"); + tryKill(process.pid, "SIGTERM"); + let timer: number | undefined; + const exited = await Promise.race([ + process.child.status.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 3_000); + }), + ]).finally(() => clearTimeout(timer)); + if (!exited) { + for (const pid of await descendantPids(process.pid)) tryKill(pid, "SIGKILL"); + tryKill(process.pid, "SIGKILL"); + await process.child.status; + } + await process.output; + } catch (error) { + diagnostics.push({ + kind: "tool", + message: `failed to clean process ${process.id}: ${ + bounded(error instanceof Error ? error.message : String(error), 500) + }`, + }); + } + } + return diagnostics; +} diff --git a/tools/web-ux/src/scenario.ts b/tools/web-ux/src/scenario.ts new file mode 100644 index 00000000..b09fbd53 --- /dev/null +++ b/tools/web-ux/src/scenario.ts @@ -0,0 +1,270 @@ +import { isAbsolute, join, resolve } from "@std/path"; +import type { + CapturePoint, + Persona, + ReadyCondition, + RouteScenario, + Scenario, + Viewport, +} from "./types.ts"; + +function record(value: unknown, at: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${at} must be an object`); + } + return value as Record; +} + +function text(value: unknown, at: string): string { + if (typeof value !== "string" || value.trim() === "") throw new Error(`${at} must be text`); + return value; +} + +function positiveInteger(value: unknown, at: string): number { + if (!Number.isInteger(value) || (value as number) <= 0) { + throw new Error(`${at} must be a positive integer`); + } + return value as number; +} + +function identifier(value: unknown, at: string): string { + const result = text(value, at); + if (!/^[a-z0-9][a-z0-9-]*$/.test(result)) { + throw new Error(`${at} must contain lowercase ASCII letters, digits, or hyphens`); + } + return result; +} + +function stringArray(value: unknown, at: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error(`${at} must be an array`); + return value.map((item, index) => text(item, `${at}[${index}]`)); +} + +function parseReady(value: unknown, at: string): ReadyCondition { + const source = record(value, at); + const kind = text(source.kind, `${at}.kind`); + const timeoutMs = source.timeoutMs === undefined + ? undefined + : positiveInteger(source.timeoutMs, `${at}.timeoutMs`); + if (kind === "selector") { + return { kind, selector: text(source.selector, `${at}.selector`), timeoutMs }; + } + if (kind === "response") { + const status = source.status === undefined + ? undefined + : positiveInteger(source.status, `${at}.status`); + return { kind, urlPattern: text(source.urlPattern, `${at}.urlPattern`), status, timeoutMs }; + } + if (kind === "network-idle") return { kind, timeoutMs }; + throw new Error(`${at}.kind is unsupported: ${kind}`); +} + +function parseCapturePoint(value: unknown, at: string): CapturePoint { + const source = record(value, at); + const result: CapturePoint = { + id: identifier(source.id, `${at}.id`), + label: text(source.label, `${at}.label`), + fullPage: source.fullPage === undefined ? false : Boolean(source.fullPage), + }; + if (source.ready !== undefined) result.ready = parseReady(source.ready, `${at}.ready`); + if (source.interaction !== undefined) { + if (!Array.isArray(source.interaction)) throw new Error(`${at}.interaction must be an array`); + result.interaction = source.interaction.map((raw, index) => { + const action = record(raw, `${at}.interaction[${index}]`); + const name = text(action.action, `${at}.interaction[${index}].action`); + if (name === "wait") { + return { + action: name, + ready: parseReady(action.ready, `${at}.interaction[${index}].ready`), + }; + } + const selector = text(action.selector, `${at}.interaction[${index}].selector`); + const timeoutMs = action.timeoutMs === undefined + ? undefined + : positiveInteger(action.timeoutMs, `${at}.interaction[${index}].timeoutMs`); + if (name === "click") return { action: name, selector, timeoutMs }; + if (name === "fill") { + return { + action: name, + selector, + value: text(action.value, `${at}.interaction[${index}].value`), + timeoutMs, + }; + } + if (name === "press") { + return { + action: name, + selector, + key: text(action.key, `${at}.interaction[${index}].key`), + timeoutMs, + }; + } + throw new Error(`${at}.interaction[${index}].action is unsupported: ${name}`); + }); + } + return result; +} + +function parsePersona(value: unknown, at: string): Persona { + const source = record(value, at); + const auth = record(source.auth, `${at}.auth`); + const kind = text(auth.kind, `${at}.auth.kind`); + const persona: Persona = { + id: identifier(source.id, `${at}.id`), + label: text(source.label, `${at}.label`), + auth: kind === "anonymous" + ? { kind } + : kind === "storage-state" + ? { kind, path: text(auth.path, `${at}.auth.path`) } + : (() => { + throw new Error(`${at}.auth.kind is unsupported: ${kind}`); + })(), + }; + if (source.login !== undefined) { + const login = record(source.login, `${at}.login`); + persona.login = { + path: login.path === undefined ? "/" : text(login.path, `${at}.login.path`), + successUrl: text(login.successUrl, `${at}.login.successUrl`), + }; + } + return persona; +} + +function parseViewport(value: unknown, at: string): Viewport { + const source = record(value, at); + return { + width: positiveInteger(source.width, `${at}.width`), + height: positiveInteger(source.height, `${at}.height`), + label: source.label === undefined ? undefined : identifier(source.label, `${at}.label`), + deviceScaleFactor: source.deviceScaleFactor === undefined + ? 1 + : positiveInteger(source.deviceScaleFactor, `${at}.deviceScaleFactor`), + }; +} + +function parseRoute(value: unknown, at: string): RouteScenario { + const source = record(value, at); + if (!Array.isArray(source.capturePoints) || source.capturePoints.length === 0) { + throw new Error(`${at}.capturePoints must have at least one item`); + } + return { + id: identifier(source.id, `${at}.id`), + label: text(source.label, `${at}.label`), + path: text(source.path, `${at}.path`), + goal: text(source.goal, `${at}.goal`), + dataState: text(source.dataState, `${at}.dataState`), + ready: parseReady(source.ready, `${at}.ready`), + capturePoints: source.capturePoints.map((point, index) => + parseCapturePoint(point, `${at}.capturePoints[${index}]`) + ), + }; +} + +function uniqueIds(values: { id: string }[], at: string): void { + const seen = new Set(); + for (const value of values) { + if (seen.has(value.id)) throw new Error(`${at} contains duplicate id: ${value.id}`); + seen.add(value.id); + } +} + +export function interpolateEnvironment(value: string, environment = Deno.env.toObject()): string { + return value.replaceAll(/\$\{([A-Z][A-Z0-9_]*)\}/g, (_match, name: string) => { + const replacement = environment[name]; + if (replacement === undefined) { + throw new Error(`required environment variable is missing: ${name}`); + } + return replacement; + }); +} + +export function validateBaseUrl(value: string): string { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("base URL must use http or https"); + } + if (url.username || url.password) throw new Error("base URL must not contain credentials"); + url.pathname = url.pathname.replace(/\/$/, ""); + return url.toString().replace(/\/$/, ""); +} + +export function resolveScenarioPath(sourcePath: string, value: string): string { + const expanded = interpolateEnvironment(value); + return isAbsolute(expanded) ? expanded : resolve(join(sourcePath, "..", expanded)); +} + +export async function loadScenario(sourcePath: string): Promise { + const parsed = JSON.parse(await Deno.readTextFile(sourcePath)); + const source = record(parsed, "scenario"); + if (source.schemaVersion !== 1) throw new Error("scenario.schemaVersion must equal 1"); + if (!Array.isArray(source.personas) || source.personas.length === 0) { + throw new Error("scenario.personas must have at least one item"); + } + if (!Array.isArray(source.viewports) || source.viewports.length === 0) { + throw new Error("scenario.viewports must have at least one item"); + } + if (!Array.isArray(source.routes) || source.routes.length === 0) { + throw new Error("scenario.routes must have at least one item"); + } + const personas = source.personas.map((value, index) => + parsePersona(value, `scenario.personas[${index}]`) + ); + const routes = source.routes.map((value, index) => + parseRoute(value, `scenario.routes[${index}]`) + ); + const scenario: Scenario = { + schemaVersion: 1, + id: identifier(source.id, "scenario.id"), + title: text(source.title, "scenario.title"), + baseUrl: source.baseUrl === undefined ? undefined : text(source.baseUrl, "scenario.baseUrl"), + locale: source.locale === undefined ? "en-US" : text(source.locale, "scenario.locale"), + timezone: source.timezone === undefined ? "UTC" : text(source.timezone, "scenario.timezone"), + colorScheme: source.colorScheme === "dark" ? "dark" : "light", + reducedMotion: source.reducedMotion === "no-preference" ? "no-preference" : "reduce", + redact: source.redact === undefined ? undefined : (() => { + const redact = record(source.redact, "scenario.redact"); + return { + selectors: stringArray(redact.selectors, "scenario.redact.selectors"), + text: stringArray(redact.text, "scenario.redact.text").map((value) => + interpolateEnvironment(value) + ), + }; + })(), + personas, + viewports: source.viewports.map((value, index) => + parseViewport(value, `scenario.viewports[${index}]`) + ), + routes, + }; + if (source.processes !== undefined) { + if (!Array.isArray(source.processes)) throw new Error("scenario.processes must be an array"); + scenario.processes = source.processes.map((value, index) => { + const at = `scenario.processes[${index}]`; + const process = record(value, at); + const env = process.env === undefined ? undefined : record(process.env, `${at}.env`); + return { + id: identifier(process.id, `${at}.id`), + command: text(process.command, `${at}.command`), + args: stringArray(process.args, `${at}.args`), + cwd: process.cwd === undefined ? undefined : text(process.cwd, `${at}.cwd`), + env: env === undefined ? undefined : Object.fromEntries( + Object.entries(env).map(( + [key, raw], + ) => [key, interpolateEnvironment(text(raw, `${at}.env.${key}`))]), + ), + readyUrl: process.readyUrl === undefined ? undefined : validateBaseUrl( + interpolateEnvironment(text(process.readyUrl, `${at}.readyUrl`)), + ), + readyTimeoutMs: process.readyTimeoutMs === undefined + ? undefined + : positiveInteger(process.readyTimeoutMs, `${at}.readyTimeoutMs`), + }; + }); + uniqueIds(scenario.processes, "scenario.processes"); + } + uniqueIds(personas, "scenario.personas"); + uniqueIds(routes, "scenario.routes"); + for (const route of routes) uniqueIds(route.capturePoints, `route ${route.id} capturePoints`); + return scenario; +} diff --git a/tools/web-ux/src/types.ts b/tools/web-ux/src/types.ts new file mode 100644 index 00000000..296639a5 --- /dev/null +++ b/tools/web-ux/src/types.ts @@ -0,0 +1,116 @@ +export type Viewport = { + width: number; + height: number; + label?: string; + deviceScaleFactor?: number; +}; + +export type Persona = { + id: string; + label: string; + auth: { kind: "anonymous" } | { kind: "storage-state"; path: string }; + login?: { + path?: string; + successUrl: string; + }; +}; + +export type ReadyCondition = + | { kind: "selector"; selector: string; timeoutMs?: number } + | { kind: "response"; urlPattern: string; status?: number; timeoutMs?: number } + | { kind: "network-idle"; timeoutMs?: number }; + +export type Interaction = + | { action: "click"; selector: string; timeoutMs?: number } + | { action: "fill"; selector: string; value: string; timeoutMs?: number } + | { action: "press"; selector: string; key: string; timeoutMs?: number } + | { action: "wait"; ready: ReadyCondition }; + +export type CapturePoint = { + id: string; + label: string; + interaction?: Interaction[]; + ready?: ReadyCondition; + fullPage?: boolean; +}; + +export type RouteScenario = { + id: string; + label: string; + path: string; + goal: string; + dataState: string; + ready: ReadyCondition; + capturePoints: CapturePoint[]; +}; + +export type OwnedProcess = { + id: string; + command: string; + args?: string[]; + cwd?: string; + env?: Record; + readyUrl?: string; + readyTimeoutMs?: number; +}; + +export type Scenario = { + schemaVersion: 1; + id: string; + title: string; + baseUrl?: string; + locale?: string; + timezone?: string; + colorScheme?: "light" | "dark"; + reducedMotion?: "reduce" | "no-preference"; + redact?: { + selectors?: string[]; + text?: string[]; + }; + personas: Persona[]; + viewports: Viewport[]; + routes: RouteScenario[]; + processes?: OwnedProcess[]; +}; + +export type CaptureError = { + kind: "console" | "page" | "request" | "document" | "tool"; + message: string; + url?: string; + status?: number; +}; + +export type ScreenshotEvidence = { + kind: "viewport" | "full-page"; + path: string; + sha256: string; +}; + +export type CaptureEvidence = { + persona: { id: string; label: string }; + route: { id: string; path: string; goal: string; dataState: string }; + viewport: Viewport; + theme: string; + capturePoint: { id: string; label: string }; + document: { url: string; status: number | null }; + screenshots: ScreenshotEvidence[]; + snapshotPath: string | null; + errors: CaptureError[]; + startedAt: string; + finishedAt: string; +}; + +export type ReviewContext = { + schemaVersion: 1; + runId: string; + scenario: { id: string; title: string; sourcePath: string }; + source: { revision: string | null; dirty: boolean | null }; + baseUrl: string; + browser: { name: "chromium"; version: string }; + createdAt: string; + status: "completed" | "completed-with-errors" | "failed"; + filters: { personas: string[]; routes: string[]; viewports: string[] }; + captures: CaptureEvidence[]; + contactSheet: { html: string | null; png: string | null }; + diagnostics: CaptureError[]; +}; diff --git a/tools/web-ux/tests/cli_test.ts b/tools/web-ux/tests/cli_test.ts new file mode 100644 index 00000000..dbd6a4fb --- /dev/null +++ b/tools/web-ux/tests/cli_test.ts @@ -0,0 +1,26 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { parseArguments } from "../cli.ts"; +import { isVisibleUiErrorText } from "../src/capture.ts"; + +Deno.test("CLI parses bounded capture filters", () => { + const parsed = parseArguments([ + "capture", + "--scenario", + "scenario.json", + "--personas", + "owner,non-owner", + "--headed", + ]); + assertEquals(parsed.command, "capture"); + assertEquals(parsed.values.get("personas"), ["owner,non-owner"]); + assertEquals(parsed.flags.has("headed"), true); +}); + +Deno.test("visible UI error classification ignores ordinary status text", () => { + assertEquals(isVisibleUiErrorText("Refresh failed (401 Unauthorized)"), true); + assertEquals(isVisibleUiErrorText("Workspace list loaded"), false); +}); + +Deno.test("CLI rejects positional and missing option values", () => { + assertThrows(() => parseArguments(["capture", "scenario.json"]), Error, "unexpected argument"); +}); diff --git a/tools/web-ux/tests/lifecycle_test.ts b/tools/web-ux/tests/lifecycle_test.ts new file mode 100644 index 00000000..6e5775b3 --- /dev/null +++ b/tools/web-ux/tests/lifecycle_test.ts @@ -0,0 +1,75 @@ +import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; +import { join } from "@std/path"; +import { + assertBundleIsSecretFree, + redactText, + safeUrl, + writePrivateJson, +} from "../src/artifacts.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; + +Deno.test("redaction removes common credentials and query values", () => { + const redacted = redactText( + "Authorization: Bearer abc.def cookie=session-value token=secret-value", + ["abc.def"], + ); + assertStringIncludes(redacted, "[REDACTED]"); + assertEquals(redacted.includes("abc.def"), false); + assertEquals(redacted.includes("session-value"), false); + assertEquals( + safeUrl("https://user:pass@example.test/path?token=secret#fragment"), + "https://example.test/path?token=%5BREDACTED%5D", + ); + assertRejects( + async () => assertBundleIsSecretFree('{"authorization":"Bearer abc"}'), + Error, + "forbidden secret marker", + ); +}); + +Deno.test("private JSON state uses owner-only permissions", async () => { + const directory = await Deno.makeTempDir(); + try { + const path = join(directory, "state", "owner.json"); + await writePrivateJson(path, { cookies: [], origins: [] }); + assertEquals(JSON.parse(await Deno.readTextFile(path)), { cookies: [], origins: [] }); + if (Deno.build.os !== "windows") assertEquals((await Deno.stat(path)).mode! & 0o777, 0o600); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("owned process is terminated and its logs are redacted", async () => { + const directory = await Deno.makeTempDir(); + const scenario = join(directory, "scenario.json"); + await Deno.writeTextFile(scenario, "{}"); + try { + const processes = await startOwnedProcesses( + [{ + id: "fixture", + command: Deno.execPath(), + args: ["eval", 'console.log("authorization: secret-value"); setInterval(() => {}, 1000)'], + }], + scenario, + join(directory, "logs"), + ["secret-value"], + ); + assertEquals(processes.length, 1); + const logPath = join(directory, "logs", "fixture.stdout.log"); + for (let attempt = 0; attempt < 20; attempt++) { + try { + if ((await Deno.readTextFile(logPath)).length > 0) break; + } catch { + // The output pump creates the file asynchronously. + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const diagnostics = await stopOwnedProcesses(processes); + assertEquals(diagnostics, []); + const log = await Deno.readTextFile(logPath); + assertEquals(log.includes("secret-value"), false); + assertStringIncludes(log, "[REDACTED]"); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); diff --git a/tools/web-ux/tests/scenario_test.ts b/tools/web-ux/tests/scenario_test.ts new file mode 100644 index 00000000..961464ee --- /dev/null +++ b/tools/web-ux/tests/scenario_test.ts @@ -0,0 +1,98 @@ +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { join } from "@std/path"; +import { cleanup } from "../src/lifecycle.ts"; +import { interpolateEnvironment, loadScenario, validateBaseUrl } from "../src/scenario.ts"; + +function minimalScenario(extra = ""): string { + return `{ + "schemaVersion": 1, + "id": "test-screen", + "title": "Test screen", + "baseUrl": "http://127.0.0.1:5173", + "personas": [{"id":"anonymous","label":"Anonymous","auth":{"kind":"anonymous"}}], + "viewports": [{"label":"desktop","width":1000,"height":800}], + "routes": [{ + "id":"home","label":"Home","path":"/","goal":"Inspect home", + "dataState":"Fixture data","ready":{"kind":"selector","selector":"main"}, + "capturePoints":[{"id":"initial","label":"Initial"}] + }]${extra} + }`; +} + +Deno.test("scenario parser preserves explicit visual review context", async () => { + const directory = await Deno.makeTempDir(); + try { + const path = join(directory, "scenario.json"); + await Deno.writeTextFile(path, minimalScenario()); + const scenario = await loadScenario(path); + assertEquals(scenario.personas[0].auth, { kind: "anonymous" }); + assertEquals(scenario.routes[0].goal, "Inspect home"); + assertEquals(scenario.routes[0].dataState, "Fixture data"); + assertEquals(scenario.routes[0].ready, { + kind: "selector", + selector: "main", + timeoutMs: undefined, + }); + assertEquals(scenario.reducedMotion, "reduce"); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("scenario parser rejects duplicate persona identity", async () => { + const directory = await Deno.makeTempDir(); + try { + const path = join(directory, "scenario.json"); + await Deno.writeTextFile( + path, + minimalScenario().replace( + '[{"id":"anonymous","label":"Anonymous","auth":{"kind":"anonymous"}}]', + '[{"id":"same","label":"First","auth":{"kind":"anonymous"}},{"id":"same","label":"Second","auth":{"kind":"anonymous"}}]', + ), + ); + await assertRejects(() => loadScenario(path), Error, "duplicate id: same"); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("base URL rejects embedded credentials and non-http schemes", () => { + assertThrows( + () => validateBaseUrl("https://user:secret@example.test"), + Error, + "must not contain credentials", + ); + assertThrows(() => validateBaseUrl("file:///tmp/index.html"), Error, "must use http or https"); +}); + +Deno.test("environment interpolation fails closed", () => { + assertEquals( + interpolateEnvironment("/w/${WORKSPACE_ID}", { WORKSPACE_ID: "W-test" }), + "/w/W-test", + ); + assertThrows( + () => interpolateEnvironment("${MISSING}", {}), + Error, + "required environment variable is missing", + ); +}); + +Deno.test("cleanup removes only complete review bundles beyond retention", async () => { + const directory = await Deno.makeTempDir(); + try { + for (const name of ["one", "two", "three"]) { + const run = join(directory, name); + await Deno.mkdir(run); + await Deno.writeTextFile(join(run, "review-context.json"), "{}"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + const unrelated = join(directory, "auth"); + await Deno.mkdir(unrelated); + await Deno.writeTextFile(join(unrelated, "state.json"), "secret"); + const removed = await cleanup({ outputDirectory: directory, keep: 1 }); + assertEquals(removed.length, 2); + assertEquals(await Deno.readTextFile(join(unrelated, "state.json")), "secret"); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); From c4a3f4ba1e968d9c65c4768281eb334c8f3cb1b9 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 23:00:28 +0900 Subject: [PATCH 02/11] fix: harden Web UX capture profiles --- .gitignore | 1 - tools/web-ux/README.md | 37 +++++--- .../browser-tests/capture_smoke_test.ts | 5 +- tools/web-ux/cli.ts | 26 ++++-- tools/web-ux/scenarios/anonymous-entry.json | 9 ++ .../scenarios/workspace-control-plane.json | 20 +++++ tools/web-ux/src/auth_state.ts | 87 +++++++++++++++++++ tools/web-ux/src/capture.ts | 3 +- tools/web-ux/src/lifecycle.ts | 32 ++++--- tools/web-ux/src/processes.ts | 6 +- tools/web-ux/tests/auth_state_test.ts | 56 ++++++++++++ 11 files changed, 247 insertions(+), 35 deletions(-) create mode 100644 tools/web-ux/src/auth_state.ts create mode 100644 tools/web-ux/tests/auth_state_test.ts diff --git a/.gitignore b/.gitignore index 0b737840..838d45b8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,3 @@ *.local* .env .web-ux/ -artifacts/web-ux/ diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index cabcf9ef..14a5b147 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -57,7 +57,9 @@ export WORKSPACE_ID='' Authentication state is local sensitive material. `.web-ux/` is gitignored, files are written with mode `0600`, state contents are never copied into a review bundle, and the CLI never prints cookies -or credentials. +or credentials. Each profile has a sidecar binding it to the exact persona and base URL origin with +a 12-hour default expiry. Capture fails explicitly when metadata is missing, the origin differs, or +the profile has expired; it never silently reuses or refreshes that state. For an interactive Passkey/browser login: @@ -78,7 +80,17 @@ without putting its value on the command line: deno task web-ux auth \ --scenario scenarios/workspace-control-plane.json \ --persona owner \ - --import-state /private/path/owner-state.json + --import-state /private/path/owner-state.json \ + --expires-in-hours 8 +``` + +Delete both the profile and its metadata when it is no longer needed: + +```sh +deno task web-ux auth \ + --scenario scenarios/workspace-control-plane.json \ + --persona owner \ + --delete ``` Do not place passwords, bearer tokens, private keys, WebAuthn material, or inline cookies in a @@ -93,7 +105,7 @@ Capture a stable multi-persona bundle: ```sh deno task web-ux capture \ --scenario scenarios/workspace-control-plane.json \ - --output ../../artifacts/web-ux \ + --output ../../target/web-ux \ --run-id before-change ``` @@ -102,7 +114,7 @@ Use filters for a bounded feedback loop: ```sh deno task web-ux capture \ --scenario scenarios/workspace-control-plane.json \ - --output ../../artifacts/web-ux \ + --output ../../target/web-ux \ --run-id ticket-list-after \ --personas owner,non-owner \ --routes tickets \ @@ -127,9 +139,9 @@ the new evidence. Playwright success alone is not visual acceptance. ```sh deno task web-ux compare \ - --before ../../artifacts/web-ux/before-change/review-context.json \ - --after ../../artifacts/web-ux/after-change/review-context.json \ - --output ../../artifacts/web-ux/before-vs-after + --before ../../target/web-ux/before-change/review-context.json \ + --after ../../target/web-ux/after-change/review-context.json \ + --output ../../target/web-ux/before-vs-after ``` `comparison.html` and `comparison.png` show before, after, and pixel diff side by side. @@ -150,13 +162,14 @@ that it did not start. Old complete review bundles can be removed without touching auth state or arbitrary directories: ```sh -deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14 --dry-run -deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14 +deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 --dry-run +deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 ``` -Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and -`artifacts/web-ux/` are ignored by Git. Keep a bundle outside Git or publish it through the approved -immutable artifact channel when durable review evidence is required. +Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and the repository +`target/` tree are ignored by Git. `capture` defaults to `target/web-ux` when `--output` is omitted. +Keep a bundle outside Git or publish it through the approved immutable artifact channel when durable +review evidence is required. ## Adding a scenario diff --git a/tools/web-ux/browser-tests/capture_smoke_test.ts b/tools/web-ux/browser-tests/capture_smoke_test.ts index 8b655495..cce16b14 100644 --- a/tools/web-ux/browser-tests/capture_smoke_test.ts +++ b/tools/web-ux/browser-tests/capture_smoke_test.ts @@ -1,5 +1,6 @@ import { assertEquals, assertRejects } from "@std/assert"; import { join } from "@std/path"; +import { writeAuthMetadata } from "../src/auth_state.ts"; import { capture } from "../src/capture.ts"; async function freePort(): Promise { @@ -17,8 +18,9 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea const authDirectory = join(directory, "auth"); await Deno.mkdir(authDirectory); for (const persona of ["owner", "non-owner"]) { + const storageState = join(authDirectory, `${persona}.json`); await Deno.writeTextFile( - join(authDirectory, `${persona}.json`), + storageState, JSON.stringify({ cookies: [{ name: "persona", @@ -33,6 +35,7 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea origins: [], }), ); + await writeAuthMetadata(storageState, persona, baseUrl, 1); } const scenarioPath = join(directory, "scenario.json"); await Deno.writeTextFile( diff --git a/tools/web-ux/cli.ts b/tools/web-ux/cli.ts index 6351642a..2c5472f9 100644 --- a/tools/web-ux/cli.ts +++ b/tools/web-ux/cli.ts @@ -1,13 +1,17 @@ #!/usr/bin/env -S deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys +import { dirname, fromFileUrl, resolve } from "@std/path"; import { authenticate, cleanup } from "./src/lifecycle.ts"; import { capture, describeCapture } from "./src/capture.ts"; import { compare } from "./src/compare.ts"; +const DEFAULT_OUTPUT = resolve(dirname(fromFileUrl(import.meta.url)), "../..", "target/web-ux"); + const HELP = `Web UX inspection workbench Usage: - deno task web-ux auth --scenario --persona [--base-url ] [--import-state ] [--headless] - deno task web-ux capture --scenario --output [--base-url ] [--run-id ] [--personas ] [--routes ] [--viewports ] [--headed] + deno task web-ux auth --scenario --persona [--base-url ] [--import-state ] [--expires-in-hours ] [--headless] + deno task web-ux auth --scenario --persona --delete + deno task web-ux capture --scenario [--output ] [--base-url ] [--run-id ] [--personas ] [--routes ] [--viewports ] [--headed] deno task web-ux compare --before --after --output [--threshold <0..1>] deno task web-ux cleanup --output [--keep ] [--older-than-days ] [--dry-run] @@ -81,18 +85,26 @@ export async function main(rawArgs: string[]): Promise { return 0; } if (args.command === "auth") { - rejectUnknown(args, ["scenario", "persona", "base-url", "import-state", "timeout-ms"], [ - "headless", - ]); + rejectUnknown( + args, + ["scenario", "persona", "base-url", "import-state", "timeout-ms", "expires-in-hours"], + ["headless", "delete"], + ); + const deleting = args.flags.has("delete"); + if (deleting && optional(args, "import-state")) { + throw new Error("--delete cannot be combined with --import-state"); + } const path = await authenticate({ scenarioPath: required(args, "scenario"), personaId: required(args, "persona"), baseUrl: optional(args, "base-url"), importState: optional(args, "import-state"), timeoutMs: integer(args, "timeout-ms"), + expiresInHours: integer(args, "expires-in-hours"), + delete: deleting, headless: args.flags.has("headless"), }); - console.log(`auth state saved: ${path}`); + console.log(`auth state ${deleting ? "deleted" : "saved"}: ${path}`); return 0; } if (args.command === "capture") { @@ -105,7 +117,7 @@ export async function main(rawArgs: string[]): Promise { "routes", "viewports", ], ["headed"]); - const outputDirectory = required(args, "output"); + const outputDirectory = optional(args, "output") ?? DEFAULT_OUTPUT; const manifest = await capture({ scenarioPath: required(args, "scenario"), outputDirectory, diff --git a/tools/web-ux/scenarios/anonymous-entry.json b/tools/web-ux/scenarios/anonymous-entry.json index 5eda5fe6..95a329a5 100644 --- a/tools/web-ux/scenarios/anonymous-entry.json +++ b/tools/web-ux/scenarios/anonymous-entry.json @@ -23,6 +23,15 @@ "dataState": "Fresh browser context with no cookies, local storage, or session state.", "ready": { "kind": "network-idle", "timeoutMs": 15000 }, "capturePoints": [{ "id": "initial", "label": "Anonymous entry", "fullPage": true }] + }, + { + "id": "account", + "label": "Account entry", + "path": "/account", + "goal": "Understand current authentication state and the available account action without Workspace-private content.", + "dataState": "Fresh browser context with no cookies, local storage, or session state.", + "ready": { "kind": "network-idle", "timeoutMs": 15000 }, + "capturePoints": [{ "id": "initial", "label": "Anonymous account screen", "fullPage": true }] } ] } diff --git a/tools/web-ux/scenarios/workspace-control-plane.json b/tools/web-ux/scenarios/workspace-control-plane.json index 81da7d09..e23962fc 100644 --- a/tools/web-ux/scenarios/workspace-control-plane.json +++ b/tools/web-ux/scenarios/workspace-control-plane.json @@ -57,6 +57,15 @@ "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, "capturePoints": [{ "id": "initial", "label": "Worker list", "fullPage": true }] }, + { + "id": "settings", + "label": "Workspace settings", + "path": "/w/${WORKSPACE_ID}/settings", + "goal": "Reach the relevant Workspace settings area without presenting owner-only destinations as usable actions to a non-owner.", + "dataState": "Current permission projection for the selected Workspace.", + "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, + "capturePoints": [{ "id": "initial", "label": "Workspace settings", "fullPage": true }] + }, { "id": "repositories", "label": "Repository settings", @@ -65,6 +74,17 @@ "dataState": "Workspace repository catalog projected through current permissions.", "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, "capturePoints": [{ "id": "initial", "label": "Repository settings", "fullPage": true }] + }, + { + "id": "repository-access", + "label": "Repository access", + "path": "/w/${WORKSPACE_ID}/settings/repository-access", + "goal": "Review credential and host-trust bindings as an owner and verify non-owner composition fails closed without secret material.", + "dataState": "Configured repository access bindings projected without credential bytes.", + "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, + "capturePoints": [ + { "id": "initial", "label": "Repository access settings", "fullPage": true } + ] } ] } diff --git a/tools/web-ux/src/auth_state.ts b/tools/web-ux/src/auth_state.ts new file mode 100644 index 00000000..25a9288e --- /dev/null +++ b/tools/web-ux/src/auth_state.ts @@ -0,0 +1,87 @@ +import { writePrivateJson } from "./artifacts.ts"; + +export type AuthStateMetadata = { + schemaVersion: 1; + personaId: string; + baseOrigin: string; + createdAt: string; + expiresAt: string; +}; + +export function authMetadataPath(storageStatePath: string): string { + return `${storageStatePath}.meta.json`; +} + +function baseOrigin(baseUrl: string): string { + return new URL(baseUrl).origin; +} + +export async function writeAuthMetadata( + storageStatePath: string, + personaId: string, + baseUrl: string, + expiresInHours: number, +): Promise { + if (!Number.isFinite(expiresInHours) || expiresInHours <= 0) { + throw new Error("auth state expiry must be a positive number of hours"); + } + const createdAt = new Date(); + const metadata: AuthStateMetadata = { + schemaVersion: 1, + personaId, + baseOrigin: baseOrigin(baseUrl), + createdAt: createdAt.toISOString(), + expiresAt: new Date(createdAt.getTime() + expiresInHours * 60 * 60 * 1000).toISOString(), + }; + await writePrivateJson(authMetadataPath(storageStatePath), metadata); +} + +export async function validateAuthState( + storageStatePath: string, + personaId: string, + baseUrl: string, + now = new Date(), +): Promise { + await Deno.stat(storageStatePath); + let parsed: unknown; + try { + parsed = JSON.parse(await Deno.readTextFile(authMetadataPath(storageStatePath))); + } catch (error) { + throw new Error( + `auth state metadata is missing or invalid for ${personaId}; run the auth command again: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`auth state metadata is invalid for ${personaId}`); + } + const metadata = parsed as Partial; + if (metadata.schemaVersion !== 1 || metadata.personaId !== personaId) { + throw new Error(`auth state metadata does not match persona ${personaId}`); + } + if (metadata.baseOrigin !== baseOrigin(baseUrl)) { + throw new Error( + `auth state for ${personaId} belongs to ${metadata.baseOrigin ?? "an unknown origin"}, not ${ + baseOrigin(baseUrl) + }`, + ); + } + const expiresAt = Date.parse(metadata.expiresAt ?? ""); + if (!Number.isFinite(expiresAt)) throw new Error(`auth state expiry is invalid for ${personaId}`); + if (expiresAt <= now.getTime()) { + throw new Error( + `auth state expired for ${personaId} at ${metadata.expiresAt}; run the auth command again`, + ); + } +} + +export async function deleteAuthState(storageStatePath: string): Promise { + for (const path of [storageStatePath, authMetadataPath(storageStatePath)]) { + try { + await Deno.remove(path); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + } +} diff --git a/tools/web-ux/src/capture.ts b/tools/web-ux/src/capture.ts index aed3c52d..4608723e 100644 --- a/tools/web-ux/src/capture.ts +++ b/tools/web-ux/src/capture.ts @@ -1,5 +1,6 @@ import { basename, dirname, join, relative, resolve } from "@std/path"; import { type Browser, chromium, type Page, type Response } from "playwright"; +import { validateAuthState } from "./auth_state.ts"; import { assertBundleIsSecretFree, bounded, @@ -348,7 +349,7 @@ export async function capture(options: CaptureOptions): Promise { const storageState = persona.auth.kind === "storage-state" ? resolveScenarioPath(scenarioPath, persona.auth.path) : undefined; - if (storageState) await Deno.stat(storageState); + if (storageState) await validateAuthState(storageState, persona.id, baseUrl); for (const viewport of viewports) { const context = await browser.newContext({ storageState, diff --git a/tools/web-ux/src/lifecycle.ts b/tools/web-ux/src/lifecycle.ts index bdf0b783..fc6eae4a 100644 --- a/tools/web-ux/src/lifecycle.ts +++ b/tools/web-ux/src/lifecycle.ts @@ -1,6 +1,7 @@ import { dirname, resolve } from "@std/path"; import { chromium } from "playwright"; -import { ensurePrivateDirectory, writePrivateJson } from "./artifacts.ts"; +import { ensurePrivateDirectory, makePrivate, writePrivateJson } from "./artifacts.ts"; +import { deleteAuthState, writeAuthMetadata } from "./auth_state.ts"; import { interpolateEnvironment, loadScenario, @@ -14,6 +15,8 @@ export type AuthOptions = { baseUrl?: string; importState?: string; timeoutMs?: number; + expiresInHours?: number; + delete?: boolean; headless?: boolean; }; @@ -37,22 +40,28 @@ export async function authenticate(options: AuthOptions): Promise { throw new Error(`persona ${persona.id} is anonymous and has no auth state`); } const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path); - await ensurePrivateDirectory(dirname(outputPath)); - if (options.importState) { - const imported = validateStorageState( - JSON.parse(await Deno.readTextFile(resolve(options.importState))), - ); - await writePrivateJson(outputPath, imported); + if (options.delete) { + await deleteAuthState(outputPath); return outputPath; } - if (!persona.login) { - throw new Error(`persona ${persona.id} needs login configuration or --import-state`); - } + await ensurePrivateDirectory(dirname(outputPath)); const baseUrl = validateBaseUrl( interpolateEnvironment( options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "", ), ); + const expiresInHours = options.expiresInHours ?? 12; + if (options.importState) { + const imported = validateStorageState( + JSON.parse(await Deno.readTextFile(resolve(options.importState))), + ); + await writePrivateJson(outputPath, imported); + await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours); + return outputPath; + } + if (!persona.login) { + throw new Error(`persona ${persona.id} needs login configuration or --import-state`); + } const browser = await chromium.launch({ headless: options.headless ?? false }); try { const context = await browser.newContext(); @@ -67,7 +76,8 @@ export async function authenticate(options: AuthOptions): Promise { }); } await context.storageState({ path: outputPath }); - if (Deno.build.os !== "windows") await Deno.chmod(outputPath, 0o600); + await makePrivate(outputPath); + await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours); return outputPath; } finally { await browser.close(); diff --git a/tools/web-ux/src/processes.ts b/tools/web-ux/src/processes.ts index dce99086..7fca0876 100644 --- a/tools/web-ux/src/processes.ts +++ b/tools/web-ux/src/processes.ts @@ -38,8 +38,10 @@ async function waitForReady(url: string, timeoutMs: number): Promise { while (Date.now() < deadline) { try { const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) }); - if (response.status < 500) return; - lastError = `HTTP ${response.status}`; + const status = response.status; + await response.body?.cancel(); + if (status < 500) return; + lastError = `HTTP ${status}`; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } diff --git a/tools/web-ux/tests/auth_state_test.ts b/tools/web-ux/tests/auth_state_test.ts new file mode 100644 index 00000000..d3685fea --- /dev/null +++ b/tools/web-ux/tests/auth_state_test.ts @@ -0,0 +1,56 @@ +import { assertRejects } from "@std/assert"; +import { join } from "@std/path"; +import { + authMetadataPath, + deleteAuthState, + validateAuthState, + writeAuthMetadata, +} from "../src/auth_state.ts"; + +Deno.test("auth state is bound to persona, base origin, and expiry", async () => { + const directory = await Deno.makeTempDir(); + try { + const state = join(directory, "owner.json"); + await Deno.writeTextFile(state, '{"cookies":[],"origins":[]}'); + await writeAuthMetadata(state, "owner", "https://example.test/path", 1); + await validateAuthState(state, "owner", "https://example.test/other"); + await assertRejects( + () => validateAuthState(state, "non-owner", "https://example.test"), + Error, + "does not match persona", + ); + await assertRejects( + () => validateAuthState(state, "owner", "https://other.test"), + Error, + "belongs to https://example.test", + ); + await assertRejects( + () => + validateAuthState( + state, + "owner", + "https://example.test", + new Date(Date.now() + 2 * 60 * 60 * 1000), + ), + Error, + "auth state expired", + ); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("auth state deletion removes state and metadata idempotently", async () => { + const directory = await Deno.makeTempDir(); + try { + const state = join(directory, "owner.json"); + await Deno.writeTextFile(state, "{}"); + await writeAuthMetadata(state, "owner", "http://127.0.0.1:3000", 1); + await deleteAuthState(state); + await deleteAuthState(state); + await assertRejects(() => Deno.stat(state), Deno.errors.NotFound); + await assertRejects(() => Deno.stat(authMetadataPath(state)), Deno.errors.NotFound); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); From eea79dead49356bc9465ca9170f099d44ec25b5f Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 23:44:24 +0900 Subject: [PATCH 03/11] fix: bound Web UX review evidence and cleanup --- .gitignore | 1 - tools/web-ux/README.md | 34 ++-- .../browser-tests/capture_smoke_test.ts | 39 +++- tools/web-ux/browser-tests/fixture_server.ts | 5 +- .../scenarios/workspace-control-plane.json | 7 +- tools/web-ux/src/artifacts.ts | 61 ++++++- tools/web-ux/src/capture.ts | 172 ++++++++++++++---- tools/web-ux/src/compare.ts | 4 +- tools/web-ux/src/processes.ts | 103 +++++++++-- tools/web-ux/src/scenario.ts | 15 ++ tools/web-ux/src/types.ts | 47 ++++- tools/web-ux/tests/lifecycle_test.ts | 99 +++++++++- tools/web-ux/tests/scenario_test.ts | 17 +- 13 files changed, 518 insertions(+), 86 deletions(-) diff --git a/.gitignore b/.gitignore index 838d45b8..fe1edfae 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,3 @@ .worktree *.local* .env -.web-ux/ diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index 14a5b147..e78bab0c 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -51,15 +51,17 @@ screen-owned selector. ```sh export WEB_UX_BASE_URL='http://127.0.0.1:5173' export WORKSPACE_ID='' +export XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}" ``` ## Authentication fixtures -Authentication state is local sensitive material. `.web-ux/` is gitignored, files are written with -mode `0600`, state contents are never copied into a review bundle, and the CLI never prints cookies -or credentials. Each profile has a sidecar binding it to the exact persona and base URL origin with -a 12-hour default expiry. Capture fails explicitly when metadata is missing, the origin differs, or -the profile has expired; it never silently reuses or refreshes that state. +Authentication state is local sensitive material stored under `$XDG_STATE_HOME/yoi/web-ux/auth/`, +outside the Repository and Workdir. Files are written with mode `0600`, state contents are never +copied into a review bundle, and the CLI never prints cookies or credentials. Each profile has a +sidecar binding it to the exact persona and base URL origin with a 12-hour default expiry. Capture +fails explicitly when metadata is missing, the origin differs, or the profile has expired; it never +silently reuses or refreshes that state. For an interactive Passkey/browser login: @@ -125,11 +127,15 @@ The command exits `2` when it produced evidence but observed UI/tool errors, and capture itself failed. It continues other route/persona captures after a bounded route failure. Inspect: -- `review-context.json` for the exact context, hashes, HTTP status, and failures; -- `contact-sheet.png` with an image-capable reviewer for composition, hierarchy, density, clipping, - empty/error states, and permission-specific affordances; -- each `accessibility.md` for landmark/name/state evidence that a screenshot cannot prove; -- `process-logs/` when the scenario owns a server process. Logs are redacted before writing. +- `review-context.json` for the exact context, hashes, HTTP status, retained/truncated diagnostic + counts, route and capture-point readiness, and the redacted interaction sequence; +- `contact-sheet.png` through its manifest `workdirPath` with an image-capable reviewer for + composition, hierarchy, density, clipping, empty/error states, and permission-specific + affordances; +- each `accessibility.md` through its manifest `workdirPath` for landmark/name/state evidence that a + screenshot cannot prove; +- `process-logs/` when the scenario owns a server process. Each stdout/stderr stream is redacted, + capped at 1 MiB, and paired with truncation metadata. The implementing agent must inspect the actual contact sheet (for example with `ViewImage`), record concrete findings, fix them, recapture under the same persona/route/viewport filters, and inspect @@ -166,10 +172,10 @@ deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 ``` -Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and the repository -`target/` tree are ignored by Git. `capture` defaults to `target/web-ux` when `--output` is omitted. -Keep a bundle outside Git or publish it through the approved immutable artifact channel when durable -review evidence is required. +Cleanup recognizes only directories containing `review-context.json`. The repository `target/` tree +is ignored by Git, while authentication state remains outside the repository. `capture` defaults to +`target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the +approved immutable artifact channel when durable review evidence is required. ## Adding a scenario diff --git a/tools/web-ux/browser-tests/capture_smoke_test.ts b/tools/web-ux/browser-tests/capture_smoke_test.ts index cce16b14..550f1689 100644 --- a/tools/web-ux/browser-tests/capture_smoke_test.ts +++ b/tools/web-ux/browser-tests/capture_smoke_test.ts @@ -12,6 +12,9 @@ async function freePort(): Promise { Deno.test("browser smoke captures distinct owner and non-owner evidence and cleans its server", async () => { const directory = await Deno.makeTempDir(); + const previousSecret = Deno.env.get("WEB_UX_FIXTURE_SECRET"); + const fixtureSecret = "fixture-canary-secret"; + Deno.env.set("WEB_UX_FIXTURE_SECRET", fixtureSecret); const port = await freePort(); const baseUrl = `http://127.0.0.1:${port}`; try { @@ -45,6 +48,10 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea id: "browser-smoke", title: "Browser smoke", baseUrl, + redact: { + selectors: ["[data-web-ux-redact]"], + text: ["${WEB_UX_FIXTURE_SECRET}"], + }, personas: [ { id: "owner", label: "Owner", auth: { kind: "storage-state", path: "auth/owner.json" } }, { @@ -61,17 +68,26 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea goal: "Verify permission-specific composition", dataState: "Deterministic fixture repository", ready: { kind: "selector", selector: "main" }, - capturePoints: [{ id: "initial", label: "Initial" }], + capturePoints: [{ + id: "initial", + label: "Initial", + interaction: [{ + action: "wait", + ready: { kind: "selector", selector: "h1" }, + }], + }], }], processes: [{ id: "fixture-server", command: Deno.execPath(), args: [ "run", + "--allow-env", "--allow-net", join(Deno.cwd(), "browser-tests/fixture_server.ts"), String(port), ], + env: { WEB_UX_FIXTURE_SECRET: "${WEB_UX_FIXTURE_SECRET}" }, readyUrl: `${baseUrl}/health`, }], }), @@ -81,15 +97,28 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea outputDirectory: join(directory, "artifacts"), runId: "multi-persona", }); - assertEquals(manifest.status, "completed"); + assertEquals(manifest.status, "completed-with-errors"); assertEquals(manifest.captures.map((item) => item.persona.id), ["owner", "non-owner"]); assertEquals(manifest.captures.every((item) => item.screenshots.length === 1), true); - assertEquals(manifest.contactSheet.png, "contact-sheet.png"); + assertEquals(manifest.captures[0].route.ready.kind, "selector"); + assertEquals(manifest.captures[0].interactions[0].action, "wait"); + assertEquals(manifest.captures[0].errorSummary, { + observed: 150, + retained: 100, + truncated: true, + limit: 100, + }); + assertEquals(manifest.contactSheet.png?.bundlePath, "contact-sheet.png"); const runDirectory = join(directory, "artifacts", "multi-persona"); const reviewContext = await Deno.readTextFile(join(runDirectory, "review-context.json")); assertEquals(reviewContext.includes('"cookies"'), false); + assertEquals(reviewContext.includes(fixtureSecret), false); + const processLog = await Deno.readTextFile( + join(runDirectory, "process-logs", "fixture-server.stdout.log"), + ); + assertEquals(processLog.includes(fixtureSecret), false); if (Deno.build.os !== "windows") { - const screenshot = join(runDirectory, manifest.captures[0].screenshots[0].path); + const screenshot = join(runDirectory, manifest.captures[0].screenshots[0].bundlePath); assertEquals((await Deno.stat(screenshot)).mode! & 0o777, 0o600); } await assertRejects( @@ -97,6 +126,8 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea TypeError, ); } finally { + if (previousSecret === undefined) Deno.env.delete("WEB_UX_FIXTURE_SECRET"); + else Deno.env.set("WEB_UX_FIXTURE_SECRET", previousSecret); await Deno.remove(directory, { recursive: true }); } }); diff --git a/tools/web-ux/browser-tests/fixture_server.ts b/tools/web-ux/browser-tests/fixture_server.ts index 7aa607f6..e2de13f5 100644 --- a/tools/web-ux/browser-tests/fixture_server.ts +++ b/tools/web-ux/browser-tests/fixture_server.ts @@ -1,6 +1,9 @@ const port = Number(Deno.args[0]); if (!Number.isInteger(port) || port <= 0) throw new Error("port is required"); +const canary = Deno.env.get("WEB_UX_FIXTURE_SECRET") ?? ""; +console.log(`Authorization: Bearer ${canary}`); + Deno.serve({ hostname: "127.0.0.1", port }, (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); @@ -11,7 +14,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, (request) => { ? '' : '

Ask a Workspace owner to change repository access.

'; return new Response( - `${title}
Workspace

${title}

main

SSH repository access is configured.

${action}
`, + `${title}
Workspace

${title}

main

SSH repository access is configured.

${action}${canary}
`, { headers: { "content-type": "text/html; charset=utf-8" } }, ); }); diff --git a/tools/web-ux/scenarios/workspace-control-plane.json b/tools/web-ux/scenarios/workspace-control-plane.json index e23962fc..1b548f29 100644 --- a/tools/web-ux/scenarios/workspace-control-plane.json +++ b/tools/web-ux/scenarios/workspace-control-plane.json @@ -15,13 +15,16 @@ { "id": "owner", "label": "Workspace owner", - "auth": { "kind": "storage-state", "path": "../../../.web-ux/auth/owner.json" }, + "auth": { "kind": "storage-state", "path": "${XDG_STATE_HOME}/yoi/web-ux/auth/owner.json" }, "login": { "path": "/", "successUrl": "/w/" } }, { "id": "non-owner", "label": "Authenticated non-owner", - "auth": { "kind": "storage-state", "path": "../../../.web-ux/auth/non-owner.json" }, + "auth": { + "kind": "storage-state", + "path": "${XDG_STATE_HOME}/yoi/web-ux/auth/non-owner.json" + }, "login": { "path": "/", "successUrl": "/w/" } } ], diff --git a/tools/web-ux/src/artifacts.ts b/tools/web-ux/src/artifacts.ts index ebfbc178..64db7585 100644 --- a/tools/web-ux/src/artifacts.ts +++ b/tools/web-ux/src/artifacts.ts @@ -62,13 +62,70 @@ export async function writePrivateJson(path: string, value: unknown): Promise { + const files: string[] = []; + const visit = async (directory: string) => { + for await (const entry of Deno.readDir(directory)) { + const path = resolve(directory, entry.name); + if (entry.isDirectory) await visit(path); + else if (entry.isFile) files.push(path); + } + }; + await visit(root); + return files; +} + +export async function assertReviewBundleIsSecretFree( + root: string, + exactSecrets: string[] = [], +): Promise { + const secrets = exactSecrets.filter(Boolean); + const overlapLength = Math.max(256, ...secrets.map((secret) => secret.length + 1)); + for (const path of await artifactFiles(root)) { + const isText = TEXT_ARTIFACT_EXTENSIONS.some((extension) => path.endsWith(extension)); + const file = await Deno.open(path, { read: true }); + const decoder = new TextDecoder(); + let overlap = ""; + try { + const buffer = new Uint8Array(64 * 1024); + while (true) { + const count = await file.read(buffer); + if (count === null) break; + const content = overlap + decoder.decode(buffer.subarray(0, count), { stream: true }); + for (const secret of secrets) { + if (content.includes(secret)) { + throw new Error( + `review bundle artifact contains configured secret text: ${relative(root, path)}`, + ); + } + } + if (isText) assertBundleIsSecretFree(content, secrets); + overlap = content.slice(-overlapLength); + } + const final = overlap + decoder.decode(); + for (const secret of secrets) { + if (final.includes(secret)) { + throw new Error( + `review bundle artifact contains configured secret text: ${relative(root, path)}`, + ); + } + } + if (isText) assertBundleIsSecretFree(final, secrets); + } finally { + file.close(); + } + } } export async function sha256File(path: string): Promise { diff --git a/tools/web-ux/src/capture.ts b/tools/web-ux/src/capture.ts index 4608723e..05ddc18c 100644 --- a/tools/web-ux/src/capture.ts +++ b/tools/web-ux/src/capture.ts @@ -3,12 +3,14 @@ import { type Browser, chromium, type Page, type Response } from "playwright"; import { validateAuthState } from "./auth_state.ts"; import { assertBundleIsSecretFree, + assertReviewBundleIsSecretFree, bounded, ensurePrivateDirectory, makePrivate, redactText, safeUrl, sha256File, + workdirLogicalPath, } from "./artifacts.ts"; import { type RunningProcess, startOwnedProcesses, stopOwnedProcesses } from "./processes.ts"; import { @@ -21,7 +23,9 @@ import type { CaptureError, CaptureEvidence, CapturePoint, + DiagnosticSummary, Interaction, + InteractionEvidence, Persona, ReadyCondition, ReviewContext, @@ -43,6 +47,32 @@ export type CaptureOptions = { }; type SourceState = { revision: string | null; dirty: boolean | null }; +type ErrorCollector = { errors: CaptureError[]; observed: number; limit: number }; + +const CAPTURE_ERROR_LIMIT = 100; + +function recordError(collector: ErrorCollector, error: CaptureError): void { + collector.observed++; + if (collector.errors.length < collector.limit) collector.errors.push(error); +} + +function errorSummary(collector: ErrorCollector): DiagnosticSummary { + return { + observed: collector.observed, + retained: collector.errors.length, + truncated: collector.observed > collector.errors.length, + limit: collector.limit, + }; +} + +function interactionEvidence(interaction: Interaction): InteractionEvidence { + if (interaction.action === "wait") return { action: "wait", ready: interaction.ready }; + if (interaction.action === "click") return { action: "click", selector: interaction.selector }; + if (interaction.action === "fill") { + return { action: "fill", selector: interaction.selector, value: "[REDACTED]" }; + } + return { action: "press", selector: interaction.selector, key: interaction.key }; +} function slug(value: string): string { return value.replaceAll(/[^a-zA-Z0-9.-]+/g, "-").replaceAll(/^-+|-+$/g, "").toLowerCase(); @@ -73,6 +103,20 @@ async function sourceState(): Promise { } } +async function repositoryRoot(): Promise { + try { + const result = await new Deno.Command("git", { + args: ["rev-parse", "--show-toplevel"], + stdout: "piped", + stderr: "null", + }).output(); + if (result.success) return resolve(new TextDecoder().decode(result.stdout).trim()); + } catch { + // Fall back to the invocation directory outside a Git checkout. + } + return resolve(Deno.cwd()); +} + function selectById( values: T[], requested: string[] | undefined, @@ -162,7 +206,7 @@ export function isVisibleUiErrorText(content: string): boolean { async function collectVisibleUiErrors( page: Page, - errors: CaptureError[], + collector: ErrorCollector, secrets: string[], ): Promise { const alerts = page.locator('[role="alert"], [aria-live="assertive"]'); @@ -172,8 +216,10 @@ async function collectVisibleUiErrors( const content = (await alert.innerText().catch(() => "")).trim(); if (!isVisibleUiErrorText(content)) continue; const message = `visible UI error: ${bounded(redactText(content, secrets), 500)}`; - if (!errors.some((error) => error.kind === "document" && error.message === message)) { - errors.push({ kind: "document", message }); + if ( + !collector.errors.some((error) => error.kind === "document" && error.message === message) + ) { + recordError(collector, { kind: "document", message }); } } } @@ -181,19 +227,24 @@ async function collectVisibleUiErrors( async function capturePoint( page: Page, runDirectory: string, + repositoryRoot: string, persona: Persona, route: RouteScenario, viewport: Viewport, point: CapturePoint, documentResponse: Response | null, - errors: CaptureError[], + collector: ErrorCollector, + executedInteractions: InteractionEvidence[], scenario: Scenario, ): Promise { const startedAt = new Date().toISOString(); - for (const interaction of point.interaction ?? []) await performInteraction(page, interaction); + for (const interaction of point.interaction ?? []) { + await performInteraction(page, interaction); + executedInteractions.push(interactionEvidence(interaction)); + } if (point.ready) await waitReady(page, point.ready); await hideRedactedSelectors(page, scenario.redact?.selectors ?? []); - await collectVisibleUiErrors(page, errors, scenario.redact?.text ?? []); + await collectVisibleUiErrors(page, collector, scenario.redact?.text ?? []); const directory = join( runDirectory, "captures", @@ -208,7 +259,8 @@ async function capturePoint( await makePrivate(viewportScreenshot); const screenshots: ScreenshotEvidence[] = [{ kind: "viewport", - path: relative(runDirectory, viewportScreenshot), + bundlePath: relative(runDirectory, viewportScreenshot), + workdirPath: workdirLogicalPath(repositoryRoot, viewportScreenshot), sha256: await sha256File(viewportScreenshot), }]; if (point.fullPage) { @@ -217,19 +269,23 @@ async function capturePoint( await makePrivate(fullPageScreenshot); screenshots.push({ kind: "full-page", - path: relative(runDirectory, fullPageScreenshot), + bundlePath: relative(runDirectory, fullPageScreenshot), + workdirPath: workdirLogicalPath(repositoryRoot, fullPageScreenshot), sha256: await sha256File(fullPageScreenshot), }); } - let snapshotPath: string | null = null; + let snapshot: { bundlePath: string; workdirPath: string | null } | null = null; try { - const snapshot = await page.locator("body").ariaSnapshot({ timeout: 5_000 }); - const redacted = redactText(snapshot, scenario.redact?.text ?? []); + const accessibility = await page.locator("body").ariaSnapshot({ timeout: 5_000 }); + const redacted = redactText(accessibility, scenario.redact?.text ?? []); const target = join(directory, "accessibility.md"); await Deno.writeTextFile(target, redacted, { mode: 0o600 }); - snapshotPath = relative(runDirectory, target); + snapshot = { + bundlePath: relative(runDirectory, target), + workdirPath: workdirLogicalPath(repositoryRoot, target), + }; } catch (error) { - errors.push({ + recordError(collector, { kind: "tool", message: `accessibility snapshot failed: ${ bounded(error instanceof Error ? error.message : String(error)) @@ -238,14 +294,22 @@ async function capturePoint( } return { persona: { id: persona.id, label: persona.label }, - route: { id: route.id, path: route.path, goal: route.goal, dataState: route.dataState }, + route: { + id: route.id, + path: route.path, + goal: route.goal, + dataState: route.dataState, + ready: route.ready, + }, viewport, theme: scenario.colorScheme ?? "light", - capturePoint: { id: point.id, label: point.label }, + capturePoint: { id: point.id, label: point.label, ready: point.ready ?? null }, + interactions: [...executedInteractions], document: { url: safeUrl(page.url()), status: documentResponse?.status() ?? null }, screenshots, - snapshotPath, - errors: [...errors], + snapshot, + errors: [...collector.errors], + errorSummary: errorSummary(collector), startedAt, finishedAt: new Date().toISOString(), }; @@ -267,14 +331,18 @@ function escapeHtml(value: string): string { async function createContactSheet( browser: Browser, runDirectory: string, + repositoryRoot: string, captures: CaptureEvidence[], -): Promise<{ html: string | null; png: string | null }> { +): Promise<{ + html: { bundlePath: string; workdirPath: string | null } | null; + png: { bundlePath: string; workdirPath: string | null } | null; +}> { const cells: string[] = []; for (const capture of captures) { const screenshot = capture.screenshots.find((item) => item.kind === "viewport") ?? capture.screenshots[0]; if (!screenshot) continue; - const bytes = await Deno.readFile(join(runDirectory, screenshot.path)); + const bytes = await Deno.readFile(join(runDirectory, screenshot.bundlePath)); cells.push( `
${ escapeHtml(capture.persona.label) @@ -305,11 +373,21 @@ async function createContactSheet( } finally { await page.close(); } - return { html: relative(runDirectory, htmlPath), png: relative(runDirectory, pngPath) }; + return { + html: { + bundlePath: relative(runDirectory, htmlPath), + workdirPath: workdirLogicalPath(repositoryRoot, htmlPath), + }, + png: { + bundlePath: relative(runDirectory, pngPath), + workdirPath: workdirLogicalPath(repositoryRoot, pngPath), + }, + }; } export async function capture(options: CaptureOptions): Promise { const scenarioPath = resolve(options.scenarioPath); + const repository = await repositoryRoot(); const scenario = await loadScenario(scenarioPath); const baseUrl = validateBaseUrl( interpolateEnvironment( @@ -332,8 +410,13 @@ export async function capture(options: CaptureOptions): Promise { let browser: Browser | null = null; let processes: RunningProcess[] = []; const captures: CaptureEvidence[] = []; - const diagnostics: CaptureError[] = []; - let contactSheet = { html: null as string | null, png: null as string | null }; + const globalCollector: ErrorCollector = { + errors: [], + observed: 0, + limit: CAPTURE_ERROR_LIMIT, + }; + const diagnostics = globalCollector.errors; + let contactSheet: ReviewContext["contactSheet"] = { html: null, png: null }; let browserVersion = "unknown"; let status: ReviewContext["status"] = "completed"; try { @@ -362,11 +445,17 @@ export async function capture(options: CaptureOptions): Promise { }); try { for (const route of routes) { - const routeErrors: CaptureError[] = []; + const routeCollector: ErrorCollector = { + errors: [], + observed: 0, + limit: CAPTURE_ERROR_LIMIT, + }; + const routeErrors = routeCollector.errors; + const executedInteractions: InteractionEvidence[] = []; const page = await context.newPage(); page.on("console", (message) => { if (message.type() === "error") { - routeErrors.push({ + recordError(routeCollector, { kind: "console", message: bounded(redactText(message.text(), secrets)), }); @@ -375,7 +464,7 @@ export async function capture(options: CaptureOptions): Promise { page.on( "pageerror", (error) => - routeErrors.push({ + recordError(routeCollector, { kind: "page", message: bounded(redactText(error.message, secrets)), }), @@ -383,7 +472,7 @@ export async function capture(options: CaptureOptions): Promise { page.on( "requestfailed", (request) => - routeErrors.push({ + recordError(routeCollector, { kind: "request", message: bounded( redactText(request.failure()?.errorText ?? "request failed", secrets), @@ -393,7 +482,7 @@ export async function capture(options: CaptureOptions): Promise { ); page.on("response", (response) => { if (response.status() >= 400) { - routeErrors.push({ + recordError(routeCollector, { kind: "request", message: `HTTP ${response.status()}`, url: safeUrl(response.url()), @@ -426,7 +515,7 @@ export async function capture(options: CaptureOptions): Promise { } }); if (response && response.status() >= 400) { - routeErrors.push({ + recordError(routeCollector, { kind: "document", message: `document returned HTTP ${response.status()}`, url: safeUrl(response.url()), @@ -438,19 +527,21 @@ export async function capture(options: CaptureOptions): Promise { await capturePoint( page, runDirectory, + repository, persona, route, viewport, point, response, - routeErrors, + routeCollector, + executedInteractions, scenario, ), ); } } catch (error) { status = "completed-with-errors"; - routeErrors.push({ + recordError(routeCollector, { kind: "tool", message: bounded( redactText(error instanceof Error ? error.message : String(error), secrets), @@ -463,14 +554,17 @@ export async function capture(options: CaptureOptions): Promise { path: route.path, goal: route.goal, dataState: route.dataState, + ready: route.ready, }, viewport, theme: scenario.colorScheme ?? "light", - capturePoint: { id: "failed", label: "Capture failed" }, + capturePoint: { id: "failed", label: "Capture failed", ready: null }, + interactions: [...executedInteractions], document: { url: safeUrl(page.url()), status: null }, screenshots: [], - snapshotPath: null, - errors: routeErrors, + snapshot: null, + errors: [...routeErrors], + errorSummary: errorSummary(routeCollector), startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), }); @@ -483,24 +577,24 @@ export async function capture(options: CaptureOptions): Promise { } } } - contactSheet = await createContactSheet(browser, runDirectory, captures); + contactSheet = await createContactSheet(browser, runDirectory, repository, captures); if (captures.some((capture) => capture.errors.length > 0)) status = "completed-with-errors"; } catch (error) { status = "failed"; - diagnostics.push({ + recordError(globalCollector, { kind: "tool", message: bounded(redactText(error instanceof Error ? error.message : String(error), secrets)), }); } finally { if (browser) { await browser.close().catch((error) => - diagnostics.push({ + recordError(globalCollector, { kind: "tool", message: `browser cleanup failed: ${bounded(String(error))}`, }) ); } - diagnostics.push(...await stopOwnedProcesses(processes)); + for (const error of await stopOwnedProcesses(processes)) recordError(globalCollector, error); } if (diagnostics.length > 0 && status === "completed") status = "completed-with-errors"; const manifest: ReviewContext = { @@ -509,7 +603,7 @@ export async function capture(options: CaptureOptions): Promise { scenario: { id: scenario.id, title: scenario.title, - sourcePath: relative(Deno.cwd(), scenarioPath), + sourcePath: workdirLogicalPath(repository, scenarioPath), }, source: await sourceState(), baseUrl: safeUrl(baseUrl), @@ -524,10 +618,12 @@ export async function capture(options: CaptureOptions): Promise { captures, contactSheet, diagnostics, + diagnosticSummary: errorSummary(globalCollector), }; const serialized = `${JSON.stringify(manifest, null, 2)}\n`; assertBundleIsSecretFree(serialized, secrets); await Deno.writeTextFile(join(runDirectory, "review-context.json"), serialized, { mode: 0o600 }); + await assertReviewBundleIsSecretFree(runDirectory, secrets); if (status === "failed") { throw new Error(`capture failed; inspect ${join(runDirectory, "review-context.json")}`); } diff --git a/tools/web-ux/src/compare.ts b/tools/web-ux/src/compare.ts index 05f8d767..a6b95fca 100644 --- a/tools/web-ux/src/compare.ts +++ b/tools/web-ux/src/compare.ts @@ -39,8 +39,8 @@ async function readManifest(path: string): Promise { } function viewportScreenshot(capture: CaptureEvidence): string | null { - return capture.screenshots.find((item) => item.kind === "viewport")?.path ?? - capture.screenshots[0]?.path ?? null; + return capture.screenshots.find((item) => item.kind === "viewport")?.bundlePath ?? + capture.screenshots[0]?.bundlePath ?? null; } function dataUrl(bytes: Uint8Array): string { diff --git a/tools/web-ux/src/processes.ts b/tools/web-ux/src/processes.ts index 7fca0876..27082f15 100644 --- a/tools/web-ux/src/processes.ts +++ b/tools/web-ux/src/processes.ts @@ -1,11 +1,15 @@ import { dirname, isAbsolute, resolve } from "@std/path"; -import { bounded, redactText } from "./artifacts.ts"; +import { bounded, redactText, writePrivateJson } from "./artifacts.ts"; import type { CaptureError, OwnedProcess } from "./types.ts"; +export const PROCESS_LOG_BYTE_LIMIT = 1024 * 1024; +const PROCESS_STOP_TIMEOUT_MS = 3_000; + export type RunningProcess = { id: string; pid: number; child: Deno.ChildProcess; + status: Promise; output: Promise; }; @@ -20,15 +24,45 @@ async function appendOutput( write: true, mode: 0o600, }); + const encoder = new TextEncoder(); + const overlapCharacters = Math.max(512, ...secrets.map((secret) => secret.length + 128)); + let pending = ""; + let bytesObserved = 0; + let bytesWritten = 0; + let truncated = false; + const writeRedacted = async (value: string) => { + const encoded = encoder.encode(redactText(value, secrets)); + const remaining = Math.max(0, PROCESS_LOG_BYTE_LIMIT - bytesWritten); + if (encoded.length > remaining) truncated = true; + if (remaining > 0) { + const output = encoded.subarray(0, remaining); + await file.write(output); + bytesWritten += output.length; + } + }; try { const reader = stream.pipeThrough(new TextDecoderStream()).getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; - await file.write(new TextEncoder().encode(redactText(value, secrets))); + bytesObserved += encoder.encode(value).length; + pending += value; + if (pending.length > overlapCharacters * 2) { + const splitAt = pending.length - overlapCharacters; + await writeRedacted(pending.slice(0, splitAt)); + pending = pending.slice(splitAt); + } } + await writeRedacted(pending); } finally { file.close(); + await writePrivateJson(`${destination}.meta.json`, { + schemaVersion: 1, + byteLimit: PROCESS_LOG_BYTE_LIMIT, + bytesObserved, + bytesWritten, + truncated, + }); } } @@ -83,17 +117,19 @@ export async function startOwnedProcesses( `${logsDirectory}/${specification.id}.stderr.log`, secrets, ); + const status = child.status; const process = { id: specification.id, pid: child.pid, child, + status, output: Promise.all([stdout, stderr]).then(() => undefined), }; running.push(process); if (specification.readyUrl) { await Promise.race([ waitForReady(specification.readyUrl, specification.readyTimeoutMs ?? 30_000), - child.status.then((status) => { + status.then((status) => { throw new Error( `owned process ${specification.id} exited before readiness: ${status.code}`, ); @@ -145,23 +181,64 @@ function tryKill(pid: number, signal: Deno.Signal): void { } } +async function livePids(pids: number[]): Promise { + if (Deno.build.os === "windows") return []; + if (pids.length === 0) return []; + try { + const result = await new Deno.Command("ps", { + args: ["-o", "pid=", "-p", pids.join(",")], + stdout: "piped", + stderr: "null", + }).output(); + if (!result.success && result.code !== 1) return pids; + const live = new Set( + new TextDecoder().decode(result.stdout).trim().split(/\s+/).map(Number).filter( + Number.isFinite, + ), + ); + return pids.filter((pid) => live.has(pid)); + } catch { + return pids; + } +} + +async function waitForPidsToExit(pids: number[], timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let live = await livePids(pids); + while (live.length > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + live = await livePids(live); + } + return live; +} + export async function stopOwnedProcesses(processes: RunningProcess[]): Promise { const diagnostics: CaptureError[] = []; for (const process of [...processes].reverse()) { try { - for (const pid of await descendantPids(process.pid)) tryKill(pid, "SIGTERM"); + const descendants = await descendantPids(process.pid); tryKill(process.pid, "SIGTERM"); + for (const pid of descendants) tryKill(pid, "SIGTERM"); let timer: number | undefined; - const exited = await Promise.race([ - process.child.status.then(() => true), - new Promise((resolve) => { - timer = setTimeout(() => resolve(false), 3_000); - }), - ]).finally(() => clearTimeout(timer)); - if (!exited) { - for (const pid of await descendantPids(process.pid)) tryKill(pid, "SIGKILL"); + const [parentExited, liveDescendants] = await Promise.all([ + Promise.race([ + process.status.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), PROCESS_STOP_TIMEOUT_MS); + }), + ]).finally(() => clearTimeout(timer)), + waitForPidsToExit(descendants, PROCESS_STOP_TIMEOUT_MS), + ]); + if (!parentExited || liveDescendants.length > 0) { + const lateDescendants = await descendantPids(process.pid); + const forceTargets = [...new Set([...liveDescendants, ...lateDescendants])]; + for (const pid of forceTargets) tryKill(pid, "SIGKILL"); tryKill(process.pid, "SIGKILL"); - await process.child.status; + await process.status; + const survivors = await waitForPidsToExit(forceTargets, 1_000); + if (survivors.length > 0) { + throw new Error(`descendant processes did not exit: ${survivors.join(",")}`); + } } await process.output; } catch (error) { diff --git a/tools/web-ux/src/scenario.ts b/tools/web-ux/src/scenario.ts index b09fbd53..fa0b7796 100644 --- a/tools/web-ux/src/scenario.ts +++ b/tools/web-ux/src/scenario.ts @@ -70,6 +70,9 @@ function parseCapturePoint(value: unknown, at: string): CapturePoint { if (source.ready !== undefined) result.ready = parseReady(source.ready, `${at}.ready`); if (source.interaction !== undefined) { if (!Array.isArray(source.interaction)) throw new Error(`${at}.interaction must be an array`); + if (source.interaction.length > 20) { + throw new Error(`${at}.interaction must not exceed 20 items`); + } result.interaction = source.interaction.map((raw, index) => { const action = record(raw, `${at}.interaction[${index}]`); const name = text(action.action, `${at}.interaction[${index}].action`); @@ -148,6 +151,9 @@ function parseRoute(value: unknown, at: string): RouteScenario { if (!Array.isArray(source.capturePoints) || source.capturePoints.length === 0) { throw new Error(`${at}.capturePoints must have at least one item`); } + if (source.capturePoints.length > 12) { + throw new Error(`${at}.capturePoints must not exceed 12 items`); + } return { id: identifier(source.id, `${at}.id`), label: text(source.label, `${at}.label`), @@ -201,12 +207,15 @@ export async function loadScenario(sourcePath: string): Promise { if (!Array.isArray(source.personas) || source.personas.length === 0) { throw new Error("scenario.personas must have at least one item"); } + if (source.personas.length > 8) throw new Error("scenario.personas must not exceed 8 items"); if (!Array.isArray(source.viewports) || source.viewports.length === 0) { throw new Error("scenario.viewports must have at least one item"); } + if (source.viewports.length > 8) throw new Error("scenario.viewports must not exceed 8 items"); if (!Array.isArray(source.routes) || source.routes.length === 0) { throw new Error("scenario.routes must have at least one item"); } + if (source.routes.length > 40) throw new Error("scenario.routes must not exceed 40 items"); const personas = source.personas.map((value, index) => parsePersona(value, `scenario.personas[${index}]`) ); @@ -239,6 +248,7 @@ export async function loadScenario(sourcePath: string): Promise { }; if (source.processes !== undefined) { if (!Array.isArray(source.processes)) throw new Error("scenario.processes must be an array"); + if (source.processes.length > 8) throw new Error("scenario.processes must not exceed 8 items"); scenario.processes = source.processes.map((value, index) => { const at = `scenario.processes[${index}]`; const process = record(value, at); @@ -266,5 +276,10 @@ export async function loadScenario(sourcePath: string): Promise { uniqueIds(personas, "scenario.personas"); uniqueIds(routes, "scenario.routes"); for (const route of routes) uniqueIds(route.capturePoints, `route ${route.id} capturePoints`); + const captureCount = personas.length * scenario.viewports.length * + routes.reduce((total, route) => total + route.capturePoints.length, 0); + if (captureCount > 200) { + throw new Error(`scenario capture matrix must not exceed 200 items (received ${captureCount})`); + } return scenario; } diff --git a/tools/web-ux/src/types.ts b/tools/web-ux/src/types.ts index 296639a5..bfee1633 100644 --- a/tools/web-ux/src/types.ts +++ b/tools/web-ux/src/types.ts @@ -80,22 +80,51 @@ export type CaptureError = { status?: number; }; -export type ScreenshotEvidence = { +export type ArtifactReference = { + bundlePath: string; + workdirPath: string | null; +}; + +export type ScreenshotEvidence = ArtifactReference & { kind: "viewport" | "full-page"; - path: string; sha256: string; }; +export type InteractionEvidence = + | { action: "click"; selector: string } + | { action: "fill"; selector: string; value: "[REDACTED]" } + | { action: "press"; selector: string; key: string } + | { action: "wait"; ready: ReadyCondition }; + +export type DiagnosticSummary = { + observed: number; + retained: number; + truncated: boolean; + limit: number; +}; + export type CaptureEvidence = { persona: { id: string; label: string }; - route: { id: string; path: string; goal: string; dataState: string }; + route: { + id: string; + path: string; + goal: string; + dataState: string; + ready: ReadyCondition; + }; viewport: Viewport; theme: string; - capturePoint: { id: string; label: string }; + capturePoint: { + id: string; + label: string; + ready: ReadyCondition | null; + }; + interactions: InteractionEvidence[]; document: { url: string; status: number | null }; screenshots: ScreenshotEvidence[]; - snapshotPath: string | null; + snapshot: ArtifactReference | null; errors: CaptureError[]; + errorSummary: DiagnosticSummary; startedAt: string; finishedAt: string; }; @@ -103,7 +132,7 @@ export type CaptureEvidence = { export type ReviewContext = { schemaVersion: 1; runId: string; - scenario: { id: string; title: string; sourcePath: string }; + scenario: { id: string; title: string; sourcePath: string | null }; source: { revision: string | null; dirty: boolean | null }; baseUrl: string; browser: { name: "chromium"; version: string }; @@ -111,6 +140,10 @@ export type ReviewContext = { status: "completed" | "completed-with-errors" | "failed"; filters: { personas: string[]; routes: string[]; viewports: string[] }; captures: CaptureEvidence[]; - contactSheet: { html: string | null; png: string | null }; + contactSheet: { + html: ArtifactReference | null; + png: ArtifactReference | null; + }; diagnostics: CaptureError[]; + diagnosticSummary: DiagnosticSummary; }; diff --git a/tools/web-ux/tests/lifecycle_test.ts b/tools/web-ux/tests/lifecycle_test.ts index 6e5775b3..735a2111 100644 --- a/tools/web-ux/tests/lifecycle_test.ts +++ b/tools/web-ux/tests/lifecycle_test.ts @@ -6,7 +6,11 @@ import { safeUrl, writePrivateJson, } from "../src/artifacts.ts"; -import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +import { + PROCESS_LOG_BYTE_LIMIT, + startOwnedProcesses, + stopOwnedProcesses, +} from "../src/processes.ts"; Deno.test("redaction removes common credentials and query values", () => { const redacted = redactText( @@ -69,6 +73,99 @@ Deno.test("owned process is terminated and its logs are redacted", async () => { const log = await Deno.readTextFile(logPath); assertEquals(log.includes("secret-value"), false); assertStringIncludes(log, "[REDACTED]"); + const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`)); + assertEquals(metadata.truncated, false); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("owned process logs stop at the byte limit and record truncation", async () => { + const directory = await Deno.makeTempDir(); + const scenario = join(directory, "scenario.json"); + await Deno.writeTextFile(scenario, "{}"); + try { + const processes = await startOwnedProcesses( + [{ + id: "large-output", + command: Deno.execPath(), + args: [ + "eval", + `console.log("x".repeat(${ + PROCESS_LOG_BYTE_LIMIT + 32_768 + })); setInterval(() => {}, 1000)`, + ], + }], + scenario, + join(directory, "logs"), + [], + ); + const logPath = join(directory, "logs", "large-output.stdout.log"); + for (let attempt = 0; attempt < 100; attempt++) { + try { + if ((await Deno.stat(logPath)).size >= PROCESS_LOG_BYTE_LIMIT) break; + } catch { + // The output pump creates the file asynchronously. + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assertEquals(await stopOwnedProcesses(processes), []); + assertEquals((await Deno.stat(logPath)).size, PROCESS_LOG_BYTE_LIMIT); + const metadata = JSON.parse(await Deno.readTextFile(`${logPath}.meta.json`)); + assertEquals(metadata.byteLimit, PROCESS_LOG_BYTE_LIMIT); + assertEquals(metadata.truncated, true); + assertEquals(metadata.bytesWritten, PROCESS_LOG_BYTE_LIMIT); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("forced cleanup terminates a TERM-resistant descendant", async () => { + if (Deno.build.os === "windows") return; + const directory = await Deno.makeTempDir(); + const scenario = join(directory, "scenario.json"); + const childPidPath = join(directory, "child.pid"); + await Deno.writeTextFile(scenario, "{}"); + try { + const childProgram = 'Deno.addSignalListener("SIGTERM", () => {}); setInterval(() => {}, 1000)'; + const parentProgram = ` + const child = new Deno.Command(Deno.execPath(), { + args: ["eval", ${JSON.stringify(childProgram)}], + stdout: "null", + stderr: "null" + }).spawn(); + Deno.writeTextFileSync(Deno.args[0], String(child.pid)); + Deno.addSignalListener("SIGTERM", () => {}); + setInterval(() => {}, 1000); + `; + const processes = await startOwnedProcesses( + [{ + id: "process-tree", + command: Deno.execPath(), + args: ["eval", parentProgram, childPidPath], + }], + scenario, + join(directory, "logs"), + [], + ); + let childPid = 0; + for (let attempt = 0; attempt < 100; attempt++) { + try { + childPid = Number(await Deno.readTextFile(childPidPath)); + if (childPid > 0) break; + } catch { + // The fixture publishes its descendant PID after spawn. + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assertEquals(childPid > 0, true); + assertEquals(await stopOwnedProcesses(processes), []); + const status = await new Deno.Command("ps", { + args: ["-p", String(childPid), "-o", "pid="], + stdout: "piped", + stderr: "null", + }).output(); + assertEquals(new TextDecoder().decode(status.stdout).trim(), ""); } finally { await Deno.remove(directory, { recursive: true }); } diff --git a/tools/web-ux/tests/scenario_test.ts b/tools/web-ux/tests/scenario_test.ts index 961464ee..abbb4c1d 100644 --- a/tools/web-ux/tests/scenario_test.ts +++ b/tools/web-ux/tests/scenario_test.ts @@ -1,7 +1,12 @@ import { assertEquals, assertRejects, assertThrows } from "@std/assert"; import { join } from "@std/path"; import { cleanup } from "../src/lifecycle.ts"; -import { interpolateEnvironment, loadScenario, validateBaseUrl } from "../src/scenario.ts"; +import { + interpolateEnvironment, + loadScenario, + resolveScenarioPath, + validateBaseUrl, +} from "../src/scenario.ts"; function minimalScenario(extra = ""): string { return `{ @@ -77,6 +82,16 @@ Deno.test("environment interpolation fails closed", () => { ); }); +Deno.test("committed auth profiles resolve outside the repository", async () => { + const source = "scenarios/workspace-control-plane.json"; + const scenario = await loadScenario(source); + for (const persona of scenario.personas) { + if (persona.auth.kind !== "storage-state") continue; + const statePath = resolveScenarioPath(source, persona.auth.path); + assertEquals(statePath.startsWith(Deno.cwd()), false); + } +}); + Deno.test("cleanup removes only complete review bundles beyond retention", async () => { const directory = await Deno.makeTempDir(); try { From e96fde0632aac4d34f36883c1ad5773c5ae59767 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 00:01:16 +0900 Subject: [PATCH 04/11] feat: add durable Workdir removal authority --- .../src/feature/builtin/manage_workdir.rs | 64 +- crates/workspace-api/src/lib.rs | 28 + crates/workspace-server/src/lib.rs | 1 + crates/workspace-server/src/server.rs | 593 ++++++++--- crates/workspace-server/src/store.rs | 90 +- .../workspace-server/src/workdir_removal.rs | 952 ++++++++++++++++++ 6 files changed, 1522 insertions(+), 206 deletions(-) create mode 100644 crates/workspace-server/src/workdir_removal.rs diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index bd138eb7..74419af6 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -23,8 +23,9 @@ use workdir::{ use workspace_api::{ WorkingDirectoryCreateRequest as WorkdirCreateRequest, WorkingDirectoryCreateResponse as WorkdirCreateResponse, - WorkingDirectoryDetailResponse as WorkdirDetailResponse, WorkingDirectoryListResponse as WorkdirListResponse, + WorkingDirectoryRemovalRequest as WorkdirRemovalRequest, + WorkingDirectoryRemovalResponse as WorkdirRemovalResponse, }; use crate::feature::{ @@ -51,7 +52,7 @@ const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspac const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation."; const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session."; const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed."; -const DELETE_DESCRIPTION: &str = "Delete one persistent Workdir by id through Backend Workspace API authority. Occupied, blocked, or dirty Workdirs requiring confirmation are rejected."; +const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals."; #[derive(Clone, Debug)] pub struct ManageWorkdirFeature { @@ -484,12 +485,21 @@ impl WorkspaceHttpWorkdirBackend { )?; let workspace_id = encode_path_segment(self.workspace_id()?); let workdir_path = encode_path_segment(workdir_id); - let response = self.execute_json::(WorkspaceRequest { - method: WorkspaceRequestMethod::Delete, - path: format!("/api/w/{workspace_id}/working-directories/{workdir_path}"), - body: None, - })?; - workdir_output(format!("Deleted Workdir {workdir_id}"), &response) + let response = self.execute_json::(WorkspaceRequest::json( + WorkspaceRequestMethod::Delete, + format!("/api/w/{workspace_id}/working-directories/{workdir_path}"), + serde_json::to_string(&WorkdirRemovalRequest { + reason: validate_delete_reason(&input.reason)?.to_string(), + }) + .map_err(decode_error)?, + ))?; + workdir_output( + format!( + "Workdir {workdir_id} removal disposition: {:?}", + response.disposition + ), + &response, + ) } fn execute_json Deserialize<'de>>( @@ -611,6 +621,17 @@ fn validate_identity<'a>( Ok(value) } +fn validate_delete_reason(reason: &str) -> Result<&str, ToolError> { + let reason = reason.trim(); + if reason.is_empty() || reason.len() > 500 || reason.chars().any(char::is_control) { + return Err(ToolError::InvalidArgument( + "WorkdirDelete reason must be non-empty, contain no control characters, and be at most 500 bytes" + .to_string(), + )); + } + Ok(reason) +} + fn validate_optional_selector(selector: Option) -> Result, ToolError> { let Some(selector) = selector else { return Ok(None); @@ -689,9 +710,10 @@ fn delete_schema() -> serde_json::Value { json!({ "type": "object", "additionalProperties": false, - "required": ["working_directory_id"], + "required": ["working_directory_id", "reason"], "properties": { - "working_directory_id": {"type": "string", "minLength": 1} + "working_directory_id": {"type": "string", "minLength": 1}, + "reason": {"type": "string", "minLength": 1, "maxLength": 500} } }) } @@ -736,6 +758,7 @@ struct WorkdirAttachmentResponse { #[serde(deny_unknown_fields)] struct WorkdirDeleteInput { working_directory_id: String, + reason: String, } #[cfg(test)] @@ -959,7 +982,10 @@ mod tests { assert!(create["properties"].get("session_id").is_none()); assert_eq!(attach_schema()["required"], json!(["workdir_id"])); assert!(attach_schema()["properties"].get("session_id").is_none()); - assert_eq!(delete_schema()["required"], json!(["working_directory_id"])); + assert_eq!( + delete_schema()["required"], + json!(["working_directory_id", "reason"]) + ); } #[test] @@ -999,15 +1025,9 @@ mod tests { "attached": false })), response(json!({ - "workspace_id": "workspace/test", - "runtime_id": "runtime/one", - "item": { - "working_directory_id": "wd-created", - "repository_id": "main", - "materializer_kind": "local_git_worktree", - "status": "not_found" - }, - "diagnostics": [] + "working_directory_id": "wd-created", + "disposition": "removed", + "retryable": false })), ])); let backend = WorkspaceHttpWorkdirBackend::new(client.clone()); @@ -1052,6 +1072,7 @@ mod tests { backend .delete(WorkdirDeleteInput { working_directory_id: "wd-created".to_string(), + reason: "remove stale Workdir".to_string(), }) .unwrap(); @@ -1087,6 +1108,9 @@ mod tests { "/api/w/workspace%2Ftest/working-directories/wd-created" ); assert_eq!(requests[4].method, WorkspaceRequestMethod::Delete); + let body: serde_json::Value = + serde_json::from_str(requests[4].body.as_deref().unwrap()).unwrap(); + assert_eq!(body, json!({"reason": "remove stale Workdir"})); } #[tokio::test] diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 6860c5dc..8570dd26 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -397,6 +397,34 @@ pub struct WorkingDirectoryCleanupTarget { pub repository_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryRemovalRequest { + pub reason: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkingDirectoryRemovalDisposition { + Removed, + Retained, + AttentionRequired, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryRemovalResponse { + pub working_directory_id: String, + pub disposition: WorkingDirectoryRemovalDisposition, + pub retryable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub failure_category: Option, +} + /// Durable Workspace occupancy projection for one Workdir. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 30b301d6..87d47955 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -30,6 +30,7 @@ pub mod server; pub mod skills; pub mod store; pub mod workdir_create_operations; +mod workdir_removal; pub mod worker_source; pub mod workspace_catalog; mod workspace_subscription; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index e043a97d..2498265c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -70,10 +70,11 @@ use workspace_api::{ WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, - WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceExtensionPointState, - WorkspaceExtensionPoints, WorkspacePermissionSummary, WorkspaceRepositoryRecord, - WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, - WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, + WorkingDirectoryRemovalDisposition, WorkingDirectoryRemovalRequest, + WorkingDirectoryRemovalResponse, WorkspaceCatalogListResponse, WorkspaceCreateResponse, + WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspacePermissionSummary, + WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary, + WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; use crate::auth::{ @@ -140,6 +141,10 @@ use crate::store::{ WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind, }; +use crate::workdir_removal::{ + WorkdirRemovalDisposition, WorkdirRemovalOperation, WorkdirRemovalOperationState, + workdir_removal_intent, +}; use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest}; use crate::{Error, Result}; use worker_runtime::catalog::{ @@ -513,6 +518,7 @@ pub struct WorkspaceApi { workdir_sessions: Arc>, workdir_session_locks: Arc>>>>, worker_remove_locks: Arc>>>>, + workdir_remove_locks: Arc>>>>, worker_control_locks: Arc>>>>, } @@ -1633,6 +1639,7 @@ impl WorkspaceApi { workdir_sessions: Arc::new(Mutex::new(WorkdirSessionRegistry::default())), workdir_session_locks: Arc::new(Mutex::new(HashMap::new())), worker_remove_locks: Arc::new(Mutex::new(HashMap::new())), + workdir_remove_locks: Arc::new(Mutex::new(HashMap::new())), worker_control_locks: Arc::new(Mutex::new(HashMap::new())), }; if let Some(dispatcher) = worker_remove_dispatcher { @@ -1640,6 +1647,7 @@ impl WorkspaceApi { .install_executor(Arc::new(WorkspaceWorkerRemoveExecutor::new(&api))) .map_err(|message| Error::Config(message.to_string()))?; } + recover_workdir_removals(&api)?; Ok(api) } @@ -8979,9 +8987,34 @@ async fn scoped_runtime_working_directory_detail( async fn scoped_cleanup_runtime_working_directory( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { + worker_source: Option>, + request_actor: Option>, + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - cleanup_working_directory_for_runtime(api, &path.runtime_id, &path.working_directory_id) + let registered_runtime = registered_workdir_runtime_id(&api, &path.working_directory_id)?; + if registered_runtime != path.runtime_id { + return Err(ApiError::from(Error::WorkspacePermissionDenied( + "Workdir does not belong to the requested Runtime".to_string(), + ))); + } + let source_actor = if let Some(Extension(source)) = worker_source { + format!("worker:{}:{}", source.runtime_id, source.worker_id) + } else if let Some(Extension(actor)) = request_actor { + format!("account:{}", actor.account_id) + } else { + return Err(ApiError::from(Error::WorkspacePermissionDenied( + "Workdir removal requires authenticated source authority".to_string(), + ))); + }; + execute_workdir_removal( + &api, + &path.working_directory_id, + &source_actor, + &request.reason, + ) + .map(Json) + .map_err(ApiError::from) } async fn scoped_list_working_directories( @@ -9017,10 +9050,28 @@ async fn scoped_working_directory_detail( async fn scoped_cleanup_working_directory( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { + worker_source: Option>, + request_actor: Option>, + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let runtime_id = registered_workdir_runtime_id(&api, &path.working_directory_id)?; - cleanup_working_directory_for_runtime(api, &runtime_id, &path.working_directory_id) + let source_actor = if let Some(Extension(source)) = worker_source { + format!("worker:{}:{}", source.runtime_id, source.worker_id) + } else if let Some(Extension(actor)) = request_actor { + format!("account:{}", actor.account_id) + } else { + return Err(ApiError::from(Error::WorkspacePermissionDenied( + "Workdir removal requires authenticated source authority".to_string(), + ))); + }; + execute_workdir_removal( + &api, + &path.working_directory_id, + &source_actor, + &request.reason, + ) + .map(Json) + .map_err(ApiError::from) } fn registered_workdir_runtime_id( @@ -9497,54 +9548,218 @@ fn working_directory_detail_for_runtime( )) } -fn cleanup_working_directory_for_runtime( - api: WorkspaceApi, - runtime_id: &str, - working_directory_id: &str, -) -> ApiResult> { - if let Some(candidate) = build_runtime_cleanup_plan(&api, runtime_id)? - .workdirs - .into_iter() - .find(|candidate| candidate.workdir_id == working_directory_id) - { - if let Some(reason) = candidate.blocking_reason { - return Err(cleanup_api_error( - runtime_id, - "workspace_cleanup_workdir_blocked", - &reason, - )); +fn workdir_removal_response( + operation: &WorkdirRemovalOperation, +) -> WorkingDirectoryRemovalResponse { + let disposition = match operation.disposition { + Some(WorkdirRemovalDisposition::Removed) => WorkingDirectoryRemovalDisposition::Removed, + Some(WorkdirRemovalDisposition::Retained) => WorkingDirectoryRemovalDisposition::Retained, + Some(WorkdirRemovalDisposition::AttentionRequired) | None => { + WorkingDirectoryRemovalDisposition::AttentionRequired } - if candidate.action == CleanupTargetKind::WorkdirDirtyDiscard { - return Err(cleanup_api_error( - runtime_id, - "workspace_cleanup_dirty_confirmation_required", - "dirty Workdir discard requires the cleanup execution API with explicit confirmation", - )); + }; + WorkingDirectoryRemovalResponse { + working_directory_id: operation.working_directory_id.clone(), + disposition, + retryable: operation.retryable, + failure_category: operation.failure_category.clone(), + } +} + +fn runtime_reports_workdir_not_found(result: &crate::hosts::RuntimeWorkingDirectoryResult) -> bool { + result.working_directory.is_none() + && result + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "working_directory_not_found") +} + +fn classify_workdir_provider_error(error: &RuntimeRegistryError) -> (&'static str, bool) { + match error { + RuntimeRegistryError::UnknownRuntime(_) => ("runtime_unavailable", true), + RuntimeRegistryError::RuntimeOperationFailed { code, .. } + if code == "working_directory_unsupported" => + { + ("unsupported_target", false) + } + RuntimeRegistryError::RuntimeOperationFailed { .. } => ("provider_unavailable", true), + RuntimeRegistryError::InvalidIdentifier { .. } + | RuntimeRegistryError::UnknownHost(_) + | RuntimeRegistryError::UnknownWorker { .. } => ("authority_invalid", false), + } +} + +fn execute_reserved_workdir_removal( + api: &WorkspaceApi, + operation: WorkdirRemovalOperation, +) -> Result { + if operation.state == WorkdirRemovalOperationState::Completed { + return Ok(operation); + } + let operation = api.config_store.begin_workdir_removal_attempt( + &operation.workspace_id, + &operation.operation_id, + &operation.request_fingerprint, + )?; + let guards = match api.config_store.workdir_removal_guards(&operation) { + Ok(guards) => guards, + Err( + Error::InvalidInput(_) + | Error::WorkdirAttachmentConflict(_) + | Error::RegistryInconsistency(_), + ) => { + return api.config_store.fail_workdir_removal_operation( + &operation, + "authority_changed", + false, + ); + } + Err(error) => return Err(error), + }; + if !guards.is_empty() { + return api.config_store.complete_workdir_removal_retained( + &operation, + WorkdirRemovalDisposition::Retained, + "blocked_by_live_authority", + ); + } + + // Runtime observation is the provider's publication/removal authority. A + // missing summary without the exact not-found diagnostic is unknown, not a + // successful delete. + let observed = match api + .runtime + .working_directory(&operation.runtime_id, &operation.working_directory_id) + { + Ok(observed) => observed, + Err(error) => { + let (category, retryable) = classify_workdir_provider_error(&error); + return api + .config_store + .fail_workdir_removal_operation(&operation, category, retryable); + } + }; + if runtime_reports_workdir_not_found(&observed) { + return api.config_store.commit_workdir_removal_removed(&operation); + } + let Some(status) = observed.working_directory.as_ref() else { + return api.config_store.fail_workdir_removal_operation( + &operation, + "provider_observation_unknown", + true, + ); + }; + if status.summary.cleanliness.as_deref() != Some("clean") + || !matches!( + status.summary.status, + WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending + ) + { + return api.config_store.complete_workdir_removal_retained( + &operation, + WorkdirRemovalDisposition::Retained, + "dirty_or_unknown", + ); + } + + let deleted = match api + .runtime + .cleanup_working_directory(&operation.runtime_id, &operation.working_directory_id) + { + Ok(deleted) => deleted, + Err(error) => { + let (category, retryable) = classify_workdir_provider_error(&error); + return api + .config_store + .fail_workdir_removal_operation(&operation, category, retryable); + } + }; + if deleted.state != WorkerOperationState::Accepted + && !runtime_reports_workdir_not_found(&deleted) + { + let category = if deleted + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "working_directory_unsupported") + { + "unsupported_target" + } else { + "provider_cleanup_failed" + }; + return api.config_store.fail_workdir_removal_operation( + &operation, + category, + category != "unsupported_target", + ); + } + api.config_store.commit_workdir_removal_removed(&operation) +} + +fn execute_workdir_removal( + api: &WorkspaceApi, + working_directory_id: &str, + source_actor: &str, + reason: &str, +) -> Result { + let reason = reason.trim(); + if reason.is_empty() || reason.len() > 500 { + return Err(Error::InvalidInput( + "Workdir removal reason must be between 1 and 500 bytes".to_string(), + )); + } + let lock = { + let mut locks = api + .workdir_remove_locks + .lock() + .map_err(|_| Error::Store("Workdir removal lock registry was poisoned".to_string()))?; + locks + .entry(working_directory_id.to_string()) + .or_insert_with(|| Arc::new(std::sync::Mutex::new(()))) + .clone() + }; + let _guard = lock + .lock() + .map_err(|_| Error::Store("Workdir removal lock was poisoned".to_string()))?; + + let operation = if let Some(existing) = + api.config_store.find_workdir_removal_operation_by_intent( + api.workspace_id(), + working_directory_id, + source_actor, + reason, + )? { + existing + } else { + let workdir = api + .config_store + .get_workdir_registry(api.workspace_id(), working_directory_id)? + .ok_or_else(|| { + Error::InvalidInput(format!("Unknown Workdir `{working_directory_id}`")) + })?; + let intent = workdir_removal_intent(&workdir, source_actor, reason)?; + api.config_store + .reserve_workdir_removal_operation(&intent)? + }; + execute_reserved_workdir_removal(api, operation) + .map(|operation| workdir_removal_response(&operation)) +} + +fn recover_workdir_removals(api: &WorkspaceApi) -> Result<()> { + for operation in api + .config_store + .recoverable_workdir_removal_operations(api.workspace_id(), 100)? + { + if let Err(error) = execute_reserved_workdir_removal(api, operation.clone()) { + tracing::warn!( + workspace_id = %api.workspace_id(), + workdir_id = %operation.working_directory_id, + operation_id = %operation.operation_id, + category = "workdir_removal_recovery_failed", + "durable Workdir removal recovery failed: {error}" + ); } } - let result = api - .runtime - .cleanup_working_directory(runtime_id, working_directory_id) - .map_err(|err| err.into_error())?; - let Some(working_directory) = result.working_directory else { - return Err(ApiError::with_diagnostics( - Error::RuntimeOperationFailed { - runtime_id: runtime_id.to_string(), - code: "workspace_working_directory_cleanup_failed".to_string(), - message: "Runtime did not cleanup working directory".to_string(), - }, - result.diagnostics, - )); - }; - let mut summary = working_directory.summary; - persist_workdir_cleanup_observation(&api, runtime_id, &summary)?; - apply_workdir_occupancy_projection(&api, &mut summary)?; - Ok(Json(BrowserWorkingDirectoryDetailResponse { - workspace_id: api.config.workspace_id.clone(), - runtime_id: runtime_id.to_string(), - item: summary, - diagnostics: working_directory_diagnostics(result.diagnostics), - })) + Ok(()) } async fn set_worker_retention( @@ -9813,11 +10028,6 @@ async fn execute_runtime_cleanup( } let worker_targets: HashSet<_> = request.worker_target_ids.iter().cloned().collect(); let workdir_targets: HashSet<_> = request.workdir_target_ids.iter().cloned().collect(); - let dirty_confirmations: HashSet<_> = request - .confirm_dirty_discard_target_ids - .iter() - .cloned() - .collect(); let mut results = Vec::new(); for candidate in plan @@ -9891,72 +10101,41 @@ async fn execute_runtime_cleanup( } match candidate.action { CleanupTargetKind::WorkdirDirtyDiscard => { - if !dirty_confirmations.contains(candidate.target_id.as_str()) { - return Err(cleanup_api_error( - runtime_id, - "workspace_cleanup_dirty_confirmation_required", - "dirty Workdir discard requires explicit confirmation", - )); - } - cleanup_runtime_workdir_for_execution(api, runtime_id, candidate)?; - let deleted = api.store.delete_workdir_registry( - &api.config.workspace_id, - candidate.workdir_id.as_str(), - )?; - if !deleted { - return Err(cleanup_api_error( - runtime_id, - "workspace_cleanup_workdir_registry_not_found", - "Backend Workdir registry row was not found after Runtime cleanup", - )); - } results.push(RuntimeCleanupExecutionResult { target_id: candidate.target_id.clone(), action: candidate.action.clone(), - status: "deleted".to_string(), + status: "retained".to_string(), message: - "Dirty/unknown Workdir was deleted from Runtime storage and Backend registry after explicit confirmation" + "Dirty or unknown Workdir was retained; forced deletion is not supported" .to_string(), }); } - CleanupTargetKind::WorkdirCleanCleanup => { - cleanup_runtime_workdir_for_execution(api, runtime_id, candidate)?; - let deleted = api.store.delete_workdir_registry( - &api.config.workspace_id, + CleanupTargetKind::WorkdirCleanCleanup | CleanupTargetKind::WorkdirRecordDelete => { + let removal = execute_workdir_removal( + api, candidate.workdir_id.as_str(), + &format!("runtime-cleanup:{runtime_id}"), + &format!("cleanup target {}", candidate.target_id), )?; - if !deleted { - return Err(cleanup_api_error( - runtime_id, - "workspace_cleanup_workdir_registry_not_found", - "Backend Workdir registry row was not found after Runtime cleanup", - )); - } + let (status, message) = match removal.disposition { + WorkingDirectoryRemovalDisposition::Removed => ( + "deleted", + "Workdir removed through the durable Backend operation", + ), + WorkingDirectoryRemovalDisposition::Retained => ( + "retained", + "Workdir retained after live authority revalidation", + ), + WorkingDirectoryRemovalDisposition::AttentionRequired => ( + "attention_required", + "Workdir removal requires attention and may be retried", + ), + }; results.push(RuntimeCleanupExecutionResult { target_id: candidate.target_id.clone(), action: candidate.action.clone(), - status: "deleted".to_string(), - message: "Workdir deleted from Runtime storage and Backend registry" - .to_string(), - }); - } - CleanupTargetKind::WorkdirRecordDelete => { - let deleted = api.store.delete_workdir_registry( - &api.config.workspace_id, - candidate.workdir_id.as_str(), - )?; - if !deleted { - return Err(cleanup_api_error( - runtime_id, - "workspace_cleanup_workdir_registry_not_found", - "Backend Workdir registry row was not found", - )); - } - results.push(RuntimeCleanupExecutionResult { - target_id: candidate.target_id.clone(), - action: candidate.action.clone(), - status: "deleted".to_string(), - message: "Not-found Workdir registry row deleted".to_string(), + status: status.to_string(), + message: message.to_string(), }); } CleanupTargetKind::WorkerDelete => { @@ -10033,28 +10212,6 @@ fn cleanup_runtime_worker_for_execution( } } -fn cleanup_runtime_workdir_for_execution( - api: &WorkspaceApi, - runtime_id: &str, - candidate: &CleanupWorkdirCandidate, -) -> ApiResult<()> { - let result = api - .runtime - .cleanup_working_directory(runtime_id, candidate.workdir_id.as_str()) - .map_err(|err| err.into_error())?; - if result.working_directory.is_none() { - return Err(ApiError::with_diagnostics( - Error::RuntimeOperationFailed { - runtime_id: runtime_id.to_string(), - code: "workspace_cleanup_workdir_runtime_failed".to_string(), - message: "Runtime did not cleanup selected Workdir".to_string(), - }, - result.diagnostics, - )); - }; - Ok(()) -} - fn cleanup_api_error(runtime_id: &str, code: &str, message: &str) -> ApiError { Error::RuntimeOperationFailed { runtime_id: runtime_id.to_string(), @@ -14342,10 +14499,12 @@ fn sync_runtime_workdir_observations( for status in &response.items { observed.insert(status.summary.working_directory_id.clone()); if status.summary.status == WorkingDirectoryStatusKind::NotFound { - api.store.delete_workdir_registry( + if let Some(record) = api.store.get_workdir_registry( &api.config.workspace_id, &status.summary.working_directory_id, - )?; + )? { + persist_workdir_not_found(api, record)?; + } continue; } let existing = api.store.get_workdir_registry( @@ -14369,10 +14528,7 @@ fn sync_runtime_workdir_observations( Ok(result) => { if let Some(status) = result.working_directory { if status.summary.status == WorkingDirectoryStatusKind::NotFound { - api.store.delete_workdir_registry( - &api.config.workspace_id, - record.workdir_id.as_str(), - )?; + persist_workdir_not_found(api, record)?; } else { let mut updated = workdir_record_from_summary(api, runtime_id, &status.summary); @@ -14403,10 +14559,12 @@ fn persist_workdir_cleanup_observation( summary: &WorkingDirectorySummary, ) -> ApiResult<()> { if summary.status == WorkingDirectoryStatusKind::NotFound { - api.store.delete_workdir_registry( + if let Some(record) = api.store.get_workdir_registry( &api.config.workspace_id, summary.working_directory_id.as_str(), - )?; + )? { + persist_workdir_not_found(api, record)?; + } } else { let record = workdir_record_from_summary(api, runtime_id, summary); api.store.upsert_workdir_registry(&record)?; @@ -14428,14 +14586,28 @@ fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'stat } } +fn persist_workdir_not_found( + api: &WorkspaceApi, + mut record: WorkdirRegistryRecord, +) -> ApiResult<()> { + record.materialization_status = "not_found".to_string(); + record.cleanliness = "unknown".to_string(); + record.observed_at_epoch_seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs()); + record.updated_at = now_registry_timestamp(); + api.store.upsert_workdir_registry(&record)?; + Ok(()) +} + fn persist_workdir_runtime_miss( api: &WorkspaceApi, mut record: WorkdirRegistryRecord, diagnostics: &[RuntimeDiagnostic], ) -> ApiResult<()> { if workdir_runtime_miss_is_not_found(diagnostics) { - api.store - .delete_workdir_registry(&api.config.workspace_id, record.workdir_id.as_str())?; + persist_workdir_not_found(api, record)?; } else { record.materialization_status = "unknown".to_string(); record.cleanliness = "unknown".to_string(); @@ -22154,31 +22326,120 @@ mod tests { } #[tokio::test] - async fn simple_workdir_cleanup_rejects_dirty_and_blocked_candidates() { + async fn durable_workdir_removal_replays_completed_provider_not_found_result() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; - seed_cleanup_workdir(&api, "dirty-workdir", "present", "dirty"); - let dirty = - cleanup_working_directory_for_runtime(api.clone(), "runtime-test", "dirty-workdir") - .unwrap_err(); - assert!(matches!( - dirty.error, - Error::RuntimeOperationFailed { ref code, .. } - if code == "workspace_cleanup_dirty_confirmation_required" - )); + seed_cleanup_workdir(&api, "missing-clean-workdir", "present", "clean"); - let pinned = seed_cleanup_worker(&api, 17, "pinned"); - seed_cleanup_workdir(&api, "blocked-workdir", "present", "clean"); - seed_cleanup_link(&api, pinned.as_str(), "blocked-workdir"); - let blocked = - cleanup_working_directory_for_runtime(api.clone(), "runtime-test", "blocked-workdir") - .unwrap_err(); - assert!(matches!( - blocked.error, - Error::RuntimeOperationFailed { ref code, .. } - if code == "workspace_cleanup_workdir_blocked" - )); + let first = execute_workdir_removal( + &api, + "missing-clean-workdir", + "account:owner", + "remove stale clean Workdir", + ) + .unwrap(); + assert_eq!( + first.disposition, + WorkingDirectoryRemovalDisposition::Removed, + "response: {first:?}" + ); + assert!(!first.retryable); + assert!( + api.store + .get_workdir_registry(&api.config.workspace_id, "missing-clean-workdir") + .unwrap() + .is_none() + ); + + let replay = execute_workdir_removal( + &api, + "missing-clean-workdir", + "account:owner", + "remove stale clean Workdir", + ) + .unwrap(); + assert_eq!(replay, first); + let operation = api + .config_store + .find_workdir_removal_operation_by_intent( + &api.config.workspace_id, + "missing-clean-workdir", + "account:owner", + "remove stale clean Workdir", + ) + .unwrap() + .unwrap(); + assert_eq!(operation.attempt_count, 1); + } + + #[tokio::test] + async fn durable_workdir_removal_retains_occupied_and_pinned_workdir() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let worker = seed_cleanup_worker(&api, 27, "pinned"); + seed_cleanup_workdir(&api, "occupied-workdir", "present", "clean"); + seed_cleanup_link(&api, worker.as_str(), "occupied-workdir"); + + let result = execute_workdir_removal( + &api, + "occupied-workdir", + "account:owner", + "remove occupied Workdir", + ) + .unwrap(); + assert_eq!( + result.disposition, + WorkingDirectoryRemovalDisposition::Retained + ); + assert_eq!( + result.failure_category.as_deref(), + Some("blocked_by_live_authority") + ); + assert!( + api.store + .get_workdir_registry(&api.config.workspace_id, "occupied-workdir") + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn recoverable_workdir_removal_converges_after_provider_side_effect() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + seed_cleanup_workdir(&api, "recovery-workdir", "present", "clean"); + let record = api + .store + .get_workdir_registry(&api.config.workspace_id, "recovery-workdir") + .unwrap() + .unwrap(); + let intent = + workdir_removal_intent(&record, "account:owner", "recover provider cleanup").unwrap(); + api.config_store + .reserve_workdir_removal_operation(&intent) + .unwrap(); + + recover_workdir_removals(&api).unwrap(); + + let operation = api + .config_store + .get_workdir_removal_operation(&api.config.workspace_id, &intent.operation_id) + .unwrap() + .unwrap(); + assert_eq!(operation.state, WorkdirRemovalOperationState::Completed); + assert_eq!( + operation.disposition, + Some(WorkdirRemovalDisposition::Removed) + ); + assert!( + api.store + .get_workdir_registry(&api.config.workspace_id, "recovery-workdir") + .unwrap() + .is_none() + ); } #[tokio::test] diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 8114d56c..271bd05c 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -267,6 +267,11 @@ const MIGRATIONS: &[Migration] = &[ name: "require one account owner for every Workspace", apply: require_workspace_account_owner, }, + Migration { + version: 49, + name: "create durable Workdir removal operations", + apply: crate::workdir_removal::create_workdir_removal_operations, + }, ]; struct Migration { @@ -10085,6 +10090,51 @@ mod tests { .unwrap(); } + #[test] + fn schema_v49_upgrades_v48_with_durable_workdir_removal_authority() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 48).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert!(!table_exists(&conn, "workdir_removal_operations").unwrap()); + + apply_migrations(&conn).unwrap(); + + assert_eq!(current_schema_version(&conn).unwrap(), 49); + assert!(table_exists(&conn, "workdir_removal_operations").unwrap()); + let columns = table_columns(&conn, "workdir_removal_operations").unwrap(); + for required in [ + "workspace_id", + "operation_id", + "request_fingerprint", + "workdir_id", + "runtime_id", + "repository_id", + "materialization_fingerprint", + "source_actor", + "reason", + "state", + "attempt_count", + "retryable", + "disposition", + "failure_category", + "created_at", + "updated_at", + "completed_at", + ] { + assert!( + columns.iter().any(|column| column == required), + "missing {required}" + ); + } + let foreign_key_failures: i64 = conn + .query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(foreign_key_failures, 0); + } + #[test] fn schema_v44_migrates_repository_sources_without_promoting_legacy_auth_refs() { let conn = Connection::open_in_memory().unwrap(); @@ -10118,7 +10168,7 @@ mod tests { assign_explicit_test_workspace_owner(&conn); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); let remote = conn .query_row( "SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \ @@ -10197,7 +10247,7 @@ mod tests { let before = std::fs::read(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, 36); - assert_eq!(plan.target_schema_version, 48); + assert_eq!(plan.target_schema_version, 49); assert!(plan.migration_required); assert_eq!(plan.worker_count, 1); assert_eq!(plan.mappings[0].legacy_worker_id, 7); @@ -10211,7 +10261,7 @@ mod tests { store .with_conn(|conn| { assert!(table_exists(conn, "worker_diagnostics_archives")?); - assert_eq!(current_schema_version(conn)?, 48); + assert_eq!(current_schema_version(conn)?, 49); Ok(()) }) .unwrap(); @@ -10351,7 +10401,7 @@ mod tests { ), ] ); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); let foreign_key_error: Option = conn .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .optional() @@ -10481,7 +10531,7 @@ INSERT INTO worker_orphan_diagnostics ( assign_explicit_test_workspace_owner(&conn); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -10599,7 +10649,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -10618,7 +10668,7 @@ INSERT INTO worker_orphan_diagnostics ( assign_explicit_test_workspace_owner(&conn); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); let settings = conn .query_row( "SELECT settings_revision, language FROM workspace_memory_settings \ @@ -10659,7 +10709,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -10727,7 +10777,7 @@ INSERT INTO worker_workdir_attachment_reservations ( assign_explicit_test_workspace_owner(&conn); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -10910,7 +10960,7 @@ INSERT INTO workdir_registry ( let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 48); + assert_eq!(store.schema_version().await.unwrap(), 49); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -10927,7 +10977,7 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 48); + assert_eq!(reopened.schema_version().await.unwrap(), 49); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -11693,7 +11743,7 @@ INSERT INTO worker_registry ( let migrated = SqliteWorkspaceStore::open(&db_path).unwrap(); migrated .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 48); + assert_eq!(current_schema_version(conn)?, 49); assert_eq!( conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?, 1, @@ -12044,7 +12094,7 @@ INSERT INTO worker_registry ( assert_eq!(current_schema_version(&conn).unwrap(), 44); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); assert!(table_exists(&conn, "workdir_create_operations").unwrap()); let columns = table_columns(&conn, "workdir_create_operations").unwrap(); for required in [ @@ -12071,7 +12121,7 @@ INSERT INTO worker_registry ( assert_eq!(current_schema_version(&conn).unwrap(), 45); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); for table in [ "repository_ssh_credentials", "repository_ssh_credential_revisions", @@ -12098,7 +12148,7 @@ INSERT INTO worker_registry ( assert_eq!(current_schema_version(&conn).unwrap(), 46); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); let columns = table_columns(&conn, "workdir_create_operations").unwrap(); for required in [ "source_kind", @@ -12362,7 +12412,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026- assign_explicit_test_workspace_owner(&conn); apply_migrations(&mut conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert_eq!(current_schema_version(&conn).unwrap(), 49); let workspace_id: Option = conn .query_row( "SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'", @@ -12991,7 +13041,7 @@ WHERE workspace_id = 'workspace-a' assign_explicit_test_workspace_owner(&conn); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 48); + assert_eq!(store.schema_version().await.unwrap(), 49); store .with_conn(|conn| { @@ -13180,7 +13230,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 48); + assert_eq!(store.schema_version().await.unwrap(), 49); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: "owner-account".to_string(), @@ -13258,7 +13308,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 48); + assert_eq!(store.schema_version().await.unwrap(), 49); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: "owner-account".to_string(), @@ -13671,7 +13721,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 48); + assert_eq!(store.schema_version().await.unwrap(), 49); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs new file mode 100644 index 00000000..28abc84c --- /dev/null +++ b/crates/workspace-server/src/workdir_removal.rs @@ -0,0 +1,952 @@ +//! Durable, retryable Backend authority for persistent Workdir removal. +//! +//! Runtime cleanup is an external side effect, so callers reserve one immutable +//! operation before invoking the provider. The final registry deletion and +//! operation completion are committed atomically. If a process stops after the +//! provider side effect but before that transaction, recovery re-observes the +//! provider and converges through the same operation. + +use chrono::Utc; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::store::WorkdirRegistryRecord; +use crate::{Error, Result, SqliteWorkspaceStore}; + +const MAX_REASON_BYTES: usize = 500; +const MAX_ACTOR_BYTES: usize = 200; +const MAX_FAILURE_CATEGORY_BYTES: usize = 128; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkdirRemovalOperationState { + Pending, + Failed, + Completed, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkdirRemovalDisposition { + Removed, + Retained, + AttentionRequired, +} + +impl WorkdirRemovalDisposition { + fn as_str(self) -> &'static str { + match self { + Self::Removed => "removed", + Self::Retained => "retained", + Self::AttentionRequired => "attention_required", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkdirRemovalIntent { + pub operation_id: String, + pub request_fingerprint: String, + pub workspace_id: String, + pub working_directory_id: String, + pub runtime_id: String, + pub repository_id: String, + pub materialization_fingerprint: String, + pub source_actor: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkdirRemovalOperation { + pub operation_id: String, + pub request_fingerprint: String, + pub workspace_id: String, + pub working_directory_id: String, + pub runtime_id: String, + pub repository_id: String, + pub materialization_fingerprint: String, + pub source_actor: String, + pub reason: String, + pub state: WorkdirRemovalOperationState, + pub attempt_count: u64, + pub retryable: bool, + pub disposition: Option, + pub failure_category: Option, + pub created_at: String, + pub updated_at: String, + pub completed_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkdirRemovalGuard { + pub category: &'static str, + pub detail: &'static str, +} + +pub(crate) fn create_workdir_removal_operations(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +CREATE TABLE workdir_removal_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + workdir_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + materialization_fingerprint TEXT NOT NULL, + source_actor TEXT NOT NULL, + reason TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), + disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')), + failure_category TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +CREATE INDEX idx_workdir_removal_operations_recovery + ON workdir_removal_operations(workspace_id, state, retryable, updated_at); +CREATE INDEX idx_workdir_removal_operations_workdir + ON workdir_removal_operations(workspace_id, workdir_id, created_at DESC); +"#, + )?; + Ok(()) +} + +pub fn workdir_materialization_fingerprint(record: &WorkdirRegistryRecord) -> String { + let bytes = serde_json::to_vec(&serde_json::json!([ + record.workspace_id, + record.workdir_id, + record.runtime_id, + record.repository_id, + record.creation_selector, + record.creation_ref, + record.creation_tree, + ])) + .expect("Workdir materialization identity is serializable"); + hex_sha256(&bytes) +} + +pub fn workdir_removal_intent( + record: &WorkdirRegistryRecord, + source_actor: &str, + reason: &str, +) -> Result { + validate_bounded("source actor", source_actor, MAX_ACTOR_BYTES)?; + validate_bounded("reason", reason, MAX_REASON_BYTES)?; + let materialization_fingerprint = workdir_materialization_fingerprint(record); + let fingerprint_bytes = serde_json::to_vec(&serde_json::json!([ + record.workspace_id, + record.workdir_id, + record.runtime_id, + record.repository_id, + materialization_fingerprint, + source_actor, + reason, + ])) + .map_err(|error| Error::Store(format!("Workdir removal fingerprint failed: {error}")))?; + let request_fingerprint = hex_sha256(&fingerprint_bytes); + Ok(WorkdirRemovalIntent { + operation_id: format!("wdr_{}", &request_fingerprint[..32]), + request_fingerprint, + workspace_id: record.workspace_id.clone(), + working_directory_id: record.workdir_id.clone(), + runtime_id: record.runtime_id.clone(), + repository_id: record.repository_id.clone(), + materialization_fingerprint, + source_actor: source_actor.to_string(), + reason: reason.to_string(), + }) +} + +impl SqliteWorkspaceStore { + pub fn reserve_workdir_removal_operation( + &self, + intent: &WorkdirRemovalIntent, + ) -> Result { + validate_intent(intent)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(existing) = load_operation(&tx, &intent.workspace_id, &intent.operation_id)? { + if existing.request_fingerprint != intent.request_fingerprint + || existing.working_directory_id != intent.working_directory_id + || existing.runtime_id != intent.runtime_id + || existing.repository_id != intent.repository_id + || existing.materialization_fingerprint != intent.materialization_fingerprint + || existing.source_actor != intent.source_actor + || existing.reason != intent.reason + { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir removal operation `{}` was reused with different intent", + intent.operation_id + ))); + } + tx.commit()?; + return Ok(existing); + } + let current = load_workdir_record(&tx, &intent.workspace_id, &intent.working_directory_id)? + .ok_or_else(|| Error::InvalidInput(format!( + "Unknown Workdir `{}`", + intent.working_directory_id + )))?; + require_matching_materialization(intent, ¤t)?; + let repository_exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM repositories WHERE workspace_id=?1 AND repository_id=?2)", + params![intent.workspace_id, intent.repository_id], + |row| row.get(0), + )?; + if !repository_exists { + return Err(Error::RegistryInconsistency( + "Workdir Repository authority is missing".to_string(), + )); + } + let now = Utc::now().to_rfc3339(); + tx.execute( + r#"INSERT INTO workdir_removal_operations ( + workspace_id, operation_id, request_fingerprint, workdir_id, runtime_id, + repository_id, materialization_fingerprint, source_actor, reason, state, + attempt_count, retryable, disposition, failure_category, + created_at, updated_at, completed_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending', 0, 1, NULL, NULL, ?10, ?10, NULL)"#, + params![ + intent.workspace_id, + intent.operation_id, + intent.request_fingerprint, + intent.working_directory_id, + intent.runtime_id, + intent.repository_id, + intent.materialization_fingerprint, + intent.source_actor, + intent.reason, + now, + ], + )?; + let operation = load_operation(&tx, &intent.workspace_id, &intent.operation_id)? + .ok_or_else(|| Error::Store("reserved Workdir removal operation is missing".to_string()))?; + tx.commit()?; + Ok(operation) + }) + } + + pub fn begin_workdir_removal_attempt( + &self, + workspace_id: &str, + operation_id: &str, + request_fingerprint: &str, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let operation = require_operation(&tx, workspace_id, operation_id, request_fingerprint)?; + if operation.state == WorkdirRemovalOperationState::Completed { + tx.commit()?; + return Ok(operation); + } + if operation.state == WorkdirRemovalOperationState::Failed && !operation.retryable { + return Err(Error::InvalidInput(format!( + "Workdir removal operation `{operation_id}` is not retryable" + ))); + } + let now = Utc::now().to_rfc3339(); + tx.execute( + "UPDATE workdir_removal_operations SET state='pending', attempt_count=attempt_count+1, retryable=1, failure_category=NULL, disposition=NULL, updated_at=?1, completed_at=NULL WHERE workspace_id=?2 AND operation_id=?3 AND request_fingerprint=?4", + params![now, workspace_id, operation_id, request_fingerprint], + )?; + let operation = require_operation(&tx, workspace_id, operation_id, request_fingerprint)?; + tx.commit()?; + Ok(operation) + }) + } + + pub fn workdir_removal_guards( + &self, + operation: &WorkdirRemovalOperation, + ) -> Result> { + self.with_conn(|conn| { + let current = load_workdir_record( + conn, + &operation.workspace_id, + &operation.working_directory_id, + )? + .ok_or_else(|| Error::InvalidInput(format!( + "Unknown Workdir `{}`", + operation.working_directory_id + )))?; + require_operation_materialization(operation, ¤t)?; + let active_attachment: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM worker_workdir_links WHERE workspace_id=?1 AND workdir_id=?2 AND unlinked_at IS NULL)", + params![operation.workspace_id, operation.working_directory_id], + |row| row.get(0), + )?; + let pending_attachment: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM worker_workdir_attachment_reservations WHERE workspace_id=?1 AND workdir_id=?2)", + params![operation.workspace_id, operation.working_directory_id], + |row| row.get(0), + )?; + let current_assignment: bool = conn.query_row( + r#"SELECT EXISTS( + SELECT 1 FROM worker_workdir_links AS link + JOIN ticket_current_worker_assignments AS current + ON current.workspace_id=link.workspace_id + JOIN ticket_worker_assignments AS assignment + ON assignment.workspace_id=current.workspace_id + AND assignment.ticket_id=current.ticket_id + AND assignment.role=current.role + AND assignment.assignment_id=current.assignment_id + AND assignment.runtime_id=link.runtime_id + AND assignment.worker_id=link.worker_id + WHERE link.workspace_id=?1 AND link.workdir_id=?2 AND link.unlinked_at IS NULL + )"#, + params![operation.workspace_id, operation.working_directory_id], + |row| row.get(0), + )?; + let retention_hold: bool = conn.query_row( + r#"SELECT EXISTS( + SELECT 1 FROM worker_workdir_links AS link + JOIN worker_registry AS worker + ON worker.workspace_id=link.workspace_id + AND worker.runtime_id=link.runtime_id + AND worker.worker_id=link.worker_id + WHERE link.workspace_id=?1 AND link.workdir_id=?2 + AND link.unlinked_at IS NULL AND worker.retention_state='pinned' + )"#, + params![operation.workspace_id, operation.working_directory_id], + |row| row.get(0), + )?; + let pending_materialization: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workdir_create_operations WHERE workspace_id=?1 AND working_directory_id=?2 AND state='pending')", + params![operation.workspace_id, operation.working_directory_id], + |row| row.get(0), + )?; + let mut guards = Vec::new(); + if active_attachment { + guards.push(WorkdirRemovalGuard { + category: "active_attachment", + detail: "Workdir has an active Worker attachment", + }); + } + if pending_attachment { + guards.push(WorkdirRemovalGuard { + category: "pending_attachment", + detail: "Workdir has a pending Worker attachment reservation", + }); + } + if current_assignment { + guards.push(WorkdirRemovalGuard { + category: "current_assignment", + detail: "Workdir is bound to a Worker with a current Ticket assignment", + }); + } + if retention_hold { + guards.push(WorkdirRemovalGuard { + category: "retention_hold", + detail: "Workdir is bound to a retained Worker", + }); + } + if pending_materialization { + guards.push(WorkdirRemovalGuard { + category: "materialization_pending", + detail: "Workdir materialization is still pending", + }); + } + Ok(guards) + }) + } + + pub fn complete_workdir_removal_retained( + &self, + operation: &WorkdirRemovalOperation, + disposition: WorkdirRemovalDisposition, + category: &str, + ) -> Result { + validate_failure_category(category)?; + if disposition == WorkdirRemovalDisposition::Removed { + return Err(Error::InvalidInput( + "retained completion cannot use removed disposition".to_string(), + )); + } + self.finish_workdir_removal_operation(operation, disposition, false, Some(category), false) + } + + pub fn fail_workdir_removal_operation( + &self, + operation: &WorkdirRemovalOperation, + category: &str, + retryable: bool, + ) -> Result { + validate_failure_category(category)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = require_operation( + &tx, + &operation.workspace_id, + &operation.operation_id, + &operation.request_fingerprint, + )?; + if current.state == WorkdirRemovalOperationState::Completed { + tx.commit()?; + return Ok(current); + } + let now = Utc::now().to_rfc3339(); + tx.execute( + "UPDATE workdir_removal_operations SET state='failed', retryable=?1, disposition=?2, failure_category=?3, updated_at=?4 WHERE workspace_id=?5 AND operation_id=?6 AND request_fingerprint=?7", + params![ + retryable, + WorkdirRemovalDisposition::AttentionRequired.as_str(), + category, + now, + operation.workspace_id, + operation.operation_id, + operation.request_fingerprint, + ], + )?; + let updated = require_operation( + &tx, + &operation.workspace_id, + &operation.operation_id, + &operation.request_fingerprint, + )?; + tx.commit()?; + Ok(updated) + }) + } + + pub fn commit_workdir_removal_removed( + &self, + operation: &WorkdirRemovalOperation, + ) -> Result { + self.finish_workdir_removal_operation( + operation, + WorkdirRemovalDisposition::Removed, + false, + None, + true, + ) + } + + fn finish_workdir_removal_operation( + &self, + operation: &WorkdirRemovalOperation, + disposition: WorkdirRemovalDisposition, + retryable: bool, + category: Option<&str>, + delete_registry: bool, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = require_operation( + &tx, + &operation.workspace_id, + &operation.operation_id, + &operation.request_fingerprint, + )?; + if current.state == WorkdirRemovalOperationState::Completed { + tx.commit()?; + return Ok(current); + } + if delete_registry { + let registry = load_workdir_record( + &tx, + &operation.workspace_id, + &operation.working_directory_id, + )? + .ok_or_else(|| Error::RegistryInconsistency( + "Workdir registry row disappeared before durable removal commit".to_string(), + ))?; + require_operation_materialization(operation, ®istry)?; + require_no_removal_blockers( + &tx, + &operation.workspace_id, + &operation.working_directory_id, + )?; + let deleted = tx.execute( + "DELETE FROM workdir_registry WHERE workspace_id=?1 AND workdir_id=?2", + params![operation.workspace_id, operation.working_directory_id], + )?; + if deleted != 1 { + return Err(Error::RegistryInconsistency( + "Workdir registry deletion did not remove exactly one row".to_string(), + )); + } + } + let now = Utc::now().to_rfc3339(); + tx.execute( + "UPDATE workdir_removal_operations SET state='completed', retryable=?1, disposition=?2, failure_category=?3, updated_at=?4, completed_at=?4 WHERE workspace_id=?5 AND operation_id=?6 AND request_fingerprint=?7", + params![ + retryable, + disposition.as_str(), + category, + now, + operation.workspace_id, + operation.operation_id, + operation.request_fingerprint, + ], + )?; + let updated = require_operation( + &tx, + &operation.workspace_id, + &operation.operation_id, + &operation.request_fingerprint, + )?; + tx.commit()?; + Ok(updated) + }) + } + + pub fn find_workdir_removal_operation_by_intent( + &self, + workspace_id: &str, + working_directory_id: &str, + source_actor: &str, + reason: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + &format!( + "{} WHERE workspace_id=?1 AND workdir_id=?2 AND source_actor=?3 AND reason=?4 ORDER BY created_at DESC, operation_id DESC LIMIT 1", + operation_select_sql() + ), + params![workspace_id, working_directory_id, source_actor, reason], + read_operation, + ) + .optional() + .map_err(Error::from) + }) + } + + pub fn recoverable_workdir_removal_operations( + &self, + workspace_id: &str, + limit: usize, + ) -> Result> { + self.with_conn(|conn| { + let mut statement = conn.prepare( + &format!( + "{} WHERE workspace_id=?1 AND (state='pending' OR (state='failed' AND retryable=1)) ORDER BY updated_at ASC, operation_id ASC LIMIT ?2", + operation_select_sql() + ), + )?; + let rows = statement.query_map(params![workspace_id, limit as i64], read_operation)?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } + + pub fn get_workdir_removal_operation( + &self, + workspace_id: &str, + operation_id: &str, + ) -> Result> { + self.with_conn(|conn| load_operation(conn, workspace_id, operation_id)) + } +} + +fn require_no_removal_blockers( + conn: &Connection, + workspace_id: &str, + workdir_id: &str, +) -> Result<()> { + let blocked: bool = conn.query_row( + r#"SELECT EXISTS( + SELECT 1 FROM worker_workdir_links + WHERE workspace_id=?1 AND workdir_id=?2 AND unlinked_at IS NULL + UNION ALL + SELECT 1 FROM worker_workdir_attachment_reservations + WHERE workspace_id=?1 AND workdir_id=?2 + )"#, + params![workspace_id, workdir_id], + |row| row.get(0), + )?; + if blocked { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir {workdir_id} acquired an active or pending attachment during removal" + ))); + } + Ok(()) +} + +fn validate_intent(intent: &WorkdirRemovalIntent) -> Result<()> { + for (label, value) in [ + ("operation id", intent.operation_id.as_str()), + ("request fingerprint", intent.request_fingerprint.as_str()), + ("Workspace id", intent.workspace_id.as_str()), + ("Workdir id", intent.working_directory_id.as_str()), + ("Runtime id", intent.runtime_id.as_str()), + ("Repository id", intent.repository_id.as_str()), + ( + "materialization fingerprint", + intent.materialization_fingerprint.as_str(), + ), + ] { + validate_bounded(label, value, 256)?; + } + validate_bounded("source actor", &intent.source_actor, MAX_ACTOR_BYTES)?; + validate_bounded("reason", &intent.reason, MAX_REASON_BYTES) +} + +fn validate_bounded(label: &str, value: &str, max: usize) -> Result<()> { + if value.trim().is_empty() || value.len() > max { + return Err(Error::InvalidInput(format!( + "{label} must be non-empty and at most {max} bytes" + ))); + } + Ok(()) +} + +fn validate_failure_category(category: &str) -> Result<()> { + if category.is_empty() + || category.len() > MAX_FAILURE_CATEGORY_BYTES + || !category + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(Error::InvalidInput( + "Workdir removal failure category is invalid".to_string(), + )); + } + Ok(()) +} + +fn require_matching_materialization( + intent: &WorkdirRemovalIntent, + record: &WorkdirRegistryRecord, +) -> Result<()> { + if intent.workspace_id != record.workspace_id + || intent.working_directory_id != record.workdir_id + || intent.runtime_id != record.runtime_id + || intent.repository_id != record.repository_id + || intent.materialization_fingerprint != workdir_materialization_fingerprint(record) + { + return Err(Error::WorkdirAttachmentConflict( + "Workdir authority changed before removal reservation".to_string(), + )); + } + Ok(()) +} + +fn require_operation_materialization( + operation: &WorkdirRemovalOperation, + record: &WorkdirRegistryRecord, +) -> Result<()> { + if operation.workspace_id != record.workspace_id + || operation.working_directory_id != record.workdir_id + || operation.runtime_id != record.runtime_id + || operation.repository_id != record.repository_id + || operation.materialization_fingerprint != workdir_materialization_fingerprint(record) + { + return Err(Error::WorkdirAttachmentConflict( + "Workdir authority changed after removal reservation".to_string(), + )); + } + Ok(()) +} + +fn load_workdir_record( + conn: &Connection, + workspace_id: &str, + workdir_id: &str, +) -> Result> { + conn.query_row( + r#"SELECT workspace_id, workdir_id, runtime_id, repository_id, + creation_selector, creation_ref, creation_tree, + current_selector, current_ref, current_tree, observed_at_epoch_seconds, + materialization_status, cleanliness, created_at, updated_at + FROM workdir_registry WHERE workspace_id=?1 AND workdir_id=?2"#, + params![workspace_id, workdir_id], + |row| { + Ok(WorkdirRegistryRecord { + workspace_id: row.get(0)?, + workdir_id: row.get(1)?, + runtime_id: row.get(2)?, + repository_id: row.get(3)?, + creation_selector: row.get(4)?, + creation_ref: row.get(5)?, + creation_tree: row.get(6)?, + current_selector: row.get(7)?, + current_ref: row.get(8)?, + current_tree: row.get(9)?, + observed_at_epoch_seconds: row.get::<_, Option>(10)?.map(|value| value as u64), + materialization_status: row.get(11)?, + cleanliness: row.get(12)?, + created_at: row.get(13)?, + updated_at: row.get(14)?, + }) + }, + ) + .optional() + .map_err(Error::from) +} + +fn require_operation( + conn: &Connection, + workspace_id: &str, + operation_id: &str, + request_fingerprint: &str, +) -> Result { + let operation = load_operation(conn, workspace_id, operation_id)?.ok_or_else(|| { + Error::InvalidInput(format!( + "Unknown Workdir removal operation `{operation_id}`" + )) + })?; + if operation.request_fingerprint != request_fingerprint { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir removal operation `{operation_id}` fingerprint mismatch" + ))); + } + Ok(operation) +} + +fn load_operation( + conn: &Connection, + workspace_id: &str, + operation_id: &str, +) -> Result> { + conn.query_row( + &format!( + "{} WHERE workspace_id=?1 AND operation_id=?2", + operation_select_sql() + ), + params![workspace_id, operation_id], + read_operation, + ) + .optional() + .map_err(Error::from) +} + +fn operation_select_sql() -> &'static str { + r#"SELECT operation_id, request_fingerprint, workspace_id, workdir_id, runtime_id, + repository_id, materialization_fingerprint, source_actor, reason, state, + attempt_count, retryable, disposition, failure_category, + created_at, updated_at, completed_at + FROM workdir_removal_operations"# +} + +fn read_operation(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let state = parse_state(&row.get::<_, String>(9)?)?; + let disposition = row + .get::<_, Option>(12)? + .map(|value| parse_disposition(&value)) + .transpose()?; + let attempt_count = row.get::<_, i64>(10)?; + Ok(WorkdirRemovalOperation { + operation_id: row.get(0)?, + request_fingerprint: row.get(1)?, + workspace_id: row.get(2)?, + working_directory_id: row.get(3)?, + runtime_id: row.get(4)?, + repository_id: row.get(5)?, + materialization_fingerprint: row.get(6)?, + source_actor: row.get(7)?, + reason: row.get(8)?, + state, + attempt_count: attempt_count + .try_into() + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(10, attempt_count))?, + retryable: row.get(11)?, + disposition, + failure_category: row.get(13)?, + created_at: row.get(14)?, + updated_at: row.get(15)?, + completed_at: row.get(16)?, + }) +} + +fn parse_state(value: &str) -> rusqlite::Result { + match value { + "pending" => Ok(WorkdirRemovalOperationState::Pending), + "failed" => Ok(WorkdirRemovalOperationState::Failed), + "completed" => Ok(WorkdirRemovalOperationState::Completed), + _ => Err(invalid_enum(9, value)), + } +} + +fn parse_disposition(value: &str) -> rusqlite::Result { + match value { + "removed" => Ok(WorkdirRemovalDisposition::Removed), + "retained" => Ok(WorkdirRemovalDisposition::Retained), + "attention_required" => Ok(WorkdirRemovalDisposition::AttentionRequired), + _ => Err(invalid_enum(12, value)), + } +} + +fn invalid_enum(column: usize, value: &str) -> rusqlite::Error { + rusqlite::Error::FromSqlConversionFailure( + column, + rusqlite::types::Type::Text, + format!("invalid Workdir removal value `{value}`").into(), + ) +} + +fn hex_sha256(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{AccountRecord, ControlPlaneStore, RepositoryRecord, WorkspaceRecord}; + use workspace_api::{RepositoryObservedStatus, RepositorySource, RepositorySourceKind}; + + async fn seeded_store() -> (SqliteWorkspaceStore, WorkdirRegistryRecord) { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store + .upsert_account(&AccountRecord { + account_id: "account-a".to_string(), + kind: "user".to_string(), + handle: "owner".to_string(), + display_name: "Owner".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-a".to_string(), + owner_account_id: "account-a".to_string(), + display_name: "Workspace A".to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + store + .upsert_repository(&RepositoryRecord { + workspace_id: "workspace-a".to_string(), + repository_id: "repository-a".to_string(), + name: "Repository A".to_string(), + kind: "git".to_string(), + provider: Some("local".to_string()), + source: RepositorySource { + kind: RepositorySourceKind::LocalPath, + uri: "/repository-a".to_string(), + }, + default_ref: Some("develop".to_string()), + source_revision: 1, + source_fingerprint: "source-a".to_string(), + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .unwrap(); + let workdir = WorkdirRegistryRecord { + workspace_id: "workspace-a".to_string(), + workdir_id: "workdir-a".to_string(), + runtime_id: "runtime-a".to_string(), + repository_id: "repository-a".to_string(), + creation_selector: Some("refs/heads/develop".to_string()), + creation_ref: Some("abc".to_string()), + creation_tree: Some("tree-a".to_string()), + current_selector: Some("refs/heads/work".to_string()), + current_ref: Some("def".to_string()), + current_tree: Some("tree-b".to_string()), + observed_at_epoch_seconds: Some(1), + materialization_status: "present".to_string(), + cleanliness: "clean".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }; + store.upsert_workdir_registry(&workdir).unwrap(); + (store, workdir) + } + + #[tokio::test] + async fn exact_replay_reuses_operation_and_conflicting_intent_fails() { + let (store, workdir) = seeded_store().await; + let intent = + workdir_removal_intent(&workdir, "worker:W-1", "remove stale Workdir").unwrap(); + let first = store.reserve_workdir_removal_operation(&intent).unwrap(); + let replay = store.reserve_workdir_removal_operation(&intent).unwrap(); + assert_eq!(replay, first); + + let mut conflict = intent.clone(); + conflict.reason = "different intent".to_string(); + conflict.request_fingerprint = "f".repeat(64); + let error = store + .reserve_workdir_removal_operation(&conflict) + .unwrap_err(); + assert!(matches!(error, Error::WorkdirAttachmentConflict(_))); + } + + #[tokio::test] + async fn failed_attempt_is_retryable_and_completed_retry_replays() { + let (store, workdir) = seeded_store().await; + let intent = + workdir_removal_intent(&workdir, "workspace-api", "remove clean Workdir").unwrap(); + let reserved = store.reserve_workdir_removal_operation(&intent).unwrap(); + let first = store + .begin_workdir_removal_attempt( + &reserved.workspace_id, + &reserved.operation_id, + &reserved.request_fingerprint, + ) + .unwrap(); + assert_eq!(first.attempt_count, 1); + let failed = store + .fail_workdir_removal_operation(&first, "runtime_unavailable", true) + .unwrap(); + assert_eq!(failed.state, WorkdirRemovalOperationState::Failed); + let retry = store + .begin_workdir_removal_attempt( + &failed.workspace_id, + &failed.operation_id, + &failed.request_fingerprint, + ) + .unwrap(); + assert_eq!(retry.attempt_count, 2); + let completed = store.commit_workdir_removal_removed(&retry).unwrap(); + assert_eq!( + completed.disposition, + Some(WorkdirRemovalDisposition::Removed) + ); + assert!( + store + .get_workdir_registry("workspace-a", "workdir-a") + .unwrap() + .is_none() + ); + let replay = store + .begin_workdir_removal_attempt( + &completed.workspace_id, + &completed.operation_id, + &completed.request_fingerprint, + ) + .unwrap(); + assert_eq!(replay, completed); + } + + #[tokio::test] + async fn retained_completion_keeps_registry_and_is_auditable() { + let (store, workdir) = seeded_store().await; + let intent = + workdir_removal_intent(&workdir, "workspace-api", "inspect dirty Workdir").unwrap(); + let operation = store.reserve_workdir_removal_operation(&intent).unwrap(); + let retained = store + .complete_workdir_removal_retained( + &operation, + WorkdirRemovalDisposition::Retained, + "dirty_or_unknown", + ) + .unwrap(); + assert_eq!(retained.state, WorkdirRemovalOperationState::Completed); + assert_eq!( + retained.failure_category.as_deref(), + Some("dirty_or_unknown") + ); + assert!( + store + .get_workdir_registry("workspace-a", "workdir-a") + .unwrap() + .is_some() + ); + } +} From 510795f1c5f9ea2f49e1a214a5ab2ab2b38afe1d Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 00:25:21 +0900 Subject: [PATCH 05/11] fix: fence Workdir removal retries --- crates/workspace-server/src/server.rs | 188 +++++++++--------- crates/workspace-server/src/store.rs | 17 +- .../workspace-server/src/workdir_removal.rs | 60 +++++- docs/README.md | 15 +- docs/design/durable-operations.md | 45 +++++ 5 files changed, 216 insertions(+), 109 deletions(-) create mode 100644 docs/design/durable-operations.md diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2498265c..b75048e9 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -9650,10 +9650,7 @@ fn execute_reserved_workdir_removal( ); }; if status.summary.cleanliness.as_deref() != Some("clean") - || !matches!( - status.summary.status, - WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending - ) + || status.summary.status != WorkingDirectoryStatusKind::Active { return api.config_store.complete_workdir_removal_retained( &operation, @@ -12687,50 +12684,33 @@ fn finalize_spawn_compensation_after_worker_delete( if context.cleanup_spawned_workdir { if let Some(workdir_id) = context.prepared_workdir_id { - let runtime_cleanup_succeeded = match api - .runtime - .cleanup_working_directory(&worker.worker.runtime_id, workdir_id) - { - Ok(result) if result.state == WorkerOperationState::Accepted => true, - Ok(result) => { - diagnostics.push(spawn_compensation_diagnostic( - "worker_spawn_compensation_workdir_cleanup_failed", - format!( - "Runtime did not clean up spawn-created Workdir `{workdir_id}` for Worker {}:{}: state={:?}; {}", - worker.worker.runtime_id, - worker.worker.worker_id, - result.state, - runtime_diagnostics_message(&result.diagnostics) - ), - )); - false - } - Err(error) => { - diagnostics.push(spawn_compensation_diagnostic( - "worker_spawn_compensation_workdir_cleanup_failed", - format!( - "Failed to clean up spawn-created Workdir `{workdir_id}` for Worker {}:{}: {}", - worker.worker.runtime_id, - worker.worker.worker_id, - error.message() - ), - )); - false - } - }; - if runtime_cleanup_succeeded { - if let Err(error) = api - .store - .delete_workdir_registry(&api.config.workspace_id, workdir_id) - { - diagnostics.push(spawn_compensation_diagnostic( - "worker_spawn_compensation_workdir_registry_delete_failed", - format!( - "Failed to remove Backend Workdir registry `{workdir_id}` after Runtime cleanup: {}", - sanitize_backend_error(&error.to_string()) - ), - )); - } + match execute_workdir_removal( + api, + workdir_id, + "backend:worker_spawn_compensation", + "remove Workdir created by rejected Worker spawn", + ) { + Ok(result) + if result.disposition == WorkingDirectoryRemovalDisposition::Removed => {} + Ok(result) => diagnostics.push(spawn_compensation_diagnostic( + "worker_spawn_compensation_workdir_cleanup_failed", + format!( + "Durable removal retained spawn-created Workdir `{workdir_id}` for Worker {}:{}: disposition={:?}, retryable={}", + worker.worker.runtime_id, + worker.worker.worker_id, + result.disposition, + result.retryable, + ), + )), + Err(error) => diagnostics.push(spawn_compensation_diagnostic( + "worker_spawn_compensation_workdir_cleanup_failed", + format!( + "Failed to reserve durable removal for spawn-created Workdir `{workdir_id}` for Worker {}:{}: {}", + worker.worker.runtime_id, + worker.worker.worker_id, + sanitize_backend_error(&error.to_string()) + ), + )), } } } @@ -14553,25 +14533,6 @@ fn sync_runtime_workdir_observations( Ok(response.diagnostics) } -fn persist_workdir_cleanup_observation( - api: &WorkspaceApi, - runtime_id: &str, - summary: &WorkingDirectorySummary, -) -> ApiResult<()> { - if summary.status == WorkingDirectoryStatusKind::NotFound { - if let Some(record) = api.store.get_workdir_registry( - &api.config.workspace_id, - summary.working_directory_id.as_str(), - )? { - persist_workdir_not_found(api, record)?; - } - } else { - let record = workdir_record_from_summary(api, runtime_id, summary); - api.store.upsert_workdir_registry(&record)?; - } - Ok(()) -} - fn workdir_runtime_miss_is_not_found(diagnostics: &[RuntimeDiagnostic]) -> bool { diagnostics .iter() @@ -22132,7 +22093,7 @@ mod tests { } #[tokio::test] - async fn confirmed_runtime_miss_removes_registry_record_but_unknown_is_retained() { + async fn provider_not_found_observation_is_retained_until_durable_removal_commits() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -22154,11 +22115,13 @@ mod tests { ) .unwrap(); - assert!( + assert_eq!( api.store .get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir") .unwrap() - .is_none() + .unwrap() + .materialization_status, + "not_found" ); seed_cleanup_workdir(&api, "unknown-workdir", "present", "clean"); @@ -22188,7 +22151,7 @@ mod tests { } #[tokio::test] - async fn cleanup_not_found_observation_removes_registry_record() { + async fn cleanup_not_found_observation_marks_registry_for_durable_removal() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -22199,16 +22162,15 @@ mod tests { .get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id) .unwrap() .unwrap(); - let mut summary = workdir_summary_from_record(&record); - summary.status = WorkingDirectoryStatusKind::NotFound; + persist_workdir_not_found(&api, record).unwrap(); - persist_workdir_cleanup_observation(&api, "runtime-test", &summary).unwrap(); - - assert!( + assert_eq!( api.store .get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id) .unwrap() - .is_none() + .unwrap() + .materialization_status, + "not_found" ); } @@ -22325,8 +22287,33 @@ mod tests { )); } + #[test] + fn only_exact_provider_not_found_is_removal_evidence() { + let not_found = crate::hosts::RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![RuntimeDiagnostic { + code: "working_directory_not_found".to_string(), + severity: DiagnosticSeverity::Error, + message: "missing".to_string(), + }], + }; + assert!(runtime_reports_workdir_not_found(¬_found)); + + let unknown = crate::hosts::RuntimeWorkingDirectoryResult { + state: WorkerOperationState::Rejected, + working_directory: None, + diagnostics: vec![RuntimeDiagnostic { + code: "working_directory_provider_timeout".to_string(), + severity: DiagnosticSeverity::Error, + message: "timeout".to_string(), + }], + }; + assert!(!runtime_reports_workdir_not_found(&unknown)); + } + #[tokio::test] - async fn durable_workdir_removal_replays_completed_provider_not_found_result() { + async fn durable_workdir_removal_retries_provider_unavailable_without_deleting_registry() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -22341,25 +22328,28 @@ mod tests { .unwrap(); assert_eq!( first.disposition, - WorkingDirectoryRemovalDisposition::Removed, - "response: {first:?}" + WorkingDirectoryRemovalDisposition::AttentionRequired + ); + assert!(first.retryable); + assert_eq!( + first.failure_category.as_deref(), + Some("runtime_unavailable") ); - assert!(!first.retryable); assert!( api.store .get_workdir_registry(&api.config.workspace_id, "missing-clean-workdir") .unwrap() - .is_none() + .is_some() ); - let replay = execute_workdir_removal( + let retry = execute_workdir_removal( &api, "missing-clean-workdir", "account:owner", "remove stale clean Workdir", ) .unwrap(); - assert_eq!(replay, first); + assert_eq!(retry, first); let operation = api .config_store .find_workdir_removal_operation_by_intent( @@ -22370,7 +22360,7 @@ mod tests { ) .unwrap() .unwrap(); - assert_eq!(operation.attempt_count, 1); + assert_eq!(operation.attempt_count, 2); } #[tokio::test] @@ -22406,7 +22396,7 @@ mod tests { } #[tokio::test] - async fn recoverable_workdir_removal_converges_after_provider_side_effect() { + async fn recovery_retries_same_operation_and_retains_unknown_provider_result() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -22429,16 +22419,17 @@ mod tests { .get_workdir_removal_operation(&api.config.workspace_id, &intent.operation_id) .unwrap() .unwrap(); - assert_eq!(operation.state, WorkdirRemovalOperationState::Completed); + assert_eq!(operation.state, WorkdirRemovalOperationState::Failed); + assert!(operation.retryable); assert_eq!( - operation.disposition, - Some(WorkdirRemovalDisposition::Removed) + operation.failure_category.as_deref(), + Some("runtime_unavailable") ); assert!( api.store .get_workdir_registry(&api.config.workspace_id, "recovery-workdir") .unwrap() - .is_none() + .is_some() ); } @@ -22602,7 +22593,7 @@ mod tests { } #[tokio::test] - async fn cleanup_execution_requires_dirty_confirmation_and_deletes_removed_record() { + async fn cleanup_execution_retains_dirty_and_requires_fresh_provider_not_found() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -22631,10 +22622,15 @@ mod tests { workdir_target_ids: vec![dirty_target], confirm_dirty_discard_target_ids: Vec::new(), }; + let retained = execute_runtime_cleanup(&api, "runtime-test", missing_confirmation) + .await + .unwrap_or_else(|err| panic!("cleanup execution: {}", err.error)); + assert_eq!(retained.results[0].status, "retained"); assert!( - execute_runtime_cleanup(&api, "runtime-test", missing_confirmation) - .await - .is_err() + api.store + .get_workdir_registry(&api.config.workspace_id, "workdir-dirty") + .unwrap() + .is_some() ); let delete_removed = ExecuteRuntimeCleanupRequest { expected_plan_revision: plan.revision, @@ -22646,12 +22642,12 @@ mod tests { let response = execute_runtime_cleanup(&api, "runtime-test", delete_removed) .await .unwrap_or_else(|err| panic!("cleanup execution: {}", err.error)); - assert_eq!(response.results[0].status, "deleted"); + assert_eq!(response.results[0].status, "attention_required"); assert!( api.store .get_workdir_registry(&api.config.workspace_id, "workdir-not-found") .unwrap() - .is_none() + .is_some() ); } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 271bd05c..de2cd372 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -4889,6 +4889,19 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "Workdir {workdir_id} is not registered in Workspace {workspace_id}" ))); } + let removal_pending: bool = tx.query_row( + r#"SELECT EXISTS( + SELECT 1 FROM workdir_removal_operations + WHERE workspace_id = ?1 AND workdir_id = ?2 AND state = 'pending' + )"#, + params![workspace_id, workdir_id], + |row| row.get(0), + )?; + if removal_pending { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir {workdir_id} has a pending durable removal operation" + ))); + } let occupied: bool = tx.query_row( r#"SELECT EXISTS( SELECT 1 FROM worker_workdir_links @@ -12182,13 +12195,13 @@ INSERT INTO worker_registry ( configure_sqlite(&conn).unwrap(); apply_migrations(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (49, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (50, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 49 is newer"), "{error}"); + assert!(error.contains("schema version 50 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 28abc84c..91ed972f 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -106,9 +106,7 @@ CREATE TABLE workdir_removal_operations ( updated_at TEXT NOT NULL, completed_at TEXT, PRIMARY KEY (workspace_id, operation_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, repository_id) - REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); CREATE INDEX idx_workdir_removal_operations_recovery ON workdir_removal_operations(workspace_id, state, retryable, updated_at); @@ -278,6 +276,16 @@ impl SqliteWorkspaceStore { operation.working_directory_id )))?; require_operation_materialization(operation, ¤t)?; + let repository_exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM repositories WHERE workspace_id=?1 AND repository_id=?2)", + params![operation.workspace_id, operation.repository_id], + |row| row.get(0), + )?; + if !repository_exists { + return Err(Error::RegistryInconsistency( + "Workdir Repository authority is missing".to_string(), + )); + } let active_attachment: bool = conn.query_row( "SELECT EXISTS(SELECT 1 FROM worker_workdir_links WHERE workspace_id=?1 AND workdir_id=?2 AND unlinked_at IS NULL)", params![operation.workspace_id, operation.working_directory_id], @@ -590,7 +598,7 @@ fn validate_intent(intent: &WorkdirRemovalIntent) -> Result<()> { } fn validate_bounded(label: &str, value: &str, max: usize) -> Result<()> { - if value.trim().is_empty() || value.len() > max { + if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) { return Err(Error::InvalidInput(format!( "{label} must be non-empty and at most {max} bytes" ))); @@ -924,6 +932,50 @@ mod tests { assert_eq!(replay, completed); } + #[tokio::test] + async fn pending_removal_fences_new_attachment_and_retry_rereads_live_reservation() { + let (store, workdir) = seeded_store().await; + let intent = + workdir_removal_intent(&workdir, "workspace-api", "remove clean Workdir").unwrap(); + let pending = store.reserve_workdir_removal_operation(&intent).unwrap(); + + let error = store + .reserve_worker_workdir_attachment("workspace-a", "workdir-a", "reservation-a", "2") + .unwrap_err(); + assert!(matches!(error, Error::WorkdirAttachmentConflict(_))); + + let failed = store + .fail_workdir_removal_operation(&pending, "provider_unavailable", true) + .unwrap(); + store + .reserve_worker_workdir_attachment("workspace-a", "workdir-a", "reservation-b", "3") + .unwrap(); + let retry = store + .begin_workdir_removal_attempt( + &failed.workspace_id, + &failed.operation_id, + &failed.request_fingerprint, + ) + .unwrap(); + let guards = store.workdir_removal_guards(&retry).unwrap(); + assert!( + guards + .iter() + .any(|guard| guard.category == "pending_attachment") + ); + let retained = store + .complete_workdir_removal_retained( + &retry, + WorkdirRemovalDisposition::Retained, + "blocked_by_live_authority", + ) + .unwrap(); + assert_eq!( + retained.disposition, + Some(WorkdirRemovalDisposition::Retained) + ); + } + #[tokio::test] async fn retained_completion_keeps_registry_and_is_auditable() { let (store, workdir) = seeded_store().await; diff --git a/docs/README.md b/docs/README.md index d02c1065..534bf851 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,13 +17,14 @@ It is not a dumping ground for external research, old plans, API inventories, or 9. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins. 10. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records. 11. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions. -12. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. -13. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks. -14. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. -15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. -16. [`development/validation.md`](development/validation.md) — how to check changes. -17. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. -18. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。 +12. [`design/durable-operations.md`](design/durable-operations.md) — durable Backend intents that cross Runtime/provider side-effect boundaries, including Workdir removal. +13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. +14. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks. +15. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. +16. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. +17. [`development/validation.md`](development/validation.md) — how to check changes. +18. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. +19. [`design/standalone-agent-host.md`](design/standalone-agent-host.md) — in-process standalone Worker host の依存方向、authority、lifecycle、非目標。 ## What belongs here diff --git a/docs/design/durable-operations.md b/docs/design/durable-operations.md new file mode 100644 index 00000000..b4799a1d --- /dev/null +++ b/docs/design/durable-operations.md @@ -0,0 +1,45 @@ +# Durable side-effect operations + +A durable side-effect operation is a Backend-owned intent whose execution crosses an authority boundary, such as a Runtime/provider mutation, and must converge across duplicate requests and bounded recovery. The durable record is not a trace of Rust control flow. + +## Durable authority + +The record stores only facts that affect identity, authorization, replay, or the final domain result: + +- a stable operation identity and request fingerprint derived from caller intent; +- the Workspace resource and the resolved authority that exact retries must keep; +- `pending`, `failed`, or `completed` lifecycle state; +- attempt count and timestamps as operational evidence; +- explicit retryability, bounded failure category, and bounded disposition; +- a factual checkpoint only when a non-idempotent provider effect cannot be safely re-observed or repeated. + +A fingerprint excludes Server-generated result identifiers, attempt data, diagnostics, and fresh observations. Reusing one operation identity with a different fingerprint is an error. A completed exact retry replays the committed bounded result. + +`pending` means only that the intent remains open. Function names, validation steps, and provider-call positions are not persisted as lifecycle stages. `failed` records the latest terminal attempt outcome; retryability remains separate metadata. `completed` means the required domain result and disposition are durably committed. + +## Live authority and provider evidence + +Before every attempt, the Backend rereads current Workspace ownership and guards. A previous observation that a resource was detached, unblocked, or clean is not authorization for a later retry. + +Provider timeout, unavailability, an empty response, or an unknown outcome is not authoritative absence. Registry cleanup may use only an explicit provider success contract or the provider's exact not-found evidence. If a provider effect is idempotent and exact not-found can be re-observed, arbitrary execution stages and crash-window checkpoints are unnecessary: recovery repeats observation and converges from last committed facts. + +## Workdir removal + +Workdir removal is one durable side-effect operation in the Workspace Server DB. It binds the Workspace, Workdir, owning Runtime, Repository/materialization identity, source actor, stable intent fingerprint, lifecycle, retry metadata, and bounded result. Runtime URL, provider handle, host path, credentials, and caller-selected Runtime are not operation inputs. + +Each attempt: + +1. resolves or revalidates the persisted same-Workspace Workdir, Runtime, Repository, and materialization identity; +2. checks current attachments, attachment reservations, current assignment occupancy, retention/cleanup holds, and pending materialization authority; +3. retains dirty, occupied, blocked, or otherwise unknown Workdirs without detaching a Worker or forcing deletion; +4. observes the owning Runtime/provider and calls its existing Workdir cleanup only for an eligible clean Workdir; +5. treats only successful provider cleanup or exact `working_directory_not_found` as removal evidence; +6. deletes the Backend Workdir registry row and commits the operation's `completed`/`removed` result in one SQLite transaction. + +A provider error leaves the registry intact and records a bounded `attention_required` result with explicit retryability. Startup recovery lists `pending` and retryable `failed` operations, then executes this same path after rereading live authority. `WorkdirDelete`, Workspace REST removal, Runtime cleanup execution, and recovery must not maintain separate inline provider-delete paths. + +The public request contains only `working_directory_id` plus a bounded reason. The public result contains only the Workdir ID, `removed | retained | attention_required`, retryability, and an optional bounded failure category. Internal operation identifiers, checkpoints, provider paths, and credentials are not public DTO fields. + +## Resilience boundary + +This pattern covers duplicate requests, returned failures, timeouts and unknown outcomes, known partial-completion contracts, and restart recovery from committed facts. It does not provide general exactly-once execution or claim recovery from every instruction-boundary panic, process kill, machine loss, or power failure. A stronger provider guarantee requires a separately specified protocol, checkpoint ordering, reconciliation evidence, and tests. From 5418fad7d773136bedd0666b8a5f93d77d2386fd Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 01:00:24 +0900 Subject: [PATCH 06/11] fix: serialize Workdir removal attempts --- crates/workspace-server/src/server.rs | 371 ++++++++++++++++-- .../workspace-server/src/workdir_removal.rs | 111 +++++- docs/design/durable-operations.md | 2 +- 3 files changed, 455 insertions(+), 29 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index b75048e9..f8576579 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -9589,18 +9589,71 @@ fn classify_workdir_provider_error(error: &RuntimeRegistryError) -> (&'static st } } +trait WorkdirRemovalRuntimeProvider: Send + Sync { + fn observe_workdir( + &self, + runtime_id: &str, + working_directory_id: &str, + ) -> std::result::Result; + + fn cleanup_workdir( + &self, + runtime_id: &str, + working_directory_id: &str, + ) -> std::result::Result; +} + +impl WorkdirRemovalRuntimeProvider for RuntimeRegistry { + fn observe_workdir( + &self, + runtime_id: &str, + working_directory_id: &str, + ) -> std::result::Result + { + self.working_directory(runtime_id, working_directory_id) + } + + fn cleanup_workdir( + &self, + runtime_id: &str, + working_directory_id: &str, + ) -> std::result::Result + { + self.cleanup_working_directory(runtime_id, working_directory_id) + } +} + fn execute_reserved_workdir_removal( api: &WorkspaceApi, operation: WorkdirRemovalOperation, + recovery: bool, +) -> Result { + execute_reserved_workdir_removal_with_provider(api, operation, recovery, api.runtime.as_ref()) +} + +fn execute_reserved_workdir_removal_with_provider( + api: &WorkspaceApi, + operation: WorkdirRemovalOperation, + recovery: bool, + provider: &dyn WorkdirRemovalRuntimeProvider, ) -> Result { if operation.state == WorkdirRemovalOperationState::Completed { return Ok(operation); } - let operation = api.config_store.begin_workdir_removal_attempt( - &operation.workspace_id, - &operation.operation_id, - &operation.request_fingerprint, - )?; + let operation = if recovery { + api.config_store + .reclaim_workdir_removal_attempt_for_recovery( + &operation.workspace_id, + &operation.operation_id, + &operation.request_fingerprint, + )? + } else { + api.config_store.begin_workdir_removal_attempt( + &operation.workspace_id, + &operation.operation_id, + &operation.request_fingerprint, + )? + }; let guards = match api.config_store.workdir_removal_guards(&operation) { Ok(guards) => guards, Err( @@ -9627,10 +9680,8 @@ fn execute_reserved_workdir_removal( // Runtime observation is the provider's publication/removal authority. A // missing summary without the exact not-found diagnostic is unknown, not a // successful delete. - let observed = match api - .runtime - .working_directory(&operation.runtime_id, &operation.working_directory_id) - { + let observed = provider.observe_workdir(&operation.runtime_id, &operation.working_directory_id); + let observed = match observed { Ok(observed) => observed, Err(error) => { let (category, retryable) = classify_workdir_provider_error(&error); @@ -9659,10 +9710,8 @@ fn execute_reserved_workdir_removal( ); } - let deleted = match api - .runtime - .cleanup_working_directory(&operation.runtime_id, &operation.working_directory_id) - { + let deleted = provider.cleanup_workdir(&operation.runtime_id, &operation.working_directory_id); + let deleted = match deleted { Ok(deleted) => deleted, Err(error) => { let (category, retryable) = classify_workdir_provider_error(&error); @@ -9692,6 +9741,20 @@ fn execute_reserved_workdir_removal( api.config_store.commit_workdir_removal_removed(&operation) } +fn workdir_removal_execution_lock( + api: &WorkspaceApi, + working_directory_id: &str, +) -> Result>> { + let mut locks = api + .workdir_remove_locks + .lock() + .map_err(|_| Error::Store("Workdir removal lock registry was poisoned".to_string()))?; + Ok(locks + .entry(working_directory_id.to_string()) + .or_insert_with(|| Arc::new(std::sync::Mutex::new(()))) + .clone()) +} + fn execute_workdir_removal( api: &WorkspaceApi, working_directory_id: &str, @@ -9704,16 +9767,7 @@ fn execute_workdir_removal( "Workdir removal reason must be between 1 and 500 bytes".to_string(), )); } - let lock = { - let mut locks = api - .workdir_remove_locks - .lock() - .map_err(|_| Error::Store("Workdir removal lock registry was poisoned".to_string()))?; - locks - .entry(working_directory_id.to_string()) - .or_insert_with(|| Arc::new(std::sync::Mutex::new(()))) - .clone() - }; + let lock = workdir_removal_execution_lock(api, working_directory_id)?; let _guard = lock .lock() .map_err(|_| Error::Store("Workdir removal lock was poisoned".to_string()))?; @@ -9737,7 +9791,7 @@ fn execute_workdir_removal( api.config_store .reserve_workdir_removal_operation(&intent)? }; - execute_reserved_workdir_removal(api, operation) + execute_reserved_workdir_removal(api, operation, false) .map(|operation| workdir_removal_response(&operation)) } @@ -9746,7 +9800,14 @@ fn recover_workdir_removals(api: &WorkspaceApi) -> Result<()> { .config_store .recoverable_workdir_removal_operations(api.workspace_id(), 100)? { - if let Err(error) = execute_reserved_workdir_removal(api, operation.clone()) { + let result = + workdir_removal_execution_lock(api, &operation.working_directory_id).and_then(|lock| { + let _guard = lock + .lock() + .map_err(|_| Error::Store("Workdir removal lock was poisoned".to_string()))?; + execute_reserved_workdir_removal(api, operation.clone(), true) + }); + if let Err(error) = result { tracing::warn!( workspace_id = %api.workspace_id(), workdir_id = %operation.working_directory_id, @@ -22287,6 +22348,253 @@ mod tests { )); } + struct FakeWorkdirRemovalProvider { + observation: Mutex< + Option< + std::result::Result< + crate::hosts::RuntimeWorkingDirectoryResult, + RuntimeRegistryError, + >, + >, + >, + cleanup: Mutex< + Option< + std::result::Result< + crate::hosts::RuntimeWorkingDirectoryResult, + RuntimeRegistryError, + >, + >, + >, + cleanup_calls: std::sync::atomic::AtomicUsize, + } + + impl FakeWorkdirRemovalProvider { + fn new( + observation: crate::hosts::RuntimeWorkingDirectoryResult, + cleanup: crate::hosts::RuntimeWorkingDirectoryResult, + ) -> Self { + Self { + observation: Mutex::new(Some(Ok(observation))), + cleanup: Mutex::new(Some(Ok(cleanup))), + cleanup_calls: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn cleanup_calls(&self) -> usize { + self.cleanup_calls.load(std::sync::atomic::Ordering::SeqCst) + } + } + + impl WorkdirRemovalRuntimeProvider for FakeWorkdirRemovalProvider { + fn observe_workdir( + &self, + _runtime_id: &str, + _working_directory_id: &str, + ) -> std::result::Result + { + self.observation + .lock() + .unwrap() + .take() + .expect("one observation fixture") + } + + fn cleanup_workdir( + &self, + _runtime_id: &str, + _working_directory_id: &str, + ) -> std::result::Result + { + self.cleanup_calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(5)); + self.cleanup + .lock() + .unwrap() + .take() + .expect("one cleanup fixture") + } + } + + fn workdir_removal_result( + state: WorkerOperationState, + summary: Option, + diagnostics: Vec, + ) -> crate::hosts::RuntimeWorkingDirectoryResult { + crate::hosts::RuntimeWorkingDirectoryResult { + state, + working_directory: summary + .map(|summary| worker_runtime::catalog::WorkingDirectoryStatus { summary }), + diagnostics, + } + } + + fn reserve_removal_fixture( + api: &WorkspaceApi, + working_directory_id: &str, + ) -> (WorkdirRemovalOperation, WorkingDirectorySummary) { + seed_cleanup_workdir(api, working_directory_id, "present", "clean"); + let record = api + .store + .get_workdir_registry(&api.config.workspace_id, working_directory_id) + .unwrap() + .unwrap(); + let summary = workdir_summary_from_record(&record); + let intent = workdir_removal_intent( + &record, + "account:owner", + &format!("remove {working_directory_id}"), + ) + .unwrap(); + let operation = api + .config_store + .reserve_workdir_removal_operation(&intent) + .unwrap(); + (operation, summary) + } + + #[tokio::test] + async fn injectable_provider_covers_cleanup_not_found_unknown_dirty_and_unsupported() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + + let (clean_operation, clean_summary) = reserve_removal_fixture(&api, "clean-provider"); + let clean_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + WorkerOperationState::Accepted, + Some(clean_summary), + Vec::new(), + ), + workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + ); + let removed = execute_reserved_workdir_removal_with_provider( + &api, + clean_operation.clone(), + false, + &clean_provider, + ) + .unwrap(); + assert_eq!( + removed.disposition, + Some(WorkdirRemovalDisposition::Removed) + ); + assert_eq!(clean_provider.cleanup_calls(), 1); + let replay = execute_reserved_workdir_removal_with_provider( + &api, + removed.clone(), + false, + &clean_provider, + ) + .unwrap(); + assert_eq!(replay, removed); + assert_eq!(clean_provider.cleanup_calls(), 1); + + let (missing_operation, _) = reserve_removal_fixture(&api, "provider-not-found"); + let not_found = RuntimeDiagnostic { + code: "working_directory_not_found".to_string(), + severity: DiagnosticSeverity::Error, + message: "missing".to_string(), + }; + let missing_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + WorkerOperationState::Rejected, + None, + vec![not_found.clone()], + ), + workdir_removal_result(WorkerOperationState::Rejected, None, vec![not_found]), + ); + let removed = execute_reserved_workdir_removal_with_provider( + &api, + missing_operation, + false, + &missing_provider, + ) + .unwrap(); + assert_eq!( + removed.disposition, + Some(WorkdirRemovalDisposition::Removed) + ); + assert_eq!(missing_provider.cleanup_calls(), 0); + + let (unknown_operation, _) = reserve_removal_fixture(&api, "provider-unknown"); + let unknown_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + WorkerOperationState::Accepted, + None, + vec![RuntimeDiagnostic { + code: "working_directory_provider_timeout".to_string(), + severity: DiagnosticSeverity::Error, + message: "timeout".to_string(), + }], + ), + workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + ); + let unknown = execute_reserved_workdir_removal_with_provider( + &api, + unknown_operation, + false, + &unknown_provider, + ) + .unwrap(); + assert_eq!(unknown.state, WorkdirRemovalOperationState::Failed); + assert!(unknown.retryable); + assert_eq!(unknown_provider.cleanup_calls(), 0); + + let (dirty_operation, mut dirty_summary) = reserve_removal_fixture(&api, "provider-dirty"); + dirty_summary.cleanliness = Some("dirty".to_string()); + let dirty_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + WorkerOperationState::Accepted, + Some(dirty_summary), + Vec::new(), + ), + workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + ); + let dirty = execute_reserved_workdir_removal_with_provider( + &api, + dirty_operation, + false, + &dirty_provider, + ) + .unwrap(); + assert_eq!(dirty.disposition, Some(WorkdirRemovalDisposition::Retained)); + assert_eq!(dirty_provider.cleanup_calls(), 0); + + let (unsupported_operation, unsupported_summary) = + reserve_removal_fixture(&api, "provider-unsupported"); + let unsupported_provider = FakeWorkdirRemovalProvider::new( + workdir_removal_result( + WorkerOperationState::Accepted, + Some(unsupported_summary), + Vec::new(), + ), + workdir_removal_result( + WorkerOperationState::Unsupported, + None, + vec![RuntimeDiagnostic { + code: "working_directory_unsupported".to_string(), + severity: DiagnosticSeverity::Error, + message: "unsupported".to_string(), + }], + ), + ); + let unsupported = execute_reserved_workdir_removal_with_provider( + &api, + unsupported_operation, + false, + &unsupported_provider, + ) + .unwrap(); + assert_eq!(unsupported.state, WorkdirRemovalOperationState::Failed); + assert!(!unsupported.retryable); + assert_eq!( + unsupported.failure_category.as_deref(), + Some("unsupported_target") + ); + assert_eq!(unsupported_provider.cleanup_calls(), 1); + } + #[test] fn only_exact_provider_not_found_is_removal_evidence() { let not_found = crate::hosts::RuntimeWorkingDirectoryResult { @@ -22408,9 +22716,19 @@ mod tests { .unwrap(); let intent = workdir_removal_intent(&record, "account:owner", "recover provider cleanup").unwrap(); - api.config_store + let reserved = api + .config_store .reserve_workdir_removal_operation(&intent) .unwrap(); + let interrupted_attempt = api + .config_store + .begin_workdir_removal_attempt( + &reserved.workspace_id, + &reserved.operation_id, + &reserved.request_fingerprint, + ) + .unwrap(); + assert_eq!(interrupted_attempt.attempt_count, 1); recover_workdir_removals(&api).unwrap(); @@ -22420,6 +22738,7 @@ mod tests { .unwrap() .unwrap(); assert_eq!(operation.state, WorkdirRemovalOperationState::Failed); + assert_eq!(operation.attempt_count, 2); assert!(operation.retryable); assert_eq!( operation.failure_category.as_deref(), diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 91ed972f..e5eb0cf6 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -108,6 +108,9 @@ CREATE TABLE workdir_removal_operations ( PRIMARY KEY (workspace_id, operation_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); +CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending + ON workdir_removal_operations(workspace_id, workdir_id) + WHERE state = 'pending'; CREATE INDEX idx_workdir_removal_operations_recovery ON workdir_removal_operations(workspace_id, state, retryable, updated_at); CREATE INDEX idx_workdir_removal_operations_workdir @@ -188,6 +191,18 @@ impl SqliteWorkspaceStore { tx.commit()?; return Ok(existing); } + let pending_operation: Option = tx + .query_row( + "SELECT operation_id FROM workdir_removal_operations WHERE workspace_id=?1 AND workdir_id=?2 AND state='pending' LIMIT 1", + params![intent.workspace_id, intent.working_directory_id], + |row| row.get(0), + ) + .optional()?; + if let Some(pending_operation) = pending_operation { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir removal operation `{pending_operation}` is already pending" + ))); + } let current = load_workdir_record(&tx, &intent.workspace_id, &intent.working_directory_id)? .ok_or_else(|| Error::InvalidInput(format!( "Unknown Workdir `{}`", @@ -237,10 +252,40 @@ impl SqliteWorkspaceStore { workspace_id: &str, operation_id: &str, request_fingerprint: &str, + ) -> Result { + self.begin_workdir_removal_attempt_inner( + workspace_id, + operation_id, + request_fingerprint, + false, + ) + } + + pub fn reclaim_workdir_removal_attempt_for_recovery( + &self, + workspace_id: &str, + operation_id: &str, + request_fingerprint: &str, + ) -> Result { + self.begin_workdir_removal_attempt_inner( + workspace_id, + operation_id, + request_fingerprint, + true, + ) + } + + fn begin_workdir_removal_attempt_inner( + &self, + workspace_id: &str, + operation_id: &str, + request_fingerprint: &str, + recovery: bool, ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let operation = require_operation(&tx, workspace_id, operation_id, request_fingerprint)?; + let operation = + require_operation(&tx, workspace_id, operation_id, request_fingerprint)?; if operation.state == WorkdirRemovalOperationState::Completed { tx.commit()?; return Ok(operation); @@ -250,12 +295,21 @@ impl SqliteWorkspaceStore { "Workdir removal operation `{operation_id}` is not retryable" ))); } + if operation.state == WorkdirRemovalOperationState::Pending + && operation.attempt_count > 0 + && !recovery + { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir removal operation `{operation_id}` already has an active attempt" + ))); + } let now = Utc::now().to_rfc3339(); tx.execute( "UPDATE workdir_removal_operations SET state='pending', attempt_count=attempt_count+1, retryable=1, failure_category=NULL, disposition=NULL, updated_at=?1, completed_at=NULL WHERE workspace_id=?2 AND operation_id=?3 AND request_fingerprint=?4", params![now, workspace_id, operation_id, request_fingerprint], )?; - let operation = require_operation(&tx, workspace_id, operation_id, request_fingerprint)?; + let operation = + require_operation(&tx, workspace_id, operation_id, request_fingerprint)?; tx.commit()?; Ok(operation) }) @@ -932,6 +986,59 @@ mod tests { assert_eq!(replay, completed); } + #[tokio::test] + async fn concurrent_claims_invoke_the_simulated_provider_once() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier}; + + let (store, workdir) = seeded_store().await; + let intent = + workdir_removal_intent(&workdir, "workspace-api", "remove clean Workdir").unwrap(); + let operation = store.reserve_workdir_removal_operation(&intent).unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let provider_calls = Arc::new(AtomicUsize::new(0)); + let mut callers = Vec::new(); + for _ in 0..2 { + let store = store.clone(); + let operation = operation.clone(); + let barrier = barrier.clone(); + let provider_calls = provider_calls.clone(); + callers.push(std::thread::spawn(move || { + barrier.wait(); + let claim = store.begin_workdir_removal_attempt( + &operation.workspace_id, + &operation.operation_id, + &operation.request_fingerprint, + ); + if claim.is_ok() { + provider_calls.fetch_add(1, Ordering::SeqCst); + } + claim + })); + } + barrier.wait(); + let results = callers + .into_iter() + .map(|caller| caller.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(Error::WorkdirAttachmentConflict(_)))) + .count(), + 1 + ); + assert_eq!(provider_calls.load(Ordering::SeqCst), 1); + + let claimed = results.into_iter().find_map(|result| result.ok()).unwrap(); + let completed = store.commit_workdir_removal_removed(&claimed).unwrap(); + assert_eq!( + completed.disposition, + Some(WorkdirRemovalDisposition::Removed) + ); + } + #[tokio::test] async fn pending_removal_fences_new_attachment_and_retry_rereads_live_reservation() { let (store, workdir) = seeded_store().await; diff --git a/docs/design/durable-operations.md b/docs/design/durable-operations.md index b4799a1d..7098b0f3 100644 --- a/docs/design/durable-operations.md +++ b/docs/design/durable-operations.md @@ -13,7 +13,7 @@ The record stores only facts that affect identity, authorization, replay, or the - explicit retryability, bounded failure category, and bounded disposition; - a factual checkpoint only when a non-idempotent provider effect cannot be safely re-observed or repeated. -A fingerprint excludes Server-generated result identifiers, attempt data, diagnostics, and fresh observations. Reusing one operation identity with a different fingerprint is an error. A completed exact retry replays the committed bounded result. +A fingerprint excludes Server-generated result identifiers, attempt data, diagnostics, and fresh observations. Reusing one operation identity with a different fingerprint is an error. A completed exact retry replays the committed bounded result. A durable one-pending-operation constraint plus an atomic attempt claim prevents concurrent callers from entering the provider side effect for the same Workdir; the in-process resource lock is an additional serialization layer, not the sole authority. `pending` means only that the intent remains open. Function names, validation steps, and provider-call positions are not persisted as lifecycle stages. `failed` records the latest terminal attempt outcome; retryability remains separate metadata. `completed` means the required domain result and disposition are durably committed. From d2ffbf2c401ee8c3ee74aaeea29e5c6cca161347 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 01:40:48 +0900 Subject: [PATCH 07/11] fix: guard Workdir removal recovery ownership --- crates/workspace-server/src/server.rs | 158 +++++++++++++++++- crates/workspace-server/src/store.rs | 147 +++++++++++----- .../src/workdir_create_operations.rs | 84 +++++++++- .../workspace-server/src/workdir_removal.rs | 142 ++++++++++++++-- docs/design/durable-operations.md | 4 +- 5 files changed, 479 insertions(+), 56 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f8576579..9c08f871 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -142,8 +142,8 @@ use crate::store::{ WorkspaceResourceKind, }; use crate::workdir_removal::{ - WorkdirRemovalDisposition, WorkdirRemovalOperation, WorkdirRemovalOperationState, - workdir_removal_intent, + WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation, + WorkdirRemovalOperationState, workdir_removal_intent, }; use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest}; use crate::{Error, Result}; @@ -519,6 +519,7 @@ pub struct WorkspaceApi { workdir_session_locks: Arc>>>>, worker_remove_locks: Arc>>>>, workdir_remove_locks: Arc>>>>, + workdir_remove_attempt_owner: WorkdirRemovalAttemptOwner, worker_control_locks: Arc>>>>, } @@ -1640,6 +1641,7 @@ impl WorkspaceApi { workdir_session_locks: Arc::new(Mutex::new(HashMap::new())), worker_remove_locks: Arc::new(Mutex::new(HashMap::new())), workdir_remove_locks: Arc::new(Mutex::new(HashMap::new())), + workdir_remove_attempt_owner: current_workdir_removal_attempt_owner()?, worker_control_locks: Arc::new(Mutex::new(HashMap::new())), }; if let Some(dispatcher) = worker_remove_dispatcher { @@ -9320,6 +9322,16 @@ async fn create_workspace_working_directory( ) }); } + let reserved = if reserved.state == "failed" { + api.config_store.begin_failed_workdir_create_retry( + workspace_id, + &operation_id, + &request_fingerprint, + &now_registry_timestamp(), + )? + } else { + reserved + }; let runtime = match api .runtime @@ -9589,6 +9601,91 @@ fn classify_workdir_provider_error(error: &RuntimeRegistryError) -> (&'static st } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkdirRemovalOwnerObservation { + Running { process_start_marker: u64 }, + Missing, + Unobservable, +} + +fn current_workdir_removal_attempt_owner() -> Result { + let process_id = std::process::id(); + match observe_workdir_removal_owner(process_id) { + WorkdirRemovalOwnerObservation::Running { + process_start_marker, + } => Ok(WorkdirRemovalAttemptOwner { + process_id, + process_start_marker, + }), + WorkdirRemovalOwnerObservation::Missing | WorkdirRemovalOwnerObservation::Unobservable => { + Err(Error::Config( + "current Server process identity is unavailable for durable Workdir removal" + .to_string(), + )) + } + } +} + +fn workdir_removal_attempt_is_orphaned(operation: &WorkdirRemovalOperation) -> Result { + let Some(owner) = operation.attempt_owner else { + return Ok(operation.attempt_count == 0); + }; + match observe_workdir_removal_owner(owner.process_id) { + WorkdirRemovalOwnerObservation::Running { + process_start_marker, + } if process_start_marker == owner.process_start_marker => Ok(false), + WorkdirRemovalOwnerObservation::Running { .. } + | WorkdirRemovalOwnerObservation::Missing => Ok(true), + WorkdirRemovalOwnerObservation::Unobservable => Err(Error::RegistryInconsistency( + "prior Workdir removal attempt owner liveness is unobservable".to_string(), + )), + } +} + +#[cfg(target_os = "linux")] +fn observe_workdir_removal_owner(process_id: u32) -> WorkdirRemovalOwnerObservation { + let stat = match std::fs::read_to_string(format!("/proc/{process_id}/stat")) { + Ok(stat) => stat, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return if process_id != std::process::id() + && std::fs::read_to_string("/proc/self/stat") + .ok() + .and_then(|stat| parse_linux_process_start_marker(&stat)) + .is_some() + { + WorkdirRemovalOwnerObservation::Missing + } else { + WorkdirRemovalOwnerObservation::Unobservable + }; + } + Err(_) => return WorkdirRemovalOwnerObservation::Unobservable, + }; + parse_linux_process_start_marker(&stat) + .map( + |process_start_marker| WorkdirRemovalOwnerObservation::Running { + process_start_marker, + }, + ) + .unwrap_or(WorkdirRemovalOwnerObservation::Unobservable) +} + +#[cfg(target_os = "linux")] +fn parse_linux_process_start_marker(stat: &str) -> Option { + let (_, tail) = stat.rsplit_once(") ")?; + tail.split_whitespace().nth(19)?.parse().ok() +} + +#[cfg(not(target_os = "linux"))] +fn observe_workdir_removal_owner(process_id: u32) -> WorkdirRemovalOwnerObservation { + if process_id == std::process::id() { + WorkdirRemovalOwnerObservation::Running { + process_start_marker: 0, + } + } else { + WorkdirRemovalOwnerObservation::Unobservable + } +} + trait WorkdirRemovalRuntimeProvider: Send + Sync { fn observe_workdir( &self, @@ -9641,17 +9738,25 @@ fn execute_reserved_workdir_removal_with_provider( return Ok(operation); } let operation = if recovery { + let prior_owner_is_orphaned = if operation.state == WorkdirRemovalOperationState::Pending { + workdir_removal_attempt_is_orphaned(&operation)? + } else { + false + }; api.config_store .reclaim_workdir_removal_attempt_for_recovery( &operation.workspace_id, &operation.operation_id, &operation.request_fingerprint, + api.workdir_remove_attempt_owner, + prior_owner_is_orphaned, )? } else { api.config_store.begin_workdir_removal_attempt( &operation.workspace_id, &operation.operation_id, &operation.request_fingerprint, + api.workdir_remove_attempt_owner, )? }; let guards = match api.config_store.workdir_removal_guards(&operation) { @@ -22703,6 +22808,51 @@ mod tests { ); } + #[tokio::test] + async fn recovery_does_not_reclaim_live_attempt_owner() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + seed_cleanup_workdir(&api, "live-owner-workdir", "present", "clean"); + let record = api + .store + .get_workdir_registry(&api.config.workspace_id, "live-owner-workdir") + .unwrap() + .unwrap(); + let intent = workdir_removal_intent( + &record, + "account:owner", + "do not steal a live provider call", + ) + .unwrap(); + let reserved = api + .config_store + .reserve_workdir_removal_operation(&intent) + .unwrap(); + api.config_store + .begin_workdir_removal_attempt( + &reserved.workspace_id, + &reserved.operation_id, + &reserved.request_fingerprint, + api.workdir_remove_attempt_owner, + ) + .unwrap(); + + recover_workdir_removals(&api).unwrap(); + + let operation = api + .config_store + .get_workdir_removal_operation(&api.config.workspace_id, &intent.operation_id) + .unwrap() + .unwrap(); + assert_eq!(operation.state, WorkdirRemovalOperationState::Pending); + assert_eq!(operation.attempt_count, 1); + assert_eq!( + operation.attempt_owner, + Some(api.workdir_remove_attempt_owner) + ); + } + #[tokio::test] async fn recovery_retries_same_operation_and_retains_unknown_provider_result() { let workspace = tempfile::tempdir().unwrap(); @@ -22726,6 +22876,10 @@ mod tests { &reserved.workspace_id, &reserved.operation_id, &reserved.request_fingerprint, + WorkdirRemovalAttemptOwner { + process_id: u32::MAX, + process_start_marker: 1, + }, ) .unwrap(); assert_eq!(interrupted_attempt.attempt_count, 1); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index de2cd372..137487b6 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -10104,48 +10104,117 @@ mod tests { } #[test] - fn schema_v49_upgrades_v48_with_durable_workdir_removal_authority() { - let conn = Connection::open_in_memory().unwrap(); - configure_sqlite(&conn).unwrap(); - apply_migrations_through(&conn, 48).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 48); - assert!(!table_exists(&conn, "workdir_removal_operations").unwrap()); - - apply_migrations(&conn).unwrap(); - - assert_eq!(current_schema_version(&conn).unwrap(), 49); - assert!(table_exists(&conn, "workdir_removal_operations").unwrap()); - let columns = table_columns(&conn, "workdir_removal_operations").unwrap(); - for required in [ - "workspace_id", - "operation_id", - "request_fingerprint", - "workdir_id", - "runtime_id", - "repository_id", - "materialization_fingerprint", - "source_actor", - "reason", - "state", - "attempt_count", - "retryable", - "disposition", - "failure_category", - "created_at", - "updated_at", - "completed_at", - ] { - assert!( - columns.iter().any(|column| column == required), - "missing {required}" - ); + fn schema_v49_upgrades_persisted_v48_workdir_fixture() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server-v48.db"); + { + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 48).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 48); + assert!(!table_exists(&conn, "workdir_removal_operations").unwrap()); + conn.execute_batch( + r#" + INSERT INTO accounts ( + account_id, kind, handle, display_name, created_at, updated_at + ) VALUES ('owner-account', 'user', 'owner', 'Owner', '1', '1'); + INSERT INTO workspaces ( + workspace_id, owner_account_id, display_name, state, created_at, updated_at + ) VALUES ('workspace-a', 'owner-account', 'Workspace A', 'active', '1', '1'); + INSERT INTO repositories ( + workspace_id, repository_id, name, kind, provider, uri, + source_kind, source_uri, default_ref, source_revision, + source_fingerprint, observed_status, observed_at, created_at, updated_at + ) VALUES ( + 'workspace-a', 'repository-a', 'Repository A', 'git', 'local', '/repo-a', + 'local_path', '/repo-a', 'develop', 1, + 'sha256:source-a', 'unverified', NULL, '1', '1' + ); + INSERT INTO workdir_registry ( + workspace_id, workdir_id, runtime_id, repository_id, + creation_selector, creation_ref, creation_tree, + current_selector, current_ref, current_tree, + observed_at_epoch_seconds, materialization_status, cleanliness, + created_at, updated_at + ) VALUES ( + 'workspace-a', 'workdir-a', 'runtime-a', 'repository-a', + 'refs/heads/develop', 'abc', 'tree-a', + 'refs/heads/work', 'def', 'tree-b', + 1, 'present', 'clean', '1', '1' + ); + "#, + ) + .unwrap(); } - let foreign_key_failures: i64 = conn - .query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| { - row.get(0) + + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 49); + assert!(table_exists(conn, "workdir_removal_operations")?); + let columns = table_columns(conn, "workdir_removal_operations")?; + for required in [ + "workspace_id", + "operation_id", + "request_fingerprint", + "workdir_id", + "runtime_id", + "repository_id", + "materialization_fingerprint", + "source_actor", + "reason", + "state", + "attempt_count", + "retryable", + "disposition", + "failure_category", + "attempt_owner_pid", + "attempt_owner_start_marker", + "created_at", + "updated_at", + "completed_at", + ] { + assert!( + columns.iter().any(|column| column == required), + "missing {required}" + ); + } + let preserved: (String, String, String) = conn.query_row( + "SELECT workspace_id, repository_id, materialization_status FROM workdir_registry WHERE workdir_id='workdir-a'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?; + assert_eq!( + preserved, + ( + "workspace-a".to_string(), + "repository-a".to_string(), + "present".to_string(), + ) + ); + let foreign_key_failures: i64 = conn.query_row( + "SELECT count(*) FROM pragma_foreign_key_check", + [], + |row| row.get(0), + )?; + assert_eq!(foreign_key_failures, 0); + Ok(()) }) .unwrap(); - assert_eq!(foreign_key_failures, 0); + + let workdir = store + .get_workdir_registry("workspace-a", "workdir-a") + .unwrap() + .unwrap(); + let intent = crate::workdir_removal::workdir_removal_intent( + &workdir, + "migration-test", + "remove migrated Workdir", + ) + .unwrap(); + let operation = store.reserve_workdir_removal_operation(&intent).unwrap(); + assert_eq!(operation.workspace_id, "workspace-a"); + assert_eq!(operation.working_directory_id, "workdir-a"); } #[test] diff --git a/crates/workspace-server/src/workdir_create_operations.rs b/crates/workspace-server/src/workdir_create_operations.rs index 3ad73ab3..25fd910d 100644 --- a/crates/workspace-server/src/workdir_create_operations.rs +++ b/crates/workspace-server/src/workdir_create_operations.rs @@ -1,4 +1,4 @@ -use rusqlite::{OptionalExtension, params}; +use rusqlite::{OptionalExtension, TransactionBehavior, params}; use sha2::{Digest, Sha256}; use crate::store::WorkdirCreateOperationRecord; @@ -103,6 +103,65 @@ impl SqliteWorkspaceStore { }) } + pub fn begin_failed_workdir_create_retry( + &self, + workspace_id: &str, + operation_id: &str, + request_fingerprint: &str, + updated_at: &str, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workdir create operation `{operation_id}` disappeared before retry" + )) + })?; + if operation.request_fingerprint != request_fingerprint { + return Err(Error::InvalidInput(format!( + "Workdir create operation `{operation_id}` was reused with different input" + ))); + } + if operation.state != "failed" { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir create operation `{operation_id}` is not a failed retry" + ))); + } + let removal_pending: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workdir_removal_operations WHERE workspace_id=?1 AND workdir_id=?2 AND state='pending')", + params![workspace_id, operation.working_directory_id], + |row| row.get(0), + )?; + if removal_pending { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir {} has a pending durable removal operation", + operation.working_directory_id + ))); + } + let changed = tx.execute( + r#"UPDATE workdir_create_operations + SET state='pending', failure=NULL, updated_at=?1 + WHERE workspace_id=?2 AND operation_id=?3 + AND request_fingerprint=?4 AND state='failed'"#, + params![updated_at, workspace_id, operation_id, request_fingerprint], + )?; + if changed != 1 { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir create operation `{operation_id}` retry was claimed concurrently" + ))); + } + let updated = read_workdir_create_operation(&tx, workspace_id, operation_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workdir create operation `{operation_id}` disappeared after retry claim" + )) + })?; + tx.commit()?; + Ok(updated) + }) + } + pub fn bind_workdir_create_repository_access( &self, workspace_id: &str, @@ -406,11 +465,32 @@ mod tests { .unwrap(); assert_eq!(replayed, bound); assert_eq!(replayed.source_uri.as_deref(), Some("/tmp/repo")); + let failed = store + .finish_workdir_create_operation( + "workspace", + "call-1", + &record.request_fingerprint, + false, + Some("provider failed"), + "2026-08-24T00:00:03Z", + ) + .unwrap(); + assert_eq!(failed.state, "failed"); + let retry = store + .begin_failed_workdir_create_retry( + "workspace", + "call-1", + &record.request_fingerprint, + "2026-08-24T00:00:04Z", + ) + .unwrap(); + assert_eq!(retry.state, "pending"); + assert_eq!(retry.failure, None); assert_eq!( store .load_workdir_create_operation("workspace", "call-1") .unwrap(), - Some(bound.clone()) + Some(retry.clone()) ); let mut changed_input = record.clone(); changed_input.request_fingerprint = diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index e5eb0cf6..ae8cfa04 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -44,6 +44,12 @@ impl WorkdirRemovalDisposition { } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkdirRemovalAttemptOwner { + pub process_id: u32, + pub process_start_marker: u64, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkdirRemovalIntent { pub operation_id: String, @@ -73,6 +79,7 @@ pub struct WorkdirRemovalOperation { pub retryable: bool, pub disposition: Option, pub failure_category: Option, + pub attempt_owner: Option, pub created_at: String, pub updated_at: String, pub completed_at: Option, @@ -102,6 +109,8 @@ CREATE TABLE workdir_removal_operations ( retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')), failure_category TEXT, + attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0), + attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0), created_at TEXT NOT NULL, updated_at TEXT NOT NULL, completed_at TEXT, @@ -252,11 +261,13 @@ impl SqliteWorkspaceStore { workspace_id: &str, operation_id: &str, request_fingerprint: &str, + owner: WorkdirRemovalAttemptOwner, ) -> Result { self.begin_workdir_removal_attempt_inner( workspace_id, operation_id, request_fingerprint, + owner, false, ) } @@ -266,12 +277,15 @@ impl SqliteWorkspaceStore { workspace_id: &str, operation_id: &str, request_fingerprint: &str, + owner: WorkdirRemovalAttemptOwner, + prior_owner_is_orphaned: bool, ) -> Result { self.begin_workdir_removal_attempt_inner( workspace_id, operation_id, request_fingerprint, - true, + owner, + prior_owner_is_orphaned, ) } @@ -280,7 +294,8 @@ impl SqliteWorkspaceStore { workspace_id: &str, operation_id: &str, request_fingerprint: &str, - recovery: bool, + owner: WorkdirRemovalAttemptOwner, + prior_owner_is_orphaned: bool, ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; @@ -297,7 +312,7 @@ impl SqliteWorkspaceStore { } if operation.state == WorkdirRemovalOperationState::Pending && operation.attempt_count > 0 - && !recovery + && !prior_owner_is_orphaned { return Err(Error::WorkdirAttachmentConflict(format!( "Workdir removal operation `{operation_id}` already has an active attempt" @@ -305,8 +320,19 @@ impl SqliteWorkspaceStore { } let now = Utc::now().to_rfc3339(); tx.execute( - "UPDATE workdir_removal_operations SET state='pending', attempt_count=attempt_count+1, retryable=1, failure_category=NULL, disposition=NULL, updated_at=?1, completed_at=NULL WHERE workspace_id=?2 AND operation_id=?3 AND request_fingerprint=?4", - params![now, workspace_id, operation_id, request_fingerprint], + "UPDATE workdir_removal_operations SET state='pending', attempt_count=attempt_count+1, retryable=1, failure_category=NULL, disposition=NULL, attempt_owner_pid=?1, attempt_owner_start_marker=?2, updated_at=?3, completed_at=NULL WHERE workspace_id=?4 AND operation_id=?5 AND request_fingerprint=?6", + params![ + owner.process_id, + i64::try_from(owner.process_start_marker).map_err(|_| { + Error::InvalidInput( + "process start marker is out of SQLite range".to_string(), + ) + })?, + now, + workspace_id, + operation_id, + request_fingerprint, + ], )?; let operation = require_operation(&tx, workspace_id, operation_id, request_fingerprint)?; @@ -456,7 +482,7 @@ impl SqliteWorkspaceStore { } let now = Utc::now().to_rfc3339(); tx.execute( - "UPDATE workdir_removal_operations SET state='failed', retryable=?1, disposition=?2, failure_category=?3, updated_at=?4 WHERE workspace_id=?5 AND operation_id=?6 AND request_fingerprint=?7", + "UPDATE workdir_removal_operations SET state='failed', retryable=?1, disposition=?2, failure_category=?3, attempt_owner_pid=NULL, attempt_owner_start_marker=NULL, updated_at=?4 WHERE workspace_id=?5 AND operation_id=?6 AND request_fingerprint=?7", params![ retryable, WorkdirRemovalDisposition::AttentionRequired.as_str(), @@ -538,7 +564,7 @@ impl SqliteWorkspaceStore { } let now = Utc::now().to_rfc3339(); tx.execute( - "UPDATE workdir_removal_operations SET state='completed', retryable=?1, disposition=?2, failure_category=?3, updated_at=?4, completed_at=?4 WHERE workspace_id=?5 AND operation_id=?6 AND request_fingerprint=?7", + "UPDATE workdir_removal_operations SET state='completed', retryable=?1, disposition=?2, failure_category=?3, attempt_owner_pid=NULL, attempt_owner_start_marker=NULL, updated_at=?4, completed_at=?4 WHERE workspace_id=?5 AND operation_id=?6 AND request_fingerprint=?7", params![ retryable, disposition.as_str(), @@ -620,13 +646,16 @@ fn require_no_removal_blockers( UNION ALL SELECT 1 FROM worker_workdir_attachment_reservations WHERE workspace_id=?1 AND workdir_id=?2 + UNION ALL + SELECT 1 FROM workdir_create_operations + WHERE workspace_id=?1 AND working_directory_id=?2 AND state='pending' )"#, params![workspace_id, workdir_id], |row| row.get(0), )?; if blocked { return Err(Error::WorkdirAttachmentConflict(format!( - "Workdir {workdir_id} acquired an active or pending attachment during removal" + "Workdir {workdir_id} acquired active attachment or materialization authority during removal" ))); } Ok(()) @@ -784,6 +813,7 @@ fn operation_select_sql() -> &'static str { r#"SELECT operation_id, request_fingerprint, workspace_id, workdir_id, runtime_id, repository_id, materialization_fingerprint, source_actor, reason, state, attempt_count, retryable, disposition, failure_category, + attempt_owner_pid, attempt_owner_start_marker, created_at, updated_at, completed_at FROM workdir_removal_operations"# } @@ -795,6 +825,26 @@ fn read_operation(row: &rusqlite::Row<'_>) -> rusqlite::Result(10)?; + let attempt_owner_pid = row.get::<_, Option>(14)?; + let attempt_owner_start_marker = row.get::<_, Option>(15)?; + let attempt_owner = match (attempt_owner_pid, attempt_owner_start_marker) { + (None, None) => None, + (Some(process_id), Some(process_start_marker)) => Some(WorkdirRemovalAttemptOwner { + process_id: process_id + .try_into() + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(14, process_id))?, + process_start_marker: process_start_marker + .try_into() + .map_err(|_| rusqlite::Error::IntegralValueOutOfRange(15, process_start_marker))?, + }), + _ => { + return Err(rusqlite::Error::FromSqlConversionFailure( + 14, + rusqlite::types::Type::Integer, + "incomplete Workdir removal attempt owner".into(), + )); + } + }; Ok(WorkdirRemovalOperation { operation_id: row.get(0)?, request_fingerprint: row.get(1)?, @@ -812,9 +862,10 @@ fn read_operation(row: &rusqlite::Row<'_>) -> rusqlite::Result WorkdirRemovalAttemptOwner { + WorkdirRemovalAttemptOwner { + process_id: 100, + process_start_marker: 200, + } + } + async fn seeded_store() -> (SqliteWorkspaceStore, WorkdirRegistryRecord) { let store = SqliteWorkspaceStore::in_memory().unwrap(); store @@ -950,6 +1008,7 @@ mod tests { &reserved.workspace_id, &reserved.operation_id, &reserved.request_fingerprint, + attempt_owner(), ) .unwrap(); assert_eq!(first.attempt_count, 1); @@ -962,6 +1021,7 @@ mod tests { &failed.workspace_id, &failed.operation_id, &failed.request_fingerprint, + attempt_owner(), ) .unwrap(); assert_eq!(retry.attempt_count, 2); @@ -981,6 +1041,7 @@ mod tests { &completed.workspace_id, &completed.operation_id, &completed.request_fingerprint, + attempt_owner(), ) .unwrap(); assert_eq!(replay, completed); @@ -1009,6 +1070,7 @@ mod tests { &operation.workspace_id, &operation.operation_id, &operation.request_fingerprint, + attempt_owner(), ); if claim.is_ok() { provider_calls.fetch_add(1, Ordering::SeqCst); @@ -1039,6 +1101,63 @@ mod tests { ); } + #[tokio::test] + async fn failed_workdir_create_retry_cannot_start_after_removal_claim() { + use crate::store::WorkdirCreateOperationRecord; + + let (store, workdir) = seeded_store().await; + let create = WorkdirCreateOperationRecord { + workspace_id: "workspace-a".to_string(), + operation_id: "create-a".to_string(), + request_fingerprint: "create-fingerprint".to_string(), + repository_id: "repository-a".to_string(), + selector: Some("develop".to_string()), + requested_runtime_id: Some("runtime-a".to_string()), + resolved_runtime_id: "runtime-a".to_string(), + config_revision: 1, + config_projection_digest: "projection-a".to_string(), + source_kind: Some("local_path".to_string()), + source_uri: Some("/repository-a".to_string()), + source_revision: Some(1), + source_fingerprint: Some("source-a".to_string()), + credential_id: None, + credential_revision: None, + host_trust_id: None, + host_trust_revision: None, + repository_access_mode: None, + cache_generation: 0, + working_directory_id: "workdir-a".to_string(), + state: "pending".to_string(), + failure: None, + created_at: "1".to_string(), + updated_at: "1".to_string(), + }; + store.reserve_workdir_create_operation(&create).unwrap(); + store + .finish_workdir_create_operation( + "workspace-a", + "create-a", + "create-fingerprint", + false, + Some("provider failed"), + "2", + ) + .unwrap(); + let intent = + workdir_removal_intent(&workdir, "workspace-api", "remove clean Workdir").unwrap(); + store.reserve_workdir_removal_operation(&intent).unwrap(); + + let error = store + .begin_failed_workdir_create_retry("workspace-a", "create-a", "create-fingerprint", "3") + .unwrap_err(); + assert!(matches!(error, Error::WorkdirAttachmentConflict(_))); + let create = store + .load_workdir_create_operation("workspace-a", "create-a") + .unwrap() + .unwrap(); + assert_eq!(create.state, "failed"); + } + #[tokio::test] async fn pending_removal_fences_new_attachment_and_retry_rereads_live_reservation() { let (store, workdir) = seeded_store().await; @@ -1062,6 +1181,7 @@ mod tests { &failed.workspace_id, &failed.operation_id, &failed.request_fingerprint, + attempt_owner(), ) .unwrap(); let guards = store.workdir_removal_guards(&retry).unwrap(); diff --git a/docs/design/durable-operations.md b/docs/design/durable-operations.md index 7098b0f3..3455aab2 100644 --- a/docs/design/durable-operations.md +++ b/docs/design/durable-operations.md @@ -13,7 +13,7 @@ The record stores only facts that affect identity, authorization, replay, or the - explicit retryability, bounded failure category, and bounded disposition; - a factual checkpoint only when a non-idempotent provider effect cannot be safely re-observed or repeated. -A fingerprint excludes Server-generated result identifiers, attempt data, diagnostics, and fresh observations. Reusing one operation identity with a different fingerprint is an error. A completed exact retry replays the committed bounded result. A durable one-pending-operation constraint plus an atomic attempt claim prevents concurrent callers from entering the provider side effect for the same Workdir; the in-process resource lock is an additional serialization layer, not the sole authority. +A fingerprint excludes Server-generated result identifiers, attempt data, diagnostics, and fresh observations. Reusing one operation identity with a different fingerprint is an error. A completed exact retry replays the committed bounded result. A durable one-pending-operation constraint plus an atomic attempt claim prevents concurrent callers from entering the provider side effect for the same Workdir; the in-process resource lock is an additional serialization layer, not the sole authority. Each active attempt persists the Server process ID and process-start marker. Recovery reclaims only an owner proven missing or replaced; a live or unobservable owner is never stolen. `pending` means only that the intent remains open. Function names, validation steps, and provider-call positions are not persisted as lifecycle stages. `failed` records the latest terminal attempt outcome; retryability remains separate metadata. `completed` means the required domain result and disposition are durably committed. @@ -30,7 +30,7 @@ Workdir removal is one durable side-effect operation in the Workspace Server DB. Each attempt: 1. resolves or revalidates the persisted same-Workspace Workdir, Runtime, Repository, and materialization identity; -2. checks current attachments, attachment reservations, current assignment occupancy, retention/cleanup holds, and pending materialization authority; +2. checks current attachments, attachment reservations, current assignment occupancy, retention/cleanup holds, and pending materialization authority; a failed Workdir-create retry must atomically return to `pending` before provider work and is rejected while removal is pending; 3. retains dirty, occupied, blocked, or otherwise unknown Workdirs without detaching a Worker or forcing deletion; 4. observes the owning Runtime/provider and calls its existing Workdir cleanup only for an eligible clean Workdir; 5. treats only successful provider cleanup or exact `working_directory_not_found` as removal evidence; From faa727965b53d1a037c731b0029afccbba3722b6 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 01:59:32 +0900 Subject: [PATCH 08/11] fix: fence orphan recovery claims --- crates/workspace-server/src/server.rs | 96 +++++++++++++++++-- .../workspace-server/src/workdir_removal.rs | 21 ++-- docs/design/durable-operations.md | 2 +- 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 9c08f871..26bf67cb 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -9737,19 +9737,21 @@ fn execute_reserved_workdir_removal_with_provider( if operation.state == WorkdirRemovalOperationState::Completed { return Ok(operation); } - let operation = if recovery { - let prior_owner_is_orphaned = if operation.state == WorkdirRemovalOperationState::Pending { - workdir_removal_attempt_is_orphaned(&operation)? - } else { - false - }; + let operation = if recovery && operation.state == WorkdirRemovalOperationState::Pending { + if !workdir_removal_attempt_is_orphaned(&operation)? { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir removal operation `{}` still has a live attempt owner", + operation.operation_id + ))); + } api.config_store .reclaim_workdir_removal_attempt_for_recovery( &operation.workspace_id, &operation.operation_id, &operation.request_fingerprint, api.workdir_remove_attempt_owner, - prior_owner_is_orphaned, + operation.attempt_owner, + operation.attempt_count, )? } else { api.config_store.begin_workdir_removal_attempt( @@ -22471,6 +22473,7 @@ mod tests { >, >, cleanup_calls: std::sync::atomic::AtomicUsize, + observation_delay: std::time::Duration, } impl FakeWorkdirRemovalProvider { @@ -22482,9 +22485,15 @@ mod tests { observation: Mutex::new(Some(Ok(observation))), cleanup: Mutex::new(Some(Ok(cleanup))), cleanup_calls: std::sync::atomic::AtomicUsize::new(0), + observation_delay: std::time::Duration::ZERO, } } + fn with_observation_delay(mut self, delay: std::time::Duration) -> Self { + self.observation_delay = delay; + self + } + fn cleanup_calls(&self) -> usize { self.cleanup_calls.load(std::sync::atomic::Ordering::SeqCst) } @@ -22497,6 +22506,7 @@ mod tests { _working_directory_id: &str, ) -> std::result::Result { + std::thread::sleep(self.observation_delay); self.observation .lock() .unwrap() @@ -22700,6 +22710,78 @@ mod tests { assert_eq!(unsupported_provider.cleanup_calls(), 1); } + #[tokio::test] + async fn concurrent_orphan_recovery_runs_delayed_provider_cleanup_once() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let (reserved, clean_summary) = reserve_removal_fixture(&api, "orphan-race"); + let interrupted = api + .config_store + .begin_workdir_removal_attempt( + &reserved.workspace_id, + &reserved.operation_id, + &reserved.request_fingerprint, + WorkdirRemovalAttemptOwner { + process_id: u32::MAX, + process_start_marker: 1, + }, + ) + .unwrap(); + let provider = Arc::new( + FakeWorkdirRemovalProvider::new( + workdir_removal_result( + WorkerOperationState::Accepted, + Some(clean_summary), + Vec::new(), + ), + workdir_removal_result(WorkerOperationState::Accepted, None, Vec::new()), + ) + .with_observation_delay(std::time::Duration::from_millis(100)), + ); + let barrier = Arc::new(std::sync::Barrier::new(3)); + let mut callers = Vec::new(); + for _ in 0..2 { + let api = api.clone(); + let operation = interrupted.clone(); + let provider = provider.clone(); + let barrier = barrier.clone(); + callers.push(std::thread::spawn(move || { + barrier.wait(); + execute_reserved_workdir_removal_with_provider( + &api, + operation, + true, + provider.as_ref(), + ) + })); + } + barrier.wait(); + let results = callers + .into_iter() + .map(|caller| caller.join().unwrap()) + .collect::>(); + assert_eq!( + results + .iter() + .filter(|result| { + result.as_ref().is_ok_and(|operation| { + operation.disposition == Some(WorkdirRemovalDisposition::Removed) + }) + }) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(Error::WorkdirAttachmentConflict(_)))) + .count(), + 1 + ); + assert_eq!(provider.cleanup_calls(), 1); + } + #[test] fn only_exact_provider_not_found_is_removal_evidence() { let not_found = crate::hosts::RuntimeWorkingDirectoryResult { diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index ae8cfa04..e8c104f4 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -268,7 +268,7 @@ impl SqliteWorkspaceStore { operation_id, request_fingerprint, owner, - false, + None, ) } @@ -278,14 +278,15 @@ impl SqliteWorkspaceStore { operation_id: &str, request_fingerprint: &str, owner: WorkdirRemovalAttemptOwner, - prior_owner_is_orphaned: bool, + expected_prior_owner: Option, + expected_attempt_count: u64, ) -> Result { self.begin_workdir_removal_attempt_inner( workspace_id, operation_id, request_fingerprint, owner, - prior_owner_is_orphaned, + Some((expected_prior_owner, expected_attempt_count)), ) } @@ -295,7 +296,7 @@ impl SqliteWorkspaceStore { operation_id: &str, request_fingerprint: &str, owner: WorkdirRemovalAttemptOwner, - prior_owner_is_orphaned: bool, + recovery_expected: Option<(Option, u64)>, ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; @@ -310,9 +311,17 @@ impl SqliteWorkspaceStore { "Workdir removal operation `{operation_id}` is not retryable" ))); } - if operation.state == WorkdirRemovalOperationState::Pending + if let Some((expected_owner, expected_attempt_count)) = recovery_expected { + if operation.state != WorkdirRemovalOperationState::Pending + || operation.attempt_owner != expected_owner + || operation.attempt_count != expected_attempt_count + { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir removal operation `{operation_id}` changed after orphan proof" + ))); + } + } else if operation.state == WorkdirRemovalOperationState::Pending && operation.attempt_count > 0 - && !prior_owner_is_orphaned { return Err(Error::WorkdirAttachmentConflict(format!( "Workdir removal operation `{operation_id}` already has an active attempt" diff --git a/docs/design/durable-operations.md b/docs/design/durable-operations.md index 3455aab2..a582e63f 100644 --- a/docs/design/durable-operations.md +++ b/docs/design/durable-operations.md @@ -13,7 +13,7 @@ The record stores only facts that affect identity, authorization, replay, or the - explicit retryability, bounded failure category, and bounded disposition; - a factual checkpoint only when a non-idempotent provider effect cannot be safely re-observed or repeated. -A fingerprint excludes Server-generated result identifiers, attempt data, diagnostics, and fresh observations. Reusing one operation identity with a different fingerprint is an error. A completed exact retry replays the committed bounded result. A durable one-pending-operation constraint plus an atomic attempt claim prevents concurrent callers from entering the provider side effect for the same Workdir; the in-process resource lock is an additional serialization layer, not the sole authority. Each active attempt persists the Server process ID and process-start marker. Recovery reclaims only an owner proven missing or replaced; a live or unobservable owner is never stolen. +A fingerprint excludes Server-generated result identifiers, attempt data, diagnostics, and fresh observations. Reusing one operation identity with a different fingerprint is an error. A completed exact retry replays the committed bounded result. A durable one-pending-operation constraint plus an atomic attempt claim prevents concurrent callers from entering the provider side effect for the same Workdir; the in-process resource lock is an additional serialization layer, not the sole authority. Each active attempt persists the Server process ID and process-start marker. Recovery reclaims only an owner proven missing or replaced; a live or unobservable owner is never stolen. The reclaim transaction CAS-checks the exact proved owner snapshot and attempt count so a stale orphan proof cannot overwrite a newer live claim. `pending` means only that the intent remains open. Function names, validation steps, and provider-call positions are not persisted as lifecycle stages. `failed` records the latest terminal attempt outcome; retryability remains separate metadata. `completed` means the required domain result and disposition are durably committed. From b29b003ea3c07297a532839b5ac96d9e9a095edf Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 02:26:35 +0900 Subject: [PATCH 09/11] feat: define orchestrator cleanup ownership --- crates/manifest/src/profile.rs | 22 ++++++++++++++ crates/worker/src/prompt/catalog.rs | 18 ++++++++++++ crates/worker/tests/controller_test.rs | 40 ++++++++++++++++++++++++++ resources/prompts/role/orchestrator.md | 4 +++ 4 files changed, 84 insertions(+) diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index aed17134..2c8e639f 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1438,6 +1438,28 @@ mod tests { assert!(resolved.manifest.feature.workspace_worker_discovery.enabled); } + #[test] + fn builtin_orchestrator_keeps_cleanup_tool_providers_enabled() { + let tmp = TempDir::new().unwrap(); + let resolved = ProfileResolver::new() + .with_workspace_base(tmp.path()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"), + ProfileResolveOptions::with_worker_name("orchestrator-worker"), + ) + .unwrap(); + let feature = resolved.manifest.feature; + + assert!(feature.worker.enabled); + assert!(!feature.worker.direct_spawn); + assert!(feature.manage_workdir.enabled); + assert!(feature.merge_request.show); + assert!(feature.merge_request.readiness_check); + assert!(feature.merge_request.complete); + assert!(!feature.merge_request.open); + assert!(!feature.merge_request.review); + } + #[test] fn profile_resolution_requires_runtime_worker_name() { let tmp = TempDir::new().unwrap(); diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index fc3c9dc1..df1261f6 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -920,4 +920,22 @@ mod tests { ); } } + + #[test] + fn builtin_orchestrator_cleanup_policy_renders_with_common_includes() { + let rendered = PromptCatalog::builtins_only() + .unwrap() + .render_name("role.orchestrator", Value::UNDEFINED) + .unwrap(); + + assert!(rendered.contains("This policy governs naming only")); + assert!(rendered.contains("Coder cleanup is a separate post-completion decision")); + assert!(rendered.contains("perform one cleanup pass before ending the orchestration turn")); + assert!(rendered.contains("Never predeclare `delete_on_completion`")); + assert!(rendered.contains("call `WorkerStop`")); + assert!(rendered.contains("call `WorkerRemove`")); + assert!(rendered.contains("only then call `WorkdirDelete`")); + assert!(rendered.contains("`CurrentAssignment` means unassign and reread")); + assert!(!rendered.contains("{% include")); + } } diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 0a1b8e31..403ca3cb 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -9,6 +9,7 @@ use agen::llm_client::{ClientError, LlmClient, Request}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use async_trait::async_trait; use futures::{Stream, StreamExt}; +use manifest::{ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector}; use session_store::{CombinedStore, FsWorkerStore}; use session_store::{FsStore, LogEntry}; use workdir::{ @@ -248,6 +249,14 @@ async fn make_worker_with_pwd_manifest_and_workspace_context( workspace_context: WorkerWorkspaceContext, ) -> (Worker, std::path::PathBuf) { let manifest = WorkerManifest::from_toml(manifest_toml).unwrap(); + make_worker_with_manifest_and_workspace_context(client, manifest, workspace_context).await +} + +async fn make_worker_with_manifest_and_workspace_context( + client: MockClient, + manifest: WorkerManifest, + workspace_context: WorkerWorkspaceContext, +) -> (Worker, std::path::PathBuf) { let store_tmp = tempfile::tempdir().unwrap(); let store = CombinedStore::new( FsStore::new(store_tmp.path()).unwrap(), @@ -783,6 +792,37 @@ permission = "write" } } +#[tokio::test] +async fn builtin_orchestrator_exposes_worker_remove_and_workdir_delete() { + let workspace = tempfile::tempdir().unwrap(); + let resolved = ProfileResolver::new() + .with_workspace_base(workspace.path()) + .resolve( + &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"), + ProfileResolveOptions::with_worker_name("orchestrator-worker"), + ) + .unwrap(); + let workspace_context = + WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient)); + let client = MockClient::new(simple_text_events()); + let client_for_assert = client.clone(); + let (worker, _pwd) = make_worker_with_manifest_and_workspace_context( + client, + resolved.manifest, + workspace_context, + ) + .await; + let handle = spawn_controller(worker).await; + + handle.send(Method::run_text("Hello")).await.unwrap(); + wait_for_status(&handle, WorkerStatus::Idle).await; + let request = wait_for_captured_request(&client_for_assert).await; + let installed = request_tool_names(&request); + + assert!(installed.iter().any(|name| name == "WorkerRemove")); + assert!(installed.iter().any(|name| name == "WorkdirDelete")); +} + #[tokio::test] async fn worker_and_sub_worker_features_install_one_canonical_control_surface() { let manifest = r#" diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index 3c51b12a..d5053a9c 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -23,3 +23,7 @@ Do not create or delegate an implementation worktree/branch until the Ticket rec Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots. Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization. + +Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After `CompleteMergeRequest` and Ticket completion, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no current Ticket assignment, then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied. + +For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; unassign it through the available orchestration authority; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `CurrentAssignment` means unassign and reread before retrying `WorkerRemove`. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, unassign, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, assignment, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions. From bad37ddc7da83437790fd581fa285b9c560d16db Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 15:24:12 +0900 Subject: [PATCH 10/11] feat: centralize workspace profile DTOs --- crates/workspace-api/src/lib.rs | 186 ++++++++++++++++++ .../workspace-server/src/profile_settings.rs | 85 +------- crates/workspace-server/src/server.rs | 54 ++--- .../src/lib/generated/workspace-api.ts | 58 ++++++ 4 files changed, 282 insertions(+), 101 deletions(-) diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 8570dd26..79153639 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -222,6 +222,100 @@ pub struct WorkspaceResponse { pub extension_points: WorkspaceExtensionPoints, } +/// Workspace identity metadata exposed by the current settings resource. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceMetadataSettingsResponse { + pub workspace_id: String, + pub display_name: String, + pub created_at: String, + pub revision: String, + pub source: String, + pub diagnostics: Vec, +} + +/// Compare-and-swap update for Workspace identity display metadata. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceMetadataRequest { + pub display_name: String, + pub revision: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceMetadataMutationResponse { + pub workspace: WorkspaceMetadataSettingsResponse, + pub diagnostics: Vec, +} + +/// Read-only Profile catalog projected from one active Workspace config revision. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] +#[serde(deny_unknown_fields)] +pub struct ProfileSettingsResponse { + pub workspace_id: String, + pub registry_revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] + pub config_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tree_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projection_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_profile: Option, + pub profiles: Vec, + pub sources: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceProfileSummary { + pub profile_id: String, + pub selector: String, + pub label: String, + pub source_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub editable: bool, + pub is_default: bool, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkspaceProfileSourceSummary { + pub profile_source_id: String, + pub display_path: String, + pub kind: String, + pub content_type: String, + pub content_digest: String, + pub provenance: WorkspaceProfileSourceProvenance, + pub editable: bool, + pub revision: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub size_bytes: u64, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceProfileSourceProvenance { + ProjectProfileSourceTree, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -1124,6 +1218,13 @@ pub fn catalog_typescript() -> String { WorkspaceExtensionPointState::decl(&config), WorkspaceExtensionPoints::decl(&config), WorkspaceResponse::decl(&config), + WorkspaceMetadataSettingsResponse::decl(&config), + UpdateWorkspaceMetadataRequest::decl(&config), + WorkspaceMetadataMutationResponse::decl(&config), + ProfileSettingsResponse::decl(&config), + WorkspaceProfileSummary::decl(&config), + WorkspaceProfileSourceSummary::decl(&config), + WorkspaceProfileSourceProvenance::decl(&config), RepositorySourceKind::decl(&config), RepositorySource::decl(&config), RepositoryObservedStatus::decl(&config), @@ -1322,6 +1423,14 @@ mod tests { assert!(output.contains("export type RepositoryListResponse =")); assert!(output.contains("items: Array")); assert!(output.contains("observed_at?: string | null")); + assert!(output.contains("export type WorkspaceMetadataSettingsResponse =")); + assert!(output.contains("export type WorkspaceMetadataMutationResponse =")); + assert!(output.contains("export type ProfileSettingsResponse =")); + assert!(output.contains("config_revision?: number | null")); + assert!(output.contains("provenance: WorkspaceProfileSourceProvenance")); + assert!(output.contains( + "export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\"" + )); assert!(!output.contains("repository_id: string, display_name")); } @@ -1354,6 +1463,83 @@ mod tests { assert_eq!(decoded, value); } + #[test] + fn workspace_metadata_and_profile_projection_fixtures_round_trip() { + let diagnostic = Diagnostic { + code: "profile_projection_warning".to_string(), + severity: DiagnosticSeverity::Warning, + message: "projected from the active config revision".to_string(), + }; + let metadata = WorkspaceMetadataSettingsResponse { + workspace_id: "workspace-test".to_string(), + display_name: "Test".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + revision: "sha256:metadata".to_string(), + source: "workspace-config".to_string(), + diagnostics: vec![diagnostic.clone()], + }; + round_trip(metadata.clone()); + round_trip(UpdateWorkspaceMetadataRequest { + display_name: "Renamed".to_string(), + revision: metadata.revision.clone(), + }); + round_trip(WorkspaceMetadataMutationResponse { + workspace: metadata, + diagnostics: vec![], + }); + + round_trip(ProfileSettingsResponse { + workspace_id: "workspace-test".to_string(), + registry_revision: "config-source:7:sha256:tree:sha256:projection".to_string(), + config_revision: Some(7), + tree_digest: Some("sha256:tree".to_string()), + projection_digest: Some("sha256:projection".to_string()), + default_profile: Some("workspace:coder".to_string()), + profiles: vec![WorkspaceProfileSummary { + profile_id: "workspace:coder".to_string(), + selector: "workspace:coder".to_string(), + label: "Coder".to_string(), + source_kind: "project".to_string(), + profile_source_id: Some("profile-source-1".to_string()), + description: None, + editable: true, + is_default: true, + diagnostics: vec![diagnostic.clone()], + }], + sources: vec![WorkspaceProfileSourceSummary { + profile_source_id: "profile-source-1".to_string(), + display_path: "profiles/coder.dcdl".to_string(), + kind: "profile".to_string(), + content_type: "text/x-decodal".to_string(), + content_digest: "sha256:source".to_string(), + provenance: WorkspaceProfileSourceProvenance::ProjectProfileSourceTree, + editable: false, + revision: "config-source:7".to_string(), + size_bytes: 128, + diagnostics: vec![], + }], + diagnostics: vec![diagnostic], + }); + + let absent_optional_fields = serde_json::json!({ + "workspace_id": "workspace-test", + "registry_revision": "builtin", + "profiles": [], + "sources": [], + "diagnostics": [] + }); + let decoded: ProfileSettingsResponse = + serde_json::from_value(absent_optional_fields.clone()).unwrap(); + assert_eq!(decoded.config_revision, None); + assert_eq!(decoded.tree_digest, None); + assert_eq!(decoded.projection_digest, None); + assert_eq!(decoded.default_profile, None); + assert_eq!( + serde_json::to_value(decoded).unwrap(), + absent_optional_fields + ); + } + fn companion_worker() -> WorkspaceWorkerDiscoveryItem { WorkspaceWorkerDiscoveryItem { subject: WorkspaceWorkerSubject::RuntimeWorker { diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index f76aff77..e1888f8f 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -12,11 +12,15 @@ use worker_runtime::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, }; use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; +use workspace_api::{ + Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest, + WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance, + WorkspaceProfileSourceSummary, WorkspaceProfileSummary, +}; use crate::config_source::{ WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, }; -use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic}; use crate::{Error, Result}; const PROFILE_SCHEMA_SOURCE: &str = r#"{ @@ -427,81 +431,6 @@ fn build_virtual_profile_archive( .map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string())) } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceMetadataSettingsResponse { - pub workspace_id: String, - pub display_name: String, - pub created_at: String, - pub revision: String, - pub source: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct UpdateWorkspaceMetadataRequest { - pub display_name: String, - pub revision: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceMetadataMutationResponse { - pub workspace: WorkspaceMetadataSettingsResponse, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProfileSettingsResponse { - pub workspace_id: String, - pub registry_revision: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub config_revision: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tree_digest: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub projection_digest: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_profile: Option, - pub profiles: Vec, - pub sources: Vec, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSummary { - pub profile_id: String, - pub selector: String, - pub label: String, - pub source_kind: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile_source_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - pub editable: bool, - pub is_default: bool, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkspaceProfileSourceSummary { - pub profile_source_id: String, - pub display_path: String, - pub kind: String, - pub content_type: String, - pub content_digest: String, - pub provenance: WorkspaceProfileSourceProvenance, - pub editable: bool, - pub revision: String, - pub size_bytes: u64, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkspaceProfileSourceProvenance { - ProjectProfileSourceTree, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct WorkspaceIdentityFile { @@ -804,8 +733,8 @@ fn diagnostic( code: impl Into, severity: DiagnosticSeverity, message: impl Into, -) -> RuntimeDiagnostic { - RuntimeDiagnostic { +) -> Diagnostic { + Diagnostic { code: code.into(), severity, message: message.into(), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 26bf67cb..1343205d 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -61,20 +61,22 @@ use workspace_api::{ CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse, DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, - ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, - RepositoryDetailResponse, RepositoryListResponse, RepositoryLogResponse, - RepositorySshCredential, RepositorySshHostTrust, RotateRepositorySshCredentialRequest, - RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, - TICKET_RELATIONS_QUERY_PATH, + ObjectiveStateRequest, ProfileSettingsResponse, PutRepositorySshHostTrustRequest, + RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse, + RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, + RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary, + TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, + UpdateWorkspaceMetadataRequest, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, WorkingDirectoryListResponse as BrowserWorkingDirectoryListResponse, WorkingDirectoryRemovalDisposition, WorkingDirectoryRemovalRequest, WorkingDirectoryRemovalResponse, WorkspaceCatalogListResponse, WorkspaceCreateResponse, - WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspacePermissionSummary, - WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary, - WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, + WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, + WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspaceRepositoryRecord, + WorkspaceResponse, WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, + WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; use crate::auth::{ @@ -114,7 +116,6 @@ use crate::observation::{ BackendObservationProxy, ObservationProxyError, RuntimeObservationClient, RuntimeObservationSource, RuntimeObservationSourceConfig, }; -use crate::profile_settings::UpdateWorkspaceMetadataRequest; use crate::records::{ MergeRequestListItem, MergeRequestListResponse, ObjectiveDetail, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveShowRequest, ProjectRecordList, TicketDetail, @@ -3360,7 +3361,7 @@ async fn scoped_get_workspace( async fn scoped_get_workspace_settings( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; Ok(Json(crate::profile_settings::workspace_metadata_settings( &api.config.workspace_root, @@ -3374,20 +3375,18 @@ async fn scoped_update_workspace_settings( State(api): State, AxumPath(path): AxumPath, Json(request): Json, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let workspace = crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?; - Ok(Json( - crate::profile_settings::WorkspaceMetadataMutationResponse { - workspace, - diagnostics: vec![RuntimeDiagnostic { - code: "workspace_metadata_updated".to_string(), - severity: DiagnosticSeverity::Info, - message: "Workspace display metadata was updated.".to_string(), - }], - }, - )) + Ok(Json(WorkspaceMetadataMutationResponse { + workspace, + diagnostics: vec![workspace_api::Diagnostic { + code: "workspace_metadata_updated".to_string(), + severity: workspace_api::DiagnosticSeverity::Info, + message: "Workspace display metadata was updated.".to_string(), + }], + })) } #[derive(Debug, Deserialize)] @@ -3763,7 +3762,7 @@ async fn scoped_commit_workspace_config_tree( async fn scoped_get_profile_settings( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let state = api .config_store @@ -14353,7 +14352,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult; +}; + +export type UpdateWorkspaceMetadataRequest = { + display_name: string; + revision: string; +}; + +export type WorkspaceMetadataMutationResponse = { + workspace: WorkspaceMetadataSettingsResponse; + diagnostics: Array; +}; + +export type ProfileSettingsResponse = { + workspace_id: string; + registry_revision: string; + config_revision?: number | null; + tree_digest?: string | null; + projection_digest?: string | null; + default_profile?: string | null; + profiles: Array; + sources: Array; + diagnostics: Array; +}; + +export type WorkspaceProfileSummary = { + profile_id: string; + selector: string; + label: string; + source_kind: string; + profile_source_id?: string | null; + description?: string | null; + editable: boolean; + is_default: boolean; + diagnostics: Array; +}; + +export type WorkspaceProfileSourceSummary = { + profile_source_id: string; + display_path: string; + kind: string; + content_type: string; + content_digest: string; + provenance: WorkspaceProfileSourceProvenance; + editable: boolean; + revision: string; + size_bytes: number; + diagnostics: Array; +}; + +export type WorkspaceProfileSourceProvenance = "project_profile_source_tree"; + export type RepositorySourceKind = | "local_path" | "file" From 538da1f2b210d0504ee9d26e705cd2863ac6ceea Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 2 Sep 2026 15:24:33 +0900 Subject: [PATCH 11/11] fix: validate workspace profile responses --- web/workspace/deno.json | 2 +- .../src/lib/workspace/settings/model.ts | 8 +- .../src/lib/workspace/settings/profile-api.ts | 358 +++++++++++++++--- .../lib/workspace/settings/profile-types.ts | 52 --- .../settings/profiles/+page.svelte | 2 +- .../settings/workspace/+page.svelte | 14 +- web/workspace/tests/profile-api.test.ts | 213 +++++++++-- 7 files changed, 506 insertions(+), 143 deletions(-) delete mode 100644 web/workspace/src/lib/workspace/settings/profile-types.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index c416e66d..f6bb8c2a 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/workspace/settings/model.ts b/web/workspace/src/lib/workspace/settings/model.ts index 97c1f68c..59f69f3c 100644 --- a/web/workspace/src/lib/workspace/settings/model.ts +++ b/web/workspace/src/lib/workspace/settings/model.ts @@ -1,8 +1,6 @@ -export type Diagnostic = { - severity: "info" | "warning" | "error"; - code: string; - message: string; -}; +import type { Diagnostic as WorkspaceApiDiagnostic } from "$lib/generated/workspace-api"; + +export type Diagnostic = WorkspaceApiDiagnostic; export type SettingsSectionId = | "runtimes" diff --git a/web/workspace/src/lib/workspace/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index faea8804..e0ff66e7 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -1,69 +1,335 @@ import type { + Diagnostic, + DiagnosticSeverity, ProfileSettingsResponse, + UpdateWorkspaceMetadataRequest, WorkspaceMetadataMutationResponse, WorkspaceMetadataSettingsResponse, -} from "./profile-types"; + WorkspaceProfileSourceProvenance, + WorkspaceProfileSourceSummary, + WorkspaceProfileSummary, +} from "$lib/generated/workspace-api"; -export type WorkspaceProfileApi = { - getMetadata(workspaceId: string): Promise; - updateMetadata( - workspaceId: string, - displayName: string, - expectedRevision: string, - ): Promise; - getProfiles(workspaceId: string): Promise; -}; - -async function requestJson( - input: RequestInfo | URL, - init?: RequestInit, -): Promise { - const response = await fetch(input, init); - if (!response.ok) { - throw new Error(`request failed: ${response.status}`); +export class ProfileApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "ProfileApiError"; } - return (await response.json()) as T; } -export async function fetchWorkspaceMetadataSettings( +type JsonRecord = Record; + +function record(value: unknown, context: string): JsonRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value as JsonRecord; +} + +function exactKeys( + value: JsonRecord, + required: readonly string[], + optional: readonly string[], + context: string, +): void { + const allowed = new Set([...required, ...optional]); + if ( + required.some((key) => !(key in value)) || + Object.keys(value).some((key) => !allowed.has(key)) + ) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } +} + +function stringValue(value: unknown, context: string): string { + if (typeof value !== "string") { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value; +} + +function booleanValue(value: unknown, context: string): boolean { + if (typeof value !== "boolean") { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value; +} + +function optionalString( + value: unknown, + context: string, +): string | null | undefined { + if (value === undefined || value === null) return value; + return stringValue(value, context); +} + +function optionalRevision( + value: unknown, + context: string, +): number | null | undefined { + if (value === undefined || value === null) return value; + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value as number; +} + +function arrayValue( + value: unknown, + parser: (item: unknown) => T, + context: string, +): T[] { + if (!Array.isArray(value)) { + throw new ProfileApiError(`${context} returned an invalid response.`, 502); + } + return value.map(parser); +} + +function parseDiagnostic(value: unknown): Diagnostic { + const item = record(value, "Workspace settings"); + exactKeys(item, ["code", "severity", "message"], [], "Workspace settings"); + const severity = stringValue(item.severity, "Workspace settings"); + if (!(["info", "warning", "error"] as string[]).includes(severity)) { + throw new ProfileApiError( + "Workspace settings returned an invalid response.", + 502, + ); + } + return { + code: stringValue(item.code, "Workspace settings"), + severity: severity as DiagnosticSeverity, + message: stringValue(item.message, "Workspace settings"), + }; +} + +export function parseWorkspaceMetadataSettingsResponse( + value: unknown, +): WorkspaceMetadataSettingsResponse { + const item = record(value, "Workspace metadata"); + exactKeys( + item, + [ + "workspace_id", + "display_name", + "created_at", + "revision", + "source", + "diagnostics", + ], + [], + "Workspace metadata", + ); + return { + workspace_id: stringValue(item.workspace_id, "Workspace metadata"), + display_name: stringValue(item.display_name, "Workspace metadata"), + created_at: stringValue(item.created_at, "Workspace metadata"), + revision: stringValue(item.revision, "Workspace metadata"), + source: stringValue(item.source, "Workspace metadata"), + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Workspace metadata", + ), + }; +} + +export function parseWorkspaceMetadataMutationResponse( + value: unknown, +): WorkspaceMetadataMutationResponse { + const item = record(value, "Workspace metadata update"); + exactKeys( + item, + ["workspace", "diagnostics"], + [], + "Workspace metadata update", + ); + return { + workspace: parseWorkspaceMetadataSettingsResponse(item.workspace), + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Workspace metadata update", + ), + }; +} + +function parseWorkspaceProfileSummary(value: unknown): WorkspaceProfileSummary { + const item = record(value, "Profile catalog"); + exactKeys( + item, + [ + "profile_id", + "selector", + "label", + "source_kind", + "editable", + "is_default", + "diagnostics", + ], + ["profile_source_id", "description"], + "Profile catalog", + ); + return { + profile_id: stringValue(item.profile_id, "Profile catalog"), + selector: stringValue(item.selector, "Profile catalog"), + label: stringValue(item.label, "Profile catalog"), + source_kind: stringValue(item.source_kind, "Profile catalog"), + profile_source_id: optionalString( + item.profile_source_id, + "Profile catalog", + ), + description: optionalString(item.description, "Profile catalog"), + editable: booleanValue(item.editable, "Profile catalog"), + is_default: booleanValue(item.is_default, "Profile catalog"), + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Profile catalog", + ), + }; +} + +function parseWorkspaceProfileSourceSummary( + value: unknown, +): WorkspaceProfileSourceSummary { + const item = record(value, "Profile source catalog"); + exactKeys( + item, + [ + "profile_source_id", + "display_path", + "kind", + "content_type", + "content_digest", + "provenance", + "editable", + "revision", + "size_bytes", + "diagnostics", + ], + [], + "Profile source catalog", + ); + const provenance = stringValue(item.provenance, "Profile source catalog"); + if (provenance !== "project_profile_source_tree") { + throw new ProfileApiError( + "Profile source catalog returned an invalid response.", + 502, + ); + } + const sizeBytes = optionalRevision(item.size_bytes, "Profile source catalog"); + if (sizeBytes === undefined || sizeBytes === null) { + throw new ProfileApiError( + "Profile source catalog returned an invalid response.", + 502, + ); + } + return { + profile_source_id: stringValue( + item.profile_source_id, + "Profile source catalog", + ), + display_path: stringValue(item.display_path, "Profile source catalog"), + kind: stringValue(item.kind, "Profile source catalog"), + content_type: stringValue(item.content_type, "Profile source catalog"), + content_digest: stringValue(item.content_digest, "Profile source catalog"), + provenance: provenance as WorkspaceProfileSourceProvenance, + editable: booleanValue(item.editable, "Profile source catalog"), + revision: stringValue(item.revision, "Profile source catalog"), + size_bytes: sizeBytes, + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Profile source catalog", + ), + }; +} + +export function parseProfileSettingsResponse( + value: unknown, +): ProfileSettingsResponse { + const item = record(value, "Profile settings"); + exactKeys( + item, + ["workspace_id", "registry_revision", "profiles", "sources", "diagnostics"], + ["config_revision", "tree_digest", "projection_digest", "default_profile"], + "Profile settings", + ); + return { + workspace_id: stringValue(item.workspace_id, "Profile settings"), + registry_revision: stringValue(item.registry_revision, "Profile settings"), + config_revision: optionalRevision(item.config_revision, "Profile settings"), + tree_digest: optionalString(item.tree_digest, "Profile settings"), + projection_digest: optionalString( + item.projection_digest, + "Profile settings", + ), + default_profile: optionalString(item.default_profile, "Profile settings"), + profiles: arrayValue( + item.profiles, + parseWorkspaceProfileSummary, + "Profile settings", + ), + sources: arrayValue( + item.sources, + parseWorkspaceProfileSourceSummary, + "Profile settings", + ), + diagnostics: arrayValue( + item.diagnostics, + parseDiagnostic, + "Profile settings", + ), + }; +} + +async function parseResponse( + response: Response, + parser: (value: unknown) => T, +): Promise { + if (!response.ok) { + throw new ProfileApiError( + (await response.text()) || response.statusText, + response.status, + ); + } + return parser(await response.json() as unknown); +} + +export async function fetchWorkspaceMetadata( workspaceId: string, ): Promise { - return await requestJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, + return await parseResponse( + await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`), + parseWorkspaceMetadataSettingsResponse, ); } -export async function updateWorkspaceMetadataSettings( +export async function updateWorkspaceMetadata( workspaceId: string, - request: { display_name: string; revision: string }, + request: UpdateWorkspaceMetadataRequest, ): Promise { - return await requestJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify(request), - }, + return await parseResponse( + await fetch( + `/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }, + ), + parseWorkspaceMetadataMutationResponse, ); } export async function fetchProfileSettings( workspaceId: string, ): Promise { - return await requestJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, + return await parseResponse( + await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`), + parseProfileSettingsResponse, ); } - -export function createWorkspaceProfileApi(): WorkspaceProfileApi { - return { - getMetadata: fetchWorkspaceMetadataSettings, - async updateMetadata(workspaceId, displayName, expectedRevision) { - return await updateWorkspaceMetadataSettings(workspaceId, { - display_name: displayName, - revision: expectedRevision, - }); - }, - getProfiles: fetchProfileSettings, - }; -} diff --git a/web/workspace/src/lib/workspace/settings/profile-types.ts b/web/workspace/src/lib/workspace/settings/profile-types.ts deleted file mode 100644 index 85fdf323..00000000 --- a/web/workspace/src/lib/workspace/settings/profile-types.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Diagnostic } from "./model"; - -export type WorkspaceMetadataSettingsResponse = { - workspace_id: string; - display_name: string; - created_at: string; - revision: string; - source: string; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceMetadataMutationResponse = { - workspace: WorkspaceMetadataSettingsResponse; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceProfileSummary = { - profile_id: string; - selector: string; - label: string; - source_kind: "builtin" | "project" | string; - profile_source_id?: string | null; - description?: string | null; - editable: boolean; - is_default: boolean; - diagnostics: Diagnostic[]; -}; - -export type WorkspaceProfileSourceSummary = { - profile_source_id: string; - display_path: string; - kind: "virtual_config" | string; - content_type: string; - content_digest: string; - provenance: "project_profile_source_tree" | string; - editable: boolean; - revision: string; - size_bytes: number; - diagnostics: Diagnostic[]; -}; - -export type ProfileSettingsResponse = { - workspace_id: string; - registry_revision: string; - config_revision?: number | null; - tree_digest?: string | null; - projection_digest?: string | null; - default_profile?: string | null; - profiles: WorkspaceProfileSummary[]; - sources: WorkspaceProfileSourceSummary[]; - diagnostics: Diagnostic[]; -}; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte index 87ae865a..71a1bf0b 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte @@ -1,8 +1,8 @@