diff --git a/packages/argent-cli/test/flag-parser.test.ts b/packages/argent-cli/test/flag-parser.test.ts index aff0c06ff..999cf1e83 100644 --- a/packages/argent-cli/test/flag-parser.test.ts +++ b/packages/argent-cli/test/flag-parser.test.ts @@ -114,15 +114,25 @@ describe("flag-parser array + -json interleave never throws a raw error", () => }); // A tool (like flow-add-step) whose schema declares its own `args` field — a -// JSON string holding the recorded step's tool arguments. +// JSON string holding the recorded step's tool arguments. Mirrors the schema the +// registry advertises for the real tool (zodObjectToJsonSchema over +// packages/tool-server/src/tools/flows/flow-add-step.ts): recordings are keyed by +// `name` + `project_root`, so both are required alongside `command`. +// +// This fixture is hand-copied: `@argent/cli` does not depend on the tool-server, +// so it cannot derive the schema. The guard that catches drift lives where the +// schema does — `flow-tools.test.ts`'s "the flow-add-step schema the CLI tests +// hand-copy". If that fails, this fixture is what it is telling you to update. const flowAddStepSchema: JsonSchema = { type: "object", properties: { + name: { type: "string" }, + project_root: { type: "string" }, command: { type: "string" }, args: { type: "string" }, delayMs: { type: "integer" }, }, - required: ["command"], + required: ["name", "project_root", "command"], }; // A tool (like gesture-tap) with NO `args` field — here `--args` must stay the @@ -138,6 +148,43 @@ const gestureTapSchema: JsonSchema = { }; describe("parseFlags — schema-aware --args", () => { + it("routes the recording identity through the plain scalar path", () => { + const result = parseFlags( + [ + "--name", + "checkout-e2e", + "--project_root", + "/Users/dev/My Projects/demo-app", + "--command", + "gesture-tap", + "--args", + '{"udid":"X"}', + ], + flowAddStepSchema + ); + expect(result.args.name).toBe("checkout-e2e"); + // `project_root` is the only schema field carrying an underscore, so it pins + // that flag names reach the payload verbatim — a parser that normalised them + // to camel/kebab case would file the value under the wrong key and the server + // would reject the step for a missing `project_root`. The value also holds a + // space: argv arrives already split, so it must survive whole. + expect(result.args.project_root).toBe("/Users/dev/My Projects/demo-app"); + expect(result.args.command).toBe("gesture-tap"); + expect(result.args.args).toBe('{"udid":"X"}'); + expect(result.rawArgs).toBeNull(); + }); + + it("routes the recording identity through the inline --field= form too", () => { + const result = parseFlags( + ["--name=checkout-e2e", "--project_root=/Users/dev/demo-app", "--command=screenshot"], + flowAddStepSchema + ); + expect(result.args.name).toBe("checkout-e2e"); + expect(result.args.project_root).toBe("/Users/dev/demo-app"); + expect(result.args.command).toBe("screenshot"); + expect(result.rawArgs).toBeNull(); + }); + it("treats --args as the tool's own string field (space-separated form)", () => { const result = parseFlags( ["--command", "gesture-tap", "--args", '{"udid":"X","x":0.5}'], diff --git a/packages/argent-cli/test/run-flow-add-step-payload.test.ts b/packages/argent-cli/test/run-flow-add-step-payload.test.ts index 8426b0013..485cccf22 100644 --- a/packages/argent-cli/test/run-flow-add-step-payload.test.ts +++ b/packages/argent-cli/test/run-flow-add-step-payload.test.ts @@ -5,11 +5,13 @@ import { run, type RunCommandOptions } from "../src/run.js"; // End-to-end regression guard for issue #452 at the `run()` layer. // // The documented per-flag form -// argent run flow-add-step --command gesture-tap --args '{"udid":...}' -// must reach the tool-server with BOTH `command` AND the tool's own `args` -// field in the payload. The bug shadowed the `args` field with the -// whole-payload escape hatch, so `args` was consumed as the entire payload and -// the field arrived `undefined` (with udid/x/y hoisted to the top level). +// argent run flow-add-step --name t --project_root /p --command gesture-tap \ +// --args '{"udid":...}' +// must reach the tool-server with the recording identity (`name` + +// `project_root`), the `command`, AND the tool's own `args` field in the +// payload. The bug shadowed the `args` field with the whole-payload escape +// hatch, so `args` was consumed as the entire payload and the field arrived +// `undefined` (with udid/x/y hoisted to the top level). // // `parseFlags` is unit-tested directly, and `--help` suppression is covered in // run-help.test.ts. Neither drives the whole `run()` path through to the wire. @@ -32,15 +34,38 @@ function startServer(cap: Captured): Promise<{ url: string; close: () => Promise tools: [ { name: "flow-add-step", - description: "Add a step to the active flow recording", + // Leading sentence of the real tool description, verbatim. + description: + "Execute a tool call and record it as a step in the flow named by `name` + `project_root` (the recording must already be open — see flow-start-recording).", + // Mirrors what the registry advertises for the real tool — + // zodObjectToJsonSchema over the zod schema in + // packages/tool-server/src/tools/flows/flow-add-step.ts. `name` + // and `project_root` identify which open recording the step + // belongs to and are required alongside `command`. + // + // Only `properties` is load-bearing here: `parseFlags` reads it + // to decide whether `args` belongs to the tool, and reads + // `required` nowhere (its one consumer is `formatSchemaUsage`, + // the help renderer, which this file never invokes — that is + // covered by run-help.test.ts). The array is kept faithful so the + // fixture stays readable as the real schema, not because dropping + // an entry would fail here. + // + // Hand-copied because `@argent/cli` does not depend on the + // tool-server. The guard that catches drift lives where the schema + // does — flow-tools.test.ts's "the flow-add-step schema the CLI + // tests hand-copy"; if that fails, this is one of the fixtures it + // is telling you to update. inputSchema: { type: "object", properties: { + name: { type: "string" }, + project_root: { type: "string" }, command: { type: "string" }, args: { type: "string" }, - delayMs: { type: "integer" }, + delayMs: { type: "integer", minimum: 0, maximum: 9007199254740991 }, }, - required: ["command"], + required: ["name", "project_root", "command"], }, }, ], @@ -88,6 +113,11 @@ describe("CLI run — flow-add-step --args reaches the payload (issue #452)", () const opts: RunCommandOptions = { paths: {} as never }; // unused: ARGENT_TOOLS_URL is set + const FLOW = "checkout-e2e"; + // A path with a space: the shell hands argv already split, so the value must + // arrive verbatim rather than being re-split or truncated by the parser. + const ROOT = "/Users/dev/My Projects/demo-app"; + beforeEach(async () => { cap = { path: null, body: null }; server = await startServer(cap); @@ -109,10 +139,23 @@ describe("CLI run — flow-add-step --args reaches the payload (issue #452)", () await server.close(); }); - it("per-flag form: --command X --args '' sends BOTH fields verbatim to the server", async () => { + it("per-flag form: every required field plus --args '' reaches the server verbatim", async () => { const stepArgs = '{"udid":"SIM-1","x":0.5,"y":0.35}'; - await run(["flow-add-step", "--command", "gesture-tap", "--args", stepArgs], opts); + await run( + [ + "flow-add-step", + "--name", + FLOW, + "--project_root", + ROOT, + "--command", + "gesture-tap", + "--args", + stepArgs, + ], + opts + ); expect(cap.path).toMatch(/^\/tools\/flow-add-step/); expect(cap.body).not.toBeNull(); @@ -120,15 +163,68 @@ describe("CLI run — flow-add-step --args reaches the payload (issue #452)", () // The exact regression from #452: `args` survives as the tool's own string // field (the raw JSON passed through untouched), and its keys are NOT // hoisted to the top level as they were when `--args` was swallowed whole. - expect(payload).toEqual({ command: "gesture-tap", args: stepArgs }); + // The recording identity rides alongside it — without both `name` and + // `project_root` the server cannot find the open recording, so a payload + // missing either is a failed step, not a mislabelled one. + expect(payload).toEqual({ + name: FLOW, + project_root: ROOT, + command: "gesture-tap", + args: stepArgs, + }); }); - it("inline --args= form also sends both fields", async () => { + it("inline --field= form sends the same payload", async () => { const stepArgs = '{"udid":"SIM-1","x":0.5,"y":0.35}'; - await run(["flow-add-step", "--command", "gesture-tap", `--args=${stepArgs}`], opts); + await run( + [ + "flow-add-step", + `--name=${FLOW}`, + `--project_root=${ROOT}`, + "--command", + "gesture-tap", + `--args=${stepArgs}`, + ], + opts + ); + + const payload = JSON.parse(cap.body!) as Record; + expect(payload).toEqual({ + name: FLOW, + project_root: ROOT, + command: "gesture-tap", + args: stepArgs, + }); + }); + + it("coerces --delayMs by its declared integer type and omits absent optionals", async () => { + // `delayMs` is the only non-string field in the schema, so it is the one + // place the payload can arrive with the wrong JSON type: a string "250" + // fails the server's zod validation. `args` is optional — omitting the flag + // must leave the key out rather than sending null/"". + await run( + [ + "flow-add-step", + "--name", + FLOW, + "--project_root", + ROOT, + "--command", + "screenshot", + "--delayMs", + "250", + ], + opts + ); const payload = JSON.parse(cap.body!) as Record; - expect(payload).toEqual({ command: "gesture-tap", args: stepArgs }); + expect(payload).toEqual({ + name: FLOW, + project_root: ROOT, + command: "screenshot", + delayMs: 250, + }); + expect(payload).not.toHaveProperty("args"); }); }); diff --git a/packages/argent-cli/test/run-help.test.ts b/packages/argent-cli/test/run-help.test.ts index 3ec7addd8..55773fe35 100644 --- a/packages/argent-cli/test/run-help.test.ts +++ b/packages/argent-cli/test/run-help.test.ts @@ -24,18 +24,37 @@ vi.mock("@argent/tools-client", () => ({ vi.mock("@argent/telemetry", () => telemetryMock); -// A tool (like flow-add-step) that owns its `args` field. +// A tool (like flow-add-step) that owns its `args` field. Schema and +// description mirror what the registry advertises for the real tool - +// zodObjectToJsonSchema over the zod schema in +// packages/tool-server/src/tools/flows/flow-add-step.ts. Recordings are keyed +// by `name` + `project_root`, so both are required alongside `command` and only +// `args` / `delayMs` are optional. The assertions below pin `required` in both +// directions — each entry against its `(required)` marker, each non-entry +// against a negative lookahead — so dropping or adding one here fails loudly. +// The drift that does pass silently is the opposite one: if the real +// flow-add-step schema ever relaxes, nothing here notices this fixture went +// stale. +// +// This fixture is hand-copied: `@argent/cli` does not depend on the tool-server, +// so it cannot derive the schema. The guard that catches drift lives where the +// schema does — `flow-tools.test.ts`'s "the flow-add-step schema the CLI tests +// hand-copy". If that fails, this fixture is what it is telling you to update. const flowAddStepMeta = { name: "flow-add-step", - description: "Add a step to the active flow recording", + // Leading sentence of the real tool description, verbatim. + description: + "Execute a tool call and record it as a step in the flow named by `name` + `project_root` (the recording must already be open — see flow-start-recording).", inputSchema: { type: "object", properties: { + name: { type: "string" }, + project_root: { type: "string" }, command: { type: "string" }, args: { type: "string" }, - delayMs: { type: "integer" }, + delayMs: { type: "integer", minimum: 0, maximum: 9007199254740991 }, }, - required: ["command"], + required: ["name", "project_root", "command"], }, }; @@ -100,4 +119,30 @@ describe("argent run --help — whole-payload --args advertisement", () => { expect(help).toContain("--args "); expect(toolsClientMock.callTool).not.toHaveBeenCalled(); }); + + it("renders each required flag with the (required) marker and leaves the optionals unmarked", async () => { + toolsClientMock.fetchTool.mockResolvedValue(flowAddStepMeta); + + await run(["flow-add-step", "--help"], { paths: {} as never }); + + const help = capturedHelp(); + // The tool's own prose is printed ABOVE the flag block. Asserting mere + // containment says almost nothing here — `help` is rendered from this same + // fixture, so it reduces to `x.toContain(x)` and holds for any renderer + // that emits the description anywhere at all, including below the flags. + // Pin the placement, which is the part the renderer decides. + const descriptionAt = help.indexOf(flowAddStepMeta.description); + expect(descriptionAt).toBeGreaterThanOrEqual(0); + expect(descriptionAt).toBeLessThan(help.indexOf("--name ")); + // The recording identity is required alongside `command`: omitting either + // flag fails the server's zod validation, so the help has to say so up front + // instead of presenting them as optional extras. + expect(help).toMatch(/--name \s+string \(required\)/); + expect(help).toMatch(/--project_root \s+string \(required\)/); + expect(help).toMatch(/--command \s+string \(required\)/); + // ...while the two genuinely optional fields must NOT carry the marker. + expect(help).toMatch(/--args \s+string(?! \(required\))/); + expect(help).toMatch(/--delayMs \s+integer(?! \(required\))/); + expect(toolsClientMock.callTool).not.toHaveBeenCalled(); + }); }); diff --git a/packages/argent-mcp/src/mcp-server.ts b/packages/argent-mcp/src/mcp-server.ts index 5bb4646fc..ac9076c8f 100644 --- a/packages/argent-mcp/src/mcp-server.ts +++ b/packages/argent-mcp/src/mcp-server.ts @@ -240,7 +240,8 @@ export async function startMcpServer(options: StartMcpServerOptions): Promise () => void; /** diff --git a/packages/skills/rules/argent.md b/packages/skills/rules/argent.md index dbf578b2d..588e2f917 100644 --- a/packages/skills/rules/argent.md +++ b/packages/skills/rules/argent.md @@ -77,7 +77,10 @@ Decision order: Call `screenshot` separately only for a baseline before any action or after a delay. - Always open apps with `launch-app` or `open-url` — never tap home screen icons. - Always use `run-sequence` when performing multiple sequential device actions where you don't need to observe the screen between steps. More in `argent-device-interact` skill. -- When the session ends or the user says they are done: call `stop-all-simulator-servers`. +- When the session ends or the user says they are done: call `stop-all-simulator-servers` with `devices: [...]` + naming the devices this session actually used. One tool-server is shared by every other agent using this + argent install, so an unscoped call tears down their devices too; reserve that form for a deliberate + machine-wide cleanup. If the user started Metro separately, ask whether to call `stop-metro` (specify the port if not 8081). - If tools provided by mcp-server are not sufficient and action can be done using `xcrun`, `adb`, or other commands, use the command. Examples: changing device options, performing a device action such as lock, shake, etc. - When waiting for an action, do not call `screenshot` repeatedly without a proper wait mechanism. Use the `await-ui-element` tool to block until the UI settles (e.g. wait for an element to become `visible`/`hidden`, or to contain expected `text`) instead of polling. diff --git a/packages/skills/skills/argent-create-flow/SKILL.md b/packages/skills/skills/argent-create-flow/SKILL.md index 1f77e85dc..00876ee6c 100644 --- a/packages/skills/skills/argent-create-flow/SKILL.md +++ b/packages/skills/skills/argent-create-flow/SKILL.md @@ -9,6 +9,8 @@ A flow is a sequence of steps saved to a `.yaml` file in the `.argent/flows/` di Flows store **no device id**: the runner binds a device (the single booted one, or pass `device`/`platform`). A recorded coordinate `gesture-tap` is captured as a portable `tap: { selector }` step whenever the tapped element has stable text/identifier. +The one exception is a device _scope_ rather than a target: `stop-all-simulator-servers`' `devices` list is kept in the YAML, because without it the step means the machine-wide sweep and would tear down devices other agents are mid-session on. Replay rebinds a recorded scope only when you pass `device` explicitly — an auto-detected device would retarget the teardown at a device the flow never named, which is the cross-agent teardown the scope exists to prevent. So the recorded ids are what run when you replay without `device`, and when you hand-run the step (see _Strategy 2 — Manual recovery + continue_); on another host they reap nothing and come back in `unmatched`. Re-record the cleanup flow there, or pass `device`. A step that recorded NO scope is still narrowed onto whatever device the run resolved, since binding can only make the machine-wide sweep smaller. A cleanup flow whose only step is that teardown needs no device and runs whether none or several are booted. + **Two flow types** - **e2e** — begins with a `launch:` step, which starts that app from scratch (terminate + relaunch), so the flow controls its own start state. No `executionPrerequisite`. May `run:` other flows, and may itself be a `run:` target — when nested, its `launch` runs inline, restarting the app for that sub-scenario. **On Chromium a launch is a process, not a relaunch:** the "device" is the booted app (its id is the CDP port). The runner needs a device before step 1, so it boots for the launch the run _begins_ with, following a leading `run:` — a fragment whose first step composes a chromium e2e flow boots that flow's app (pass `--platform chromium` when the launch names several platforms, or the target is ambiguous and auto-detection is used instead). That first launch then just settles the instance it was booted for; every _later_ launch — a nested e2e flow's own, or a mid-flow `launch:` of the same app — boots its own instance and the run moves onto it for the remaining steps, replacing the one the runner already owns for that app. Every instance the runner boots is torn down at run end; one you pinned with `--device` is attached to, never killed — so relaunching _that_ app mid-flow fails if it holds a single-instance lock. A launch that names no id for the run's platform is an error — a `chromium:` entry does not make a flow runnable on iOS, and the run never switches platforms mid-flight. Record one by adding a `restart-app` of the app under test as the **first** step — it is captured as the `launch` step. Not on Chromium, though: `restart-app` has no chromium support and only successful calls are recorded, so a recorded chromium flow is always a fragment — write the `launch: { chromium: }` line into the YAML yourself afterward, and delete any `executionPrerequisite` the recording declared: with its own launch the flow controls its start state, and a launch-first flow must not carry one. @@ -126,21 +128,23 @@ The standalone command uses only the auto-started local tool server. It is unava ## Tools -| Tool | Purpose | -| ------------------------ | --------------------------------------------------------------------------------------------------------- | -| `flow-start-recording` | Start recording — takes a name and (fragments only) an optional `executionPrerequisite`; creates the file | -| `flow-add-step` | Execute a tool call live and record it if it succeeds | -| `flow-add-echo` | Add a label/comment that prints during replay | -| `flow-finish-recording` | Stop recording and get a summary | -| `flow-read-prerequisite` | Read a flow's execution prerequisite without running it (same `name`/`flow_path` sources) | -| `flow-execute` | Replay a flow — a saved one by `name`, or any flow YAML by absolute `flow_path` | +| Tool | Purpose | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `flow-start-recording` | Start recording — takes `name` + `project_root` and (fragments only) an optional `executionPrerequisite`; creates the file, truncating any existing one | +| `flow-add-step` | Execute a tool call live and record it if it succeeds | +| `flow-add-echo` | Add a label/comment that prints during replay | +| `flow-finish-recording` | Stop recording and get a summary | +| `flow-read-prerequisite` | Read a flow's execution prerequisite without running it (same `name`/`flow_path` sources) | +| `flow-execute` | Replay a flow — a saved one by `name`, or any flow YAML by absolute `flow_path` | Every tool during recording returns the current flow file contents, so you can track what has been recorded. Rules: - **Every step runs live.** You see the real tool result (including screenshots) — verify the step worked before continuing. **Only successful steps are recorded**: a failed call writes nothing to the flow file; fix the issue and try again. -- **Pass `project_root` once.** Give the absolute `project_root` (an error is returned if the path is not absolute) to `flow-start-recording` — it is stored for the session and used by all subsequent flow tools. You do **not** pass a flow name to `flow-add-step`, `flow-add-echo`, or `flow-finish-recording` — the active flow is tracked automatically. -- **Start before adding.** Calling those tools without an active recording returns _"No active flow. Call flow-start-recording first."_ -- **One flow at a time.** `flow-start-recording` while already recording switches to the new flow — the response tells you which flow was abandoned and which is now active; the old flow's file remains on disk. +- **Every recording tool takes `name` + `project_root`.** `flow-add-step`, `flow-add-echo`, and `flow-finish-recording` each name the recording they address, repeating the `name` and the absolute `project_root` (an error is returned if the path is not absolute) given to `flow-start-recording`. Nothing is carried over between calls. +- **Recording _state_ is isolated; the device is not.** A recording is keyed by its output file, `/.argent/flows/.yaml`, so several can be open at once — different names, different projects — and one recording's steps never land in another's file. Nothing is isolated on the device: every step runs live, so two recordings driving one device interleave real UI actions, and one flow's recorded `restart-app` resets the app under the other. Give each concurrent recording its own device. +- **Starting always truncates the `.yaml`.** `flow-start-recording` resets `/.argent/flows/.yaml` to an empty flow on every call — including a name that is only a saved file with no recording in progress, so starting under the name of a committed flow wipes it. `restarted: true` is reported only when a LIVE recording of that flow was discarded, so its **absence does not mean nothing was overwritten**. `discardedSteps` (in the return value) counts the discarded take, but can be absent even on a restart. Starting a _different_ flow abandons nothing. +- **Pick a name unique to your task.** The key is `(project_root, name)` with no ownership check: if another agent starts the same name + project while you are recording, your file is truncated and it takes the key. **Usually nothing tells you.** The liveness check runs only while an append is executing, so if you were between calls (the common case) your next `flow-add-step`/`flow-add-echo` simply re-resolves the key and appends into the other agent's recording, reporting success. You are told only in the lucky case where a step happened to be in flight at the moment of the takeover: `Recording of "" in is no longer active — it was restarted while this step was running…`. `flow-finish-recording` re-resolves the key the same way, so it finishes and clears whichever recording currently holds it — possibly the other agent's, which leaves _them_ with `Active recordings: none in this project` and no step in flight to have warned them. Either way, restart under a fresh name instead of re-adding the step. A name that only _resolves_ to the same file — a differently-cased one on macOS/Windows, or a flow (or `.argent/flows`) symlinked into a shared vault from two projects — is the same key, because the key is the file the filesystem resolves to, not the spelling you passed. That collision is reported rather than silent: the second start says `restarted` with a `discardedSteps` count, and the first recording's next call fails with `… are the same file on this filesystem …` naming both spellings. +- **Start before adding.** Calling those tools for a flow with no recording in progress returns `No active recording for flow "" in . If you have not started it yet, call flow-start-recording — but note it truncates, so if already holds a take you want (finished, or interrupted by a restart), copy it aside or record under a fresh name instead. Active recordings: ...`. The truncation caveat is there because this same error is what you get when your take was finished or dropped by the concurrent-recording cap — and on those branches the `.yaml` on disk is fully populated, so starting again destroys it. (A takeover by another agent is different: it resolves to _their_ recording and succeeds — see the previous bullet — rather than reaching this error.) The tail names only the flows live under **the `project_root` you passed** — `"checkout"`, or `none in this project` — and merely counts any others as `(plus N in other projects)`, since a shared tool-server serves callers whose project paths are not yours to see. So a mistyped `name` is spelled out for you; a wrong `project_root` shows up as your flow missing from a project you expected it in. - **Mistakes can be edited out.** Edit the `.yaml` file directly to remove or reorder steps. ### flow-add-step arguments @@ -148,21 +152,33 @@ Every tool during recording returns the current flow file contents, so you can t The `command` parameter is the MCP tool name; `args` is a **JSON string** (not an object), omitted entirely for tools with no arguments: ``` +name: "checkout-e2e" project_root: "/Users/dev/MyApp" command: "gesture-tap" args: "{\"udid\": \"\", \"x\": 0.5, \"y\": 0.35}" +name: "checkout-e2e" project_root: "/Users/dev/MyApp" command: "await-ui-element" args: "{\"udid\": \"\", \"condition\": \"visible\", \"selector\": {\"text\": \"Continue\"}}" ``` +Recording a `flow-execute` step carries **two** `name`s: the top-level `name` is the recording being appended to, `args.name` is the flow being run (captured as a `run:` step). + +``` +name: "checkout-e2e" project_root: "/Users/dev/MyApp" +command: "flow-execute" +args: "{\"name\": \"login\", \"project_root\": \"/Users/dev/MyApp\"}" +``` + +Caveat to the "only successful steps are recorded" rule: if that sibling is a fragment with an `executionPrerequisite`, `flow-execute` returns its prerequisite **notice** instead of running - still a successful return, so `run: login` is recorded even though nothing executed. Add `"prerequisiteAcknowledged": true` to `args` to actually run it. + Record an `await-ui-element` step to **gate** the next step on a screen transition — it blocks until the element is `visible`/`hidden` (or contains `text`), so the following step runs only once the screen has actually settled; prefer this over a fixed `delayMs`. If its condition is not met before the timeout, replay **stops at that step** (the steps after it assume the transition happened). See the `await-ui-element` section of `argent-device-interact` for the full condition/selector reference. The live call sees only the trimmed `describe` tree — if it can't find an identifier you know exists, gate on visible text to get the step recorded, then retarget the identifier in the `await:` form during polish (the directive resolves the full hierarchy — see Selectors); don't conclude the testID is unusable in the flow. ## Recording 1. **Start, then launch as the first step (e2e) or set the stage yourself (fragment).** Call `flow-start-recording` with a descriptive name and the absolute `project_root`. For an **e2e** flow, record a `restart-app` of the app under test as the **first** step — it runs live (resetting the device for the rest of the recording) and is captured as the flow's `launch` step (`restart-app` has no chromium support, so on Chromium record the flow as a fragment against the running app and add the `launch:` line to the YAML afterward, deleting the `executionPrerequisite` line if you passed one — a launch-first flow must not declare it). For a **fragment**, bring the device to the entry state _before_ recording and pass an `executionPrerequisite` describing it (e.g. "App on the login screen") to `flow-start-recording` instead. -2. **Build step-by-step**: for each action, call `flow-add-step` with the tool name and args. The tool runs immediately — check the result before moving on, and gate each navigation with an `await-ui-element` step. -3. **Add labels**: use `flow-add-echo` between steps — echo the expected state, not just the action (see _Making flows resilient_). -4. **Finish**: call `flow-finish-recording`. It returns the file path where the flow was saved and a summary of all steps. +2. **Build step-by-step**: for each action, call `flow-add-step` with the same `name` + `project_root`, plus the tool name and args. The tool runs immediately — check the result before moving on, and gate each navigation with an `await-ui-element` step. +3. **Add labels**: use `flow-add-echo` (same `name` + `project_root`) between steps — echo the expected state, not just the action (see _Making flows resilient_). +4. **Finish**: call `flow-finish-recording` with the same `name` + `project_root`. It returns the file path where the flow was saved and a summary of all steps. 5. **Polish**: **read the saved `.yaml` file** and convert the raw `tool:` steps that have a cleaner directive form (the recorder leaves these as tools): - `tool: keyboard` typing into a field → `type: { into: "", text: "…" }`, folding in the `tap` that focused the field. - `tool: await-ui-element` gating a transition → `await: { visible: "…" }` / `{ hidden: … }` / `{ text: { in: …, equals: … } }`, carrying a custom `timeoutMs` over as a `timeout` sibling key. Converting also upgrades the wait from the trimmed `describe` tree to the flow's full-hierarchy tree (see Selectors). Keep the raw `tool: await-ui-element` step only when it sets a custom `pollIntervalMs`/`bundleId` the directive can't express. @@ -176,22 +192,22 @@ Every other recorded tool (a velocity-dependent `gesture-swipe`, a fixed-distanc ``` flow-start-recording { name: "open-about", project_root: "/Users/dev/MyApp" } -flow-add-echo { message: "Start Settings from scratch" } -flow-add-step { command: "restart-app", args: "{\"udid\": \"ABC\", \"bundleId\": \"com.apple.Preferences\"}" } # ⇒ captured as `- launch: com.apple.Preferences` — this is now an e2e flow -flow-add-echo { message: "On the Settings root list, tapping the 'General' row" } -flow-add-step { command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.35}" } # ⇒ captured as `- tap: { text: General }` (portable selector, no udid) -flow-add-step { command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"About\"}}" } # gate the transition -flow-add-echo { message: "On Settings > General, tapping 'About'" } -flow-add-step { command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.17}" } -flow-add-step { command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"Model Name\"}}" } -flow-finish-recording {} +flow-add-echo { name: "open-about", project_root: "/Users/dev/MyApp", message: "Start Settings from scratch" } +flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "restart-app", args: "{\"udid\": \"ABC\", \"bundleId\": \"com.apple.Preferences\"}" } # ⇒ captured as `- launch: com.apple.Preferences` — this is now an e2e flow +flow-add-echo { name: "open-about", project_root: "/Users/dev/MyApp", message: "On the Settings root list, tapping the 'General' row" } +flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.35}" } # ⇒ captured as `- tap: { text: General }` (portable selector, no udid) +flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"About\"}}" } # gate the transition +flow-add-echo { name: "open-about", project_root: "/Users/dev/MyApp", message: "On Settings > General, tapping 'About'" } +flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "gesture-tap", args: "{\"udid\": \"ABC\", \"x\": 0.5, \"y\": 0.17}" } +flow-add-step { name: "open-about", project_root: "/Users/dev/MyApp", command: "await-ui-element", args: "{\"udid\": \"ABC\", \"condition\": \"visible\", \"selector\": {\"text\": \"Model Name\"}}" } +flow-finish-recording { name: "open-about", project_root: "/Users/dev/MyApp" } ``` Then polish the saved file: the two `await-ui-element` steps become `await:` directives (see the file below). ## Replaying -Call `flow-execute` with exactly one flow source: `name` for a flow saved under `.argent/flows/` (this form also works through a remote tool server), or `flow_path` — an absolute path to any flow `.yaml`. A flow's `run:` targets and `__baselines__/` resolve on the **tool server's** filesystem, beside the YAML it actually reads. `flow_path` requires the agent and the tool server to share a filesystem and is refused when they don't; `name` is what still runs then, but it is not a way to keep siblings and baselines — a remote call reaches the server as an upload of that one YAML into a fresh temp directory, so a `run:` target errors as a missing fragment and a `snapshot` step fails for a missing baseline under a temp path (and `updateBaselines` writes the baseline there, to be deleted with the directory). Remotely, replay self-contained flows; a flow that composes or snapshots needs the agent and the tool server on one filesystem. Pass `project_root` too — it is always required here; the stored-for-the-session shortcut applies only to the recording tools. If the flow has an execution prerequisite, the tool returns a **notice** with the prerequisite text instead of running — verify the prerequisite is met (you can also inspect it beforehand with `flow-read-prerequisite`, which takes the same `name`/`flow_path` pair) and call `flow-execute` again with `prerequisiteAcknowledged: true`. A flow without a prerequisite runs immediately. The run executes all steps in order and returns a structured report: `{ ok, passed, failed, skipped, errored, steps }`. +Call `flow-execute` with exactly one flow source: `name` for a flow saved under `.argent/flows/` (this form also works through a remote tool server), or `flow_path` — an absolute path to any flow `.yaml`. A flow's `run:` targets and `__baselines__/` resolve on the **tool server's** filesystem, beside the YAML it actually reads. `flow_path` requires the agent and the tool server to share a filesystem and is refused when they don't; `name` is what still runs then, but it is not a way to keep siblings and baselines — a remote call reaches the server as an upload of that one YAML into a fresh temp directory, so a `run:` target errors as a missing fragment and a `snapshot` step fails for a missing baseline under a temp path (and `updateBaselines` writes the baseline there, to be deleted with the directory). Remotely, replay self-contained flows; a flow that composes or snapshots needs the agent and the tool server on one filesystem. Pass `project_root` too — it is always required here, and replaying reads no recording state, so an in-progress recording never stands in for it. **Pass `device` explicitly whenever more than one device is booted:** auto-detection resolves only when exactly one booted device matches — optionally narrowed by `platform` — and otherwise throws, listing what is available. (A Chromium e2e flow boots and tears down its own instance, but only when the launch resolves to a real Electron app path — a `launch: { chromium: }` map, or `platform: "chromium"` with `device` unset. **Don't force it with `platform: "chromium"` on a recorded flow:** the recorder writes a bare-string `launch:` holding a bundle _id_, which the boot branch reads as an app path and fails with `Electron boot: path does not exist: …`. Hand-edit the launch to `{ chromium: }` first.) If the flow has an execution prerequisite, the tool returns a **notice** with the prerequisite text instead of running — verify the prerequisite is met (you can also inspect it beforehand with `flow-read-prerequisite`, which takes the same `name`/`flow_path` pair) and call `flow-execute` again with `prerequisiteAcknowledged: true`. A flow without a prerequisite runs immediately. The run executes all steps in order and returns a structured report: `{ ok, passed, failed, skipped, errored, steps }`. **What each step reports.** Raw `tool:` steps include the underlying tool's full `result` (screenshots and other outputs render as usual). The directive steps are summarized: `tap`/`type`/`await`/`assert` report only `status` + `reason`, and `snapshot` adds `artifacts` only when there is something to look at — a failed comparison (baseline/current/diff paths), a missing-baseline failure (`current` only), or a baseline write; a clean pass reports just `status` + `reason`. So converting a `tool: gesture-tap` into a `tap:` directive during cleanup drops only that tap's (uninteresting) raw result — output-bearing tools like `screenshot` have no directive form and stay `tool:` steps, so their results keep flowing through. @@ -250,6 +266,11 @@ For silent misfires and partial divergence, echo annotations (see _Making flows 1. Note the failure step index and error message (if hard error). 2. Call `screenshot` to see where the app actually is now. 3. Call `describe` or `debugger-component-tree` to get the current element tree. Remember `describe` shows less than the flow tree — a testID missing from its output can still resolve as a selector (see Selectors). + + `debugger-component-tree` is an **authoring aid only — never record a `debugger-*` step into a flow.** `device_id` is stripped at record time and re-injected at replay, but `port` is not a device-bind key, so a recorded debugger step carries whatever `port` it was given (or falls through to the 8081 default at replay) and runs against whatever Metro happens to be on that port. + + When calling any `debugger-*` tool directly, mind the shared-Metro rule: `port` is the **only** project discriminator (default `8081`), so with two RN projects running, pass the `port` of the one under test — otherwise the call lands on whichever Metro owns 8081. + 4. Compare current state to what the failed step expected. Classify the root cause: | Root cause | Symptoms | @@ -273,10 +294,10 @@ Read `.argent/flows/.yaml`, update the broken step's `x`/`y`, `bundle Manually execute the failed step with corrected coordinates from the Diagnose step, then manually execute remaining steps. Does not fix the YAML — use only when re-recording is not worth it. **Strategy 3 — Re-record from failure point** (structural changes, new intermediate screens). -Navigate the app to the state just before the failure point. Call `flow-start-recording` with the same flow name (overwrites). Re-add the working prefix steps via `flow-add-step`, then continue recording new steps from the divergence point. Call `flow-finish-recording`. +Navigate the app to the state just before the failure point. Call `flow-start-recording` with the same `name` + `project_root` — the start truncates the saved `.yaml` immediately, so copy the working prefix out of the file first. Re-add that prefix via `flow-add-step` (same `name` + `project_root`), then continue recording new steps from the divergence point. Call `flow-finish-recording` with the same `name` + `project_root`. **Strategy 4 — Full re-record** (major changes, unclear diagnosis, or 3+ broken steps). -Reset the app to prerequisite state (`restart-app` + `launch-app`). Record from scratch with the same flow name. +Reset the app to prerequisite state (`restart-app` + `launch-app`). Record from scratch with the same `name` + `project_root` — the start truncates the old `.yaml`, so keep a copy if you may want to diff against it. **Decision heuristic:** diff --git a/packages/skills/skills/argent-metro-debugger/SKILL.md b/packages/skills/skills/argent-metro-debugger/SKILL.md index a4a04196d..67cb9dd39 100644 --- a/packages/skills/skills/argent-metro-debugger/SKILL.md +++ b/packages/skills/skills/argent-metro-debugger/SKILL.md @@ -27,6 +27,8 @@ All tools accept `port` (default 8081) AND `device_id` (the iOS Simulator UDID, One Metro port can serve multiple connected devices (e.g. two simulators on `localhost:8081`, or an iOS simulator alongside an Android emulator with `adb reverse` set up). `device_id` pins every debugger/network/profiler call to a specific device so sessions do not collide. +With two or more devices on one Metro, `debugger-connect` refuses a udid/serial and hands back the `logicalDeviceId` to re-target with. That id then keys the session — including for teardown. **Pass it in `stop-all-simulator-servers`' `devices` alongside the device id**, or the session survives your session end holding its CDP socket, console server and log file. The teardown reports what it could not reach in `left_running`; re-call with the id it names. + ### Connect & diagnostics | Tool | Purpose | diff --git a/packages/skills/skills/argent-react-native-app-workflow/SKILL.md b/packages/skills/skills/argent-react-native-app-workflow/SKILL.md index eba0c7975..2cc4797f5 100644 --- a/packages/skills/skills/argent-react-native-app-workflow/SKILL.md +++ b/packages/skills/skills/argent-react-native-app-workflow/SKILL.md @@ -137,17 +137,17 @@ Once you discover the correct build/run workflow for a project, **save it to pro ### 3.5 Device Control -| Action | Tool / Command | -| -------------------------- | ---------------------------------------------------------------------- | -| List devices | `list-devices` tool (iOS + Android) | -| Boot an iOS simulator | `boot-device` tool with `udid` | -| Boot an Android emulator | `boot-device` tool with `avdName` | -| Launch an app | `launch-app` tool (pass device id + bundle id / package name) | -| Restart an app | `restart-app` tool (pass device id + bundle id / package name) | -| Open a URL / deep link | `open-url` tool (pass device id + URL) | -| Rotate device | `rotate` tool | -| Stop simulator server | `stop-simulator-server` tool (iOS UDID or Android serial — one device) | -| Stop all simulator servers | `stop-all-simulator-servers` tool (iOS + Android) | +| Action | Tool / Command | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| List devices | `list-devices` tool (iOS + Android) | +| Boot an iOS simulator | `boot-device` tool with `udid` | +| Boot an Android emulator | `boot-device` tool with `avdName` | +| Launch an app | `launch-app` tool (pass device id + bundle id / package name) | +| Restart an app | `restart-app` tool (pass device id + bundle id / package name) | +| Open a URL / deep link | `open-url` tool (pass device id + URL) | +| Rotate device | `rotate` tool | +| Stop simulator server | `stop-simulator-server` tool (iOS UDID or Android serial — one device) | +| Stop all simulator servers | `stop-all-simulator-servers` tool — pass `devices: [...]` to scope the teardown to this session's devices (an unscoped call also tears down other agents' devices; use it only for a machine-wide cleanup) | For full simulator setup workflow, refer to the `argent-ios-simulator-setup` skill. diff --git a/packages/tool-server/src/blueprints/chromium-js-runtime-debugger.ts b/packages/tool-server/src/blueprints/chromium-js-runtime-debugger.ts index c2e5af1a1..c14e440f7 100644 --- a/packages/tool-server/src/blueprints/chromium-js-runtime-debugger.ts +++ b/packages/tool-server/src/blueprints/chromium-js-runtime-debugger.ts @@ -12,6 +12,7 @@ import { SourceMapsRegistry } from "../utils/debugger/source-maps"; import type { SourceResolver } from "../utils/debugger/source-resolver"; import { LogFileWriter } from "../utils/debugger/log-file-writer"; import { consoleTimestampToIso } from "../utils/debugger/console-timestamp"; +import { recordReapedSession } from "../utils/reaped-sessions"; import { type ConsoleLogEntry, type ConsoleLogEvents, @@ -246,6 +247,28 @@ export const chromiumJsRuntimeDebuggerBlueprint: ServiceBlueprint 0) { + recordReapedSession( + "js-runtime-debugger", + device.id, + `The ${captured} captured console ${captured === 1 ? "entry" : "entries"} went with ` + + `it — the log file is deleted on teardown, so this registry starts empty rather ` + + `than the app having logged nothing.` + ); + } logWriter.close(); // Do NOT disconnect the cdp — it belongs to the ChromiumCdp service. // Disposing this blueprint must leave the underlying CDP session alive diff --git a/packages/tool-server/src/blueprints/js-runtime-debugger.ts b/packages/tool-server/src/blueprints/js-runtime-debugger.ts index 609bbc899..2e99bb6e4 100644 --- a/packages/tool-server/src/blueprints/js-runtime-debugger.ts +++ b/packages/tool-server/src/blueprints/js-runtime-debugger.ts @@ -9,7 +9,13 @@ import { discoverMetro } from "../utils/debugger/discovery"; import { classifyDevice } from "../utils/device-info"; import { proxyStart } from "../utils/sim-remote"; import { selectTarget } from "../utils/debugger/target-selection"; -import { rememberDeviceAlias, forgetDeviceAlias } from "../utils/debugger/device-alias"; +import { + rememberDeviceAlias, + forgetDeviceAlias, + rememberLogicalKeyedDevice, + forgetLogicalKeyedDevice, +} from "../utils/debugger/device-alias"; +import { recordReapedSession } from "../utils/reaped-sessions"; import { CDPClient, type ConsoleAPICalledParams } from "../utils/debugger/cdp-client"; import { createSourceResolver, type SourceResolver } from "../utils/debugger/source-resolver"; import { SourceMapsRegistry } from "../utils/debugger/source-maps"; @@ -265,6 +271,13 @@ export const jsRuntimeDebuggerBlueprint: ServiceBlueprint(); @@ -284,7 +297,32 @@ export const jsRuntimeDebuggerBlueprint: ServiceBlueprint { + // `logWriter.close()` below unlinks the log file — up to 50,000 + // captured console entries. That is correct as cleanup (nothing can + // read it again: the next resolve builds a new writer over a new path) + // but it is invisible, and since `JsRuntimeDebugger` joined the + // teardown's namespace set this dispose is routinely triggered by + // another agent's `stop-all-simulator-servers`. Leave a breadcrumb so + // `debugger-log-registry`'s otherwise silent `totalEntries: 0` can say + // what happened to the history. + // + // Only when there IS history to lose, and under both ids this device + // answers to: the caller may read back with either the id it connected + // with or the `logicalDeviceId` Metro echoed, and `forgetDeviceAlias` + // below removes the only thing that joins them. + const captured = logWriter.getStats().totalEntries; + if (captured > 0) { + const salvage = + `The ${captured} captured console ${captured === 1 ? "entry" : "entries"} went with ` + + `it — the log file is deleted on teardown, so this registry starts empty rather ` + + `than the app having logged nothing.`; + recordReapedSession("js-runtime-debugger", deviceId, salvage); + if (api.logicalDeviceId && api.logicalDeviceId !== deviceId) { + recordReapedSession("js-runtime-debugger", api.logicalDeviceId, salvage); + } + } forgetDeviceAlias(api.logicalDeviceId); + forgetLogicalKeyedDevice(deviceId); await consoleServer.close(); logWriter.close(); await cdp.disconnect(); diff --git a/packages/tool-server/src/blueprints/native-profiler-session.ts b/packages/tool-server/src/blueprints/native-profiler-session.ts index d58d5b3ec..ea351e951 100644 --- a/packages/tool-server/src/blueprints/native-profiler-session.ts +++ b/packages/tool-server/src/blueprints/native-profiler-session.ts @@ -11,6 +11,7 @@ import type { ChildProcess } from "child_process"; import type { CpuSample, UiHang, MemoryLeak, CpuHotspot } from "../utils/ios-profiler/types"; import { waitForChildExit } from "../utils/profiler-shared/lifecycle"; import { adbShell } from "../utils/adb"; +import { recordReapedSession } from "../utils/reaped-sessions"; import { disposeWarmEngine } from "@argent/native-devtools-android"; // Cross-platform session for the `native-profiler-*` tools: iOS uses an xctrace @@ -86,6 +87,19 @@ export interface NativeProfilerSessionApi { * Null when unknown — before any stop, on Android, or after a load. */ mallocStackLogging: boolean | null; + /** + * Whether this session has been torn down. Set by `dispose()` and never + * cleared: `Registry._teardown` nulls the node's instance, so the next + * resolve builds a fresh api rather than reviving this one. + * + * Read by `native-profiler-start`, which spawns its capture child and then + * awaits a readiness handshake. A teardown arriving inside that window + * destroys the session the start is about to report success for — leaving a + * `status: "recording"` against a session the registry no longer has, whose + * owner's `native-profiler-stop` then answers "call native-profiler-start + * first". Start checks this before returning and fails instead. + */ + disposed: boolean; recordingTimeout: NodeJS.Timeout | null; recordingTimedOut: boolean; recordingExitedUnexpectedly: boolean; @@ -94,12 +108,54 @@ export interface NativeProfilerSessionApi { androidOnDeviceTracePath: string | null; } -// Dispose only fires on process shutdown, where an in-flight recording is being -// abandoned: skip the SIGINT finalise grace (that's the native-profiler-stop -// contract) and SIGKILL straight away so shutdown isn't held up. +// Dispose fires on process shutdown, and on `stop-all-simulator-servers` (which +// reaps every device-owned service, `NativeProfilerSession` among them) — the +// call every agent makes at session end. Either way an in-flight capture is +// being abandoned with nobody waiting on the trace, so skip the SIGINT finalise +// grace (that's the native-profiler-stop contract, and a caller that wants the +// trace calls that) and SIGKILL straight away rather than holding the caller up. const DISPOSE_REAP_MS = 1_000; const ANDROID_DISPOSE_ADB_TIMEOUT_MS = 5_000; +/** + * What survived an iOS teardown, per arm — see the two flags in `dispose()`. + * `midCapture` names the arm that was still recording; the other one had + * already stopped, by the 10-minute cap's SIGINT or by xctrace exiting on its + * own, so its bundle went through a finalize pass and calling it half-written + * would send the owner away from a trace they can still read. + */ +function iosSalvage(midCapture: boolean, traceFile: string | null): string | undefined { + if (!traceFile) return undefined; + return midCapture + ? `xctrace was killed without its finalize pass, so the partial bundle at ${traceFile} is ` + + `very likely unreadable — re-profile rather than trying to salvage it.` + : `The recording had already ended before this teardown (the 10-minute cap, or xctrace ` + + `exiting on its own), so the bundle at ${traceFile} was finalized and may well be ` + + `readable — but this session was the only thing that could export it, so re-profile ` + + `unless you can open that bundle yourself.`; +} + +/** The Android twin of {@link iosSalvage}. */ +function androidSalvage(midCapture: boolean, onDeviceTracePath: string | null): string { + if (midCapture) { + // The on-device .pftrace is removed by the kill branch, and nothing was + // pulled to the host yet, so there is genuinely nothing to point at. + return ( + "The perfetto daemon was killed and its on-device trace removed, so no trace " + + "survived — re-profile to capture again." + ); + } + // The cap arm sent SIGTERM and cleared `profilingActive`, so the kill branch + // does not run and the trace is still on the device — but only this session + // knew to pull it. + return ( + `The recording had already ended before this teardown (the 10-minute cap), so the ` + + `on-device trace was left in place${onDeviceTracePath ? ` at ${onDeviceTracePath}` : ""} — ` + + `but this session was the only thing that could pull it to the host. Re-profile, or ` + + `\`adb pull\` it yourself.` + ); +} + function clearLiveState(state: NativeProfilerSessionApi): void { state.profilingActive = false; state.capturePid = null; @@ -160,6 +216,7 @@ export const nativeProfilerSessionBlueprint: ServiceBlueprint< cpuFilterPid: null, recordingMallocStackLogging: null, mallocStackLogging: null, + disposed: false, recordingTimeout: null, recordingTimedOut: false, recordingExitedUnexpectedly: false, @@ -172,15 +229,46 @@ export const nativeProfilerSessionBlueprint: ServiceBlueprint< return { api: state, dispose: async () => { + // Before anything else, and read by a start still inside its readiness + // handshake: from here on this session no longer exists, so a start + // that resumes must fail rather than report a recording nothing can + // reach. See {@link NativeProfilerSessionApi.disposed}. + state.disposed = true; if (state.recordingTimeout) { clearTimeout(state.recordingTimeout); state.recordingTimeout = null; } + // Read before the teardown below clears it. A capture killed here is + // destroyed rather than salvaged — no SIGINT finalize grace, and on + // Android the on-device trace is removed outright — so the breadcrumb + // exists purely so `native-profiler-stop` stops answering "call + // native-profiler-start first" for a session that really did run. + const midCapture = state.profilingActive; + // …and a capture the 10-minute cap or an unexpected exit already ended + // is one that RAN too. Those arms clear `profilingActive` while leaving + // the trace recoverable — `native-profiler-stop` has a whole branch for + // exporting it — so a teardown here still destroys the owner's only way + // to reach it, and gating the breadcrumb on `profilingActive` alone sent + // that owner back to "you never started one". It is also destroyed + // DIFFERENTLY: that arm already sent SIGINT (or the process exited on + // its own), so the salvage text below must not call the bundle a + // half-written one. + const endedCapture = + (state.recordingTimedOut || state.recordingExitedUnexpectedly) && + state.traceFile !== null; + const abandonedCapture = midCapture || endedCapture; + const abandonedTrace = state.traceFile; if (state.platform === "ios") { const child = state.captureProcess; try { - if (state.profilingActive && child) { + // Whether or not the run has been declared active: `attemptStart` + // hands the child over BEFORE awaiting xctrace's readiness + // handshake, so a teardown inside that window sees `profilingActive` + // still false while a spawned xctrace is very much running. Gating + // the kill on the flag left it behind, recording into a trace + // nobody would ever stop. + if (child) { try { child.kill("SIGKILL"); } catch { @@ -190,6 +278,13 @@ export const nativeProfilerSessionBlueprint: ServiceBlueprint< } } finally { clearLiveState(state); + if (abandonedCapture) { + recordReapedSession( + "native-profiler", + state.deviceId, + iosSalvage(midCapture, abandonedTrace) + ); + } } return; } @@ -211,6 +306,13 @@ export const nativeProfilerSessionBlueprint: ServiceBlueprint< } } finally { clearLiveState(state); + if (abandonedCapture) { + recordReapedSession( + "native-profiler", + state.deviceId, + androidSalvage(midCapture, onDeviceTracePath) + ); + } } // ANDROID: Free this trace's warm Perfetto engine (trace memory + wasm heap) now diff --git a/packages/tool-server/src/blueprints/react-profiler-session.ts b/packages/tool-server/src/blueprints/react-profiler-session.ts index 56c9cc748..494470b2b 100644 --- a/packages/tool-server/src/blueprints/react-profiler-session.ts +++ b/packages/tool-server/src/blueprints/react-profiler-session.ts @@ -7,7 +7,10 @@ import { } from "@argent/registry"; import type { CDPClient } from "../utils/debugger/cdp-client"; import type { JsRuntimeDebuggerApi } from "./js-runtime-debugger"; -import { FIBER_ROOT_TRACKER_SCRIPT } from "../utils/react-profiler/scripts"; +import { + FIBER_ROOT_TRACKER_SCRIPT, + STOP_FOR_TAKEOVER_SCRIPT, +} from "../utils/react-profiler/scripts"; export const REACT_PROFILER_SESSION_NAMESPACE = "ReactProfilerSession"; @@ -195,7 +198,35 @@ export const reactProfilerSessionBlueprint: ServiceBlueprint { - // Profiler.stop is called explicitly in react-profiler-stop before disposal. + // A dispose reached from `react-profiler-stop` arrives with the run + // already ended — that tool clears `profilingActive`, calls + // `Profiler.stop` and runs STOP_AND_READ_SCRIPT (which stops every + // renderer) before disposing. A dispose reached from + // `stop-all-simulator-servers` does not: this session is in that + // teardown's namespace set, so it arrives mid-run, with nothing having + // stopped the in-app backend. + // + // Left alone, the React DevTools backend keeps recording every commit + // into a buffer only an app or bundle reload frees, and argent's + // patched commit hook keeps re-serializing that whole accumulated + // buffer synchronously on React's commit path — inside the user's app, + // outliving the argent session that started it. Stop it here while the + // CDP session is still up: `Registry._teardown` disposes dependents + // before their dependency, so the JsRuntimeDebugger this rides on has + // not disconnected yet, and this is the last moment anything can reach + // the app. + if (state.profilingActive) { + state.profilingActive = false; + await cdp + .send("Runtime.evaluate", { + expression: STOP_FOR_TAKEOVER_SCRIPT, + returnByValue: true, + }) + .catch(warnOnError("STOP_FOR_TAKEOVER_SCRIPT")); + // And the Hermes CPU sampler `react-profiler-start` enabled, which + // `Profiler.disable` alone is not documented to end. + await cdp.send("Profiler.stop").catch(ignore); + } await cdp.send("Profiler.disable").catch(ignore); }, events, diff --git a/packages/tool-server/src/blueprints/screen-recording-session.ts b/packages/tool-server/src/blueprints/screen-recording-session.ts index 2dd139f21..9e01f4af0 100644 --- a/packages/tool-server/src/blueprints/screen-recording-session.ts +++ b/packages/tool-server/src/blueprints/screen-recording-session.ts @@ -11,6 +11,7 @@ import type { ChildProcess } from "child_process"; import { promises as fs } from "fs"; import { waitForChildExit } from "../utils/profiler-shared/lifecycle"; import { clearActiveScreenRecording } from "../utils/screen-recording-reminder"; +import { recordReapedSession } from "../utils/reaped-sessions"; // Session for the `screen-recording-*` tools. One shape for every platform: // frames come from simulator-server's MJPEG stream and are paced into an ffmpeg @@ -42,7 +43,9 @@ export interface ScreenRecordingSessionApi { /** True while a stop is running; a concurrent start/stop must not interleave. */ stopPending: boolean; /** - * Set the moment dispose() begins (process shutdown). A start suspended at a + * Set the moment dispose() begins — process shutdown, or a + * `stop-all-simulator-servers` that reaps this device (a scoped call + * including it, or an unscoped machine-wide sweep). A start suspended at a * pre-spawn await (resolving ffmpeg, connecting to the frame stream) checks * this immediately before spawning and aborts — otherwise it would spawn an * encoder AFTER dispose already ran, orphaning a process that `pendingChild` @@ -102,10 +105,13 @@ export interface ScreenRecordingSessionApi { lastExitInfo: { code: number | null; signal: string | null } | null; } -// Dispose only fires on process shutdown, where an in-flight recording is -// being abandoned. Closing ffmpeg's stdin is what finalizes the container, so -// give that one short grace before SIGKILL — shutdown must not be held up by a -// slow finalize, but a playable file is worth a moment. +// Dispose fires on process shutdown, and on `stop-all-simulator-servers` (which +// reaps every device-owned service, `ScreenRecordingSession` among them) — the +// call every agent makes at session end. Either way an in-flight recording is +// being abandoned, so the video is a best-effort salvage rather than something a +// caller is waiting on: closing ffmpeg's stdin is what finalizes the container, +// so give that one short grace before SIGKILL. A caller that wants the file +// calls `screen-recording-stop`, which has its own (longer) finalize contract. const DISPOSE_FINALIZE_GRACE_MS = 1_500; const DISPOSE_REAP_MS = 1_000; @@ -200,6 +206,13 @@ export const screenRecordingSessionBlueprint: ServiceBlueprint< // await will observe this and abort instead of spawning an orphan the // teardown below can no longer reap. state.disposed = true; + // Whether this dispose is destroying an unretrieved capture, decided + // BEFORE the teardown below clears the flags it is read from. Both + // states owe the caller a video: one is still encoding, the other + // finished and is waiting to be handed over. + const hadUnretrievedCapture = + state.recordingActive || state.startPending || state.pendingRetrieval; + const abandonedOutput = state.outputFile; if (state.recordingTimeout) { clearTimeout(state.recordingTimeout); state.recordingTimeout = null; @@ -257,6 +270,22 @@ export const screenRecordingSessionBlueprint: ServiceBlueprint< clearLiveState(state); // The reminder must not outlive the process that owns the capture. clearActiveScreenRecording(state.deviceId); + // Leave a breadcrumb so the owner's `screen-recording-stop` reports + // the teardown instead of "you never started a recording". The stdin + // close above is ffmpeg's normal finalize path, so the file usually + // is playable — but nothing else would ever say it exists, and the + // next resolve builds a session that has never heard of it. + if (hadUnretrievedCapture) { + recordReapedSession( + "screen-recording", + state.deviceId, + abandonedOutput + ? `ffmpeg was given a moment to finalize the container first, so the video ` + + `captured up to that point is usually playable at ${abandonedOutput} — ` + + `check it before re-recording.` + : undefined + ); + } } }, events, diff --git a/packages/tool-server/src/http.ts b/packages/tool-server/src/http.ts index 07ca8a5ea..0d926cf6c 100644 --- a/packages/tool-server/src/http.ts +++ b/packages/tool-server/src/http.ts @@ -143,6 +143,22 @@ function extractDeviceArg(data: unknown): string | null { const record = data as Record; if (typeof record.udid === "string") return record.udid; if (typeof record.device_id === "string") return record.device_id; + // `devices: string[]` is a third spelling, used only by + // `stop-all-simulator-servers`' scoped teardown. A call can name several + // devices of different platforms; the first is enough for the coarse + // telemetry platform. + // + // Live today, not latent. Of this function's three consumers only the + // capability gate is gated — and `stop-all-simulator-servers` declares no + // capability, so `devices` never reaches that one. The other two are ungated + // and do read it: `emitHttpFailure` classifies a rejected call straight from + // `req.body` (a `.strict()` rejection of the `udids` slip is a real 400 on a + // body that carries `devices`), and `platformFromArgs` via + // `deriveChildInvocationMeta` attributes a sub-tool from its own args — which + // for a replayed teardown step is exactly `devices`. + if (Array.isArray(record.devices) && typeof record.devices[0] === "string") { + return record.devices[0]; + } return null; } @@ -199,9 +215,10 @@ function extractInvocationMeta( /** * Telemetry platform from a tool call's device arg, or null when it carries none. - * A `udid` / `device_id` resolves through the runtime-kind cache and refines to - * `tvos` / `android-tv` once that cache is warm (coarse `ios` / `android` until - * then); only the `avdName`-only fallback is unconditionally coarse. + * A `udid` / `device_id` (or the first of a scoped stop-all's `devices`) resolves + * through the runtime-kind cache and refines to `tvos` / `android-tv` once that + * cache is warm (coarse `ios` / `android` until then); only the `avdName`-only + * fallback is unconditionally coarse. */ function platformFromArgs(data: unknown): TelemetryPlatform | null { if (!data || typeof data !== "object") return null; @@ -219,9 +236,12 @@ function platformFromArgs(data: unknown): TelemetryPlatform | null { /** * Attribution for a sub-tool an orchestrator dispatches: the outer request's AI * client is inherited unchanged, but the platform is re-derived from the child's - * OWN device arg. Orchestrators like flow-execute carry no platform (and a flow - * can span several devices), so the child's `udid` is the only correct source; - * the parent's platform is the fallback when the child has no device arg. + * OWN device arg — `udid` / `device_id` / `devices` / `avdName`, whichever it + * spells. Orchestrators like flow-execute carry no platform (and a flow can span + * several devices), so the child's device arg is the only correct source; the + * parent's platform is the fallback when the child has none. A replayed + * `stop-all-simulator-servers` step is the `devices` case, and it resolves here + * rather than falling back. */ function deriveChildInvocationMeta(parentMeta: InvocationMeta, childArgs: unknown): InvocationMeta { const childPlatform = platformFromArgs(childArgs); @@ -737,11 +757,17 @@ export function createHttpApp(registry: Registry, options?: HttpAppOptions): Htt // Cross-platform tools double-check inside their dispatch helper, so // non-HTTP callers (run-sequence, flow-run) are also covered. // - // Tools spell the device parameter two ways — `udid` (legacy iOS-only - // tools and gestures) and `device_id` (debugger / profiler / network - // tools). Honour both so an Android serial reaching an iOS-only - // device_id-tool is rejected at the gate instead of falling through - // to the deeper blueprint error (which surfaces as a generic 500). + // Tools spell the device parameter three ways — `udid` (legacy iOS-only + // tools and gestures), `device_id` (debugger / profiler / network tools), + // and `devices` (only `stop-all-simulator-servers`' scoped teardown). + // `extractDeviceArg` honours all three so an Android serial reaching an + // iOS-only device_id-tool is rejected at the gate instead of falling + // through to the deeper blueprint error (which surfaces as a generic 500). + // Only the first two ever reach THIS gate — the `devices` tool declares + // no capability. That is a fact about the gate alone: telemetry reads + // `devices` today through two ungated consumers (see `extractDeviceArg`). + // The third spelling is honoured here so it behaves like the others the + // day a capability-bearing tool takes a device list. const deviceArg = extractDeviceArg(parsedData); if (def.capability && deviceArg) { try { diff --git a/packages/tool-server/src/tools/debugger/debugger-connect.ts b/packages/tool-server/src/tools/debugger/debugger-connect.ts index 19e281c5d..a7ab76811 100644 --- a/packages/tool-server/src/tools/debugger/debugger-connect.ts +++ b/packages/tool-server/src/tools/debugger/debugger-connect.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; import { DEBUGGER_TOOL_CAPABILITY, debuggerServiceRef } from "./debugger-service-ref"; +import { takeReapedSession } from "../../utils/reaped-sessions"; const zodSchema = z.object({ port: z.coerce @@ -44,8 +45,25 @@ Use when starting a debug session or before calling other debugger-* tools. Fail services: (params) => ({ debugger: debuggerServiceRef(params), }), - async execute(services) { + async execute(services, params) { const api = services.debugger as JsRuntimeDebuggerApi; + // Drop any teardown breadcrumb for this device, the way the screen-recording + // and native-profiler starts drop theirs. Its only consumer, + // `debugger-log-registry`, is gated on an EMPTY registry, so one left here + // survives every read that finds entries — and then attaches "a teardown ate + // your logs" to some later, unrelated empty read, which the tool description + // tells the agent to trust. An explicit connect makes it wrong anyway: from + // here the capture is this session's, so an empty registry honestly means + // this app has logged nothing since. + // + // Not in the blueprint's factory: that runs for an IMPLICIT resolve too — + // `debugger-log-registry` reconnects through it — and clearing there would + // consume the breadcrumb one line before the read that exists to report it. + for (const id of new Set( + [params.device_id, api.logicalDeviceId].filter((v): v is string => v !== undefined) + )) { + takeReapedSession("js-runtime-debugger", id); + } return { port: api.port, projectRoot: api.projectRoot, diff --git a/packages/tool-server/src/tools/debugger/debugger-log-registry.ts b/packages/tool-server/src/tools/debugger/debugger-log-registry.ts index 38117c88b..e987ab918 100644 --- a/packages/tool-server/src/tools/debugger/debugger-log-registry.ts +++ b/packages/tool-server/src/tools/debugger/debugger-log-registry.ts @@ -3,12 +3,22 @@ import type { ToolDefinition } from "@argent/registry"; import type { JsRuntimeDebuggerApi } from "../../blueprints/js-runtime-debugger"; import type { LogStats, MessageCluster } from "../../utils/debugger/log-file-writer"; import { DEBUGGER_TOOL_CAPABILITY, debuggerServiceRef } from "./debugger-service-ref"; +import { canonicalDeviceId } from "../../utils/debugger/device-alias"; +import { describeReapedSession, takeReapedSession } from "../../utils/reaped-sessions"; interface LogRegistryResponse extends LogStats { clusters: MessageCluster[]; deviceName: string; appName: string; logicalDeviceId: string | undefined; + /** + * Why this registry is empty when it should not be — present only when the + * previous debugger session for this device was torn down by a + * `stop-all-simulator-servers` with console history captured. Without it an + * empty registry reads as "the app logged nothing", which is the wrong + * conclusion to hand an agent debugging a silent app. + */ + note?: string; } const zodSchema = z.object({ @@ -32,23 +42,56 @@ export const debuggerLogRegistryTool: ToolDefinition< }, description: `Get a summary of all console logs captured from the app's JS runtime. Returns the log file path, entry counts by level, and message clusters (grouped by similarity). Works against Hermes (iOS / Android / Vega) and V8 (Chromium). -Use when investigating warnings, errors, or unexpected output — call this first for an overview, then read the returned file for details. Returns empty stats if no log data has been captured yet.`, +Use when investigating warnings, errors, or unexpected output — call this first for an overview, then read the returned file for details. Returns empty stats if no log data has been captured yet — but check { note }, which is present only when the stats are empty BECAUSE a stop-all-simulator-servers tore the previous debugger session down and deleted its log file. Absent that note, empty really does mean the app has logged nothing.`, zodSchema, capability: DEBUGGER_TOOL_CAPABILITY, services: (params) => ({ debugger: debuggerServiceRef(params), }), - async execute(services) { + async execute(services, params) { const api = services.debugger as JsRuntimeDebuggerApi; const stats = api.logWriter.getStats(); const clusters = api.logWriter.getClusters(20); - return { + const response: LogRegistryResponse = { ...stats, clusters, deviceName: api.deviceName, appName: api.appName, logicalDeviceId: api.logicalDeviceId, }; + + // Resolving the service above silently RECONNECTED if a teardown had reaped + // the previous session, so an empty registry here is ambiguous: either the + // app has logged nothing, or a `stop-all-simulator-servers` deleted the log + // file. Only the empty case is ambiguous — a registry with entries in it is + // reporting this session's own capture, and consuming a breadcrumb there + // would attach a stale explanation to a healthy result. + if (stats.totalEntries === 0) { + // Every id this device answers to, and all of them unconditionally — NOT + // `a ?? b`. The disposer writes ONE event under two keys (the id the + // caller connected with and the `logicalDeviceId` Metro echoed) so either + // spelling can read it back. Short-circuiting consumed only the key that + // matched and left the other behind, where it would attach a stale + // explanation to a later, unrelated empty read — against the report-once + // invariant the breadcrumb store states. `forgetDeviceAlias` runs in that + // same dispose, so by the time this read happens the alias no longer + // joins the two: the logical id has to come from the freshly resolved + // api, which is the only thing that still knows it. + const aliases = [ + canonicalDeviceId(params.device_id), + params.device_id, + api.logicalDeviceId, + ].filter((id): id is string => id !== undefined); + let reaped: ReturnType; + for (const id of new Set(aliases)) { + // Take FIRST, keep second: `reaped ??= take(...)` would short-circuit + // once one matched and leave the rest behind — the very bug above. + const entry = takeReapedSession("js-runtime-debugger", id); + reaped ??= entry; + } + if (reaped) response.note = describeReapedSession(reaped, "JS-runtime debugger session"); + } + return response; }, }; diff --git a/packages/tool-server/src/tools/flows/flow-add-step.ts b/packages/tool-server/src/tools/flows/flow-add-step.ts index fe2ac948e..b83e0db05 100644 --- a/packages/tool-server/src/tools/flows/flow-add-step.ts +++ b/packages/tool-server/src/tools/flows/flow-add-step.ts @@ -3,9 +3,8 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { FAILURE_CODES, FailureError, type Registry, type ToolDefinition } from "@argent/registry"; import { - getActiveFlow, - getRecordingSession, - appendStepToActiveFlow, + requireRecordingSession, + appendStepToFlow, parseFlow, assertSafeFlowName, classifyOnDiskSpelling, @@ -29,7 +28,15 @@ import { } from "../../utils/ui-tree-match"; const zodSchema = z.object({ - command: z.string().describe('MCP tool name (e.g. "tap", "screenshot", "launch-app")'), + name: z + .string() + .describe("Name of the flow being recorded — the one passed to flow-start-recording."), + project_root: z + .string() + .describe( + "Absolute path to the project root of the flow being recorded — the same value passed to flow-start-recording. Together with `name` it identifies which recording this step belongs to." + ), + command: z.string().describe('MCP tool name (e.g. "gesture-tap", "screenshot", "launch-app")'), args: z .string() .optional() @@ -209,7 +216,8 @@ async function rewriteSiblingFlowPath( // anchors a relative root at the tool SERVER's cwd, which bears no relation // to the calling agent's, so a relative root would pass or fail by accident // of where the server was started. flow-execute itself demands an absolute - // root (setActiveProjectRoot), so this refuses nothing that could have run. + // root (`assertValidProjectRoot`, called by `resolveFlowSource` before either + // of its branches), so this refuses nothing that could have run. const projectRoot = args.project_root; if (typeof projectRoot !== "string" || !path.isAbsolute(projectRoot)) { throw invalid( @@ -302,22 +310,23 @@ async function rewriteSiblingFlowPath( * actually ran, carrying the caller's own project_root. */ async function captureRunTarget( - session: RecordingSession | null, + session: RecordingSession, args: Record ): Promise<{ flow?: string; warning?: string }> { const name = typeof args.name === "string" ? args.name : undefined; if (name === undefined) { return { warning: "flow-execute call had no flow name; kept the raw step" }; } - if (!session || session.persist !== "host") { + if (session.persist !== "host") { return { warning: `kept the raw flow-execute step — run: composition is host-resolved, so a remote recording can't reference "${name}" portably`, }; } try { assertSafeFlowName(name); - // Resolve against the recording's own flows dir (the running flow-execute - // may have mutated the active-project-root global), not getFlowsDir() — + // Resolve against THIS recording's own flows dir, not the project root the + // nested flow-execute ran under: `run:` composes siblings of the flow being + // recorded, which is not necessarily the project that nested call ran in — // and against the recording's REAL file, because the runner resolves the // recorded `run:` against the canonical containing-file directory // (scopeFlowDir in flow-run.ts). When the recording is itself a symlink, @@ -435,20 +444,21 @@ export function createFlowAddStepTool( return { id: "flow-add-step", interaction: { - startedMsg: ({ params }) => `Adding ${params.command} step to recorded flow`, - completedMsg: ({ params }) => `Added ${params.command} step to recorded flow`, + // Name the flow: recordings are concurrent, so several of these lines can + // interleave in one log and "the recorded flow" would not identify which. + startedMsg: ({ params }) => `Adding ${params.command} step to flow ${params.name}`, + completedMsg: ({ params }) => `Added ${params.command} step to flow ${params.name}`, failedMsg: ({ params, failureSignal }) => - `Failed to add ${params.command} step to recorded flow: ${failureSignal.error_code}`, + `Failed to add ${params.command} step to flow ${params.name}: ${failureSignal.error_code}`, }, - description: `Execute a tool call and record it as a step in the active flow. Use when recording a flow with flow-start-recording and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow; restart-app has no chromium support, so a chromium flow records as a fragment — add the \`launch: { chromium: }\` line to the YAML afterward, deleting the executionPrerequisite line if one was recorded: a flow that starts with a launch must not declare it). Returns { message, toolResult, flowFile } on success. If it fails an error is returned and nothing is recorded. + description: `Execute a tool call and record it as a step in the flow named by \`name\` + \`project_root\` (the recording must already be open — see flow-start-recording). Use when recording a flow and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow; restart-app has no chromium support, so a chromium flow records as a fragment — add the \`launch: { chromium: }\` line to the YAML afterward, deleting the executionPrerequisite line if one was recorded: a flow that starts with a launch must not declare it). Returns { message, toolResult, flowFile, savedTo } on success. If it fails an error is returned and nothing is recorded. If a step was recorded by mistake, edit the .yaml file directly to remove it.`, zodSchema, services: () => ({}), async execute(_services, params, ctx) { - const flowName = getActiveFlow(); + const session = await requireRecordingSession(params.project_root, params.name); const args: Record = params.args ? JSON.parse(params.args) : {}; - const session = getRecordingSession(); // A nested flow-execute must never carry a raw flow_path into the live // invoke — it has no boundary metadata there and would be rejected. if (params.command === RUN_TARGET_COMMAND) await rewriteSiblingFlowPath(session, args); @@ -530,10 +540,10 @@ If a step was recorded by mistake, edit the .yaml file directly to remove it.`, }; } - const { flowFile, savedTo } = await appendStepToActiveFlow(step); + const { flowFile, savedTo } = await appendStepToFlow(session, step); return { - message: `Step added to "${flowName}" flow${warning ? ` — ${warning}` : ""}`, + message: `Step added to "${params.name}" flow${warning ? ` — ${warning}` : ""}`, toolResult, flowFile, savedTo, diff --git a/packages/tool-server/src/tools/flows/flow-device.ts b/packages/tool-server/src/tools/flows/flow-device.ts index 50a732fb9..22a2d1da4 100644 --- a/packages/tool-server/src/tools/flows/flow-device.ts +++ b/packages/tool-server/src/tools/flows/flow-device.ts @@ -35,12 +35,43 @@ export type FlowPlatform = WhenPlatform; const DEVICE_BIND_KEYS = ["udid", "device_id", "device"] as const; /** - * Keys that mean a tool acts on a device. A superset of the keys the runner - * injects: `device` names one without receiving the run's own (a nested flow - * takes it that way), and a step that drives a device must count as needing one - * even when the runner does not hand it over. + * Args keys holding a LIST of device ids. Same treatment as + * {@link DEVICE_BIND_KEYS} — stripped at record time, re-injected at replay — + * but rebound to `[deviceId]`, since the runner resolves exactly one device per + * run and a flow that named several would be naming the recording host's. + * + * `stop-all-simulator-servers`' `devices` is the only such key. It is a scope + * rather than a target, and that difference decides when it is rebound. A + * recording of the UNSCOPED sweep always replays as a stop of the run device: + * the replayed artifact must not tear down devices another agent is mid-session + * on, which is the hazard the `devices` scope was added for, and binding can + * only narrow there. A recorded scope, on the other hand, is the flow's own + * statement of what to reap, and is overridden only by an explicit `device` — + * see {@link bindDeviceArgs}, which is where the two cases part. + */ +const DEVICE_BIND_LIST_KEYS = ["devices"] as const; + +/** + * Keys that mean a tool needs a device to act on at all — the TARGET keys, and + * deliberately not the scope keys in {@link DEVICE_BIND_LIST_KEYS}. + * + * `toolRequiresDevice` consults this, and `resolveRunDevice` skips resolving a + * device for a flow no step here matches. The distinction is what a missing + * device does to the step: a `screenshot` with no `udid` has nothing to point + * at and cannot run, while `stop-all-simulator-servers` with no `devices` is + * the machine-wide sweep — a complete, meaningful call, and the whole content + * of a cleanup flow. Listing `devices` here made such a flow demand a device it + * has no use for, so the two situations a cleanup flow actually runs in — none + * booted, or several — failed it outright. + * + * A scope key is therefore bound OPPORTUNISTICALLY: {@link bindDeviceArgs} + * injects it when the run resolved a device (so a replayed teardown cannot reap + * devices another agent is mid-session on) and leaves it off when the run has + * none, rather than binding the empty string — `{ devices: [""] }` would be a + * teardown scoped to an id that owns nothing, reaping nothing while reporting + * pass, which is the failure {@link DEVICE_BIND_LIST_KEYS} exists to prevent. */ -const DEVICE_ARG_KEYS = [...DEVICE_BIND_KEYS, "device"] as const; +const DEVICE_ARG_KEYS = DEVICE_BIND_KEYS; interface RawDevice { platform: FlowPlatform; @@ -120,11 +151,25 @@ export async function resolveFlowDevice( } /** - * Strip the device-id keys from a set of args (so a flow stores none). + * Strip the device-TARGET keys from a set of args (so a recorded flow stores no + * device to point at). Scope keys are deliberately kept — see below. * * Schema-blind on purpose: `bindDeviceArgs` strips unconditionally and re-injects * only what the target tool declares, so a stale id is never forwarded to a tool * that does not want it. + * + * A SCOPE survives into the YAML because dropping it changes what the recorded + * step MEANS. `stop-all-simulator-servers` with no `devices` is the machine-wide + * sweep, so a correctly scoped teardown would record as a bare + * `- tool: stop-all-simulator-servers` — and the YAML is the artifact that gets + * committed, read, and (per the create-flow skill's manual-execution strategy) + * hand-run a step at a time. Replay rebinds it either way, but hand-running that + * bare step reaps every device on the machine, which is the cross-agent teardown + * the scope exists to prevent. The contrast is the point: a recorded `screenshot` + * loses its `udid` too, and hand-running it fails loudly because `udid` is + * required. Losing `devices` fails OPEN. The recorded ids are host-specific, but + * that costs only a no-op plus an `unmatched` report on another machine — the + * safe direction, and a legible one. */ export function stripDeviceKeys(args: Record): Record { const out = { ...args }; @@ -134,11 +179,15 @@ export function stripDeviceKeys(args: Record): Record stepRequiresDevice(registry, step)); } +/** + * Whether any step would NARROW itself to the run device if one were resolved, + * without needing one to run — a `devices` scope, and only that today. + * + * Asked of a flow that {@link flowRequiresDevice} said no to, so the run has a + * choice: resolve a device opportunistically and scope the teardown to it + * (keeping the cross-agent protection the scope exists for), or, where no + * single device is resolvable, run the step's unscoped meaning rather than + * failing a flow whose whole purpose is to clear the machine. + */ +export function flowScopesDevice(registry: Registry, steps: FlowStep[]): boolean { + return steps.some( + (step) => step.kind === "tool" && declaresAny(registry, step.name, DEVICE_BIND_LIST_KEYS) + ); +} + function toolRequiresDevice(registry: Registry, toolName: string): boolean { - const toolDef = registry.getTool(toolName); // An unknown tool is assumed to need a device: the step is going to fail // either way, and it fails more usefully with one resolved. - if (!toolDef) return true; - const props = (toolDef.inputSchema as { properties?: Record } | undefined) + if (!registry.getTool(toolName)) return true; + return declaresAny(registry, toolName, DEVICE_ARG_KEYS); +} + +function declaresAny(registry: Registry, toolName: string, keys: readonly string[]): boolean { + const toolDef = registry.getTool(toolName); + const props = (toolDef?.inputSchema as { properties?: Record } | undefined) ?.properties; // A tool with no declared input takes no device. if (!props) return false; - return DEVICE_ARG_KEYS.some((k) => k in props); + return keys.some((k) => k in props); } export function bindDeviceArgs( registry: Registry, toolName: string, deviceId: string, - args: Record + args: Record, + deviceIsExplicit = false ): Record { const toolDef = registry.getTool(toolName); const props = (toolDef?.inputSchema as { properties?: Record } | undefined) ?.properties; const out = stripDeviceKeys(args); - if (props) { - for (const k of DEVICE_BIND_KEYS) if (k in props) out[k] = deviceId; + for (const k of DEVICE_BIND_LIST_KEYS) { + // Never forward a scope to a tool that does not declare it — a `.strict()` + // schema would reject the whole call. + if (!props || !(k in props)) { + delete out[k]; + continue; + } + // `[""]` is never bound: an id that owns nothing reaps nothing and still + // reports pass. + if (!deviceId) continue; + // With NO recorded scope the run device NARROWS what the step would + // otherwise do — an unscoped `stop-all-simulator-servers` is the + // machine-wide sweep — so bind it whatever resolved it. Strictly the safe + // direction, and the reason a device is resolved for a cleanup flow at all. + if (out[k] === undefined) { + out[k] = [deviceId]; + continue; + } + // With one recorded, OVERRIDING it is destructive rather than portable: a + // flow that named device A would tear down whichever device happened to + // resolve, which is precisely the cross-agent teardown the `devices` scope + // was added to prevent. Only an explicit `device` overrides — there the + // caller named the run target itself, so retargeting the teardown at it is + // what they asked for. An auto-resolved device names nobody's intent, so + // the recorded ids stand: on another host they reap nothing and come back + // in `unmatched`, which is the safe direction and a legible one. + if (deviceIsExplicit) out[k] = [deviceId]; } + if (props) for (const k of DEVICE_BIND_KEYS) if (k in props) out[k] = deviceId; return out; } diff --git a/packages/tool-server/src/tools/flows/flow-finish-recording.ts b/packages/tool-server/src/tools/flows/flow-finish-recording.ts index f1ad5c1ff..520ae9fa5 100644 --- a/packages/tool-server/src/tools/flows/flow-finish-recording.ts +++ b/packages/tool-server/src/tools/flows/flow-finish-recording.ts @@ -2,14 +2,14 @@ import { z } from "zod"; import * as fs from "node:fs/promises"; import type { ToolDefinition } from "@argent/registry"; import { - getFlowPath, - getActiveFlow, - getRecordingSession, - clearActiveFlow, + requireRecordingSession, + clearRecordingSession, + withFlowFileLock, clientFileDirective, parseFlow, serializeFlow, selectorToYaml, + type FlowFile, type FlowSavedTo, type FlowSelector, } from "./flow-utils"; @@ -41,7 +41,16 @@ function textConditionLabel( : `text ${selector} contains ${JSON.stringify(expected)}`; } -const zodSchema = z.object({}); +const zodSchema = z.object({ + name: z + .string() + .describe("Name of the flow being recorded — the one passed to flow-start-recording."), + project_root: z + .string() + .describe( + "Absolute path to the project root of the flow being recorded — the same value passed to flow-start-recording. Together with `name` it identifies which recording to finish." + ), +}); export const flowFinishRecordingTool: ToolDefinition< z.infer, @@ -57,100 +66,66 @@ export const flowFinishRecordingTool: ToolDefinition< > = { id: "flow-finish-recording", interaction: { - startedMsg: () => "Finishing flow recording", - completedMsg: ({ result }) => { - const flowName = - result.path - .split(/[\\/]/) - .pop() - ?.replace(/\.ya?ml$/, "") ?? "flow"; - return `Saved recorded flow ${flowName}`; - }, - failedMsg: ({ failureSignal }) => - `Failed to finish flow recording: ${failureSignal.error_code}`, + // Name the flow: other recordings stay live across this call, so an + // unqualified "Finishing flow recording" would not identify which one. + startedMsg: ({ params }) => `Finishing recording of flow ${params.name}`, + // `params.name` rather than the basename of `result.path`: the two are the + // same string on every branch — `assertSafeFlowName` admits no dots or + // separators, so `getFlowPath` produces `.yaml` and nothing else — + // and this spelling matches the two formatters either side of it. + completedMsg: ({ params }) => `Saved recorded flow ${params.name}`, + failedMsg: ({ params, failureSignal }) => + `Failed to finish recording of flow ${params.name}: ${failureSignal.error_code}`, }, - description: `Finish recording the active flow. Returns a summary of all recorded steps and the final YAML content. Use when you have added all desired steps and want to finalize the flow file. Fails if no active flow recording is in progress. + description: `Finish recording the flow named by \`name\` + \`project_root\`, leaving recordings under any other key untouched. Returns { message, path, executionPrerequisite, steps, summary, flowFile, savedTo } - a summary of all recorded steps plus the final YAML. Use when you have added all desired steps and want to finalize the flow file. Fails if that flow has no recording in progress. You can still edit the .yaml file directly afterwards to remove or reorder steps.`, zodSchema, services: () => ({}), - async execute(_services, _params) { - const flowName = getActiveFlow(); - const session = getRecordingSession(); + async execute(_services, params) { + // Resolve, read and clear as ONE critical section under the flow-file lock. + // Host mode's `await fs.readFile` is a yield, and an append that lands in it + // would be on disk while the summary and step count reported here — taken + // from the pre-append read — say otherwise. + const { filePath, flowFile, savedTo, flow, summary } = await withFlowFileLock( + params.project_root, + params.name, + async () => { + const session = await requireRecordingSession(params.project_root, params.name); - // Host mode re-reads the file so manual edits made during the recording - // survive into the summary; in client mode this host never has the file, - // so the in-memory copy is the truth and travels back in the directive. - const filePath = session?.filePath ?? getFlowPath(flowName); - let flowFile: string; - let savedTo: FlowSavedTo; - if (session?.persist === "client") { - flowFile = serializeFlow(session.flow); - savedTo = clientFileDirective(filePath, flowFile); - } else { - flowFile = await fs.readFile(filePath, "utf8"); - savedTo = filePath; - } - const flow = parseFlow(flowFile); - - const summary = flow.steps.map((step, i) => { - const n = i + 1; - switch (step.kind) { - case "echo": - return `${n}. echo: ${step.message}`; - case "launch": - return `${n}. launch: ${typeof step.app === "string" ? step.app : JSON.stringify(step.app)}`; - case "run": - return `${n}. run: ${step.flow}`; - case "tap": - case "long-press": - return `${n}. ${step.kind}: ${step.selector ? selectorLabel(step.selector) : `(${step.x}, ${step.y})`}`; - case "type": - return `${n}. type: ${selectorLabel(step.into)} ← "${step.text}"`; - case "await": - case "assert": { - const tail = - step.condition === "text" - ? textConditionLabel(step.selector, step.expectedText, step.textMatch) - : `${step.condition} ${selectorLabel(step.selector)}`; - return `${n}. ${step.kind}: ${tail}`; - } - case "wait": - return `${n}. wait: ${step.ms}ms`; - case "when": { - // Mirror the await/assert rendering above — selectorLabel spelling, - // same comparator tail for text guards. - const cond = - step.condition.kind === "platform" - ? `platform ${step.condition.platform}` - : step.condition.condition === "text" - ? textConditionLabel( - step.condition.selector, - step.condition.expectedText, - step.condition.textMatch - ) - : `${step.condition.condition} ${selectorLabel(step.condition.selector)}`; - // Pluralize like flow-run's skip reason so the two surfaces agree. - const count = step.steps.length; - return `${n}. when: ${cond} (${count} step${count === 1 ? "" : "s"})`; + // Host mode re-reads the file so manual edits made during the recording + // survive into the summary; in client mode this host never has the file, + // so the in-memory copy is the truth and travels back in the directive. + const filePath = session.filePath; + let flowFile: string; + let savedTo: FlowSavedTo; + if (session.persist === "client") { + flowFile = serializeFlow(session.flow); + savedTo = clientFileDirective(filePath, flowFile); + } else { + flowFile = await fs.readFile(filePath, "utf8"); + savedTo = filePath; } - case "scroll-to": - return `${n}. scroll-to: ${selectorLabel(step.target)} (${step.direction})`; - case "pinch": - return `${n}. pinch: scale ${step.scale}${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`; - case "rotate": - return `${n}. rotate: by ${step.by}°${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`; - case "snapshot": - return `${n}. snapshot: ${step.name}`; - case "tool": - default: - return `${n}. tool: ${step.name} ${JSON.stringify(step.args)}`; + // Parse BEFORE clearing. Hand-editing the .yaml mid-recording is a + // documented workflow, so parseFlow can legitimately throw here on a + // botched edit — and clearing first would destroy the session on the + // way out, leaving the agent unable to retry the finish after repairing + // the file (the only tool that re-establishes the key, + // flow-start-recording, truncates the take it would be recovering). + const flow = parseFlow(flowFile); + // Render the summary before clearing too, for the same reason: it walks + // step bodies the parser does not fully constrain, and nothing that can + // throw may run after the session is destroyed. The one known thrower + // there — `JSON.stringify` on a cyclic `args` anchor — is guarded in + // {@link renderToolArgs}; keeping the order is what makes the next one + // recoverable rather than fatal. + const summary = summarizeSteps(flow); + clearRecordingSession(session); + return { filePath, flowFile, savedTo, flow, summary }; } - }); - - clearActiveFlow(); + ); return { - message: `Finished recording "${flowName}" flow (${flow.steps.length} steps)`, + message: `Finished recording "${params.name}" flow (${flow.steps.length} steps)`, path: filePath, executionPrerequisite: flow.executionPrerequisite, steps: flow.steps.length, @@ -160,3 +135,88 @@ You can still edit the .yaml file directly afterwards to remove or reorder steps }; }, }; + +/** + * A `tool:` step's `args` is the one step body the parser does not constrain, so + * a cyclic YAML alias in a hand-edited file reaches here as a cyclic object and + * `JSON.stringify` throws on it. Fall back to a marker, the way `parseFlow` + * already does for the same input class (see `badEntry` in flow-utils) — the + * summary of a recording that is otherwise fine should not fail on one + * unrenderable step. + * + * The body interpolates rather than returning `JSON.stringify(args)` directly, + * because `JSON.stringify(undefined)` is the VALUE `undefined`, not a string, + * and would leave through a `string`-typed signature uncaught (TypeScript does + * not flag it — `JSON.stringify`'s overload is declared to return `string`). + * No reachable input is undefined today: every caller comes through + * {@link summarizeSteps}, which is only ever handed `parseFlow` output, and + * `fromYamlStep` normalises a missing/`null` `args:` to `{}` on the way + * through. It is the `default:` arm of that switch this guards — a step kind + * added without its own `case` lands there and is rendered as a `tool:` step, + * with no `args` field to read. + */ +function renderToolArgs(args: unknown): string { + try { + return `${JSON.stringify(args)}`; + } catch { + return "[cyclic args]"; + } +} + +/** One human-readable line per recorded step, in the flow file's own spellings. */ +function summarizeSteps(flow: FlowFile): string[] { + return flow.steps.map((step, i) => { + const n = i + 1; + switch (step.kind) { + case "echo": + return `${n}. echo: ${step.message}`; + case "launch": + return `${n}. launch: ${typeof step.app === "string" ? step.app : JSON.stringify(step.app)}`; + case "run": + return `${n}. run: ${step.flow}`; + case "tap": + case "long-press": + return `${n}. ${step.kind}: ${step.selector ? selectorLabel(step.selector) : `(${step.x}, ${step.y})`}`; + case "type": + return `${n}. type: ${selectorLabel(step.into)} ← "${step.text}"`; + case "await": + case "assert": { + const tail = + step.condition === "text" + ? textConditionLabel(step.selector, step.expectedText, step.textMatch) + : `${step.condition} ${selectorLabel(step.selector)}`; + return `${n}. ${step.kind}: ${tail}`; + } + case "wait": + return `${n}. wait: ${step.ms}ms`; + case "when": { + // Mirror the await/assert rendering above — selectorLabel spelling, + // same comparator tail for text guards. + const cond = + step.condition.kind === "platform" + ? `platform ${step.condition.platform}` + : step.condition.condition === "text" + ? textConditionLabel( + step.condition.selector, + step.condition.expectedText, + step.condition.textMatch + ) + : `${step.condition.condition} ${selectorLabel(step.condition.selector)}`; + // Pluralize like flow-run's skip reason so the two surfaces agree. + const count = step.steps.length; + return `${n}. when: ${cond} (${count} step${count === 1 ? "" : "s"})`; + } + case "scroll-to": + return `${n}. scroll-to: ${selectorLabel(step.target)} (${step.direction})`; + case "pinch": + return `${n}. pinch: scale ${step.scale}${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`; + case "rotate": + return `${n}. rotate: by ${step.by}°${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`; + case "snapshot": + return `${n}. snapshot: ${step.name}`; + case "tool": + default: + return `${n}. tool: ${step.name} ${renderToolArgs(step.args)}`; + } + }); +} diff --git a/packages/tool-server/src/tools/flows/flow-insert-echo.ts b/packages/tool-server/src/tools/flows/flow-insert-echo.ts index cf773e586..4aec50dec 100644 --- a/packages/tool-server/src/tools/flows/flow-insert-echo.ts +++ b/packages/tool-server/src/tools/flows/flow-insert-echo.ts @@ -1,8 +1,16 @@ import { z } from "zod"; import type { ToolDefinition } from "@argent/registry"; -import { getActiveFlow, appendStepToActiveFlow, type FlowSavedTo } from "./flow-utils"; +import { requireRecordingSession, appendStepToFlow, type FlowSavedTo } from "./flow-utils"; const zodSchema = z.object({ + name: z + .string() + .describe("Name of the flow being recorded — the one passed to flow-start-recording."), + project_root: z + .string() + .describe( + "Absolute path to the project root of the flow being recorded — the same value passed to flow-start-recording. Together with `name` it identifies which recording this echo belongs to." + ), message: z.string().describe("Message to echo when the flow is replayed"), }); @@ -12,26 +20,28 @@ export const flowInsertEchoTool: ToolDefinition< > = { id: "flow-add-echo", interaction: { - startedMsg: () => "Adding note to recorded flow", - completedMsg: () => "Added note to recorded flow", - failedMsg: ({ failureSignal }) => - `Failed to add note to recorded flow: ${failureSignal.error_code}`, + // Name the flow: recordings are concurrent, so several of these lines can + // interleave in one log and "the recorded flow" would not identify which. + startedMsg: ({ params }) => `Adding note to flow ${params.name}`, + completedMsg: ({ params }) => `Added note to flow ${params.name}`, + failedMsg: ({ params, failureSignal }) => + `Failed to add note to flow ${params.name}: ${failureSignal.error_code}`, }, - description: `Record an echo step in the active flow. Echo steps print a message when the flow is replayed — useful as labels between tool calls. + description: `Record an echo step in the flow named by \`name\` + \`project_root\`. Echo steps print a message when the flow is replayed — useful as labels between tool calls. Use when you want to annotate a recorded flow with a human-readable label or checkpoint message. -Returns { message, flowFile }. Fails if no active flow recording is in progress.`, +Returns { message, flowFile, savedTo }. Fails if that flow has no recording in progress.`, zodSchema, services: () => ({}), async execute(_services, params) { - const flowName = getActiveFlow(); + const session = await requireRecordingSession(params.project_root, params.name); - const { flowFile, savedTo } = await appendStepToActiveFlow({ + const { flowFile, savedTo } = await appendStepToFlow(session, { kind: "echo", message: params.message, }); return { - message: `Echo added to "${flowName}" flow`, + message: `Echo added to "${params.name}" flow`, flowFile, savedTo, }; diff --git a/packages/tool-server/src/tools/flows/flow-run.ts b/packages/tool-server/src/tools/flows/flow-run.ts index 688fbeafe..6e10c88f0 100644 --- a/packages/tool-server/src/tools/flows/flow-run.ts +++ b/packages/tool-server/src/tools/flows/flow-run.ts @@ -22,6 +22,7 @@ import type { import { appIdForPlatform, assertSafeFlowName, + assertValidProjectRoot, chromiumLaunchSpec, classifyOnDiskSpelling, describeSelector, @@ -29,7 +30,6 @@ import { getFlowPath, parseFlow, runTargetName, - setActiveProjectRoot, type FlowFile, type FlowSelector, type FlowStep, @@ -46,6 +46,7 @@ import { resolveFlowDevice, bindDeviceArgs, flowRequiresDevice, + flowScopesDevice, stepRequiresDevice, type FlowPlatform, } from "./flow-device"; @@ -102,12 +103,14 @@ const zodSchema = z .string() .optional() .describe( - "Device id to run against (iOS UDID, Android/Vega serial, Chromium id). Auto-detected when omitted." + "Device id to run against (iOS UDID, Android/Vega serial, Chromium id) — the id list-devices reports. Auto-detected when omitted, but only when exactly one booted device matches (optionally narrowed by `platform`); with several booted the run fails and lists them, so pass this explicitly whenever more than one device is up." ), platform: z .enum(LAUNCH_PLATFORMS) .optional() - .describe("Restrict auto-detection to this platform when several devices are booted."), + .describe( + "Restrict auto-detection to this platform when several devices are booted. `chromium` does more than filter: with no `device` it SELECTS the self-boot branch for an e2e flow - the runner boots an Electron instance from the `launch` step's chromium value and tears it down after the run (a single-key `launch: { chromium: … }` map selects it on its own, without this parameter). When it selects that branch it never falls back to device auto-detection (a fragment, or an e2e launch map with no `chromium` key, still does), and the launch value must be a real Electron app path on the tool-server host: a bare-string `launch:` - what the recorder writes - holds an installed-app bundle id, so passing `chromium` for one fails the whole run with `Electron boot: path does not exist`. Edit the launch to `{ chromium: }` first." + ), updateBaselines: z .boolean() .optional() @@ -729,6 +732,12 @@ function noChromiumAppReason(device: DeviceInfo): string { // (via `deviceEnv`) and the compiler can find the ones that don't. interface ExecState extends Omit { device: DeviceInfo | null; + /** + * Whether {@link device} is the one the CALLER named, rather than one + * auto-detected from what happens to be booted. Only a named device may + * override a scope a recording already carries — see {@link bindDeviceArgs}. + */ + deviceIsExplicit: boolean; /** * The ROOT flow file's canonical (realpath'd) directory — the anchor for * snapshot baselines and a chromium launch's relative app path, so a @@ -1054,6 +1063,7 @@ returns a notice with the prerequisite instead of running.`, registry, ctx, device, + deviceIsExplicit: Boolean(params.device), signal, flowsDir, viaUpload, @@ -1148,16 +1158,40 @@ async function resolveRunDevice( // Checked after the chromium boot path, which only applies to a flow led by // a `launch` step — and a launch needs a device, so the two never compete. if (!flowRequiresDevice(registry, flow.steps)) { - return { device: null, booted: null }; + if (!flowScopesDevice(registry, flow.steps)) return { device: null, booted: null }; + // A flow that only SCOPES to a device (a cleanup flow) takes one when one + // is unambiguous, so the teardown stays narrowed to the run device and + // cannot reap what another agent is mid-session on. When resolution has + // no single answer — nothing booted, or several — that is not a question + // a sweep has an answer to, so run it unscoped rather than failing the + // flow. Swallowed only here: every other caller genuinely needs the + // device, and the diagnosis in the error is the useful thing there. + // + // And swallowed only for THAT answer. `resolveFlowDevice` also reaches + // `list-devices` through the registry, so a bare catch also absorbed an + // adb/simctl failure, a dead sub-tool, an abort — and the teardown step + // then ran unscoped and reported pass, which is the machine-wide sweep + // this whole path exists to avoid. Anything that is not the ambiguity + // rethrows and fails the run. + try { + return { + device: await resolveFlowDevice(registry, ctx, resolveOpts(params)), + booted: null, + }; + } catch (err) { + if (getFailureSignal(err)?.error_code !== FAILURE_CODES.FLOW_DEVICE_RESOLUTION) throw err; + return { device: null, booted: null }; + } } } - const device = await resolveFlowDevice(registry, ctx, { - device: params.device, - platform: params.platform as FlowPlatform | undefined, - }); + const device = await resolveFlowDevice(registry, ctx, resolveOpts(params)); return { device, booted: null }; } +function resolveOpts(params: Params): { device?: string; platform?: FlowPlatform } { + return { device: params.device, platform: params.platform as FlowPlatform | undefined }; +} + /** * The hoisted boot's failure, carrying the lock explanation when it is * lock-shaped. This is the likeliest way of all to meet the lock — the app is @@ -2113,10 +2147,22 @@ async function execLeafStep( } case "tool": { - // With no device, the step reached here only because its tool declares no - // device argument, so there is nothing to inject — binding still strips - // any device key the recorded args carried. - const args = bindDeviceArgs(registry, step.name, device?.id ?? "", step.args); + // A device-less run reaches here only for a tool declaring none of + // `DEVICE_ARG_KEYS` — a target key — so binding injects no target and + // merely strips any device key the recorded args carried. The `?? ""` is + // unreachable for those and must stay unreachable: injecting the empty + // string would not fail the step, it would silently retarget it at no + // device. A SCOPE key (`devices`) does reach here device-free, which is + // the cleanup-flow case `bindDeviceArgs` guards by keeping whatever the + // recording scoped — and, when the run device was only auto-detected, it + // keeps that even with a device resolved. + const args = bindDeviceArgs( + registry, + step.name, + device?.id ?? "", + step.args, + state.deviceIsExplicit + ); const outputHint = registry.getTool(step.name)?.outputHint; if (step.delayMs && !(await sleepOrAbort(step.delayMs, signal))) { return { ...base, status: "skip", tool: step.name, reason: "run aborted during delay" }; @@ -2193,8 +2239,13 @@ function errMsg(err: unknown): string { * name must then appear in that flow's own directory listing byte-for-byte — a * case-insensitive filesystem opens files under spellings no directory entry * carries, and the name is what keys the report and `__baselines__/` (see - * {@link classifyOnDiskSpelling}). Name and project_root are validated in every - * branch. + * {@link classifyOnDiskSpelling}). Name is validated on the branch that has one; + * project_root is validated up front, before either branch, since only the + * `name` branch would otherwise reach a check. + * + * Resolution is pure: it reads and mutates no shared state, so replaying a flow + * in one project can never rebind the paths of a recording in progress in + * another (or in the same project on another agent). */ export async function resolveFlowSource( params: { @@ -2218,7 +2269,14 @@ export async function resolveFlowSource( }); } - setActiveProjectRoot(params.project_root); + // Before either branch, so both are covered. `getFlowPath` validates the root + // on the `name` branch only, and deleting `setActiveProjectRoot` — which ran + // here, unconditionally, and whose body is today's assertValidProjectRoot — + // removed the check on the `flow_path` branch entirely, letting relative and + // ".."-bearing roots through. Nothing reads project_root on that branch + // today, so this restores a guardrail rather than fixing a live exploit; it + // is here so a future reader of it does not have to establish that. + assertValidProjectRoot(params.project_root); if (params.flow_path !== undefined) { if (flowPathInput?.viaUpload) { @@ -2390,7 +2448,7 @@ export async function resolveFlowSource( const flowName = params.name!; assertSafeFlowName(flowName); - const expected = getFlowPath(flowName); + const expected = getFlowPath(params.project_root, flowName); // A path the boundary materialized from uploaded content is a fresh temp // file this process itself created (see file-inputs.ts) — trusted as-is, and // returned ahead of the on-disk-spelling gate below deliberately: the only diff --git a/packages/tool-server/src/tools/flows/flow-start-recording.ts b/packages/tool-server/src/tools/flows/flow-start-recording.ts index 02eaed494..beaa8dc8e 100644 --- a/packages/tool-server/src/tools/flows/flow-start-recording.ts +++ b/packages/tool-server/src/tools/flows/flow-start-recording.ts @@ -1,12 +1,12 @@ import { z } from "zod"; -import * as fs from "node:fs/promises"; import type { FileInputSpec, ToolDefinition } from "@argent/registry"; import { - getFlowsDir, + countStepsOnDisk, getFlowPath, - getActiveFlowOrNull, - setActiveProjectRoot, + getRecordingSession, startRecordingSession, + withFlowFileLock, + writeNewFlowFile, clientFileDirective, serializeFlow, validateFlow, @@ -15,7 +15,11 @@ import { } from "./flow-utils"; const zodSchema = z.object({ - name: z.string().describe('Name for this flow (e.g. "settings-explore")'), + name: z + .string() + .describe( + 'Name for this flow (e.g. "settings-explore") — letters, digits, underscore and hyphen only.' + ), project_root: z .string() .describe( @@ -48,18 +52,43 @@ const fileInputs: FileInputSpec[] = [ export const flowStartRecordingTool: ToolDefinition< z.infer, - { message: string; previousFlow?: string; flowFile: string; savedTo: FlowSavedTo } + { + message: string; + restarted?: true; + discardedSteps?: number; + flowFile: string; + savedTo: FlowSavedTo; + } > = { id: "flow-start-recording", interaction: { - startedMsg: () => "Starting flow recording", - completedMsg: () => "Started flow recording", - failedMsg: ({ failureSignal }) => `Failed to start flow recording: ${failureSignal.error_code}`, + // Name the flow: recordings are concurrent, so several of these lines can + // interleave in one log and "flow recording" would not identify which. + startedMsg: ({ params }) => `Starting recording of flow ${params.name}`, + completedMsg: ({ params, result }) => { + if (!result.restarted) return `Started recording flow ${params.name}`; + // A restart destroyed a live take. Reporting that as a plain start would + // hide the discard, which is the one outcome here worth reading twice. + // `discardedSteps` is absent when the superseded file could not be read + // or parsed - 0 is the answer a genuinely empty take gives - so say it + // was discarded without claiming a count we do not have. + const discarded = result.discardedSteps; + return discarded === undefined + ? `Restarted recording flow ${params.name}, discarding the previous take` + : `Restarted recording flow ${params.name}, discarding ${discarded} ${discarded === 1 ? "step" : "steps"}`; + }, + failedMsg: ({ params, failureSignal }) => + `Failed to start recording of flow ${params.name}: ${failureSignal.error_code}`, }, - description: `Start recording a new flow. Creates a .yaml file in the .argent/flows/ directory. + description: `Start recording a new flow, resetting .argent/flows/.yaml to an empty flow and replacing any existing one. Use when you want to capture a reusable sequence of device interactions for later replay. -Returns { message, flowFile, savedTo } and optionally { previousFlow } if a prior recording was abandoned. -Fails if the .argent/flows/ directory cannot be created or the flow file cannot be written. +Returns { message, flowFile, savedTo } and optionally { restarted, discardedSteps } if a live recording of the same flow was discarded. +Whether this server writes that file depends on where your project is: co-located, it creates it and fails if the .argent/flows/ directory cannot be created or the file cannot be written; against a remote tool-server it writes nothing and \`savedTo\` is a directive your client applies (a null \`savedTo\` back means it did not). + +Several flows can be recorded at once — each keyed by the \`name\` + \`project_root\` +that every subsequent recording tool repeats — and one recording's steps never +land in another's file. Steps still run LIVE, so give each concurrent recording +its own device and pick a name unique to your task. After starting, use flow-add-step to append tool calls — each step is executed LIVE so you can verify it works before it gets recorded. For a self-contained @@ -74,10 +103,7 @@ to remove or reorder steps.`, fileInputs, services: () => ({}), async execute(_services, params, ctx) { - setActiveProjectRoot(params.project_root); - const previousFlow = getActiveFlowOrNull(); - - const filePath = getFlowPath(params.name); + const filePath = getFlowPath(params.project_root, params.name); // A recording's type emerges from its steps: recording a `restart-app` // first makes it an e2e flow (captured as a leading `launch` step by // flow-add-step); declaring an executionPrerequisite documents a fragment. @@ -93,23 +119,81 @@ to remove or reorder steps.`, const probe = ctx?.fileInputs?.project_root; const persist = probe && !probe.presentOnHost ? "client" : "host"; - let savedTo: FlowSavedTo; - if (persist === "host") { - await fs.mkdir(getFlowsDir(), { recursive: true }); - await fs.writeFile(filePath, flowFile, "utf8"); - savedTo = filePath; - } else { - savedTo = clientFileDirective(filePath, flowFile); - } - startRecordingSession(params.name, { persist, filePath, flow }); + // Truncate-and-register is one critical section. Held under the flow-file + // lock, so a step from the take being discarded can neither slip into the + // file between the reset and the swap, nor be written after both: it finds + // its session superseded and fails instead. + const { savedTo, replaced, discardedSteps } = await withFlowFileLock( + params.project_root, + params.name, + async () => { + // Read the take being discarded ONCE, here, and drive both the + // `restarted` flag and its step count off that single read. Count it + // BEFORE the truncate destroys it, and where it actually lives: on disk + // in host mode, since a hand-edit made mid-recording is part of the take + // and the session's in-memory copy only catches up on the next append + // (see {@link countStepsOnDisk}). In client mode this host has no file + // and the in-memory copy IS the take. + // + // `replaced` is this read, NOT `startRecordingSession`'s return: in host + // mode two awaits (countStepsOnDisk, writeNewFlowFile) sit between them, + // and {@link evictIfOverCapacity} runs under some OTHER key's lock, so + // it can drop this key in that window. Reading `replaced` after the + // register would then see the key already gone and report a destructive + // restart — the file is already truncated — as a plain fresh start, + // discarding the count computed here. This read is inside our own key's + // lock, so it and the count agree. + const replaced = (await getRecordingSession(params.project_root, params.name)) ?? null; + const discardedSteps = + replaced === null + ? undefined + : replaced.persist === "host" + ? await countStepsOnDisk(replaced.filePath) + : replaced.flow.steps.length; + + let savedTo: FlowSavedTo; + if (persist === "host") { + await writeNewFlowFile(filePath, flowFile); + savedTo = filePath; + } else { + savedTo = clientFileDirective(filePath, flowFile); + } + await startRecordingSession({ + name: params.name, + projectRoot: params.project_root, + persist, + filePath, + flow, + }); + return { savedTo, replaced, discardedSteps }; + } + ); - if (previousFlow && previousFlow !== params.name) { + // Only a same-key restart replaces anything — the documented "re-record it + // to fix it" workflow. Recordings are keyed per flow file, so starting a + // *different* flow abandons nothing and there is nothing to report about it. + if (replaced) { + // Only claim the file was reset when this process actually reset it. In + // client mode the truncation happens only once the client applies the + // directive, and a rejected path or a failed write there surfaces as + // `savedTo: null` — so asserting the reset here would tell the agent its + // file is empty while it still holds the previous take. + const reset = + persist === "host" + ? `${filePath} reset to an empty flow.` + : `${filePath} is reset to an empty flow once your client applies \`savedTo\` ` + + `(a null \`savedTo\` means it did not).`; + // An unreadable or unparseable file leaves the loss genuinely uncounted, + // so say the take was discarded without putting a number on it rather + // than reporting one the file disagrees with. + const lost = + discardedSteps === undefined + ? "the previous take" + : `the previous take (${discardedSteps} step${discardedSteps === 1 ? "" : "s"})`; return { - message: - `Switched active flow from "${previousFlow}" to "${params.name}". ` + - `Recording "${previousFlow}" was abandoned - but the flow .yaml file has been saved to disk. ` + - `Now recording "${params.name}".`, - previousFlow, + message: `Restarted recording "${params.name}" — ${lost} was discarded and ` + reset, + restarted: true, + ...(discardedSteps === undefined ? {} : { discardedSteps }), flowFile, savedTo, }; diff --git a/packages/tool-server/src/tools/flows/flow-utils.ts b/packages/tool-server/src/tools/flows/flow-utils.ts index d659bc2d9..3b42e93d6 100644 --- a/packages/tool-server/src/tools/flows/flow-utils.ts +++ b/packages/tool-server/src/tools/flows/flow-utils.ts @@ -1,5 +1,6 @@ import * as path from "node:path"; import * as fs from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; import { FAILURE_CODES, FailureError } from "@argent/registry"; import { stringify as yamlStringify, parse as yamlParse } from "yaml"; import { @@ -30,38 +31,12 @@ const FLOWS_DIR_NAME = path.join(".argent", "flows"); // ── Paths ──────────────────────────────────────────────────────────── -// ── Active session state ───────────────────────────────────────────── - -let activeFlowName: string | null = null; -let activeProjectRoot: string | null = null; - /** - * Where the active recording's YAML is persisted: - * - `"host"` — this process writes `/.argent/flows/.yaml` - * directly (the original behavior; correct whenever the caller's - * project root is on this machine). - * - `"client"` — the caller's project root is NOT on this machine (remote - * tool-server). The flow lives in memory here and every mutating - * tool returns a {@link ClientFileDirective} so the *client* - * writes the YAML into the agent's project. + * Validate a caller-supplied `project_root`. Every path helper below joins the + * flows dir under this root, so the two rules it enforces (absolute, no "..") + * are what keep a recording's files inside the project the agent named. */ -export type FlowPersistMode = "host" | "client"; - -export interface RecordingSession { - persist: FlowPersistMode; - /** - * Absolute path of the flow file as the CALLER knows it. A real host path in - * "host" mode; in "client" mode it is only echoed back inside the directive - * (it names a file on the client's machine, never touched here). - */ - filePath: string; - /** In-memory flow content — authoritative in "client" mode. */ - flow: FlowFile; -} - -let recordingSession: RecordingSession | null = null; - -export function setActiveProjectRoot(root: string): void { +export function assertValidProjectRoot(root: string): void { if (!path.isAbsolute(root)) { throw new FailureError( `project_root must be an absolute path (got "${root}"). ` + @@ -87,36 +62,22 @@ export function setActiveProjectRoot(root: string): void { error_kind: "validation", }); } - activeProjectRoot = root; -} - -export function requireActiveProjectRoot(): string { - if (!activeProjectRoot) { - throw new FailureError( - "No active project root. The calling flow tool must pass project_root before any path is resolved.", - { - error_code: FAILURE_CODES.FLOW_PROJECT_ROOT_REQUIRED, - failure_stage: "flow_project_root_require", - failure_area: "tool_server", - error_kind: "validation", - } - ); - } - return activeProjectRoot; -} - -export function clearActiveProjectRoot(): void { - activeProjectRoot = null; } -/** The flows dir under an explicit root — for callers that must not resolve - * against the active-project-root global (see flow-add-step). */ +/** + * The flows dir under an explicit root, as pure path math. It validates + * nothing, so a caller that already rejects a bad root with its own + * tool-specific message (see flow-add-step) does not also raise a second, + * differently-worded one from here. + */ export function flowsDirFor(root: string): string { return path.join(root, FLOWS_DIR_NAME); } -export function getFlowsDir(): string { - return flowsDirFor(requireActiveProjectRoot()); +/** The flows dir under a root that has not been validated yet. */ +export function getFlowsDir(projectRoot: string): string { + assertValidProjectRoot(projectRoot); + return flowsDirFor(projectRoot); } export function assertSafeFlowName(name: string): void { @@ -134,12 +95,27 @@ export function assertSafeFlowName(name: string): void { } } -export function getFlowPath(name: string): string { +/** + * The flow file `/.argent/flows/.yaml`, as the CALLER + * spelled it. Pure path math over two validated inputs — this is the path + * reported back to the agent, not the recording-session key (that is + * {@link resolveFlowKey}, which asks the filesystem instead). + * + * `path.join` folds a trailing slash, `//` and `.` segments but NOT symlinks or + * case, so two callers can spell one real file two ways here: the ROOT spelled + * two ways (`/tmp/p` vs `/private/tmp/p` on macOS), the flows dir or the flow + * file symlinked into a shared vault from two projects, or the NAME cased two + * ways (`Login` vs `login`) on a case-insensitive volume, which APFS is by + * default. Keying sessions on this string would mint two sessions — and two + * independent locks — over one file, so nothing here is a session key. + */ +export function getFlowPath(projectRoot: string, name: string): string { + const flowsDir = getFlowsDir(projectRoot); assertSafeFlowName(name); - const filePath = path.join(getFlowsDir(), `${name}.yaml`); + const filePath = path.join(flowsDir, `${name}.yaml`); // Defense-in-depth: ensure the resolved path stays inside the flows // directory even if the regex above is ever weakened. - const rel = path.relative(getFlowsDir(), filePath); + const rel = path.relative(flowsDir, filePath); if (rel.startsWith("..") || path.isAbsolute(rel)) { throw new FailureError(`Invalid flow name "${name}": resolves outside the flows directory.`, { error_code: FAILURE_CODES.FLOW_NAME_INVALID, @@ -151,6 +127,72 @@ export function getFlowPath(name: string): string { return filePath; } +/** + * The flow file's identity as the FILESYSTEM sees it, which is what a recording + * session and its lock are keyed by. Two callers who spell one real file two + * ways — a symlink into a shared vault, a symlinked `.argent/flows`, a root + * spelled `/tmp` vs `/private/tmp`, a name cased two ways on APFS — resolve to + * one key here, so the collision reads as the restart it actually is instead of + * minting a second session that silently truncates the first. + * + * This is the same resolution {@link writeFlowFile} performs before its swap, + * deliberately: the key and the write agree by construction. Where the + * filesystem declines to answer at all — a flows dir that does not exist yet — + * both fall back to the same pure-path spelling and two spellings remain two, + * which is correct, because two files is what the write then produces. Where it + * declines only because the target is missing — a dangling vault symlink — both + * follow the link by hand ({@link followDanglingLink}), so the two spellings + * become one key, which is equally correct: one file is what the write produces + * there. + * + * It costs one `realpath` pair per recording tool call, on a path already doing + * file I/O. + * + * The earlier objection to normalizing — that the correct normalization is the + * filesystem's and we cannot ask it — held only for a hand-rolled one. Asking + * the filesystem is exactly what this does, so a case-SENSITIVE volume (ext4) + * keeps `Login` and `login` apart on its own: `realpath` there simply fails to + * find the variant spelling. + * + * "client" mode needs no special case. The caller's root does not exist on this + * host, so both `realpath` calls fail and the fallback returns + * {@link getFlowPath} unchanged — the old behavior, and the only one available + * when the file is on another machine. Two clients that share a flow file + * across that boundary are beyond this process's reach, as they were before. + */ +// `async`, so `getFlowPath`'s validation throws land as a rejection like every +// other failure here rather than synchronously out of a promise-returning call. +export async function resolveFlowKey(projectRoot: string, name: string): Promise { + const spelled = getFlowPath(projectRoot, name); + const inFlight = keyResolutions.get(spelled); + if (inFlight) return inFlight; + const resolving = canonicalFlowPath(spelled).finally(() => { + if (keyResolutions.get(spelled) === resolving) keyResolutions.delete(spelled); + }); + keyResolutions.set(spelled, resolving); + return resolving; +} + +/** + * Canonical-key resolutions currently IN FLIGHT, keyed by the spelled path. + * Not a cache — the entry is dropped the moment it settles, so a symlink + * repointed between two tool calls is seen — but a sequencer. + * + * Resolution is `realpath`, which runs on libuv's threadpool and therefore + * completes in an order unrelated to the order it was requested in. Every + * recording tool resolves its key before joining its flow file's lock queue, so + * without this, which of two tool calls acquires the lock first would be + * decided by threadpool scheduling rather than by which was issued first — a + * restart could land behind the append it is supposed to discard. Callers that + * spell one path the same way share one promise, so their continuations run in + * subscription order and the queue they join stays FIFO. + * + * Two DIFFERENT spellings of one file resolve independently and so race, as + * they did before. Nothing depends on their order: mutual exclusion comes from + * the resolved key, which is the same for both. + */ +const keyResolutions = new Map>(); + /** * How the flow file a caller addressed is spelled in its own directory. * `listed`: the directory carries that basename byte-for-byte — or its listing @@ -199,52 +241,333 @@ export async function classifyOnDiskSpelling(dir: string, base: string): Promise return { state: "case_folded", actual, addressable: FLOW_FILE_NAME_PATTERN.test(actual) }; } -export function setActiveFlow(name: string): void { - activeFlowName = name; +// ── Recording sessions ─────────────────────────────────────────────── + +/** + * Where a recording's YAML is persisted: + * - `"host"` — this process writes `/.argent/flows/.yaml` + * directly (the original behavior; correct whenever the caller's + * project root is on this machine). + * - `"client"` — the caller's project root is NOT on this machine (remote + * tool-server). The flow lives in memory here and every mutating + * tool returns a {@link ClientFileDirective} so the *client* + * writes the YAML into the agent's project. + */ +export type FlowPersistMode = "host" | "client"; + +export interface RecordingSession { + /** Flow name, as passed to every recording tool. */ + name: string; + /** Caller-supplied project root, as passed to every recording tool. */ + projectRoot: string; + /** + * The {@link resolveFlowKey} this session is registered under. Stored rather + * than re-derived, so {@link assertSessionStillLive} asks about the key the + * session actually holds — and needs no filesystem round trip to do it. + */ + key: string; + persist: FlowPersistMode; + /** + * Absolute path of the flow file as the CALLER knows it. A real host path in + * "host" mode; in "client" mode it is only echoed back inside the directive + * (it names a file on the client's machine, never touched here). + */ + filePath: string; + /** In-memory flow content — authoritative in "client" mode. */ + flow: FlowFile; + /** Order of the last touch, for the LRU eviction backstop. See {@link touch}. */ + lastTouchedSeq: number; +} + +/** + * Live recordings, keyed by {@link resolveFlowKey} — the identity of the + * artifact being built, as the FILESYSTEM resolves it rather than as a caller + * spelled it. Two sessions on one key mean two writers on one output file (a + * genuine collision, reported as a restart); two different keys are two + * different files, so concurrent agents recording different flows — in one + * project or across projects, against one device or several — never write into + * each other's take. + * + * The one window the key does not close: two starts BOTH in flight before + * either has created its file. Neither realpath can see a file that is not + * there yet, so two spellings of one not-yet-existing file resolve apart, and + * the two writes then land on one file. It closes itself on the next call — + * the file exists by then, so both spellings resolve together and the loser + * finds its key held by the other session, which fails loudly in + * {@link requireRecordingSession} rather than silently mixing takes. + * + * Isolation of the recorded artifact, not of the fact that a recording exists: + * the not-found path of {@link requireRecordingSession} deliberately names the + * other live flows in the caller's own project (and counts the rest), so that + * disclosure is bounded rather than absent. See the comment there for what it + * discloses and why. + * + * One tool-server serves every MCP client, subagent and CLI call using one + * argent install — `stateFileForBundle` gives each install its own record and + * autospawn takes a free port, so the singleton is per install bundle, not per + * machine. Within that scope this map is the only thing standing between two + * agents and a clobbered flow file. Across it there is nothing: two installs + * recording the same (project_root, name) hold two of these maps and cannot see + * each other, so each believes its own session is live while the other + * truncates and appends. What still holds there is {@link writeFlowFile}'s + * temp-file swap, which is a filesystem guarantee rather than an in-process + * one — each write stays whole, but a lost update is not prevented. + */ +const recordings = new Map(); + +/** + * Serializes every mutation of ONE flow file: an append, the reset+register a + * `flow-start-recording` performs, and the read+clear a `flow-finish-recording` + * performs. Each of those is a read/await/write straddling at least one + * microtask, and Express dispatches tool calls concurrently, so without this + * two of them interleave and one silently loses. + * + * Keyed by the flow path, NOT by the session object: a restart *replaces* the + * session, so a lock the session owned could not exclude the very operation + * that supersedes it — the restart would truncate the file while an append from + * the discarded take was mid-flight, and that step would land in the new take. + * + * Per file, not global: two recordings write two different files and must not + * queue behind each other. + */ +const flowFileLocks = new Map>(); + +async function withFlowLock(key: string, fn: () => Promise): Promise { + const previous = flowFileLocks.get(key) ?? Promise.resolve(); + // `previous` is always an already-swallowed promise, so a failed holder can + // never wedge or reject the chain. + const run = previous.then(() => fn()); + const held = run.catch(() => {}); + flowFileLocks.set(key, held); + // Drop the entry once this holder is the last one, so the map does not grow + // by one permanent entry per flow ever recorded. + void held.then(() => { + if (flowFileLocks.get(key) === held) flowFileLocks.delete(key); + }); + return run; } -/** Begin a recording session (replacing any abandoned one). */ -export function startRecordingSession(name: string, session: RecordingSession): void { - activeFlowName = name; - recordingSession = session; +/** + * Run `fn` with exclusive access to one flow file. Exported so the tools whose + * critical section spans more than an append — `flow-start-recording`'s + * truncate-then-register, `flow-finish-recording`'s read-then-clear — hold the + * same lock that {@link appendStepToFlow} takes. + */ +export async function withFlowFileLock( + projectRoot: string, + name: string, + fn: () => Promise +): Promise { + return withFlowLock(await resolveFlowKey(projectRoot, name), fn); } -export function getRecordingSession(): RecordingSession | null { - return recordingSession; +/** + * Leak backstop only. Sessions are small and auto-spawned servers idle out + * after 30 min, but a long-lived server could accumulate recordings an agent + * started and never finished. Well past any realistic concurrent-agent count, + * so evicting should never be something an agent observes — and if it ever is, + * the next append fails loudly rather than writing into a recording the server + * has forgotten. Which function reports it depends on the ordering: an append + * issued after the eviction fails in {@link requireRecordingSession}, since the + * key is already gone by the time it resolves; only one whose session was + * resolved BEFORE the eviction and landed after reaches + * {@link assertSessionStillLive}. + */ +export const MAX_RECORDINGS = 32; + +/** + * Stamp a session as most-recently-used. A counter rather than `Date.now()`: + * wall-clock has millisecond resolution, so sessions touched inside one + * millisecond tie, and the eviction scan's tie-break is map insertion order — + * which can drop the session that was touched most recently while keeping one + * that was not touched at all. A counter cannot tie, so "least recently used" + * means exactly that. + */ +let touchSeq = 0; +function touch(): number { + return ++touchSeq; } -function requireRecordingSession(): RecordingSession { - if (!activeFlowName || !recordingSession) { - throw new FailureError("No active flow. Call flow-start-recording first.", { - error_code: FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING, - failure_stage: "flow_require_recording", - failure_area: "tool_server", - error_kind: "validation", - }); +function evictIfOverCapacity(): void { + while (recordings.size > MAX_RECORDINGS) { + let oldestKey: string | undefined; + let oldestSeq = Infinity; + for (const [key, session] of recordings) { + if (session.lastTouchedSeq < oldestSeq) { + oldestSeq = session.lastTouchedSeq; + oldestKey = key; + } + } + if (oldestKey === undefined) return; + recordings.delete(oldestKey); } - return recordingSession; } -/** Returns the active flow name, or null if none is active. */ -export function getActiveFlowOrNull(): string | null { - return activeFlowName; +export interface RecordingSessionInit { + name: string; + projectRoot: string; + persist: FlowPersistMode; + filePath: string; + flow: FlowFile; } -export function getActiveFlow(): string { - if (!activeFlowName) { - throw new FailureError("No active flow. Call flow-start-recording first.", { - error_code: FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING, - failure_stage: "flow_active_recording_require", - failure_area: "tool_server", - error_kind: "validation", - }); +/** + * Begin a recording. Returns the session it replaced when one was already live + * on the same key (a re-record of the same flow, which discards the earlier + * take), or null — the common case, including starting a second, unrelated + * recording while others are in progress. + */ +export async function startRecordingSession( + init: RecordingSessionInit +): Promise { + const key = await resolveFlowKey(init.projectRoot, init.name); + const previous = recordings.get(key) ?? null; + recordings.set(key, { ...init, key, lastTouchedSeq: touch() }); + evictIfOverCapacity(); + return previous; +} + +export async function getRecordingSession( + projectRoot: string, + name: string +): Promise { + return recordings.get(await resolveFlowKey(projectRoot, name)); +} + +/** + * Every live recording. Feeds the not-found error message, which names only + * the caller's own project; `steps` is carried for tests and diagnostics. + */ +export function listActiveRecordings(): { name: string; projectRoot: string; steps: number }[] { + return [...recordings.values()].map((s) => ({ + name: s.name, + projectRoot: s.projectRoot, + steps: s.flow.steps.length, + })); +} + +export async function requireRecordingSession( + projectRoot: string, + name: string +): Promise { + const session = await getRecordingSession(projectRoot, name); + if (!session) { + // Name what was asked for AND what is live, so the agent can self-correct: + // with concurrent recordings the usual cause is a typo in `name` or the + // wrong `project_root`. + // + // Only this project's recordings are named. The others are counted, not + // listed: a tool-server bound beyond loopback is shared by unrelated + // callers (that is what "client" persist mode exists for), and their flow + // names and absolute project paths are not this caller's to see. A typo in + // your own project — the case worth recovering from — is still spelled out. + const active = listActiveRecordings(); + // Compare roots the way the key does (path.join-normalized), or a caller + // that spells its own root with a trailing slash would be told its live + // recording is in "another project" — degrading the message in exactly the + // wrong-project_root case it exists to diagnose. + const hereDir = getFlowsDir(projectRoot); + const here = active.filter((r) => getFlowsDir(r.projectRoot) === hereDir); + const elsewhere = active.length - here.length; + const others = elsewhere > 0 ? ` (plus ${elsewhere} in other projects)` : ""; + const activeList = here.length + ? `${here.map((r) => `"${r.name}"`).join(", ")}${others}` + : `none in this project${others}`; + // Do NOT tell the agent to just call flow-start-recording. This message is + // reached when the key was never started, but equally when a take was + // finished or dropped by the MAX_RECORDINGS backstop — and in those cases + // the flow file on disk is fully populated while no session owns it. (A key + // SUPERSEDED by a restart is NOT one of them: the superseding session holds + // the key, so this call resolves to it and returns success rather than + // reaching here — and that restart has already truncated the file, so + // "fully populated" would not hold there anyway.) flow-start-recording + // truncates unconditionally, so the advice that recovers the never-started + // case destroys the others. Same doctrine as {@link assertSessionStillLive}, + // which faces the identical ambiguity. + throw new FailureError( + `No active recording for flow "${name}" in ${projectRoot}. ` + + `If you have not started it yet, call flow-start-recording — but note it ` + + `truncates, so if ${getFlowPath(projectRoot, name)} already holds a take you ` + + `want (finished, or interrupted by a restart), copy it aside or record under ` + + `a fresh name instead. Active recordings: ${activeList}.`, + { + error_code: FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING, + failure_stage: "flow_require_recording", + failure_area: "tool_server", + error_kind: "validation", + } + ); + } + // The key is the file's identity, so a session found under it may have been + // registered under a DIFFERENT spelling of that one file — a symlink into a + // shared vault, a symlinked `.argent/flows`, a root spelled `/tmp` vs + // `/private/tmp`, a name cased two ways on APFS. Handing it over would risk + // silently enrolling this caller in someone else's take: its steps would land + // in a file it never addressed, under a prerequisite it never declared, and + // its finish would report the other agent's steps as its own. A root spelled + // with a trailing slash is not one of these — `getFlowPath` normalizes both + // sides before they are compared. + // + // Which of two situations this is cannot be told apart from here, so the + // message must assert neither. It is EITHER the same caller respelling its + // own root or name — nothing was truncated, the take is live and intact, and + // re-addressing it under the registered spelling resumes it — OR another + // caller's restart, which did truncate. Naming the second as fact sent a + // caller in the first situation to abandon a healthy recording and re-walk + // the whole flow on the device. The advice that recovers both is the same: + // use the spelling the session is registered under, which is the one + // `flow-start-recording` was given. + const asked = getFlowPath(projectRoot, name); + const held = getFlowPath(session.projectRoot, session.name); + if (asked !== held) { + throw new FailureError( + `Recording of "${name}" in ${projectRoot} is not registered under that spelling — ${held} ` + + `and ${asked} are the same file on this filesystem (a symlink, or a case-insensitive ` + + `volume), and the live take on it is registered as "${session.name}" in ` + + `${session.projectRoot}. If that is your own recording spelled another way, re-address ` + + `it exactly as you passed it to flow-start-recording — the take is intact and still ` + + `recording. If it is another caller's, their flow-start-recording truncated yours; ` + + `record under a name that resolves to its own file rather than restarting here, which ` + + `would destroy their take in turn.`, + { + error_code: FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING, + failure_stage: "flow_recording_key_aliased", + failure_area: "tool_server", + error_kind: "validation", + } + ); } - return activeFlowName; + session.lastTouchedSeq = touch(); + return session; +} + +/** + * Retire a finished recording, by the key the session actually HOLDS rather + * than a fresh resolution of its spelling — the same choice + * {@link appendStepToFlow} makes, and for the same reason. A key that moved + * under the session (a symlinked flow file whose target went away + * mid-recording, or a link repointed) re-resolves to something this map does + * not hold, so the delete missed silently: the finish reported success while + * the session stayed live, unfinishable, and holding the key against its own + * restart. + */ +export function clearRecordingSession(session: RecordingSession): void { + recordings.delete(session.key); } -export function clearActiveFlow(): void { - activeFlowName = null; - recordingSession = null; +export function __resetRecordingsForTesting(): void { + recordings.clear(); + flowFileLocks.clear(); + keyResolutions.clear(); +} + +/** + * How many flow files currently have a lock entry. Test-only: the map's + * self-cleanup is a leak backstop with no other observable effect, so nothing + * else can tell a released lock from a retained one. + */ +export function __flowFileLockCountForTesting(): number { + return flowFileLocks.size; } // ── Types ──────────────────────────────────────────────────────────── @@ -2177,6 +2500,378 @@ export function parseFlow(content: string): FlowFile { // ── File helpers ───────────────────────────────────────────────────── +/** + * Suffix counter for {@link writeFlowFile}'s scratch file. Paired with the pid, + * this keeps two concurrent writers off each other's temp file: the counter + * separates writers inside this process, and the pid separates this process + * from a SECOND tool-server — a different install bundle can record the same + * `(project_root, name)` and compute the same scratch path (see the + * cross-install note on {@link recordings}). The CLI is not one of the writers: + * it writes the destination flow file directly and mints no scratch file, and + * host and client persist modes are mutually exclusive per call, so no CLI is + * writing this directory while the tool-server is. + */ +let flowWriteSeq = 0; + +/** + * What actually went wrong, per errno. The swap needs write permission on the + * DIRECTORY, which is the surprising part and worth stating — but only when + * that is the failure. Stating it for every code turned an over-long flow name + * (`ENAMETOOLONG` out of `rename`) into a report of a directory-permissions + * problem the user would then go and not find. + */ +function writeFailureHint( + code: string | undefined, + filePath: string, + target: string, + resolvedDir: string +): string { + // The directory the swap actually uses — `dirname(realpath(filePath))`, not + // `dirname(filePath)`. For a flow file that is a symlink into a shared vault + // those are different directories, and only the first one can be the cause: + // naming the second sent the reader to a `.argent/flows` that is already + // writable while the vault, the only unwritable thing in the picture, went + // unmentioned. Say so when they differ, since "your flows dir is fine, the + // link target is not" is the whole diagnosis there. + // + // Compared against the RESOLVED flows dir, not the spelled one. Every + // symlinked ANCESTOR moves the target too — which on macOS is every `/tmp` + // and `/var/folders` path — so comparing against the spelling accused a flow + // file that is a perfectly ordinary regular file of being a symlink, and + // contrasted two names for one directory. + const dir = path.dirname(target); + const via = + dir === resolvedDir + ? "" + : ` (${path.basename(filePath)} is a symlink, so the write lands in ${dir}, not in ${resolvedDir})`; + switch (code) { + case "EACCES": + case "EPERM": + case "EROFS": + return ( + `an append replaces the file via a sibling temp file and rename, so ${dir} must be ` + + `writable — permission on the flow file itself is not enough${via}.` + ); + case "ENOSPC": + case "EDQUOT": + return `the filesystem holding ${dir} is out of space (or over quota)${via}.`; + case "ENAMETOOLONG": + return `the flow name makes ${path.basename(target)} longer than this filesystem allows — use a shorter name.`; + case "ENOENT": + return `${dir} does not exist${via}.`; + default: + return `an append replaces the file via a sibling temp file and rename in ${dir}${via}.`; + } +} + +/** + * The original error with the internal scratch path rewritten to the flow file, + * so the cause chain `formatErrorForAgent` renders never names a temp file that + * was already deleted. Everything else about the errno — code, syscall, the + * kernel's own wording — is kept. + */ +function scrubTempPath(err: unknown, tmpPath: string, filePath: string): Error { + if (!(err instanceof Error)) return new Error(String(err)); + if (!err.message.includes(tmpPath)) return err; + const scrubbed = new Error(err.message.split(tmpPath).join(filePath)); + scrubbed.name = err.name; + return scrubbed; +} + +/** + * A flow file's REAL path. A saved flow may be a symlink into a shared vault — + * the runner canonicalizes before reading, and `run:` composition anchors on + * the real file — and rename(2) replaces the path it is handed, so renaming + * onto the link's own spelling would swap the symlink for a regular file and + * strand the vault copy with the pre-recording content. A plain write follows + * the link; resolving first keeps that behavior while keeping the swap atomic. + * + * The directory is resolved separately so that a flow file which does not exist + * yet (the first write of a recording, which has no realpath of its own) still + * lands on the same canonical spelling as every later append — otherwise the + * first swap and the rest would disagree wherever an ancestor is itself a + * symlink, which is the default for the temp dir on macOS. + * + * A DANGLING link is the case `realpath` cannot express — it fails on the whole + * path rather than answering with the target — and that failure would put the + * link's own spelling back in front of `rename`, i.e. exactly the swap this + * exists to prevent. {@link followDanglingLink} resolves it by hand. + * + * Shared with {@link resolveFlowKey}, so the identity a recording is keyed by + * and the file its steps land in can never disagree. + * + * `dir` — the flows directory as the filesystem sees it — is returned alongside, + * because it is the only thing a caller can compare `target`'s directory against + * to tell "the flow FILE is a symlink" from "some ancestor of it is". Comparing + * against the spelled `path.dirname(filePath)` cannot: on macOS every `/tmp` and + * `/var/folders` path has a symlinked ancestor. + */ +async function canonicalFlowTarget(filePath: string): Promise<{ dir: string; target: string }> { + const dir = await fs.realpath(path.dirname(filePath)).catch(() => path.dirname(filePath)); + const real = await fs.realpath(filePath).catch(() => null); + if (real !== null) return { dir, target: real }; + return { dir, target: await followDanglingLink(path.join(dir, path.basename(filePath))) }; +} + +async function canonicalFlowPath(filePath: string): Promise { + return (await canonicalFlowTarget(filePath)).target; +} + +/** + * How deep a chain of not-yet-existing symlinks {@link followDanglingLink} + * walks. A backstop against a link cycle, which `readlink` alone cannot detect; + * far past any real vault layout, which is one hop. + */ +const MAX_DANGLING_LINK_HOPS = 32; + +/** + * Where a link whose TARGET does not exist actually points. + * + * `realpath` fails outright on a dangling symlink, so the fallback above would + * hand back the link's own path — and `rename(2)` replaces the path it is + * given, so the first write of a recording would swap the symlink for a regular + * file. That is the shared-vault setup's normal starting state: the link is + * created before the first recording, or its target is removed by a branch + * switch or a `git clean`. The vault copy is then never created, the project is + * permanently detached from the vault, and any sibling project linked to the + * same target is left dangling — with the tool reporting success. + * + * So resolve the link by hand, one hop at a time, canonicalizing each target's + * DIRECTORY the way {@link canonicalFlowPath} does so the result agrees with + * what a later append (by then a plain `realpath`) will compute. A path that is + * not a link — the ordinary "flow file does not exist yet" case — comes back + * unchanged on the first probe. + */ +async function followDanglingLink(linkPath: string): Promise { + let current = linkPath; + for (let hop = 0; hop < MAX_DANGLING_LINK_HOPS; hop++) { + const target = await fs.readlink(current).catch(() => null); + if (target === null) return current; + const resolved = path.resolve(path.dirname(current), target); + // The rest of the chain may well exist — only the last hop has to dangle + // for `realpath` to have refused the whole path. + const real = await fs.realpath(resolved).catch(() => null); + if (real !== null) return real; + const targetDir = await fs.realpath(path.dirname(resolved)).catch(() => path.dirname(resolved)); + current = path.join(targetDir, path.basename(resolved)); + } + return current; +} + +/** Whether this process may write `filePath` — its mode as the kernel reads it. */ +async function isWritable(filePath: string): Promise { + return fs.access(filePath, fsConstants.W_OK).then( + () => true, + () => false + ); +} + +/** + * Replace a flow file's contents so no reader can ever observe it half-written. + * + * {@link withFlowFileLock} serializes WRITERS, but every reader of a flow YAML + * stays outside it — `flow-execute`'s own load, its `run:` fragment load, + * `flow-read-prerequisite`, `flow-add-step`'s sibling-fragment check — and the + * `argent` CLI reads these files from another process entirely, where an + * in-process lock cannot reach. A plain `fs.writeFile` opens with O_TRUNC, so + * such a reader could land in the window between the truncate and the write and + * parse a truncated file, or an empty one — and `parseFlow("")` yields + * `{ steps: [] }` with no error, which replays as a top-level PASS over zero + * steps. + * + * Writing to a temp file beside the target and renaming makes the swap atomic: + * a reader sees either the whole previous file or the whole new one. Beside the + * TARGET, note — `canonicalFlowPath`'s result — which for a symlinked flow is + * the vault the link points into, not `path.dirname(filePath)`; rename(2) is + * atomic only within one filesystem, and that is the pairing that guarantees it. + * + * The temp name is dotted and `.tmp`-suffixed so a half-written scratch file can + * never be mistaken for a flow: `getFlowPath` only ever produces `.yaml`, + * and every site that enumerates a flows directory — `argent flow list`, + * {@link classifyOnDiskSpelling}, the CLI's recursive suite walk — filters on + * `.yaml` plus `FLOW_NAME_PATTERN`, so none of them can see one. Keep both + * halves of that agreement if either side changes. + * + * It deliberately does NOT embed the flow name. A flow name has no length cap + * (`FLOW_NAME_PATTERN` constrains the character set only), so `.yaml` can + * legitimately run to NAME_MAX — and prefixing that with a discriminator would + * push the scratch name past the limit, turning an append that used to work + * into ENAMETOOLONG. pid + counter is unique on its own: the counter separates + * writers inside this process, the pid separates this process from a second + * tool-server (a different install bundle) that could be writing the same + * directory. + * + * The swap costs one thing a write-through would have kept, accepted for the + * atomicity: it needs write permission on the DIRECTORY rather than on the + * file, and it replaces the inode, so a hardlink to the flow file does not + * survive an append. The file's own MODE is not among the costs — see below. + */ +async function writeFlowFile(filePath: string, content: string): Promise { + const { dir: resolvedDir, target } = await canonicalFlowTarget(filePath); + // Null when the flow file does not exist yet (the first write of a recording), + // which has no mode to preserve and nothing to be refused by. + const previousMode = await fs.stat(target).then( + (s) => s.mode & 0o7777, + () => null + ); + if (previousMode !== null && !(await isWritable(target))) { + // The swap needs permission on the directory, not on the file, so it would + // replace a `chmod 0444` flow file regardless — turning a plain write's + // EACCES into a silent success that also relaxed the mode to the umask + // default. Refuse instead, so a read-only flow file goes on meaning what it + // meant before the write became atomic. + throw new FailureError( + `Failed to write flow file ${filePath} (EACCES) — ${target} is not writable ` + + `(mode ${previousMode.toString(8).padStart(4, "0")}). An append replaces the file via a ` + + `sibling temp file and rename, which needs permission on the directory rather than on ` + + `the file — so this is refused explicitly rather than quietly overwriting a flow you ` + + `made read-only. chmod it writable to record over it.`, + { + error_code: FAILURE_CODES.FLOW_FILE_WRITE_FAILED, + failure_stage: "flow_file_write", + failure_area: "tool_server", + error_kind: "unknown", + } + ); + } + const tmpPath = path.join( + path.dirname(target), + `.argent-flow-${process.pid}-${++flowWriteSeq}.tmp` + ); + try { + await fs.writeFile(tmpPath, content, "utf8"); + // The scratch file was created under this process's umask, and rename + // carries ITS mode over — so without this every append would quietly + // rewrite the flow file's permissions to 0644. + if (previousMode !== null) await fs.chmod(tmpPath, previousMode); + // Atomic within a filesystem, and the temp file is a sibling of the target, + // so it is always the same one. + await fs.rename(tmpPath, target); + } catch (err) { + // Leave no scratch file behind, whichever half failed. The write itself can + // fail with the file already created (ENOSPC, EIO), so this has to cover it + // too — nothing else ever sweeps this directory. + await fs.rm(tmpPath, { force: true }).catch(() => {}); + // Rethrow against the flow file, never the scratch path. The temp name is + // an internal detail — pid+counter suffixed, and already removed above — so + // surfacing its raw errno (`EACCES: … open '.argent-flow--.tmp'`) + // would name a file that no longer exists and never mention the flow. + // + // That applies to the CAUSE as much as to this message: + // `formatErrorForAgent` walks the cause chain and appends each new message, + // so attaching the raw errno puts the scratch path in front of the agent + // anyway — through the one string it actually reads. Scrub the path out of + // the cause and keep the rest, which is the part worth having. + const errno = err instanceof Error ? (err as NodeJS.ErrnoException) : undefined; + const code = typeof errno?.code === "string" ? errno.code : undefined; + throw new FailureError( + `Failed to write flow file ${filePath}${code ? ` (${code})` : ""} — ${writeFailureHint(code, filePath, target, resolvedDir)}`, + { + error_code: FAILURE_CODES.FLOW_FILE_WRITE_FAILED, + failure_stage: "flow_file_write", + failure_area: "tool_server", + error_kind: "unknown", + }, + { cause: scrubTempPath(err, tmpPath, filePath) } + ); + } +} + +/** + * Why the flows directory could not be created, per errno. Separate from + * {@link writeFailureHint} because the surprising cause differs: the swap's + * hazard is needing permission on the directory, while `mkdir -p`'s is a path + * COMPONENT that is not a directory — which for a caller-supplied + * `project_root` almost always means it named a file. + */ +function mkdirFailureHint(code: string | undefined, dir: string): string { + switch (code) { + case "ENOTDIR": + return ( + `a component of ${dir} exists and is not a directory — check that project_root ` + + `names a directory rather than a file.` + ); + case "EACCES": + case "EPERM": + case "EROFS": + return `the nearest existing parent of ${dir} is not writable.`; + case "ENOSPC": + case "EDQUOT": + return `the filesystem holding ${dir} is out of space (or over quota).`; + case "ENAMETOOLONG": + return `${dir} is longer than this filesystem allows.`; + default: + return `${dir} could not be created.`; + } +} + +/** + * Create or reset a flow file with `content`, making the parent directory if + * needed. Atomic (see {@link writeFlowFile}). + * + * Both halves are classified. The tool description promises this "fails if the + * `.argent/flows/` directory cannot be created OR the flow file cannot be + * written", and leaving the mkdir outside the wrapping made only the second + * half keep that promise: a `project_root` naming an existing file, or an + * unwritable one, surfaced as a bare `ENOTDIR`/`EACCES` under + * REGISTRY_TOOL_EXECUTION_FAILED — no remediation hint, and telemetry + * attributing a flow failure to the registry — while the same permission + * problem one line later returned FLOW_FILE_WRITE_FAILED with a hint. + */ +export async function writeNewFlowFile(filePath: string, content: string): Promise { + const dir = path.dirname(filePath); + try { + await fs.mkdir(dir, { recursive: true }); + } catch (err) { + const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined; + throw new FailureError( + `Failed to create the flows directory ${dir}${typeof code === "string" ? ` (${code})` : ""} — ` + + mkdirFailureHint(typeof code === "string" ? code : undefined, dir), + { + error_code: FAILURE_CODES.FLOW_FILE_WRITE_FAILED, + failure_stage: "flow_dir_create", + failure_area: "tool_server", + error_kind: "unknown", + }, + { cause: err instanceof Error ? err : new Error(String(err)) } + ); + } + await writeFlowFile(filePath, content); +} + +/** + * How many steps the flow file currently holds, or undefined if it cannot be + * read or parsed. + * + * For counting what a truncate is about to destroy, and therefore only ever + * called in "host" mode. In "client" mode the file lives on the client's + * machine and this host cannot read it at all, so the in-memory copy is both + * the take and the only thing countable — the guarantee below does not carry + * across that boundary. The tool descriptions do not spell that out — they are + * kept to what the tool does; the agent-facing statement of it lives in + * `packages/skills/skills/argent-create-flow/SKILL.md`, in the + * "Starting always truncates the `.yaml`" bullet. + * + * The file — not the session's in-memory `flow` — is the take in "host" mode: + * {@link appendStep} + * re-reads it before every append and `flow-finish-recording` reads it back for + * its summary, so a hand-edit made mid-recording (a documented workflow) is + * part of the take even though the in-memory copy only catches up on the next + * append. + * + * Undefined rather than 0 on a failure, because the two are not the same + * answer: a hand-edit can leave YAML that `parseFlow` rejects, and reporting + * "0 steps discarded" there would understate the loss in exactly the case that + * caused it. The caller reports no count instead. + */ +export async function countStepsOnDisk(filePath: string): Promise { + try { + return parseFlow(await fs.readFile(filePath, "utf8")).steps.length; + } catch { + return undefined; + } +} + /** Read and parse the flow file, append a step, write it back. */ export async function appendStep(filePath: string, step: FlowStep): Promise { const content = await fs.readFile(filePath, "utf8"); @@ -2187,7 +2882,7 @@ export async function appendStep(filePath: string, step: FlowStep): Promise { - const session = requireRecordingSession(); - if (session.persist === "host") { - const flowFile = await appendStep(session.filePath, step); - session.flow = parseFlow(flowFile); - return { flowFile, savedTo: session.filePath, session }; - } - session.flow.steps.push(step); - try { - validateFlow(session.flow); - } catch (err) { - session.flow.steps.pop(); // keep the in-memory copy consistent: nothing recorded - throw err; - } - const flowFile = serializeFlow(session.flow); - return { flowFile, savedTo: clientFileDirective(session.filePath, flowFile), session }; +): Promise<{ flowFile: string; savedTo: FlowSavedTo }> { + // The session's OWN key, not a fresh resolution of it: the lock this append + // takes and the identity {@link assertSessionStillLive} checks must be the + // same one, or a key that moved under the session (a symlink repointed + // mid-recording) would let the append hold one lock while asserting about + // another. + return withFlowLock(session.key, async () => { + assertSessionStillLive(session, step); + session.lastTouchedSeq = touch(); + if (session.persist === "host") { + const flowFile = await appendStep(session.filePath, step); + session.flow = parseFlow(flowFile); + return { flowFile, savedTo: session.filePath }; + } + session.flow.steps.push(step); + try { + // Both of these can reject on a bad step — validateFlow on a cross-field + // violation, serializeFlow on an unrepresentable one (e.g. a tap with + // un-normalized coordinates). Roll back on either: in client mode this + // in-memory copy is the ONLY copy, so leaving the rejected step in it + // poisons the recording — every later append, and the finish itself, + // would re-hit the same error with no way to recover. + validateFlow(session.flow); + const flowFile = serializeFlow(session.flow); + return { flowFile, savedTo: clientFileDirective(session.filePath, flowFile) }; + } catch (err) { + session.flow.steps.pop(); // keep the in-memory copy consistent: nothing recorded + throw err; + } + }); } diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/android.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/android.ts index db3890101..5bff80712 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/android.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/android.ts @@ -1,6 +1,7 @@ import * as path from "path"; import { FAILURE_CODES, FailureError } from "@argent/registry"; import type { NativeProfilerSessionApi } from "../../../../blueprints/native-profiler-session"; +import { describeReapedSession, takeReapedSession } from "../../../../utils/reaped-sessions"; import { getDebugDir } from "../../../../utils/react-profiler/debug/dump"; import { startPerfetto, stopPerfetto } from "../../../../utils/android-profiler/capture"; import { @@ -70,6 +71,30 @@ export async function startNativeProfilerAndroid( timestamp, }); + // See the iOS twin: a `stop-all-simulator-servers` that landed while + // `startPerfetto` was in flight has already destroyed this session, and + // stamping state onto a dead api would report a recording whose owner's stop + // answers "call native-profiler-start first". The daemon is this attempt's to + // reap — the teardown never saw it, since `capturePid` is only handed over + // below. + if (api.disposed) { + const { adbShell } = await import("../../../../utils/adb"); + await adbShell(params.device_id, `kill -KILL ${pid}`).catch(() => {}); + await adbShell(params.device_id, `rm -f ${onDeviceTracePath}`).catch(() => {}); + throw new FailureError( + `The native profiling session for ${api.deviceId} was torn down by a ` + + `stop-all-simulator-servers while perfetto was starting, so nothing was recorded — ` + + `one tool-server serves every agent using this argent install, so this may have been ` + + `another agent ending its session. Call native-profiler-start again.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_TORN_DOWN, + failure_stage: "android_native_profiler_start", + failure_area: "tool_server", + error_kind: "not_found", + } + ); + } + // Perfetto is up — this capture now owns the session; stamp its descriptors // and clear any prior capture's recovery flags (superseded on success only). api.recordingTimedOut = false; @@ -82,6 +107,10 @@ export async function startNativeProfilerAndroid( api.androidOnDeviceTracePath = onDeviceTracePath; api.profilingActive = true; api.wallClockStartMs = Date.now(); + // This capture's own stop will succeed, so an earlier teardown breadcrumb + // would never be consumed — and would go on to blame a much later, genuine + // "no active session" on a teardown that had nothing to do with it. + takeReapedSession("native-profiler", api.deviceId); api.recordingTimeout = setTimeout(() => { // Best-effort SIGTERM to the on-device perfetto daemon; stop tool will pull @@ -117,8 +146,13 @@ export async function stopNativeProfilerAndroid( ): Promise { const recoveringPartialTrace = api.recordingTimedOut || api.recordingExitedUnexpectedly; if (!api.profilingActive && !recoveringPartialTrace) { + // See the iOS twin: a teardown leaves a fresh session behind, which is + // indistinguishable from one that never started without this breadcrumb. + const reaped = takeReapedSession("native-profiler", api.deviceId); throw new FailureError( - "No active native profiling session found. Call native-profiler-start first.", + reaped + ? describeReapedSession(reaped, "native profiling session") + : "No active native profiling session found. Call native-profiler-start first.", { error_code: FAILURE_CODES.NATIVE_PROFILER_NO_ACTIVE_SESSION, failure_stage: "android_native_profiler_stop", diff --git a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts index 25e09a27a..943bf3b14 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/platforms/ios.ts @@ -4,6 +4,7 @@ import { promises as fs } from "fs"; import { existsSync } from "node:fs"; import * as path from "path"; import type { NativeProfilerSessionApi } from "../../../../blueprints/native-profiler-session"; +import { describeReapedSession, takeReapedSession } from "../../../../utils/reaped-sessions"; import { deviceSetForUdid, simctlArgsForUdidSync } from "../../../../utils/ios-device-sets"; import { getDebugDir } from "../../../../utils/react-profiler/debug/dump"; import { @@ -755,6 +756,35 @@ export async function startNativeProfilerIos( } const { child: xctraceProcess, pid: xctracePid } = started; + // A `stop-all-simulator-servers` that landed inside the readiness handshake + // above has already destroyed this session — `Registry._teardown` nulled the + // node's instance, so nothing can resolve `api` again and the owner's + // `native-profiler-stop` would answer "call native-profiler-start first". + // Reporting `status: "recording"` here would hand back a session that does + // not exist, with a trace file on disk and no way to reach it. Reap what this + // attempt spawned and say what happened instead. + if (api.disposed) { + try { + xctraceProcess.kill("SIGKILL"); + } catch { + // already dead + } + resetStartState(api); + throw new FailureError( + `The native profiling session for ${api.deviceId} was torn down by a ` + + `stop-all-simulator-servers while this start was waiting for xctrace to become ` + + `ready, so nothing was recorded — one tool-server serves every agent using this ` + + `argent install, so this may have been another agent ending its session. Call ` + + `native-profiler-start again.`, + { + error_code: FAILURE_CODES.NATIVE_PROFILER_SESSION_TORN_DOWN, + failure_stage: "native_profiler_xctrace_start", + failure_area: "tool_server", + error_kind: "not_found", + } + ); + } + // Stamp the per-capture descriptors only now, on SUCCESS: a failed start // must leave the previous capture's still-loaded exports fully described // for analyze (trace name, all-processes filter PID, capture mode). The @@ -777,6 +807,9 @@ export async function startNativeProfilerIos( api.cpuFilterPid = strategy ? strategy.cpuFilterPid(detected!) : null; api.profilingActive = true; api.wallClockStartMs = Date.now(); + // See the Android twin: a live capture makes any earlier teardown breadcrumb + // unconsumable, and therefore a future false accusation. + takeReapedSession("native-profiler", api.deviceId); api.recordingTimeout = setTimeout(() => { try { xctraceProcess.kill("SIGINT"); @@ -834,8 +867,15 @@ export async function stopNativeProfilerIos(api: NativeProfilerSessionApi): Prom } if (!api.profilingActive || !api.captureProcess || !api.traceFile) { + // A teardown reaps NativeProfilerSession and the registry nulls the + // instance, so `api` here can be a fresh session that never saw the capture + // this caller started. Say that happened rather than "you never started + // one" — the trace really is gone, but the reason is not the caller's. + const reaped = takeReapedSession("native-profiler", api.deviceId); throw new FailureError( - "No active native profiling session found. Call native-profiler-start first.", + reaped + ? describeReapedSession(reaped, "native profiling session") + : "No active native profiling session found. Call native-profiler-start first.", { error_code: FAILURE_CODES.NATIVE_PROFILER_NO_ACTIVE_SESSION, failure_stage: "native_profiler_stop_session_state", diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts index 0e20034d6..67da927eb 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-status.ts @@ -174,7 +174,7 @@ export function createReactProfilerStatusTool( session_status: isMine ? "active" : "taken_over", note: isMine ? "Your profiling session is still running. Call react-profiler-stop to collect the data, or continue profiling." - : "A different profiling session is running (another tool-server instance took over, or this process restarted after start). Data from the prior session is lost at the takeover moment. Use react-profiler-start { force: true } to reclaim.", + : "A different profiling session is running (another tool-server instance took over, this process restarted after start, or a stop-all-simulator-servers reaped this device's JS-runtime debugger and took this session down with it, leaving the in-app owner behind). Data from the prior session is lost at the takeover moment. Use react-profiler-start { force: true } to reclaim.", }; }, }; diff --git a/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts b/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts index 137f54b09..c1a059599 100644 --- a/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts +++ b/packages/tool-server/src/tools/profiler/react/react-profiler-stop.ts @@ -180,8 +180,10 @@ Fails if no active profiling session exists or the CDP connection was lost durin if (!entry || entry.state !== ServiceState.RUNNING) { throw new FailureError( - "No active profiling session. The session may have been lost due to a Metro reload. " + - "Call react-profiler-start to begin a new session.", + "No active profiling session. The session may have been lost to a Metro reload, or " + + "torn down by a stop-all-simulator-servers — this session rides on the device's " + + "JS-runtime debugger, which that teardown reaps, and one tool-server serves every " + + "agent using this argent install. Call react-profiler-start to begin a new session.", { error_code: FAILURE_CODES.REACT_PROFILER_NO_ACTIVE_SESSION, failure_stage: "react_profiler_stop_session_lookup", diff --git a/packages/tool-server/src/tools/screen-recording/capture.ts b/packages/tool-server/src/tools/screen-recording/capture.ts index aa5aad9bf..74f87092d 100644 --- a/packages/tool-server/src/tools/screen-recording/capture.ts +++ b/packages/tool-server/src/tools/screen-recording/capture.ts @@ -10,6 +10,7 @@ import { markScreenRecordingFinalized, registerActiveScreenRecording, } from "../../utils/screen-recording-reminder"; +import { takeReapedSession } from "../../utils/reaped-sessions"; import { openMjpegStream, readJpegDimensions, type MjpegStream } from "./mjpeg-stream"; import { assertNoActiveRecording, @@ -330,8 +331,9 @@ async function startCaptureLocked( } // No await between here and `api.pendingChild = child`: if dispose() ran - // (shutdown) while this start was suspended above, abort now rather than - // spawn an encoder the teardown can no longer reap. + // (shutdown, or a stop-all-simulator-servers teardown of this device) while + // this start was suspended above, abort now rather than spawn an encoder the + // teardown can no longer reap. assertNotDisposed(api, "screen_recording_start"); child = spawn(ffmpeg, ffmpegArgs({ outputFile, logoFile, graph }), { stdio: ["pipe", "ignore", "pipe"], @@ -392,6 +394,10 @@ async function startCaptureLocked( api.wallClockEndMs = null; api.timeLimitSeconds = params.timeLimitSeconds; registerActiveScreenRecording(api.deviceId, api.wallClockStartMs, params.timeLimitSeconds); + // A live capture makes any earlier teardown breadcrumb unreportable: this + // recording's own stop will succeed, so nothing would ever consume it, and it + // would be left to blame a much later, genuine "no active recording". + takeReapedSession("screen-recording", api.deviceId); startPump(api, stream); // Arm the exit handler BEFORE the pointer-enable await below. readiness @@ -424,7 +430,8 @@ async function startCaptureLocked( if (params.pointer) { // Arm the touch visualizer before returning, so the very first interaction // is already drawn into the recording. Store the teardown first so a - // shutdown racing this await still restores the overlay. Best-effort: a + // shutdown (or a stop-all-simulator-servers teardown of this device) racing + // this await still restores the overlay. Best-effort: a // failure only costs the touch markers, surfaced as a warning at stop. api.pointerDisable = params.pointer.disable; api.pointerFailed = !(await params.pointer.enable()); diff --git a/packages/tool-server/src/tools/screen-recording/screen-recording-start.ts b/packages/tool-server/src/tools/screen-recording/screen-recording-start.ts index 608d0d8ff..bab91cfa3 100644 --- a/packages/tool-server/src/tools/screen-recording/screen-recording-start.ts +++ b/packages/tool-server/src/tools/screen-recording/screen-recording-start.ts @@ -160,7 +160,8 @@ Fails if a recording is already running on the device, the device is not booted, * * `disable` waits for any in-flight `enable` to settle before sending its own * `show:false`. Enabling is the one suspension point after a recording is - * stamped, so a dispose (shutdown) can call `disable` while `enable`'s + * stamped, so a dispose (shutdown, or a stop-all-simulator-servers teardown of + * this device) can call `disable` while `enable`'s * `show:true` request is still outstanding. Without this barrier the two * requests race and the earlier-issued `show:false` can be overtaken by the * later `show:true`, leaving simulator-server's overlay stuck on after the diff --git a/packages/tool-server/src/tools/screen-recording/session-guards.ts b/packages/tool-server/src/tools/screen-recording/session-guards.ts index 8d751fff3..3e4e3786b 100644 --- a/packages/tool-server/src/tools/screen-recording/session-guards.ts +++ b/packages/tool-server/src/tools/screen-recording/session-guards.ts @@ -1,6 +1,7 @@ import { promises as fs } from "fs"; import { FAILURE_CODES, FailureError } from "@argent/registry"; import type { ScreenRecordingSessionApi } from "../../blueprints/screen-recording-session"; +import { describeReapedSession, takeReapedSession } from "../../utils/reaped-sessions"; export interface StartRecordingResult { status: "recording"; @@ -106,10 +107,21 @@ export function assertStoppableSession(api: ScreenRecordingSessionApi, stage: st } const recoverable = api.pendingRetrieval && api.outputFile !== null; if (!api.recordingActive && !recoverable) { + // A teardown reaps this device's ScreenRecordingSession, and the registry + // nulls the instance — so the session resolved above is a brand new one + // that has never heard of the capture that was running a moment ago. Absent + // the breadcrumb, the only thing distinguishing "your recording was + // destroyed, here is where the video landed" from "you never started one" + // is gone, and this reports the second. + const reaped = takeReapedSession("screen-recording", api.deviceId); throw new FailureError( - `No active screen recording on device ${api.deviceId}. Call \`screen-recording-start\` first.`, + reaped + ? describeReapedSession(reaped, "screen recording") + : `No active screen recording on device ${api.deviceId}. Call \`screen-recording-start\` first.`, { - error_code: FAILURE_CODES.SCREEN_RECORDING_NO_ACTIVE_SESSION, + error_code: reaped + ? FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN + : FAILURE_CODES.SCREEN_RECORDING_NO_ACTIVE_SESSION, failure_stage: stage, failure_area: "tool_server", // Session-state, not caller input — matches the profiler family's @@ -121,15 +133,27 @@ export function assertStoppableSession(api: ScreenRecordingSessionApi, stage: st } /** - * Reject a start whose readiness resumed after the session was disposed - * (process shutdown). Call synchronously right before spawn, with no await - * between this check and the spawn/pendingChild stamp, so no capture is - * launched that dispose's teardown can no longer see and reap. + * Reject a start whose readiness resumed after the session was disposed. Call + * synchronously right before spawn, with no await between this check and the + * spawn/pendingChild stamp, so no capture is launched that dispose's teardown + * can no longer see and reap. + * + * `dispose()` runs on process shutdown, but ALSO whenever + * `stop-all-simulator-servers` reaps this device — `ScreenRecordingSession` is a + * device-owned namespace, so a session-end teardown (commonly another agent's) + * disposes it. The two are indistinguishable from `api.disposed` alone, so the + * message names both and does not tell the caller a retry is pointless: on the + * teardown branch the device is usually still up and starting again succeeds. + * (`SCREEN_RECORDING_SERVER_SHUTTING_DOWN` is the enum carried into telemetry; + * the shutdown wording there is historical, not a second claim of the cause.) */ export function assertNotDisposed(api: ScreenRecordingSessionApi, stage: string): void { if (api.disposed) { throw new FailureError( - `The tool-server is shutting down; screen recording was not started on device ${api.deviceId}.`, + `The screen-recording session for device ${api.deviceId} was torn down while this start ` + + `was still initializing, so nothing was recorded. That is either the tool-server shutting ` + + `down or a stop-all-simulator-servers reaping this device (e.g. another agent ending its ` + + `session). If the device is still up, start the recording again.`, { error_code: FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN, failure_stage: stage, diff --git a/packages/tool-server/src/tools/simulator/device-services.ts b/packages/tool-server/src/tools/simulator/device-services.ts new file mode 100644 index 000000000..f52b41eb6 --- /dev/null +++ b/packages/tool-server/src/tools/simulator/device-services.ts @@ -0,0 +1,268 @@ +import { SIMULATOR_SERVER_NAMESPACE } from "../../blueprints/simulator-server"; +import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; +import { ANDROID_DEVTOOLS_NAMESPACE } from "../../blueprints/android-devtools"; +import { CHROMIUM_CDP_NAMESPACE } from "../../blueprints/chromium-cdp"; +import { CHROMIUM_JS_RUNTIME_DEBUGGER_NAMESPACE } from "../../blueprints/chromium-js-runtime-debugger"; +import { TV_CONTROL_NAMESPACE } from "../../blueprints/tv-control"; +import { ANDROID_TV_CONTROL_NAMESPACE } from "../../blueprints/android-tv-control"; +import { AX_SERVICE_NAMESPACE } from "../../blueprints/ax-service"; +import { SCREEN_RECORDING_SESSION_NAMESPACE } from "../../blueprints/screen-recording-session"; +import { NATIVE_PROFILER_SESSION_NAMESPACE } from "../../blueprints/native-profiler-session"; +import { JS_RUNTIME_DEBUGGER_NAMESPACE } from "../../blueprints/js-runtime-debugger"; +import { NETWORK_INSPECTOR_NAMESPACE } from "../../blueprints/network-inspector"; +import { REACT_PROFILER_SESSION_NAMESPACE } from "../../blueprints/react-profiler-session"; +import { isLogicalKeyedDevice } from "../../utils/debugger/device-alias"; + +/** + * Which services one device id owns — the single definition of that mapping, + * shared by `stop-simulator-server` (one device, transport scope) and + * `stop-all-simulator-servers` (every device-owned service). Both resolve a + * URN through one matcher here, so a given udid resolves to the same URNs for + * either tool — case-insensitively, and with the `:tcp` suffix understood. + * + * This unifies how a URN is MATCHED, not which namespaces each tool sweeps: + * `stop-simulator-server` deliberately scopes to the transport session (see + * {@link transportNamespacesForPlatform}) while `stop-all-simulator-servers` + * takes every {@link DEVICE_OWNED_NAMESPACES} entry, so the same udid still + * reaps a different SET through each tool — by design. Nor does it unify how a + * raw id is CLASSIFIED: `stop-simulator-server` picks its namespace set from + * `resolveDevice().platform`, whose prefix tests are case-SENSITIVE, so an id + * spelled in the wrong case can still land on the wrong namespace set there. + */ + +/** + * Every discriminator a device-scoped URN appends AFTER the device id. Only + * `:tcp` exists, and only two namespaces can ever emit it: `axServiceRef` and + * `nativeDevtoolsRef` append it for `transport: "tcp"`. No call site passes + * that option today — including the ios-remote branches, and the remote host's + * forced-TCP decision is made inside the factory, after the ref has already + * fixed the URN — so `:tcp` is a shape the refs can mint rather than one + * production currently produces. Matched anyway so the two stop tools cannot + * drift apart again the moment a caller does pass it. + * + * Enumerated rather than matched as "anything after a colon", because a device + * id can itself end in `:`: an adb serial over wifi is + * `192.168.1.5:5555`, so a suffix wildcard would let the bare `192.168.1.5` + * claim every device at that address and tear down another agent's — while + * reporting nothing unmatched. + */ +const URN_SUFFIXES = ["", ":tcp"] as const; + +/** + * Namespaces whose URN interposes the Metro port between the namespace and the + * device id: `::`. Split off from the plain shape + * because the tail is not the device id — matching these as if it were would + * report every debugger session as belonging to no device. + * + * Only the FIRST colon is consumed. The remainder is compared whole, so a + * wireless adb serial (`JsRuntimeDebugger:8081:192.168.1.5:5555`) still + * resolves to `192.168.1.5:5555` and not to `192.168.1.5`. + */ +export const PORT_KEYED_NAMESPACES: readonly string[] = [ + JS_RUNTIME_DEBUGGER_NAMESPACE, + // Both declare `getDependencies -> JsRuntimeDebugger:`, so neither + // can be in a snapshot without it and neither adds any ownership the debugger + // entry does not already establish. They are listed for what `stopped` + // reports: a session that had a network inspector or a React profiler open is + // told those went away by name, rather than inferring it from the debugger + // line. + NETWORK_INSPECTOR_NAMESPACE, + REACT_PROFILER_SESSION_NAMESPACE, +]; + +/** + * Every namespace whose service belongs to exactly one device and whose + * `dispose()` frees something worth freeing. A device owning none of these is + * not a bad id: Vega is driven by shell-outs — the `vega` CLI for boot and + * launch, adb for describe, screenshot and the remote — so a Vega device owns a + * RUNNING service only once `debugger-connect` or a network-log tool has run. + * `DEBUGGER_TOOL_CAPABILITY` declares `vega: { vvd: true }`, and those two + * (`JsRuntimeDebugger`, `NetworkInspector`) are the only entries here a Vega + * serial can hold live. + * + * It can still MATCH others, because ownership is counted regardless of state + * and a failed resolve leaves its node behind in ERROR (`Registry._resolve` + * inserts before the factory runs, and nothing is ever removed). A tool's + * capability is enforced by the HTTP layer, not by `registry.invokeTool`, so a + * call that reaches the registry another way — `flow-add-step` takes `command` + * as a bare string — can mint e.g. `SimulatorServer:` in ERROR. A + * Vega serial appearing in `stopped` is therefore impossible, but one absent + * from `unmatched` is not. + * + * Membership is decided by "does dispose() reap a resource that outlives the + * call", and every namespace that meets that test is listed even when a cascade + * would already have reached it. Three blueprints declare `getDependencies` — + * NetworkInspector and ReactProfilerSession on `JsRuntimeDebugger`, + * ChromiumJsRuntimeDebugger on `ChromiumCdp` — and teardown runs + * dependency → dependents, so all three can arrive via a cascade. Listing them + * is about what `stopped` names, not about whether they die: an unlisted + * dependent is torn down silently, which contradicts what the tool documents + * `stopped` to be. + * + * - `AXService` owns the in-sim ax daemon (spawned `--timeout 3600`) and its + * socket, and is the only entry that reaps it. An iOS session that only ran + * boot/launch/describe also owns `NativeDevtools` — `bootIos` and + * `launch-app`'s iOS handler both resolve it unconditionally — so leaving + * `AXService` out would not orphan the device, just that daemon. + * - `TvControl` owns two spawned `--timeout 3600` daemons. + * - `ScreenRecordingSession` owns an ffmpeg child, an MJPEG frame stream, and + * the touch-visualizer overlay it enabled on the device. + * - `NativeProfilerSession` owns an xctrace child on iOS, and on Android an + * on-device perfetto process plus its trace file. + * - `JsRuntimeDebugger` owns a bound loopback HTTP/WebSocket server, the CDP + * socket to Metro, and a log file handle. + * - `ChromiumJsRuntimeDebugger` owns a bound loopback server, a log handle and + * its captured console history — but NOT the CDP socket: its `dispose()` + * deliberately leaves that to `ChromiumCdp` (and there is no Metro on the + * chromium path). That is precisely why the narrowness note below holds — + * disposing `ChromiumCdp` cascades to this one BECAUSE this one does not own + * the transport. Its dependency (`ChromiumCdp`) is listed here too, so a + * scoped stop reaches it twice over — as it does the two `JsRuntimeDebugger` + * dependents, whose own dependency is equally listed. All three are here for + * the naming, not for the reaping. What is particular to this one is its URN + * SHAPE: `:`, not port-keyed like the other two dependents, so + * it belongs in this list and not in {@link PORT_KEYED_NAMESPACES}. + * + * (`AndroidTvControl` is stateless adb shell-outs with a no-op dispose, but is + * included for symmetry so the snapshot is fully drained.) + */ +export const DEVICE_OWNED_NAMESPACES: readonly string[] = [ + SIMULATOR_SERVER_NAMESPACE, + NATIVE_DEVTOOLS_NAMESPACE, + ANDROID_DEVTOOLS_NAMESPACE, + CHROMIUM_CDP_NAMESPACE, + CHROMIUM_JS_RUNTIME_DEBUGGER_NAMESPACE, + TV_CONTROL_NAMESPACE, + ANDROID_TV_CONTROL_NAMESPACE, + AX_SERVICE_NAMESPACE, + SCREEN_RECORDING_SESSION_NAMESPACE, + NATIVE_PROFILER_SESSION_NAMESPACE, + ...PORT_KEYED_NAMESPACES, +]; + +/** + * The subset `stop-simulator-server` disposes: the device's transport session, + * plus the TV-control daemons a tvOS udid may own alongside it. + * + * Deliberately narrower than {@link DEVICE_OWNED_NAMESPACES}. That tool is also + * the documented recovery for a wedged transport ("stop it and retry"), and + * widening it to devtools/AX would make a routine retry silently drop the + * native-devtools connection another agent's in-progress recording depends on — + * degrading that flow to coordinate taps, which is the hazard + * `stop-all-simulator-servers`' `devices` scope exists to prevent. Agents + * finishing a session call `stop-all-simulator-servers` instead, which drains + * everything. + * + * That narrowness is only as strong as the dependency graph, and on CHROMIUM it + * does not hold: `ChromiumJsRuntimeDebugger` declares `ChromiumCdp` as a + * dependency, so disposing the transport tears the debugger down as a dependent + * along with its captured console history. Nothing here can prevent that + * without leaving the wedged transport in place, which is the tool's whole + * purpose; `stop-simulator-server`'s description says so outright instead. + */ +export function transportNamespacesForPlatform(platform: string): readonly string[] { + if (platform === "chromium") return [CHROMIUM_CDP_NAMESPACE]; + if (platform === "android") return [SIMULATOR_SERVER_NAMESPACE, ANDROID_TV_CONTROL_NAMESPACE]; + // A tvOS UDID is iOS-shaped and can't be told apart from a phone here without + // an async probe, so cover both. + return [SIMULATOR_SERVER_NAMESPACE, TV_CONTROL_NAMESPACE]; +} + +/** + * The device-id portion of `urn` if it belongs to `namespace`, else undefined. + * Accounts for the two URN shapes (see {@link PORT_KEYED_NAMESPACES}). + */ +function deviceIdPortion(urn: string, namespace: string): string | undefined { + if (!urn.startsWith(`${namespace}:`)) return undefined; + const tail = urn.slice(namespace.length + 1); + if (!PORT_KEYED_NAMESPACES.includes(namespace)) return tail; + const afterPort = tail.indexOf(":"); + return afterPort < 0 ? undefined : tail.slice(afterPort + 1); +} + +/** + * Which entry of `deviceIds` owns `urn` within `namespaces`, if any. The device + * id is compared whole (never split on ":", see {@link URN_SUFFIXES}). + * + * Matching is case-insensitive: iOS UDIDs are conventionally upper-case but + * agents pass through whatever they were given, and a case mismatch must not + * silently turn a scoped stop into a no-op. + * + * That is safe only if no two distinct devices can differ by case alone. Of the + * id spaces we support, six are structurally case-safe: iOS UDIDs (hex UUID), + * `emulator-N`, `chromium-cdp-N`, adb-over-wifi `ip:port`, `remote:` for + * ios-remote, and Vega's `amazon-`. The seventh is an assumption rather + * than a guarantee: a physical Android serial is `ro.serialno`, which + * `device-info.ts` notes is vendor-defined and unconstrained, so a vendor could + * in principle ship two devices differing only in case. Accepted — colliding + * serials on ONE host would already be indistinguishable to `adb -s`, and the + * alternative (case-sensitive matching) reintroduces the silent no-op this + * exists to fix on the id space agents actually mistype, iOS UDIDs. + * + * Returns the caller's spelling of the id, so a tool can report which of the + * ids it was given matched nothing. + */ +export function deviceIdOwningUrn( + urn: string, + namespaces: readonly string[], + deviceIds: readonly string[] +): string | undefined { + for (const namespace of namespaces) { + const portion = deviceIdPortion(urn, namespace); + if (portion === undefined) continue; + const tail = portion.toLowerCase(); + const owner = deviceIds.find((id) => { + const lower = id.toLowerCase(); + return URN_SUFFIXES.some((suffix) => tail === `${lower}${suffix}`); + }); + // No namespace can contain ":", so at most one can prefix a given URN — + // a miss here is a miss outright, not a reason to keep scanning. + return owner; + } + return undefined; +} + +/** Whether `urn` belongs to any of `namespaces`, regardless of which device. */ +export function isDeviceServiceUrn(urn: string, namespaces: readonly string[]): boolean { + return namespaces.some((ns) => urn.startsWith(`${ns}:`)); +} + +/** + * The device-id portion of `urn` under whichever of `namespaces` owns it, in + * that namespace's own URN shape — the same reading {@link deviceIdOwningUrn} + * matches against, minus the caller's id list. Undefined when no namespace in + * the set prefixes it. + */ +export function deviceIdOfUrn(urn: string, namespaces: readonly string[]): string | undefined { + for (const namespace of namespaces) { + const portion = deviceIdPortion(urn, namespace); + if (portion !== undefined) return portion; + } + return undefined; +} + +/** + * Of `urns`, the port-keyed sessions no device-scoped teardown could ever name, + * whatever ids it was given. + * + * `JsRuntimeDebugger`'s URN embeds the id the caller CONNECTED with, and on a + * Metro serving two or more devices that cannot be a UDID or serial: + * `selectTarget` refuses to guess which target a device id means and instructs + * the caller to re-target with the `logicalDeviceId` Metro echoed — an opaque + * per-connection handle `list-devices` never mints, and the only id that then + * resolves the session. A teardown scoped to real device ids therefore leaves + * that session holding its CDP socket to Metro, a bound loopback console + * server and a log file handle; and because the caller's serial still matches + * that device's OTHER services, the serial is not reported `unmatched` either, + * so the whole thing reads as a clean machine. + * + * Which ids those are is not inferred from the URN — it is recorded by the + * connect that minted it, the one place both ids are known at once (see + * {@link isLogicalKeyedDevice}). A session another agent opened with its own + * serial is therefore NOT reported: that id is one `list-devices` hands out, so + * a scope could have named it, and a session left on someone else's device is + * that agent's business rather than a scope that cannot express itself. + */ +export function unnameableSessionUrns(urns: readonly string[]): string[] { + return urns.filter((urn) => isLogicalKeyedDevice(deviceIdOfUrn(urn, PORT_KEYED_NAMESPACES))); +} diff --git a/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts b/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts index ec3d230d8..a304f4219 100644 --- a/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts +++ b/packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts @@ -1,46 +1,127 @@ +import { z } from "zod"; import { ServiceState, isLiveServiceState } from "@argent/registry"; import type { Registry, ToolDefinition } from "@argent/registry"; -import { SIMULATOR_SERVER_NAMESPACE } from "../../blueprints/simulator-server"; -import { NATIVE_DEVTOOLS_NAMESPACE } from "../../blueprints/native-devtools"; -import { ANDROID_DEVTOOLS_NAMESPACE } from "../../blueprints/android-devtools"; -import { CHROMIUM_CDP_NAMESPACE } from "../../blueprints/chromium-cdp"; -import { TV_CONTROL_NAMESPACE } from "../../blueprints/tv-control"; -import { ANDROID_TV_CONTROL_NAMESPACE } from "../../blueprints/android-tv-control"; +import { + DEVICE_OWNED_NAMESPACES, + PORT_KEYED_NAMESPACES, + deviceIdOwningUrn, + isDeviceServiceUrn, + unnameableSessionUrns, +} from "./device-services"; -const PREFIXES = [ - `${SIMULATOR_SERVER_NAMESPACE}:`, - `${NATIVE_DEVTOOLS_NAMESPACE}:`, - `${ANDROID_DEVTOOLS_NAMESPACE}:`, - `${CHROMIUM_CDP_NAMESPACE}:`, - // The Apple TV service owns two spawned daemons (in-sim tvos-ax-service + - // host-side tvos-hid-daemon, both --timeout 3600); only its dispose() reaps - // them and unlinks the sockets. Without this prefix a session-end stop leaves - // them running for up to an hour. (AndroidTvControl is stateless adb shell-outs - // with a no-op dispose, but include it for symmetry so the snapshot is fully - // drained.) - `${TV_CONTROL_NAMESPACE}:`, - `${ANDROID_TV_CONTROL_NAMESPACE}:`, -]; +const zodSchema = z + .object({ + devices: z + .array(z.string()) + .optional() + .describe( + "Device ids (iOS UDID / Android serial / Chromium id) to scope the teardown to — pass the devices THIS session actually used. Omit only for a deliberate machine-wide cleanup: one tool-server serves every agent using this argent install, so an unscoped stop also kills devices another agent is mid-session on." + ), + }) + // `.strict()` because omitting `devices` is the machine-wide sweep, so a + // misspelled key must not be silently stripped down to it. `udids` is the + // natural slip — every sibling tool in this directory spells the device + // parameter `udid`, and this is the only one that spells it `devices` — and + // under a stripping schema that typo tears down every other agent's devices + // while the caller believes it scoped, with `unmatched` unreachable on that + // path so nothing in the response says otherwise. Strict makes it a + // validation error instead, matching `stop-simulator-server`, where the same + // typo already fails loudly because `udid` is required. This also puts + // `additionalProperties: false` in the schema advertised by `GET /tools`, so + // MCP, `argent run` and raw HTTP callers all get the rejection. + .strict(); export function createStopAllSimulatorServersTool( registry: Registry -): ToolDefinition { +): ToolDefinition< + z.infer, + { stopped: string[]; unmatched?: string[]; left_running?: string[]; aborted?: true } +> { return { id: "stop-all-simulator-servers", interaction: { - startedMsg: () => "Stopping all simulator servers", - completedMsg: ({ result }) => - `Stopped ${result.stopped.length} simulator ${result.stopped.length === 1 ? "server" : "servers"}`, + // "all" only holds for the unscoped sweep; a scoped call touches just the + // ids it was given, and saying otherwise would misreport a teardown that + // deliberately left another agent's devices running. + startedMsg: ({ params }) => { + const devices = params?.devices; + return devices + ? `Stopping simulator servers for ${devices.length} ${devices.length === 1 ? "device" : "devices"}` + : "Stopping all simulator servers"; + }, + completedMsg: ({ result }) => { + const n = result.stopped.length; + const base = `Stopped ${n} simulator ${n === 1 ? "server" : "servers"}`; + // `unmatched` is the whole point of the scoped stop: a mistyped id must + // not read as a clean machine. Omitting it here would report exactly + // that — "Stopped 0 simulator servers" for a teardown that reaped + // nothing because every id was wrong. + const unmatched = result.unmatched; + const notes: string[] = []; + if (unmatched?.length) { + notes.push( + `${unmatched.length} supplied ${unmatched.length === 1 ? "id" : "ids"} matched no service` + ); + } + // Same reason as `unmatched`: a debugger session keyed by an id no + // device scope can name is still a session left holding a CDP socket + // and a bound port, and silence about it reads as a clean machine. + const left = result.left_running; + if (left?.length) { + notes.push( + `${left.length} debugger ${left.length === 1 ? "session" : "sessions"} left running` + ); + } + return notes.length ? `${base} (${notes.join("; ")})` : base; + }, failedMsg: ({ failureSignal }) => `Failed to stop simulator servers: ${failureSignal.error_code}`, }, - description: `Stop all running simulator-server processes (iOS + Android), native devtools services, and Chromium CDP sessions, freeing their resources. Call this when your session ends or the user says they are done. Returns { stopped } — an array of URNs that were shut down. Fails silently if no servers are running.`, + description: `Stop the services a device owns - simulator-server processes (iOS + Android), native devtools, the iOS accessibility service, TV-control daemons, Chromium CDP sessions, screen recordings, native profiler sessions, and JS-runtime debugger sessions along with the network inspectors and React profiler sessions that ride on them - freeing their spawned processes, sockets and ports. Call this when your session ends or the user says they are done. +PASS \`devices\` with the device ids this session used — one tool-server serves every agent, subagent and CLI call using this argent install, and an unscoped call tears down THEIR devices too (a mid-recording devtools teardown degrades another agent's flow to brittle coordinate taps; that agent is warned, but its recorded steps are already the worse kind). Omit \`devices\` only when a machine-wide cleanup is what you actually want. Passing an EMPTY array scopes to nothing and stops nothing - it is not a way to ask for the machine-wide sweep. +A JS-runtime debugger session is keyed by the id you called \`debugger-connect\` with. On a Metro serving two or more devices that id is not a udid or serial - connect refuses those and tells you to re-target with the \`logicalDeviceId\` it returns - so a scope built from \`list-devices\` ids cannot reach that session. Pass any such \`logicalDeviceId\` in \`devices\` ALONGSIDE the device id; { left_running } names the ones you missed. +Returns { stopped } - the URNs of the services that were actually live and got shut down; an ERROR node is disposed too but never appears there, so an empty \`stopped\` only means nothing was still running. { unmatched } lists supplied ids that own no service here, so a mistyped id - or a device NAME passed where an id was expected - does not read as a clean machine. It is NOT proof the id is wrong: a Vega device is driven through CLI/adb shell-outs, so one you only booted and drove with the remote registers no service and always lands here — as does a real device of any platform this session never started anything on. Present ONLY when \`devices\` was supplied AND at least one id matched nothing - absent on an unscoped call and when every id matched. Stopping the same device twice does not report it unmatched: ownership counts regardless of service state. { left_running } lists live debugger sessions (and the network inspectors / React profiler sessions riding on them) whose id no device scope can name - re-call with that id to reap them. { aborted: true } means the caller cancelled the request part-way, so the rest of the machine was left untouched and neither of the other two fields was computed. Past the schema - which rejects an unknown key outright, so the \`udids\` slip is an error rather than a silent machine-wide sweep - the call always succeeds; reaping nothing is a result, not a failure.`, + zodSchema, services: () => ({}), - async execute() { + async execute(_services, params, ctx) { + const devices = params.devices; + // Present-but-empty scopes to nothing rather than falling back to the + // machine-wide sweep: a caller that computed a device list and got none + // must not accidentally tear down every other agent's services. + const scoped = devices !== undefined; const snapshot = registry.getSnapshot(); const stopped: string[] = []; + const matchedIds = new Set(); + // Live device-owned services this scope did NOT claim. Only the port-keyed + // ones are ever reported (see `unnameableSessionUrns`) — the rest are + // other agents' devices, which a scoped stop leaves alone by design. + const survivors: string[] = []; + let aborted = false; for (const [urn, entry] of snapshot.services) { - if (PREFIXES.some((p) => urn.startsWith(p)) && entry.state !== ServiceState.IDLE) { + // A sweep is a loop of awaited disposals — thirteen namespaces, each + // reaping spawned processes and sockets — so a caller that has given up + // (an MCP client timing out, a cancelled CLI run) would otherwise be + // billed for the whole of it. Checked between disposals rather than + // inside one: a dispose already under way finishes, since abandoning a + // blueprint mid-teardown is what leaks the handles this tool exists to + // free. + if (ctx?.signal?.aborted) { + aborted = true; + break; + } + const matchedId = scoped + ? deviceIdOwningUrn(urn, DEVICE_OWNED_NAMESPACES, devices) + : undefined; + const matches = scoped + ? matchedId !== undefined + : isDeviceServiceUrn(urn, DEVICE_OWNED_NAMESPACES); + // Ownership is recorded regardless of state. `disposeService` moves a + // node to IDLE without removing it, so a device this session already + // stopped would otherwise be reported as unmatched by the next scoped + // call — turning the routine "stop one, then stop the rest" sequence + // into a false alarm about a mistyped id. + if (matchedId !== undefined) matchedIds.add(matchedId.toLowerCase()); + if (matches && entry.state !== ServiceState.IDLE) { // Dispose any non-IDLE node (this also clears ERROR/TERMINATING // nodes), but only report the ones that were actually live — an // ERROR node (e.g. a tvOS SimulatorServer that refused to start) @@ -48,9 +129,46 @@ export function createStopAllSimulatorServersTool( const wasLive = isLiveServiceState(entry.state); await registry.disposeService(urn); if (wasLive) stopped.push(urn); + } else if ( + scoped && + isLiveServiceState(entry.state) && + isDeviceServiceUrn(urn, PORT_KEYED_NAMESPACES) + ) { + survivors.push(urn); } } - return { stopped }; + // An abort left the rest of the snapshot untouched, so neither `unmatched` + // nor `left_running` can be computed — an id whose only service the sweep + // never reached would read as a typo, and every namespace past the break + // would read as unreachable. Report the partial teardown as partial. + if (aborted) return { stopped, aborted: true }; + if (!scoped) return { stopped }; + // A scoped stop that named an id owning nothing is indistinguishable from + // a clean machine unless we say so — and when that id is a typo, or a + // device NAME passed where an id belongs, its simulator-server, devtools + // and (on tvOS) two --timeout 3600 daemons are being left running. + // Compared AND de-duplicated case-insensitively, to match the lookup: two + // spellings of one id are one mistake, reported in the caller's first + // spelling. + const seen = new Set(); + const unmatched = devices.filter((id) => { + const key = id.toLowerCase(); + if (matchedIds.has(key) || seen.has(key)) return false; + seen.add(key); + return true; + }); + // A debugger session opened against a multi-device Metro is keyed by the + // `logicalDeviceId` Metro echoed, which no `list-devices` id equals — so + // no `devices` scope can reap it, and the ids that DID match keep it out + // of `unmatched`. Name it, so the caller can pass that id (which does + // reap it) instead of reading silence as a clean machine. + const leftRunning = unnameableSessionUrns(survivors); + const result: { stopped: string[]; unmatched?: string[]; left_running?: string[] } = { + stopped, + }; + if (unmatched.length > 0) result.unmatched = unmatched; + if (leftRunning.length > 0) result.left_running = leftRunning; + return result; }, }; } diff --git a/packages/tool-server/src/tools/simulator/stop-simulator-server.ts b/packages/tool-server/src/tools/simulator/stop-simulator-server.ts index 532ed9cc5..0366691d2 100644 --- a/packages/tool-server/src/tools/simulator/stop-simulator-server.ts +++ b/packages/tool-server/src/tools/simulator/stop-simulator-server.ts @@ -1,11 +1,8 @@ import { z } from "zod"; import { ServiceState, isLiveServiceState } from "@argent/registry"; import type { Registry, ToolDefinition } from "@argent/registry"; -import { SIMULATOR_SERVER_NAMESPACE } from "../../blueprints/simulator-server"; -import { CHROMIUM_CDP_NAMESPACE } from "../../blueprints/chromium-cdp"; -import { TV_CONTROL_NAMESPACE } from "../../blueprints/tv-control"; -import { ANDROID_TV_CONTROL_NAMESPACE } from "../../blueprints/android-tv-control"; import { resolveDevice } from "../../utils/device-info"; +import { deviceIdOwningUrn, transportNamespacesForPlatform } from "./device-services"; const zodSchema = z.object({ udid: z @@ -26,31 +23,31 @@ export function createStopSimulatorServerTool( failedMsg: ({ params, failureSignal }) => `Failed to stop simulator server for ${params.udid}: ${failureSignal.error_code}`, }, - description: `Stop the transport session for a specific device (iOS / Android: simulator-server process; Chromium: CDP WebSocket) and free its resources. Use when you are done interacting with one device but want to keep others running. Returns { stopped, udid }. Fails silently if no session is open for the given id.`, + description: `Stop the transport session for a specific device (iOS / Android: simulator-server process; Chromium: CDP WebSocket) and free its resources; on a TV target it also reaps that device's TV-control daemons. Use when you are done interacting with one device but want to keep others running, or to restart a wedged transport. On iOS / Android / TV it deliberately leaves this device's native-devtools, accessibility, profiler and debugger services running - to drain those as well, use stop-all-simulator-servers with \`devices\`. On CHROMIUM that does not hold: the JS-runtime debugger declares the CDP session as a dependency, so stopping the transport cascades to it and its captured console history goes with it - reconnect with debugger-connect afterwards. Returns { stopped, udid }. Fails silently if no session is open for the given id.`, zodSchema, services: () => ({}), async execute(_services, params) { const udid = (params as { udid: string }).udid; // A single device id can back more than one service: the transport // (SimulatorServer / ChromiumCdp) and — for a TV target — the focus-driven - // TvControl daemon, which owns the spawned tvos-ax/tvos-hid processes. A - // tvOS UDID is iOS-shaped, so we can't tell it apart from a phone here - // without an async probe; instead, dispose every namespace this id could - // own and report `stopped` if any of them was live. Shape narrows the set: - // chromium ids only have a CDP session; everything else can be a simulator - // server and/or a TV-control service. + // TvControl daemon, which owns the spawned tvos-ax/tvos-hid processes. + // Shape narrows the set; see `transportNamespacesForPlatform` for why it + // stops there rather than draining everything this device owns. const platform = resolveDevice(udid).platform; - const namespaces = - platform === "chromium" - ? [CHROMIUM_CDP_NAMESPACE] - : platform === "android" - ? [SIMULATOR_SERVER_NAMESPACE, ANDROID_TV_CONTROL_NAMESPACE] - : [SIMULATOR_SERVER_NAMESPACE, TV_CONTROL_NAMESPACE]; + const namespaces = transportNamespacesForPlatform(platform); const snapshot = registry.getSnapshot(); let stopped = false; - for (const namespace of namespaces) { - const urn = `${namespace}:${udid}`; + // Scanned rather than looked up by exact URN, so this agrees with + // `stop-all-simulator-servers` on which services a device id owns — in + // particular the match is case-insensitive, where an exact + // `services.get()` would silently no-op on a lower-cased UDID. (The shared + // matcher also understands the `:tcp` suffix, which no namespace in this + // tool's set currently emits — it costs nothing and keeps one grammar.) + const urns = [...snapshot.services.keys()].filter( + (urn) => deviceIdOwningUrn(urn, namespaces, [udid]) !== undefined + ); + for (const urn of urns) { const entry = snapshot.services.get(urn); if (!entry || entry.state === ServiceState.IDLE) continue; // A non-live node (ERROR / TERMINATING) holds no running process — e.g. diff --git a/packages/tool-server/src/utils/debugger/device-alias.ts b/packages/tool-server/src/utils/debugger/device-alias.ts index 8e6fe0a76..e70fe21d8 100644 --- a/packages/tool-server/src/utils/debugger/device-alias.ts +++ b/packages/tool-server/src/utils/debugger/device-alias.ts @@ -58,7 +58,48 @@ export function forgetDeviceAlias(logicalDeviceId: string | undefined): void { if (logicalDeviceId) logicalIdToConnectId.delete(logicalDeviceId); } +/** + * Connect ids that ARE a Metro `logicalDeviceId` — the case the alias above has + * nothing to record, because the two ids are the same string. + * + * It happens whenever two or more devices share one Metro: `selectTarget` + * refuses to guess which target a udid or serial means and tells the caller to + * re-target with the logicalDeviceId, so that is what the debugger service ends + * up keyed by. Nothing joins such an id back to a device — Metro never sees the + * udid — so a teardown scoped to `list-devices` ids cannot reach the session, + * and its serial still matches that device's other services, so the miss is + * invisible. `stop-all-simulator-servers` reads this to say so. + * + * Recorded at connect, which is the only place the two ids are compared, and + * dropped on dispose alongside the alias. + */ +const logicalKeyedConnectIds = new Set(); + +/** + * Note that `connectDeviceId` is itself the `logicalDeviceId` Metro echoed, so + * no device-scoped teardown can name the session it keys. No-op otherwise. + */ +export function rememberLogicalKeyedDevice( + logicalDeviceId: string | undefined, + connectDeviceId: string +): void { + if (logicalDeviceId && logicalDeviceId === connectDeviceId) { + logicalKeyedConnectIds.add(connectDeviceId.toLowerCase()); + } +} + +/** Whether `deviceId` keys a session only its logicalDeviceId can address. */ +export function isLogicalKeyedDevice(deviceId: string | undefined): boolean { + return deviceId !== undefined && logicalKeyedConnectIds.has(deviceId.toLowerCase()); +} + +/** Drop the logical-keyed marker when its debugger connection is disposed. */ +export function forgetLogicalKeyedDevice(connectDeviceId: string): void { + logicalKeyedConnectIds.delete(connectDeviceId.toLowerCase()); +} + /** Test-only: clear all learned aliases. */ export function resetDeviceAliases(): void { logicalIdToConnectId.clear(); + logicalKeyedConnectIds.clear(); } diff --git a/packages/tool-server/src/utils/reaped-sessions.ts b/packages/tool-server/src/utils/reaped-sessions.ts new file mode 100644 index 000000000..cea84e482 --- /dev/null +++ b/packages/tool-server/src/utils/reaped-sessions.ts @@ -0,0 +1,108 @@ +/** + * Process-global record of capture sessions a teardown reaped while they still + * held data nobody had retrieved. + * + * `stop-all-simulator-servers` disposes every device-owned service, which since + * the `devices` scope landed includes the three that hold captured output — + * `ScreenRecordingSession` (a video), `NativeProfilerSession` (a trace) and + * `JsRuntimeDebugger` (a console-log file). Disposing them is deliberate: each + * owns a spawned process or an open fd that must not outlive the session. + * + * What is not deliberate is what the owner is then told. `Registry._teardown` + * nulls the node's instance, so the next tool call resolves a FRESH service + * whose api is indistinguishable from one that never ran — and the stop tools + * answer "no active session, call start first" for a capture that did run and + * whose output may still be on disk. That reads as "you never started one", + * which is the one thing that is certainly false. + * + * So the disposer leaves a breadcrumb here and the tool that would otherwise + * report absence reports the teardown instead. Module-global for the same + * reason as `screen-recording-reminder`: it has to outlive the service instance + * it describes, which is exactly what teardown destroys. + * + * Entries are CONSUMED by the read ({@link takeReapedSession}) — the breadcrumb + * explains one confusing answer, once. Leaving it would make a genuine later + * "you never started a recording" blame a teardown from an hour ago. + */ + +/** Which session kind was reaped; scopes the key so two kinds can't collide. */ +export type ReapedSessionKind = "screen-recording" | "native-profiler" | "js-runtime-debugger"; + +export interface ReapedSession { + kind: ReapedSessionKind; + deviceId: string; + /** When the teardown ran, for "…N seconds ago" phrasing. */ + atMs: number; + /** + * What survived, as a ready-to-read clause (e.g. naming a salvaged file), or + * undefined when nothing did. Built by the disposer, which is the only place + * that still knows. + */ + salvage?: string; +} + +const reaped = new Map(); + +function key(kind: ReapedSessionKind, deviceId: string): string { + return `${kind}:${deviceId.toLowerCase()}`; +} + +/** + * Note that `kind`'s session for `deviceId` was disposed with data unretrieved. + * + * Call ONLY when there was something to lose: a dispose of an idle session is + * routine cleanup, and recording it would make the next honest "no active + * session" answer claim a teardown destroyed something. + */ +export function recordReapedSession( + kind: ReapedSessionKind, + deviceId: string, + salvage?: string +): void { + const entry: ReapedSession = { kind, deviceId, atMs: Date.now() }; + if (salvage) entry.salvage = salvage; + reaped.set(key(kind, deviceId), entry); +} + +/** Read and consume the breadcrumb for `kind`/`deviceId`, if there is one. */ +export function takeReapedSession( + kind: ReapedSessionKind, + deviceId: string +): ReapedSession | undefined { + const k = key(kind, deviceId); + const entry = reaped.get(k); + if (entry) reaped.delete(k); + return entry; +} + +/** + * The sentence a tool shows in place of "no active session". Names what + * happened, says it is not necessarily this agent's own doing (one tool-server + * serves every agent), and points at whatever survived. + * + * The disposer that leaves a breadcrumb cannot see who triggered it — a + * blueprint's `dispose()` is called by `Registry._teardown`, with no caller — so + * the message names the family rather than asserting one member. + * `stop-all-simulator-servers` is the common one and is named first, but it is + * not the only one: `stop-simulator-server` on Chromium cascades into the + * debugger through `ChromiumCdp` (its documented behaviour), and + * `react-profiler-start { force: true }` disposes the debugger and the profiler + * session to reclaim them. + */ +export function describeReapedSession(entry: ReapedSession, what: string): string { + const secondsAgo = Math.max(0, Math.round((Date.now() - entry.atMs) / 1000)); + return ( + `The ${what} for device ${entry.deviceId} was torn down ${secondsAgo}s ago — by a ` + + `stop-all-simulator-servers, which reaps every service a device owns, or by another ` + + `teardown that reaches the same services (a stop-simulator-server on Chromium, or a ` + + `react-profiler-start reclaiming the session with force). One tool-server serves every ` + + `agent using this argent install, so this may have been another agent rather than your own ` + + `call. It was not a session that never started.` + + (entry.salvage ? ` ${entry.salvage}` : "") + ); +} + +/** Test-only: drop all breadcrumbs so cases don't leak across tests. */ +export function __resetReapedSessionsForTesting(): void { + reaped.clear(); +} diff --git a/packages/tool-server/test/chromium-js-runtime-debugger.test.ts b/packages/tool-server/test/chromium-js-runtime-debugger.test.ts index 71aae0e4d..0ef641f55 100644 --- a/packages/tool-server/test/chromium-js-runtime-debugger.test.ts +++ b/packages/tool-server/test/chromium-js-runtime-debugger.test.ts @@ -8,6 +8,7 @@ import { import { resolveDevice } from "../src/utils/device-info"; import type { ChromiumCdpApi } from "../src/blueprints/chromium-cdp"; import type { CDPClientEvents } from "../src/utils/debugger/cdp-client"; +import { takeReapedSession, __resetReapedSessionsForTesting } from "../src/utils/reaped-sessions"; function makeFakeChromiumCdpApi(): { api: ChromiumCdpApi; @@ -131,6 +132,50 @@ describe("ChromiumJsRuntimeDebugger blueprint", () => { expect(received).toHaveLength(0); }); + it("dispose leaves a reaped-session breadcrumb when it deletes captured history", async () => { + // `debugger-log-registry` documents itself as working against Hermes AND + // V8, and promises that an empty registry with no `note` means the app + // logged nothing. `logWriter.close()` here unlinks the log file, and since + // ChromiumJsRuntimeDebugger joined DEVICE_OWNED_NAMESPACES a + // stop-all-simulator-servers (or a stop-simulator-server cascading through + // ChromiumCdp) routinely triggers this dispose. Without the breadcrumb the + // promise is false on V8: destroyed history reads as a silent app. + __resetReapedSessionsForTesting(); + const fake = makeFakeChromiumCdpApi(); + const instance = await chromiumJsRuntimeDebuggerBlueprint.factory( + { chromium: fake.api }, + "chromium-cdp-19222", + { device: chromiumDevice } + ); + for (let i = 0; i < 18; i++) { + instance.api.logWriter.write({ + id: i, + timestamp: new Date(1710000000000 + i * 1000).toISOString(), + level: "log", + message: `captured ${i}`, + }); + } + await instance.dispose(); + + const reaped = takeReapedSession("js-runtime-debugger", "chromium-cdp-19222"); + expect(reaped).toBeDefined(); + expect(reaped!.salvage).toContain("18 captured console entries"); + }); + + it("dispose leaves NO breadcrumb when there was no history to lose", async () => { + // A dispose of a session that captured nothing destroyed nothing, and + // claiming otherwise would make every empty registry look like a lost one. + __resetReapedSessionsForTesting(); + const fake = makeFakeChromiumCdpApi(); + const instance = await chromiumJsRuntimeDebuggerBlueprint.factory( + { chromium: fake.api }, + "chromium-cdp-19222", + { device: chromiumDevice } + ); + await instance.dispose(); + expect(takeReapedSession("js-runtime-debugger", "chromium-cdp-19222")).toBeUndefined(); + }); + it("dispose does NOT disconnect the underlying CDP — that belongs to ChromiumCdp", async () => { const fake = makeFakeChromiumCdpApi(); // Track whether anything calls disconnect on the cdp. diff --git a/packages/tool-server/test/failure-classification.test.ts b/packages/tool-server/test/failure-classification.test.ts index e9f1994ce..1929d0e95 100644 --- a/packages/tool-server/test/failure-classification.test.ts +++ b/packages/tool-server/test/failure-classification.test.ts @@ -2,12 +2,10 @@ import { createServer, type Server } from "node:http"; import { describe, it, expect, afterEach } from "vitest"; import { FAILURE_CODES, getFailureSignal, type FailureCode } from "@argent/registry"; -import { - setActiveProjectRoot, - clearActiveProjectRoot, - assertSafeFlowName, - getFlowPath, -} from "../src/tools/flows/flow-utils"; +import { assertValidProjectRoot, assertSafeFlowName } from "../src/tools/flows/flow-utils"; +import { createFlowAddStepTool } from "../src/tools/flows/flow-add-step"; +import { flowInsertEchoTool } from "../src/tools/flows/flow-insert-echo"; +import { createRunFlowTool } from "../src/tools/flows/flow-run"; import type { DeviceInfo, Registry } from "@argent/registry"; import { makeChromiumImpl } from "../src/tools/keyboard/platforms/chromium"; import { chromiumCdpBlueprint } from "../src/blueprints/chromium-cdp"; @@ -77,8 +75,6 @@ function startServer(handler: (path: string, res: import("node:http").ServerResp } afterEach(async () => { - // setActiveProjectRoot mutates module state; reset so cases don't leak. - clearActiveProjectRoot(); // Tear down any local servers a case spun up. await Promise.all(openServers.splice(0).map((s) => new Promise((r) => s.close(() => r())))); }); @@ -86,30 +82,92 @@ afterEach(async () => { describe("flow-utils classifications", () => { it("classifies a relative project_root as FLOW_PROJECT_ROOT_INVALID", () => { expectCode( - captureSync(() => setActiveProjectRoot("relative/path")), + captureSync(() => assertValidProjectRoot("relative/path")), FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID ); }); it("classifies a project_root containing '..' as FLOW_PROJECT_ROOT_INVALID", () => { expectCode( - captureSync(() => setActiveProjectRoot("/a/../b")), + captureSync(() => assertValidProjectRoot("/a/../b")), FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID ); }); - it("classifies path resolution with no active project_root as FLOW_PROJECT_ROOT_REQUIRED", () => { - // No active root → getFlowPath → getFlowsDir → requireActiveProjectRoot throws. + it("classifies an unsafe flow name as FLOW_NAME_INVALID", () => { expectCode( - captureSync(() => getFlowPath("valid-name")), - FAILURE_CODES.FLOW_PROJECT_ROOT_REQUIRED + captureSync(() => assertSafeFlowName("bad name!")), + FAILURE_CODES.FLOW_NAME_INVALID ); }); +}); - it("classifies an unsafe flow name as FLOW_NAME_INVALID", () => { +describe("flow tool project_root classifications", () => { + // Every flow tool takes `project_root` from the caller, so the invalid-root + // throw is reachable from a tool's execute — not just from the helper above. + // Root validation happens inside getFlowPath, which runs BEFORE the recording + // is looked up (`requireRecordingSession` keys the session map by that path), + // so a bad root reports FLOW_PROJECT_ROOT_INVALID rather than the + // FLOW_NO_ACTIVE_RECORDING it would earn if the lookup came first — even + // though no recording was ever started here. + const RELATIVE_ROOT = "relative/project"; + const DOTDOT_ROOT = "/tmp/project/../../etc"; + + // Both tools throw on the root before any tool dispatch or file read, so a + // bare stub registry is never actually used. + const registry = {} as unknown as Registry; + + it("classifies flow-add-step with a relative project_root as FLOW_PROJECT_ROOT_INVALID", async () => { + const addStep = createFlowAddStepTool(registry); + const err = await captureError( + addStep.execute( + {}, + { name: "some-flow", project_root: RELATIVE_ROOT, command: "gesture-tap" } + ) + ); + expectCode(err, FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID); + // The root is judged first: no "No active recording" for an unstarted flow. + expect((err as Error).message).not.toMatch(/No active recording/); + }); + + it("classifies flow-add-step with a '..'-bearing project_root as FLOW_PROJECT_ROOT_INVALID", async () => { + const addStep = createFlowAddStepTool(registry); expectCode( - captureSync(() => assertSafeFlowName("bad name!")), - FAILURE_CODES.FLOW_NAME_INVALID + await captureError( + addStep.execute( + {}, + { name: "some-flow", project_root: DOTDOT_ROOT, command: "gesture-tap" } + ) + ), + FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID + ); + }); + + it("classifies flow-add-echo with a relative project_root as FLOW_PROJECT_ROOT_INVALID", async () => { + expectCode( + await captureError( + flowInsertEchoTool.execute( + {}, + { name: "some-flow", project_root: RELATIVE_ROOT, message: "label" } + ) + ), + FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID + ); + }); + + it("classifies flow-execute with a relative project_root as FLOW_PROJECT_ROOT_INVALID", async () => { + const runFlow = createRunFlowTool(registry); + expectCode( + await captureError(runFlow.execute({}, { name: "some-flow", project_root: RELATIVE_ROOT })), + FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID + ); + }); + + it("classifies flow-execute with a '..'-bearing project_root as FLOW_PROJECT_ROOT_INVALID", async () => { + const runFlow = createRunFlowTool(registry); + expectCode( + await captureError(runFlow.execute({}, { name: "some-flow", project_root: DOTDOT_ROOT })), + FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID ); }); }); diff --git a/packages/tool-server/test/flows/flow-composition.test.ts b/packages/tool-server/test/flows/flow-composition.test.ts index 2ed6619ee..9bbe98387 100644 --- a/packages/tool-server/test/flows/flow-composition.test.ts +++ b/packages/tool-server/test/flows/flow-composition.test.ts @@ -2375,10 +2375,45 @@ describe("device binding (portability)", () => { expect(out).toEqual({ foo: 1 }); }); - it("stripDeviceKeys removes udid / device_id / device", () => { + it("stripDeviceKeys removes udid / device_id / device, leaving other args untouched", () => { expect(stripDeviceKeys({ udid: "A", device_id: "B", device: "C", x: 1 })).toEqual({ x: 1 }); }); + it("stripDeviceKeys KEEPS a `devices` scope, because dropping it changes the step's meaning", () => { + // A target is stripped so the flow points at no device. A scope is not: + // `stop-all-simulator-servers` with no `devices` is the machine-wide sweep, + // so stripping it would record a correctly scoped teardown as a bare step + // that reaps every device on the machine when hand-run from the YAML — the + // manual-execution strategy the create-flow skill documents. Replay rebinds + // it either way (see bindDeviceArgs below). + expect(stripDeviceKeys({ udid: "A", devices: ["D", "E"], x: 1 })).toEqual({ + devices: ["D", "E"], + x: 1, + }); + }); + + it("bindDeviceArgs keeps a recorded scope when the run resolved NO device", () => { + // A cleanup flow resolves no device when none is unambiguous. Dropping the + // recorded scope there would widen the teardown from the devices the + // recording named to every device on the machine — the one direction that + // costs another agent their session. There is no run target to override. + expect( + bindDeviceArgs(reg({ devices: {} }), "stop-all-simulator-servers", "", { + devices: ["RECORDED"], + }) + ).toEqual({ devices: ["RECORDED"] }); + }); + + it("bindDeviceArgs never forwards a scope to a tool that does not declare it", () => { + // The schema-blind strip's job: a `.strict()` schema would reject the call. + expect( + bindDeviceArgs(reg({ port: {} }), "stop-metro", "RESOLVED", { + devices: ["RECORDED"], + port: 8081, + }) + ).toEqual({ port: 8081 }); + }); + it("rebinds a nested flow-execute onto the run device (issue #607)", () => { // `flow-execute`'s own device parameter is named `device`, so before it was // a bind key a recorded nested step kept the id it was recorded on and the @@ -2400,6 +2435,76 @@ describe("device binding (portability)", () => { // bound, because device resolution returns before it is ever read. expect(stripDeviceKeys({ platform: "android", x: 1 })).toEqual({ platform: "android", x: 1 }); }); + + it("injects devices: [resolvedId] for a tool that declares it in its schema", () => { + // stop-all-simulator-servers' `devices` is a scope, not a single-device + // target, but it names the recording host's device ids the same way `udid` + // does — so it gets the same schema-aware rebind, as a one-element list. + const out = bindDeviceArgs(reg({ devices: {} }), "stop-all-simulator-servers", "RESOLVED", {}); + expect(out).toEqual({ devices: ["RESOLVED"] }); + }); + + it("does not invent a devices key for a tool that doesn't declare it", () => { + const out = bindDeviceArgs(reg({ foo: {} }), "x", "RESOLVED", { foo: 1 }); + expect(out).toEqual({ foo: 1 }); + expect(out).not.toHaveProperty("devices"); + }); + + it("replaces a stale recorded devices list when the caller NAMED the run device", () => { + // An explicit `device` is the caller saying which device this run is about, + // so retargeting the teardown at it is what they asked for — and a flow + // recorded on one host must not carry that host's ids forward. + const out = bindDeviceArgs( + reg({ devices: {} }), + "stop-all-simulator-servers", + "RESOLVED", + { devices: ["OLD-HOST-ID", "OTHER"] }, + true + ); + expect(out).toEqual({ devices: ["RESOLVED"] }); + }); + + it("keeps a recorded scope when the run device was only auto-detected", () => { + // The destructive direction: the flow named one device, exactly one other + // happens to be booted, and replay would reap THAT one — a device nobody in + // this run ever named, quite possibly another agent's. This is the + // cross-agent teardown the `devices` scope exists to prevent, so the + // recorded ids stand; on another host they reap nothing and come back in + // `unmatched`, which is the safe direction and a legible one. + const out = bindDeviceArgs(reg({ devices: {} }), "stop-all-simulator-servers", "AUTO", { + devices: ["RECORDED-HOST"], + }); + expect(out).toEqual({ devices: ["RECORDED-HOST"] }); + }); + + it("still narrows an UNSCOPED recorded sweep onto an auto-detected device", () => { + // Nothing recorded means the step is the machine-wide sweep, so binding can + // only narrow it. That is why a cleanup flow resolves a device at all. + const out = bindDeviceArgs(reg({ devices: {} }), "stop-all-simulator-servers", "AUTO", {}); + expect(out).toEqual({ devices: ["AUTO"] }); + }); + + it("binds a scalar and a list device key together when a tool declares both", () => { + const out = bindDeviceArgs( + reg({ udid: {}, devices: {} }), + "hypothetical-tool", + "RESOLVED", + { udid: "STALE", devices: ["OLD"] }, + true + ); + expect(out).toEqual({ udid: "RESOLVED", devices: ["RESOLVED"] }); + }); + + it("rebinds the TARGET but not the recorded SCOPE on an auto-detected device", () => { + // The two keys part company here: a stale `udid` must never survive (the + // step would drive the wrong device), while a stale `devices` must never be + // retargeted (the step would destroy the wrong device). + const out = bindDeviceArgs(reg({ udid: {}, devices: {} }), "hypothetical-tool", "AUTO", { + udid: "STALE", + devices: ["OLD"], + }); + expect(out).toEqual({ udid: "AUTO", devices: ["OLD"] }); + }); }); describe("flow validation", () => { diff --git a/packages/tool-server/test/flows/flow-concurrent-recording.test.ts b/packages/tool-server/test/flows/flow-concurrent-recording.test.ts new file mode 100644 index 000000000..d9e88da71 --- /dev/null +++ b/packages/tool-server/test/flows/flow-concurrent-recording.test.ts @@ -0,0 +1,1973 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { FAILURE_CODES, getFailureSignal } from "@argent/registry"; +import type { Registry } from "@argent/registry"; + +import { flowStartRecordingTool } from "../../src/tools/flows/flow-start-recording"; +import { flowInsertEchoTool } from "../../src/tools/flows/flow-insert-echo"; +import { flowFinishRecordingTool } from "../../src/tools/flows/flow-finish-recording"; +import { createFlowAddStepTool } from "../../src/tools/flows/flow-add-step"; +import { createRunFlowTool } from "../../src/tools/flows/flow-run"; +import { flowReadPrerequisiteTool } from "../../src/tools/flows/flow-read-prerequisite"; +import { formatErrorForAgent } from "../../src/utils/format-error"; +import { + __resetRecordingsForTesting, + getRecordingSession, + listActiveRecordings, + MAX_RECORDINGS, + parseFlow, + serializeFlow, + withFlowFileLock, + __flowFileLockCountForTesting, + type FlowFile, + type FlowStep, +} from "../../src/tools/flows/flow-utils"; + +// Wrap (not replace) `rename` so every call still does the real filesystem +// rename — every other test's atomicity assertions depend on that — while +// letting the atomic-swap test below inspect exactly which paths each write +// renamed between. Everything else in `node:fs/promises` passes through +// untouched. +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, rename: vi.fn(actual.rename) }; +}); + +/** + * Concurrency contract of the recording tools. One tool-server serves every MCP + * client, subagent and CLI call using one argent install, so several agents can + * legitimately be recording at the same moment — in one project or across + * projects. (Two INSTALLS run two servers and two recording maps; nothing here + * covers that, and nothing can — see the note on `recordings` in flow-utils.) + * A recording is identified by its + * (project_root, name) key, and these tests assert the ISOLATION that follows: + * one recording's steps never land in another's file, addressing a key that + * isn't live fails loudly (naming the ones that are), replaying a flow + * elsewhere rebinds nothing, and appends to one session can't lose each other. + * + * The second half pins the *mutual exclusion* that makes the above hold when + * the tools genuinely overlap. Every recording tool's critical section straddles + * an await — a restart's truncate-then-register, a finish's read-then-clear, an + * append's read-then-write — so each is covered by the per-flow-file lock, and a + * step that resolved its session before some other tool superseded it must fail + * rather than write into a file that now belongs to a different take. A finish + * that fails inside its critical section must also leave the recording live, so + * the take survives the failure and can be finished on a retry. + */ + +const IOS_DEVICE = "00000000-0000-0000-0000-0000000000ab"; + +// ── Harness ────────────────────────────────────────────────────────── + +let roots: string[] = []; + +/** A real temp dir standing in for one agent's project root. */ +async function makeRoot(label: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), `flow-concurrent-${label}-`)); + roots.push(dir); + return dir; +} + +/** A promise plus the function that resolves it. */ +function openGate(): { promise: Promise; open: () => void } { + let open!: () => void; + const promise = new Promise((resolve) => { + open = () => resolve(); + }); + return { promise, open }; +} + +/** Installed by {@link gateNextSubTool}; consumed by the mock registry. */ +let subToolGate: (() => Promise) | null = null; + +/** + * Suspend the NEXT live sub-tool execution and report when it is reached. + * + * flow-add-step resolves its recording session, runs the step LIVE (which can + * take minutes on a device), and only then appends. Parking a step inside that + * window is what puts an append genuinely in flight across a concurrent + * restart / finish / eviction — deterministically, with no timing guesses. + */ +function gateNextSubTool(): { reached: Promise; release: () => void } { + const arrived = openGate(); + const held = openGate(); + subToolGate = async () => { + // One-shot: later calls (including the ones asserting the recording still + // works afterwards) run straight through. + subToolGate = null; + arrived.open(); + await held.promise; + }; + return { reached: arrived.promise, release: held.open }; +} + +function createMockRegistry(): Registry { + return { + invokeTool: vi.fn(async (id: string) => { + if (id === "list-devices") return { devices: [] }; + // Yield a macrotask, so calls issued without an await in between all + // finish their LIVE phase before any of them appends. This is NOT what + // creates the overlap the file tests: `appendStep`'s own + // `await fs.readFile` already suspends every caller inside the + // read-modify-write, so the append phases interleave with or without this + // line. It stands in for a real sub-tool's I/O, and lines the calls up at + // the same starting gun. + await new Promise((resolve) => setTimeout(resolve, 0)); + if (subToolGate) await subToolGate(); + return { ok: true }; + }), + getTool: vi.fn(() => ({ inputSchema: { properties: { udid: {} } } })), + } as unknown as Registry; +} + +const registry = createMockRegistry(); +const addStepTool = createFlowAddStepTool(registry); + +const flowPath = (root: string, name: string): string => + path.join(root, ".argent", "flows", `${name}.yaml`); + +function start(root: string, name: string, executionPrerequisite?: string) { + return flowStartRecordingTool.execute({}, { name, project_root: root, executionPrerequisite }); +} + +function addRawStep(root: string, name: string, command: string, args: Record) { + return addStepTool.execute({}, { name, project_root: root, command, args: JSON.stringify(args) }); +} + +/** Record a `tool` step tagged with `marker`, so its file of origin is provable. */ +function addStep(root: string, name: string, marker: string) { + return addRawStep(root, name, "keyboard", { text: marker }); +} + +function addEcho(root: string, name: string, message: string) { + return flowInsertEchoTool.execute({}, { name, project_root: root, message }); +} + +function finish(root: string, name: string) { + return flowFinishRecordingTool.execute({}, { name, project_root: root }); +} + +async function writeSavedFlow(root: string, name: string, flow: FlowFile): Promise { + await fs.mkdir(path.dirname(flowPath(root, name)), { recursive: true }); + await fs.writeFile(flowPath(root, name), serializeFlow(flow), "utf8"); +} + +/** Collapse steps to their markers so a file's contents read at a glance. */ +function markers(steps: FlowStep[]): string[] { + return steps.map((step) => { + if (step.kind === "echo") return `echo:${step.message}`; + if (step.kind === "tool") return `tool:${String(step.args.text)}`; + return step.kind; + }); +} + +async function readSteps(root: string, name: string): Promise { + return parseFlow(await fs.readFile(flowPath(root, name), "utf8")).steps; +} + +async function readMarkers(root: string, name: string): Promise { + return markers(await readSteps(root, name)); +} + +async function captureFailure(promise: Promise): Promise { + try { + await promise; + } catch (err) { + return err; + } + throw new Error("expected the call to fail"); +} + +/** Let real timers and in-flight fs I/O drain, so "still blocked" means blocked. */ +function settle(ms = 25): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Resolve to `label` if `promise` settles in time, else to "timed-out". */ +async function within(promise: Promise, label: string, ms = 2000): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve("timed-out"), ms); + }); + try { + return await Promise.race([promise.then(() => label), timeout]); + } finally { + clearTimeout(timer); + } +} + +/** Fill the recording table exactly to its cap; returns the names, oldest first. */ +async function fillRecordings(root: string): Promise { + const names = Array.from({ length: MAX_RECORDINGS }, (_, i) => `rec-${i}`); + for (const name of names) await start(root, name); + return names; +} + +beforeEach(() => { + __resetRecordingsForTesting(); + subToolGate = null; + roots = []; +}); + +afterEach(async () => { + __resetRecordingsForTesting(); + subToolGate = null; + await Promise.all(roots.map((dir) => fs.rm(dir, { recursive: true, force: true }))); + roots = []; +}); + +// ── Two recordings, one project ────────────────────────────────────── + +describe("two recordings in one project", () => { + it("keeps interleaved steps on their own files, in order", async () => { + const root = await makeRoot("one-project"); + await start(root, "alpha"); + await start(root, "beta"); + + expect( + listActiveRecordings() + .map((r) => r.name) + .sort() + ).toEqual(["alpha", "beta"]); + + // Interleave the two recordings the way two agents sharing the server would. + await addStep(root, "alpha", "a1"); + await addEcho(root, "beta", "b1"); + await addStep(root, "alpha", "a2"); + await addEcho(root, "beta", "b2"); + + expect(await readMarkers(root, "alpha")).toEqual(["tool:a1", "tool:a2"]); + expect(await readMarkers(root, "beta")).toEqual(["echo:b1", "echo:b2"]); + }); + + it("finishing one leaves the other live and still appendable", async () => { + const root = await makeRoot("one-project-finish"); + await start(root, "alpha"); + await start(root, "beta"); + await addStep(root, "alpha", "a1"); + await addEcho(root, "beta", "b1"); + + const finished = await finish(root, "alpha"); + expect(finished.path).toBe(flowPath(root, "alpha")); + expect(markers(parseFlow(finished.flowFile).steps)).toEqual(["tool:a1"]); + expect(finished.steps).toBe(1); + + // Only alpha's key was cleared. + expect(await getRecordingSession(root, "alpha")).toBeUndefined(); + expect((await getRecordingSession(root, "beta"))?.filePath).toBe(flowPath(root, "beta")); + + // beta keeps recording into its own file. + await addEcho(root, "beta", "b2"); + await addStep(root, "beta", "b3"); + const finishedB = await finish(root, "beta"); + expect(markers(parseFlow(finishedB.flowFile).steps)).toEqual(["echo:b1", "echo:b2", "tool:b3"]); + expect(await readMarkers(root, "beta")).toEqual(["echo:b1", "echo:b2", "tool:b3"]); + // alpha was never reopened by beta's appends. + expect(await readMarkers(root, "alpha")).toEqual(["tool:a1"]); + }); +}); + +// ── One name, two project roots ────────────────────────────────────── + +describe("the same flow name under two project roots", () => { + it("records each project's steps into that project's file only", async () => { + const rootA = await makeRoot("root-a"); + const rootB = await makeRoot("root-b"); + + await start(rootA, "checkout", "Cart has one item"); + await start(rootB, "checkout", "Cart is empty"); + + await addStep(rootA, "checkout", "a1"); + await addStep(rootB, "checkout", "b1"); + await addEcho(rootA, "checkout", "a2"); + await addStep(rootB, "checkout", "b2"); + + expect(await readMarkers(rootA, "checkout")).toEqual(["tool:a1", "echo:a2"]); + expect(await readMarkers(rootB, "checkout")).toEqual(["tool:b1", "tool:b2"]); + + // Sessions carry their own project root and prerequisite, not the other's. + expect((await getRecordingSession(rootA, "checkout"))?.projectRoot).toBe(rootA); + expect((await getRecordingSession(rootB, "checkout"))?.projectRoot).toBe(rootB); + + const finishedA = await finish(rootA, "checkout"); + expect(finishedA.path).toBe(flowPath(rootA, "checkout")); + expect(finishedA.executionPrerequisite).toBe("Cart has one item"); + + // B is untouched by A finishing, and still resolves to B's file. + const finishedB = await finish(rootB, "checkout"); + expect(finishedB.path).toBe(flowPath(rootB, "checkout")); + expect(finishedB.executionPrerequisite).toBe("Cart is empty"); + expect(markers(parseFlow(finishedB.flowFile).steps)).toEqual(["tool:b1", "tool:b2"]); + }); +}); + +// ── Two keys the filesystem considers one file ─────────────────────── + +/** + * The isolation above is stated per KEY, and the key is `path.join` string + * math while the write resolves through the filesystem. Everything here is a + * pair of distinct, correctly spelled keys that land on ONE real file — via a + * symlink, or via a case-insensitive volume. Nothing may treat those as + * independent: the second start must read as the restart it actually is + * (discarding the first take, counted), and the first recording's next append + * must fail loudly rather than land in a take that is no longer its own. + */ +describe("two recording keys that resolve to one file", () => { + /** Skipped on a case-sensitive volume, where the two names ARE two files. */ + async function fsFoldsCase(dir: string): Promise { + const probe = path.join(dir, "ArgentCaseProbe"); + await fs.writeFile(probe, "", "utf8"); + try { + await fs.stat(path.join(dir, "argentcaseprobe")); + return true; + } catch { + return false; + } finally { + await fs.rm(probe, { force: true }); + } + } + + it("treats a second project's symlink to the same flow file as a restart", async () => { + const vault = await makeRoot("vault"); + const rootA = await makeRoot("symlink-a"); + const rootB = await makeRoot("symlink-b"); + const shared = path.join(vault, "checkout.yaml"); + await fs.writeFile(shared, "steps: []\n", "utf8"); + for (const root of [rootA, rootB]) { + await fs.mkdir(path.dirname(flowPath(root, "checkout")), { recursive: true }); + await fs.symlink(shared, flowPath(root, "checkout")); + } + + await start(rootA, "checkout"); + await addEcho(rootA, "checkout", "h1-a"); + await addEcho(rootA, "checkout", "h1-b"); + await addEcho(rootA, "checkout", "h1-c"); + + // B addresses the same real file under its own spelling. That is a + // restart, and it destroys A's three-step take — so it must say so. + const restarted = await start(rootB, "checkout"); + expect(restarted.restarted).toBe(true); + expect(restarted.discardedSteps).toBe(3); + + // A's session lost the key; its next append fails instead of landing in + // B's take. + const err = await captureFailure(addEcho(rootA, "checkout", "h1-d")); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + // The guard cannot tell this from the same caller respelling its own root, + // so it names the take that holds the key and offers both readings rather + // than asserting the destructive one. Here the destructive one is true. + expect(formatErrorForAgent(err)).toContain("not registered under that spelling"); + expect(formatErrorForAgent(err)).toContain("truncated yours"); + + await addEcho(rootB, "checkout", "h2-a"); + // A's finish reports the same loss, rather than handing back B's take as + // if it were A's own. + const finishErr = await captureFailure(finish(rootA, "checkout")); + expect(getFailureSignal(finishErr)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + + const finishedB = await finish(rootB, "checkout"); + expect(markers(parseFlow(finishedB.flowFile).steps)).toEqual(["echo:h2-a"]); + // The link survived the swap: one real file, holding only B's take. + expect(markers(parseFlow(await fs.readFile(shared, "utf8")).steps)).toEqual(["echo:h2-a"]); + expect((await fs.lstat(flowPath(rootA, "checkout"))).isSymbolicLink()).toBe(true); + }); + + it("treats a shared symlinked flows DIRECTORY the same way", async () => { + const vault = await makeRoot("vault-dir"); + const rootA = await makeRoot("symdir-a"); + const rootB = await makeRoot("symdir-b"); + for (const root of [rootA, rootB]) { + await fs.mkdir(path.join(root, ".argent"), { recursive: true }); + await fs.symlink(vault, path.join(root, ".argent", "flows")); + } + + await start(rootA, "checkout"); + await addEcho(rootA, "checkout", "a1"); + await addEcho(rootA, "checkout", "a2"); + + const restarted = await start(rootB, "checkout"); + expect(restarted.restarted).toBe(true); + expect(restarted.discardedSteps).toBe(2); + + const err = await captureFailure(addEcho(rootA, "checkout", "a3")); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + }); + + it("treats two case-variant flow names on a case-folding volume as one key", async () => { + const root = await makeRoot("case-variant"); + await fs.mkdir(path.dirname(flowPath(root, "Login")), { recursive: true }); + if (!(await fsFoldsCase(path.dirname(flowPath(root, "Login"))))) return; + + await start(root, "Login"); + await addEcho(root, "Login", "l1"); + await addEcho(root, "Login", "l2"); + + // `login` is a different key by string math, the same file by this volume. + const restarted = await start(root, "login"); + expect(restarted.restarted).toBe(true); + expect(restarted.discardedSteps).toBe(2); + + const err = await captureFailure(addEcho(root, "Login", "l3")); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + }); + + it("does not accuse a caller that respelled its own root of destroying a take", async () => { + // The other half of the guard's ambiguity, and the common one on macOS: + // `/tmp` is a symlink, so any code path that realpaths a root produces the + // second spelling. Nothing was truncated, there is no other caller, and the + // take is live and intact — so the message must say how to resume it rather + // than sending the agent to re-walk the whole flow on the device. + const root = await makeRoot("respelled-root"); + const realRoot = await fs.realpath(root); + if (realRoot === root) return; // no symlinked ancestor on this host + + await start(root, "checkout"); + await addEcho(root, "checkout", "c1"); + + const err = await captureFailure(addEcho(realRoot, "checkout", "c2")); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + // Its own stage, so telemetry can tell an aliased key from a key that was + // never started — the two share an error code and want different fixes. + expect(getFailureSignal(err)?.failure_stage).toBe("flow_recording_key_aliased"); + const message = formatErrorForAgent(err); + expect(message).toContain("re-address it exactly as you passed it to flow-start-recording"); + expect(message).toContain("the take is intact and still recording"); + // The claim that made this a false alarm. + expect(message).not.toMatch(/truncated this one/); + expect(message).toContain(root); + + // And the take really is resumable under its registered spelling. + await addEcho(root, "checkout", "c3"); + expect(await readMarkers(root, "checkout")).toEqual(["echo:c1", "echo:c3"]); + }); + + it("writes THROUGH a dangling vault symlink instead of replacing it", async () => { + // The shared-vault workflow's normal starting state: the link is created + // before the first recording, or the vault copy is removed by a branch + // switch or a `git clean`. `realpath` fails on the whole path there, so the + // swap used to rename onto the link's own spelling — replacing the symlink + // with a regular file, never creating the vault target, and permanently + // detaching the project from the vault while reporting success. + const vault = await makeRoot("dangling-vault"); + const root = await makeRoot("dangling-proj"); + const target = path.join(vault, "shared.yaml"); + await fs.mkdir(path.dirname(flowPath(root, "shared")), { recursive: true }); + await fs.symlink(target, flowPath(root, "shared")); + + await start(root, "shared"); + await addEcho(root, "shared", "s1"); + + expect((await fs.lstat(flowPath(root, "shared"))).isSymbolicLink()).toBe(true); + expect(markers(parseFlow(await fs.readFile(target, "utf8")).steps)).toEqual(["echo:s1"]); + }); + + it("keys two projects onto one dangling vault target, as one file", async () => { + // The key follows the same resolution as the write, so two projects linking + // the same not-yet-created vault file are one recording — matching what the + // write then produces, rather than two sessions racing onto one output. + const vault = await makeRoot("dangling-shared-vault"); + const rootA = await makeRoot("dangling-a"); + const rootB = await makeRoot("dangling-b"); + const target = path.join(vault, "checkout.yaml"); + for (const root of [rootA, rootB]) { + await fs.mkdir(path.dirname(flowPath(root, "checkout")), { recursive: true }); + await fs.symlink(target, flowPath(root, "checkout")); + } + + await start(rootA, "checkout"); + await addEcho(rootA, "checkout", "a1"); + + const restarted = await start(rootB, "checkout"); + expect(restarted.restarted).toBe(true); + expect(restarted.discardedSteps).toBe(1); + expect((await fs.lstat(flowPath(rootA, "checkout"))).isSymbolicLink()).toBe(true); + }); + + it("keeps a recording reachable when its vault target is deleted mid-take", async () => { + // The link is still there and still names the same file, so the recording's + // identity has not moved — it is only the target that is momentarily + // absent. Resolving that back to the link's own path made the key move, + // orphaning the live session behind a generic "no active recording". + const vault = await makeRoot("deleted-target-vault"); + const root = await makeRoot("deleted-target-proj"); + const target = path.join(vault, "checkout.yaml"); + await fs.mkdir(path.dirname(flowPath(root, "checkout")), { recursive: true }); + await fs.symlink(target, flowPath(root, "checkout")); + + await start(root, "checkout"); + await addEcho(root, "checkout", "c1"); + await fs.rm(target); + + // Still addressable under the spelling it was started with. + expect((await getRecordingSession(root, "checkout"))?.name).toBe("checkout"); + + // The append does fail — its file really is gone — but as the missing file + // it is, not as a recording that was never started. The distinction is the + // whole point: the second answer sends the agent to flow-start-recording, + // which truncates. + const err = await captureFailure(addEcho(root, "checkout", "c2")); + expect(getFailureSignal(err)?.error_code).not.toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + expect((err as Error).message).toMatch(/ENOENT/); + + // And restoring the target resumes the same take. + await fs.writeFile(target, "steps: []\n", "utf8"); + await addEcho(root, "checkout", "c3"); + const finished = await finish(root, "checkout"); + expect(markers(parseFlow(finished.flowFile).steps)).toEqual(["echo:c3"]); + expect((await fs.lstat(flowPath(root, "checkout"))).isSymbolicLink()).toBe(true); + expect(await getRecordingSession(root, "checkout")).toBeUndefined(); + }); + + it("keeps two genuinely distinct flows independent", async () => { + // The control: no symlink, no case variance, so nothing is canonicalized + // together and the isolation guarantee holds exactly as stated. + const rootA = await makeRoot("control-a"); + const rootB = await makeRoot("control-b"); + await start(rootA, "checkout"); + await start(rootB, "checkout"); + await addEcho(rootA, "checkout", "a1"); + await addEcho(rootB, "checkout", "b1"); + expect(await readMarkers(rootA, "checkout")).toEqual(["echo:a1"]); + expect(await readMarkers(rootB, "checkout")).toEqual(["echo:b1"]); + }); +}); + +// ── Addressing a key that isn't live ───────────────────────────────── + +describe("addressing an unknown recording key", () => { + it("fails with FLOW_NO_ACTIVE_RECORDING and names this project's live recordings", async () => { + const rootA = await makeRoot("unknown-a"); + const rootB = await makeRoot("unknown-b"); + await start(rootA, "alpha"); + await start(rootB, "beta"); + + const err = await captureFailure(addEcho(rootA, "never-started", "x")); + + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + const message = (err as Error).message; + expect(message).toContain('No active recording for flow "never-started"'); + expect(message).toContain(rootA); + // This project's live keys are named so the agent can self-correct… + expect(message).toContain('Active recordings: "alpha" (plus 1 in other projects)'); + // …while another caller's flow name and project path stay theirs. + expect(message).not.toContain('"beta"'); + expect(message).not.toContain(rootB); + }); + + it("fails the same way for the right name under the wrong project_root", async () => { + const rootA = await makeRoot("wrong-root-a"); + const rootB = await makeRoot("wrong-root-b"); + await start(rootA, "alpha"); + + const err = await captureFailure(addStep(rootB, "alpha", "stray")); + + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + const message = (err as Error).message; + expect(message).toContain("Active recordings: none in this project (plus 1 in other projects)"); + expect(message).not.toContain(rootA); + + // The misdirected step was not recorded anywhere. + expect(await readMarkers(rootA, "alpha")).toEqual([]); + await expect(fs.stat(flowPath(rootB, "alpha"))).rejects.toThrow(); + }); + + it("reports the live recordings as none when nothing is being recorded", async () => { + const root = await makeRoot("nothing-live"); + const err = await captureFailure(finish(root, "alpha")); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + // No parenthetical: there is nothing elsewhere to count either. + expect((err as Error).message).toContain("Active recordings: none in this project."); + }); +}); + +// ── Concurrent appends to one session ──────────────────────────────── + +describe("concurrent flow-add-step calls on one recording", () => { + it("loses no step when several appends are in flight at once", async () => { + const root = await makeRoot("append-race"); + await start(root, "burst"); + + const tags = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"]; + // Fire without awaiting in between: every call is past its live execution + // and inside the append phase before the first one writes. appendStep is + // read → await → write, so without the per-file lock these would all + // read the same file and the last write would drop the others. + const inflight = tags.map((tag) => addStep(root, "burst", tag)); + await Promise.all(inflight); + + const recorded = await readMarkers(root, "burst"); + expect(recorded).toHaveLength(tags.length); + expect([...recorded].sort()).toEqual(tags.map((t) => `tool:${t}`).sort()); + + // The in-memory copy the session serves to flow-finish-recording agrees. + expect((await getRecordingSession(root, "burst"))?.flow.steps).toHaveLength(tags.length); + const finished = await finish(root, "burst"); + expect(finished.steps).toBe(tags.length); + }); + + it("keeps two concurrent bursts on their own files", async () => { + const root = await makeRoot("append-race-two"); + await start(root, "alpha"); + await start(root, "beta"); + + await Promise.all([ + ...["a0", "a1", "a2", "a3"].map((tag) => addStep(root, "alpha", tag)), + ...["b0", "b1", "b2", "b3"].map((tag) => addStep(root, "beta", tag)), + ]); + + expect([...(await readMarkers(root, "alpha"))].sort()).toEqual([ + "tool:a0", + "tool:a1", + "tool:a2", + "tool:a3", + ]); + expect([...(await readMarkers(root, "beta"))].sort()).toEqual([ + "tool:b0", + "tool:b1", + "tool:b2", + "tool:b3", + ]); + }); +}); + +// ── The lock is per flow file, not one global mutex ────────────────── + +describe("the flow-file lock", () => { + it("lets one recording append while another recording's file is locked", async () => { + const root = await makeRoot("per-file-lock"); + await start(root, "alpha"); + await start(root, "beta"); + + // Hold alpha's file lock — this is exactly the state an alpha append is in + // while it is mid read-modify-write. Whether beta can make progress *during* + // that window is the property under test: a single global lock passes any + // assertion about final file contents, and fails this one. + const order: string[] = []; + const alphaLock = openGate(); + const alphaHeld = withFlowFileLock(root, "alpha", () => alphaLock.promise); + + // A second append to alpha must queue behind the holder… + const alphaAppend = addStep(root, "alpha", "a1").then((r) => { + order.push("alpha-appended"); + return r; + }); + const betaAppend = addStep(root, "beta", "b1").then((r) => { + order.push("beta-appended"); + return r; + }); + + // …while beta's append, on a different file, runs to completion inside it. + expect(await within(betaAppend, "beta-appended")).toBe("beta-appended"); + await settle(); + expect(order).toEqual(["beta-appended"]); + expect(await readMarkers(root, "beta")).toEqual(["tool:b1"]); + expect(await readMarkers(root, "alpha")).toEqual([]); + + order.push("alpha-lock-released"); + alphaLock.open(); + await alphaHeld; + expect(await within(alphaAppend, "alpha-appended")).toBe("alpha-appended"); + + // beta finished strictly inside alpha's critical section — real overlap, + // not a serialization that happened to be fast. + expect(order).toEqual(["beta-appended", "alpha-lock-released", "alpha-appended"]); + expect(await readMarkers(root, "alpha")).toEqual(["tool:a1"]); + expect(await readMarkers(root, "beta")).toEqual(["tool:b1"]); + }); + + it("still excludes a third acquirer after the first one has released", async () => { + const root = await makeRoot("lock-three-deep"); + + // Three acquirers on ONE key, which is where the lock map's self-cleanup + // has to be careful: the entry may only be dropped by the holder that is + // still the tail. Deleting it unconditionally looks harmless — every + // two-party test still passes — but once A finishes while B is holding, the + // key is gone from the map, so C finds no predecessor to queue behind and + // runs *concurrently with B*. That is the lost update this lock exists to + // prevent, so pin three-deep contention explicitly. + const order: string[] = []; + const gateA = openGate(); + const gateB = openGate(); + + const heldA = withFlowFileLock(root, "alpha", async () => { + order.push("a-enter"); + await gateA.promise; + order.push("a-exit"); + }); + const heldB = withFlowFileLock(root, "alpha", async () => { + order.push("b-enter"); + await gateB.promise; + order.push("b-exit"); + }); + + // A is holding, B is queued. Release A so B takes the lock and A's cleanup + // runs while B is still inside its critical section. + gateA.open(); + await heldA; + await settle(); + expect(order).toEqual(["a-enter", "a-exit", "b-enter"]); + + // C arrives now — after A's cleanup, while B holds. + const heldC = withFlowFileLock(root, "alpha", async () => { + order.push("c-enter"); + }); + expect(await within(heldC, "c-done", 200)).toBe("timed-out"); + expect(order).toEqual(["a-enter", "a-exit", "b-enter"]); + + gateB.open(); + await heldB; + await heldC; + expect(order).toEqual(["a-enter", "a-exit", "b-enter", "b-exit", "c-enter"]); + }); + + it("drops the lock entry once released, so the map does not grow per flow ever recorded", async () => { + // The other half of the self-cleanup. The test above pins the CONDITION + // (only the tail may delete); this pins that the delete happens at all. + // Nothing else can observe it — a retained entry is functionally identical + // to a released one for every caller — so without this the whole + // `void held.then(...)` block can be deleted with the suite still green, + // and a long-lived server accumulates one permanent entry per flow anyone + // using that argent install ever recorded. + const root = await makeRoot("lock-cleanup"); + const before = __flowFileLockCountForTesting(); + + for (const name of ["alpha", "beta", "gamma"]) { + await start(root, name); + await addEcho(root, name, "one"); + await finish(root, name); + } + expect(__flowFileLockCountForTesting()).toBe(before); + + // And while a lock is genuinely held, the entry IS there — so the + // assertion above is about release, not about the map never being used. + const gate = openGate(); + const held = withFlowFileLock(root, "alpha", () => gate.promise); + // `settle` first: the lock is taken on the CANONICAL key, so the entry + // appears only once that resolution has come back from the filesystem. + await settle(); + expect(__flowFileLockCountForTesting()).toBe(before + 1); + gate.open(); + await held; + await settle(); + expect(__flowFileLockCountForTesting()).toBe(before); + }); +}); + +// ── What a READER of the flow file can observe ─────────────────────── + +describe("flow-file writes as seen by a concurrent reader", () => { + // The lock serializes writers, but no reader of a flow YAML joins it — + // `flow-execute`'s own load, its `run:` fragment load, flow-read-prerequisite, + // flow-add-step's sibling-fragment check, and the `argent` CLI reading from + // another process, where an in-process lock cannot reach at all. A plain + // `fs.writeFile` opens O_TRUNC, so such a reader could observe the file empty + // or half-written — and `parseFlow("")` returns `{ steps: [] }` with no error, + // which `flow-execute` summarizes as a top-level PASS over zero steps. So the + // writes must be atomic swaps rather than in-place truncations. + + /** The file's identity on disk. A rename replaces it; a truncate does not. */ + async function inode(root: string, name: string): Promise { + return (await fs.stat(flowPath(root, name))).ino; + } + + /** Anything the writer left behind next to the flow file. */ + async function strayFiles(root: string, name: string): Promise { + const entries = await fs.readdir(path.dirname(flowPath(root, name))); + return entries.filter((entry) => entry !== `${name}.yaml`); + } + + it("replaces the file on append instead of truncating it in place", async () => { + const root = await makeRoot("append-atomic"); + await start(root, "alpha"); + const before = await inode(root, "alpha"); + + await addStep(root, "alpha", "a1"); + const after = await inode(root, "alpha"); + + // Different inode == the reader either had the old file open (still whole) + // or opens the new one (whole). There is no window where the path resolves + // to a zero-length file. + expect(after).not.toBe(before); + expect(await readMarkers(root, "alpha")).toEqual(["tool:a1"]); + }); + + it("replaces the file on start, so a reset is never observable as a partial file", async () => { + const root = await makeRoot("start-atomic"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + const before = await inode(root, "alpha"); + + // A restart truncates to an empty flow — the write most likely to be caught + // mid-flight, since it is what a concurrent flow-execute would read as a + // green run over zero steps. + await start(root, "alpha"); + expect(await inode(root, "alpha")).not.toBe(before); + expect(await readMarkers(root, "alpha")).toEqual([]); + }); + + it("leaves no scratch file behind in the flows directory", async () => { + // The swap writes a sibling temp file first. `argent flow list` enumerates + // this directory and filters on `.yaml`, so a stray `.tmp` never surfaces + // as a flow — but that agreement only hides a leftover, it does not stop + // one accumulating per append. + const root = await makeRoot("no-scratch"); + await start(root, "alpha"); + expect(await strayFiles(root, "alpha")).toEqual([]); + + await addStep(root, "alpha", "a1"); + await addEcho(root, "alpha", "note"); + await addStep(root, "alpha", "a2"); + + expect(await strayFiles(root, "alpha")).toEqual([]); + expect(await readMarkers(root, "alpha")).toEqual(["tool:a1", "echo:note", "tool:a2"]); + }); + + it("renames the scratch file from beside the target, never from a shared temp dir", async () => { + // "leaves no scratch file behind" (above) only proves the .tmp is gone by + // the time the call returns — a scratch file built under `os.tmpdir()` + // instead of the flow's own directory satisfies that identically, since it + // was never IN the flows directory to begin with. What actually keeps the + // swap atomic is `fs.rename` staying on ONE filesystem, which only holds + // because the scratch path is a sibling of the target — so pin THAT + // property directly, on the arguments the real rename call is made with, + // rather than on a side effect two different implementations both produce. + // + // This does not depend on the test root's filesystem: `flowsDir` here is + // always a subdirectory of whatever `os.tmpdir()` returns (`makeRoot` + // mkdtemps under it), never equal to it, so relocating the scratch file to + // `os.tmpdir()` itself is caught by the directory comparison below on any + // host, without ever needing two real filesystems to reproduce EXDEV. + const root = await makeRoot("scratch-sibling"); + vi.mocked(fs.rename).mockClear(); + + await start(root, "alpha"); // writeNewFlowFile → writeFlowFile → 1 rename + await addStep(root, "alpha", "a1"); // appendStep → writeFlowFile → 1 rename + await addEcho(root, "alpha", "a2"); // appendStep → writeFlowFile → 1 rename + + // Canonical, because the writer swaps onto the flow file's REAL path so a + // symlinked flow keeps its link (see writeFlowFile). The directory that + // must hold the scratch file is therefore the resolved one — still a strict + // subdirectory of the temp root, so the os.tmpdir() relocation this case + // exists to catch is caught exactly as before. + const flowsDir = await fs.realpath(path.dirname(flowPath(root, "alpha"))); + const renameCalls = vi.mocked(fs.rename).mock.calls; + expect(renameCalls).toHaveLength(3); + for (const [from, to] of renameCalls) { + // The rename target is always the flow file itself… + expect(path.dirname(String(to))).toBe(flowsDir); + // …and the scratch source must sit right next to it. If it didn't, this + // same rename would cross filesystems in production (project root vs. + // OS temp dir) and fail with EXDEV instead of swapping atomically. + expect(path.dirname(String(from))).toBe(flowsDir); + } + }); + + it("still appends under a flow name long enough to fill the filesystem's limit", async () => { + // A flow name has no length cap — FLOW_NAME_PATTERN constrains the + // character set only — so `.yaml` can legitimately reach NAME_MAX + // (255 on APFS/ext4). A scratch name derived from the flow file's basename + // would overflow that and fail an append that used to work, so the temp + // name must be a fixed-length one. + const root = await makeRoot("long-name"); + const name = "a".repeat(250); + expect(`${name}.yaml`.length).toBe(255); + + await start(root, name); + await addStep(root, name, "a1"); + await addEcho(root, name, "note"); + + expect(await readMarkers(root, name)).toEqual(["tool:a1", "echo:note"]); + expect(await strayFiles(root, name)).toEqual([]); + }); + + it("propagates a failed swap and leaves no scratch file behind", async () => { + // The only coverage the cleanup branch has otherwise is the success path, + // where the rename itself consumes the temp file — so deleting the whole + // try/catch passes. Force the rename to fail by planting a NON-EMPTY + // DIRECTORY where the flow file goes: `mkdir -p` on the parent still + // succeeds and the temp write still succeeds, so this reaches `fs.rename` + // and nothing else. (A read-only dir fails earlier, at the temp write.) + const root = await makeRoot("swap-fails"); + const target = flowPath(root, "alpha"); + await fs.mkdir(path.join(target, "occupied"), { recursive: true }); + + await expect(start(root, "alpha")).rejects.toThrow(); + + // The failure must not leave a scratch file in the user's committed + // .argent/flows/ — nothing else ever sweeps it. + const entries = await fs.readdir(path.dirname(target)); + expect(entries.filter((e) => e.endsWith(".tmp"))).toEqual([]); + // And it must not register a session for a file that was never written: + // the next append would otherwise die on an unrelated ENOENT. + expect(listActiveRecordings()).toEqual([]); + }); + + it("cleans up the scratch file and names the flow when the WRITE half fails", async () => { + // The "failed swap" case above reaches only `fs.rename`; a read-only dir + // fails even earlier, at the temp open, before a file exists. Neither + // exercises the other live trigger the cleanup exists for: the write itself + // failing (ENOSPC / EIO) with the scratch file ALREADY created. Force + // exactly that — write the real temp file, then throw — and assert both that + // the scratch file is swept and that the surfaced error names the flow file + // rather than the internal `.argent-flow-*.tmp` path (which is gone by then). + const root = await makeRoot("write-fails"); + const target = flowPath(root, "alpha"); + await fs.mkdir(path.dirname(target), { recursive: true }); + + const realWriteFile = fs.writeFile; + const spy = vi.spyOn(fs, "writeFile").mockImplementationOnce(async (p, data, opts) => { + await realWriteFile(p as Parameters[0], data as string, opts as never); + const err: NodeJS.ErrnoException = new Error( + `ENOSPC: no space left on device, write ${String(p)}` + ); + err.code = "ENOSPC"; + throw err; + }); + + const err = await start(root, "alpha").catch((e: unknown) => e); + spy.mockRestore(); + + expect(err).toBeInstanceOf(Error); + // Against the string an agent actually READS, not `err.message`: + // `formatErrorForAgent` appends the cause chain, so asserting on the + // message alone passed while the rendered text still named the scratch + // file. The invariant is about what is disclosed, so assert on what is. + const message = formatErrorForAgent(err); + expect(message).toContain(target); + expect(message).not.toMatch(/\.argent-flow-\d+-\d+\.tmp/); + // The errno itself is worth keeping — only the phantom path is not. + expect(message).toContain("ENOSPC"); + expect(message).toContain("out of space"); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED); + + // The half-written scratch file must not survive in the committed flows dir. + const entries = await fs.readdir(path.dirname(target)); + expect(entries.filter((e) => e.endsWith(".tmp"))).toEqual([]); + expect(listActiveRecordings()).toEqual([]); + }); + + it("blames the LINK TARGET's directory when a symlinked flow cannot be written", async () => { + // A 0755 flows dir holding a link into a 0555 vault. The hint used + // `dirname(filePath)` while the temp file and rename use + // `dirname(realpath(filePath))`, so it named a directory that already IS + // writable and never mentioned the vault — the only unwritable thing. + const vault = await makeRoot("hint-vault"); + const root = await makeRoot("hint-proj"); + const shared = path.join(vault, "f.yaml"); + const link = flowPath(root, "f"); + await fs.writeFile(shared, "steps: []\n", "utf8"); + await fs.mkdir(path.dirname(link), { recursive: true }); + await fs.symlink(shared, link); + await start(root, "f"); + await fs.chmod(vault, 0o555); + try { + const err = await addEcho(root, "f", "note").catch((e: unknown) => e); + + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED); + const message = formatErrorForAgent(err); + // The real directory is named… + expect(message).toContain(await fs.realpath(vault)); + // …and the reader is told why it is not the one they expected. + expect(message).toContain("is a symlink"); + expect(message).toMatch(/must be writable/); + } finally { + await fs.chmod(vault, 0o755); + } + }); + + it("classifies a project_root that is a FILE as a flow write failure, not a registry one", async () => { + // The tool description promises it "fails if the .argent/flows/ directory + // cannot be created OR the flow file cannot be written". With the mkdir + // outside the wrapping only the second half kept that promise: this + // surfaced as a bare ENOTDIR under REGISTRY_TOOL_EXECUTION_FAILED, with no + // remediation hint, and telemetry blaming the registry for a flow failure. + const root = await makeRoot("root-is-a-file"); + const notADir = path.join(root, "notadir"); + await fs.writeFile(notADir, "x", "utf8"); + + const err = await start(notADir, "alpha").catch((e: unknown) => e); + + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED); + expect(getFailureSignal(err)?.failure_stage).toBe("flow_dir_create"); + const message = formatErrorForAgent(err); + expect(message).toContain(path.join(notADir, ".argent", "flows")); + expect(message).toContain("project_root"); + // The kernel's own errno is worth keeping. + expect(message).toContain("ENOTDIR"); + }); + + it("classifies an unwritable project_root the same way", async () => { + const root = await makeRoot("root-unwritable"); + const proj = path.join(root, "proj"); + await fs.mkdir(proj); + await fs.chmod(proj, 0o555); + try { + const err = await start(proj, "alpha").catch((e: unknown) => e); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED); + expect(getFailureSignal(err)?.failure_stage).toBe("flow_dir_create"); + expect(formatErrorForAgent(err)).toMatch(/not writable/); + } finally { + await fs.chmod(proj, 0o755); + } + }); + + it("names only the flow file when a read-only flows dir fails an append", async () => { + // The review's own repro: chmod 500 the flows dir, then append to a live + // recording. This fails at the temp OPEN — earlier than either case above — + // and the errno names a scratch file that has never existed on disk. + const root = await makeRoot("readonly-dir"); + const target = flowPath(root, "alpha"); + await start(root, "alpha"); + const flowsDir = path.dirname(target); + await fs.chmod(flowsDir, 0o500); + try { + const err = await addEcho(root, "alpha", "note").catch((e: unknown) => e); + + const message = formatErrorForAgent(err); + expect(message).toContain(target); + expect(message).not.toMatch(/\.argent-flow-\d+-\d+\.tmp/); + // Here the directory-permission explanation IS the right one. + expect(message).toContain("must be writable"); + } finally { + await fs.chmod(flowsDir, 0o700); + } + }); + + it("explains the errno it actually got, not directory permissions every time", async () => { + // "so must be writable" used to be appended to every failure. An + // over-long flow name fails in `rename` with ENAMETOOLONG — a writable + // directory does not help, and sending someone to check permissions on one + // they will find perfectly writable is a wrong lead, not a vague one. + const root = await makeRoot("nametoolong"); + const target = flowPath(root, "alpha"); + await fs.mkdir(path.dirname(target), { recursive: true }); + + const realRename = fs.rename; + const spy = vi.spyOn(fs, "rename").mockImplementationOnce(async () => { + const err: NodeJS.ErrnoException = new Error( + `ENAMETOOLONG: name too long, rename '${target}'` + ); + err.code = "ENAMETOOLONG"; + throw err; + }); + const err = await start(root, "alpha").catch((e: unknown) => e); + spy.mockRestore(); + void realRename; + + const message = formatErrorForAgent(err); + expect(message).toContain("ENAMETOOLONG"); + expect(message).toContain("use a shorter name"); + expect(message).not.toContain("must be writable"); + }); + + it("never exposes an empty or unparseable file while appends are in flight", async () => { + // The property the two inode assertions above encode, observed the way a + // reader actually experiences it: poll the path as fast as the event loop + // allows across a run of appends, and require every single observation to + // be a complete, parseable flow. Against an in-place `fs.writeFile` this + // catches zero-length reads. + const root = await makeRoot("reader-race"); + await start(root, "alpha"); + + let polling = true; + const observed: number[] = []; + let torn: string | undefined; + const reader = (async () => { + while (polling) { + try { + const raw = await fs.readFile(flowPath(root, "alpha"), "utf8"); + observed.push(parseFlow(raw).steps.length); + } catch (err) { + // ENOENT is equally a failure here: the path must resolve to a + // complete file at every instant, never to a gap. + torn = `${(err as Error).message} — content boundary observed`; + break; + } + } + })(); + + for (let i = 0; i < 40; i++) await addStep(root, "alpha", `a${i}`); + polling = false; + await reader; + + expect(torn).toBeUndefined(); + // The reader has to have actually looked, or it proves nothing. + expect(observed.length).toBeGreaterThan(0); + // Step counts only ever grow: no observation caught a reset-to-empty file. + expect(observed).toEqual([...observed].sort((a, b) => a - b)); + expect((await readSteps(root, "alpha")).length).toBe(40); + }); +}); + +// ── The append path's source of truth ──────────────────────────────── + +describe("appending to a recording whose file was hand-edited", () => { + it("re-reads the file, so an edit made mid-recording survives the next append", async () => { + const root = await makeRoot("append-rereads"); + await start(root, "alpha"); + await addEcho(root, "alpha", "s1"); + await addEcho(root, "alpha", "s2"); + expect(await readMarkers(root, "alpha")).toEqual(["echo:s1", "echo:s2"]); + + // Removing a bad step by editing the .yaml is what both recording tools' + // descriptions tell the agent to do. It only survives because the host-mode + // append re-reads from disk; serializing the in-memory copy instead would + // silently resurrect the deleted step on the very next append. + await fs.writeFile( + flowPath(root, "alpha"), + 'executionPrerequisite: ""\nsteps:\n - echo: s2\n', + "utf8" + ); + + await addEcho(root, "alpha", "s3"); + expect(await readMarkers(root, "alpha")).toEqual(["echo:s2", "echo:s3"]); + + // …and the finish reports the file, not the take as it was recorded. + const finished = await finish(root, "alpha"); + expect(markers(parseFlow(finished.flowFile).steps)).toEqual(["echo:s2", "echo:s3"]); + }); +}); + +// ── Replaying a flow while recordings are live ─────────────────────── + +describe("running a flow in a third project while two recordings are live", () => { + it("rebinds neither recording's file path", async () => { + const rootA = await makeRoot("exec-a"); + const rootB = await makeRoot("exec-b"); + const rootC = await makeRoot("exec-c"); + + await start(rootA, "alpha"); + await start(rootB, "beta"); + await addStep(rootA, "alpha", "a1"); + await addEcho(rootB, "beta", "b1"); + + // A saved flow belonging to a third project, replayed mid-recording. + await writeSavedFlow(rootC, "standalone", { + executionPrerequisite: "App on the home screen", + steps: [{ kind: "echo", message: "replayed" }], + }); + + const prereq = await flowReadPrerequisiteTool.execute( + {}, + { name: "standalone", project_root: rootC } + ); + expect(prereq.executionPrerequisite).toBe("App on the home screen"); + + const runResult = await createRunFlowTool(registry).execute( + {}, + { + name: "standalone", + project_root: rootC, + device: IOS_DEVICE, + prerequisiteAcknowledged: true, + } + ); + expect(runResult).toHaveProperty("ok", true); + + // Both sessions still point at their own files… + expect((await getRecordingSession(rootA, "alpha"))?.filePath).toBe(flowPath(rootA, "alpha")); + expect((await getRecordingSession(rootB, "beta"))?.filePath).toBe(flowPath(rootB, "beta")); + + // …and subsequent steps still land there. + await addStep(rootA, "alpha", "a2"); + await addEcho(rootB, "beta", "b2"); + expect(await readMarkers(rootA, "alpha")).toEqual(["tool:a1", "tool:a2"]); + expect(await readMarkers(rootB, "beta")).toEqual(["echo:b1", "echo:b2"]); + + // Nothing was written into the replayed project, and the replayed flow + // did not pick up either recording's steps. + await expect(fs.stat(flowPath(rootC, "alpha"))).rejects.toThrow(); + expect(await readMarkers(rootC, "standalone")).toEqual(["echo:replayed"]); + }); +}); + +// ── Restarting one recording ───────────────────────────────────────── + +describe("restarting a recording on one key", () => { + it("resets only that flow and leaves a concurrent recording untouched", async () => { + const root = await makeRoot("restart"); + + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + await addStep(root, "alpha", "a2"); + + const startedBeta = await start(root, "beta"); + // Starting a DIFFERENT key abandons nothing — nothing to report. + expect(startedBeta.restarted).toBeUndefined(); + expect(startedBeta.discardedSteps).toBeUndefined(); + await addEcho(root, "beta", "b1"); + + const restarted = await start(root, "alpha"); + expect(restarted.restarted).toBe(true); + expect(restarted.discardedSteps).toBe(2); + expect(restarted.message).toContain("alpha"); + expect(await readMarkers(root, "alpha")).toEqual([]); + + // beta neither lost its steps nor its session. + expect(await readMarkers(root, "beta")).toEqual(["echo:b1"]); + expect((await getRecordingSession(root, "beta"))?.flow.steps).toHaveLength(1); + await addEcho(root, "beta", "b2"); + expect(await readMarkers(root, "beta")).toEqual(["echo:b1", "echo:b2"]); + + // The restarted take records into the reset file. + await addStep(root, "alpha", "a3"); + expect(await readMarkers(root, "alpha")).toEqual(["tool:a3"]); + }); + + it("restarts this project's take and leaves the same name elsewhere alone", async () => { + const rootA = await makeRoot("restart-a"); + const rootB = await makeRoot("restart-b"); + + await start(rootA, "alpha"); + await addStep(rootA, "alpha", "a1"); + await start(rootB, "alpha"); + await addStep(rootB, "alpha", "b1"); + + // Same name AND same root ⇒ the same key ⇒ this take is restarted… + const restarted = await start(rootB, "alpha"); + expect(restarted.restarted).toBe(true); + expect(restarted.discardedSteps).toBe(1); + expect(await readMarkers(rootB, "alpha")).toEqual([]); + + // …while the same name under the other root — a different key — is not + // touched: that recording kept its step and its session. + expect(await readMarkers(rootA, "alpha")).toEqual(["tool:a1"]); + expect((await getRecordingSession(rootA, "alpha"))?.flow.steps).toHaveLength(1); + }); + + it("counts the steps the FILE held, not the ones this session appended", async () => { + // Hand-editing the .yaml mid-recording is a documented workflow, and in + // host mode the file is the take: every other host-mode operation re-reads + // it, and the in-memory copy only catches up on the next append. The + // restart is the one destructive operation, so counting from memory would + // report a fraction of what it just wiped. + const root = await makeRoot("restart-handedit"); + + await start(root, "alpha"); + await addEcho(root, "alpha", "a1"); + + await fs.writeFile( + flowPath(root, "alpha"), + serializeFlow({ + executionPrerequisite: "", + steps: [ + { kind: "echo", message: "a1" }, + { kind: "echo", message: "by-hand-2" }, + { kind: "echo", message: "by-hand-3" }, + { kind: "echo", message: "by-hand-4" }, + ], + }), + "utf8" + ); + + const restarted = await start(root, "alpha"); + expect(restarted.restarted).toBe(true); + expect(restarted.discardedSteps).toBe(4); + expect(restarted.message).toContain("(4 steps)"); + expect(await readMarkers(root, "alpha")).toEqual([]); + }); + + it("reports no count at all when the file it discarded could not be parsed", async () => { + // A hand-edit can also leave YAML `parseFlow` rejects. There is no honest + // number then — and 0 is the least honest of all, since it is the answer a + // genuinely empty take gives. `restarted` alone says the take is gone. + const root = await makeRoot("restart-unparseable"); + + await start(root, "alpha"); + await addEcho(root, "alpha", "a1"); + await fs.writeFile(flowPath(root, "alpha"), "steps: [ this: is: not: a: flow\n", "utf8"); + + const restarted = await start(root, "alpha"); + expect(restarted.restarted).toBe(true); + expect(restarted).not.toHaveProperty("discardedSteps"); + expect(restarted.message).not.toMatch(/\d+ steps?\)/); + expect(restarted.message).toContain("the previous take was discarded"); + // The reset still happened — the unreadable take is gone either way. + expect(await readMarkers(root, "alpha")).toEqual([]); + }); +}); + +// ── A restart landing on top of an in-flight append ────────────────── + +describe("a restart that lands while a step is still running", () => { + it("rejects the in-flight step instead of writing it into the new take", async () => { + const root = await makeRoot("restart-inflight"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + + // The step resolves its session, then parks in its LIVE execution. + const gate = gateNextSubTool(); + const appending = addStep(root, "alpha", "a2"); + await gate.reached; + + // The take that step belongs to is discarded while it is still running. + const restarted = await start(root, "alpha"); + expect(restarted.restarted).toBe(true); + expect(restarted.discardedSteps).toBe(1); + + gate.release(); + const err = await captureFailure(appending); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + expect(getFailureSignal(err)?.failure_stage).toBe("flow_session_superseded"); + expect((err as Error).message).toContain("restarted while this step was running"); + expect((err as Error).message).toContain("Nothing was added to the flow file"); + // The recovery advice must not send the agent back to flow-start-recording: + // it truncates, so on this branch it would wipe the live take that just took + // the key (and take it back again). A fresh name is the only safe recovery. + expect((err as Error).message).toContain("fresh name"); + expect((err as Error).message).not.toMatch(/Call flow-start-recording/); + // This is the branch where a foreign take really does hold the key, so the + // message says so — the empty-key branches must not (see the finish and + // eviction cases). + expect((err as Error).message).toContain("belongs to another take"); + // The step already ran live before the append was rejected, so an agent that + // simply retries it would repeat the device action. + expect((err as Error).message).toContain("already ran on the device"); + + // The new take is empty — no step from the discarded one leaked into it. + expect(await readMarkers(root, "alpha")).toEqual([]); + expect((await getRecordingSession(root, "alpha"))?.flow.steps).toHaveLength(0); + + // …and the restarted recording still works. + await addStep(root, "alpha", "a3"); + expect(await readMarkers(root, "alpha")).toEqual(["tool:a3"]); + const finished = await finish(root, "alpha"); + expect(finished.steps).toBe(1); + }); + + it("does not warn a superseded ECHO that it already ran on the device", async () => { + // The "repeating it repeats that action" caveat is true of a tool step, + // which executed live before the append was rejected. An echo is a label — + // it touched no device, so telling its author to weigh a repeat is the same + // class of false advice the fresh-name wording replaced. Only the tool + // branch of that ternary is asserted above, so pin the echo branch here. + const root = await makeRoot("supersede-echo"); + await start(root, "alpha"); + + // An echo has no live device step to park in, so the only window in which + // it can be superseded is the flow-file lock. Hold the lock, then queue the + // restart AHEAD of the echo: the echo still resolves its session now (the + // restart's body has not run, so the old session is still registered), but + // by the time it reaches the front of the queue the restart has replaced it. + const gate = openGate(); + const held = withFlowFileLock(root, "alpha", () => gate.promise); + const restarting = start(root, "alpha"); + const echoing = addEcho(root, "alpha", "a label"); + + gate.open(); + await held; + expect((await restarting).restarted).toBe(true); + + const err = await captureFailure(echoing); + expect(getFailureSignal(err)?.failure_stage).toBe("flow_session_superseded"); + expect((err as Error).message).toContain("Nothing was added to the flow file"); + expect((err as Error).message).toContain("fresh name"); + expect((err as Error).message).not.toContain("already ran on the device"); + }); + + it("truncates and re-registers only once the flow's lock is free", async () => { + const root = await makeRoot("restart-lock"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + const firstSession = await getRecordingSession(root, "alpha"); + + // Stand in for an append that is mid read-modify-write on alpha's file. + const order: string[] = []; + const lock = openGate(); + const held = withFlowFileLock(root, "alpha", () => lock.promise); + + const restarting = start(root, "alpha").then((r) => { + order.push("restart-returned"); + return r; + }); + + await settle(); + // The restart is a truncate AND a session swap; neither half may happen + // while another writer holds the file. + expect(order).toEqual([]); + expect(await readMarkers(root, "alpha")).toEqual(["tool:a1"]); + expect(await getRecordingSession(root, "alpha")).toBe(firstSession); + + order.push("lock-released"); + lock.open(); + await held; + const restarted = await restarting; + + expect(order).toEqual(["lock-released", "restart-returned"]); + expect(restarted.restarted).toBe(true); + expect(restarted.discardedSteps).toBe(1); + expect(await readMarkers(root, "alpha")).toEqual([]); + expect(await getRecordingSession(root, "alpha")).not.toBe(firstSession); + }); + + it("keeps a step queued behind the restart out of the new take", async () => { + const root = await makeRoot("restart-queued-append"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + const discarded = await getRecordingSession(root, "alpha"); + + // Park a holder on alpha's file lock. Everything issued below queues behind + // it, so the interleaving is fixed by the lock's arrival order rather than + // by how long any I/O happens to take. + const holder = openGate(); + const held = withFlowFileLock(root, "alpha", () => holder.promise); + + // Second in the queue: the restart — truncate the file, swap the session. + const restarting = start(root, "alpha"); + // Third: a step for the take the restart is discarding. Both calls resolve + // the same spelled path, so they share one in-flight key resolution and + // join the lock queue in the order issued (see `keyResolutions`) — this + // append is bound to the OLD session and enters the lock the instant the + // restart's critical section ends, which is the window a truncate that is + // not fused to the session swap leaves open, onto a file already empty. + const appending = addEcho(root, "alpha", "stray"); + expect(await getRecordingSession(root, "alpha")).toBe(discarded); + + holder.open(); + const [restartResult, appendResult] = await Promise.allSettled([restarting, appending]); + await held; + + if (restartResult.status === "rejected") throw restartResult.reason; + expect(restartResult.value.restarted).toBe(true); + expect(restartResult.value.discardedSteps).toBe(1); + + // The step belongs to a take that no longer exists: it must be reported as + // rejected, never as recorded. + expect(appendResult.status).toBe("rejected"); + const failure = + appendResult.status === "rejected" ? getFailureSignal(appendResult.reason) : undefined; + expect(failure?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + expect(failure?.failure_stage).toBe("flow_session_superseded"); + + // The invariant: the new take's file is what the new take says it is, and + // carries nothing from the discarded one. + const session = await getRecordingSession(root, "alpha"); + expect(session).toBeDefined(); + expect(session).not.toBe(discarded); + const onDisk = await readMarkers(root, "alpha"); + expect(onDisk).toEqual(markers(session!.flow.steps)); + expect(onDisk).not.toContain("echo:stray"); + + // …and the new take records from there as a fresh recording. + await addStep(root, "alpha", "a2"); + expect(await readMarkers(root, "alpha")).toEqual(["tool:a2"]); + const finished = await finish(root, "alpha"); + expect(finished.steps).toBe(1); + }); +}); + +// ── A finish landing on top of an in-flight append ─────────────────── + +describe("a finish that lands while a step is still running", () => { + /** + * The invariant both outcomes share: the whole report is one snapshot of one + * file state, and the recording is gone afterwards either way. + */ + async function expectReportMatchesDisk( + root: string, + report: Awaited> + ): Promise { + const onDisk = await readMarkers(root, "alpha"); + expect(markers(parseFlow(report.flowFile).steps)).toEqual(onDisk); + expect(report.steps).toBe(onDisk.length); + expect(report.summary).toHaveLength(onDisk.length); + expect(report.path).toBe(flowPath(root, "alpha")); + expect(report.savedTo).toBe(flowPath(root, "alpha")); + expect(await getRecordingSession(root, "alpha")).toBeUndefined(); + return onDisk; + } + + // Both outcomes are pinned, each by the lock rather than by timing. An + // earlier version varied a microtask count instead and only ever produced the + // first one: the finish awaits a real `realpath` before joining the lock + // queue, so no amount of microtask tuning can make it overtake an append + // already queued — the "append rejected" branch simply never ran. + + it("includes an append that WON the lock in everything it reports", async () => { + const root = await makeRoot("finish-inflight-append-wins"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + + // A parked holder fixes the queue order: append, then finish. + const holder = openGate(); + const held = withFlowFileLock(root, "alpha", () => holder.promise); + const appending = addStep(root, "alpha", "a2"); + await settle(); + const finishing = finish(root, "alpha"); + holder.open(); + + const [appended, finished] = await Promise.allSettled([appending, finishing]); + await held; + + if (appended.status === "rejected") throw appended.reason; + if (finished.status === "rejected") throw finished.reason; + expect(await expectReportMatchesDisk(root, finished.value)).toEqual(["tool:a1", "tool:a2"]); + }); + + it("reports the file without an append that LOST, and rejects that append", async () => { + const root = await makeRoot("finish-inflight-finish-wins"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + + // Parked in its LIVE phase, before it has taken the lock — a real step can + // sit here for minutes on a device. The finish runs to completion across it. + const gate = gateNextSubTool(); + const appending = addStep(root, "alpha", "a2"); + await gate.reached; + + const report = await finish(root, "alpha"); + expect(await expectReportMatchesDisk(root, report)).toEqual(["tool:a1"]); + + gate.release(); + const appended = await captureFailure(appending); + expect(getFailureSignal(appended)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + // The step ran on the device but is in no take, and the file the finish + // reported is still exactly what is on disk. + expect(await readMarkers(root, "alpha")).toEqual(["tool:a1"]); + }); + + it("reads the file back and clears the session only once the lock is free", async () => { + const root = await makeRoot("finish-lock"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + + const order: string[] = []; + const lock = openGate(); + const held = withFlowFileLock(root, "alpha", () => lock.promise); + + const finishing = finish(root, "alpha").then((r) => { + order.push("finish-returned"); + return r; + }); + + await settle(); + expect(order).toEqual([]); + // The session is still live: resolve-read-clear is one critical section. + expect(await getRecordingSession(root, "alpha")).toBeDefined(); + + order.push("lock-released"); + lock.open(); + await held; + const finished = await finishing; + + expect(order).toEqual(["lock-released", "finish-returned"]); + expect(finished.steps).toBe(1); + expect(markers(parseFlow(finished.flowFile).steps)).toEqual(["tool:a1"]); + expect(await getRecordingSession(root, "alpha")).toBeUndefined(); + }); + + it("rejects a step whose recording was already finished", async () => { + const root = await makeRoot("append-after-finish"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + + const gate = gateNextSubTool(); + const appending = addStep(root, "alpha", "a2"); + await gate.reached; + + // The finish completes end-to-end before the step comes back. + const finished = await finish(root, "alpha"); + expect(finished.steps).toBe(1); + + gate.release(); + const err = await captureFailure(appending); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + expect(getFailureSignal(err)?.failure_stage).toBe("flow_session_superseded"); + expect((err as Error).message).toContain("no longer active"); + // The key is EMPTY here — this session's own finish cleared it, and a new + // take could only have claimed it under the same lock. Blaming a competing + // agent sends the reader hunting for one that does not exist; the hazard to + // name is the finished take now sitting on disk. + expect((err as Error).message).not.toMatch(/belongs to another take/); + expect((err as Error).message).toMatch(/finished take is on disk/); + + // The finished file is exactly what the finish reported. + expect(await readMarkers(root, "alpha")).toEqual(["tool:a1"]); + expect(markers(parseFlow(finished.flowFile).steps)).toEqual(["tool:a1"]); + }); +}); + +// ── A finish whose file a hand-edit broke ──────────────────────────── + +describe("a finish on a flow file that no longer parses", () => { + // Hand-editing the .yaml mid-recording is a documented workflow, so parseFlow + // can legitimately throw inside flow-finish-recording's critical section. The + // session must survive that: clearing the key first leaves the agent unable to + // retry the finish after repairing the file — flow-finish-recording answers + // "No active recording", and the only tool that re-establishes the key, + // flow-start-recording, truncates the very take it would be recovering. + + /** `steps` present but not a list — a shape parseFlow rejects outright. */ + const NOT_A_LIST = 'executionPrerequisite: ""\nsteps: oops\n'; + + it("keeps the recording live and finishable once the file is repaired", async () => { + const root = await makeRoot("finish-unparseable"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + await addEcho(root, "alpha", "a2"); + const session = await getRecordingSession(root, "alpha"); + const repaired = await fs.readFile(flowPath(root, "alpha"), "utf8"); + + await fs.writeFile(flowPath(root, "alpha"), NOT_A_LIST, "utf8"); + + const err = await captureFailure(finish(root, "alpha")); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_INVALID); + expect(getFailureSignal(err)?.failure_stage).toBe("flow_file_parse"); + + // The finish reads and clears; it never writes — the botched edit is still + // on disk byte for byte, so the agent can diff it against what it typed. + expect(await fs.readFile(flowPath(root, "alpha"), "utf8")).toBe(NOT_A_LIST); + + // The take survived the failure, as the same session object. + expect(await getRecordingSession(root, "alpha")).toBe(session); + expect(session?.flow.steps).toHaveLength(2); + + // A retry while the file is still broken fails the same way — the recording + // is live (the call got past requireRecordingSession), the FILE is at fault. + const again = await captureFailure(finish(root, "alpha")); + expect(getFailureSignal(again)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_INVALID); + + // Repair the file the way the agent would, then carry on recording… + await fs.writeFile(flowPath(root, "alpha"), repaired, "utf8"); + await addEcho(root, "alpha", "a3"); + expect(await readMarkers(root, "alpha")).toEqual(["tool:a1", "echo:a2", "echo:a3"]); + + // …and the retried finish succeeds, reporting the repaired file. + const finished = await finish(root, "alpha"); + expect(finished.steps).toBe(3); + expect(finished.summary).toHaveLength(3); + expect(markers(parseFlow(finished.flowFile).steps)).toEqual(["tool:a1", "echo:a2", "echo:a3"]); + expect(await getRecordingSession(root, "alpha")).toBeUndefined(); + }); + + it("leaves a concurrent recording — and its own key — exactly as they were", async () => { + const root = await makeRoot("finish-unparseable-step"); + await start(root, "alpha"); + await start(root, "beta"); + await addStep(root, "alpha", "a1"); + await addEcho(root, "beta", "b1"); + + // A second botched-edit shape: a step whose directive key is a typo. + await fs.writeFile( + flowPath(root, "alpha"), + 'executionPrerequisite: ""\nsteps:\n - ecko: oops\n', + "utf8" + ); + + const err = await captureFailure(finish(root, "alpha")); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_ENTRY_UNRECOGNIZED); + + // Both keys are still live, each bound to its own file. + expect( + listActiveRecordings() + .map((r) => r.name) + .sort() + ).toEqual(["alpha", "beta"]); + expect((await getRecordingSession(root, "alpha"))?.filePath).toBe(flowPath(root, "alpha")); + + // beta neither lost its file nor its ability to finish. + expect(await readMarkers(root, "beta")).toEqual(["echo:b1"]); + const finishedBeta = await finish(root, "beta"); + expect(markers(parseFlow(finishedBeta.flowFile).steps)).toEqual(["echo:b1"]); + + // alpha outlived beta's finish too, and finishes on the repaired file. + expect(await getRecordingSession(root, "alpha")).toBeDefined(); + await fs.writeFile( + flowPath(root, "alpha"), + 'executionPrerequisite: ""\nsteps:\n - echo: repaired\n', + "utf8" + ); + const finishedAlpha = await finish(root, "alpha"); + expect(markers(parseFlow(finishedAlpha.flowFile).steps)).toEqual(["echo:repaired"]); + expect(listActiveRecordings()).toEqual([]); + }); +}); + +// ── The concurrent-recording cap ───────────────────────────────────── + +describe("the concurrent-recording cap", () => { + it("evicts the least recently touched recording and keeps the rest", async () => { + const root = await makeRoot("evict"); + const names = await fillRecordings(root); + expect(listActiveRecordings()).toHaveLength(MAX_RECORDINGS); + + // Touch everything EXCEPT one entry in the middle of the table, so the + // least-recently-used entry and the first-registered one are different + // keys: `rec-7` is the only one never touched since it was started, while + // `rec-0` was registered first but has since been used. An insertion-order + // eviction would drop `rec-0`, so the assertions below separate the two + // policies rather than passing under either. + const untouched = names[7]; + for (const name of names.filter((n) => n !== untouched)) await addEcho(root, name, "touch"); + + await start(root, "overflow"); + + const live = listActiveRecordings() + .map((r) => r.name) + .sort(); + expect(live).toHaveLength(MAX_RECORDINGS); + expect(live).toEqual([...names.filter((n) => n !== untouched), "overflow"].sort()); + expect(await getRecordingSession(root, untouched)).toBeUndefined(); + // The oldest registration survived, because it was still being used. + expect(await getRecordingSession(root, names[0])).toBeDefined(); + // The survivors are still usable — eviction dropped one, not the table. + await addEcho(root, names[0], "still-live"); + expect(await readMarkers(root, names[0])).toEqual(["echo:touch", "echo:still-live"]); + }); + + it("re-stamps a recording when its step LANDS, not just when it was resolved", async () => { + // A step that takes minutes on a device would otherwise leave its own + // session as the least-recently-used one for that whole time: the resolve + // stamped it before the step ran, and every quick call elsewhere on the + // host stamps later. The next `flow-start-recording` anywhere would then + // evict the recording that had just successfully appended, and the agent's + // next `flow-add-step` would fail on a take it was actively recording. + // + // Both callers touch on resolve via `requireRecordingSession`, so only the + // stamp inside `appendStepToFlow` separates the two — and every other + // eviction test drives recency through resolve, so none of them can. + const root = await makeRoot("touch-on-land"); + const names = await fillRecordings(root); + + // rec-0's step resolves (stamping it) and then parks on the device. + const gate = gateNextSubTool(); + const appending = addStep(root, "rec-0", "slow"); + await gate.reached; + + // Every other recording is used while that step is still running, so by + // resolve-time recency rec-0 is now the oldest entry on the table. + for (const name of names.slice(1)) await addEcho(root, name, "touch"); + + // The step lands, which must re-stamp rec-0 as most recently used. + gate.release(); + await appending; + expect(await readMarkers(root, "rec-0")).toEqual(["tool:slow"]); + + await start(root, "overflow"); + + // The recording that just appended survives; the victim is the one whose + // last use really is the oldest. Without the stamp on land, rec-0 is the + // one dropped here. + expect(await getRecordingSession(root, "rec-0")).toBeDefined(); + expect(await getRecordingSession(root, names[1])).toBeUndefined(); + // And it is still usable, not merely present. + await addEcho(root, "rec-0", "after"); + expect(await readMarkers(root, "rec-0")).toEqual(["tool:slow", "echo:after"]); + }); + + it("rejects an append whose recording was evicted while the step ran", async () => { + const root = await makeRoot("evict-inflight"); + const names = await fillRecordings(root); + + // The step resolves rec-0's session (touching it) and parks. + const gate = gateNextSubTool(); + const appending = addStep(root, "rec-0", "victim"); + await gate.reached; + + // Every other recording is touched afterwards, so rec-0's use is the oldest + // one on the table; the next start overflows the cap and drops it out from + // under the running step. + for (const name of names.slice(1)) await addEcho(root, name, "touch"); + await start(root, "overflow"); + expect(await getRecordingSession(root, "rec-0")).toBeUndefined(); + + gate.release(); + const err = await captureFailure(appending); + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + expect(getFailureSignal(err)?.failure_stage).toBe("flow_session_superseded"); + expect((err as Error).message).toContain("concurrent-recording cap"); + // Same empty-key branch as a self-finish: no other take holds this key, so + // the message must not send the agent looking for one — which would also + // bury the actionable cause named one clause earlier. + expect((err as Error).message).not.toMatch(/belongs to another take/); + expect(await readMarkers(root, "rec-0")).toEqual([]); + + // A fresh call on the evicted key fails the ordinary not-live way. + const late = await captureFailure(addEcho(root, "rec-0", "late")); + expect(getFailureSignal(late)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + expect(getFailureSignal(late)?.failure_stage).toBe("flow_require_recording"); + }); + + it("reports a destructive restart even when eviction drops the key mid-restart", async () => { + // A restart reads the take it is discarding ONCE, at the top of its critical + // section, and drives BOTH `restarted` and `discardedSteps` off that read. + // It must not re-derive `restarted` from the map after the truncate: + // `evictIfOverCapacity` runs under another key's lock and can drop this key + // in the window between the read and the register, and a `restarted` read + // there would see the key already gone and report a restart that truncated a + // real take (its file already reset) as a plain fresh start. + const root = await makeRoot("restart-evict-race"); + const names = await fillRecordings(root); + + // rec-0 holds a real step and is the least-recently-used entry: record the + // step first, then touch every other recording, so rec-0's last use is + // oldest and the next overflow evicts exactly it. + await addStep(root, "rec-0", "real"); + for (const name of names.slice(1)) await addEcho(root, name, "touch"); + + // Park rec-0's restart on its own `countStepsOnDisk` read — after it has + // captured the live session, before it truncates or re-registers. + const target = flowPath(root, "rec-0"); + const arrived = openGate(); + const held = openGate(); + let gated = false; + const realReadFile = fs.readFile; + const spy = vi.spyOn(fs, "readFile").mockImplementation((async ( + p: unknown, + ...rest: unknown[] + ) => { + if (!gated && String(p) === target) { + gated = true; + arrived.open(); + await held.promise; + } + return (realReadFile as (...a: unknown[]) => Promise)(p, ...rest); + }) as unknown as typeof fs.readFile); + + const restarting = start(root, "rec-0"); + await arrived.promise; + + // A 33rd recording overflows the cap and evicts the LRU — rec-0's key — + // while the restart is parked with rec-0's live session already captured. + await start(root, "overflow"); + expect(await getRecordingSession(root, "rec-0")).toBeUndefined(); + + held.open(); + const res = await restarting; + spy.mockRestore(); + + // The take really was destroyed… + expect(await readMarkers(root, "rec-0")).toEqual([]); + // …and the result says so, rather than collapsing to a plain fresh start. + // Reading `restarted` from `startRecordingSession`'s post-eviction return + // instead leaves `restarted` undefined here, so this separates the two. + expect(res.restarted).toBe(true); + expect(res.discardedSteps).toBe(1); + }); +}); + +// ── Recording a flow-execute step ──────────────────────────────────── + +describe("recording a flow-execute step while several projects are in play", () => { + const fragment: FlowFile = { + executionPrerequisite: "", + steps: [{ kind: "echo", message: "helper" }], + }; + + it("keeps the raw step when the target is not a sibling of the RECORDING", async () => { + const recordingRoot = await makeRoot("run-target-recording"); + const executedRoot = await makeRoot("run-target-executed"); + + // The fragment exists in the project the nested flow-execute ran in, but + // NOT next to the flow being recorded — so `run: helper` would be a + // dangling reference at replay, which resolves siblings of the recording. + await writeSavedFlow(executedRoot, "helper", fragment); + + await start(recordingRoot, "wrapper"); + const res = await addRawStep(recordingRoot, "wrapper", "flow-execute", { + name: "helper", + project_root: executedRoot, + udid: IOS_DEVICE, + }); + + expect(res.message).toContain('could not resolve "helper" as a sibling fragment'); + expect(res.message).toContain("kept the raw flow-execute step"); + expect(await readSteps(recordingRoot, "wrapper")).toEqual([ + { kind: "tool", name: "flow-execute", args: { name: "helper", project_root: executedRoot } }, + ]); + }); + + it("keeps the raw step when the executed project has no file to compare against", async () => { + const recordingRoot = await makeRoot("run-target-sibling"); + const executedRoot = await makeRoot("run-target-elsewhere"); + + // Mirror image: the fragment is a sibling of the flow being recorded and is + // absent from the executed project. Being a sibling is necessary for `run:` + // but not sufficient — the recorded directive must replay the file that + // just RAN, and nothing verifiable ran from the executed project's flows + // dir, so the two cannot be shown to be one file. The raw step, which + // replays via name + project_root, is then the only honest record. + await writeSavedFlow(recordingRoot, "helper", fragment); + await fs.mkdir(path.join(executedRoot, ".argent", "flows"), { recursive: true }); + + await start(recordingRoot, "wrapper"); + const res = await addRawStep(recordingRoot, "wrapper", "flow-execute", { + name: "helper", + project_root: executedRoot, + udid: IOS_DEVICE, + }); + + expect(res.message).toContain("could not verify which file the live flow-execute ran"); + expect(await readSteps(recordingRoot, "wrapper")).toEqual([ + { kind: "tool", name: "flow-execute", args: { name: "helper", project_root: executedRoot } }, + ]); + }); + + it("keeps the raw step when a same-named fragment exists in BOTH projects", async () => { + const recordingRoot = await makeRoot("run-target-both"); + const executedRoot = await makeRoot("run-target-both-other"); + + // The ambiguous case concurrent recording makes routine: a generic fragment + // name that exists in two projects. `run: helper` resolves against the + // recording, so replay would run a DIFFERENT file than the one that just + // ran — same name, different flow, both green and nothing said. The + // recorder refuses the substitution and keeps the raw call, which names + // both files and reproduces exactly what ran. + await writeSavedFlow(recordingRoot, "helper", fragment); + await writeSavedFlow(executedRoot, "helper", { + executionPrerequisite: "", + steps: [{ kind: "echo", message: "the other project's helper" }], + }); + + await start(recordingRoot, "wrapper"); + const res = await addRawStep(recordingRoot, "wrapper", "flow-execute", { + name: "helper", + project_root: executedRoot, + udid: IOS_DEVICE, + }); + + expect(res.message).toContain("not the file the live flow-execute ran"); + expect(res.message).toContain(executedRoot); + expect(await readSteps(recordingRoot, "wrapper")).toEqual([ + { kind: "tool", name: "flow-execute", args: { name: "helper", project_root: executedRoot } }, + ]); + + // Same project on both sides is the unambiguous case: the file that ran and + // the sibling that would replay are one file, so it composes and stays quiet. + await start(recordingRoot, "quiet"); + const same = await addRawStep(recordingRoot, "quiet", "flow-execute", { + name: "helper", + project_root: recordingRoot, + udid: IOS_DEVICE, + }); + expect(same.message).toBe('Step added to "quiet" flow'); + expect(await readSteps(recordingRoot, "quiet")).toEqual([{ kind: "run", flow: "helper.yaml" }]); + }); +}); + +// ── Summarizing a hand-edited file that the parser cannot fully constrain ── + +describe("finishing a recording whose YAML was hand-edited into an unrenderable step", () => { + it("summarizes a cyclic tool-args anchor instead of throwing", async () => { + const root = await makeRoot("cyclic-args"); + await start(root, "alpha"); + await addStep(root, "alpha", "a1"); + + // Hand-editing mid-recording is a documented workflow, and `args:` is the + // one step body the parser does not constrain — a cyclic YAML anchor + // reaches the summarizer as a cyclic object, which JSON.stringify throws on. + await fs.writeFile( + flowPath(root, "alpha"), + 'executionPrerequisite: ""\nsteps:\n - tool: keyboard\n args: &a\n self: *a\n', + "utf8" + ); + + const finished = await finish(root, "alpha"); + expect(finished.steps).toBe(1); + expect(finished.summary).toEqual(["1. tool: keyboard [cyclic args]"]); + // The recording is properly closed, not left dangling by a thrown summary. + expect(await getRecordingSession(root, "alpha")).toBeUndefined(); + }); +}); diff --git a/packages/tool-server/test/flows/flow-deviceless.test.ts b/packages/tool-server/test/flows/flow-deviceless.test.ts index 8b3eec9a9..5f1bc5716 100644 --- a/packages/tool-server/test/flows/flow-deviceless.test.ts +++ b/packages/tool-server/test/flows/flow-deviceless.test.ts @@ -3,9 +3,11 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { Registry } from "@argent/registry"; +import { zodObjectToJsonSchema } from "@argent/registry"; import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run"; import { serializeFlow, type FlowStep } from "../../src/tools/flows/flow-utils"; import { stepRequiresDevice } from "../../src/tools/flows/flow-device"; +import { createStopAllSimulatorServersTool } from "../../src/tools/simulator/stop-all-simulator-servers"; const DEVICE = "00000000-0000-0000-0000-0000000000ab"; let tmpDir: string; @@ -17,8 +19,11 @@ let tmpDir: string; const TOOLS: Record = { "tap": { inputSchema: { properties: { udid: {}, x: {}, y: {} } } }, "stop-metro": { inputSchema: { properties: { port: {} } } }, - // A real tool that declares no input at all. - "stop-all-simulator-servers": {}, + // Declares a device LIST rather than a single id — the shape the runner has + // to rebind to the run device, and therefore one that makes a step need one. + "stop-all-simulator-servers": { inputSchema: { properties: { devices: {} } } }, + // A tool that declares no input at all. + "gather-workspace-data": {}, // Takes a device without receiving the run's own. "flow-execute": { inputSchema: { properties: { name: {}, device: {} } } }, }; @@ -141,10 +146,10 @@ describe("a flow that touches no device", () => { it("runs a tool step whose tool declares no input at all", async () => { // A tool with no schema must not be mistaken for one that needs a device, // and reading its absent schema must not throw. - await writeFlow("stop-all", [{ kind: "tool", name: "stop-all-simulator-servers", args: {} }]); + await writeFlow("no-schema", [{ kind: "tool", name: "gather-workspace-data", args: {} }]); const { registry } = mockRegistry({ booted: [] }); - expect(asRun(await runAuto(registry, "stop-all")).ok).toBe(true); + expect(asRun(await runAuto(registry, "no-schema")).ok).toBe(true); }); it("runs an empty flow", async () => { @@ -282,7 +287,157 @@ describe("stepRequiresDevice", () => { expect(stepRequiresDevice(registry, toolStep("tap"))).toBe(true); expect(stepRequiresDevice(registry, toolStep("flow-execute"))).toBe(true); expect(stepRequiresDevice(registry, toolStep("stop-metro"))).toBe(false); - expect(stepRequiresDevice(registry, toolStep("stop-all-simulator-servers"))).toBe(false); + expect(stepRequiresDevice(registry, toolStep("gather-workspace-data"))).toBe(false); expect(stepRequiresDevice(registry, toolStep("not-a-tool"))).toBe(true); }); + + it("does NOT count the REAL stop-all-simulator-servers schema as needing a device", () => { + // Against the derived JSON schema, not the mock above: the mock is only as + // good as its agreement with the tool, and the failure this guards is + // exactly a drift between the two. Catches a rename of `devices` too. + // + // `devices` is a SCOPE, not a target: the unscoped call is a complete, + // meaningful machine-wide sweep, so a flow whose only step is this one + // needs no device. Counting it made such a flow demand one — see the + // cleanup-flow cases below, which are the two situations it actually runs + // in. + const schema = zodObjectToJsonSchema( + createStopAllSimulatorServersTool({} as unknown as Registry).zodSchema! + ); + expect(Object.keys((schema as { properties: Record }).properties)).toContain( + "devices" + ); + const registry = { getTool: () => ({ inputSchema: schema }) } as unknown as Registry; + expect( + stepRequiresDevice(registry, { kind: "tool", name: "stop-all-simulator-servers", args: {} }) + ).toBe(false); + }); + + it("counts a device TARGET argument, but not a device LIST scope", () => { + // The distinction is what a missing device does to the step: `screenshot` + // with no `udid` has nothing to point at, while the teardown with no + // `devices` is the sweep itself. + const { registry } = mockRegistry(); + expect( + stepRequiresDevice(registry, { kind: "tool", name: "stop-all-simulator-servers", args: {} }) + ).toBe(false); + expect(stepRequiresDevice(registry, { kind: "tool", name: "tap", args: {} })).toBe(true); + }); +}); + +describe("a cleanup flow whose only step is stop-all-simulator-servers", () => { + const teardownOnly: FlowStep[] = [ + // What the recorder writes for an UNSCOPED `stop-all-simulator-servers`. + // A scoped one keeps its `devices` in the YAML — `stripDeviceKeys` touches + // only the target keys, and `flow-tools.test.ts`'s "keeps the devices list + // when recording a scoped teardown" pins that — so the empty args here are + // the recording of the machine-wide sweep, which replay then NARROWS onto + // the run device. + { kind: "tool", name: "stop-all-simulator-servers", args: {} }, + ]; + + it("replays against the run device when exactly one is booted", async () => { + await writeFlow("teardownonly", teardownOnly); + const { registry, invokeTool } = mockRegistry({ booted: [DEVICE] }); + const run = asRun(await runAuto(registry, "teardownonly")); + + expect(run.device).toBe(DEVICE); + expect(run.ok).toBe(true); + expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", { devices: [DEVICE] }); + }); + + it("runs as the machine-wide sweep with NOTHING booted", async () => { + // One of the two situations a cleanup flow actually runs in. Requiring a + // device here failed it with "No booted device found" — on a flow whose + // entire purpose is to run when the machine needs clearing. + await writeFlow("teardownonly", teardownOnly); + const { registry, invokeTool } = mockRegistry({ booted: [] }); + const run = asRun(await runAuto(registry, "teardownonly")); + + expect(run.ok).toBe(true); + expect(run.passed).toBe(1); + // No scope, and emphatically not `[""]` — an id that owns nothing would + // reap nothing and still pass. + expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", {}); + }); + + it("runs as the machine-wide sweep with SEVERAL booted, without disambiguation", async () => { + // The other one. Requiring a device here failed with "2 booted devices + // matched — pass --device or --platform", which is not a question a sweep + // has an answer to. + await writeFlow("teardownonly", teardownOnly); + const other = "11111111-1111-1111-1111-111111111111"; + const { registry, invokeTool } = mockRegistry({ booted: [DEVICE, other] }); + const run = asRun(await runAuto(registry, "teardownonly")); + + expect(run.ok).toBe(true); + expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", {}); + }); + + it("scopes to an explicitly passed device", async () => { + // The narrowing is deliberate where the run has an answer: a replayed + // teardown must not reap devices another agent is mid-session on. + await writeFlow("teardownonly", teardownOnly); + const other = "11111111-1111-1111-1111-111111111111"; + const { registry, invokeTool } = mockRegistry({ booted: [DEVICE, other] }); + const runFlow = createRunFlowTool(registry); + const run = asRun( + await runFlow.execute({}, { name: "teardownonly", project_root: tmpDir, device: DEVICE }) + ); + + expect(run.ok).toBe(true); + expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", { devices: [DEVICE] }); + }); + + it("falls back to the sweep when a passed platform still matches several", async () => { + // A platform that does not narrow to one device is not an answer either, + // and the flow must still run rather than demanding --device. + await writeFlow("teardownonly", teardownOnly); + const other = "11111111-1111-1111-1111-111111111111"; + const { registry, invokeTool } = mockRegistry({ booted: [DEVICE, other] }); + const runFlow = createRunFlowTool(registry); + const run = asRun( + await runFlow.execute({}, { name: "teardownonly", project_root: tmpDir, platform: "ios" }) + ); + + expect(run.ok).toBe(true); + expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", {}); + }); + + it("fails the run when list-devices itself breaks, rather than sweeping the machine", async () => { + // The opportunistic resolve swallows one answer — "nothing booted, or + // several" — and used to swallow every other failure with it: an + // adb/simctl error, a dead sub-tool, an abort. The teardown then ran + // UNSCOPED and reported pass, which is the machine-wide sweep this path + // exists to avoid, on a machine whose device list nobody could even read. + await writeFlow("teardownonly", teardownOnly); + const { registry, invokeTool } = mockRegistry({ booted: [DEVICE] }); + vi.mocked(registry.invokeTool).mockImplementation(async (id: string) => { + if (id === "list-devices") throw new Error("adb: device offline"); + return { ok: true }; + }); + + await expect(runAuto(registry, "teardownonly")).rejects.toThrow(/adb: device offline/); + expect(invokeTool).not.toHaveBeenCalledWith( + "stop-all-simulator-servers", + expect.anything(), + expect.anything() + ); + }); + + it("still scopes the teardown when the flow ALSO has a device step", async () => { + // A flow with a real device step resolves one as it always did, and the + // teardown is scoped to it — the cross-agent protection the scope exists + // for is unaffected by any of the above. + await writeFlow("teardownmixed", [ + { kind: "tool", name: "tap", args: { x: 1, y: 2 } }, + ...teardownOnly, + ]); + const { registry, invokeTool } = mockRegistry({ booted: [DEVICE] }); + const run = asRun(await runAuto(registry, "teardownmixed")); + + expect(run.device).toBe(DEVICE); + expect(run.ok).toBe(true); + expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", { devices: [DEVICE] }); + }); }); diff --git a/packages/tool-server/test/flows/flow-feature-flag-gate.test.ts b/packages/tool-server/test/flows/flow-feature-flag-gate.test.ts index 1b6a813ae..1660f8500 100644 --- a/packages/tool-server/test/flows/flow-feature-flag-gate.test.ts +++ b/packages/tool-server/test/flows/flow-feature-flag-gate.test.ts @@ -12,8 +12,8 @@ * effect never happens (store not mutated); * - flag ON → the same step runs and the side effect lands. * - * Run `run_in_band`-style serially because it relies on a shared active project - * root (the flow harness's module state), like the sibling flow tests. + * Each case gets its own temp project root, passed explicitly to `flow-execute`, + * so nothing is shared between them. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as fs from "node:fs/promises"; @@ -23,11 +23,7 @@ import { z } from "zod"; import { Registry } from "@argent/registry"; import { createRunFlowTool } from "../../src/tools/flows/flow-run"; -import { - clearActiveProjectRoot, - setActiveProjectRoot, - serializeFlow, -} from "../../src/tools/flows/flow-utils"; +import { serializeFlow } from "../../src/tools/flows/flow-utils"; let tmpDir: string; @@ -65,11 +61,9 @@ async function writeFlow(name: string): Promise { beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-flag-gate-")); - setActiveProjectRoot(tmpDir); }); afterEach(async () => { - clearActiveProjectRoot(); await fs.rm(tmpDir, { recursive: true, force: true }); }); diff --git a/packages/tool-server/test/flows/flow-key-sequencer.test.ts b/packages/tool-server/test/flows/flow-key-sequencer.test.ts new file mode 100644 index 000000000..477571579 --- /dev/null +++ b/packages/tool-server/test/flows/flow-key-sequencer.test.ts @@ -0,0 +1,107 @@ +/** + * `keyResolutions` — the in-flight map every recording tool's key resolution + * passes through. + * + * Resolution is `realpath`, which runs on libuv's threadpool and completes in + * an order unrelated to the order it was requested in. Every recording tool + * resolves its key BEFORE joining its flow file's lock queue, so without the + * sequencer which of two calls acquires the lock first is decided by threadpool + * scheduling rather than by which was issued first — and a restart can land + * behind the append it is supposed to discard. + * + * Nothing imported it, so the property had no test at all. Here `realpath` is + * mocked to complete in decreasing time, which inverts the issue order + * deterministically: without the sequencer the second caller wins. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as realFs from "node:fs/promises"; + +/** + * How long each `realpath` call takes, by call index — strictly decreasing, so + * a later request always finishes before an earlier one. + */ +const DELAYS = [40, 30, 20, 10, 8, 6, 4, 2]; +let realpathCalls = 0; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpath: (p: string) => { + const delay = DELAYS[Math.min(realpathCalls++, DELAYS.length - 1)]!; + return new Promise((resolve, reject) => { + setTimeout(() => { + actual.realpath(p).then(resolve, reject); + }, delay); + }); + }, + }; +}); + +import { withFlowFileLock, __resetRecordingsForTesting } from "../../src/tools/flows/flow-utils"; + +let root: string; + +beforeEach(async () => { + __resetRecordingsForTesting(); + realpathCalls = 0; + root = await realFs.mkdtemp(path.join(os.tmpdir(), "flow-key-seq-")); + await realFs.mkdir(path.join(root, ".argent", "flows"), { recursive: true }); + await realFs.writeFile(path.join(root, ".argent", "flows", "alpha.yaml"), "steps: []\n", "utf8"); +}); + +afterEach(async () => { + await realFs.rm(root, { recursive: true, force: true }); +}); + +describe("the flow-key resolution sequencer", () => { + it("keeps the lock queue in the order the calls were issued", async () => { + const order: string[] = []; + const enter = (label: string) => + withFlowFileLock(root, "alpha", async () => { + order.push(label); + }); + + // Issued first, resolves SLOWEST if it resolves on its own. + const first = enter("first"); + const second = enter("second"); + const third = enter("third"); + await Promise.all([first, second, third]); + + expect(order).toEqual(["first", "second", "third"]); + }); + + it("shares one resolution between callers spelling the path the same way", async () => { + const before = realpathCalls; + await Promise.all([ + withFlowFileLock(root, "alpha", async () => {}), + withFlowFileLock(root, "alpha", async () => {}), + withFlowFileLock(root, "alpha", async () => {}), + ]); + // One resolution — a dir + a file realpath — not three. + expect(realpathCalls - before).toBe(2); + }); + + it("drops the entry once it settles, so a repointed link is seen next time", async () => { + // Not a cache: a second round must resolve again rather than reuse the + // first round's answer. + await withFlowFileLock(root, "alpha", async () => {}); + const after = realpathCalls; + await withFlowFileLock(root, "alpha", async () => {}); + expect(realpathCalls).toBeGreaterThan(after); + }); + + it("resolves two DIFFERENT flows separately rather than sharing one answer", async () => { + // Keyed by the SPELLED path, so two flows never collapse onto one + // resolution — which would hand one file's key to the other's lock. + await realFs.writeFile(path.join(root, ".argent", "flows", "beta.yaml"), "steps: []\n", "utf8"); + const before = realpathCalls; + await Promise.all([ + withFlowFileLock(root, "alpha", async () => {}), + withFlowFileLock(root, "beta", async () => {}), + ]); + expect(realpathCalls - before).toBe(4); + }); +}); diff --git a/packages/tool-server/test/flows/flow-record-tap.test.ts b/packages/tool-server/test/flows/flow-record-tap.test.ts index b470072b6..8729efe03 100644 --- a/packages/tool-server/test/flows/flow-record-tap.test.ts +++ b/packages/tool-server/test/flows/flow-record-tap.test.ts @@ -15,14 +15,10 @@ vi.mock("../../src/tools/flows/flow-tree", () => ({ import { createFlowAddStepTool } from "../../src/tools/flows/flow-add-step"; import { flowStartRecordingTool } from "../../src/tools/flows/flow-start-recording"; -import { - clearActiveFlow, - clearActiveProjectRoot, - parseFlow, - setActiveProjectRoot, -} from "../../src/tools/flows/flow-utils"; +import { __resetRecordingsForTesting, parseFlow } from "../../src/tools/flows/flow-utils"; const DEVICE = "00000000-0000-0000-0000-0000000000AB"; // iOS UDID shape +const FLOW = "rec"; const PREREQ = "App on home screen"; let tmpDir: string; @@ -53,28 +49,31 @@ async function recordTap(point: { x: number; y: number }) { const tool = createFlowAddStepTool(mockRegistry()); return tool.execute( {}, - { command: "gesture-tap", args: JSON.stringify({ udid: DEVICE, ...point }) } + { + name: FLOW, + project_root: tmpDir, + command: "gesture-tap", + args: JSON.stringify({ udid: DEVICE, ...point }), + } ); } async function recordedSteps() { - const content = await fs.readFile(path.join(tmpDir, ".argent", "flows", "rec.yaml"), "utf8"); + const content = await fs.readFile(path.join(tmpDir, ".argent", "flows", `${FLOW}.yaml`), "utf8"); return parseFlow(content).steps; } beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-record-tap-")); - setActiveProjectRoot(tmpDir); - clearActiveFlow(); + __resetRecordingsForTesting(); await flowStartRecordingTool.execute( {}, - { name: "rec", project_root: tmpDir, executionPrerequisite: PREREQ } + { name: FLOW, project_root: tmpDir, executionPrerequisite: PREREQ } ); }); afterEach(async () => { - clearActiveFlow(); - clearActiveProjectRoot(); + __resetRecordingsForTesting(); await fs.rm(tmpDir, { recursive: true, force: true }); }); @@ -128,6 +127,8 @@ describe("flow-add-step tap selector capture", () => { await tool.execute( {}, { + name: FLOW, + project_root: tmpDir, command: "gesture-tap", args: JSON.stringify({ udid: DEVICE, x: 0.5, y: 0.52, clickCount: 2 }), } diff --git a/packages/tool-server/test/flows/flow-remote-recording.test.ts b/packages/tool-server/test/flows/flow-remote-recording.test.ts index a4befac80..726135421 100644 --- a/packages/tool-server/test/flows/flow-remote-recording.test.ts +++ b/packages/tool-server/test/flows/flow-remote-recording.test.ts @@ -3,7 +3,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { Registry, ToolContext } from "@argent/registry"; -import { ArtifactStore, CLIENT_FILE_MARKER } from "@argent/registry"; +import { + ArtifactStore, + CLIENT_FILE_MARKER, + FAILURE_CODES, + getFailureSignal, +} from "@argent/registry"; import { flowStartRecordingTool } from "../../src/tools/flows/flow-start-recording"; import { flowInsertEchoTool } from "../../src/tools/flows/flow-insert-echo"; @@ -11,11 +16,7 @@ import { flowFinishRecordingTool } from "../../src/tools/flows/flow-finish-recor import { createFlowAddStepTool } from "../../src/tools/flows/flow-add-step"; import { createRunFlowTool, resolveFlowSource } from "../../src/tools/flows/flow-run"; import { flowReadPrerequisiteTool } from "../../src/tools/flows/flow-read-prerequisite"; -import { - clearActiveFlow, - clearActiveProjectRoot, - parseFlow, -} from "../../src/tools/flows/flow-utils"; +import { __resetRecordingsForTesting, parseFlow } from "../../src/tools/flows/flow-utils"; /** * Remote-mode flow behavior: the agent's project_root does NOT exist on this @@ -28,11 +29,16 @@ import { const CLIENT_ROOT = path.join(os.tmpdir(), "definitely-not-on-this-host", "agent-project"); const CLIENT_FLOW_PATH = path.join(CLIENT_ROOT, ".argent", "flows", "remote-flow.yaml"); -function remoteCtx(): ToolContext { +// A SECOND client project — a different agent recording a flow of the same +// name. Same host, same flow name, different project root. +const OTHER_CLIENT_ROOT = path.join(os.tmpdir(), "definitely-not-on-this-host", "other-project"); +const OTHER_CLIENT_FLOW_PATH = path.join(OTHER_CLIENT_ROOT, ".argent", "flows", "remote-flow.yaml"); + +function remoteCtx(root: string = CLIENT_ROOT): ToolContext { return { artifacts: new ArtifactStore(), fileInputs: { - project_root: { clientPath: CLIENT_ROOT, presentOnHost: false, viaUpload: false }, + project_root: { clientPath: root, presentOnHost: false, viaUpload: false }, }, }; } @@ -59,13 +65,13 @@ function createMockRegistry(tools: Record = {}) { } beforeEach(() => { - clearActiveFlow(); + __resetRecordingsForTesting(); }); afterEach(async () => { - clearActiveFlow(); - clearActiveProjectRoot(); + __resetRecordingsForTesting(); await fs.rm(CLIENT_ROOT, { recursive: true, force: true }); + await fs.rm(OTHER_CLIENT_ROOT, { recursive: true, force: true }); }); describe("flow recording with a remote client (probe miss)", () => { @@ -96,8 +102,14 @@ describe("flow recording with a remote client (probe miss)", () => { remoteCtx() ); - await flowInsertEchoTool.execute({}, { message: "label" }); - const stepResult = await addStep.execute({}, { command: "tap", args: '{"x":0.5}' }); + await flowInsertEchoTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, message: "label" } + ); + const stepResult = await addStep.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, command: "tap", args: '{"x":0.5}' } + ); const directive = stepResult.savedTo as { path: string; content: string }; expect(directive.path).toBe(CLIENT_FLOW_PATH); @@ -127,9 +139,12 @@ describe("flow recording with a remote client (probe miss)", () => { const stepResult = await addStep.execute( {}, { + name: "remote-flow", + project_root: CLIENT_ROOT, command: "flow-execute", args: JSON.stringify({ name: "sub", project_root: CLIENT_ROOT, device: "RECORD-TIME-ID" }), - } + }, + remoteCtx() ); const directive = stepResult.savedTo as { content: string }; @@ -152,6 +167,8 @@ describe("flow recording with a remote client (probe miss)", () => { addStep.execute( {}, { + name: "remote-flow", + project_root: CLIENT_ROOT, command: "flow-execute", args: JSON.stringify({ flow_path: path.join(CLIENT_ROOT, ".argent", "flows", "login.yaml"), @@ -170,16 +187,181 @@ describe("flow recording with a remote client (probe miss)", () => { { name: "remote-flow", project_root: CLIENT_ROOT, executionPrerequisite: "Home" }, remoteCtx() ); - await flowInsertEchoTool.execute({}, { message: "only step" }); + await flowInsertEchoTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, message: "only step" } + ); - const result = await flowFinishRecordingTool.execute({}, {}); + const result = await flowFinishRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT } + ); expect(result.steps).toBe(1); expect(result.summary).toEqual(["1. echo: only step"]); expect(result.path).toBe(CLIENT_FLOW_PATH); - expect(result.savedTo).toMatchObject({ [CLIENT_FILE_MARKER]: true }); - await expect(flowFinishRecordingTool.execute({}, {})).rejects.toThrow("No active flow"); + // Assert the directive's CONTENT, not just its shape. `steps`, `summary` + // and `path` all derive from the in-memory flow, so they agree with each + // other no matter what `savedTo` carries — and in client mode `savedTo` is + // the only thing that lands the artifact (`path` names a file that does not + // exist on this host). A directive built with an empty body would satisfy + // every other assertion here while the client wrote a flow with no steps, + // which replays as a top-level PASS over nothing. + const savedTo = result.savedTo as { [CLIENT_FILE_MARKER]: true; path: string; content: string }; + expect(savedTo[CLIENT_FILE_MARKER]).toBe(true); + expect(savedTo.path).toBe(CLIENT_FLOW_PATH); + expect(parseFlow(savedTo.content).steps).toEqual([{ kind: "echo", message: "only step" }]); + // The finished YAML the caller is shown and the one the client writes must + // be the same bytes. + expect(savedTo.content).toBe(result.flowFile); + + await expect( + flowFinishRecordingTool.execute({}, { name: "remote-flow", project_root: CLIENT_ROOT }) + ).rejects.toThrow("No active recording"); + }); + + it("a rejected append leaves the session usable instead of poisoning it", async () => { + // In client mode the in-memory flow is the ONLY copy, so a step the append + // refuses must not stay in it — every later append, and the finish itself, + // would re-hit the same error with no way to recover. Both gates are + // exercised: serializeFlow (an unrepresentable step) and validateFlow (a + // cross-field violation). + const registry = createMockRegistry({ + "gesture-tap": { result: { tapped: true } }, + "restart-app": { result: { restarted: true } }, + }); + const addStep = createFlowAddStepTool(registry); + const device = "00000000-0000-0000-0000-0000000000ab"; + + await flowStartRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, executionPrerequisite: "Home" }, + remoteCtx() + ); + await flowInsertEchoTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, message: "before" } + ); + + // serializeFlow rejects: a tap carrying pixel coordinates, not the + // normalized 0–1 fractions a YAML gesture target can represent. (Selector + // capture can't reach a device here, so the coordinates are kept as-is.) + await expect( + addStep.execute( + {}, + { + name: "remote-flow", + project_root: CLIENT_ROOT, + command: "gesture-tap", + args: JSON.stringify({ udid: device, x: 250, y: 400 }), + } + ) + ).rejects.toThrow("not pixels"); + + // validateFlow rejects: a `restart-app` is recorded as a `launch`, and this + // recording declared an executionPrerequisite — a flow that begins by + // launching controls its own start state and must not declare one. + await expect( + addStep.execute( + {}, + { + name: "remote-flow", + project_root: CLIENT_ROOT, + command: "restart-app", + args: JSON.stringify({ udid: device, bundleId: "com.example.app" }), + } + ) + ).rejects.toThrow("must not declare executionPrerequisite"); + + // The session survived both rejections: the next append succeeds, and its + // directive carries only the accepted steps — neither rejected step is in + // the flow, and neither error is replayed. + const after = await flowInsertEchoTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, message: "after" } + ); + const directive = after.savedTo as { path: string; content: string }; + expect(directive.path).toBe(CLIENT_FLOW_PATH); + expect(parseFlow(directive.content).steps).toEqual([ + { kind: "echo", message: "before" }, + { kind: "echo", message: "after" }, + ]); + expect(parseFlow(directive.content).executionPrerequisite).toBe("Home"); + + // And the recording still finishes — the whole point of rolling back. + const finished = await flowFinishRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT } + ); + expect(finished.steps).toBe(2); + expect(finished.summary).toEqual(["1. echo: before", "2. echo: after"]); + expect(finished.savedTo).toMatchObject({ + [CLIENT_FILE_MARKER]: true, + path: CLIENT_FLOW_PATH, + }); + await expect(fs.stat(CLIENT_ROOT)).rejects.toThrow(); + }); + + it("keeps same-named recordings under different client roots isolated", async () => { + const registry = createMockRegistry({ tap: { result: { tapped: true } } }); + const addStep = createFlowAddStepTool(registry); + + await flowStartRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, executionPrerequisite: "Home" }, + remoteCtx() + ); + await flowStartRecordingTool.execute( + {}, + { name: "remote-flow", project_root: OTHER_CLIENT_ROOT, executionPrerequisite: "Settings" }, + remoteCtx(OTHER_CLIENT_ROOT) + ); + + await flowInsertEchoTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, message: "first client" } + ); + const otherStep = await addStep.execute( + {}, + { name: "remote-flow", project_root: OTHER_CLIENT_ROOT, command: "tap", args: '{"x":0.5}' } + ); + + // Each directive names its OWN client's file and carries only that + // recording's steps — the second agent's tap never joins the first's flow. + const otherDirective = otherStep.savedTo as { path: string; content: string }; + expect(otherDirective.path).toBe(OTHER_CLIENT_FLOW_PATH); + expect(parseFlow(otherDirective.content).steps).toEqual([ + { kind: "tool", name: "tap", args: { x: 0.5 } }, + ]); + expect(parseFlow(otherDirective.content).executionPrerequisite).toBe("Settings"); + + // Finishing one leaves the other live, with its own path and steps. + const first = await flowFinishRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT } + ); + expect(first.path).toBe(CLIENT_FLOW_PATH); + expect(first.summary).toEqual(["1. echo: first client"]); + expect(first.savedTo).toMatchObject({ + [CLIENT_FILE_MARKER]: true, + path: CLIENT_FLOW_PATH, + }); + + const other = await flowFinishRecordingTool.execute( + {}, + { name: "remote-flow", project_root: OTHER_CLIENT_ROOT } + ); + expect(other.path).toBe(OTHER_CLIENT_FLOW_PATH); + expect(other.summary).toEqual(['1. tool: tap {"x":0.5}']); + expect(other.savedTo).toMatchObject({ + [CLIENT_FILE_MARKER]: true, + path: OTHER_CLIENT_FLOW_PATH, + }); + + // Neither client's directory layout was recreated on this host. + await expect(fs.stat(CLIENT_ROOT)).rejects.toThrow(); + await expect(fs.stat(OTHER_CLIENT_ROOT)).rejects.toThrow(); }); }); @@ -577,6 +759,32 @@ describe("flow_file containment", () => { ).toBe(uploaded); }); + it("exempts an upload, and ONLY an upload, from containment", async () => { + // The exemption keys on `viaUpload`, not on the mere presence of a file + // input — but nothing pinned that discrimination: every containment case + // above passes `fileInput: undefined`, and the one case that supplies one + // uses `viaUpload: true`. So relaxing the guard to + // + // if (fileInput?.viaUpload) -> if (fileInput) + // + // left the whole suite green while opening the containment bypass to the + // COMMON shape: `resolveOne` returns `viaUpload: false` for any wire path + // that already exists on the host, i.e. every same-machine `flow-execute` + // carrying a `flow_file`. + const hostPath = { clientPath: CLIENT_FLOW_PATH, presentOnHost: true, viaUpload: false }; + + await expect(resolveFlowSource(params("/etc/anything.yaml"), hostPath)).rejects.toThrow( + "Invalid flow_file" + ); + // The same input with the upload flag set is the trusted case, and must + // still pass — otherwise this test would also hold for a guard that simply + // ignored `fileInput` altogether. + expect( + (await resolveFlowSource(params("/etc/anything.yaml"), { ...hostPath, viaUpload: true })) + .filePath + ).toBe("/etc/anything.yaml"); + }); + it("rejects a relative flow_file", async () => { await expect(resolveFlowSource(params(".argent/flows/remote-flow.yaml"))).rejects.toThrow( "Invalid flow_file" @@ -614,3 +822,168 @@ describe("flow_file containment", () => { ).rejects.toThrow("Invalid flow_file"); }); }); + +/** + * The concurrency contract, exercised in CLIENT mode. The two mechanisms cross + * here: the session key is a path that does not exist on this host, and the + * authoritative flow content is the in-memory copy rather than the file. The + * host-mode suite (flow-concurrent-recording.test.ts) cannot reach either. + */ +describe("concurrent recordings against a remote client", () => { + it("keeps genuinely overlapping remote appends complete and ordered", async () => { + // The overlap has to come from flow-add-step's LIVE sub-tool call, which is + // the only await in the client-mode path: once past it, push → validate → + // serialize runs synchronously, so echoes alone can never interleave and + // would prove nothing. Each sub-tool call parks until all of them have + // arrived, so every append is in flight simultaneously before any completes. + const arrived: (() => void)[] = []; + const allArrived = new Promise((resolve) => { + arrived.push(resolve); + }); + let seen = 0; + const registry = { + invokeTool: vi.fn(async () => { + if (++seen === 6) arrived[0](); + await allArrived; + return { tapped: true }; + }), + getTool: vi.fn(() => undefined), + } as unknown as Registry; + const addStep = createFlowAddStepTool(registry); + + await flowStartRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT }, + remoteCtx() + ); + + const results = await Promise.all( + Array.from({ length: 6 }, (_, i) => + addStep.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, command: "tap", args: `{"x":0.${i}}` } + ) + ) + ); + + // The last directive to be produced carries the full flow. Every one of the + // six steps must be in it exactly once — in client mode the in-memory copy + // is the ONLY copy, so a lost update is unrecoverable. + const contents = results.map((r) => { + const directive = r.savedTo as { [CLIENT_FILE_MARKER]: true; content: string }; + expect(directive[CLIENT_FILE_MARKER]).toBe(true); + return parseFlow(directive.content).steps; + }); + const fullest = contents.reduce((a, b) => (b.length > a.length ? b : a)); + expect(fullest).toHaveLength(6); + const xs = fullest.map((s) => (s.kind === "tool" ? String(s.args.x) : "?")); + expect(new Set(xs).size).toBe(6); + // Each append saw a strictly larger flow than the one before it. + expect(contents.map((c) => c.length).sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it("starts the remote take over on a restart, discarding the previous one and writing nothing to this host", async () => { + await flowStartRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT }, + remoteCtx() + ); + await flowInsertEchoTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, message: "first take" } + ); + + // A second agent takes the same key on the same client project. + const restarted = await flowStartRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT }, + remoteCtx() + ); + expect(restarted).toMatchObject({ restarted: true, discardedSteps: 1 }); + // The reset is the client's to perform, so the message must not assert it + // as done here — nothing on this host was touched. + expect((restarted as { message: string }).message).toContain("once your client applies"); + + // The new take is empty and usable; the discarded take's content is gone. + const after = await flowInsertEchoTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, message: "second take" } + ); + const directive = after.savedTo as { content: string }; + const flow = parseFlow(directive.content); + expect(flow.steps).toHaveLength(1); + expect(flow.steps[0]).toMatchObject({ kind: "echo", message: "second take" }); + + // Still nothing on this host: the client's root was never created here. + await expect(fs.stat(CLIENT_ROOT)).rejects.toThrow(); + }); + + it("rejects a remote append that was already in flight when the restart landed", async () => { + // The case above restarts BETWEEN calls, so the next append re-resolves the + // key and legitimately gets the new session — the supersede guard is never + // reached. Reaching it needs an append that resolved its session before the + // restart and lands after, and in client mode the live sub-tool call is the + // only await that can hold one open across it: past that point the client + // path (push → validate → serialize) runs to completion synchronously. + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let arrive!: () => void; + const reached = new Promise((resolve) => { + arrive = resolve; + }); + const registry = { + invokeTool: vi.fn(async () => { + arrive(); + await held; + return { tapped: true }; + }), + getTool: vi.fn(() => undefined), + } as unknown as Registry; + const addStep = createFlowAddStepTool(registry); + + await flowStartRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT }, + remoteCtx() + ); + + const inFlight = addStep.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, command: "tap", args: '{"x":0.5}' } + ); + await reached; // parked in the live step, session already resolved + + const restarted = await flowStartRecordingTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT }, + remoteCtx() + ); + expect(restarted).toMatchObject({ restarted: true }); + + release(); + let caught: unknown; + try { + await inFlight; + throw new Error("expected the superseded append to fail"); + } catch (err) { + caught = err; + } + expect((caught as Error).message).toMatch(/no longer active/); + expect(getFailureSignal(caught)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); + + // The step ran on the device but never entered the new take, and the client + // is told so — in client mode the in-memory copy is the only copy, so a + // superseded step landing in it would be unrecoverable. + const after = await flowInsertEchoTool.execute( + {}, + { name: "remote-flow", project_root: CLIENT_ROOT, message: "second take" } + ); + const flow = parseFlow((after.savedTo as { content: string }).content); + expect(flow.steps).toHaveLength(1); + expect(flow.steps[0]).toMatchObject({ kind: "echo", message: "second take" }); + + await expect(fs.stat(CLIENT_ROOT)).rejects.toThrow(); + }); +}); diff --git a/packages/tool-server/test/flows/flow-tools.test.ts b/packages/tool-server/test/flows/flow-tools.test.ts index 05fe706de..afdc03d46 100644 --- a/packages/tool-server/test/flows/flow-tools.test.ts +++ b/packages/tool-server/test/flows/flow-tools.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { Registry, ToolContext } from "@argent/registry"; -import { ArtifactStore } from "@argent/registry"; +import { ArtifactStore, zodObjectToJsonSchema } from "@argent/registry"; import { flowStartRecordingTool } from "../../src/tools/flows/flow-start-recording"; import { flowInsertEchoTool } from "../../src/tools/flows/flow-insert-echo"; @@ -17,10 +17,9 @@ import { } from "../../src/tools/flows/flow-run"; import { flowReadPrerequisiteTool } from "../../src/tools/flows/flow-read-prerequisite"; import { - clearActiveFlow, - setActiveProjectRoot, - clearActiveProjectRoot, + __resetRecordingsForTesting, flowsDirFor, + getRecordingSession, parseFlow, serializeFlow, type FlowStep, @@ -37,6 +36,9 @@ function assertFlowRunResult( } let tmpDir: string; +// A second project root. Recordings are keyed by /, so it +// is what the cross-project cases address: same flow name, different project. +let otherDir: string; function createMockRegistry( tools: Record = {} @@ -56,8 +58,8 @@ function createMockRegistry( } as unknown as Registry; } -async function readFlowFile(name: string): Promise { - return fs.readFile(path.join(tmpDir, ".argent", "flows", `${name}.yaml`), "utf8"); +async function readFlowFile(name: string, projectRoot: string = tmpDir): Promise { + return fs.readFile(path.join(projectRoot, ".argent", "flows", `${name}.yaml`), "utf8"); } const PREREQ = "App on home screen"; @@ -66,14 +68,14 @@ const PREREQ = "App on home screen"; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-test-")); - setActiveProjectRoot(tmpDir); - clearActiveFlow(); + otherDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-test-other-")); + __resetRecordingsForTesting(); }); afterEach(async () => { - clearActiveFlow(); - clearActiveProjectRoot(); + __resetRecordingsForTesting(); await fs.rm(tmpDir, { recursive: true, force: true }); + await fs.rm(otherDir, { recursive: true, force: true }); }); // ── flow-start-recording ───────────────────────────────────────────── @@ -92,12 +94,15 @@ describe("flow-start-recording", () => { expect(flow.steps).toEqual([]); }); - it("sets the active flow", async () => { + it("opens a recording addressable by name + project_root", async () => { await flowStartRecordingTool.execute( {}, { name: "my-flow", project_root: tmpDir, executionPrerequisite: PREREQ } ); - const result = await flowInsertEchoTool.execute({}, { message: "test" }); + const result = await flowInsertEchoTool.execute( + {}, + { name: "my-flow", project_root: tmpDir, message: "test" } + ); expect(result.message).toContain("my-flow"); }); @@ -106,7 +111,10 @@ describe("flow-start-recording", () => { {}, { name: "overwrite", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await flowInsertEchoTool.execute({}, { message: "line1" }); + await flowInsertEchoTool.execute( + {}, + { name: "overwrite", project_root: tmpDir, message: "line1" } + ); // Start again with same name — should reset await flowStartRecordingTool.execute( @@ -132,7 +140,7 @@ describe("flow-start-recording", () => { // ── flow-start-recording edge cases ────────────────────────────────── describe("flow-start-recording edge cases", () => { - it("starting a new flow while another is recording notifies about the switch", async () => { + it("starting a differently-named flow leaves the earlier recording live", async () => { await flowStartRecordingTool.execute( {}, { name: "first-flow", project_root: tmpDir, executionPrerequisite: PREREQ } @@ -142,52 +150,108 @@ describe("flow-start-recording edge cases", () => { { name: "second-flow", project_root: tmpDir, executionPrerequisite: "Different" } ); - // Should mention both the old and new flow - expect(result.message).toContain("first-flow"); + // A second recording abandons nothing, so there is no switch to report. expect(result.message).toContain("second-flow"); - expect(result.previousFlow).toBe("first-flow"); + expect(result.message).not.toContain("first-flow"); + expect(result.restarted).toBeUndefined(); + expect(result.discardedSteps).toBeUndefined(); + + // Both recordings still take steps, each addressed by its own name. + const secondEcho = await flowInsertEchoTool.execute( + {}, + { name: "second-flow", project_root: tmpDir, message: "goes to second" } + ); + expect(secondEcho.message).toContain("second-flow"); + const firstEcho = await flowInsertEchoTool.execute( + {}, + { name: "first-flow", project_root: tmpDir, message: "goes to first" } + ); + expect(firstEcho.message).toContain("first-flow"); + + // …and each file ends up holding only its own steps. + expect(parseFlow(await readFlowFile("first-flow")).steps).toEqual([ + { kind: "echo", message: "goes to first" }, + ]); + expect(parseFlow(await readFlowFile("second-flow")).steps).toEqual([ + { kind: "echo", message: "goes to second" }, + ]); + }); - // Adding a step should target second-flow, not first-flow - const echoResult = await flowInsertEchoTool.execute({}, { message: "goes to second" }); - expect(echoResult.message).toContain("second-flow"); + it("keeps same-named recordings in different projects independent", async () => { + await flowStartRecordingTool.execute( + {}, + { name: "shared-name", project_root: tmpDir, executionPrerequisite: PREREQ } + ); + const result = await flowStartRecordingTool.execute( + {}, + { name: "shared-name", project_root: otherDir, executionPrerequisite: PREREQ } + ); - // first-flow should still exist on disk but be empty - const firstContent = await readFlowFile("first-flow"); - const firstFlow = parseFlow(firstContent); - expect(firstFlow.steps).toEqual([]); + // Same name, other project — a different key, so nothing was restarted. + expect(result.restarted).toBeUndefined(); + expect(result.discardedSteps).toBeUndefined(); - // second-flow should have the echo - const secondContent = await readFlowFile("second-flow"); - const secondFlow = parseFlow(secondContent); - expect(secondFlow.steps).toEqual([{ kind: "echo", message: "goes to second" }]); + await flowInsertEchoTool.execute( + {}, + { name: "shared-name", project_root: tmpDir, message: "in first project" } + ); + await flowInsertEchoTool.execute( + {}, + { name: "shared-name", project_root: otherDir, message: "in second project" } + ); + + expect(parseFlow(await readFlowFile("shared-name")).steps).toEqual([ + { kind: "echo", message: "in first project" }, + ]); + expect(parseFlow(await readFlowFile("shared-name", otherDir)).steps).toEqual([ + { kind: "echo", message: "in second project" }, + ]); }); - it("restarting the same flow does not report a switch", async () => { + it("restarting the same flow reports the discarded steps and resets the file", async () => { await flowStartRecordingTool.execute( {}, { name: "same-flow", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await flowInsertEchoTool.execute({}, { message: "will be reset" }); + await flowInsertEchoTool.execute( + {}, + { name: "same-flow", project_root: tmpDir, message: "will be reset" } + ); + await flowInsertEchoTool.execute( + {}, + { name: "same-flow", project_root: tmpDir, message: "also reset" } + ); const result = await flowStartRecordingTool.execute( {}, { name: "same-flow", project_root: tmpDir, executionPrerequisite: "Updated prereq" } ); - // Should NOT mention a switch — it's the same flow being restarted - expect(result.message).not.toContain("Switched"); - expect(result.previousFlow).toBeUndefined(); + expect(result.restarted).toBe(true); + expect(result.discardedSteps).toBe(2); expect(result.message).toContain("same-flow"); + + // The earlier take is gone from the file too, prerequisite included. + const flow = parseFlow(await readFlowFile("same-flow")); + expect(flow.steps).toEqual([]); + expect(flow.executionPrerequisite).toBe("Updated prereq"); + + // The restarted recording is the live one, and it starts from empty. + const echo = await flowInsertEchoTool.execute( + {}, + { name: "same-flow", project_root: tmpDir, message: "new take" } + ); + expect(parseFlow(echo.flowFile).steps).toEqual([{ kind: "echo", message: "new take" }]); }); - it("does not report a switch when no flow was previously active", async () => { + it("does not report a restart when the flow was not already recording", async () => { const result = await flowStartRecordingTool.execute( {}, { name: "fresh-start", project_root: tmpDir, executionPrerequisite: PREREQ } ); - expect(result.message).not.toContain("Switched"); - expect(result.previousFlow).toBeUndefined(); + expect(result.restarted).toBeUndefined(); + expect(result.discardedSteps).toBeUndefined(); }); }); @@ -199,7 +263,10 @@ describe("flow-add-echo", () => { {}, { name: "echo-test", project_root: tmpDir, executionPrerequisite: PREREQ } ); - const result = await flowInsertEchoTool.execute({}, { message: "Hello world" }); + const result = await flowInsertEchoTool.execute( + {}, + { name: "echo-test", project_root: tmpDir, message: "Hello world" } + ); expect(result.message).toContain("echo-test"); const flow = parseFlow(result.flowFile); @@ -211,8 +278,14 @@ describe("flow-add-echo", () => { {}, { name: "multi-echo", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await flowInsertEchoTool.execute({}, { message: "First" }); - const result = await flowInsertEchoTool.execute({}, { message: "Second" }); + await flowInsertEchoTool.execute( + {}, + { name: "multi-echo", project_root: tmpDir, message: "First" } + ); + const result = await flowInsertEchoTool.execute( + {}, + { name: "multi-echo", project_root: tmpDir, message: "Second" } + ); const flow = parseFlow(result.flowFile); expect(flow.steps).toEqual([ @@ -221,10 +294,33 @@ describe("flow-add-echo", () => { ]); }); - it("throws when no active flow", async () => { - await expect(flowInsertEchoTool.execute({}, { message: "oops" })).rejects.toThrow( - "No active flow" + it("throws when that flow has no recording in progress", async () => { + await expect( + flowInsertEchoTool.execute( + {}, + { name: "not-recording", project_root: tmpDir, message: "oops" } + ) + ).rejects.toThrow("No active recording"); + }); + + it("throws when the recording is open under a different project root", async () => { + await flowStartRecordingTool.execute( + {}, + { name: "wrong-root", project_root: tmpDir, executionPrerequisite: PREREQ } ); + + // Right name, wrong project — a different key, so no recording is found. + const err = await flowInsertEchoTool + .execute({}, { name: "wrong-root", project_root: otherDir, message: "oops" }) + .catch((e: unknown) => e as Error); + + expect(err.message).toContain("No active recording"); + // The error names the key that was asked for, and counts — without naming — + // the recordings live under other roots, so a wrong project_root is + // recognizable without disclosing another project's flows. + expect(err.message).toContain(`No active recording for flow "wrong-root" in ${otherDir}`); + expect(err.message).toContain("Active recordings: none in this project (plus 1 in other"); + expect(err.message).not.toContain(tmpDir); }); }); @@ -241,7 +337,15 @@ describe("flow-add-step", () => { {}, { name: "step-test", project_root: tmpDir, executionPrerequisite: PREREQ } ); - const result = await tool.execute({}, { command: "tap", args: '{"x":0.5,"y":0.3}' }); + const result = await tool.execute( + {}, + { + name: "step-test", + project_root: tmpDir, + command: "tap", + args: '{"x":0.5,"y":0.3}', + } + ); expect(result.toolResult).toEqual({ tapped: true }); const flow = parseFlow(result.flowFile); @@ -263,7 +367,11 @@ describe("flow-add-step", () => { {}, { name: "tele-step", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await tool.execute({}, { command: "tap", args: '{"x":0.5}' }, ctx); + await tool.execute( + {}, + { name: "tele-step", project_root: tmpDir, command: "tap", args: '{"x":0.5}' }, + ctx + ); expect(recordChildInvocation).toHaveBeenCalledOnce(); const childId = recordChildInvocation.mock.calls[0]![0]; @@ -287,9 +395,12 @@ describe("flow-add-step", () => { {}, { name: "fail-test", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await expect(tool.execute({}, { command: "tap", args: '{"x":0.5}' })).rejects.toThrow( - 'Tool "tap" failed' - ); + await expect( + tool.execute( + {}, + { name: "fail-test", project_root: tmpDir, command: "tap", args: '{"x":0.5}' } + ) + ).rejects.toThrow('Tool "tap" failed'); const content = await readFlowFile("fail-test"); const flow = parseFlow(content); @@ -306,7 +417,7 @@ describe("flow-add-step", () => { {}, { name: "no-args", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await tool.execute({}, { command: "screenshot" }); + await tool.execute({}, { name: "no-args", project_root: tmpDir, command: "screenshot" }); const content = await readFlowFile("no-args"); const flow = parseFlow(content); @@ -314,15 +425,20 @@ describe("flow-add-step", () => { expect(registry.invokeTool).toHaveBeenCalledWith("screenshot", {}); }); - it("throws when no active flow", async () => { + it("throws when that flow has no recording in progress", async () => { const registry = createMockRegistry({ tap: { result: { ok: true } }, }); const tool = createFlowAddStepTool(registry); - await expect(tool.execute({}, { command: "tap", args: '{"x":0.5}' })).rejects.toThrow( - "No active flow" - ); + await expect( + tool.execute( + {}, + { name: "not-recording", project_root: tmpDir, command: "tap", args: '{"x":0.5}' } + ) + ).rejects.toThrow("No active recording"); + // The step must not run either — the recording is resolved first. + expect(registry.invokeTool).not.toHaveBeenCalled(); }); it("records a restart-app as a portable launch step (device id dropped)", async () => { @@ -334,7 +450,12 @@ describe("flow-add-step", () => { await flowStartRecordingTool.execute({}, { name: "launch-rewrite", project_root: tmpDir }); const result = await tool.execute( {}, - { command: "restart-app", args: '{"udid":"ABC","bundleId":"com.acme.app"}' } + { + name: "launch-rewrite", + project_root: tmpDir, + command: "restart-app", + args: '{"udid":"ABC","bundleId":"com.acme.app"}', + } ); // Ran live with the full args… @@ -356,6 +477,8 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, { + name: "launch-activity", + project_root: tmpDir, command: "restart-app", args: '{"udid":"ABC","bundleId":"com.acme.app","activity":".Main"}', } @@ -383,7 +506,15 @@ describe("flow-add-step", () => { { name: "contradiction", project_root: tmpDir, executionPrerequisite: PREREQ } ); await expect( - tool.execute({}, { command: "restart-app", args: '{"bundleId":"com.acme.app"}' }) + tool.execute( + {}, + { + name: "contradiction", + project_root: tmpDir, + command: "restart-app", + args: '{"bundleId":"com.acme.app"}', + } + ) ).rejects.toThrow(/must not declare executionPrerequisite/i); const flow = parseFlow(await readFlowFile("contradiction")); @@ -406,6 +537,8 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, { + name: "compose-test", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ name: "login", @@ -434,6 +567,8 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, { + name: "compose-e2e", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ name: "other-e2e", project_root: tmpDir, device: "ABC" }), } @@ -454,6 +589,8 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, { + name: "compose-missing", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ name: "elsewhere", project_root: tmpDir }), } @@ -480,6 +617,8 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, { + name: "compose-pinned", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ name: "elsewhere", project_root: tmpDir, device: "ABC" }), } @@ -508,7 +647,15 @@ describe("flow-add-step", () => { await fs.writeFile(otherTwin, "steps:\n - echo: theirs\n", "utf8"); const args = { name: "twin", project_root: otherRoot }; - const result = await tool.execute({}, { command: "flow-execute", args: JSON.stringify(args) }); + const result = await tool.execute( + {}, + { + name: "compose-twin", + project_root: tmpDir, + command: "flow-execute", + args: JSON.stringify(args), + } + ); // The live invoke ran the other project's copy… expect(registry.invokeTool).toHaveBeenCalledWith("flow-execute", args); @@ -546,7 +693,15 @@ describe("flow-add-step", () => { await writeSiblingFlow("frag", "steps:\n - echo: hi\n"); const args = { name: "Frag", project_root: tmpDir }; - const result = await tool.execute({}, { command: "flow-execute", args: JSON.stringify(args) }); + const result = await tool.execute( + {}, + { + name: "compose-name-casing", + project_root: tmpDir, + command: "flow-execute", + args: JSON.stringify(args), + } + ); // `run: Frag` names a flow no case-sensitive checkout can find, so the raw // step is kept and the warning hands back the recordable spelling. @@ -573,7 +728,15 @@ describe("flow-add-step", () => { ); const args = { name: "frag", project_root: tmpDir }; - const result = await tool.execute({}, { command: "flow-execute", args: JSON.stringify(args) }); + const result = await tool.execute( + {}, + { + name: "compose-name-rename", + project_root: tmpDir, + command: "flow-execute", + args: JSON.stringify(args), + } + ); expect(result.message).toContain('case-insensitively to "frag.YAML"'); expect(result.message).toContain( @@ -599,6 +762,8 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, { + name: "compose-name-mixed", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ name: "MixedCase", project_root: tmpDir }), } @@ -635,7 +800,12 @@ describe("flow-add-step", () => { } const result = await tool.execute( {}, - { command: "flow-execute", args: JSON.stringify(args) } + { + name: "compose-unanchored", + project_root: tmpDir, + command: "flow-execute", + args: JSON.stringify(args), + } ); expect(result.message).toContain(`project_root must be an absolute path ${detail}`); @@ -694,7 +864,12 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, - { command: "flow-execute", args: JSON.stringify({ name: "frag", project_root: base }) } + { + name: "rec", + project_root: base, + command: "flow-execute", + args: JSON.stringify({ name: "frag", project_root: base }), + } ); // Anchored beside the symlink's spelling this would miss the fragment and @@ -722,7 +897,12 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, - { command: "flow-execute", args: JSON.stringify({ name: "frag", project_root: base }) } + { + name: "rec", + project_root: base, + command: "flow-execute", + args: JSON.stringify({ name: "frag", project_root: base }), + } ); expect(result.message).toMatch(/could not resolve/i); @@ -754,7 +934,12 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, - { command: "flow-execute", args: JSON.stringify({ name: "frag", project_root: base }) } + { + name: "rec", + project_root: base, + command: "flow-execute", + args: JSON.stringify({ name: "frag", project_root: base }), + } ); expect(result.message).toMatch(/not the file the live flow-execute ran/i); @@ -776,6 +961,8 @@ describe("flow-add-step", () => { const result = await tool.execute( {}, { + name: "compose-path", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: sibling, project_root: tmpDir }), } @@ -813,6 +1000,8 @@ describe("flow-add-step", () => { .execute( {}, { + name: "compose-casing", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: path.join(tmpDir, ".argent", "flows", "Sibling.yaml"), @@ -855,6 +1044,8 @@ describe("flow-add-step", () => { .execute( {}, { + name: "compose-rename", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: path.join(tmpDir, ".argent", "flows", "frag.yaml"), @@ -889,6 +1080,8 @@ describe("flow-add-step", () => { tool.execute( {}, { + name: "compose-outside", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: outside, project_root: tmpDir }), } @@ -917,6 +1110,8 @@ describe("flow-add-step", () => { tool.execute( {}, { + name: "compose-dotdot", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: dotdot, project_root: tmpDir }), } @@ -945,6 +1140,8 @@ describe("flow-add-step", () => { tool.execute( {}, { + name: "compose-stemless", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: stemless, project_root: tmpDir }), } @@ -971,6 +1168,8 @@ describe("flow-add-step", () => { tool.execute( {}, { + name: "compose-cased", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: path.join(tmpDir, ".argent", "flows", "Login.YAML"), @@ -996,6 +1195,8 @@ describe("flow-add-step", () => { tool.execute( {}, { + name: "compose-mismatch", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: path.join(tmpDir, ".argent", "flows", "login.yaml"), @@ -1045,6 +1246,8 @@ describe("flow-add-step", () => { tool.execute( {}, { + name: "compose-relative", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: sibling, project_root: root }), } @@ -1074,6 +1277,8 @@ describe("flow-add-step", () => { tool.execute( {}, { + name: "compose-rootless", + project_root: tmpDir, command: "flow-execute", args: JSON.stringify({ flow_path: path.join(tmpDir, ".argent", "flows", "login.yaml"), @@ -1115,7 +1320,15 @@ describe("flow-add-step", () => { const args = buildArgs(path.join(tmpDir, ".argent", "flows", "login.yaml"), tmpDir); await expect( - tool.execute({}, { command: "flow-execute", args: JSON.stringify(args) }) + tool.execute( + {}, + { + name: "compose-ambiguous", + project_root: tmpDir, + command: "flow-execute", + args: JSON.stringify(args), + } + ) ).rejects.toThrow(); // The nested call must reach flow-execute exactly as written — no flow_path @@ -1138,7 +1351,10 @@ describe("flow-add-step", () => { { name: "bad-json", project_root: tmpDir, executionPrerequisite: PREREQ } ); await expect( - tool.execute({}, { command: "tap", args: "not valid json {{{" }) + tool.execute( + {}, + { name: "bad-json", project_root: tmpDir, command: "tap", args: "not valid json {{{" } + ) ).rejects.toThrow(); // Flow file should remain unchanged (no step recorded) @@ -1147,6 +1363,43 @@ describe("flow-add-step", () => { expect(flow.steps).toEqual([]); }); + it("keeps the devices list when recording a scoped teardown, so the YAML stays scoped", async () => { + // `devices` is a scope, not a target: with it stripped, a correctly scoped + // teardown recorded as a bare `- tool: stop-all-simulator-servers`, which + // IS the machine-wide sweep — so hand-running the step from the YAML (the + // create-flow skill's manual-execution strategy) reaped every device on the + // machine. Replay rebinds the scope to the run device regardless, so + // keeping it costs portability nothing. + const registry = createMockRegistry({ + "stop-all-simulator-servers": { result: { stopped: 1 } }, + }); + const tool = createFlowAddStepTool(registry); + + await flowStartRecordingTool.execute({}, { name: "teardown-test", project_root: tmpDir }); + const result = await tool.execute( + {}, + { + name: "teardown-test", + project_root: tmpDir, + command: "stop-all-simulator-servers", + args: JSON.stringify({ devices: ["00000000-HOST-DEVICE-ID"] }), + } + ); + + // Ran live with the real devices to stop… + expect(registry.invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", { + devices: ["00000000-HOST-DEVICE-ID"], + }); + // …and the recorded step still reads as the scoped teardown it was. + expect(parseFlow(result.flowFile).steps).toEqual([ + { + kind: "tool", + name: "stop-all-simulator-servers", + args: { devices: ["00000000-HOST-DEVICE-ID"] }, + }, + ]); + }); + it("propagates error when tool is not registered in the registry", async () => { const registry = createMockRegistry({}); // no tools registered const tool = createFlowAddStepTool(registry); @@ -1155,9 +1408,12 @@ describe("flow-add-step", () => { {}, { name: "missing-tool", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await expect(tool.execute({}, { command: "nonexistent-tool", args: "{}" })).rejects.toThrow( - 'Tool "nonexistent-tool" not found' - ); + await expect( + tool.execute( + {}, + { name: "missing-tool", project_root: tmpDir, command: "nonexistent-tool", args: "{}" } + ) + ).rejects.toThrow('Tool "nonexistent-tool" not found'); // Flow file should remain unchanged const content = await readFlowFile("missing-tool"); @@ -1169,28 +1425,61 @@ describe("flow-add-step", () => { // ── flow-finish-recording ──────────────────────────────────────────── describe("flow-finish-recording", () => { - it("returns summary with prerequisite and clears active flow", async () => { + it("returns summary with prerequisite and clears that recording", async () => { await flowStartRecordingTool.execute( {}, { name: "finish-test", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await flowInsertEchoTool.execute({}, { message: "Step 1" }); + await flowInsertEchoTool.execute( + {}, + { name: "finish-test", project_root: tmpDir, message: "Step 1" } + ); - const result = await flowFinishRecordingTool.execute({}, {}); + const result = await flowFinishRecordingTool.execute( + {}, + { name: "finish-test", project_root: tmpDir } + ); expect(result.message).toContain("finish-test"); expect(result.executionPrerequisite).toBe(PREREQ); expect(result.steps).toBe(1); expect(result.summary).toEqual(["1. echo: Step 1"]); - // Active flow should be cleared - await expect(flowInsertEchoTool.execute({}, { message: "after finish" })).rejects.toThrow( - "No active flow" + // The recording is gone — no more steps can be added to it. + await expect( + flowInsertEchoTool.execute( + {}, + { name: "finish-test", project_root: tmpDir, message: "after finish" } + ) + ).rejects.toThrow("No active recording"); + }); + + it("leaves other recordings in progress untouched", async () => { + await flowStartRecordingTool.execute( + {}, + { name: "finish-one", project_root: tmpDir, executionPrerequisite: PREREQ } + ); + await flowStartRecordingTool.execute( + {}, + { name: "keep-going", project_root: tmpDir, executionPrerequisite: PREREQ } + ); + + await flowFinishRecordingTool.execute({}, { name: "finish-one", project_root: tmpDir }); + + const result = await flowInsertEchoTool.execute( + {}, + { name: "keep-going", project_root: tmpDir, message: "still open" } ); + expect(result.message).toContain("keep-going"); + expect(parseFlow(await readFlowFile("keep-going")).steps).toEqual([ + { kind: "echo", message: "still open" }, + ]); }); - it("throws when no active flow", async () => { - await expect(flowFinishRecordingTool.execute({}, {})).rejects.toThrow("No active flow"); + it("throws when that flow has no recording in progress", async () => { + await expect( + flowFinishRecordingTool.execute({}, { name: "not-recording", project_root: tmpDir }) + ).rejects.toThrow("No active recording"); }); it("handles empty flow", async () => { @@ -1198,7 +1487,10 @@ describe("flow-finish-recording", () => { {}, { name: "empty", project_root: tmpDir, executionPrerequisite: PREREQ } ); - const result = await flowFinishRecordingTool.execute({}, {}); + const result = await flowFinishRecordingTool.execute( + {}, + { name: "empty", project_root: tmpDir } + ); expect(result.steps).toBe(0); expect(result.summary).toEqual([]); @@ -1209,10 +1501,12 @@ describe("flow-finish-recording", () => { {}, { name: "double-finish", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await flowFinishRecordingTool.execute({}, {}); + await flowFinishRecordingTool.execute({}, { name: "double-finish", project_root: tmpDir }); - // Second call should fail — active flow was cleared - await expect(flowFinishRecordingTool.execute({}, {})).rejects.toThrow("No active flow"); + // Second call should fail — the recording was cleared + await expect( + flowFinishRecordingTool.execute({}, { name: "double-finish", project_root: tmpDir }) + ).rejects.toThrow("No active recording"); }); it("returns the file path so the agent knows where it was written", async () => { @@ -1220,7 +1514,10 @@ describe("flow-finish-recording", () => { {}, { name: "path-check", project_root: tmpDir, executionPrerequisite: PREREQ } ); - const result = await flowFinishRecordingTool.execute({}, {}); + const result = await flowFinishRecordingTool.execute( + {}, + { name: "path-check", project_root: tmpDir } + ); expect(result.path).toContain(path.join(".argent", "flows")); expect(result.path).toContain("path-check.yaml"); @@ -1236,10 +1533,19 @@ describe("flow-finish-recording", () => { {}, { name: "summary-test", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await flowInsertEchoTool.execute({}, { message: "Before tap" }); - await addStep.execute({}, { command: "tap", args: '{"x":0.5}' }); + await flowInsertEchoTool.execute( + {}, + { name: "summary-test", project_root: tmpDir, message: "Before tap" } + ); + await addStep.execute( + {}, + { name: "summary-test", project_root: tmpDir, command: "tap", args: '{"x":0.5}' } + ); - const result = await flowFinishRecordingTool.execute({}, {}); + const result = await flowFinishRecordingTool.execute( + {}, + { name: "summary-test", project_root: tmpDir } + ); expect(result.summary).toEqual(["1. echo: Before tap", '2. tool: tap {"x":0.5}']); }); @@ -1286,7 +1592,7 @@ describe("flow-finish-recording", () => { }) ); - const result = await flowFinishRecordingTool.execute({}, {}); + const result = await flowFinishRecordingTool.execute({}, { name, project_root: tmpDir }); expect(result.summary).toEqual([ '1. await: text {"id":"status"} contains "Ready \\"now\\"\\nnext"', @@ -1346,7 +1652,7 @@ describe("flow-finish-recording", () => { }) ); - const result = await flowFinishRecordingTool.execute({}, {}); + const result = await flowFinishRecordingTool.execute({}, { name, project_root: tmpDir }); expect(result.summary).toEqual([ '1. when: text {"id":"status"} contains "Ready \\"now\\"\\nnext" (1 step)', @@ -1379,11 +1685,23 @@ describe("flow-execute", () => { {}, { name: "run-test", project_root: tmpDir, executionPrerequisite: PREREQ } ); - await flowInsertEchoTool.execute({}, { message: "Tap button" }); - await addStep.execute({}, { command: "tap", args: '{"x":0.5}' }); - await flowInsertEchoTool.execute({}, { message: "Take screenshot" }); - await addStep.execute({}, { command: "screenshot", args: "{}" }); - await flowFinishRecordingTool.execute({}, {}); + await flowInsertEchoTool.execute( + {}, + { name: "run-test", project_root: tmpDir, message: "Tap button" } + ); + await addStep.execute( + {}, + { name: "run-test", project_root: tmpDir, command: "tap", args: '{"x":0.5}' } + ); + await flowInsertEchoTool.execute( + {}, + { name: "run-test", project_root: tmpDir, message: "Take screenshot" } + ); + await addStep.execute( + {}, + { name: "run-test", project_root: tmpDir, command: "screenshot", args: "{}" } + ); + await flowFinishRecordingTool.execute({}, { name: "run-test", project_root: tmpDir }); // Reset mock call counts vi.mocked(registry.invokeTool).mockClear(); @@ -1793,28 +2111,60 @@ describe("flow-execute", () => { tap: { result: { ok: true } }, }); const runFlow = createRunFlowTool(registry); + const addStep = createFlowAddStepTool(registry); - // Write a flow to run - const dir = path.join(tmpDir, ".argent", "flows"); - await fs.mkdir(dir, { recursive: true }); + // A flow to run in the recording's own project AND one in another project — + // replay must be inert for the recording either way, and a replay under a + // different project_root is exactly what a second agent's run looks like. const content = serializeFlow({ executionPrerequisite: "", steps: [{ kind: "tool", name: "tap", args: { x: 0.1 } }], }); - await fs.writeFile(path.join(dir, "side-effect.yaml"), content); + for (const root of [tmpDir, otherDir]) { + const dir = path.join(root, ".argent", "flows"); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, "side-effect.yaml"), content); + } // Start recording a different flow await flowStartRecordingTool.execute( {}, { name: "recording", project_root: tmpDir, executionPrerequisite: PREREQ } ); + const before = await getRecordingSession(tmpDir, "recording"); + expect(before).toBeDefined(); - // Execute a saved flow — this should NOT affect the active recording + // Execute saved flows — neither should affect the active recording await runFlow.execute({}, { name: "side-effect", project_root: tmpDir, device: DEVICE }); + await runFlow.execute({}, { name: "side-effect", project_root: otherDir, device: DEVICE }); - // We should still be able to add steps to the recording - const result = await flowInsertEchoTool.execute({}, { message: "still recording" }); + // The recording still points at the flow it was opened for, in its own + // project — a replay elsewhere must not rebind name/root/file. + const after = await getRecordingSession(tmpDir, "recording"); + expect(after).toBe(before); + expect(after).toMatchObject({ + name: "recording", + projectRoot: tmpDir, + filePath: path.join(tmpDir, ".argent", "flows", "recording.yaml"), + }); + + // We should still be able to add steps to the recording… + const result = await flowInsertEchoTool.execute( + {}, + { name: "recording", project_root: tmpDir, message: "still recording" } + ); expect(result.message).toContain("recording"); + await addStep.execute( + {}, + { name: "recording", project_root: tmpDir, command: "tap", args: '{"x":0.9}' } + ); + + // …and they land in the original flow's file, not the replayed project's. + expect(parseFlow(await readFlowFile("recording")).steps).toEqual([ + { kind: "echo", message: "still recording" }, + { kind: "tool", name: "tap", args: { x: 0.9 } }, + ]); + await expect(readFlowFile("recording", otherDir)).rejects.toThrow(); }); }); @@ -2116,3 +2466,37 @@ describe("flow-read-prerequisite", () => { ).rejects.toThrow("exactly one flow source"); }); }); + +describe("the flow-add-step schema the CLI tests hand-copy", () => { + // Three CLI test files encode this schema as a fixture — `run-help.test.ts`, + // `flag-parser.test.ts` and `run-flow-add-step-payload.test.ts` — because + // `@argent/cli` does not depend on the tool-server and so cannot derive it. + // That makes drift silent in the direction that matters: relaxing the real + // schema here (making `project_root` optional, renaming `args`) leaves all + // three green while the CLI's `--args` handling and help output are decided + // by a schema nothing resembles any more. + // + // So the guard lives on this side, where the schema is. If this fails, + // update those three fixtures in the same change. + const CLI_FIXTURE_PROPERTIES = ["name", "project_root", "command", "args", "delayMs"]; + const CLI_FIXTURE_REQUIRED = ["name", "project_root", "command"]; + + it("still declares exactly the properties and required keys those fixtures encode", () => { + const schema = zodObjectToJsonSchema( + createFlowAddStepTool({} as unknown as Registry).zodSchema! + ) as { properties: Record; required?: string[] }; + + expect(Object.keys(schema.properties).sort()).toEqual([...CLI_FIXTURE_PROPERTIES].sort()); + expect([...(schema.required ?? [])].sort()).toEqual([...CLI_FIXTURE_REQUIRED].sort()); + // `parseFlags` branches on this one specifically: a tool that declares its + // own `args` must not also advertise the whole-payload `--args ` + // escape hatch. + expect(schema.properties["args"]).toMatchObject({ type: "string" }); + }); + + it("still opens its description with the sentence those fixtures quote verbatim", () => { + expect(createFlowAddStepTool({} as unknown as Registry).description).toContain( + "Execute a tool call and record it as a step in the flow named by `name` + `project_root`" + ); + }); +}); diff --git a/packages/tool-server/test/flows/flow-utils.test.ts b/packages/tool-server/test/flows/flow-utils.test.ts index 93a5ed613..93bfc2b98 100644 --- a/packages/tool-server/test/flows/flow-utils.test.ts +++ b/packages/tool-server/test/flows/flow-utils.test.ts @@ -1,26 +1,33 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { FAILURE_CODES, getFailureSignal } from "@argent/registry"; import { + countStepsOnDisk, serializeFlow, parseFlow, describeSelector, - setActiveFlow, - getActiveFlow, - getActiveFlowOrNull, - clearActiveFlow, - setActiveProjectRoot, - clearActiveProjectRoot, + assertValidProjectRoot, + startRecordingSession, + getRecordingSession, + requireRecordingSession, + clearRecordingSession, + listActiveRecordings, + __resetRecordingsForTesting, + MAX_RECORDINGS, getFlowPath, appIdForPlatform, chromiumLaunchSpec, + writeNewFlowFile, type FlowFile, } from "../../src/tools/flows/flow-utils"; // ── serializeFlow ──────────────────────────────────────────────────── describe("serializeFlow", () => { - it("serializes an empty flow with prerequisite", () => { + it("serializes an empty flow with prerequisite", async () => { const flow: FlowFile = { executionPrerequisite: "App on home screen", steps: [], @@ -30,7 +37,7 @@ describe("serializeFlow", () => { expect(result).toContain("steps: []"); }); - it("serializes echo steps", () => { + it("serializes echo steps", async () => { const flow: FlowFile = { executionPrerequisite: "Fresh reload", steps: [{ kind: "echo", message: "Hello" }], @@ -39,7 +46,7 @@ describe("serializeFlow", () => { expect(result).toContain("- echo: Hello"); }); - it("serializes tool steps with args", () => { + it("serializes tool steps with args", async () => { const flow: FlowFile = { executionPrerequisite: "", steps: [{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } }], @@ -50,7 +57,7 @@ describe("serializeFlow", () => { expect(result).toContain(" y: 0.3"); }); - it("serializes tool steps with empty args (omits args key)", () => { + it("serializes tool steps with empty args (omits args key)", async () => { const flow: FlowFile = { executionPrerequisite: "", steps: [{ kind: "tool", name: "screenshot", args: {} }], @@ -60,7 +67,7 @@ describe("serializeFlow", () => { expect(result).not.toContain("args:"); }); - it("rejects gesture targets that cannot round-trip through the parser", () => { + it("rejects gesture targets that cannot round-trip through the parser", async () => { const serializeStep = (step: FlowFile["steps"][number]) => serializeFlow({ executionPrerequisite: "", steps: [step] }); @@ -80,19 +87,19 @@ describe("serializeFlow", () => { // ── describeSelector ───────────────────────────────────────────────── describe("describeSelector", () => { - it("spells identifier as id, the flow-YAML spelling", () => { + it("spells identifier as id, the flow-YAML spelling", async () => { expect(describeSelector({ identifier: "submit" })).toBe('id="submit"'); }); - it("renders a text selector", () => { + it("renders a text selector", async () => { expect(describeSelector({ text: "Login" })).toBe('text="Login"'); }); - it("drops the internal loose flag", () => { + it("drops the internal loose flag", async () => { expect(describeSelector({ text: "Login", loose: true })).toBe('text="Login"'); }); - it("joins multiple keys with spaces", () => { + it("joins multiple keys with spaces", async () => { expect(describeSelector({ text: "Login", role: "button" })).toBe('text="Login" role="button"'); }); }); @@ -100,27 +107,27 @@ describe("describeSelector", () => { // ── parseFlow ──────────────────────────────────────────────────────── describe("parseFlow", () => { - it("parses a flow with executionPrerequisite and echo steps", () => { + it("parses a flow with executionPrerequisite and echo steps", async () => { const content = "executionPrerequisite: App on home screen\nsteps:\n - echo: Hello\n"; const flow = parseFlow(content); expect(flow.executionPrerequisite).toBe("App on home screen"); expect(flow.steps).toEqual([{ kind: "echo", message: "Hello" }]); }); - it("parses tool entries with args", () => { + it("parses tool entries with args", async () => { const content = 'executionPrerequisite: ""\nsteps:\n - tool: tap\n args:\n x: 0.5\n y: 0.3\n'; const flow = parseFlow(content); expect(flow.steps).toEqual([{ kind: "tool", name: "tap", args: { x: 0.5, y: 0.3 } }]); }); - it("parses tool entries with no args", () => { + it("parses tool entries with no args", async () => { const content = 'executionPrerequisite: ""\nsteps:\n - tool: screenshot\n'; const flow = parseFlow(content); expect(flow.steps).toEqual([{ kind: "tool", name: "screenshot", args: {} }]); }); - it("parses a multi-step flow", () => { + it("parses a multi-step flow", async () => { const content = [ "executionPrerequisite: Settings open", "steps:", @@ -144,32 +151,32 @@ describe("parseFlow", () => { ]); }); - it("returns empty steps for empty content", () => { + it("returns empty steps for empty content", async () => { const flow = parseFlow(""); expect(flow.executionPrerequisite).toBe(""); expect(flow.steps).toEqual([]); }); - it("defaults executionPrerequisite to empty string when missing", () => { + it("defaults executionPrerequisite to empty string when missing", async () => { const content = "steps:\n - echo: Hello\n"; const flow = parseFlow(content); expect(flow.executionPrerequisite).toBe(""); expect(flow.steps).toEqual([{ kind: "echo", message: "Hello" }]); }); - it("throws on unrecognized entries", () => { + it("throws on unrecognized entries", async () => { const content = 'executionPrerequisite: ""\nsteps:\n - bogus: line\n'; expect(() => parseFlow(content)).toThrow("Unrecognized flow entry"); }); - it("renders a small unrecognized entry in full", () => { + it("renders a small unrecognized entry in full", async () => { // The common authoring error: a short mistyped step. The echo cap must // leave it untouched — seeing the whole entry is what makes it fixable. const content = 'executionPrerequisite: ""\nsteps:\n - bogus: line\n'; expect(() => parseFlow(content)).toThrow(': {"bogus":"line"}'); }); - it("caps the echoed entry so an oversized value cannot ride the diagnostic", () => { + it("caps the echoed entry so an oversized value cannot ride the diagnostic", async () => { // A mistyped run: path can point parseFlow at any in-project YAML file, // and this message flows verbatim to stdout and into agent context — so // the render must be bounded, and the tail of the value must not appear. @@ -186,11 +193,11 @@ describe("parseFlow", () => { expect(message.length).toBeLessThan(400); }); - it("throws when content is not an object with steps", () => { + it("throws when content is not an object with steps", async () => { expect(() => parseFlow("- echo: Hello\n")).toThrow("expected an object with a steps array"); }); - it("classifies a YAML syntax error as a validation failure with the parser's detail", () => { + it("classifies a YAML syntax error as a validation failure with the parser's detail", async () => { let thrown: unknown; try { parseFlow("steps: ][\n"); @@ -207,35 +214,35 @@ describe("parseFlow", () => { expect((thrown as Error).message).toContain("line 1"); }); - it("throws a validation error (not a TypeError) on a primitive step entry", () => { + it("throws a validation error (not a TypeError) on a primitive step entry", async () => { const content = 'executionPrerequisite: ""\nsteps:\n - tap\n'; expect(() => parseFlow(content)).toThrow("Unrecognized flow entry"); }); - it("throws a validation error on a null step entry", () => { + it("throws a validation error on a null step entry", async () => { const content = 'executionPrerequisite: ""\nsteps:\n - ~\n'; expect(() => parseFlow(content)).toThrow("Unrecognized flow entry"); }); - it("sugars a bare-string selector into a loose { text } for tap", () => { + it("sugars a bare-string selector into a loose { text } for tap", async () => { const flow = parseFlow("steps:\n - tap: Settings\n"); // Bare string ⇒ loose: resolves identifier-first, then falls back to text. expect(flow.steps).toEqual([{ kind: "tap", selector: { text: "Settings", loose: true } }]); }); - it("sugars a bare-string selector for type.into", () => { + it("sugars a bare-string selector for type.into", async () => { const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com" }\n'); expect(flow.steps).toEqual([ { kind: "type", into: { text: "email", loose: true }, text: "a@b.com" }, ]); }); - it("defaults type.submit to on (no submit key in the parsed model)", () => { + it("defaults type.submit to on (no submit key in the parsed model)", async () => { const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com" }\n'); expect(flow.steps[0]).not.toHaveProperty("submit"); }); - it("parses and round-trips an explicit type.submit: false opt-out", () => { + it("parses and round-trips an explicit type.submit: false opt-out", async () => { const flow = parseFlow('steps:\n - type: { into: email, text: "a@b.com", submit: false }\n'); expect(flow.steps).toEqual([ { kind: "type", into: { text: "email", loose: true }, text: "a@b.com", submit: false }, @@ -244,11 +251,11 @@ describe("parseFlow", () => { expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps); }); - it("rejects a non-boolean type.submit", () => { + it("rejects a non-boolean type.submit", async () => { expect(() => parseFlow('steps:\n - type: { into: email, text: "x", submit: 3 }\n')).toThrow(); }); - it("keeps an explicit { text } map strict (no loose fallback)", () => { + it("keeps an explicit { text } map strict (no loose fallback)", async () => { const flow = parseFlow("steps:\n - tap: { text: Settings }\n"); expect(flow.steps).toEqual([{ kind: "tap", selector: { text: "Settings" } }]); }); @@ -274,7 +281,7 @@ describe("parseFlow", () => { expect(parseFlow(yaml).steps).toEqual([expected]); }); - it("accepts a regex selector combined with id and role", () => { + it("accepts a regex selector combined with id and role", async () => { expect( parseFlow( "steps:\n - tap: { text: { matches: '^Order #\\d+$' }, id: order-row, role: button }\n" @@ -335,23 +342,23 @@ describe("parseFlow", () => { expect(() => parseFlow(yaml)).toThrow(`${where} \`matches\` is not a valid regular expression`); }); - it("parses the map form's `id` as the internal identifier field (strict)", () => { + it("parses the map form's `id` as the internal identifier field (strict)", async () => { const flow = parseFlow("steps:\n - tap: { id: submit-btn }\n"); expect(flow.steps).toEqual([{ kind: "tap", selector: { identifier: "submit-btn" } }]); }); - it("accepts `identifier` as a parse-only alias for `id`", () => { + it("accepts `identifier` as a parse-only alias for `id`", async () => { const flow = parseFlow("steps:\n - tap: { identifier: submit-btn }\n"); expect(flow.steps).toEqual([{ kind: "tap", selector: { identifier: "submit-btn" } }]); }); - it("rejects a selector map carrying both `id` and `identifier`", () => { + it("rejects a selector map carrying both `id` and `identifier`", async () => { expect(() => parseFlow("steps:\n - tap: { id: a, identifier: b }\n")).toThrow( /`id` or `identifier`.*not both/ ); }); - it("re-serializes an identifier-spelled flow with the `id` spelling", () => { + it("re-serializes an identifier-spelled flow with the `id` spelling", async () => { // Old files parse via the alias; the next write (appendStep re-serializes // the whole file) migrates them to the canonical `id` spelling. const yaml = serializeFlow(parseFlow("steps:\n - tap: { identifier: submit-btn }\n")); @@ -359,7 +366,7 @@ describe("parseFlow", () => { expect(yaml).not.toContain("identifier:"); }); - it("parses condition-as-key await/assert sugar (visible/exists/hidden)", () => { + it("parses condition-as-key await/assert sugar (visible/exists/hidden)", async () => { const flow = parseFlow( [ "steps:", @@ -375,7 +382,7 @@ describe("parseFlow", () => { ]); }); - it("parses the text sugar { in, contains } as a substring match", () => { + it("parses the text sugar { in, contains } as a substring match", async () => { const flow = parseFlow( 'steps:\n - assert: { text: { in: { id: counter }, contains: "Taps: 0" } }\n' ); @@ -390,7 +397,7 @@ describe("parseFlow", () => { ]); }); - it("parses the text sugar { in, equals } as an exact match", () => { + it("parses the text sugar { in, equals } as an exact match", async () => { const flow = parseFlow( 'steps:\n - assert: { text: { in: { id: counter }, equals: "Taps: 0" } }\n' ); @@ -405,13 +412,13 @@ describe("parseFlow", () => { ]); }); - it("rejects text sugar with both contains and equals", () => { + it("rejects text sugar with both contains and equals", async () => { expect(() => parseFlow("steps:\n - assert: { text: { in: counter, contains: a, equals: b } }\n") ).toThrow(/exactly one of `contains`, `equals`, or `matches`/); }); - it("rejects the explicit { condition, selector, expectedText } form (sugar only)", () => { + it("rejects the explicit { condition, selector, expectedText } form (sugar only)", async () => { expect(() => parseFlow( [ @@ -425,25 +432,25 @@ describe("parseFlow", () => { ).toThrow(/exactly one condition key/); }); - it("rejects an await/assert body with no condition key", () => { + it("rejects an await/assert body with no condition key", async () => { expect(() => parseFlow("steps:\n - assert: { selector: foo }\n")).toThrow( /exactly one condition key/ ); }); - it("rejects text sugar with neither contains nor equals", () => { + it("rejects text sugar with neither contains nor equals", async () => { expect(() => parseFlow("steps:\n - assert: { text: { in: counter } }\n")).toThrow( /exactly one of `contains`, `equals`, or `matches`/ ); }); - it("rejects text sugar with an empty contains", () => { + it("rejects text sugar with an empty contains", async () => { expect(() => parseFlow('steps:\n - assert: { text: { in: counter, contains: "" } }\n') ).toThrow(/non-empty `contains`/); }); - it("serializes await/assert with the condition-as-key sugar (no condition: field)", () => { + it("serializes await/assert with the condition-as-key sugar (no condition: field)", async () => { const yaml = serializeFlow({ executionPrerequisite: "", steps: [ @@ -494,7 +501,7 @@ describe("parseFlow", () => { expect(parseFlow(yaml).steps).toEqual([step]); }); - it("roundtrips the sugared step kinds through YAML", () => { + it("roundtrips the sugared step kinds through YAML", async () => { // The spelling carries the loose bit exactly both ways: a LOOSE text-only // selector serializes to a bare string (which parses back loose); a strict // `{ text }` keeps the map form (which parses back strict). Identifier @@ -541,7 +548,7 @@ describe("parseFlow", () => { expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps); }); - it("keeps a strict { text } selector strict across repeated round-trips (never collapsed to a bare loose string)", () => { + it("keeps a strict { text } selector strict across repeated round-trips (never collapsed to a bare loose string)", async () => { // The recorder derives strict `{ text }` selectors, and every recorded step // re-reads and re-writes the whole file (appendStep) — so a single lossy // serialization would silently promote them to loose, sending them through @@ -558,7 +565,7 @@ describe("parseFlow", () => { expect(parseFlow(serializeFlow(reparsed)).steps).toEqual(flow.steps); }); - it("sugars a bare-string scroll-to target and keeps the within map", () => { + it("sugars a bare-string scroll-to target and keeps the within map", async () => { const flow = parseFlow( ["steps:", " - scroll-to: { target: Account, direction: down }"].join("\n") ); @@ -567,17 +574,17 @@ describe("parseFlow", () => { ]); }); - it("parses a bare-number wait as milliseconds", () => { + it("parses a bare-number wait as milliseconds", async () => { const flow = parseFlow("steps:\n - wait: 750\n"); expect(flow.steps).toEqual([{ kind: "wait", ms: 750 }]); }); - it("rejects a wait that is not a non-negative number", () => { + it("rejects a wait that is not a non-negative number", async () => { expect(() => parseFlow("steps:\n - wait: soon\n")).toThrow("wait needs a non-negative number"); expect(() => parseFlow("steps:\n - wait: -5\n")).toThrow("wait needs a non-negative number"); }); - it("parses an await timeout in milliseconds", () => { + it("parses an await timeout in milliseconds", async () => { const flow = parseFlow("steps:\n - await: { visible: Account, timeout: 10000 }\n"); expect(flow.steps).toEqual([ { @@ -589,7 +596,7 @@ describe("parseFlow", () => { ]); }); - it("rejects an await timeout that is not a positive finite number", () => { + it("rejects an await timeout that is not a positive finite number", async () => { // `.inf`, `.nan`, and an overflowing literal all parse to a typeof-number // value; letting Infinity through would make the runner's poll deadline // unreachable (an unbounded await). @@ -600,7 +607,7 @@ describe("parseFlow", () => { } }); - it("rejects a timeout on an assert step (an assert is an immediate check)", () => { + it("rejects a timeout on an assert step (an assert is an immediate check)", async () => { // The internal assert step has no timeout field, so a YAML `timeout` used // to be silently dropped; reject it loudly instead — a check that needs // time to become true is a wait, spelled `await`. @@ -612,27 +619,27 @@ describe("parseFlow", () => { ).toThrow(/assert has no timeout/); }); - it("rejects a scroll-to with an invalid direction", () => { + it("rejects a scroll-to with an invalid direction", async () => { expect(() => parseFlow("steps:\n - scroll-to: { target: Account, direction: sideways }\n") ).toThrow("scroll-to direction must be one of"); }); - it("defaults scroll-to direction to down", () => { + it("defaults scroll-to direction to down", async () => { const flow = parseFlow("steps:\n - scroll-to: { target: Account }\n"); expect(flow.steps).toEqual([ { kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" }, ]); }); - it("parses a bare-string scroll-to as a down-scroll to that target", () => { + it("parses a bare-string scroll-to as a down-scroll to that target", async () => { const flow = parseFlow("steps:\n - scroll-to: Account\n"); expect(flow.steps).toEqual([ { kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" }, ]); }); - it("serializes the default scroll-to back to the bare-string sugar", () => { + it("serializes the default scroll-to back to the bare-string sugar", async () => { const steps = [ { kind: "scroll-to", target: { text: "Account", loose: true }, direction: "down" }, ] as FlowFile["steps"]; @@ -641,12 +648,12 @@ describe("parseFlow", () => { expect(parseFlow(yaml).steps).toEqual(steps); }); - it("parses a bare-string snapshot as its name", () => { + it("parses a bare-string snapshot as its name", async () => { const flow = parseFlow("steps:\n - snapshot: home\n"); expect(flow.steps).toEqual([{ kind: "snapshot", name: "home" }]); }); - it("serializes a name-only snapshot as a bare string, keeps the map with maxMismatch", () => { + it("serializes a name-only snapshot as a bare string, keeps the map with maxMismatch", async () => { const steps = [ { kind: "snapshot", name: "home" }, { kind: "snapshot", name: "cart", maxMismatch: 1.5 }, @@ -657,16 +664,16 @@ describe("parseFlow", () => { expect(parseFlow(yaml).steps).toEqual(steps); }); - it("rejects a snapshot name that is not path-safe", () => { + it("rejects a snapshot name that is not path-safe", async () => { expect(() => parseFlow("steps:\n - snapshot: ../evil\n")).toThrow(/must match/); }); - it("accepts a string-number maxMismatch", () => { + it("accepts a string-number maxMismatch", async () => { const flow = parseFlow('steps:\n - snapshot: { name: home, maxMismatch: "1.5" }\n'); expect(flow.steps).toEqual([{ kind: "snapshot", name: "home", maxMismatch: 1.5 }]); }); - it("rejects a non-numeric, negative, or out-of-range maxMismatch", () => { + it("rejects a non-numeric, negative, or out-of-range maxMismatch", async () => { for (const bad of ['"5%"', "-1", "101", ".nan"]) { expect(() => parseFlow(`steps:\n - snapshot: { name: home, maxMismatch: ${bad} }\n`) @@ -674,7 +681,7 @@ describe("parseFlow", () => { } }); - it("parses snapshot cropOn as a selector (bare-string loose, map strict)", () => { + it("parses snapshot cropOn as a selector (bare-string loose, map strict)", async () => { const flow = parseFlow( "steps:\n" + " - snapshot: { name: home, cropOn: Header }\n" + @@ -686,7 +693,7 @@ describe("parseFlow", () => { ]); }); - it("serializes snapshot cropOn in the map form and round-trips", () => { + it("serializes snapshot cropOn in the map form and round-trips", async () => { const steps = [ { kind: "snapshot", name: "home", cropOn: { text: "Header", loose: true } }, { kind: "snapshot", name: "cart", maxMismatch: 1.5, cropOn: { identifier: "cart-total" } }, @@ -696,13 +703,13 @@ describe("parseFlow", () => { expect(parseFlow(yaml).steps).toEqual(steps); }); - it("rejects a point-form cropOn — a point has no extent to crop to", () => { + it("rejects a point-form cropOn — a point has no extent to crop to", async () => { expect(() => parseFlow("steps:\n - snapshot: { name: home, cropOn: { x: 0.5, y: 0.5 } }\n") ).toThrow(/snapshot\.cropOn: selector has unknown keys `x`, `y`/); }); - it("rejects a tap body mixing a selector with coordinates", () => { + it("rejects a tap body mixing a selector with coordinates", async () => { for (const key of ["id", "identifier"]) { expect(() => parseFlow(`steps:\n - tap: { ${key}: box, x: 0.5, y: 0.5 }\n`)).toThrow( "tap takes a selector or x/y coordinates, not both" @@ -710,7 +717,7 @@ describe("parseFlow", () => { } }); - it("rejects a coordinate tap with a missing or non-numeric x/y", () => { + it("rejects a coordinate tap with a missing or non-numeric x/y", async () => { expect(() => parseFlow("steps:\n - tap: { x: 0.5 }\n")).toThrow( "tap: a coordinate target needs numeric x and y" ); @@ -719,7 +726,7 @@ describe("parseFlow", () => { ); }); - it("round-trips free-text values exactly, including whitespace-only lines", () => { + it("round-trips free-text values exactly, including whitespace-only lines", async () => { // The parser stores every free-text field verbatim — `type.text`, `echo`, // await/assert `contains`/`equals`, and `executionPrerequisite` are never // trimmed — so serialization must be byte-exact too. Default yamlStringify @@ -762,7 +769,7 @@ describe("parseFlow", () => { } }); - it("never serializes a whitespace-only-line value as a block scalar", () => { + it("never serializes a whitespace-only-line value as a block scalar", async () => { const steps = [{ kind: "echo", message: "step one \n \ndone" }] as FlowFile["steps"]; const yaml = serializeFlow({ executionPrerequisite: "", steps }); // Block (|) and folded (>) scalars are not round-trip-safe for this shape; @@ -778,37 +785,37 @@ describe("parseFlow", () => { // surface later as a misleading runtime failure (wrong scroll direction, // lost submit opt-out, lost timeout, lost snapshot tolerance). describe("unknown option keys are rejected at parse time", () => { - it("rejects a misspelled scroll-to direction key with a suggestion", () => { + it("rejects a misspelled scroll-to direction key with a suggestion", async () => { expect(() => parseFlow("steps:\n - scroll-to: { target: Order-1234, directon: up }\n") ).toThrow(/scroll-to has unknown key `directon` \(did you mean `direction`\?\)/); }); - it("rejects a misspelled type.submit key with a suggestion", () => { + it("rejects a misspelled type.submit key with a suggestion", async () => { expect(() => parseFlow('steps:\n - type: { into: email, text: "a@b.com", sumbit: false }\n') ).toThrow(/type has unknown key `sumbit` \(did you mean `submit`\?\)/); }); - it("rejects a misspelled await.timeout key with a suggestion", () => { + it("rejects a misspelled await.timeout key with a suggestion", async () => { expect(() => parseFlow("steps:\n - await: { visible: Account, timeut: 10000 }\n")).toThrow( /await has unknown key `timeut` \(did you mean `timeout`\?\)/ ); }); - it("rejects a misspelled snapshot.maxMismatch key with a suggestion", () => { + it("rejects a misspelled snapshot.maxMismatch key with a suggestion", async () => { expect(() => parseFlow("steps:\n - snapshot: { name: home, maxMissmatch: 1.5 }\n")).toThrow( /snapshot has unknown key `maxMissmatch` \(did you mean `maxMismatch`\?\)/ ); }); - it("rejects a miscased snapshot.cropOn key with a suggestion", () => { + it("rejects a miscased snapshot.cropOn key with a suggestion", async () => { expect(() => parseFlow("steps:\n - snapshot: { name: home, cropon: Header }\n")).toThrow( /snapshot has unknown key `cropon` \(did you mean `cropOn`\?\)/ ); }); - it("rejects an unknown key on a selector map", () => { + it("rejects an unknown key on a selector map", async () => { expect(() => parseFlow("steps:\n - tap: { text: Save, roel: button }\n")).toThrow( /tap: selector has unknown key `roel` \(did you mean `role`\?\)/ ); @@ -822,25 +829,25 @@ describe("parseFlow", () => { ); }); - it("rejects an unknown key without a suggestion when nothing is close", () => { + it("rejects an unknown key without a suggestion when nothing is close", async () => { expect(() => parseFlow("steps:\n - scroll-to: { target: Row, sideways: true }\n")).toThrow( /scroll-to has unknown key `sideways` — allowed keys: target, direction, within/ ); }); - it("rejects an unknown key in an await/assert text body", () => { + it("rejects an unknown key in an await/assert text body", async () => { expect(() => parseFlow('steps:\n - assert: { text: { in: counter, contians: "Taps: 0" } }\n') ).toThrow(/assert.text has unknown key `contians` \(did you mean `contains`\?\)/); }); - it("rejects a stray key on a coordinate tap", () => { + it("rejects a stray key on a coordinate tap", async () => { expect(() => parseFlow("steps:\n - tap: { x: 0.5, y: 0.5, why: 0.6 }\n")).toThrow( /tap: a coordinate target takes only \{ x, y \}/ ); }); - it("rejects an unknown key in a launch map and its chromium value", () => { + it("rejects an unknown key in a launch map and its chromium value", async () => { expect(() => parseFlow("steps:\n - launch: { amdroid: com.acme.app }\n")).toThrow( /launch has unknown key `amdroid` \(did you mean `android`\?\)/ ); @@ -849,7 +856,7 @@ describe("parseFlow", () => { ).toThrow(/launch.chromium has unknown key `arg` \(did you mean `args`\?\)/); }); - it("rejects a step-level sibling key (options belong inside the directive value)", () => { + it("rejects a step-level sibling key (options belong inside the directive value)", async () => { expect(() => parseFlow("steps:\n - await: { visible: Account }\n timeout: 5000\n") ).toThrow( @@ -857,26 +864,26 @@ describe("parseFlow", () => { ); }); - it("rejects a step carrying two directive keys", () => { + it("rejects a step carrying two directive keys", async () => { expect(() => parseFlow("steps:\n - echo: hi\n tap: Save\n")).toThrow( /a step takes exactly one directive key, found `echo`, `tap`/ ); }); - it("suggests the directive key for a misspelled step kind", () => { + it("suggests the directive key for a misspelled step kind", async () => { expect(() => parseFlow("steps:\n - snapshoot: home\n")).toThrow( /unrecognized step kind \(did you mean `snapshot`\?\)/ ); }); - it("rejects an unknown top-level flow file key", () => { + it("rejects an unknown top-level flow file key", async () => { expect(() => parseFlow("executionPrerequisit: Settings open\nsteps:\n - echo: hi\n") ).toThrow(/unknown key `executionPrerequisit` \(did you mean `executionPrerequisite`\?\)/); }); }); - it("roundtrips: serialize then parse", () => { + it("roundtrips: serialize then parse", async () => { const flow: FlowFile = { executionPrerequisite: "App freshly loaded on home screen", steps: [ @@ -894,26 +901,26 @@ describe("parseFlow", () => { // ── chromium launch (app path) ─────────────────────────────────────── describe("chromium launch parsing", () => { - it("parses a chromium launch with a bare-string app path", () => { + it("parses a chromium launch with a bare-string app path", async () => { const flow = parseFlow("steps:\n - launch: { chromium: ./app }\n"); expect(flow.steps).toEqual([{ kind: "launch", app: { chromium: "./app" } }]); }); - it("parses a chromium launch with a { path, args } map", () => { + it("parses a chromium launch with a { path, args } map", async () => { const flow = parseFlow("steps:\n - launch: { chromium: { path: ./app, args: [--e2e] } }\n"); expect(flow.steps).toEqual([ { kind: "launch", app: { chromium: { path: "./app", args: ["--e2e"] } } }, ]); }); - it("parses a mixed per-platform launch (ios id + chromium path)", () => { + it("parses a mixed per-platform launch (ios id + chromium path)", async () => { const flow = parseFlow("steps:\n - launch: { ios: com.acme.app, chromium: ./app }\n"); expect(flow.steps).toEqual([ { kind: "launch", app: { ios: "com.acme.app", chromium: "./app" } }, ]); }); - it("round-trips a chromium { path, args } launch through YAML", () => { + it("round-trips a chromium { path, args } launch through YAML", async () => { const flow: FlowFile = { executionPrerequisite: "", steps: [ @@ -923,13 +930,13 @@ describe("chromium launch parsing", () => { expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps); }); - it("rejects a chromium map with no path", () => { + it("rejects a chromium map with no path", async () => { expect(() => parseFlow("steps:\n - launch: { chromium: { args: [--e2e] } }\n")).toThrow( /launch needs/ ); }); - it("rejects a chromium map with non-string args", () => { + it("rejects a chromium map with non-string args", async () => { expect(() => parseFlow("steps:\n - launch: { chromium: { path: ./app, args: [1, 2] } }\n") ).toThrow(/launch needs/); @@ -937,27 +944,27 @@ describe("chromium launch parsing", () => { }); describe("chromiumLaunchSpec", () => { - it("reads a bare-string launch as the app path", () => { + it("reads a bare-string launch as the app path", async () => { expect(chromiumLaunchSpec("./app")).toEqual({ path: "./app" }); }); - it("reads a chromium string value as the path", () => { + it("reads a chromium string value as the path", async () => { expect(chromiumLaunchSpec({ chromium: "./app" })).toEqual({ path: "./app" }); }); - it("reads a chromium { path, args } value", () => { + it("reads a chromium { path, args } value", async () => { expect(chromiumLaunchSpec({ chromium: { path: "./app", args: ["--e2e"] } })).toEqual({ path: "./app", args: ["--e2e"], }); }); - it("returns null when no chromium target is declared", () => { + it("returns null when no chromium target is declared", async () => { expect(chromiumLaunchSpec({ ios: "com.acme.app" })).toBeNull(); expect(chromiumLaunchSpec(undefined)).toBeNull(); }); - it("appIdForPlatform returns the chromium path (the runner's declared-target guard)", () => { + it("appIdForPlatform returns the chromium path (the runner's declared-target guard)", async () => { expect(appIdForPlatform({ chromium: { path: "./app", args: ["--e2e"] } }, "chromium")).toBe( "./app" ); @@ -969,13 +976,13 @@ describe("chromiumLaunchSpec", () => { // ── native shorthand ───────────────────────────────────────────────── describe("native launch shorthand", () => { - it("parses a native-only launch and round-trips it", () => { + it("parses a native-only launch and round-trips it", async () => { const flow = parseFlow("steps:\n - launch: { native: com.acme.app }\n"); expect(flow.steps).toEqual([{ kind: "launch", app: { native: "com.acme.app" } }]); expect(parseFlow(serializeFlow(flow)).steps).toEqual(flow.steps); }); - it("parses native alongside a per-platform override and a chromium path", () => { + it("parses native alongside a per-platform override and a chromium path", async () => { const flow = parseFlow( "steps:\n - launch: { native: com.acme.app, android: com.acme.app.debug, chromium: ./app }\n" ); @@ -987,11 +994,11 @@ describe("native launch shorthand", () => { ]); }); - it("rejects an empty native id", () => { + it("rejects an empty native id", async () => { expect(() => parseFlow('steps:\n - launch: { native: "" }\n')).toThrow(/launch needs/); }); - it("appIdForPlatform falls back to native for installed platforms, override wins", () => { + it("appIdForPlatform falls back to native for installed platforms, override wins", async () => { const app = { native: "com.acme.app", android: "com.acme.app.debug" }; // native fills in for platforms without a specific key… expect(appIdForPlatform(app, "ios")).toBe("com.acme.app"); @@ -1000,112 +1007,352 @@ describe("native launch shorthand", () => { expect(appIdForPlatform(app, "android")).toBe("com.acme.app.debug"); }); - it("native never applies to chromium (chromium takes a path, not an id)", () => { + it("native never applies to chromium (chromium takes a path, not an id)", async () => { expect(appIdForPlatform({ native: "com.acme.app" }, "chromium")).toBeNull(); expect(chromiumLaunchSpec({ native: "com.acme.app" })).toBeNull(); }); }); -// ── Active flow state ──────────────────────────────────────────────── +// ── Recording sessions ─────────────────────────────────────────────── -describe("active flow state", () => { +// Recordings live in a map keyed by the resolved flow file path, so a session +// has no identity beyond (project_root, name) — two agents recording at once +// must never write into each other's take. What is isolated is the artifact, +// not the fact that a recording exists: the not-found message deliberately +// names the other live flows in the caller's own project and counts the rest, +// and the two cases below pin that disclosure as bounded rather than absent. +describe("recording sessions", () => { beforeEach(() => { - clearActiveFlow(); + __resetRecordingsForTesting(); }); - it("throws when no active flow", () => { - expect(() => getActiveFlow()).toThrow("No active flow"); + const emptyFlow = (): FlowFile => ({ executionPrerequisite: "", steps: [] }); + + const start = (projectRoot: string, name: string, flow: FlowFile = emptyFlow()) => + startRecordingSession({ + name, + projectRoot, + persist: "host", + filePath: getFlowPath(projectRoot, name), + flow, + }); + + it("throws when the key has no recording", async () => { + await expect(requireRecordingSession("/tmp/proj-a", "my-flow")).rejects.toThrow( + /No active recording for flow "my-flow"/ + ); + }); + + it("classifies the not-found throw as FLOW_NO_ACTIVE_RECORDING", async () => { + let caught: unknown; + try { + await requireRecordingSession("/tmp/proj-a", "my-flow"); + } catch (err) { + caught = err; + } + expect(getFailureSignal(caught)?.error_code).toBe(FAILURE_CODES.FLOW_NO_ACTIVE_RECORDING); }); - it("returns the active flow after setActiveFlow", () => { - setActiveFlow("my-flow"); - expect(getActiveFlow()).toBe("my-flow"); + it("names the asked-for key and this project's live recordings in the not-found message", async () => { + // With concurrent recordings the usual cause is a typo or the wrong + // project_root; the agent can only self-correct if it sees the live keys. + await start("/tmp/proj-a", "checkout"); + await start("/tmp/proj-b", "login"); + await expect(requireRecordingSession("/tmp/proj-a", "chekout")).rejects.toThrow( + /No active recording for flow "chekout" in \/tmp\/proj-a\./ + ); + await expect(requireRecordingSession("/tmp/proj-a", "chekout")).rejects.toThrow( + /Active recordings: "checkout" \(plus 1 in other projects\)\./ + ); }); - it("clears the active flow", () => { - setActiveFlow("my-flow"); - clearActiveFlow(); - expect(() => getActiveFlow()).toThrow("No active flow"); + it("counts other projects' recordings without naming them", async () => { + // A tool-server bound beyond loopback serves unrelated callers; another + // project's flow names and absolute paths are not this caller's to see. + await start("/tmp/proj-b", "login"); + await start("/tmp/proj-c", "secret-onboarding"); + const message = await (async () => { + try { + await requireRecordingSession("/tmp/proj-a", "my-flow"); + } catch (err) { + return (err as Error).message; + } + throw new Error("expected a throw"); + })(); + expect(message).toMatch( + /Active recordings: none in this project \(plus 2 in other projects\)\./ + ); + expect(message).not.toContain("login"); + expect(message).not.toContain("secret-onboarding"); + expect(message).not.toContain("/tmp/proj-b"); + expect(message).not.toContain("/tmp/proj-c"); + }); + + it("treats a differently-spelled but identical root as THIS project", async () => { + // The partition compares path.join-normalized flows dirs, not raw strings. + // A caller that spells its own root with a trailing slash must still be + // shown its own live recordings — a strict === would answer "none in this + // project (plus 1 in other projects)", degrading the message in exactly the + // wrong-project_root case it exists to diagnose. Every other test here + // spells both sides identically, so only this one separates the two. + await start("/tmp/proj-a", "checkout"); + const message = await (async () => { + try { + await requireRecordingSession("/tmp/proj-a/", "chekout"); + } catch (err) { + return (err as Error).message; + } + throw new Error("expected a throw"); + })(); + expect(message).toMatch(/Active recordings: "checkout"\./); + expect(message).not.toContain("other projects"); + }); + + it("does not tell the agent to just call flow-start-recording", async () => { + // This message is reached for a key that was never started, but equally for + // one that was finished, superseded, or dropped by the concurrency cap — + // and in those cases the flow file on disk is fully populated. Naming + // flow-start-recording as the fix destroys it, because it truncates + // unconditionally and reports no `restarted` when no session was replaced. + const message = await (async () => { + try { + await requireRecordingSession("/tmp/proj-a", "finished-earlier"); + } catch (err) { + return (err as Error).message; + } + throw new Error("expected a throw"); + })(); + expect(message).toContain("truncates"); + expect(message).toMatch(/record under a fresh name|copy it aside/); + expect(message).not.toMatch(/Call flow-start-recording first/); + }); + + it('reports "none in this project" when nothing is being recorded', async () => { + await expect(requireRecordingSession("/tmp/proj-a", "my-flow")).rejects.toThrow( + /Active recordings: none in this project\./ + ); }); - it("overwrites previous active flow", () => { - setActiveFlow("first"); - setActiveFlow("second"); - expect(getActiveFlow()).toBe("second"); + it("returns the session that was started for that key", async () => { + await start("/tmp/proj-a", "my-flow"); + const session = await requireRecordingSession("/tmp/proj-a", "my-flow"); + expect(session.name).toBe("my-flow"); + expect(session.projectRoot).toBe("/tmp/proj-a"); + expect(session.persist).toBe("host"); + expect(session.filePath).toBe(getFlowPath("/tmp/proj-a", "my-flow")); }); - it("getActiveFlowOrNull returns null when no active flow", () => { - expect(getActiveFlowOrNull()).toBeNull(); + it("getRecordingSession returns undefined for a key with no recording", async () => { + expect(await getRecordingSession("/tmp/proj-a", "my-flow")).toBeUndefined(); }); - it("getActiveFlowOrNull returns the active flow name", () => { - setActiveFlow("my-flow"); - expect(getActiveFlowOrNull()).toBe("my-flow"); + it("getRecordingSession returns the live session", async () => { + await start("/tmp/proj-a", "my-flow"); + expect((await getRecordingSession("/tmp/proj-a", "my-flow"))?.name).toBe("my-flow"); }); - it("getActiveFlowOrNull returns null after clearing", () => { - setActiveFlow("my-flow"); - clearActiveFlow(); - expect(getActiveFlowOrNull()).toBeNull(); + it("clearRecordingSession removes only that key", async () => { + await start("/tmp/proj-a", "my-flow"); + await start("/tmp/proj-a", "other-flow"); + clearRecordingSession(await requireRecordingSession("/tmp/proj-a", "my-flow")); + expect(await getRecordingSession("/tmp/proj-a", "my-flow")).toBeUndefined(); + await expect(requireRecordingSession("/tmp/proj-a", "my-flow")).rejects.toThrow( + /No active recording for flow "my-flow"/ + ); + // The unrelated recording is untouched. + expect((await requireRecordingSession("/tmp/proj-a", "other-flow")).name).toBe("other-flow"); + }); + + it("clearRecordingSession deletes by the key the session HOLDS, not a fresh resolution", async () => { + // The same choice appendStepToFlow documents. Re-resolving the spelling + // looks up a key the map may no longer hold once the flow file's identity + // has moved under the session — a symlink repointed mid-recording — so the + // delete missed silently and the finish reported success while the session + // stayed live, unfinishable, and holding the key against its own restart. + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clear-moved-key-")); + try { + const root = path.join(dir, "proj"); + const flows = path.join(root, ".argent", "flows"); + await fs.mkdir(flows, { recursive: true }); + const first = path.join(dir, "first.yaml"); + const second = path.join(dir, "second.yaml"); + for (const f of [first, second]) await fs.writeFile(f, "steps: []\n", "utf8"); + const link = path.join(flows, "shared.yaml"); + await fs.symlink(first, link); + + await startRecordingSession({ + name: "shared", + projectRoot: root, + persist: "host", + filePath: link, + flow: emptyFlow(), + }); + const session = (await getRecordingSession(root, "shared"))!; + + await fs.rm(link); + await fs.symlink(second, link); + // The spelling now resolves to a different file entirely. + expect(await getRecordingSession(root, "shared")).toBeUndefined(); + + clearRecordingSession(session); + expect(listActiveRecordings()).toEqual([]); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("keeps same-named recordings under different project roots independent", async () => { + await start("/tmp/proj-a", "my-flow", { executionPrerequisite: "A", steps: [] }); + await start("/tmp/proj-b", "my-flow", { executionPrerequisite: "B", steps: [] }); + expect( + (await requireRecordingSession("/tmp/proj-a", "my-flow")).flow.executionPrerequisite + ).toBe("A"); + expect( + (await requireRecordingSession("/tmp/proj-b", "my-flow")).flow.executionPrerequisite + ).toBe("B"); + // Finishing one leaves the other recording. + clearRecordingSession(await requireRecordingSession("/tmp/proj-a", "my-flow")); + expect(await getRecordingSession("/tmp/proj-a", "my-flow")).toBeUndefined(); + expect( + (await requireRecordingSession("/tmp/proj-b", "my-flow")).flow.executionPrerequisite + ).toBe("B"); + }); + + it("returns null when starting a recording on a free key", async () => { + expect(await start("/tmp/proj-a", "my-flow")).toBeNull(); + // A second, unrelated recording is the common concurrent case — not a replace. + expect(await start("/tmp/proj-a", "other-flow")).toBeNull(); + expect(await start("/tmp/proj-b", "my-flow")).toBeNull(); + }); + + it("returns the replaced session when re-recording the same key", async () => { + await start("/tmp/proj-a", "my-flow", { executionPrerequisite: "first take", steps: [] }); + const replaced = await start("/tmp/proj-a", "my-flow", { + executionPrerequisite: "second take", + steps: [], + }); + expect(replaced?.flow.executionPrerequisite).toBe("first take"); + // The later take wins — one key, one writer. + expect( + (await requireRecordingSession("/tmp/proj-a", "my-flow")).flow.executionPrerequisite + ).toBe("second take"); + }); + + it("evicts the least recently USED recording, not the oldest one", async () => { + // The cap is a leak backstop, but which entry it drops matters: evicting a + // recording an agent is actively using would strand its steps. Fill past + // the cap, touching the first-registered key just before the overflow — it + // must survive and the untouched next-oldest must go. A FIFO eviction fails + // this deterministically. + // + // It does NOT reliably catch an LRU keyed on a millisecond clock: that only + // ties when the whole fill and the touch land inside one millisecond, which + // holds when this file runs alone but not under full-suite load. The + // counter's tie-freedom is argued at `touch()` rather than pinned here. + const cap = MAX_RECORDINGS; + for (let i = 0; i < cap; i++) await start("/tmp/proj-a", `flow-${i}`); + expect(listActiveRecordings()).toHaveLength(cap); + + await requireRecordingSession("/tmp/proj-a", "flow-0"); // now most-recently-used + await start("/tmp/proj-a", "overflow"); + + const live = new Set(listActiveRecordings().map((r) => r.name)); + expect(live.size).toBe(cap); + expect(live.has("flow-0")).toBe(true); // touched, so kept + expect(live.has("flow-1")).toBe(false); // untouched and now the oldest use + expect(live.has("overflow")).toBe(true); + }); + + it("listActiveRecordings reflects what is live", async () => { + expect(listActiveRecordings()).toEqual([]); + await start("/tmp/proj-a", "my-flow", { + executionPrerequisite: "", + steps: [{ kind: "echo", message: "hi" }], + }); + await start("/tmp/proj-b", "my-flow"); + expect(listActiveRecordings()).toEqual([ + { name: "my-flow", projectRoot: "/tmp/proj-a", steps: 1 }, + { name: "my-flow", projectRoot: "/tmp/proj-b", steps: 0 }, + ]); + clearRecordingSession(await requireRecordingSession("/tmp/proj-a", "my-flow")); + expect(listActiveRecordings()).toEqual([ + { name: "my-flow", projectRoot: "/tmp/proj-b", steps: 0 }, + ]); + __resetRecordingsForTesting(); + expect(listActiveRecordings()).toEqual([]); + }); + + it("keys a session by the normalized flow path, so a trailing slash rejoins it", async () => { + await start("/tmp/proj-a", "my-flow"); + expect((await requireRecordingSession("/tmp/proj-a/", "my-flow")).name).toBe("my-flow"); + expect(await start("/tmp/proj-a/", "my-flow")).not.toBeNull(); + expect(listActiveRecordings()).toHaveLength(1); }); }); // ── getFlowPath name validation ────────────────────────────────────── describe("getFlowPath name validation", () => { - beforeEach(() => { - clearActiveProjectRoot(); - setActiveProjectRoot("/tmp/argent-flow-name-test"); - }); + // Pure path math over two explicit inputs — the root is a parameter, never + // shared state, so two callers naming two projects can never collide. + const root = "/tmp/argent-flow-name-test"; - it("accepts plain alphanumeric names", () => { - expect(getFlowPath("my-flow_1")).toBe( - path.join("/tmp/argent-flow-name-test", ".argent", "flows", "my-flow_1.yaml") + it("accepts plain alphanumeric names", async () => { + expect(getFlowPath(root, "my-flow_1")).toBe( + path.join(root, ".argent", "flows", "my-flow_1.yaml") ); }); - it("rejects path-traversal segments", () => { - expect(() => getFlowPath("../../etc/passwd")).toThrow(/Invalid flow name/); - expect(() => getFlowPath("../foo")).toThrow(/Invalid flow name/); + it("normalizes a trailing slash on the project root", async () => { + // The flow path doubles as the recording-session key: a trailing slash must + // not mint a second identity for the same file. + expect(getFlowPath("/tmp/x/", "f")).toBe(getFlowPath("/tmp/x", "f")); }); - it("rejects path separators", () => { - expect(() => getFlowPath("foo/bar")).toThrow(/Invalid flow name/); - expect(() => getFlowPath("/abs/path")).toThrow(/Invalid flow name/); + it("rejects path-traversal segments", async () => { + expect(() => getFlowPath(root, "../../etc/passwd")).toThrow(/Invalid flow name/); + expect(() => getFlowPath(root, "../foo")).toThrow(/Invalid flow name/); }); - it("rejects names with spaces or shell metacharacters", () => { - expect(() => getFlowPath("foo bar")).toThrow(/Invalid flow name/); - expect(() => getFlowPath("foo;bar")).toThrow(/Invalid flow name/); - expect(() => getFlowPath("foo$(id)")).toThrow(/Invalid flow name/); + it("rejects path separators", async () => { + expect(() => getFlowPath(root, "foo/bar")).toThrow(/Invalid flow name/); + expect(() => getFlowPath(root, "/abs/path")).toThrow(/Invalid flow name/); }); - it("rejects empty names", () => { - expect(() => getFlowPath("")).toThrow(/Invalid flow name/); + it("rejects names with spaces or shell metacharacters", async () => { + expect(() => getFlowPath(root, "foo bar")).toThrow(/Invalid flow name/); + expect(() => getFlowPath(root, "foo;bar")).toThrow(/Invalid flow name/); + expect(() => getFlowPath(root, "foo$(id)")).toThrow(/Invalid flow name/); + }); + + it("rejects empty names", async () => { + expect(() => getFlowPath(root, "")).toThrow(/Invalid flow name/); }); }); // PR #194 follow-up C: project_root must be absolute AND free of ".." // segments (path.join collapses ".." and would relocate the flows dir). -describe("setActiveProjectRoot validation", () => { - it("rejects a relative project_root", () => { - expect(() => setActiveProjectRoot("relative/path")).toThrow(/absolute path/); +describe("assertValidProjectRoot validation", () => { + it("rejects a relative project_root", async () => { + expect(() => assertValidProjectRoot("relative/path")).toThrow(/absolute path/); }); - it('rejects an absolute project_root containing ".." segments', () => { - expect(() => setActiveProjectRoot("/a/../../../etc")).toThrow(/must not contain "\.\."/); - expect(() => setActiveProjectRoot("/home/user/../../root")).toThrow(/must not contain "\.\."/); + it('rejects an absolute project_root containing ".." segments', async () => { + expect(() => assertValidProjectRoot("/a/../../../etc")).toThrow(/must not contain "\.\."/); + expect(() => assertValidProjectRoot("/home/user/../../root")).toThrow( + /must not contain "\.\."/ + ); }); - it("accepts a clean absolute project_root", () => { - expect(() => setActiveProjectRoot("/tmp/argent-pr194-c-test")).not.toThrow(); + it("accepts a clean absolute project_root", async () => { + expect(() => assertValidProjectRoot("/tmp/argent-pr194-c-test")).not.toThrow(); }); }); // ── within (descendant) selector scoping ───────────────────────────── describe("within selector scoping", () => { - it("parses a within scope on a tap selector", () => { + it("parses a within scope on a tap selector", async () => { const flow = parseFlow("steps:\n - tap: { text: Delete, within: { id: profile-card } }\n"); expect(flow.steps).toEqual([ { @@ -1115,7 +1362,7 @@ describe("within selector scoping", () => { ]); }); - it("a bare-string within stays loose (identifier-first, then text)", () => { + it("a bare-string within stays loose (identifier-first, then text)", async () => { const flow = parseFlow("steps:\n - tap: { text: Delete, within: profile-card }\n"); expect(flow.steps).toEqual([ { @@ -1125,7 +1372,7 @@ describe("within selector scoping", () => { ]); }); - it("within chains outward and round-trips exactly", () => { + it("within chains outward and round-trips exactly", async () => { const flow: FlowFile = { executionPrerequisite: "", steps: [ @@ -1153,7 +1400,7 @@ describe("within selector scoping", () => { expect(parseFlow(serializeFlow(flow))).toEqual(flow); }); - it("serializes a loose within back to its bare-string spelling", () => { + it("serializes a loose within back to its bare-string spelling", async () => { const yaml = serializeFlow({ executionPrerequisite: "", steps: [ @@ -1166,7 +1413,7 @@ describe("within selector scoping", () => { expect(yaml).toContain("within: profile-card"); }); - it("accepts the regex text matcher inside a within scope", () => { + it("accepts the regex text matcher inside a within scope", async () => { const flow = parseFlow( "steps:\n - assert: { visible: { text: Delete, within: { text: { matches: '^Card \\d+$' } } } }\n" ); @@ -1179,42 +1426,42 @@ describe("within selector scoping", () => { ]); }); - it("rejects a selector that is ONLY a within scope", () => { + it("rejects a selector that is ONLY a within scope", async () => { expect(() => parseFlow("steps:\n - tap: { within: { id: card } }\n")).toThrow( /still needs its own text\/id\/role/ ); }); - it("rejects unknown keys inside a within scope, naming the nested slot", () => { + it("rejects unknown keys inside a within scope, naming the nested slot", async () => { expect(() => parseFlow("steps:\n - tap: { text: Delete, within: { idd: card } }\n")).toThrow( /tap\.within: selector has unknown key `idd` \(did you mean `id`\?\)/ ); }); - it("rejects id+identifier both set inside a within scope", () => { + it("rejects id+identifier both set inside a within scope", async () => { expect(() => parseFlow("steps:\n - tap: { text: A, within: { id: x, identifier: x } }\n") ).toThrow(/`id` or `identifier` \(its alias\), not both/); }); - it("rejects a cyclic within alias via the depth cap", () => { + it("rejects a cyclic within alias via the depth cap", async () => { const yaml = "steps:\n - tap: &s { text: Delete, within: *s }\n"; expect(() => parseFlow(yaml)).toThrow(/nest deeper than|cyclic YAML alias/); }); - it("rejects a within selector mixed with coordinates", () => { + it("rejects a within selector mixed with coordinates", async () => { expect(() => parseFlow("steps:\n - tap: { within: { id: card }, x: 0.5, y: 0.5 }\n")).toThrow( /takes a selector or x\/y coordinates, not both/ ); }); - it("rejects a within key beside the tap options form", () => { + it("rejects a within key beside the tap options form", async () => { expect(() => parseFlow("steps:\n - tap: { on: Photo, times: 2, within: { id: card } }\n") ).toThrow(/the tap options form takes a nested selector/); }); - it("within works in scroll-to's target while scroll-to's own within stays the container anchor", () => { + it("within works in scroll-to's target while scroll-to's own within stays the container anchor", async () => { const flow = parseFlow( [ "steps:", @@ -1234,7 +1481,7 @@ describe("within selector scoping", () => { ]); }); - it("describeSelector renders the scope chain in parentheses", () => { + it("describeSelector renders the scope chain in parentheses", async () => { expect( describeSelector({ text: "Delete", @@ -1243,7 +1490,7 @@ describe("within selector scoping", () => { ).toBe('text="Delete" within (id="cards" within (text="Settings"))'); }); - it("when guards reject a {{secret:…}} placeholder hidden in a within scope", () => { + it("when guards reject a {{secret:…}} placeholder hidden in a within scope", async () => { expect(() => parseFlow( [ @@ -1260,7 +1507,7 @@ describe("within selector scoping", () => { // ── sibling scopes (`after`/`next`) and the `any` universal selector ── describe("sibling selector scopes and the universal selector", () => { - it("parses `after` (CSS ~) and `next` (CSS +) scopes", () => { + it("parses `after` (CSS ~) and `next` (CSS +) scopes", async () => { const flow = parseFlow( [ "steps:", @@ -1278,7 +1525,7 @@ describe("sibling selector scopes and the universal selector", () => { ]); }); - it("parses `any: true` paired with a scope and round-trips exactly", () => { + it("parses `any: true` paired with a scope and round-trips exactly", async () => { const yaml = [ "steps:", @@ -1300,7 +1547,7 @@ describe("sibling selector scopes and the universal selector", () => { expect(parseFlow(serializeFlow(flow))).toEqual(flow); }); - it("a bare-string sibling scope stays loose, and serializes back to the bare spelling", () => { + it("a bare-string sibling scope stays loose, and serializes back to the bare spelling", async () => { const flow = parseFlow("steps:\n - tap: { role: Switch, next: wifi-row }\n"); expect(flow.steps).toEqual([ { kind: "tap", selector: { role: "Switch", next: { text: "wifi-row", loose: true } } }, @@ -1309,7 +1556,7 @@ describe("sibling selector scopes and the universal selector", () => { expect(parseFlow(serializeFlow(flow))).toEqual(flow); }); - it("scopes combine and nest, round-tripping through YAML", () => { + it("scopes combine and nest, round-tripping through YAML", async () => { const flow = parseFlow( [ "steps:", @@ -1332,7 +1579,7 @@ describe("sibling selector scopes and the universal selector", () => { expect(parseFlow(serializeFlow(flow))).toEqual(flow); }); - it("accepts the regex text matcher inside a sibling scope", () => { + it("accepts the regex text matcher inside a sibling scope", async () => { const flow = parseFlow( "steps:\n - tap: { role: Switch, next: { text: { matches: '^Row \\d+$' } } }\n" ); @@ -1341,25 +1588,25 @@ describe("sibling selector scopes and the universal selector", () => { ]); }); - it("rejects a selector that is ONLY a sibling scope", () => { + it("rejects a selector that is ONLY a sibling scope", async () => { expect(() => parseFlow("steps:\n - tap: { after: { text: Danger } }\n")).toThrow( /`after` only scopes where to look — the selector still needs its own text\/id\/role/ ); }); - it("rejects `any: true` alongside the fields it would make redundant", () => { + it("rejects `any: true` alongside the fields it would make redundant", async () => { expect(() => parseFlow("steps:\n - tap: { any: true, role: Button, next: { text: Wi-Fi } }\n") ).toThrow(/already matches every element — drop it, or drop the `role`/); }); - it("rejects a bare `any: true` with no scope to narrow it", () => { + it("rejects a bare `any: true` with no scope to narrow it", async () => { expect(() => parseFlow("steps:\n - tap: { any: true }\n")).toThrow( /matches every element on screen — pair it with a scope \(within\/after\/next\)/ ); }); - it("rejects a non-`true` any value rather than reading it as a locator", () => { + it("rejects a non-`true` any value rather than reading it as a locator", async () => { // Falsy AND truthy: a truthiness check would wave `any: 1` / `any: yes` // through as the universal selector — a spelling no reader can predict and // the serializer cannot reproduce. @@ -1370,19 +1617,19 @@ describe("sibling selector scopes and the universal selector", () => { } }); - it("rejects unknown keys inside a sibling scope, naming the nested slot", () => { + it("rejects unknown keys inside a sibling scope, naming the nested slot", async () => { expect(() => parseFlow("steps:\n - tap: { role: Switch, next: { roel: Button } }\n")).toThrow( /tap\.next: selector has unknown key `roel` \(did you mean `role`\?\)/ ); }); - it("rejects a cyclic sibling alias via the scope budget", () => { + it("rejects a cyclic sibling alias via the scope budget", async () => { expect(() => parseFlow("steps:\n - tap: &s { text: Delete, after: *s }\n")).toThrow( /more than \d+ scopes|cyclic YAML alias/ ); }); - it("bounds a selector's whole scope TREE, not just its depth", () => { + it("bounds a selector's whole scope TREE, not just its depth", async () => { // Three relations per level means a depth cap alone still admits 3^depth // scopes — and the runner expands one alternative per combination of // bare-string scopes, so a few hundred bytes of YAML would exhaust the heap @@ -1406,7 +1653,7 @@ describe("sibling selector scopes and the universal selector", () => { ).toThrow(/more than 6 scopes/); }); - it("serializeFlow refuses an `any` selector the parser would reject on read-back", () => { + it("serializeFlow refuses an `any` selector the parser would reject on read-back", async () => { // appendStep re-parses the whole file on every recorded step, so a selector // that violates the parser's `any` rules must fail where it was built, not // on some later append. @@ -1431,7 +1678,7 @@ describe("sibling selector scopes and the universal selector", () => { ]); }); - it("rejects a sibling-scoped selector mixed with coordinates or tap options", () => { + it("rejects a sibling-scoped selector mixed with coordinates or tap options", async () => { expect(() => parseFlow("steps:\n - tap: { after: { id: card }, x: 0.5, y: 0.5 }\n")).toThrow( /takes a selector or x\/y coordinates, not both/ ); @@ -1440,7 +1687,7 @@ describe("sibling selector scopes and the universal selector", () => { ).toThrow(/the tap options form takes a nested selector/); }); - it("a loose bare-string selector cannot carry a scope through serialization", () => { + it("a loose bare-string selector cannot carry a scope through serialization", async () => { expect(() => serializeFlow({ executionPrerequisite: "", @@ -1451,7 +1698,7 @@ describe("sibling selector scopes and the universal selector", () => { ).toThrow(/incompatible fields: after/); }); - it("names the missing scroll-to target instead of leaking a schema message", () => { + it("names the missing scroll-to target instead of leaking a schema message", async () => { // `within` is a selector key now, so this body reads like a scoped selector // — it is actually the options map, missing its target. for (const body of ["{ within: { id: list } }", "{ direction: up }", "{}"]) { @@ -1471,7 +1718,7 @@ describe("sibling selector scopes and the universal selector", () => { ).not.toThrow(); }); - it("describeSelector renders each scope, and `*` for the universal selector", () => { + it("describeSelector renders each scope, and `*` for the universal selector", async () => { expect(describeSelector({ role: "Switch", next: { text: "Wi-Fi" } })).toBe( 'role="Switch" next (text="Wi-Fi")' ); @@ -1480,7 +1727,7 @@ describe("sibling selector scopes and the universal selector", () => { ).toBe('* within (id="row") after (text="Name")'); }); - it("when guards reject a {{secret:…}} placeholder hidden in ANY scope", () => { + it("when guards reject a {{secret:…}} placeholder hidden in ANY scope", async () => { // Every relation, so no branch of the walk can be skipped unnoticed. for (const scope of ["within", "after", "next"]) { expect(() => @@ -1507,3 +1754,306 @@ describe("sibling selector scopes and the universal selector", () => { ).toThrow(/secret/); }); }); + +// ── countStepsOnDisk ───────────────────────────────────────────────── + +// The count `flow-start-recording` reports for a take it is about to truncate. +// Its contract is the distinction between "0 steps" and "no answer": an empty +// take really did hold nothing, while an unreadable one is a loss of unknown +// size, and reporting the first for the second understates it in exactly the +// case that produced it. +describe("countStepsOnDisk", () => { + let dir: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "count-steps-")); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + const write = async (content: string) => { + const file = path.join(dir, "flow.yaml"); + await fs.writeFile(file, content, "utf8"); + return file; + }; + + it("counts the steps a readable flow file holds", async () => { + const file = await write( + serializeFlow({ + executionPrerequisite: "", + steps: [ + { kind: "echo", message: "one" }, + { kind: "echo", message: "two" }, + { kind: "echo", message: "three" }, + ], + }) + ); + expect(await countStepsOnDisk(file)).toBe(3); + }); + + it("counts an empty take as 0, which is a real answer", async () => { + const file = await write(serializeFlow({ executionPrerequisite: "", steps: [] })); + expect(await countStepsOnDisk(file)).toBe(0); + }); + + it("returns undefined for a file that does not exist", async () => { + expect(await countStepsOnDisk(path.join(dir, "absent.yaml"))).toBeUndefined(); + }); + + it("returns undefined rather than 0 for YAML the parser rejects", async () => { + // A hand-edit can leave this behind, and `parseFlow("")` returning an empty + // flow with no error is the reason 0 cannot double as "unknown". + const file = await write("steps: [ this: is: not: a: flow\n"); + expect(await countStepsOnDisk(file)).toBeUndefined(); + }); + + it("returns undefined for a directory in the file's place", async () => { + const asDir = path.join(dir, "flow-dir.yaml"); + await fs.mkdir(asDir); + expect(await countStepsOnDisk(asDir)).toBeUndefined(); + }); +}); + +describe("writeFlowFile failure hints", () => { + let root: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-write-hint-")); + }); + + afterEach(async () => { + // Restore write permission first, or the recursive rm cannot descend. + for (const dir of [path.join(root, "vault"), path.join(root, ".argent", "flows")]) { + await fs.chmod(dir, 0o755).catch(() => {}); + } + await fs.rm(root, { recursive: true, force: true }); + }); + + /** Whether this process can be denied by mode bits at all (root cannot). */ + async function modeBitsBite(dir: string): Promise { + await fs.chmod(dir, 0o555); + const probe = path.join(dir, ".probe"); + const denied = await fs + .writeFile(probe, "x", "utf8") + .then(() => false) + .catch(() => true); + if (!denied) await fs.rm(probe, { force: true }); + return denied; + } + + it("does not call the flow file a symlink when only an ANCESTOR is one", async () => { + // On macOS the temp dir is reached through /var -> /private/var, so the + // resolved swap directory differs from the spelled one for a flow file that + // is a perfectly ordinary regular file. Comparing the two spellings made + // every such failure claim a symlink and then contrast one directory with + // itself. + const flowsDir = path.join(root, ".argent", "flows"); + await fs.mkdir(flowsDir, { recursive: true }); + if (!(await modeBitsBite(flowsDir))) return; + + const err = await writeNewFlowFile(path.join(flowsDir, "x.yaml"), "steps: []\n").catch( + (e: unknown) => e + ); + + const message = (err as Error).message; + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED); + expect(message).toContain("must be writable"); + expect(message).not.toMatch(/is a symlink/); + }); + + it("blames the name length, not the directory, on ENAMETOOLONG", async () => { + // The arm the hint was split for: an over-long flow name comes out of + // `rename` (the scratch name is short), and reporting it as a + // directory-permissions problem sent the reader looking for one that is not + // there. + const flowsDir = path.join(root, ".argent", "flows"); + await fs.mkdir(flowsDir, { recursive: true }); + + const err = await writeNewFlowFile( + path.join(flowsDir, `${"n".repeat(400)}.yaml`), + "steps: []\n" + ).catch((e: unknown) => e); + + const message = (err as Error).message; + expect(message).toContain("(ENAMETOOLONG)"); + expect(message).toContain("use a shorter name"); + expect(message).not.toContain("must be writable"); + }); + + it("names the missing VAULT directory when the link points into one", async () => { + // ENOENT out of the scratch write, in the directory the swap actually uses. + // Naming `.argent/flows` here would point at a directory that exists. + const flowsDir = path.join(root, ".argent", "flows"); + await fs.mkdir(flowsDir, { recursive: true }); + const absentVault = path.join(root, "no-such-vault"); + await fs.symlink(path.join(absentVault, "shared.yaml"), path.join(flowsDir, "shared.yaml")); + + const err = await writeNewFlowFile(path.join(flowsDir, "shared.yaml"), "steps: []\n").catch( + (e: unknown) => e + ); + + const message = (err as Error).message; + expect(message).toContain("(ENOENT)"); + expect(message).toContain(`${absentVault} does not exist`); + expect(message).toContain("shared.yaml is a symlink"); + }); + + it("still points at the vault when the flow file really is a symlink", async () => { + // The case the clause exists for: naming `.argent/flows` here would send the + // reader to a directory that is already writable while the vault, the only + // unwritable thing in the picture, went unmentioned. + const flowsDir = path.join(root, ".argent", "flows"); + const vault = path.join(root, "vault"); + await fs.mkdir(flowsDir, { recursive: true }); + await fs.mkdir(vault, { recursive: true }); + await fs.writeFile(path.join(vault, "shared.yaml"), "steps: []\n", "utf8"); + await fs.symlink(path.join(vault, "shared.yaml"), path.join(flowsDir, "shared.yaml")); + if (!(await modeBitsBite(vault))) return; + + const err = await writeNewFlowFile(path.join(flowsDir, "shared.yaml"), "steps: []\n").catch( + (e: unknown) => e + ); + + const message = (err as Error).message; + expect(message).toContain("shared.yaml is a symlink, so the write lands in"); + expect(message).toContain(await fs.realpath(vault)); + }); +}); + +describe("flow file permissions across an atomic append", () => { + let root: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-mode-")); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + /** Whether mode bits can refuse this process at all (root ignores them). */ + async function modeBitsBite(file: string): Promise { + return fs + .access(file, fsConstants.W_OK) + .then(() => false) + .catch(() => true); + } + + it("carries the flow file's mode across the swap", async () => { + // The scratch file is created under the process umask and rename carries + // ITS mode over, so without preserving it every append quietly rewrote the + // flow file's permissions to 0644. + const file = path.join(root, "flow.yaml"); + await fs.writeFile(file, "steps: []\n", "utf8"); + await fs.chmod(file, 0o600); + + await writeNewFlowFile(file, "steps: []\n"); + + expect((await fs.stat(file)).mode & 0o777).toBe(0o600); + }); + + it("refuses to overwrite a read-only flow file", async () => { + // The swap needs permission on the DIRECTORY, so it would replace a + // `chmod 0444` file regardless — turning a plain write's EACCES into a + // silent success that also relaxed the mode. + const file = path.join(root, "flow.yaml"); + await fs.writeFile(file, "steps: []\nkeep: me\n", "utf8"); + await fs.chmod(file, 0o444); + if (!(await modeBitsBite(file))) return; + + const err = await writeNewFlowFile(file, "steps: []\n").catch((e: unknown) => e); + + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED); + expect((err as Error).message).toMatch(/not writable \(mode 0444\)/); + // And it really did not touch the file. + expect(await fs.readFile(file, "utf8")).toContain("keep: me"); + }); + + it("leaves no scratch file behind when it refuses", async () => { + const flows = path.join(root, ".argent", "flows"); + await fs.mkdir(flows, { recursive: true }); + const file = path.join(flows, "flow.yaml"); + await fs.writeFile(file, "steps: []\n", "utf8"); + await fs.chmod(file, 0o444); + if (!(await modeBitsBite(file))) return; + + await writeNewFlowFile(file, "steps: []\n").catch(() => {}); + + expect(await fs.readdir(flows)).toEqual(["flow.yaml"]); + }); + + it("still creates a flow file that does not exist yet", async () => { + // The control: nothing to preserve and nothing to be refused by. + const file = path.join(root, "fresh.yaml"); + await writeNewFlowFile(file, "steps: []\n"); + expect(await fs.readFile(file, "utf8")).toBe("steps: []\n"); + }); +}); + +describe("mkdirFailureHint arms", () => { + // The flows-directory half of writeNewFlowFile's classification. Only its + // wrapping was covered; each errno arm names a different cause, and the + // ENOTDIR one — a `project_root` that names a FILE — is the mistake the hint + // exists for. + let root: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "flow-mkdir-hint-")); + }); + + afterEach(async () => { + await fs.chmod(root, 0o755).catch(() => {}); + await fs.rm(root, { recursive: true, force: true }); + }); + + it("blames a project_root that names a file, not a directory", async () => { + const asFile = path.join(root, "notadir"); + await fs.writeFile(asFile, "", "utf8"); + const flows = path.join(asFile, ".argent", "flows"); + + const err = await writeNewFlowFile(path.join(flows, "x.yaml"), "steps: []\n").catch( + (e: unknown) => e + ); + + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.FLOW_FILE_WRITE_FAILED); + expect(getFailureSignal(err)?.failure_stage).toBe("flow_dir_create"); + expect((err as Error).message).toContain("(ENOTDIR)"); + expect((err as Error).message).toContain( + "check that project_root names a directory rather than a file" + ); + }); + + it("blames the nearest existing parent when it is not writable", async () => { + await fs.chmod(root, 0o555); + if ( + await fs.access(root, fsConstants.W_OK).then( + () => true, + () => false + ) + ) + return; + const flows = path.join(root, ".argent", "flows"); + + const err = await writeNewFlowFile(path.join(flows, "x.yaml"), "steps: []\n").catch( + (e: unknown) => e + ); + + expect((err as Error).message).toMatch(/\((EACCES|EPERM)\)/); + expect((err as Error).message).toContain("nearest existing parent"); + }); + + it("blames the name length when the path is too long for the filesystem", async () => { + // ENAMETOOLONG out of mkdir -p, which must not read as a permissions + // problem the user would then go and not find. + const tooLong = path.join(root, "d".repeat(512), ".argent", "flows"); + + const err = await writeNewFlowFile(path.join(tooLong, "x.yaml"), "steps: []\n").catch( + (e: unknown) => e + ); + + expect((err as Error).message).toContain("(ENAMETOOLONG)"); + expect((err as Error).message).toContain("longer than this filesystem allows"); + }); +}); diff --git a/packages/tool-server/test/http-flow-path-boundary.test.ts b/packages/tool-server/test/http-flow-path-boundary.test.ts index ac3871ec4..24471803d 100644 --- a/packages/tool-server/test/http-flow-path-boundary.test.ts +++ b/packages/tool-server/test/http-flow-path-boundary.test.ts @@ -7,11 +7,7 @@ import { ArtifactStore, type Registry, type ToolContext } from "@argent/registry import { createHttpApp, type HttpAppHandle } from "../src/http"; import { createRunFlowTool } from "../src/tools/flows/flow-run"; import { flowReadPrerequisiteTool } from "../src/tools/flows/flow-read-prerequisite"; -import { - clearActiveFlow, - clearActiveProjectRoot, - serializeFlow, -} from "../src/tools/flows/flow-utils"; +import { serializeFlow } from "../src/tools/flows/flow-utils"; vi.mock("../src/utils/update-checker", () => ({ getUpdateState: vi.fn(() => ({ updateInstallable: false, currentVersion: "1.0.0" })), @@ -99,13 +95,10 @@ beforeEach(async () => { ); steps = stepRegistry(); handle = createHttpApp(httpRegistry(steps)); - clearActiveFlow(); }); afterEach(async () => { handle?.dispose(); - clearActiveFlow(); - clearActiveProjectRoot(); await fs.rm(tmpDir, { recursive: true, force: true }); if (originalToken === undefined) delete process.env.ARGENT_AUTH_TOKEN; else process.env.ARGENT_AUTH_TOKEN = originalToken; @@ -191,6 +184,35 @@ describe("flow-execute flow_path over HTTP", () => { } }); + it("still validates project_root on the flow_path branch", async () => { + // `getFlowPath` validates the root, but only the `name` branch reaches it. + // Deleting `setActiveProjectRoot` — which ran unconditionally, ahead of + // both branches — left this branch with no check at all, so a relative or + // ".."-bearing root sailed through. Nothing reads project_root here today, + // which is exactly why the guardrail has to be pinned rather than assumed. + const st = await fs.stat(flowPath); + const wrapper = { + __argentFileInput: true, + path: flowPath, + size: st.size, + mtimeMs: st.mtimeMs, + }; + + for (const [root, expected] of [ + ["relative/root", /project_root must be an absolute path/], + [`${projectRoot}/../elsewhere`, /must not contain "\.\." segments/], + ] as const) { + const res = await supertest(handle.app) + .post("/tools/flow-execute") + .send({ project_root: root, device: DEVICE, flow_path: wrapper }); + + expect(res.status).toBe(500); + expect(res.body.error).toMatch(expected); + expect(res.body.error_code).toBe("FLOW_PROJECT_ROOT_INVALID"); + expect(steps.invokeTool).not.toHaveBeenCalled(); + } + }); + it('rejects a ".." flow_path whose kernel and lexical resolutions disagree', async () => { // /link -> /deep/inner, so the kernel reads /deep/flow.yaml // while path.dirname keeps "/link/.." and path.join collapses it to diff --git a/packages/tool-server/test/http-tools-meta.test.ts b/packages/tool-server/test/http-tools-meta.test.ts index 483beac2e..5b99bb3fa 100644 --- a/packages/tool-server/test/http-tools-meta.test.ts +++ b/packages/tool-server/test/http-tools-meta.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import supertest from "supertest"; +import { z } from "zod"; import { createHttpApp, type HttpAppHandle } from "../src/http"; import type { Registry } from "@argent/registry"; @@ -190,6 +191,87 @@ describe("GET /tools progressive-loading metadata", () => { expect(recordInvocation).toHaveBeenCalledWith(expect.any(String), { platform: "android" }); }); + it("records the platform of a scoped teardown, whose device arg is a LIST", async () => { + // `devices` is the third device-arg spelling and the only one that is an + // array — `stop-all-simulator-servers`' scope. The other two spellings are + // pinned above; deleting the `devices` branch of `extractDeviceArg` left + // the whole suite green, so a scoped teardown silently lost its platform. + // Driven through `device-tool` because `extractInvocationMeta` derives a + // platform only for a tool that declares a capability, and + // `stop-all-simulator-servers` — the sole tool spelling `devices` today — + // declares none. That is a fact about THIS consumer: `extractDeviceArg`'s + // other two are ungated and read `devices` in production (a failure + // classified from `req.body`, and a replayed teardown step attributed + // through `deriveChildInvocationMeta`). + let seenMeta: Record | undefined; + const recordInvocation = vi.fn((_id: string, meta: Record) => { + seenMeta = meta; + return vi.fn(); + }); + handle.dispose(); + handle = createHttpApp(stubRegistry(), { recordInvocation }); + + await request(handle.app) + .post("/tools/device-tool") + .send({ devices: ["emulator-5554", "11111111-1111-1111-1111-111111111111"] }) + .expect(200); + + // The first id is enough for the coarse platform; a mixed-platform scope + // is not something this dimension tries to represent. + expect(seenMeta).toEqual({ platform: "android" }); + }); + + it("ignores a devices list that holds no usable id", async () => { + // `devices: []` and `devices: [123]` must both yield no device arg. The + // empty case alone was a tautology — deleting the + // `typeof record.devices[0] === "string"` guard left the whole suite green, + // because a non-string element was never sent. The schema rejects such a + // call in production, so this is the guard's only exercise. + const recordInvocation = vi.fn(() => vi.fn()); + handle.dispose(); + handle = createHttpApp(stubRegistry(), { recordInvocation }); + + for (const devices of [[], [123], [null]]) { + recordInvocation.mockClear(); + await request(handle.app).post("/tools/device-tool").send({ devices }).expect(200); + // No device arg, so no platform — and with nothing else to record, no + // invocation metadata at all. + expect(recordInvocation, `devices: ${JSON.stringify(devices)}`).not.toHaveBeenCalled(); + } + }); + + it("classifies a FAILED call from its devices scope, with no capability in play", async () => { + // `emitHttpFailure` is one of `extractDeviceArg`'s two UNGATED consumers, + // and the one that makes the `devices` branch live in production: a + // rejected `stop-all-simulator-servers` call is classified straight from + // `req.body`, which carries the scope. Driven through a tool that declares + // no capability, exactly like the real one. + const recordFailure = vi.fn(); + const registry = stubRegistry(); + // The real shape: `stop-all-simulator-servers` is `.strict()` precisely so + // the `udids` slip cannot be stripped down to a machine-wide sweep, and + // that rejection is a 400 classified from `req.body` — which carries + // `devices`. No capability anywhere in the path. + (registry.getTool as unknown as ReturnType).mockReturnValue({ + id: "strict-teardown", + description: "Scoped teardown", + inputSchema: { type: "object", properties: { devices: {} } }, + zodSchema: z.object({ devices: z.array(z.string()).optional() }).strict(), + services: () => ({}), + execute: async () => ({}), + }); + handle.dispose(); + handle = createHttpApp(registry, { recordFailure }); + + await request(handle.app) + .post("/tools/strict-teardown") + .send({ devices: ["emulator-5554"], udids: ["oops"] }) + .expect(400); + + expect(recordFailure).toHaveBeenCalled(); + expect(recordFailure.mock.calls[0][1]).toMatchObject({ platform: "android" }); + }); + it("refines an iOS device to `tvos` when its cached runtime kind is tv", async () => { tvKinds.ios = "tv"; let seenMeta: Record | undefined; diff --git a/packages/tool-server/test/interaction-messages.test.ts b/packages/tool-server/test/interaction-messages.test.ts index a409bce8a..fa03071de 100644 --- a/packages/tool-server/test/interaction-messages.test.ts +++ b/packages/tool-server/test/interaction-messages.test.ts @@ -110,6 +110,86 @@ describe("tool interaction messages", () => { ); }); + it("distinguishes a fresh recording start from a destructive restart", () => { + // A restart truncates and replaces a live take; if its message ever + // collapsed to the same wording as a fresh start (or reported a step + // count that was never actually obtained), an agent re-recording a flow + // would have no way to notice it just destroyed prior work. + const definitions = definitionsById(createRegistry()); + const completedMsg = definitions.get("flow-start-recording")!.interaction!.completedMsg!; + const params = { name: "checkout", project_root: "/tmp/proj" }; + + expect( + completedMsg({ + params, + result: { message: "", flowFile: "", savedTo: "project" }, + }) + ).toBe("Started recording flow checkout"); + + expect( + completedMsg({ + params, + result: { message: "", flowFile: "", savedTo: "project", restarted: true }, + }) + ).toBe("Restarted recording flow checkout, discarding the previous take"); + + expect( + completedMsg({ + params, + result: { + message: "", + flowFile: "", + savedTo: "project", + restarted: true, + discardedSteps: 1, + }, + }) + ).toBe("Restarted recording flow checkout, discarding 1 step"); + + expect( + completedMsg({ + params, + result: { + message: "", + flowFile: "", + savedTo: "project", + restarted: true, + discardedSteps: 4, + }, + }) + ).toBe("Restarted recording flow checkout, discarding 4 steps"); + }); + + it("names the flow in every recording-tool interaction line", () => { + // Recordings are concurrent, so several of these lines interleave in one log + // and an unqualified "flow recording" would not say which one died or + // finished. Only two of the twelve formatters on the four recording tools + // are pinned elsewhere (flow-start-recording.completedMsg above, + // flow-add-echo.completedMsg in the secrets test), so the other ten could + // silently revert to name-free wording. Hold every one to naming the flow — + // the property the concurrency support introduced — including the failure + // lines, which are the diagnostic when several recordings are live. + const definitions = definitionsById(createRegistry()); + const name = "checkout"; + const params = { name, project_root: "/tmp/proj", command: "gesture-tap", message: "note" }; + const result = { message: "", flowFile: "", savedTo: "project" as const }; + + for (const id of [ + "flow-start-recording", + "flow-add-step", + "flow-add-echo", + "flow-finish-recording", + ]) { + const i = definitions.get(id)!.interaction!; + expect(i.startedMsg!({ params }), `${id}.startedMsg`).toContain(name); + expect(i.completedMsg!({ params, result }), `${id}.completedMsg`).toContain(name); + expect( + i.failedMsg!({ params, error: new Error("raw error"), failureSignal }), + `${id}.failedMsg` + ).toContain(name); + } + }); + it("does not expose sensitive inputs", () => { const definitions = definitionsById(createRegistry()); const secret = "INTERACTION_MESSAGE_SECRET"; @@ -130,13 +210,17 @@ describe("tool interaction messages", () => { params: { udid: "chromium-1", action: "set", name: "session", value: secret }, result: { set: true }, }), + // Recordings are keyed by `name` + `project_root`, so both are required + // and the message names the flow. The echoed `message` is the sensitive + // part — it is caller-authored free text — and stays out. definitions.get("flow-add-echo")!.interaction!.completedMsg!({ - params: { message: secret }, + params: { name: "checkout", project_root: "/tmp/proj", message: secret }, result: { message: secret, flowFile: "/tmp/flow.yaml", savedTo: "project" }, }), ]; expect(messages.join("\n")).not.toContain(secret); expect(messages).toContain("Opening example.com"); + expect(messages).toContain("Added note to flow checkout"); }); }); diff --git a/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts b/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts index f22b6d550..411e5294c 100644 --- a/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts +++ b/packages/tool-server/test/ios-instruments/analyze-freshness.test.ts @@ -32,6 +32,7 @@ function makeApi(wallClockStartMs: number | null): NativeProfilerSessionApi { // null exporter paths → checkExportFileMissing short-circuits (no fs access); // the freshness note still renders in the all-clear header regardless. exportedFiles: { cpu: null, hangs: null, leaks: null }, + disposed: false, profilingActive: false, wallClockStartMs, parsedData: null, diff --git a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts index 0337cca2d..baef551e6 100644 --- a/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts +++ b/packages/tool-server/test/ios-instruments/malloc-stack-logging.test.ts @@ -35,6 +35,7 @@ function fakeApi(): NativeProfilerSessionApi { mallocStackLogging: null, traceFile: null, exportedFiles: null, + disposed: false, profilingActive: false, wallClockStartMs: null, parsedData: null, diff --git a/packages/tool-server/test/metro/teardown-log-history.test.ts b/packages/tool-server/test/metro/teardown-log-history.test.ts new file mode 100644 index 000000000..8b333d1e3 --- /dev/null +++ b/packages/tool-server/test/metro/teardown-log-history.test.ts @@ -0,0 +1,276 @@ +/** + * `stop-all-simulator-servers` reaps every device-owned service, and since the + * `devices` scope landed that set includes `JsRuntimeDebugger`. Its dispose + * calls `logWriter.close()`, which unlinks the console-log file — up to 50,000 + * captured entries. + * + * The deletion itself is fine: the next resolve builds a new writer over a new + * path, so nothing could ever read the old file again. What was not fine is + * that the victim's `debugger-log-registry` transparently reconnected and + * reported `totalEntries: 0` with no error and no warning — indistinguishable + * from an app that has logged nothing, which is the opposite conclusion. + * + * Drives the real Registry → JsRuntimeDebugger → debugger-log-registry path + * against a mock Metro, disposing the service exactly as the teardown does. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { WebSocketServer, WebSocket } from "ws"; +import * as http from "node:http"; +import { Registry } from "@argent/registry"; +import { + jsRuntimeDebuggerBlueprint, + type JsRuntimeDebuggerApi, +} from "../../src/blueprints/js-runtime-debugger"; +import { debuggerConnectTool } from "../../src/tools/debugger/debugger-connect"; +import { debuggerLogRegistryTool } from "../../src/tools/debugger/debugger-log-registry"; +import { __resetReapedSessionsForTesting } from "../../src/utils/reaped-sessions"; + +let mockServer: http.Server; +let wss: WebSocketServer; +let mockPort: number; +let registry: Registry; + +const LOGICAL_ID = "logical-only-device"; + +function handleCDPMessage(ws: WebSocket, raw: string) { + const { id } = JSON.parse(raw) as { id: number; method: string }; + ws.send(JSON.stringify({ id, result: {} })); +} + +beforeAll(async () => { + await new Promise((resolve) => { + mockServer = http.createServer((req, res) => { + if (req.url === "/status") { + res.setHeader("X-React-Native-Project-Root", "/mock/project"); + res.end("packager-status:running"); + return; + } + if (req.url === "/json/list") { + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify([ + { + id: "page-0", + title: "app (Test Device)", + description: "[C++ connection]", + webSocketDebuggerUrl: `ws://localhost:${mockPort}/inspector/debug?device=${LOGICAL_ID}&page=1`, + deviceName: "Test Device", + reactNative: { + logicalDeviceId: LOGICAL_ID, + capabilities: { prefersFuseboxFrontend: true }, + }, + }, + ]) + ); + return; + } + res.statusCode = 404; + res.end("Not found"); + }); + + wss = new WebSocketServer({ server: mockServer }); + wss.on("connection", (ws) => ws.on("message", (raw) => handleCDPMessage(ws, raw.toString()))); + + mockServer.listen(0, () => { + mockPort = (mockServer.address() as { port: number }).port; + resolve(); + }); + }); + + registry = new Registry(); + registry.registerBlueprint(jsRuntimeDebuggerBlueprint); + registry.registerTool(debuggerConnectTool); + registry.registerTool(debuggerLogRegistryTool); +}); + +afterAll(async () => { + await registry.dispose(); + await new Promise((resolve) => wss.close(() => mockServer.close(() => resolve()))); +}); + +beforeEach(async () => { + // The registry caches the service, so a session a previous case left + // connected would be reused — carrying its entry count into the next case. + await registry.disposeService(`JsRuntimeDebugger:${mockPort}:${LOGICAL_ID}`).catch(() => {}); + __resetReapedSessionsForTesting(); +}); + +async function connectAndCapture(deviceId: string, entries: number): Promise { + await registry.invokeTool("debugger-connect", { port: mockPort, device_id: deviceId }); + const urn = `JsRuntimeDebugger:${mockPort}:${deviceId}`; + const api = await registry.resolveService(urn); + for (let i = 0; i < entries; i++) { + api.logWriter.write({ + id: i, + timestamp: new Date(1710000000000 + i * 1000).toISOString(), + level: "log", + message: `captured ${i}`, + }); + } + expect(api.logWriter.getStats().totalEntries).toBe(entries); + return urn; +} + +describe("a debugger session reaped by stop-all-simulator-servers", () => { + it("says the console history was deleted rather than reporting a silent app", async () => { + const urn = await connectAndCapture(LOGICAL_ID, 60); + + // Exactly what the teardown does to this device's debugger. + await registry.disposeService(urn); + + // The registry reconnects transparently — a brand new writer over a new + // file. The count really is 0; the question is whether anything says why. + const result = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: LOGICAL_ID, + })) as { totalEntries: number; note?: string }; + + expect(result.totalEntries).toBe(0); + expect(result.note).toBeDefined(); + expect(result.note).toContain("60 captured console entries"); + expect(result.note).toContain("stop-all-simulator-servers"); + expect(result.note).toContain("torn down"); + }); + + it("stays silent when the previous session had captured nothing", async () => { + // A teardown that destroyed no history has nothing to explain, and saying + // otherwise would make every empty registry look like a lost one. + await registry.invokeTool("debugger-connect", { port: mockPort, device_id: LOGICAL_ID }); + await registry.disposeService(`JsRuntimeDebugger:${mockPort}:${LOGICAL_ID}`); + + const result = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: LOGICAL_ID, + })) as { totalEntries: number; note?: string }; + + expect(result.totalEntries).toBe(0); + expect(result.note).toBeUndefined(); + }); + + it("does not attach the explanation to a registry that has its own entries", async () => { + const urn = await connectAndCapture(LOGICAL_ID, 5); + await registry.disposeService(urn); + // Reconnect and capture fresh history before reading. + await connectAndCapture(LOGICAL_ID, 3); + + const result = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: LOGICAL_ID, + })) as { totalEntries: number; note?: string }; + + expect(result.totalEntries).toBe(3); + expect(result.note).toBeUndefined(); + }); + + it("reports the loss once, not on every later empty read", async () => { + const urn = await connectAndCapture(LOGICAL_ID, 12); + await registry.disposeService(urn); + + const first = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: LOGICAL_ID, + })) as { note?: string }; + const second = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: LOGICAL_ID, + })) as { note?: string }; + + expect(first.note).toBeDefined(); + expect(second.note).toBeUndefined(); + }); + + it("is dropped by an explicit debugger-connect, which starts a capture of its own", async () => { + // The consumer is gated on an EMPTY registry, so a breadcrumb survives every + // read that finds entries — and would then attach "a teardown ate your logs" + // to some later, unrelated empty read. An explicit connect makes it wrong + // anyway: from there the capture is this session's own, so empty honestly + // means nothing has been logged since. Same discipline as the + // screen-recording and native-profiler starts. + const urn = await connectAndCapture(LOGICAL_ID, 40); + await registry.disposeService(urn); + + await registry.invokeTool("debugger-connect", { port: mockPort, device_id: LOGICAL_ID }); + + const result = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: LOGICAL_ID, + })) as { totalEntries: number; note?: string }; + + expect(result.totalEntries).toBe(0); + expect(result.note).toBeUndefined(); + }); + + describe("when the connect id and the logicalDeviceId differ", () => { + // Every case above connects with LOGICAL_ID, so `api.logicalDeviceId === + // deviceId` and the disposer's SECOND recordReapedSession never fires — + // that is the Chromium/Vega shape. On iOS/Android the caller connects with + // a udid/serial and Metro echoes its own logical id, so one teardown writes + // two breadcrumbs. They describe one event and must be spent as one. + const CONNECT_ID = "00000000-0000-0000-0000-0000000000ab"; + + beforeEach(async () => { + await registry.disposeService(`JsRuntimeDebugger:${mockPort}:${CONNECT_ID}`).catch(() => {}); + __resetReapedSessionsForTesting(); + }); + + it("explains the loss whichever of the two ids the read uses", async () => { + const urn = await connectAndCapture(CONNECT_ID, 29); + const api = await registry.resolveService(urn); + // The premise: this really is the two-id shape, so both keys get written. + expect(api.logicalDeviceId).toBe(LOGICAL_ID); + expect(api.logicalDeviceId).not.toBe(CONNECT_ID); + await registry.disposeService(urn); + + const viaConnectId = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: CONNECT_ID, + })) as { totalEntries: number; note?: string }; + + expect(viaConnectId.totalEntries).toBe(0); + expect(viaConnectId.note).toContain("29 captured console entries"); + }); + + it("spends BOTH breadcrumbs on that one read, so no copy outlives the event", async () => { + // The read consumed one key and left the other, so a later unrelated + // empty read — a fresh session that genuinely logged nothing — collected + // the leftover and blamed a teardown that had already been explained. + const urn = await connectAndCapture(CONNECT_ID, 7); + await registry.disposeService(urn); + + const first = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: CONNECT_ID, + })) as { note?: string }; + expect(first.note).toBeDefined(); + + // The other spelling of the same device, and the same spelling again: + // neither may still be holding a copy of that one teardown. + const viaLogicalId = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: LOGICAL_ID, + })) as { note?: string }; + const again = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id: CONNECT_ID, + })) as { note?: string }; + + expect(viaLogicalId.note).toBeUndefined(); + expect(again.note).toBeUndefined(); + }); + + it("drops BOTH breadcrumbs on an explicit connect, under either spelling", async () => { + const urn = await connectAndCapture(CONNECT_ID, 11); + await registry.disposeService(urn); + + await registry.invokeTool("debugger-connect", { port: mockPort, device_id: CONNECT_ID }); + + for (const device_id of [CONNECT_ID, LOGICAL_ID]) { + const result = (await registry.invokeTool("debugger-log-registry", { + port: mockPort, + device_id, + })) as { note?: string }; + expect(result.note).toBeUndefined(); + } + }); + }); +}); diff --git a/packages/tool-server/test/native-profiler-analyze-failure.test.ts b/packages/tool-server/test/native-profiler-analyze-failure.test.ts index 41524063a..7ca138e58 100644 --- a/packages/tool-server/test/native-profiler-analyze-failure.test.ts +++ b/packages/tool-server/test/native-profiler-analyze-failure.test.ts @@ -90,6 +90,7 @@ async function buildSessionWithTrace(): Promise<{ captureProcess: null, traceFile: tracePath, exportedFiles: { pftrace: tracePath }, + disposed: false, profilingActive: false, wallClockStartMs: null, parsedData: null, diff --git a/packages/tool-server/test/native-profiler-ios-start.test.ts b/packages/tool-server/test/native-profiler-ios-start.test.ts new file mode 100644 index 000000000..06518f034 --- /dev/null +++ b/packages/tool-server/test/native-profiler-ios-start.test.ts @@ -0,0 +1,153 @@ +/** + * The iOS half of `native-profiler-start`, driven at its module boundaries + * (xctrace spawn, the readiness handshake, the capture strategy, simctl). + * + * Two of its lines had no coverage at all, because the only start-side test + * file is Android-only: + * + * - the teardown-breadcrumb clear. A breadcrumb explains ONE confusing + * answer — the "no active session" a reaped capture's own stop would get — + * so a start that succeeds afterwards makes it unconsumable, and it would + * sit in the process-global map until some genuinely unrelated later + * absence collected it and blamed a teardown that had nothing to do with + * it. The Android twin clears it and is tested; iOS was not. + * - the disposed-session guard, which turns a start whose session a teardown + * destroyed mid-handshake into a failure instead of a `status: "recording"` + * nothing can stop. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "events"; +import type { ChildProcess } from "child_process"; +import * as os from "node:os"; +import { FAILURE_CODES, getFailureSignal, type DeviceInfo } from "@argent/registry"; + +class FakeXctrace extends EventEmitter { + pid = 4242; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + kill = vi.fn(() => true); +} + +vi.mock("child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn: vi.fn(() => new FakeXctrace()), + // Every simctl helper on this path goes through execFileSync. Failing it puts + // `resolveExplicitApp` on its documented "app is not running yet" fallback, + // which attaches by name — the cold-start-retry shape. + execFileSync: vi.fn(() => { + throw new Error("simctl unavailable in this test"); + }), +})); +vi.mock("../src/utils/ios-device-sets", () => ({ + deviceSetForUdid: vi.fn(async () => undefined), + simctlArgsForUdidSync: vi.fn((_udid: string, args: string[]) => args), +})); +vi.mock("../src/utils/react-profiler/debug/dump", () => ({ + getDebugDir: vi.fn(async () => os.tmpdir()), +})); +vi.mock("../src/utils/ios-profiler/notify", () => ({ + // Null handle: the start falls back to the stdout substring match, and + // `waitForXctraceReady` below is what decides readiness either way. + listenForDarwinNotification: vi.fn(() => { + throw new Error("notifyutil unavailable in this test"); + }), +})); +vi.mock("../src/utils/ios-profiler/startup", () => ({ + waitForXctraceReady: vi.fn(async () => ({ stderrBuffer: "" })), +})); +vi.mock("../src/utils/ios-profiler/capture-strategy", () => ({ + selectIosCaptureStrategy: vi.fn(() => ({ + name: "device", + attachesByName: true, + cpuFilterPid: () => null, + buildRecordArgs: () => ["record", "--device", "UDID"], + })), + resolveIosCaptureStrategy: vi.fn(() => ({ name: "device" })), + warnIfInvalidCaptureOverride: vi.fn(), +})); + +import { + nativeProfilerSessionBlueprint, + type NativeProfilerSessionApi, +} from "../src/blueprints/native-profiler-session"; +import { startNativeProfilerIos } from "../src/tools/profiler/native-profiler/platforms/ios"; +import { + recordReapedSession, + takeReapedSession, + __resetReapedSessionsForTesting, +} from "../src/utils/reaped-sessions"; + +const iosDevice = { id: "6DBF83B4-0000-0000-0000-000000000000", platform: "ios" } as DeviceInfo; + +async function session() { + return nativeProfilerSessionBlueprint.factory({}, iosDevice, { device: iosDevice } as never); +} + +const startParams = { + device_id: iosDevice.id, + app_process: "Bluesky", + template_path: "/tmp/Argent.tracetemplate", +}; + +beforeEach(() => { + __resetReapedSessionsForTesting(); +}); + +describe("startNativeProfilerIos", () => { + it("clears the teardown breadcrumb its own success would make unconsumable", async () => { + const instance = await session(); + const api = instance.api as NativeProfilerSessionApi; + recordReapedSession("native-profiler", api.deviceId, "an earlier trace"); + + const result = await startNativeProfilerIos(api, startParams); + + expect(result.status).toBe("recording"); + expect(takeReapedSession("native-profiler", api.deviceId)).toBeUndefined(); + if (api.recordingTimeout) clearTimeout(api.recordingTimeout); + }); + + it("leaves another device's breadcrumb alone", async () => { + const instance = await session(); + const api = instance.api as NativeProfilerSessionApi; + recordReapedSession("native-profiler", "emulator-5554", "somebody else's trace"); + + await startNativeProfilerIos(api, startParams); + + expect(takeReapedSession("native-profiler", "emulator-5554")).toBeDefined(); + if (api.recordingTimeout) clearTimeout(api.recordingTimeout); + }); + + it("fails, rather than reporting a recording, when a teardown lands mid-handshake", async () => { + const instance = await session(); + const api = instance.api as NativeProfilerSessionApi; + const startup = await import("../src/utils/ios-profiler/startup"); + vi.mocked(startup.waitForXctraceReady).mockImplementationOnce(async () => { + await instance.dispose(); + return { stderrBuffer: "" }; + }); + + const err = await startNativeProfilerIos(api, startParams).catch((e: unknown) => e); + + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_PROFILER_SESSION_TORN_DOWN); + expect(api.profilingActive).toBe(false); + expect(api.captureProcess).toBeNull(); + expect(api.recordingTimeout).toBeNull(); + }); + + it("kills the xctrace it spawned rather than leaving it recording", async () => { + const instance = await session(); + const api = instance.api as NativeProfilerSessionApi; + const child = new FakeXctrace(); + const cp = await import("child_process"); + vi.mocked(cp.spawn).mockReturnValueOnce(child as unknown as ChildProcess); + const startup = await import("../src/utils/ios-profiler/startup"); + vi.mocked(startup.waitForXctraceReady).mockImplementationOnce(async () => { + await instance.dispose(); + return { stderrBuffer: "" }; + }); + + await startNativeProfilerIos(api, startParams).catch(() => {}); + + expect(child.kill).toHaveBeenCalled(); + }); +}); diff --git a/packages/tool-server/test/native-profiler-missing-trace.test.ts b/packages/tool-server/test/native-profiler-missing-trace.test.ts index 62d1fd145..1bc22874b 100644 --- a/packages/tool-server/test/native-profiler-missing-trace.test.ts +++ b/packages/tool-server/test/native-profiler-missing-trace.test.ts @@ -59,6 +59,7 @@ describe("native-profiler-analyze: missing trace file", () => { captureProcess: null, traceFile, exportedFiles: { cpu: cpuPath, hangs: hangsPath, leaks: leaksPath }, + disposed: false, profilingActive: false, wallClockStartMs: null, parsedData: null, diff --git a/packages/tool-server/test/native-profiler-reaped-session.test.ts b/packages/tool-server/test/native-profiler-reaped-session.test.ts new file mode 100644 index 000000000..704798d78 --- /dev/null +++ b/packages/tool-server/test/native-profiler-reaped-session.test.ts @@ -0,0 +1,180 @@ +/** + * `stop-all-simulator-servers` reaps every device-owned service, and since the + * `devices` scope landed that set includes `NativeProfilerSession`. Its dispose + * SIGKILLs the capture with no finalize grace — on Android it also removes the + * on-device trace — so the trace really is destroyed. + * + * What must not also happen is the tool-server denying it ever ran. + * `Registry._teardown` nulls the node's instance, so the next + * `native-profiler-stop` resolves a fresh session and used to answer + * "No active native profiling session found. Call native-profiler-start first." + * for a capture that had been running seconds earlier. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "events"; +import type { ChildProcess } from "child_process"; +import type { DeviceInfo } from "@argent/registry"; + +vi.mock("../src/utils/adb", () => ({ adbShell: vi.fn(async () => "") })); +vi.mock("@argent/native-devtools-android", () => ({ + disposeWarmEngine: vi.fn(async () => {}), + TraceProcessorUnavailableError: class extends Error {}, +})); + +import { + nativeProfilerSessionBlueprint, + type NativeProfilerSessionApi, +} from "../src/blueprints/native-profiler-session"; +import { stopNativeProfilerIos } from "../src/tools/profiler/native-profiler/platforms/ios"; +import { stopNativeProfilerAndroid } from "../src/tools/profiler/native-profiler/platforms/android"; +import { __resetReapedSessionsForTesting } from "../src/utils/reaped-sessions"; + +const iosDevice = { id: "6DBF83B4-0000-0000-0000-000000000000", platform: "ios" } as DeviceInfo; +const androidDevice = { id: "emulator-5554", platform: "android" } as DeviceInfo; + +class FakeChild extends EventEmitter { + kill = vi.fn((_signal?: NodeJS.Signals) => { + queueMicrotask(() => this.emit("exit", null, "SIGKILL")); + return true; + }); +} + +async function session(device: DeviceInfo) { + return nativeProfilerSessionBlueprint.factory({}, device, { device } as never); +} + +beforeEach(() => { + __resetReapedSessionsForTesting(); +}); + +describe("a native profiling session reaped by stop-all-simulator-servers", () => { + it("iOS: names the teardown, and says the partial bundle is not worth salvaging", async () => { + const instance = await session(iosDevice); + const api = instance.api as NativeProfilerSessionApi; + api.profilingActive = true; + api.captureProcess = new FakeChild() as unknown as ChildProcess; + api.traceFile = "/tmp/argent-fake.trace"; + + await instance.dispose(); + + // The registry nulls the instance, so the stop below resolves a new one. + const fresh = (await session(iosDevice)).api as NativeProfilerSessionApi; + const err = await stopNativeProfilerIos(fresh).catch((e: unknown) => e); + + const message = (err as Error).message; + expect(message).not.toMatch(/Call native-profiler-start first/); + expect(message).toContain("torn down"); + expect(message).toContain("stop-all-simulator-servers"); + expect(message).toContain("/tmp/argent-fake.trace"); + }); + + it("Android: says outright that no trace survived", async () => { + const instance = await session(androidDevice); + const api = instance.api as NativeProfilerSessionApi; + api.profilingActive = true; + api.capturePid = 4242; + api.androidOnDeviceTracePath = "/data/misc/perfetto-traces/fake.pftrace"; + + await instance.dispose(); + + const fresh = (await session(androidDevice)).api as NativeProfilerSessionApi; + const err = await stopNativeProfilerAndroid(fresh).catch((e: unknown) => e); + + const message = (err as Error).message; + expect(message).not.toMatch(/Call native-profiler-start first/); + expect(message).toContain("torn down"); + expect(message).toContain("no trace survived"); + }); + + it("iOS: still explains a capped capture, and does not call its bundle half-written", async () => { + // The 10-minute cap SIGINTs xctrace and clears `profilingActive` while + // leaving the trace recoverable — `native-profiler-stop` has a whole branch + // for exporting it. Gating the breadcrumb on `profilingActive` sent the + // owner of such a capture back to "you never started one", and the + // mid-capture salvage text would have been wrong there too: that arm's + // bundle went through a finalize pass. + const instance = await session(iosDevice); + const api = instance.api as NativeProfilerSessionApi; + api.profilingActive = false; + api.recordingTimedOut = true; + api.traceFile = "/tmp/argent-capped.trace"; + + await instance.dispose(); + + const fresh = (await session(iosDevice)).api as NativeProfilerSessionApi; + const err = await stopNativeProfilerIos(fresh).catch((e: unknown) => e); + + const message = (err as Error).message; + expect(message).not.toMatch(/Call native-profiler-start first/); + expect(message).toContain("/tmp/argent-capped.trace"); + expect(message).toContain("already ended before this teardown"); + expect(message).not.toMatch(/without its finalize pass/); + }); + + it("iOS: explains a capture that exited on its own the same way", async () => { + const instance = await session(iosDevice); + const api = instance.api as NativeProfilerSessionApi; + api.recordingExitedUnexpectedly = true; + api.traceFile = "/tmp/argent-crashed.trace"; + + await instance.dispose(); + + const fresh = (await session(iosDevice)).api as NativeProfilerSessionApi; + const err = await stopNativeProfilerIos(fresh).catch((e: unknown) => e); + + expect((err as Error).message).toContain("already ended before this teardown"); + }); + + it("Android: says the capped trace is still on the device, not that none survived", async () => { + // The Android cap sends SIGTERM and clears `profilingActive`, so dispose's + // `rm -f` branch never runs — the on-device .pftrace really is still there. + const instance = await session(androidDevice); + const api = instance.api as NativeProfilerSessionApi; + api.recordingTimedOut = true; + api.traceFile = "/tmp/host.pftrace"; + api.androidOnDeviceTracePath = "/data/misc/perfetto-traces/capped.pftrace"; + + await instance.dispose(); + + const fresh = (await session(androidDevice)).api as NativeProfilerSessionApi; + const err = await stopNativeProfilerAndroid(fresh).catch((e: unknown) => e); + + const message = (err as Error).message; + expect(message).toContain("/data/misc/perfetto-traces/capped.pftrace"); + expect(message).toContain("left in place"); + expect(message).not.toMatch(/no trace survived/); + }); + + it("leaves a plain absence alone when the disposed session was idle", async () => { + // Disposing a session nobody was profiling with is routine cleanup. If that + // left a breadcrumb, the next honest "you never started one" would accuse a + // teardown of destroying a capture that never existed. + const instance = await session(iosDevice); + await instance.dispose(); + + const fresh = (await session(iosDevice)).api as NativeProfilerSessionApi; + const err = await stopNativeProfilerIos(fresh).catch((e: unknown) => e); + + expect((err as Error).message).toBe( + "No active native profiling session found. Call native-profiler-start first." + ); + }); + + it("is consumed by the report, so it cannot blame a later unrelated absence", async () => { + const instance = await session(iosDevice); + const api = instance.api as NativeProfilerSessionApi; + api.profilingActive = true; + api.captureProcess = new FakeChild() as unknown as ChildProcess; + api.traceFile = "/tmp/argent-fake.trace"; + await instance.dispose(); + + const fresh = (await session(iosDevice)).api as NativeProfilerSessionApi; + await stopNativeProfilerIos(fresh).catch(() => {}); + const again = (await session(iosDevice)).api as NativeProfilerSessionApi; + const err = await stopNativeProfilerIos(again).catch((e: unknown) => e); + + expect((err as Error).message).toBe( + "No active native profiling session found. Call native-profiler-start first." + ); + }); +}); diff --git a/packages/tool-server/test/native-profiler-start-clears-breadcrumb.test.ts b/packages/tool-server/test/native-profiler-start-clears-breadcrumb.test.ts new file mode 100644 index 000000000..843dd79e2 --- /dev/null +++ b/packages/tool-server/test/native-profiler-start-clears-breadcrumb.test.ts @@ -0,0 +1,97 @@ +/** + * A teardown breadcrumb explains ONE confusing answer: the "no active session" + * a reaped capture's own stop would otherwise get. A start that succeeds after + * the teardown means that stop will succeed instead, so the breadcrumb is never + * consumed by the read it was left for — and would sit in the process-global + * map until some genuinely unrelated "no active session", possibly much later, + * collected it and blamed a teardown that had nothing to do with it. + * + * Both platform starts clear it for that reason. Only the stop-side consume was + * covered; this drives the real `startNativeProfilerAndroid` (perfetto, adb and + * the debug dir stubbed at their module boundaries) so the clear is exercised + * where it actually lives rather than called directly. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "events"; +import type { ChildProcess } from "child_process"; +import type { DeviceInfo } from "@argent/registry"; +import * as os from "node:os"; + +vi.mock("../src/utils/adb", () => ({ adbShell: vi.fn(async () => "") })); +vi.mock("@argent/native-devtools-android", () => ({ + disposeWarmEngine: vi.fn(async () => {}), + TraceProcessorUnavailableError: class extends Error {}, +})); +vi.mock("../src/utils/android-profiler/detect-app", () => ({ + detectAndroidRunningApp: vi.fn(async () => "com.example.app"), + validateAndroidAppProcess: vi.fn(async () => {}), +})); +vi.mock("../src/utils/react-profiler/debug/dump", () => ({ + getDebugDir: vi.fn(async () => os.tmpdir()), +})); +vi.mock("../src/utils/android-profiler/capture", () => ({ + startPerfetto: vi.fn(async () => ({ + pid: 4242, + onDeviceTracePath: "/data/misc/perfetto-traces/fake.pftrace", + child: new EventEmitter() as unknown as ChildProcess, + })), + stopPerfetto: vi.fn(async () => {}), +})); + +import { + nativeProfilerSessionBlueprint, + type NativeProfilerSessionApi, +} from "../src/blueprints/native-profiler-session"; +import { + startNativeProfilerAndroid, + stopNativeProfilerAndroid, +} from "../src/tools/profiler/native-profiler/platforms/android"; +import { + recordReapedSession, + takeReapedSession, + __resetReapedSessionsForTesting, +} from "../src/utils/reaped-sessions"; + +const androidDevice = { id: "emulator-5554", platform: "android" } as DeviceInfo; + +async function session(): Promise { + const instance = await nativeProfilerSessionBlueprint.factory({}, androidDevice, { + device: androidDevice, + } as never); + return instance.api as NativeProfilerSessionApi; +} + +beforeEach(() => { + __resetReapedSessionsForTesting(); +}); + +describe("native-profiler-start after a teardown", () => { + it("clears the breadcrumb, so a later unrelated absence is not blamed on it", async () => { + recordReapedSession("native-profiler", androidDevice.id, "salvage note"); + + const api = await session(); + await startNativeProfilerAndroid(api, { device_id: androidDevice.id }); + expect(api.profilingActive).toBe(true); + + // Nothing is left for a later read to pick up… + expect(takeReapedSession("native-profiler", androidDevice.id)).toBeUndefined(); + + // …so a genuine "no active session" much later stays a plain absence. + const fresh = await session(); + const err = await stopNativeProfilerAndroid(fresh).catch((e: unknown) => e); + expect((err as Error).message).toBe( + "No active native profiling session found. Call native-profiler-start first." + ); + }); + + it("leaves another device's breadcrumb alone", async () => { + // The clear is scoped to the device the start ran on. Clearing broadly + // would silently disarm the explanation another agent's reaped capture is + // still owed. + recordReapedSession("native-profiler", "emulator-5556", "other device"); + + await startNativeProfilerAndroid(await session(), { device_id: androidDevice.id }); + + expect(takeReapedSession("native-profiler", "emulator-5556")).toBeDefined(); + }); +}); diff --git a/packages/tool-server/test/native-profiler-teardown-race.test.ts b/packages/tool-server/test/native-profiler-teardown-race.test.ts new file mode 100644 index 000000000..c23e60972 --- /dev/null +++ b/packages/tool-server/test/native-profiler-teardown-race.test.ts @@ -0,0 +1,140 @@ +/** + * A teardown that lands INSIDE `native-profiler-start`'s readiness window. + * + * Start spawns its capture child and only then awaits a readiness handshake — + * xctrace's `--notify-tracing-started`, or `startPerfetto`'s round trip. A + * `stop-all-simulator-servers` arriving in that window used to see + * `profilingActive` still false, so it disposed the session WITHOUT killing the + * child and reported the session as stopped. The start then resumed and + * returned `status: "recording"` against a session `Registry._teardown` had + * already destroyed: the owner's `native-profiler-stop` answered "No active + * native profiling session found. Call native-profiler-start first," and the + * trace file sat on disk with nothing able to reach it. + * + * This became reachable outside process shutdown only when + * `NativeProfilerSession` joined the teardown's namespace set. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "events"; +import type { ChildProcess } from "child_process"; +import { FAILURE_CODES, getFailureSignal, type DeviceInfo } from "@argent/registry"; + +vi.mock("../src/utils/adb", () => ({ adbShell: vi.fn(async () => "") })); +vi.mock("@argent/native-devtools-android", () => ({ + disposeWarmEngine: vi.fn(async () => {}), + TraceProcessorUnavailableError: class extends Error {}, +})); +vi.mock("../src/utils/android-profiler/capture", () => ({ + startPerfetto: vi.fn(), + stopPerfetto: vi.fn(), +})); +vi.mock("../src/utils/android-profiler/detect-app", () => ({ + detectAndroidRunningApp: vi.fn(async () => "com.example.app"), + validateAndroidAppProcess: vi.fn(async () => {}), +})); + +import { adbShell } from "../src/utils/adb"; +import { startPerfetto } from "../src/utils/android-profiler/capture"; +import { + nativeProfilerSessionBlueprint, + type NativeProfilerSessionApi, +} from "../src/blueprints/native-profiler-session"; +import { startNativeProfilerAndroid } from "../src/tools/profiler/native-profiler/platforms/android"; + +const adbShellMock = vi.mocked(adbShell); +const startPerfettoMock = vi.mocked(startPerfetto); + +const iosDevice = { id: "6DBF83B4-0000-0000-0000-000000000000", platform: "ios" } as DeviceInfo; +const androidDevice = { id: "emulator-5554", platform: "android" } as DeviceInfo; + +class FakeChild extends EventEmitter { + kill = vi.fn((_signal?: NodeJS.Signals) => { + queueMicrotask(() => this.emit("exit", null, "SIGKILL")); + return true; + }); +} + +async function session(device: DeviceInfo) { + return nativeProfilerSessionBlueprint.factory({}, device, { device } as never); +} + +beforeEach(() => { + adbShellMock.mockClear(); + startPerfettoMock.mockReset(); +}); + +describe("a teardown inside the native-profiler start window", () => { + it("iOS: SIGKILLs a child the start handed over before declaring the run active", async () => { + const instance = await session(iosDevice); + const api = instance.api as NativeProfilerSessionApi; + // Exactly what `attemptStart` leaves behind while it awaits readiness. + const child = new FakeChild(); + api.captureProcess = child as unknown as ChildProcess; + api.capturePid = 4242; + expect(api.profilingActive).toBe(false); + + await instance.dispose(); + + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + expect(api.captureProcess).toBeNull(); + }); + + it("marks the session disposed, so a resuming start can see it is gone", async () => { + const instance = await session(iosDevice); + const api = instance.api as NativeProfilerSessionApi; + expect(api.disposed).toBe(false); + + await instance.dispose(); + + expect(api.disposed).toBe(true); + }); + + it("Android: fails the start instead of reporting a recording nothing can stop", async () => { + const instance = await session(androidDevice); + const api = instance.api as NativeProfilerSessionApi; + + // The teardown lands while perfetto is still coming up. + startPerfettoMock.mockImplementation(async () => { + await instance.dispose(); + return { + pid: 9001, + onDeviceTracePath: "/data/misc/perfetto-traces/fake.pftrace", + child: new FakeChild() as unknown as ChildProcess, + }; + }); + + const err = await startNativeProfilerAndroid(api, { device_id: androidDevice.id }).catch( + (e: unknown) => e + ); + + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.NATIVE_PROFILER_SESSION_TORN_DOWN); + expect((err as Error).message).toContain("torn down by a stop-all-simulator-servers"); + // The session state must stay clean — a `status: "recording"` was the bug. + expect(api.profilingActive).toBe(false); + expect(api.recordingTimeout).toBeNull(); + // And the daemon this attempt spawned is this attempt's to reap: the + // teardown never saw it, because `capturePid` is handed over after the await. + expect(adbShellMock).toHaveBeenCalledWith(androidDevice.id, "kill -KILL 9001"); + expect(adbShellMock).toHaveBeenCalledWith( + androidDevice.id, + "rm -f /data/misc/perfetto-traces/fake.pftrace" + ); + }); + + it("Android: an undisturbed start still reports the recording", async () => { + // The control — the guard must not fire on the ordinary path. + const instance = await session(androidDevice); + const api = instance.api as NativeProfilerSessionApi; + startPerfettoMock.mockResolvedValue({ + pid: 9002, + onDeviceTracePath: "/data/misc/perfetto-traces/real.pftrace", + child: new FakeChild() as unknown as ChildProcess, + }); + + const result = await startNativeProfilerAndroid(api, { device_id: androidDevice.id }); + + expect(result.status).toBe("recording"); + expect(api.profilingActive).toBe(true); + await instance.dispose(); + }); +}); diff --git a/packages/tool-server/test/react-profiler/session-dispose.test.ts b/packages/tool-server/test/react-profiler/session-dispose.test.ts new file mode 100644 index 000000000..4cc0c0c71 --- /dev/null +++ b/packages/tool-server/test/react-profiler/session-dispose.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { TypedEventEmitter } from "@argent/registry"; +import { reactProfilerSessionBlueprint } from "../../src/blueprints/react-profiler-session"; +import type { JsRuntimeDebuggerApi } from "../../src/blueprints/js-runtime-debugger"; +import { STOP_FOR_TAKEOVER_SCRIPT } from "../../src/utils/react-profiler/scripts"; + +/** + * What `ReactProfilerSession.dispose()` leaves behind IN THE APP. + * + * `react-profiler-stop` was once the only route to a dispose, so dispose could + * assume the run had already been stopped. Since `ReactProfilerSession` joined + * `stop-all-simulator-servers`' namespace set that is no longer true: a + * teardown disposes it mid-run, and an in-app React DevTools backend nobody + * stopped keeps recording every commit into a buffer only an app or bundle + * reload frees — outliving the argent session, inside the user's app, while + * the teardown reports the session as stopped. + */ + +interface SentCall { + method: string; + params?: Record; +} + +function fakeDebuggerApi(sent: SentCall[]): JsRuntimeDebuggerApi { + // The CDP event map is not exported; nothing here subscribes, the emitter only + // has to exist for the factory's `cdp.events.on(...)` calls. + const events = new TypedEventEmitter void>>(); + const cdp = { + events, + send: async (method: string, params?: Record) => { + sent.push({ method, params }); + return {}; + }, + // The factory's own probes: architecture flags, then the Hermes version. + evaluate: async (expression: string) => { + if (expression.includes("RN$Bridgeless")) { + return JSON.stringify({ bridgeless: true, turboModules: true, fabric: true }); + } + if (expression.includes("HermesInternal")) { + return JSON.stringify({ "OSS Release Version": "0.12.0" }); + } + return undefined; + }, + isConnected: () => true, + }; + return { + port: 8081, + projectRoot: "/tmp/app", + deviceName: "iPhone 16 Pro", + appName: "Bluesky", + logicalDeviceId: undefined, + isNewDebugger: true, + cdp, + } as unknown as JsRuntimeDebuggerApi; +} + +async function makeSession(sent: SentCall[]) { + return reactProfilerSessionBlueprint.factory( + { debugger: fakeDebuggerApi(sent) }, + "8081:AAAA-1111", + undefined + ); +} + +describe("ReactProfilerSession dispose", () => { + it("stops the in-app backend and the Hermes sampler when a run is still active", async () => { + const sent: SentCall[] = []; + const instance = await makeSession(sent); + instance.api.profilingActive = true; + sent.length = 0; + + await instance.dispose(); + + const takeover = sent.find( + (c) => c.method === "Runtime.evaluate" && c.params?.expression === STOP_FOR_TAKEOVER_SCRIPT + ); + expect(takeover, "the renderers must be told to stop profiling").toBeDefined(); + expect(sent.map((c) => c.method)).toEqual([ + "Runtime.evaluate", + "Profiler.stop", + "Profiler.disable", + ]); + // Nothing can reach the session after this, so the flag must not outlive it + // and read as a run still in progress. + expect(instance.api.profilingActive).toBe(false); + }); + + it("only disables the domain when the run already ended", async () => { + // The `react-profiler-stop` path: that tool clears `profilingActive`, sends + // `Profiler.stop` and runs the stop-and-read script itself. Re-stopping + // here would send a second `Profiler.stop` against an un-started sampler. + const sent: SentCall[] = []; + const instance = await makeSession(sent); + expect(instance.api.profilingActive).toBe(false); + sent.length = 0; + + await instance.dispose(); + + expect(sent.map((c) => c.method)).toEqual(["Profiler.disable"]); + }); + + it("still disables the domain when the in-app stop throws", async () => { + const sent: SentCall[] = []; + const api = fakeDebuggerApi(sent); + const cdp = api.cdp as unknown as { send: (m: string, p?: unknown) => Promise }; + const instance = await reactProfilerSessionBlueprint.factory( + { debugger: api }, + "8081:AAAA-1111", + undefined + ); + instance.api.profilingActive = true; + sent.length = 0; + cdp.send = async (method: string) => { + sent.push({ method }); + if (method !== "Profiler.disable") throw new Error("CDP went away mid-teardown"); + return {}; + }; + + await expect(instance.dispose()).resolves.toBeUndefined(); + expect(sent.map((c) => c.method)).toEqual([ + "Runtime.evaluate", + "Profiler.stop", + "Profiler.disable", + ]); + }); +}); diff --git a/packages/tool-server/test/react-profiler/session-owner.test.ts b/packages/tool-server/test/react-profiler/session-owner.test.ts index 25362ab53..4b6da459f 100644 --- a/packages/tool-server/test/react-profiler/session-owner.test.ts +++ b/packages/tool-server/test/react-profiler/session-owner.test.ts @@ -4,7 +4,11 @@ import { DEFAULT_STALE_THRESHOLD_MS, type ProfilerSessionOwner, } from "../../src/utils/react-profiler/session-ownership"; -import { flattenProfilingData } from "../../src/tools/profiler/react/react-profiler-stop"; +import { + flattenProfilingData, + createReactProfilerStopTool, +} from "../../src/tools/profiler/react/react-profiler-stop"; +import { FAILURE_CODES, getFailureSignal, type Registry } from "@argent/registry"; import { buildHotCommitSummaries } from "../../src/utils/react-profiler/pipeline/00-hot-commits"; import type { DevToolsFiberCommit, @@ -359,3 +363,37 @@ describe("buildHotCommitSummaries (unattributed threading)", () => { expect(summaries[0]!.unattributedFiberCount).toBeUndefined(); }); }); + +// ── The absent-session message ──────────────────────────────────────── + +/** + * A react-profiler session rides on the device's JS-runtime debugger, which + * `stop-all-simulator-servers` reaps — so a teardown (commonly another agent's, + * since one tool-server serves every agent using an install) is a live cause of + * "no active profiling session", alongside the Metro reload that used to be the + * only one named. Nothing pinned the wording, so reverting it left the whole + * react-profiler suite green. + */ +describe("react-profiler-stop with no live session", () => { + it("names the teardown as a cause, not just a Metro reload", async () => { + const registry = { + getSnapshot: () => ({ services: new Map(), namespaces: [], tools: [] }), + resolveService: async () => { + throw new Error("must not resolve"); + }, + } as unknown as Registry; + + const err = await createReactProfilerStopTool(registry).execute!( + {}, + { port: 8081, device_id: "emulator-5554" } + ).catch((e: unknown) => e); + + const message = (err as Error).message; + expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.REACT_PROFILER_NO_ACTIVE_SESSION); + expect(message).toContain("stop-all-simulator-servers"); + expect(message).toContain("JS-runtime debugger"); + // The pre-existing cause and the recovery both survive. + expect(message).toContain("Metro reload"); + expect(message).toContain("Call react-profiler-start"); + }); +}); diff --git a/packages/tool-server/test/react-profiler/status-ownership.test.ts b/packages/tool-server/test/react-profiler/status-ownership.test.ts index db92d1b45..bb324366e 100644 --- a/packages/tool-server/test/react-profiler/status-ownership.test.ts +++ b/packages/tool-server/test/react-profiler/status-ownership.test.ts @@ -121,6 +121,31 @@ describe("react-profiler-status: server-side ownership", () => { expect(res.current_session_id).toBe("uuid-stranger"); }); + it("names a teardown among the causes of a taken-over session", async () => { + // A react-profiler session rides on the device's JS-runtime debugger, which + // `stop-all-simulator-servers` reaps — and one tool-server serves every + // agent using this install, so the takeover is commonly another agent's + // teardown rather than a second tool-server or a restart. The note is the + // only place an agent learns that; reverting it to the previous wording + // left every react-profiler test green. + const api = buildApi({ + sessionId: "uuid-mine", + state: { + hookExists: true, + rendererInterfaceFound: true, + isRunning: true, + owner: buildOwner("uuid-stranger"), + }, + }); + const res = await runStatus(api); + expect(res.session_status).toBe("taken_over"); + expect(res.note).toContain("stop-all-simulator-servers"); + expect(res.note).toContain("JS-runtime debugger"); + // The pre-existing causes are still offered, and so is the way out. + expect(res.note).toContain("another tool-server instance took over"); + expect(res.note).toContain("force: true"); + }); + it("returns 'stopped' when no session is running, regardless of api.sessionId", async () => { const api = buildApi({ sessionId: "uuid-mine", diff --git a/packages/tool-server/test/reaped-sessions.test.ts b/packages/tool-server/test/reaped-sessions.test.ts new file mode 100644 index 000000000..e3b0b96d9 --- /dev/null +++ b/packages/tool-server/test/reaped-sessions.test.ts @@ -0,0 +1,95 @@ +/** + * The breadcrumb store's key semantics. Three tools read it — screen-recording + * stop, native-profiler stop, debugger-log-registry — and each was tested only + * against its own kind and its own single spelling, so nothing pinned what the + * key itself does: scope by kind, and fold case the way every device-id lookup + * in the stop tools does. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { + recordReapedSession, + takeReapedSession, + describeReapedSession, + __resetReapedSessionsForTesting, +} from "../src/utils/reaped-sessions"; + +const UDID = "6DBF83B4-0000-0000-0000-000000000000"; + +beforeEach(() => { + __resetReapedSessionsForTesting(); +}); + +describe("the reaped-session key", () => { + it("scopes by kind, so one device's three captures do not collide", () => { + // A teardown reaps all three of a device's capture services at once, and + // each owner reads back separately. An unscoped key would let the + // screen-recording read consume the profiler's explanation. + recordReapedSession("screen-recording", UDID, "the video"); + recordReapedSession("native-profiler", UDID, "the trace"); + recordReapedSession("js-runtime-debugger", UDID, "the console log"); + + expect(takeReapedSession("screen-recording", UDID)?.salvage).toBe("the video"); + // …and taking one leaves the other two intact. + expect(takeReapedSession("native-profiler", UDID)?.salvage).toBe("the trace"); + expect(takeReapedSession("js-runtime-debugger", UDID)?.salvage).toBe("the console log"); + }); + + it("folds case, so a device read back in another spelling still finds it", () => { + // Device ids reach the two sides from different places — an iOS UDID comes + // back uppercase from simctl and lowercase from some tool args — and every + // id lookup in the stop tools already compares case-insensitively. A + // case-sensitive key here would silently strand the explanation. + recordReapedSession("native-profiler", UDID.toUpperCase(), "the trace"); + + expect(takeReapedSession("native-profiler", UDID.toLowerCase())).toBeDefined(); + // Consumed once, whichever spelling asked. + expect(takeReapedSession("native-profiler", UDID.toUpperCase())).toBeUndefined(); + }); + + it("reports the device id in the spelling the DISPOSER used, not the reader's", () => { + // The message names the device; it must name the one the teardown actually + // reaped rather than echoing back whatever the reader happened to type. + recordReapedSession("screen-recording", UDID.toUpperCase()); + + const entry = takeReapedSession("screen-recording", UDID.toLowerCase())!; + expect(describeReapedSession(entry, "screen recording")).toContain(UDID.toUpperCase()); + }); + + it("keeps the newest record when one kind+device is reaped twice", () => { + recordReapedSession("screen-recording", UDID, "first"); + recordReapedSession("screen-recording", UDID, "second"); + + expect(takeReapedSession("screen-recording", UDID)?.salvage).toBe("second"); + expect(takeReapedSession("screen-recording", UDID)).toBeUndefined(); + }); + + it("does not pin the teardown on one caller the disposer cannot have seen", () => { + // A blueprint's dispose() is called by Registry._teardown with no caller, so + // nothing that writes a breadcrumb knows which tool triggered it. + // stop-all-simulator-servers is the common one, but stop-simulator-server on + // Chromium cascades into the debugger through ChromiumCdp, and + // react-profiler-start { force: true } disposes it to reclaim the session — + // so the message names the family rather than asserting one member. + recordReapedSession("js-runtime-debugger", UDID); + + const message = describeReapedSession( + takeReapedSession("js-runtime-debugger", UDID)!, + "JS-runtime debugger session" + ); + expect(message).toContain("stop-all-simulator-servers"); + expect(message).toContain("stop-simulator-server on Chromium"); + expect(message).toContain("react-profiler-start"); + // The claim that made it wrong two ways out of three. + expect(message).not.toMatch(/torn down \d+s ago by a stop-all-simulator-servers/); + }); + + it("omits the salvage clause entirely when nothing survived", () => { + recordReapedSession("native-profiler", UDID); + + const entry = takeReapedSession("native-profiler", UDID)!; + expect(entry.salvage).toBeUndefined(); + const message = describeReapedSession(entry, "native profiling session"); + expect(message).toContain("It was not a session that never started."); + expect(message).toMatch(/never started\.$/); + }); +}); diff --git a/packages/tool-server/test/screen-recording.test.ts b/packages/tool-server/test/screen-recording.test.ts index 04a3f0336..17e1fb095 100644 --- a/packages/tool-server/test/screen-recording.test.ts +++ b/packages/tool-server/test/screen-recording.test.ts @@ -46,6 +46,7 @@ import { __resetActiveScreenRecordingsForTesting, getActiveScreenRecordings, } from "../src/utils/screen-recording-reminder"; +import { __resetReapedSessionsForTesting } from "../src/utils/reaped-sessions"; const mockSpawn = vi.mocked(spawn); const mockOpenStream = vi.mocked(openMjpegStream); @@ -192,6 +193,7 @@ const androidDevice: DeviceInfo = { beforeEach(() => { __resetActiveScreenRecordingsForTesting(); + __resetReapedSessionsForTesting(); mockSpawn.mockReset(); mockOpenStream.mockReset(); mockResolveFfmpeg.mockReset(); @@ -277,6 +279,144 @@ describe("screen-recording session blueprint", () => { await expect(fs.access(logo)).rejects.toThrow(); expect(api.logoFile).toBeNull(); }); + + describe("a capture reaped by stop-all-simulator-servers", () => { + // The teardown sequence from the review: start a recording, let + // `stop-all-simulator-servers` reap the device (which is what disposes this + // service), then call `screen-recording-stop`. `Registry._teardown` nulls + // the node's instance, so that stop resolves a BRAND NEW session — modelled + // here by building a second one for the same device. + async function reapDuringCapture(): Promise<{ + output: string; + fresh: ScreenRecordingSessionApi; + }> { + const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, { + device: iosDevice, + } as never); + fakeStream(); + fakeChild().exitOnStdinEnd(); + await startAndSettle(instance.api); + const output = instance.api.outputFile!; + + await instance.dispose(); + + return { output, fresh: await makeSession(iosDevice) }; + } + + it("tells the owner the recording was torn down, and where the video landed", async () => { + const { output, fresh } = await reapDuringCapture(); + + const err = await stopCapture(fresh).catch((e: unknown) => e); + + const message = (err as Error).message; + // The bug: this used to be "No active screen recording … Call + // `screen-recording-start` first." while a finalized video sat on disk. + expect(message).not.toMatch(/Call `screen-recording-start` first/); + expect(message).toContain("torn down"); + expect(message).toContain("stop-all-simulator-servers"); + // Nothing else in the process still knows this path exists. + expect(message).toContain(output); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN + ); + }); + + it("tells the owner when the teardown hit a CAPPED capture awaiting retrieval", async () => { + // `hadUnretrievedCapture` has three arms and only `recordingActive` was + // covered. This is the likeliest real sequence of the three: the time + // limit fires, the video is finalized and waiting to be handed over, and + // the teardown lands in that window. The caller is owed a video just as + // much as in the mid-capture case. + const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, { + device: iosDevice, + } as never); + fakeStream(); + fakeChild().exitOnStdinEnd(); + await startAndSettle(instance.api, { timeLimitSeconds: 5 }); + await vi.advanceTimersByTimeAsync(5_000); + expect(instance.api.recordingActive).toBe(false); + expect(instance.api.pendingRetrieval).toBe(true); + const output = instance.api.outputFile!; + + await instance.dispose(); + const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e); + + const message = (err as Error).message; + expect(message).not.toMatch(/Call `screen-recording-start` first/); + expect(message).toContain("torn down"); + expect(message).toContain(output); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN + ); + }); + + it("tells the owner when the teardown hit a start still mid-readiness", async () => { + // The third arm. `startPending` is set synchronously before start's first + // await, so a teardown here destroys a capture whose child may already be + // spawned — reported as a teardown, not as "you never started one". + const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, { + device: iosDevice, + } as never); + fakeStream(); + fakeChild(); + const pending = startCapture(instance.api, { + streamUrl: STREAM_URL, + timeLimitSeconds: 180, + watermark: false, + trimStatic: false, + }); + pending.catch(() => {}); + expect(instance.api.startPending).toBe(true); + + await instance.dispose(); + await pending.catch(() => {}); + const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e); + + expect((err as Error).message).not.toMatch(/Call `screen-recording-start` first/); + expect((err as Error).message).toContain("torn down"); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN + ); + }); + + it("still reports a plain absence when no capture was reaped", async () => { + // The breadcrumb must not turn every "you never started one" into an + // accusation: disposing an idle session leaves nothing behind. + const instance = await screenRecordingSessionBlueprint.factory({}, iosDevice, { + device: iosDevice, + } as never); + await instance.dispose(); + + const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e); + + expect((err as Error).message).toContain("No active screen recording"); + expect(getFailureSignal(err)?.error_code).toBe( + FAILURE_CODES.SCREEN_RECORDING_NO_ACTIVE_SESSION + ); + }); + + it("is consumed once, so it cannot blame a later unrelated absence", async () => { + const { fresh } = await reapDuringCapture(); + await stopCapture(fresh).catch(() => {}); + + const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e); + + expect((err as Error).message).toContain("No active screen recording"); + }); + + it("is dropped by a new recording, which would otherwise never consume it", async () => { + const { fresh } = await reapDuringCapture(); + fakeStream(); + fakeChild().exitOnStdinEnd(); + await startAndSettle(fresh); + await fs.writeFile(fresh.outputFile!, Buffer.alloc(16, 1)); + await stopCapture(fresh); + + const err = await stopCapture(await makeSession(iosDevice)).catch((e: unknown) => e); + + expect((err as Error).message).toContain("No active screen recording"); + }); + }); }); describe("screen recording capture", () => { @@ -534,6 +674,19 @@ describe("screen recording capture", () => { expect(getFailureSignal(err)?.error_code).toBe( FAILURE_CODES.SCREEN_RECORDING_SERVER_SHUTTING_DOWN ); + // The error CODE is not the behaviour here — its enum name still says + // "shutting down", which is exactly the claim the message stopped making. + // A dispose is now far more often a `stop-all-simulator-servers` reaping + // this device than a process shutdown, and the two are indistinguishable + // from `api.disposed`. So the message must name both, and must not tell + // the caller a retry is pointless: on the teardown branch the device is + // usually still up. Asserted here because reverting the whole rewrite to + // the old one-liner otherwise leaves the suite green. + const message = (err as Error).message; + expect(message).toContain("stop-all-simulator-servers"); + expect(message).toContain("nothing was recorded"); + expect(message).toContain("start the recording again"); + expect(message).toContain(IOS_UDID); } expect(mockSpawn).not.toHaveBeenCalled(); expect(stream.close).toHaveBeenCalled(); diff --git a/packages/tool-server/test/stop-tools.test.ts b/packages/tool-server/test/stop-tools.test.ts index 303c37e2b..9e6a9c3bf 100644 --- a/packages/tool-server/test/stop-tools.test.ts +++ b/packages/tool-server/test/stop-tools.test.ts @@ -1,17 +1,50 @@ -import { describe, it, expect, vi } from "vitest"; -import { Registry, ServiceState } from "@argent/registry"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + forgetLogicalKeyedDevice, + rememberLogicalKeyedDevice, + resetDeviceAliases, +} from "../src/utils/debugger/device-alias"; +import type { z } from "zod"; +import { Registry, ServiceState, zodObjectToJsonSchema } from "@argent/registry"; import { createStopSimulatorServerTool } from "../src/tools/simulator/stop-simulator-server"; import { createStopAllSimulatorServersTool } from "../src/tools/simulator/stop-all-simulator-servers"; import { stopMetroTool } from "../src/tools/simulator/stop-metro"; function createMockRegistry(services: Map) { return { + // The real `getSnapshot` COPIES each node into a fresh map + // (Registry.getSnapshot), so a disposal during the sweep cannot rewrite the + // state the caller is still iterating. Handing over the live map instead + // would make a cascade retroactively hide its own victim, and the result + // would depend on the map's insertion order — an artifact of the mock that + // production does not have. getSnapshot: vi.fn(() => ({ - services, + services: new Map( + [...services].map(([urn, n]) => [urn, { ...n, dependents: [...n.dependents] }]) + ), namespaces: [], tools: [], })), - disposeService: vi.fn(async () => {}), + // The real `disposeService` returns the node to IDLE and LEAVES IT IN the + // map (Registry._teardown), rather than removing it — so a second stop of + // the same device still sees its URNs, in IDLE. Mirror that here: a mock + // that forgets disposed nodes would hide exactly the sequence the + // stop-one-then-stop-the-rest tests below exist to pin. + disposeService: vi.fn(async function dispose(urn: string) { + const node = services.get(urn); + // `Registry._teardown` early-returns for IDLE **and TERMINATING** — a node + // already being torn down is not disposed a second time. The mock used to + // recurse into a TERMINATING node, which production never does. + if (!node || node.state === ServiceState.IDLE || node.state === ServiceState.TERMINATING) + return; + // …and it recurses into dependents BEFORE clearing the node + // (Registry._teardown), so a service whose dependency is disposed goes + // down with it. Mirror that too, or a test cannot tell a namespace this + // tool reaps by name from one that merely dies as somebody else's + // dependent. Mark first, so a dependency cycle cannot recurse forever. + node.state = ServiceState.IDLE; + for (const dependent of node.dependents) await dispose(dependent); + }), } as unknown as Registry; } @@ -119,6 +152,10 @@ describe("stop-simulator-server", () => { it("does not target TvControl for a chromium id", async () => { const services = new Map([ ["ChromiumCdp:chromium-cdp-9222", { state: ServiceState.RUNNING, dependents: [] }], + // The negative control has to BE in the map. Without it, "disposed once" + // is satisfied by the single entry present and the chromium branch could + // return TvControl too without failing anything. + ["TvControl:chromium-cdp-9222", { state: ServiceState.RUNNING, dependents: [] }], ]); const registry = createMockRegistry(services); const tool = createStopSimulatorServerTool(registry); @@ -129,6 +166,107 @@ describe("stop-simulator-server", () => { expect(registry.disposeService).toHaveBeenCalledOnce(); expect(registry.disposeService).toHaveBeenCalledWith("ChromiumCdp:chromium-cdp-9222"); }); + + it("names the device and the error code in failedMsg", () => { + // The one formatter with no coverage — flattening it to a constant left the + // suite green, and it is the line an agent reads when a teardown fails. + const tool = createStopSimulatorServerTool(createMockRegistry(new Map())); + expect( + tool.interaction!.failedMsg!({ + params: { udid: "AAAA-BBBB" }, + failureSignal: { error_code: "REGISTRY_TOOL_EXECUTION_FAILED" }, + } as never) + ).toBe("Failed to stop simulator server for AAAA-BBBB: REGISTRY_TOOL_EXECUTION_FAILED"); + }); + + // Both stop tools resolve "which services does this device own" through the + // one shared matcher in device-services.ts, so a given udid — whatever its + // case — reaches the same services through either. Case-insensitivity is the + // property that matters here: an exact `services.get()` would no-op on a + // mis-cased udid, leaving a device the caller believes it stopped still + // running while the scoped stop-all (which folds case) reaps it. + + it("matches a UDID case-insensitively, like the scoped stop-all does", async () => { + // Agents pass through whatever spelling they were handed, and a case + // mismatch must not silently turn a scoped stop into a no-op. + const services = new Map([ + ["SimulatorServer:AAAA-BBBB", { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopSimulatorServerTool(registry); + + const result = await tool.execute!({}, { udid: "aaaa-bbbb" }); + + expect(result).toEqual({ stopped: true, udid: "aaaa-bbbb" }); + expect(registry.disposeService).toHaveBeenCalledWith("SimulatorServer:AAAA-BBBB"); + }); + + it("does not let a bare IP claim every wireless-adb device at that address", async () => { + // An adb serial over wifi is itself `ip:port`, so the shared matcher must + // compare the whole tail rather than splitting on ":". + const services = new Map([ + ["SimulatorServer:192.168.1.5:5555", { state: ServiceState.RUNNING, dependents: [] }], + ["SimulatorServer:192.168.1.5:5557", { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopSimulatorServerTool(registry); + + const result = await tool.execute!({}, { udid: "192.168.1.5" }); + + expect(result).toEqual({ stopped: false, udid: "192.168.1.5" }); + expect(registry.disposeService).not.toHaveBeenCalled(); + }); + + it("leaves this device's devtools and AX services alone", async () => { + // Deliberately narrower than stop-all: this tool is also the documented + // recovery for a wedged transport, and dropping native-devtools on a retry + // would degrade another agent's in-progress recording to coordinate taps. + // + // The udid must be a REAL iOS UUID. `classifyDevice` only recognizes the + // 8-4-4-4-12 hex shape, so a short id like "AAAA-BBBB" classifies as + // android — and NativeDevtools/AXService, which are iOS-only, would never + // be candidates for it under any implementation. This test would then pass + // even if the iOS branch were widened to include them, which is the exact + // regression it exists to catch. + const udid = "00000000-0000-0000-0000-0000000000ab"; + const services = new Map([ + [`SimulatorServer:${udid}`, { state: ServiceState.RUNNING, dependents: [] }], + [`NativeDevtools:${udid}`, { state: ServiceState.RUNNING, dependents: [] }], + [`AXService:${udid}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopSimulatorServerTool(registry); + + const result = await tool.execute!({}, { udid }); + + expect(result).toEqual({ stopped: true, udid }); + expect(registry.disposeService).toHaveBeenCalledOnce(); + expect(registry.disposeService).toHaveBeenCalledWith(`SimulatorServer:${udid}`); + }); + + it("leaves an android device's devtools service alone", async () => { + // The android twin of the iOS narrowness case above, and the branch the + // rationale in device-services.ts covers but no prior test did. + // stop-simulator-server is the wedged-transport recovery, and + // AndroidDevtools is the tree source an Android recording's selector capture + // runs on — dropping it on a retry degrades another agent's flow to + // coordinate taps, exactly what the narrow set exists to prevent. An + // `emulator-N` serial classifies as android, so widening the android branch + // to include AndroidDevtools would dispose it here and fail this case. + const serial = "emulator-5554"; + const services = new Map([ + [`SimulatorServer:${serial}`, { state: ServiceState.RUNNING, dependents: [] }], + [`AndroidDevtools:${serial}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopSimulatorServerTool(registry); + + const result = await tool.execute!({}, { udid: serial }); + + expect(result).toEqual({ stopped: true, udid: serial }); + expect(registry.disposeService).toHaveBeenCalledOnce(); + expect(registry.disposeService).toHaveBeenCalledWith(`SimulatorServer:${serial}`); + }); }); describe("stop-all-simulator-servers", () => { @@ -136,27 +274,151 @@ describe("stop-all-simulator-servers", () => { const services = new Map([ ["SimulatorServer:AAA", { state: ServiceState.RUNNING, dependents: [] }], ["SimulatorServer:BBB", { state: ServiceState.RUNNING, dependents: [] }], - ["JsRuntimeDebugger:CCC", { state: ServiceState.RUNNING, dependents: [] }], + // A device-owned namespace like any other: a session that ran + // debugger-connect against a Chromium app owns it, and the sweep drains + // it whether or not its transport happens to be in the same snapshot. + ["ChromiumJsRuntimeDebugger:CCC", { state: ServiceState.RUNNING, dependents: [] }], ]); const registry = createMockRegistry(services); const tool = createStopAllSimulatorServersTool(registry); - const result = await tool.execute!({}, undefined); + const result = await tool.execute!({}, {}); expect(result).toEqual({ - stopped: ["SimulatorServer:AAA", "SimulatorServer:BBB"], + stopped: ["SimulatorServer:AAA", "SimulatorServer:BBB", "ChromiumJsRuntimeDebugger:CCC"], }); - expect(registry.disposeService).toHaveBeenCalledTimes(2); + expect(registry.disposeService).toHaveBeenCalledTimes(3); expect(registry.disposeService).toHaveBeenCalledWith("SimulatorServer:AAA"); expect(registry.disposeService).toHaveBeenCalledWith("SimulatorServer:BBB"); }); + it("leaves a service whose namespace is not device-owned untouched", async () => { + // The negative control for the unscoped sweep's namespace filter. Every + // blueprint registered today is device-owned, so nothing real is left out — + // but `isDeviceServiceUrn` is the only guard between this machine-wide stop + // (the session-end call every agent makes) and any future non-device + // service, or a namespace added to the list by mistake. A synthetic + // out-of-set URN pins that the sweep is namespace-scoped, not "dispose + // everything": degrade `isDeviceServiceUrn` to `return true` and this fails. + const services = new Map([ + ["SimulatorServer:AAA", { state: ServiceState.RUNNING, dependents: [] }], + ["NotADeviceService:global-singleton", { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, {}); + + expect(result).toEqual({ stopped: ["SimulatorServer:AAA"] }); + expect(registry.disposeService).toHaveBeenCalledOnce(); + expect(registry.disposeService).toHaveBeenCalledWith("SimulatorServer:AAA"); + expect(registry.disposeService).not.toHaveBeenCalledWith("NotADeviceService:global-singleton"); + }); + + // `stopped` is documented as "the services that were actually live and got + // shut down". ChromiumJsRuntimeDebugger declares `getDependencies -> + // ChromiumCdp`, so disposing the transport takes it down regardless — while + // it was outside the namespace set, that shutdown was invisible, and an agent + // reading `stopped` was not told its console history was gone. + // + // Run under both map orders. The registry usually inserts a dependent before + // its dependency (`_resolve` creates the node, then `_initialize` resolves + // what it needs), but a session that booted and described before attaching + // the debugger inserts `ChromiumCdp` first — and since `getSnapshot` copies, + // the answer must not depend on which happened. + const CDP = "ChromiumCdp:chromium-cdp-9222"; + const CHROMIUM_DEBUGGER = "ChromiumJsRuntimeDebugger:chromium-cdp-9222"; + /** A second Electron instance, belonging to somebody else. */ + const OTHER_CDP = "ChromiumCdp:chromium-cdp-9333"; + const live = () => ({ state: ServiceState.RUNNING, dependents: [] as string[] }); + const cdpWithDependent = () => ({ + state: ServiceState.RUNNING, + dependents: [CHROMIUM_DEBUGGER], + }); + + it.each([ + ["debugger-connect first (dependent inserted first)", [CHROMIUM_DEBUGGER, CDP]], + ["boot/describe first (dependency inserted first)", [CDP, CHROMIUM_DEBUGGER]], + ])( + "names a chromium debugger in `stopped` whichever order it was inserted — %s", + async (_label, order) => { + // Both URNs carry the device id, so each is matched DIRECTLY; the + // cascade is incidental here and this case is about insertion order not + // changing membership. What the cascade alone decides is pinned below. + // + // A second chromium instance is the control: with only the target's URNs + // in the snapshot an always-match matcher passes this case, and + // `ChromiumJsRuntimeDebugger` is a namespace nothing else here scopes. + const services = new Map([ + ...order.map((urn) => [urn, urn === CDP ? cdpWithDependent() : live()] as const), + [OTHER_CDP, live()] as const, + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: ["chromium-cdp-9222"] }); + + // Order follows the snapshot; membership must not. + expect((result as { stopped: string[] }).stopped.slice().sort()).toEqual( + [CDP, CHROMIUM_DEBUGGER].sort() + ); + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).not.toHaveBeenCalledWith(OTHER_CDP); + } + ); + + it("does not credit `stopped` with a dependent that was already IDLE", async () => { + // The distinction the case above cannot make. `ChromiumJsRuntimeDebugger` + // declares `ChromiumCdp` as its dependency, so the transport's teardown + // takes it down as a dependent — but it was already IDLE, so it was not a + // running service this call shut down and must not be named. `stopped` + // reports what this teardown found LIVE, not everything the graph touched; + // naming it would tell an agent a session it had already stopped was still + // up a moment ago. + // + // The earlier version of this case fabricated a `Metro:8081` node to stand + // in for a non-device dependent. There is no such thing: every namespace a + // blueprint declares as a dependency is itself in DEVICE_OWNED_NAMESPACES, + // and `Metro:8081` is not a registry namespace at all — so what it asserted + // (`services.get(METRO)?.state`) was the mock's own recursion, which no + // production line reads. + const services = new Map([ + [CDP, { state: ServiceState.RUNNING, dependents: [CHROMIUM_DEBUGGER] }], + [CHROMIUM_DEBUGGER, { state: ServiceState.IDLE, dependents: [] as string[] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: ["chromium-cdp-9222"] }); + + expect(result).toEqual({ stopped: [CDP] }); + // Matched, so not a mistyped id — the device owns both URNs either way. + expect(result).not.toHaveProperty("unmatched"); + }); + + it("disposes a TERMINATING node without reporting it as stopped", async () => { + // A node already being torn down is not live, so `isLiveServiceState` keeps + // it out of `stopped` — but it is not IDLE either, so the sweep still calls + // `disposeService` on it (which the real `_teardown` then no-ops). Both + // halves are production lines; neither had coverage. + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.TERMINATING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [] }); + expect(registry.disposeService).toHaveBeenCalledWith(`SimulatorServer:${MINE}`); + }); + it("returns empty list when no simulators are running", async () => { const services = new Map(); const registry = createMockRegistry(services); const tool = createStopAllSimulatorServersTool(registry); - const result = await tool.execute!({}, undefined); + const result = await tool.execute!({}, {}); expect(result).toEqual({ stopped: [] }); expect(registry.disposeService).not.toHaveBeenCalled(); @@ -170,7 +432,7 @@ describe("stop-all-simulator-servers", () => { const registry = createMockRegistry(services); const tool = createStopAllSimulatorServersTool(registry); - const result = await tool.execute!({}, undefined); + const result = await tool.execute!({}, {}); expect(result).toEqual({ stopped: ["SimulatorServer:BBB"] }); expect(registry.disposeService).toHaveBeenCalledOnce(); @@ -184,7 +446,7 @@ describe("stop-all-simulator-servers", () => { const registry = createMockRegistry(services); const tool = createStopAllSimulatorServersTool(registry); - const result = await tool.execute!({}, undefined); + const result = await tool.execute!({}, {}); // Both get disposed (cleanup), but only the live one is reported as stopped. expect(result).toEqual({ stopped: ["SimulatorServer:BBB"] }); @@ -193,6 +455,27 @@ describe("stop-all-simulator-servers", () => { expect(registry.disposeService).toHaveBeenCalledTimes(2); }); + it("reports a STARTING node as stopped and a TERMINATING one as not, in the sweep", async () => { + // `wasLive` is `isLiveServiceState` — RUNNING or STARTING. The sweep's use + // of it was only ever exercised for RUNNING and ERROR; STARTING (a server + // mid-boot, which really is being killed) and TERMINATING (already on its + // way down, so nothing here stopped it) are the two arms that decide + // whether a caller is told their device was reaped. + const services = new Map([ + ["SimulatorServer:STARTING-ONE", { state: ServiceState.STARTING, dependents: [] }], + ["SimulatorServer:TERMINATING-ONE", { state: ServiceState.TERMINATING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, {}); + + expect(result).toEqual({ stopped: ["SimulatorServer:STARTING-ONE"] }); + // Both are disposed — the point is what gets REPORTED, not what gets cleaned. + expect(registry.disposeService).toHaveBeenCalledWith("SimulatorServer:TERMINATING-ONE"); + expect(registry.disposeService).toHaveBeenCalledTimes(2); + }); + it("stops the focus-driven TV control services (Apple TV + Android TV)", async () => { // The TvControl daemon owns the spawned tvos-ax / tvos-hid processes, so a // session-end stop must dispose it — not just the simulator-server/CDP nodes. @@ -204,7 +487,7 @@ describe("stop-all-simulator-servers", () => { const registry = createMockRegistry(services); const tool = createStopAllSimulatorServersTool(registry); - const result = await tool.execute!({}, undefined); + const result = await tool.execute!({}, {}); expect(result).toEqual({ stopped: ["TvControl:APPLE-TV", "AndroidTvControl:emulator-5556", "SimulatorServer:BBB"], @@ -215,6 +498,1026 @@ describe("stop-all-simulator-servers", () => { }); }); +// One tool-server serves every agent using one argent install, so an unscoped +// teardown reaps whatever device another agent is mid-session on. `devices` +// narrows the sweep to the ids the calling session actually used. +const MINE = "AAAA-1111"; +const THEIRS = "BBBB-2222"; + +describe("stop-all-simulator-servers device scoping", () => { + function twoAgentServices() { + return new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`NativeDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`SimulatorServer:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + [`NativeDevtools:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ["ChromiumCdp:chromium-cdp-9222", { state: ServiceState.RUNNING, dependents: [] }], + ]); + } + + it("disposes only the named device's URNs and leaves the other device live", async () => { + const registry = createMockRegistry(twoAgentServices()); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ + stopped: [`SimulatorServer:${MINE}`, `NativeDevtools:${MINE}`], + }); + expect(registry.disposeService).toHaveBeenCalledTimes(2); + expect(registry.disposeService).not.toHaveBeenCalledWith(`SimulatorServer:${THEIRS}`); + expect(registry.disposeService).not.toHaveBeenCalledWith(`NativeDevtools:${THEIRS}`); + expect(registry.disposeService).not.toHaveBeenCalledWith("ChromiumCdp:chromium-cdp-9222"); + }); + + it("scopes across platforms when several device ids are named", async () => { + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ["AndroidDevtools:emulator-5554", { state: ServiceState.RUNNING, dependents: [] }], + [`SimulatorServer:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE, "emulator-5554"] }); + + expect(result).toEqual({ + stopped: [`SimulatorServer:${MINE}`, "AndroidDevtools:emulator-5554"], + }); + expect(registry.disposeService).toHaveBeenCalledTimes(2); + }); + + it("still disposes everything when no devices are named", async () => { + const registry = createMockRegistry(twoAgentServices()); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, {}); + + expect(result).toEqual({ + stopped: [ + `SimulatorServer:${MINE}`, + `NativeDevtools:${MINE}`, + `SimulatorServer:${THEIRS}`, + `NativeDevtools:${THEIRS}`, + "ChromiumCdp:chromium-cdp-9222", + ], + }); + expect(registry.disposeService).toHaveBeenCalledTimes(5); + // Nothing was requested, so there is nothing that could have missed. + expect(result).not.toHaveProperty("unmatched"); + }); + + it("scopes the non-simulator namespaces too (ChromiumCdp / TvControl / AndroidTvControl)", async () => { + // Every namespace in DEVICE_OWNED_NAMESPACES must honour `devices`, not just + // SimulatorServer/NativeDevtools: a TvControl daemon left running holds two + // spawned --timeout 3600 processes, and reaping another agent's is exactly + // the cross-session damage scoping exists to prevent. + const chromium = "chromium-cdp-9222"; + const appleTv = "APPLE-TV-UDID"; + const androidTv = "emulator-5556"; + const services = new Map([ + [`ChromiumCdp:${chromium}`, { state: ServiceState.RUNNING, dependents: [] }], + [`TvControl:${appleTv}`, { state: ServiceState.RUNNING, dependents: [] }], + [`AndroidTvControl:${androidTv}`, { state: ServiceState.RUNNING, dependents: [] }], + [`TvControl:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [chromium, appleTv, androidTv] }); + + expect(result).toEqual({ + stopped: [`ChromiumCdp:${chromium}`, `TvControl:${appleTv}`, `AndroidTvControl:${androidTv}`], + }); + expect(registry.disposeService).toHaveBeenCalledTimes(3); + expect(registry.disposeService).not.toHaveBeenCalledWith(`TvControl:${THEIRS}`); + }); + + it("matches a transport-suffixed URN (NativeDevtools::tcp)", async () => { + const services = new Map([ + [`NativeDevtools:${MINE}:tcp`, { state: ServiceState.RUNNING, dependents: [] }], + [`NativeDevtools:${THEIRS}:tcp`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [`NativeDevtools:${MINE}:tcp`] }); + expect(registry.disposeService).toHaveBeenCalledOnce(); + expect(registry.disposeService).toHaveBeenCalledWith(`NativeDevtools:${MINE}:tcp`); + }); + + it("matches a device id that itself contains a colon (wireless adb serial)", async () => { + const wireless = "192.168.1.5:5555"; + const services = new Map([ + [`AndroidDevtools:${wireless}`, { state: ServiceState.RUNNING, dependents: [] }], + [`SimulatorServer:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [wireless] }); + + expect(result).toEqual({ stopped: [`AndroidDevtools:${wireless}`] }); + expect(registry.disposeService).toHaveBeenCalledOnce(); + }); + + it("does not let a bare IP claim every wireless device at that address", async () => { + // An adb serial is `ip:port`, so treating "anything after a colon" as the + // transport discriminator would let a caller who dropped the port tear down + // a second agent's device — and report nothing unmatched while doing it. + const services = new Map([ + ["AndroidDevtools:192.168.1.5:5555", { state: ServiceState.RUNNING, dependents: [] }], + ["SimulatorServer:192.168.1.5:5556", { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: ["192.168.1.5"] }); + + expect(result).toEqual({ stopped: [], unmatched: ["192.168.1.5"] }); + expect(registry.disposeService).not.toHaveBeenCalled(); + }); + + it("matches the device id case-insensitively", async () => { + // iOS UDIDs are conventionally upper-case, but an agent passes through + // whatever it was handed — a case mismatch must not silently no-op. + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`NativeDevtools:${MINE.toLowerCase()}:tcp`, { state: ServiceState.RUNNING, dependents: [] }], + [`SimulatorServer:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + // MINE is upper-cased, and the snapshot pairs it against an upper-cased URN + // (`SimulatorServer:${MINE}`) and a lower-cased one + // (`NativeDevtools:${MINE.toLowerCase()}:tcp`) — so this exercises + // upper-id/upper-URN and upper-id/lower-URN. The reverse direction (a + // lower-cased id against an upper-cased URN) is covered by a separate case + // below; both must match for a case mismatch never to silently no-op. + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ + stopped: [`SimulatorServer:${MINE}`, `NativeDevtools:${MINE.toLowerCase()}:tcp`], + }); + expect(registry.disposeService).not.toHaveBeenCalledWith(`SimulatorServer:${THEIRS}`); + }); + + it("scopes to nothing for devices: [] rather than sweeping the machine", async () => { + // A caller that computed a device list and got none must not fall back to + // tearing down every other agent's services. + const registry = createMockRegistry(twoAgentServices()); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [] }); + + expect(result).toEqual({ stopped: [] }); + // No id was requested, so nothing missed: an empty `unmatched` would read + // as a warning where there is nothing to warn about. + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).not.toHaveBeenCalled(); + }); + + it("rejects a misspelled scope key instead of stripping it into a machine-wide sweep", async () => { + // `udids` is the natural slip: every sibling tool in this directory spells + // the device parameter `udid`. Under a stripping schema it left + // `params.devices` undefined, so the call fell through to the unscoped + // branch and tore down the other agent's devices while the caller believed + // it had scoped — and `unmatched` is unreachable on that path, so nothing + // in the response said otherwise. + const registry = createMockRegistry(twoAgentServices()); + const tool = createStopAllSimulatorServersTool(registry); + + const parsed = tool.zodSchema!.safeParse({ udids: [MINE] }); + + expect(parsed.success).toBe(false); + // The zod parse above is the only gate: MCP, `argent run` and raw HTTP all + // forward the caller's args verbatim (`argent run` accepts unknown flags on + // purpose, see flag-parser.ts) and the tool-server parses them with this + // schema. What the assertion below pins is the ADVERTISED shape, derived + // from `.strict()` by `zodObjectToJsonSchema` — the schema an agent reads + // out of `GET /tools` to learn the key is `devices`. An advertised schema + // still admitting extra keys would document the `udids` typo as legal and + // leave the rejection looking like a server bug. + expect(zodObjectToJsonSchema(tool.zodSchema as z.ZodObject)).toMatchObject({ + additionalProperties: false, + }); + }); + + it("drives the scope through its own schema, not just past it", async () => { + // Every other case here hands `execute` a hand-built params object, so zod + // is never in the loop and the ONLY schema assertion is a negative (the + // `udids` rejection above). That leaves the parse itself unpinned: changing + // + // devices: z.array(z.string()).optional() -> .default([]) + // + // typechecks, keeps all 3255 tests green, and makes `params.devices` always + // `[]` — so `scoped` is permanently true and the machine-wide sweep reaps + // nothing while answering `{ stopped: [] }`, which the tool documents as + // "only means nothing was still running". Parse, then execute what the + // parse produced, on both shapes. + const registry = createMockRegistry(twoAgentServices()); + const tool = createStopAllSimulatorServersTool(registry); + const schema = tool.zodSchema!; + + // A scoped call is accepted and reaches execute as the ids it was given. + expect(schema.safeParse({ devices: [MINE] }).success).toBe(true); + const scoped = await tool.execute!({}, schema.parse({ devices: [MINE] })); + expect(scoped).toEqual({ + stopped: [`SimulatorServer:${MINE}`, `NativeDevtools:${MINE}`], + }); + + // And an omitted scope still parses to "absent" — the machine-wide sweep — + // rather than to an empty list that would scope to nothing. + const swept = createMockRegistry(twoAgentServices()); + const sweepTool = createStopAllSimulatorServersTool(swept); + expect(schema.parse({}).devices).toBeUndefined(); + const unscoped = await sweepTool.execute!({}, schema.parse({})); + expect(unscoped.stopped).toHaveLength(5); + expect(unscoped).not.toHaveProperty("unmatched"); + }); + + it("does not match a device id that is a prefix of another device's id", async () => { + const services = new Map([ + ["SimulatorServer:AAAA", { state: ServiceState.RUNNING, dependents: [] }], + ["SimulatorServer:AAAA-EXTRA", { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: ["AAAA"] }); + + expect(result).toEqual({ stopped: ["SimulatorServer:AAAA"] }); + expect(registry.disposeService).toHaveBeenCalledOnce(); + }); + + it("skips an IDLE service on the named device", async () => { + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.IDLE, dependents: [] }], + [`NativeDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [`NativeDevtools:${MINE}`] }); + expect(registry.disposeService).toHaveBeenCalledOnce(); + }); +}); + +describe("stop-all-simulator-servers unmatched ids", () => { + // Without `unmatched`, a scoped stop whose ids owned nothing answers with a + // bare `{ stopped: [] }` — byte-identical to the answer on a genuinely clean + // machine. A mistyped id, a device *name* passed where an id was expected, or + // an empty string would all read as success while the services they were + // meant to reap (on tvOS, two spawned --timeout 3600 daemons) stayed running. + // `unmatched` names them, so scoping cannot fail silently. + + it("owns no device from a port-keyed URN missing its device half", async () => { + // `:` with nothing after the port is malformed — the device + // portion is what follows the FIRST colon, and there is none. Reading the + // tail as the device id instead would let the literal Metro port `8081` + // claim it, so a `devices: ["8081"]` typo would silently reap a debugger + // session and report a clean scope. + const services = new Map([ + ["JsRuntimeDebugger:8081", { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + expect(await tool.execute!({}, { devices: ["8081"] })).toEqual({ + stopped: [], + unmatched: ["8081"], + }); + expect(registry.disposeService).not.toHaveBeenCalled(); + }); + + it("names an unknown id in unmatched while still stopping the live device", async () => { + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`NativeDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE, "GHOST-9999"] }); + + expect(result).toEqual({ + stopped: [`SimulatorServer:${MINE}`, `NativeDevtools:${MINE}`], + unmatched: ["GHOST-9999"], + }); + expect(registry.disposeService).toHaveBeenCalledTimes(2); + }); + + it("reports a typo, a device name, and an empty-string id — the shapes that would otherwise look clean", async () => { + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const typo = `${MINE}0`; + const deviceName = "iPhone 15 Pro"; + const result = await tool.execute!({}, { devices: [MINE, typo, deviceName, ""] }); + + expect(result).toEqual({ + stopped: [`SimulatorServer:${MINE}`], + unmatched: [typo, deviceName, ""], + }); + }); + + it("omits unmatched entirely when every requested id matched something", async () => { + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ["AndroidDevtools:emulator-5554", { state: ServiceState.RUNNING, dependents: [] }], + [`SimulatorServer:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE, "emulator-5554"] }); + + expect(result).toEqual({ + stopped: [`SimulatorServer:${MINE}`, "AndroidDevtools:emulator-5554"], + }); + // Absent, not an empty array — a clean scoped stop must carry no warning. + expect(result).not.toHaveProperty("unmatched"); + }); + + it("does not report an all-IDLE device as unmatched — it still owns those nodes", async () => { + // `disposeService` returns a node to IDLE without removing it, so this is + // precisely the state a device is left in by a stop THIS session already + // performed. `unmatched` means "this id owns nothing on the machine, look + // for a typo"; saying it about a device we just tore down ourselves is a + // false alarm on the routine stop-one-then-stop-the-rest sequence. + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.IDLE, dependents: [] }], + [`NativeDevtools:${MINE}`, { state: ServiceState.IDLE, dependents: [] }], + [`SimulatorServer:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE, "GHOST-9999"] }); + + // Nothing left to stop for MINE, but only the id that owns no node at all + // is a miss. + expect(result).toEqual({ stopped: [], unmatched: ["GHOST-9999"] }); + expect(registry.disposeService).not.toHaveBeenCalled(); + }); + + it("reports nothing unmatched when the same device is stopped twice in a row", async () => { + // The session-end sequence the argent rules prescribe: stop the device you + // finished with, then sweep the rest. The second call finds every URN the + // first one left behind in IDLE, and must not read that as a mistyped id. + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`NativeDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const first = await tool.execute!({}, { devices: [MINE] }); + expect(first).toEqual({ + stopped: [`SimulatorServer:${MINE}`, `NativeDevtools:${MINE}`], + }); + expect(first).not.toHaveProperty("unmatched"); + + const second = await tool.execute!({}, { devices: [MINE] }); + expect(second).toEqual({ stopped: [] }); + expect(second).not.toHaveProperty("unmatched"); + // The second call had nothing live to tear down. + expect(registry.disposeService).toHaveBeenCalledTimes(2); + }); + + it("stops AXService and does not call a describe-only iOS session a typo", async () => { + // An iOS session that only ran boot/launch/describe owns `AXService:` + // — and also `NativeDevtools:`, which bootIos and launch-app resolve + // unconditionally (omitted from this snapshot to isolate the AXService + // case). `AXService` is a device-owned namespace holding the in-sim ax + // daemon (spawned --timeout 3600), so a scoped stop reaps it AND does not + // report the correct UDID as unmatched: it owns a real service, not a typo. + // A second device's AXService is the control: without it an always-match + // matcher passes this case, and AXService is one of the namespaces nothing + // else here scopes. + const services = new Map([ + [`AXService:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`AXService:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [`AXService:${MINE}`] }); + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).toHaveBeenCalledWith(`AXService:${MINE}`); + expect(registry.disposeService).not.toHaveBeenCalledWith(`AXService:${THEIRS}`); + }); + + it("scopes the tcp-transport AXService URN to its own device", async () => { + // `axServiceRef(device, { transport: "tcp" })` appends `:tcp`, exactly as + // `nativeDevtoolsRef` does. No call site passes that option today — the + // remote host's forced-TCP decision happens inside the factory, after the + // ref has fixed the URN — so this is a shape the ref can mint rather than + // one production currently produces, and the coverage is defensive: the + // matcher must not start splitting a device id on ":" if one ever does. + const services = new Map([ + [`AXService:${MINE}:tcp`, { state: ServiceState.RUNNING, dependents: [] }], + [`AXService:${THEIRS}:tcp`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [`AXService:${MINE}:tcp`] }); + expect(registry.disposeService).not.toHaveBeenCalledWith(`AXService:${THEIRS}:tcp`); + }); + + it("owns and stops a device whose only service is a screen recording", async () => { + // ScreenRecordingSession holds an ffmpeg child, an MJPEG frame stream and + // the touch-visualizer overlay it enabled on the device, and nothing + // cascades to it. It is a device-owned namespace, so a session that ran + // screen-recording-start is correctly reaped by a scoped stop and its + // serial is not reported as a mistyped id. + // Second device as the control — an always-match matcher would otherwise + // pass, and nothing else here scopes this namespace. + const services = new Map([ + [`ScreenRecordingSession:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`ScreenRecordingSession:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [`ScreenRecordingSession:${MINE}`] }); + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).not.toHaveBeenCalledWith(`ScreenRecordingSession:${THEIRS}`); + }); + + it("owns and stops a device whose only service is a native profiler session", async () => { + // Same shape: an xctrace child on iOS, an on-device perfetto process plus + // its trace file on Android. + const services = new Map([ + [`NativeProfilerSession:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + // Control, as above. + [`NativeProfilerSession:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [`NativeProfilerSession:${MINE}`] }); + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).not.toHaveBeenCalledWith(`NativeProfilerSession:${THEIRS}`); + }); + + it("scopes the port-keyed debugger URNs to the right device", async () => { + // JsRuntimeDebugger's URN interposes the Metro port: `::`. + // Matched as `:` it would belong to nobody, so a debugger-only + // session's serial would read as unmatched while its bound port and Metro + // CDP socket stayed open — the port-keyed match is what prevents that. Both + // devices sit behind the SAME port, so this also pins that the port is not + // what the scoping keys on. + const services = new Map([ + [`JsRuntimeDebugger:8081:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`JsRuntimeDebugger:8081:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [`JsRuntimeDebugger:8081:${MINE}`] }); + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).not.toHaveBeenCalledWith(`JsRuntimeDebugger:8081:${THEIRS}`); + }); + + it("does not let a port-keyed URN's port be mistaken for a wireless-adb device id", async () => { + // The device id after the port can itself be `ip:port`. Only the FIRST + // colon is the Metro port, so the remainder must be compared whole. + const serial = "192.168.1.5:5555"; + const services = new Map([ + [`JsRuntimeDebugger:8081:${serial}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + expect(await tool.execute!({}, { devices: [serial] })).toEqual({ + stopped: [`JsRuntimeDebugger:8081:${serial}`], + }); + + // A bare IP must not claim it, and neither must the port. + const registry2 = createMockRegistry( + new Map([ + [`JsRuntimeDebugger:8081:${serial}`, { state: ServiceState.RUNNING, dependents: [] }], + ]) + ); + const tool2 = createStopAllSimulatorServersTool(registry2); + expect(await tool2.execute!({}, { devices: ["192.168.1.5", "8081"] })).toEqual({ + stopped: [], + unmatched: ["192.168.1.5", "8081"], + }); + }); + + it("scopes the port-keyed NetworkInspector and ReactProfilerSession URNs to the right device", async () => { + // NetworkInspector and ReactProfilerSession share JsRuntimeDebugger's + // port-keyed URN shape (`::`) but are declared apart + // from it in PORT_KEYED_NAMESPACES. Without that membership neither + // namespace is in DEVICE_OWNED_NAMESPACES at all, so a standalone node + // (no JsRuntimeDebugger present to cascade through) would match nothing + // and never be named in `stopped`. Both devices sit behind the SAME port, + // so this also pins that the port is not what the scoping keys on. + const services = new Map([ + [`NetworkInspector:8081:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`NetworkInspector:8081:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + [`ReactProfilerSession:8081:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`ReactProfilerSession:8081:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ + stopped: [`NetworkInspector:8081:${MINE}`, `ReactProfilerSession:8081:${MINE}`], + }); + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).not.toHaveBeenCalledWith(`NetworkInspector:8081:${THEIRS}`); + expect(registry.disposeService).not.toHaveBeenCalledWith(`ReactProfilerSession:8081:${THEIRS}`); + }); + + it("does not let a NetworkInspector/ReactProfilerSession port be mistaken for a wireless-adb device id", async () => { + // Mirrors the JsRuntimeDebugger case above: the device id after the port + // can itself be `ip:port`, so only the FIRST colon may be consumed as the + // Metro port. + const serial = "192.168.1.5:5555"; + const services = new Map([ + [`NetworkInspector:8081:${serial}`, { state: ServiceState.RUNNING, dependents: [] }], + [`ReactProfilerSession:8081:${serial}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + expect(await tool.execute!({}, { devices: [serial] })).toEqual({ + stopped: [`NetworkInspector:8081:${serial}`, `ReactProfilerSession:8081:${serial}`], + }); + + // A bare IP must not claim it, and neither must the port. + const registry2 = createMockRegistry( + new Map([ + [`NetworkInspector:8081:${serial}`, { state: ServiceState.RUNNING, dependents: [] }], + [`ReactProfilerSession:8081:${serial}`, { state: ServiceState.RUNNING, dependents: [] }], + ]) + ); + const tool2 = createStopAllSimulatorServersTool(registry2); + expect(await tool2.execute!({}, { devices: ["192.168.1.5", "8081"] })).toEqual({ + stopped: [], + unmatched: ["192.168.1.5", "8081"], + }); + }); + + it("reaps AXService on an unscoped machine-wide sweep too", async () => { + const services = new Map([ + [`AXService:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`SimulatorServer:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, {}); + + expect(result).toEqual({ + stopped: [`AXService:${MINE}`, `SimulatorServer:${THEIRS}`], + }); + }); + + it("names a repeated missing id only once", async () => { + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + // A device list assembled from several sources can repeat an id; the + // warning is about the id, not about how many times it was passed. + const result = await tool.execute!({}, { devices: [MINE, "GHOST-9999", MINE, "GHOST-9999"] }); + + expect(result).toEqual({ + stopped: [`SimulatorServer:${MINE}`], + unmatched: ["GHOST-9999"], + }); + }); + + it("names a repeated missing id only once across CASE variants too", async () => { + // The de-duplication lowercases, matching the lookup — but every case above + // repeats an id in one spelling, so mutating `seen` to identity kept the + // whole stop-tool suite green. Two spellings of one wrong id are one + // mistake, and it is reported in the caller's FIRST spelling. + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: ["GHOST-9999", "ghost-9999"] }); + + expect(result).toEqual({ stopped: [], unmatched: ["GHOST-9999"] }); + }); + + it("reports neither spelling when one device is named twice in different cases", async () => { + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + // Matching is case-insensitive, so both spellings name the same device — + // and the device matched. Neither is a miss. + const result = await tool.execute!({}, { devices: [MINE, MINE.toLowerCase()] }); + + expect(result).toEqual({ stopped: [`SimulatorServer:${MINE}`] }); + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).toHaveBeenCalledOnce(); + }); + + it("does not report an ERROR-only device as unmatched — its dead node was cleaned up", async () => { + // The other side of the IDLE case above: neither state is a miss (both own + // nodes), but an ERROR node is still DISPOSED — it never ran, so it never + // shows up in `stopped`, yet the dead node has to be cleared. + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.ERROR, dependents: [] }], + [`SimulatorServer:${THEIRS}`, { state: ServiceState.IDLE, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE, THEIRS] }); + + expect(result).toEqual({ stopped: [] }); + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).toHaveBeenCalledOnce(); + expect(registry.disposeService).toHaveBeenCalledWith(`SimulatorServer:${MINE}`); + }); + + it("counts a case-differing id as matched and echoes the caller's own spelling for the miss", async () => { + // The registry holds the upper-case UDID; the caller passes lower-case. + // The hit must not be reported as a miss (matching is case-insensitive), + // and the miss must come back spelled exactly as the caller typed it so the + // agent can find it in its own device list. + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE.toLowerCase(), "Mine-Typo"] }); + + expect(result).toEqual({ + stopped: [`SimulatorServer:${MINE}`], + unmatched: ["Mine-Typo"], + }); + }); +}); + +describe("stop-all-simulator-servers abort", () => { + // A sweep is a loop of awaited disposals across thirteen namespaces, each + // reaping spawned processes and sockets. Ignoring the request signal billed a + // caller who had already given up — an MCP client timing out, a cancelled CLI + // run — for the whole of it. + + it("stops sweeping once the request is aborted, and says the teardown is partial", async () => { + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`NativeDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`AXService:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const controller = new AbortController(); + // Abort as soon as the first disposal has happened. + vi.mocked(registry.disposeService).mockImplementationOnce(async (urn: string) => { + services.get(urn)!.state = ServiceState.IDLE; + controller.abort(); + }); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }, { + signal: controller.signal, + } as never); + + expect(result).toEqual({ stopped: [`SimulatorServer:${MINE}`], aborted: true }); + expect(registry.disposeService).toHaveBeenCalledTimes(1); + }); + + it("does not report `unmatched` for a partial sweep it never finished reading", async () => { + // The id may well own a service further down the snapshot, so calling it a + // typo here would be a guess — and `left_running` would name every + // namespace past the break. + const services = new Map([ + [`SimulatorServer:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const controller = new AbortController(); + controller.abort(); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }, { + signal: controller.signal, + } as never); + + expect(result).toEqual({ stopped: [], aborted: true }); + expect(registry.disposeService).not.toHaveBeenCalled(); + }); + + it("sweeps to completion when no signal is supplied", async () => { + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`NativeDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ + stopped: [`SimulatorServer:${MINE}`, `NativeDevtools:${MINE}`], + }); + }); +}); + +describe("stop-all-simulator-servers left_running", () => { + // With two or more devices on one Metro, `debugger-connect` refuses a udid / + // serial and instructs the caller to re-target with the `logicalDeviceId` + // Metro echoed. That id keys the session's URN, and no `list-devices` id + // equals it — so no `devices` scope can reap the CDP socket, the bound + // loopback console server or the log file handle it holds. Worse, the + // caller's real serial DOES match that device's other services, so it never + // lands in `unmatched` and the teardown reads as a clean machine. + const LOGICAL = "b5f2c1e0-7a44-4d8e-9c31-metro-logical"; + + // What the JsRuntimeDebugger factory records when the id it was resolved with + // IS the logicalDeviceId Metro echoed — the one place both ids are compared. + beforeEach(() => { + resetDeviceAliases(); + rememberLogicalKeyedDevice(LOGICAL, LOGICAL); + }); + afterEach(() => resetDeviceAliases()); + + it("names a logicalDeviceId-keyed debugger session the scope could not reach", async () => { + const services = new Map([ + [`AndroidDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`JsRuntimeDebugger:8081:${LOGICAL}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ + stopped: [`AndroidDevtools:${MINE}`], + left_running: [`JsRuntimeDebugger:8081:${LOGICAL}`], + }); + // The serial matched a service, so it is not a typo — the point is that + // `unmatched` cannot be the thing that reports this. + expect(result).not.toHaveProperty("unmatched"); + expect(registry.disposeService).not.toHaveBeenCalledWith(`JsRuntimeDebugger:8081:${LOGICAL}`); + }); + + it("names the network inspector and React profiler riding on that session too", async () => { + const services = new Map([ + [`AndroidDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [ + `JsRuntimeDebugger:8081:${LOGICAL}`, + { + state: ServiceState.RUNNING, + dependents: [`NetworkInspector:8081:${LOGICAL}`, `ReactProfilerSession:8081:${LOGICAL}`], + }, + ], + [`NetworkInspector:8081:${LOGICAL}`, { state: ServiceState.RUNNING, dependents: [] }], + [`ReactProfilerSession:8081:${LOGICAL}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const tool = createStopAllSimulatorServersTool(createMockRegistry(services)); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result.left_running).toEqual([ + `JsRuntimeDebugger:8081:${LOGICAL}`, + `NetworkInspector:8081:${LOGICAL}`, + `ReactProfilerSession:8081:${LOGICAL}`, + ]); + }); + + it("reaps rather than reports the session once the logicalDeviceId is supplied", async () => { + // The documented recovery, and the proof the id is the whole gap: pass it + // alongside the serial and the session is stopped like anything else. + const services = new Map([ + [`AndroidDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`JsRuntimeDebugger:8081:${LOGICAL}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const registry = createMockRegistry(services); + const tool = createStopAllSimulatorServersTool(registry); + + const result = await tool.execute!({}, { devices: [MINE, LOGICAL] }); + + expect(result).toEqual({ + stopped: [`AndroidDevtools:${MINE}`, `JsRuntimeDebugger:8081:${LOGICAL}`], + }); + expect(result).not.toHaveProperty("left_running"); + }); + + it("stays silent about another agent's serial-keyed session", async () => { + // `THEIRS` connected by serial (one device on that Metro), so it is an id + // `list-devices` hands out and a scope COULD have named it. A session left + // on it is that agent's business, not a scope that cannot express itself — + // reporting it would invite exactly the cross-agent teardown the `devices` + // scope exists to prevent. + const services = new Map([ + [`SimulatorServer:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`JsRuntimeDebugger:8081:${THEIRS}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const tool = createStopAllSimulatorServersTool(createMockRegistry(services)); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [`SimulatorServer:${MINE}`] }); + }); + + it("stops reporting the session once its debugger connection is disposed", async () => { + // The marker is dropped in the blueprint's dispose alongside the alias, so a + // stale one cannot make a later teardown accuse a session that is gone. + forgetLogicalKeyedDevice(LOGICAL); + const services = new Map([ + [`AndroidDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`JsRuntimeDebugger:8081:${LOGICAL}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const tool = createStopAllSimulatorServersTool(createMockRegistry(services)); + + expect(await tool.execute!({}, { devices: [MINE] })).toEqual({ + stopped: [`AndroidDevtools:${MINE}`], + }); + }); + + it("reports nothing on an unscoped sweep, which reaps every namespace anyway", async () => { + const services = new Map([ + [`JsRuntimeDebugger:8081:${LOGICAL}`, { state: ServiceState.RUNNING, dependents: [] }], + ]); + const tool = createStopAllSimulatorServersTool(createMockRegistry(services)); + + const result = await tool.execute!({}, {}); + + expect(result).toEqual({ stopped: [`JsRuntimeDebugger:8081:${LOGICAL}`] }); + }); + + it("ignores an IDLE session, which holds nothing left to leave running", async () => { + const services = new Map([ + [`AndroidDevtools:${MINE}`, { state: ServiceState.RUNNING, dependents: [] }], + [`JsRuntimeDebugger:8081:${LOGICAL}`, { state: ServiceState.IDLE, dependents: [] }], + ]); + const tool = createStopAllSimulatorServersTool(createMockRegistry(services)); + + const result = await tool.execute!({}, { devices: [MINE] }); + + expect(result).toEqual({ stopped: [`AndroidDevtools:${MINE}`] }); + }); + + it("matches the marker case-insensitively, as every other id comparison here does", async () => { + const services = new Map([ + [ + `JsRuntimeDebugger:8081:${LOGICAL.toUpperCase()}`, + { state: ServiceState.RUNNING, dependents: [] }, + ], + ]); + const tool = createStopAllSimulatorServersTool(createMockRegistry(services)); + + expect(await tool.execute!({}, { devices: [MINE] })).toEqual({ + stopped: [], + unmatched: [MINE], + left_running: [`JsRuntimeDebugger:8081:${LOGICAL.toUpperCase()}`], + }); + }); +}); + +describe("stop-all-simulator-servers interaction messages", () => { + // Both formatters previously had no coverage at all — flattening either to + // an unconditional string left the whole suite green. Pin the exact wording + // for every branch a caller can hit. + function tool() { + return createStopAllSimulatorServersTool(createMockRegistry(new Map())); + } + + it("startedMsg reports a machine-wide sweep when devices is omitted", () => { + const startedMsg = tool().interaction!.startedMsg!; + expect(startedMsg({ params: {} })).toBe("Stopping all simulator servers"); + }); + + it("startedMsg is singular for exactly one device", () => { + const startedMsg = tool().interaction!.startedMsg!; + expect(startedMsg({ params: { devices: [MINE] } })).toBe( + "Stopping simulator servers for 1 device" + ); + }); + + it("startedMsg is plural for two or more devices", () => { + const startedMsg = tool().interaction!.startedMsg!; + expect(startedMsg({ params: { devices: [MINE, THEIRS] } })).toBe( + "Stopping simulator servers for 2 devices" + ); + }); + + it("completedMsg has no unmatched clause when nothing was unmatched, singular and zero counts", () => { + const completedMsg = tool().interaction!.completedMsg!; + expect(completedMsg({ params: {}, result: { stopped: [`SimulatorServer:${MINE}`] } })).toBe( + "Stopped 1 simulator server" + ); + expect(completedMsg({ params: {}, result: { stopped: [] } })).toBe( + "Stopped 0 simulator servers" + ); + }); + + it("completedMsg pluralizes 'servers' for more than one stopped", () => { + const completedMsg = tool().interaction!.completedMsg!; + expect( + completedMsg({ + params: {}, + result: { stopped: [`SimulatorServer:${MINE}`, `SimulatorServer:${THEIRS}`] }, + }) + ).toBe("Stopped 2 simulator servers"); + }); + + it("completedMsg appends the singular unmatched clause for exactly one bad id", () => { + const completedMsg = tool().interaction!.completedMsg!; + expect( + completedMsg({ + params: { devices: [MINE, "GHOST-9999"] }, + result: { stopped: [`SimulatorServer:${MINE}`], unmatched: ["GHOST-9999"] }, + }) + ).toBe("Stopped 1 simulator server (1 supplied id matched no service)"); + }); + + it("completedMsg appends the plural unmatched clause for two or more bad ids", () => { + const completedMsg = tool().interaction!.completedMsg!; + expect( + completedMsg({ + params: { devices: ["GHOST-1", "GHOST-2"] }, + result: { stopped: [], unmatched: ["GHOST-1", "GHOST-2"] }, + }) + ).toBe("Stopped 0 simulator servers (2 supplied ids matched no service)"); + }); + + it("completedMsg appends the left_running clause, singular and plural", () => { + const completedMsg = tool().interaction!.completedMsg!; + expect( + completedMsg({ + params: { devices: [MINE] }, + result: { + stopped: [`SimulatorServer:${MINE}`], + left_running: ["JsRuntimeDebugger:8081:L"], + }, + }) + ).toBe("Stopped 1 simulator server (1 debugger session left running)"); + expect( + completedMsg({ + params: { devices: [MINE] }, + result: { + stopped: [], + left_running: ["JsRuntimeDebugger:8081:L", "NetworkInspector:8081:L"], + }, + }) + ).toBe("Stopped 0 simulator servers (2 debugger sessions left running)"); + }); + + it("failedMsg names the error code", () => { + // The one formatter of the three with no coverage — flattening it to a + // constant left the suite green. + const failedMsg = tool().interaction!.failedMsg!; + expect( + failedMsg({ + params: {}, + failureSignal: { error_code: "REGISTRY_TOOL_EXECUTION_FAILED" }, + } as never) + ).toBe("Failed to stop simulator servers: REGISTRY_TOOL_EXECUTION_FAILED"); + }); + + it("completedMsg reports both clauses when a call hits both", () => { + const completedMsg = tool().interaction!.completedMsg!; + expect( + completedMsg({ + params: { devices: ["GHOST-1"] }, + result: { stopped: [], unmatched: ["GHOST-1"], left_running: ["JsRuntimeDebugger:8081:L"] }, + }) + ).toBe( + "Stopped 0 simulator servers (1 supplied id matched no service; 1 debugger session left running)" + ); + }); +}); + describe("stop-metro", () => { it("defaults to port 8081", () => { expect(stopMetroTool.zodSchema).toBeDefined(); diff --git a/scripts/e2e-full/phases/20-validation.sh b/scripts/e2e-full/phases/20-validation.sh index d54647a93..68e373d64 100644 --- a/scripts/e2e-full/phases/20-validation.sh +++ b/scripts/e2e-full/phases/20-validation.sh @@ -13,7 +13,9 @@ # Tools with no required flags that would actually EXECUTE (touch a device / # network / state) if called empty — excluded from missing-required here and -# covered by the device tiers instead. +# covered by the device tiers instead. `stop-all-simulator-servers` gets its own +# targeted cases below, since skipping it outright left its `.strict()` +# rejection and its `unmatched` report with no coverage anywhere. _VAL_EXCLUDE_MISSING="list-devices stop-all-simulator-servers stop-metro native-devtools-status update-argent" # Build a JSON object with valid dummies for every required flag in a model, @@ -86,6 +88,26 @@ run_phase() { assert_reject "$P" "$t" "bad-enum:$ef" "$args" "$ef" "invalid_value" done + # --- stop-all-simulator-servers' strict schema and unmatched report ----- + # It is on _VAL_EXCLUDE_MISSING (calling it empty would sweep the machine), + # so the generated matrix skips it entirely — leaving the two properties + # that make its `devices` scope safe with no E2E coverage at all. + if [ "$t" = "stop-all-simulator-servers" ]; then + # `.strict()`: `udids` is the natural slip (every sibling tool spells the + # device parameter `udid`), and under a stripping schema that typo would + # be a silent machine-wide sweep. + assert_reject "$P" "$t" strict-unknown-key '{"udids":["nope"]}' "udids" "unrecognized_keys" + # `unmatched`: an id owning nothing must not read as a clean machine. A + # scope of one bogus id reaps nothing and touches no device, so this is + # safe to run here. + run_tool "$t" '{"devices":["__e2e_no_such_device__"]}' + if [ "$RT_RC" -eq 0 ] && [ "$(printf '%s' "$RT_JSON" | jq -r '.unmatched[0] // ""')" = "__e2e_no_such_device__" ]; then + pass "$P" "$t" unmatched "bogus id reported, not silently clean" + else + fail "$P" "$t" unmatched "expected unmatched:[__e2e_no_such_device__], got rc=$RT_RC $RT_JSON" + fi + fi + # --- bad-type (first required number flag gets a string) --------------- local nf nf="$(model_number_flags "$model" | while read -r f; do diff --git a/scripts/e2e-full/phases/90-cleanup.sh b/scripts/e2e-full/phases/90-cleanup.sh index 0588fcff8..7844a3706 100644 --- a/scripts/e2e-full/phases/90-cleanup.sh +++ b/scripts/e2e-full/phases/90-cleanup.sh @@ -5,7 +5,17 @@ run_phase() { local P=cleanup - # Stop any simulator-servers this run started (Android/iOS backends). + # Drain the run's own tool-server. The unscoped `{}` is the machine-wide sweep + # across every device-owned namespace — simulator-servers, native devtools, AX, + # TV-control daemons, Chromium CDP, screen recordings, native-profiler and + # JS-runtime debugger sessions — not just "the simulator-servers this run + # started", which is what this said while the tool only reached the transports. + # + # Unscoped is nonetheless right HERE, and only here: the run's HOME is the + # sandbox, so the server it discovers is this run's own (see ensure_server's + # note) and the sweep cannot reach another agent's devices. Anywhere an agent + # is talking to the shared install, pass `devices` — that is what the tool's + # own description and the skills tell agents to do. if [ -n "${ARGENT_TOOLS_URL:-}" ]; then run_tool stop-all-simulator-servers '{}' >/dev/null 2>&1 && pass "$P" stop-all-simulator-servers teardown || skip "$P" stop-all-simulator-servers teardown "no server/none running" run_tool stop-metro '{}' >/dev/null 2>&1 && pass "$P" stop-metro teardown || skip "$P" stop-metro teardown "no metro"