diff --git a/agents/myra/src/definition.test.ts b/agents/myra/src/definition.test.ts deleted file mode 100644 index 995861687..000000000 --- a/agents/myra/src/definition.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// The two facts about Myra's entry that a deploy silently depends on: she -// carries real tool factories (the source lineage resolves no pins, so an -// empty list means an agent with no tools), and she stays single-step. - -import { expect, test } from "bun:test"; -import type { StepPrimitive, WorkflowDefinition } from "@intx/workflow"; - -import { ASSISTANT_STEP_ID, ASSISTANT_WORKFLOW_ID, buildMyraWorkflow } from "./index"; - -const INPUT = { - workflowId: ASSISTANT_WORKFLOW_ID, - triggerAddress: "ins_dep000000000000@example.test", - inferencePreferences: [{ provider: "anthropic", model: "claude-test" }], - systemPrompt: "You are Myra.", - hubCredentialId: "crd_000000000000000000000000000000ab", - mcpServers: [], -} as const; - -function assistantStep(definition: WorkflowDefinition): StepPrimitive { - const primitive = definition.steps[ASSISTANT_STEP_ID]; - if (primitive === undefined || primitive.kind !== "step") { - throw new Error(`definition has no step primitive named ${ASSISTANT_STEP_ID}`); - } - return primitive; -} - -test("the agent carries its tool factories inline, not as pins", () => { - const agent = assistantStep(buildMyraWorkflow(INPUT)).agent; - expect(agent.toolFactories.length).toBeGreaterThan(0); - // Every factory must be callable and namespaced: the sidecar reads - // `factory.id` as the tool package name for the source lineage. - for (const factory of agent.toolFactories) { - expect(typeof factory).toBe("function"); - expect(factory.id).toMatch(/^@?[^/]+\/.+/); - } - expect(agent.toolPackagePins).toEqual([]); -}); - -test("the definition has exactly one step, so a deployment stays conversational", () => { - // A single-step deployment keeps one warm agent with durable memory - // across runs; a second step would silently trade that memory away. - const definition = buildMyraWorkflow(INPUT); - expect(definition.stepOrder).toEqual([ASSISTANT_STEP_ID]); - expect(assistantStep(definition).triggers).toBe("unbounded"); -}); - -test("the step carries no timeout, so an approval park never aborts the run", () => { - // A step timeout stays armed across an approval park, so any finite - // value aborts a warm agent waiting on a person to answer an ask gate. - expect(assistantStep(buildMyraWorkflow(INPUT)).timeout).toBeUndefined(); -}); diff --git a/apps/hub/src/provisioners/process.test.ts b/apps/hub/src/provisioners/process.test.ts deleted file mode 100644 index fda9dfbf8..000000000 --- a/apps/hub/src/provisioners/process.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { mkdtemp, readFile, stat } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -import { describe, expect, test } from "bun:test"; - -import type { EnsureSidecarRequest } from "@intx/hub-sessions"; - -import { - createFakeSidecarProcessRunner, - type FakeSidecarProcessRunner, -} from "./fake-process-runner"; -import { - createProcessSidecarProvisioner, - readProcessProvisionerConfig, - type ProcessProvisionerConfig, -} from "./process"; - -const HUB_WS_URL = "ws://127.0.0.1:3000/api/sidecars/ws"; -const SIDECAR_ENTRY = "/srv/workbench/apps/sidecar/src/index.ts"; -const SIDECAR_KEY = "22".repeat(32); - -describe("readProcessProvisionerConfig", () => { - test("an unconfigured environment resolves this repository's sidecar entry and the running bun", () => { - const config = readProcessProvisionerConfig({ - env: { SIDECAR_CREDENTIAL_ENCRYPTION_KEY: SIDECAR_KEY }, - dataDir: "/srv/hub-data/process-provisioner", - hubWebSocketUrl: HUB_WS_URL, - }); - - expect(config.sidecarEntryPath).toEndWith("/apps/sidecar/src/index.ts"); - expect(config.runtimePath).toBe(process.execPath); - expect(config.sidecarCredentialEncryptionKey).toBe(SIDECAR_KEY); - expect(config.allocationsDir).toBe("/srv/hub-data/process-provisioner/allocations"); - expect(config.stateFilePath).toBe("/srv/hub-data/process-provisioner/state.json"); - expect(config.hubWebSocketUrl).toBe(HUB_WS_URL); - }); - - test("an operator can point the backend at another entry point and runtime", () => { - const config = readProcessProvisionerConfig({ - env: { - PROCESS_PROVISIONER_SIDECAR_ENTRY: "/opt/sidecar/index.js", - PROCESS_PROVISIONER_RUNTIME: "/usr/bin/node", - SIDECAR_CREDENTIAL_ENCRYPTION_KEY: SIDECAR_KEY, - }, - dataDir: "/srv/hub-data/process-provisioner", - hubWebSocketUrl: HUB_WS_URL, - }); - - expect(config.sidecarEntryPath).toBe("/opt/sidecar/index.js"); - expect(config.runtimePath).toBe("/usr/bin/node"); - }); - - test("a relative data dir fails loudly instead of resolving against the cwd", () => { - expect(() => - readProcessProvisionerConfig({ - env: { SIDECAR_CREDENTIAL_ENCRYPTION_KEY: SIDECAR_KEY }, - dataDir: ".data/hub/process-provisioner", - hubWebSocketUrl: HUB_WS_URL, - }), - ).toThrow("must be an absolute path"); - }); - - test("an empty override is a misconfiguration, not an unset key", () => { - expect(() => - readProcessProvisionerConfig({ - env: { PROCESS_PROVISIONER_RUNTIME: "", SIDECAR_CREDENTIAL_ENCRYPTION_KEY: SIDECAR_KEY }, - dataDir: "/srv/hub-data/process-provisioner", - hubWebSocketUrl: HUB_WS_URL, - }), - ).toThrow("invalid process provisioner environment"); - }); - - test("a hub without the sidecar key cannot provision at all", () => { - expect(() => - readProcessProvisionerConfig({ - env: {}, - dataDir: "/srv/hub-data/process-provisioner", - hubWebSocketUrl: HUB_WS_URL, - }), - ).toThrow("invalid process provisioner environment"); - }); -}); - -async function configIn(dataDir: string): Promise { - return { - runtimePath: "/usr/local/bin/bun", - sidecarEntryPath: SIDECAR_ENTRY, - allocationsDir: resolve(dataDir, "allocations"), - stateFilePath: resolve(dataDir, "state.json"), - hubWebSocketUrl: HUB_WS_URL, - sidecarCredentialEncryptionKey: SIDECAR_KEY, - }; -} - -async function harness(opts: Parameters[0] = {}): Promise<{ - provisioner: ReturnType; - runner: FakeSidecarProcessRunner; - config: ProcessProvisionerConfig; -}> { - const dataDir = await mkdtemp(join(tmpdir(), "process-provisioner-")); - const config = await configIn(dataDir); - const runner = createFakeSidecarProcessRunner(opts); - return { - provisioner: createProcessSidecarProvisioner({ - config, - runner, - role: "deployment", - }), - runner, - config, - }; -} - -function ensureRequest(overrides: Partial = {}): EnsureSidecarRequest { - return { - allocationId: "alloc-1", - generation: 1, - tenantId: "tenant-1", - anchorRunId: "run-1", - sidecarId: "sidecar-1", - token: "token-abc", - hubWebSocketUrl: HUB_WS_URL, - ...overrides, - }; -} - -describe("createProcessSidecarProvisioner", () => { - test("ensure spawns the sidecar entry with the allocation's own token, data dir, and hub URL", async () => { - const { provisioner, runner, config } = await harness(); - - const result = await provisioner.ensure(ensureRequest()); - - expect(result.kind).toBe("accepted"); - expect(runner.spawns).toHaveLength(1); - const spawn = runner.spawns[0]; - expect(spawn?.command).toEqual([ - "/usr/local/bin/bun", - "/srv/workbench/apps/sidecar/src/index.ts", - ]); - expect(spawn?.env["HUB_WS_URL"]).toBe(HUB_WS_URL); - expect(spawn?.env["SIDECAR_TOKEN"]).toBe("token-abc"); - expect(spawn?.env["SIDECAR_ID"]).toBe("sidecar-1"); - expect(spawn?.env["SIDECAR_DATA_DIR"]).toBe( - resolve(config.allocationsDir, "alloc-1", "gen-1", "data"), - ); - expect(spawn?.env["PATH"]).toBe(process.env["PATH"]); - expect(spawn?.env["SIDECAR_CREDENTIAL_ENCRYPTION_KEY"]).toBe(SIDECAR_KEY); - }); - - test("ensure records the pid so a restarted hub can still find the unit", async () => { - const { provisioner, config } = await harness({ firstPid: 5150 }); - - const result = await provisioner.ensure(ensureRequest()); - - expect(result).toMatchObject({ - kind: "accepted", - externalRef: "alloc-1:1:5150", - }); - const pidFile = await readFile( - resolve(config.allocationsDir, "alloc-1", "gen-1", "sidecar.pid"), - "utf8", - ); - expect(pidFile.trim()).toBe("5150"); - }); - - test("ensure is idempotent for the same allocation and generation", async () => { - const { provisioner, runner } = await harness(); - - const first = await provisioner.ensure(ensureRequest()); - const second = await provisioner.ensure(ensureRequest()); - - expect(first).toEqual(second); - expect(runner.spawns).toHaveLength(1); - }); - - test("ensure rejects a generation older than one already observed", async () => { - const { provisioner, runner } = await harness(); - - await provisioner.ensure(ensureRequest({ generation: 3 })); - const stale = await provisioner.ensure(ensureRequest({ generation: 2 })); - - expect(stale).toMatchObject({ kind: "rejected", code: "stale_generation" }); - expect(runner.spawns).toHaveLength(1); - }); - - test("a newer generation replaces the previous process for the allocation", async () => { - const { provisioner, runner } = await harness({ firstPid: 700 }); - - await provisioner.ensure(ensureRequest({ generation: 1 })); - const next = await provisioner.ensure(ensureRequest({ generation: 2 })); - - expect(next).toMatchObject({ - kind: "accepted", - externalRef: "alloc-1:2:701", - }); - expect(runner.spawns).toHaveLength(2); - expect(runner.signals).toEqual([{ pid: 700, signal: "SIGTERM" }]); - }); - - test("ensure rejects, without spawning, when the process cannot start", async () => { - const { provisioner, runner } = await harness({ - spawnError: new Error("EAGAIN: out of process slots"), - }); - - const result = await provisioner.ensure(ensureRequest()); - - expect(result).toMatchObject({ - kind: "rejected", - code: "sidecar_spawn_failed", - retryable: true, - }); - expect(runner.spawns).toHaveLength(0); - }); - - test("destroy terminates the process and removes the allocation directory", async () => { - const { provisioner, runner, config } = await harness({ firstPid: 900 }); - await provisioner.ensure(ensureRequest()); - const allocationDir = resolve(config.allocationsDir, "alloc-1"); - expect((await stat(allocationDir)).isDirectory()).toBe(true); - - const result = await provisioner.destroy({ - allocationId: "alloc-1", - generation: 1, - sidecarId: "sidecar-1", - }); - - expect(result).toEqual({ kind: "destroyed" }); - expect(runner.signals).toEqual([{ pid: 900, signal: "SIGTERM" }]); - expect(runner.isAlive(900)).toBe(false); - await expect(stat(allocationDir)).rejects.toThrow(); - }); - - test("destroy is idempotent and fences a later ensure of the destroyed generation", async () => { - const { provisioner, runner } = await harness(); - await provisioner.ensure(ensureRequest()); - const destroyRequest = { - allocationId: "alloc-1", - generation: 1, - sidecarId: "sidecar-1", - }; - - expect(await provisioner.destroy(destroyRequest)).toEqual({ - kind: "destroyed", - }); - expect(await provisioner.destroy(destroyRequest)).toEqual({ - kind: "destroyed", - }); - const revived = await provisioner.ensure(ensureRequest()); - expect(revived).toMatchObject({ - kind: "rejected", - code: "generation_destroyed", - }); - expect(runner.spawns).toHaveLength(1); - }); - - test("an allocation id that is not a safe directory name is rejected outright", async () => { - const { provisioner, runner } = await harness(); - - const result = await provisioner.ensure(ensureRequest({ allocationId: "../escape" })); - - expect(result).toMatchObject({ - kind: "rejected", - code: "invalid_allocation_id", - retryable: false, - }); - expect(runner.spawns).toHaveLength(0); - }); -}); diff --git a/apps/hub/src/provisioners/sandbox-sidecar-capabilities.test.ts b/apps/hub/src/provisioners/sandbox-sidecar-capabilities.test.ts deleted file mode 100644 index 8da8e85ec..000000000 --- a/apps/hub/src/provisioners/sandbox-sidecar-capabilities.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { SidecarCapabilityDeclaration } from "@intx/types"; -import { type } from "arktype"; -import { describe, expect, test } from "bun:test"; - -import { sidecarCapabilityDeclarations } from "./sandbox-sidecar"; - -describe("sidecarCapabilityDeclarations", () => { - test("every backend's declarations parse as Interchange declarations", () => { - for (const isolation of ["process", "container", "vm"] as const) { - const parsed = SidecarCapabilityDeclaration.array()(sidecarCapabilityDeclarations(isolation)); - expect(parsed instanceof type.errors).toBe(false); - } - }); - - test("the process backend blocks container and vm isolation", () => { - expect(sidecarCapabilityDeclarations("process")).toEqual([ - { capability: "runtime:sidecar", state: "available" }, - { capability: "isolation:process", state: "available" }, - { capability: "isolation:container", state: "blocked" }, - { capability: "isolation:vm", state: "blocked" }, - ]); - }); - - test("a container reaches process isolation but not vm isolation", () => { - expect(sidecarCapabilityDeclarations("container")).toEqual([ - { capability: "runtime:sidecar", state: "available" }, - { capability: "isolation:process", state: "available" }, - { capability: "isolation:container", state: "available" }, - { capability: "isolation:vm", state: "blocked" }, - ]); - }); - - test("a vm reaches every rung of the ladder", () => { - expect(sidecarCapabilityDeclarations("vm").every(({ state }) => state === "available")).toBe( - true, - ); - }); -}); diff --git a/apps/hub/src/provisioners/sandbox-sidecar-provisioner.test.ts b/apps/hub/src/provisioners/sandbox-sidecar-provisioner.test.ts deleted file mode 100644 index 20177b22e..000000000 --- a/apps/hub/src/provisioners/sandbox-sidecar-provisioner.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { - createAllocationStateStore, - createSidecarProvisioner, - type AllocationStateStore, - type SidecarBackend, -} from "./sandbox-sidecar"; - -const TOKEN = "s3cr3t-bootstrap-token"; - -let dataDir: string; -let store: AllocationStateStore; -let started: string[]; - -beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), "sandbox-sidecar-provisioner-")); - store = createAllocationStateStore(join(dataDir, "state.json")); - started = []; -}); - -afterEach(async () => { - await rm(dataDir, { recursive: true, force: true }); -}); - -function fakeBackend(): SidecarBackend { - let counter = 0; - return { - async startUnit() { - counter += 1; - const externalRef = `unit-${String(counter)}`; - started.push(externalRef); - return externalRef; - }, - async stopUnit() {}, - async findUnitsByAllocation() { - return []; - }, - }; -} - -function baseEnsureRequest(overrides: Partial> = {}) { - return { - allocationId: "alloc-1", - generation: 0, - tenantId: "tenant-1", - anchorRunId: "run-1", - sidecarId: "sidecar-1", - token: TOKEN, - hubWebSocketUrl: "wss://hub.example.com/ws", - ...overrides, - } as never; -} - -function makeProvisioner(backend: SidecarBackend = fakeBackend()) { - return createSidecarProvisioner({ - id: "test-provisioner", - apiVersion: 1, - bindingFingerprint: "test", - capabilities: [], - backend, - store, - }); -} - -describe("ensure request validation", () => { - test("accepts generation 0, the allocation service's initial generation", async () => { - const provisioner = makeProvisioner(); - - const result = await provisioner.ensure(baseEnsureRequest({ generation: 0 })); - - expect(result).toEqual({ kind: "accepted", externalRef: "unit-1" }); - }); - - test("accepts a later generation", async () => { - const provisioner = makeProvisioner(); - - await provisioner.ensure(baseEnsureRequest({ generation: 0 })); - const result = await provisioner.ensure(baseEnsureRequest({ generation: 1 })); - - expect(result).toEqual({ kind: "accepted", externalRef: "unit-2" }); - }); - - test("still rejects a stale generation once fencing has observed a later one", async () => { - const provisioner = makeProvisioner(); - - await provisioner.ensure(baseEnsureRequest({ generation: 1 })); - const result = await provisioner.ensure(baseEnsureRequest({ generation: 0 })); - - expect(result).toMatchObject({ - kind: "rejected", - code: "stale_generation", - }); - expect(started).toHaveLength(1); - }); -}); - -describe("destroy request validation", () => { - test("accepts generation 0", async () => { - const provisioner = makeProvisioner(); - await provisioner.ensure(baseEnsureRequest({ generation: 0 })); - - const result = await provisioner.destroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 0, - }); - - expect(result).toEqual({ kind: "destroyed" }); - }); -}); diff --git a/apps/hub/src/provisioners/sandbox-sidecar-state-store.test.ts b/apps/hub/src/provisioners/sandbox-sidecar-state-store.test.ts deleted file mode 100644 index 9799e0abb..000000000 --- a/apps/hub/src/provisioners/sandbox-sidecar-state-store.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { createAllocationStateStore } from "./sandbox-sidecar"; - -let dataDir: string; - -beforeEach(async () => { - dataDir = await mkdtemp(join(tmpdir(), "docker-provisioner-state-")); -}); - -afterEach(async () => { - await rm(dataDir, { recursive: true, force: true }); -}); - -function statePath(): string { - return join(dataDir, "state.json"); -} - -describe("observeEnsure", () => { - test("accepts the first ensure for an allocation", async () => { - const store = createAllocationStateStore(statePath()); - const result = await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - expect(result.kind).toBe("observed"); - }); - - test("accepts generation 0, the allocation service's initial generation", async () => { - const store = createAllocationStateStore(statePath()); - const result = await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 0, - }); - expect(result.kind).toBe("observed"); - }); - - test("is idempotent when observed again at the same generation", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - const result = await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - expect(result.kind).toBe("observed"); - }); - - test("rejects a generation older than one already observed", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 3, - }); - const result = await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 2, - }); - expect(result).toMatchObject({ - kind: "rejected", - code: "stale_generation", - }); - }); - - test("rejects an ensure after that generation was destroyed", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - await store.observeDestroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - const result = await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - expect(result).toMatchObject({ - kind: "rejected", - code: "generation_destroyed", - }); - }); - - test("rejects a delayed ensure for a generation older than a tombstoned one", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - await store.observeDestroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 2, - }); - const result = await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - expect(result).toMatchObject({ - kind: "rejected", - code: "stale_generation", - }); - }); - - test("rejects when the sidecarId does not match the bound allocation", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - const result = await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-2", - generation: 2, - }); - expect(result).toMatchObject({ - kind: "rejected", - code: "request_conflict", - }); - }); -}); - -describe("observeDestroy", () => { - test("is idempotent once tombstoned", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeDestroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - const result = await store.observeDestroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - expect(result.kind).toBe("observed"); - }); - - test("rejects a destroy generation older than one already observed", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 3, - }); - const result = await store.observeDestroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 2, - }); - expect(result).toMatchObject({ - kind: "rejected", - code: "stale_generation", - }); - }); - - test("carries the recorded externalRef into the tombstone", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - await store.recordUnit({ - allocationId: "alloc-1", - generation: 1, - externalRef: "container-abc", - tokenHashSha256: "deadbeef", - }); - const result = await store.observeDestroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - expect(result).toMatchObject({ - kind: "observed", - record: { externalRef: "container-abc" }, - }); - }); -}); - -describe("recordUnit", () => { - test("fails when the allocation was superseded by a newer generation", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 2, - }); - const recorded = await store.recordUnit({ - allocationId: "alloc-1", - generation: 1, - externalRef: "container-abc", - tokenHashSha256: "deadbeef", - }); - expect(recorded).toBe(false); - }); - - test("fails when the allocation was destroyed before the docker run finished", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - await store.observeDestroy({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - const recorded = await store.recordUnit({ - allocationId: "alloc-1", - generation: 1, - externalRef: "container-abc", - tokenHashSha256: "deadbeef", - }); - expect(recorded).toBe(false); - }); - - test("persists the externalRef and token hash to disk", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }); - await store.recordUnit({ - allocationId: "alloc-1", - generation: 1, - externalRef: "container-abc", - tokenHashSha256: "deadbeef", - }); - const raw = JSON.parse(await readFile(statePath(), "utf8")); - expect(raw.records[0].externalRef).toBe("container-abc"); - expect(raw.records[0].tokenHashSha256).toBe("deadbeef"); - }); -}); - -test("state survives reload from a fresh store instance", async () => { - const store = createAllocationStateStore(statePath()); - await store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 5, - }); - - const reloaded = createAllocationStateStore(statePath()); - const result = await reloaded.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 4, - }); - expect(result).toMatchObject({ - kind: "rejected", - code: "stale_generation", - }); -}); - -test("the highest generation of concurrent writes wins", async () => { - const store = createAllocationStateStore(statePath()); - const results = await Promise.all([ - store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 1, - }), - store.observeEnsure({ - allocationId: "alloc-1", - sidecarId: "sidecar-1", - generation: 2, - }), - ]); - expect(results.every((result) => result.kind === "observed")).toBe(true); - const record = await store.getRecord("alloc-1"); - expect(record?.generation).toBe(2); -}); diff --git a/apps/sidecar/src/agent-key-registration-lifecycle.test.ts b/apps/sidecar/src/agent-key-registration-lifecycle.test.ts deleted file mode 100644 index e463a0b8b..000000000 --- a/apps/sidecar/src/agent-key-registration-lifecycle.test.ts +++ /dev/null @@ -1,394 +0,0 @@ -// Pins the security-critical registration lifecycle of the launched-agent -// signing key on the host transport: the single-step deploy registers the -// agent's CryptoProvider on the host transport at spawn (sub-step 4.3's -// outbound-signing registration), and undeploy UNREGISTERS it so no signing -// key leaks for a torn-down agent. The undeploy-supervisor wiring test drives -// the same harness but never asserts the transport registration state. -// -// Probe: `getTransportFor(address)` throws "is not registered" for an address -// with no CryptoProvider; it succeeds once registered. We assert (a) it -// throws BEFORE deploy, (b) succeeds AFTER deploy, (c) throws again AFTER -// undeploy. - -import { describe, test, expect } from "bun:test"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { createEd25519Crypto, createNoopCredentialCipher, generateKeyPair } from "@intx/crypto"; -import { hexEncode } from "@intx/types"; -import { createInMemoryTransport } from "@intx/mail-memory"; -import type { RepoId, RepoStore } from "@intx/hub-sessions"; -import { - createControlChannelSender, - type FrameReader, - type NdjsonReader, - type NdjsonWriter, - type SubprocessHandle, - type SubprocessSpawner, -} from "@intx/workflow-host"; -import type { AgentDeployFrame } from "@intx/types/sidecar"; -import type { WorkflowDefinition } from "@intx/workflow"; - -import { type } from "arktype"; - -import { createSidecarDeployRouter, deriveDeploymentId } from "./workflow-host-wiring"; -import { WorkflowRunRecord } from "./workflow-run-record"; -import { - createMultistepDrainRouter, - createMultistepMailRouter, - createMultistepSignalRouter, -} from "./workflow-run-pack-client"; - -function createMemoryNdjsonStream() { - const buffer: string[] = []; - let waiter: (() => void) | null = null; - let done = false; - function wake() { - const w = waiter; - waiter = null; - if (w) w(); - } - const reader: NdjsonReader = { - read(): AsyncIterableIterator { - return (async function* () { - while (true) { - if (buffer.length > 0) { - const next = buffer.shift(); - if (next === undefined) throw new Error("buffer shift undefined"); - yield next; - continue; - } - if (done) return; - await new Promise((resolve) => { - waiter = resolve; - }); - } - })(); - }, - }; - const writer: NdjsonWriter = { - write(line: string) { - buffer.push(line.replace(/\n$/, "")); - wake(); - return Promise.resolve(); - }, - }; - return { - writer, - reader, - inject(line: string) { - buffer.push(line.replace(/\n$/, "")); - wake(); - }, - close() { - done = true; - wake(); - }, - }; -} - -function createMemoryFrameStream() { - const buffer: Uint8Array[] = []; - let waiter: (() => void) | null = null; - let done = false; - function wake() { - const w = waiter; - waiter = null; - if (w) w(); - } - const reader: FrameReader = { - read(): AsyncIterableIterator { - return (async function* () { - while (true) { - if (buffer.length > 0) { - const next = buffer.shift(); - if (next === undefined) throw new Error("frame shift undefined"); - yield next; - continue; - } - if (done) return; - await new Promise((resolve) => { - waiter = resolve; - }); - } - })(); - }, - }; - return { - reader, - close() { - done = true; - wake(); - }, - }; -} - -function createSpawnTestRepoStore(tempBase: string): RepoStore { - const stub: Partial = { - getRepoDir(repoId: RepoId): string { - return path.join(tempBase, repoId.kind, repoId.id); - }, - async writeTreePreservingPrefix(_p, _id, _ref, args) { - await args.merge(new Map()); - return { commitSha: "stub-sha", newlyTerminalRuns: [] }; - }, - async writeTree(_p, repoId, _ref, content) { - const dir = path.join(tempBase, repoId.kind, repoId.id); - for (const [relPath, contents] of Object.entries(content.files)) { - const full = path.join(dir, relPath); - await fs.mkdir(path.dirname(full), { recursive: true }); - await fs.writeFile(full, contents); - } - return { commitSha: "stub-sha", newlyTerminalRuns: [] }; - }, - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub - return new Proxy(stub as RepoStore, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (value !== undefined) return value; - return () => { - throw new Error(`stub RepoStore: ${String(prop)} not implemented`); - }; - }, - }); -} - -const AGENT_ADDRESS = "run_keylifecycle@example.com"; - -function isRegistered(transport: ReturnType): boolean { - try { - transport.getTransportFor(AGENT_ADDRESS); - return true; - } catch { - return false; - } -} - -describe("agent signing-key registration lifecycle on the host transport", () => { - test("single-step deploy registers the agent crypto; undeploy unregisters it", async () => { - type SpawnEntry = { - env: Record; - childToSupervisor: ReturnType; - handle: SubprocessHandle; - killed: boolean; - resolveExited: (code: number) => void; - }; - const spawns: SpawnEntry[] = []; - const spawner: SubprocessSpawner = ({ env }) => { - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExited: (code: number) => void = () => undefined; - const exited = new Promise((resolve) => { - resolveExited = resolve; - }); - const entry: SpawnEntry = { - env, - childToSupervisor, - killed: false, - resolveExited, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- assigned below - handle: undefined as unknown as SubprocessHandle, - }; - const handle: SubprocessHandle = { - pid: 6100 + spawns.length, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - entry.killed = true; - childToSupervisor.close(); - eventChildToSupervisor.close(); - entry.resolveExited(0); - }, - exited, - }; - entry.handle = handle; - spawns.push(entry); - return handle; - }; - - const transport = createInMemoryTransport(); - const keyPair = await generateKeyPair(); - const tempBase = await fs.mkdtemp(path.join(os.tmpdir(), "sidecar-keylifecycle-")); - const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "sidecar-keylifecycle-data-")); - const repoStore = createSpawnTestRepoStore(tempBase); - - const router = createSidecarDeployRouter({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the single-step branch invokes only initRepo (head deploy-tree repo); provisionAgent/persistHubPublicKey stay unused (the supervised child mints its own key and persists no hub-agent config) - sessions: { - provisionAgent: async () => { - throw new Error("must not invoke provisionAgent"); - }, - persistHubPublicKey: async () => { - throw new Error("must not invoke persistHubPublicKey"); - }, - initRepo: async () => undefined, - } as unknown as Parameters[0]["sessions"], - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the single-step branch registers the agent's signing key (loadOrGenerateKey) and records the hub key (recordHubKey) at the head before spawn - keyStore: { - recordHubKey: () => undefined, - loadOrGenerateKey: async () => ({ - keyPair: await generateKeyPair(), - isNew: false, - }), - } as unknown as Parameters[0]["keyStore"], - senderKeyCache: { - get: () => undefined, - put: async () => undefined, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }, - transport, - repoStore, - signingKeySeed: keyPair.privateKey, - credentialCipher: createNoopCredentialCipher(), - createAgentCrypto: createEd25519Crypto, - assertSourceBuildable: () => undefined, - registerDeployment: () => undefined, - unregisterDeployment: () => undefined, - multistepSubprocessSpawner: spawner, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: dataDir, - // Source-ref materialization reads both byte caps from the substrate env. - SIDECAR_CACHE_MAX_BYTES: "1000000", - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: "1000000", - }, - // Source-ref is the only deploy lineage: the router derives the runnable - // definition by materializing the pin's closure through this dependency. - // The stub returns a valid single-step `step-1` live definition (its step - // carries an agent so it survives `projectLiveToInert`) so the deploy - // reaches the key-registration lifecycle this test exercises. - applyFrozenWorkflowClosure: (applyArgs) => - Promise.resolve({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- a hand-built live definition cannot satisfy the full WorkflowDefinition nominal type; it stands in for a real closure evaluation - definition: { - id: "wf-keylifecycle", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { - "step-1": { - kind: "step", - id: "step-1", - agent: { - id: "agent-step-1", - systemPrompt: "sys", - capabilities: [], - toolFactories: [], - inference: { sources: [] }, - }, - }, - }, - } as unknown as WorkflowDefinition, - packageDir: path.join(applyArgs.instanceDir, "package"), - deployDir: path.join(applyArgs.instanceDir, "deploy"), - }), - multistepMailRouter: createMultistepMailRouter(), - multistepSignalRouter: createMultistepSignalRouter(), - multistepDrainRouter: createMultistepDrainRouter(), - }); - - const frame: AgentDeployFrame = { - type: "agent.deploy", - agentAddress: AGENT_ADDRESS, - agentId: "keylifecycle-agent", - hubPublicKey: "hub-pk", - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- multi-step branch does not read config - config: {} as AgentDeployFrame["config"], - workflow: { - // Placeholder hub-approved wire hash so the deploy path's fail-loud - // guard passes; production always stamps it. - approvedWireHash: "a".repeat(64), - // Source-ref is the only deploy lineage: the frame carries no inline - // definition, only the pin the sidecar re-materializes (the injected - // closure stub above evaluates it to a single-step `step-1` definition). - sourceRef: { - source: { kind: "registry", registry: "test-registry" }, - closure: { schemaVersion: "1", topLevel: [], entries: [] }, - }, - sources: { - "step-1": [ - { - id: "step-1", - provider: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: "sk-step-1", - model: "claude-3-5", - }, - ], - }, - }, - }; - - // (a) Not registered before deploy. - expect(isRegistered(transport)).toBe(false); - - const deployPromise = router.deploy(frame); - while (spawns.length === 0) { - await new Promise((r) => setTimeout(r, 1)); - } - const spawn = spawns[0]; - if (spawn === undefined) throw new Error("unreachable"); - const channelId = spawn.env.IPC_CHANNEL_ID; - if (channelId === undefined) throw new Error("IPC_CHANNEL_ID missing"); - const childIpcKeyPair = await generateKeyPair(); - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - spawn.childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: spawn.handle.pid, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - await deployPromise; - - // (b) Registered after deploy -- the supervisor can now sign agent mail. - expect(isRegistered(transport)).toBe(true); - - // (b') The deploy persisted a schema-valid restore record for the - // deployment, carrying the head address so a boot-time restore can - // re-establish it. - const anchorRunId = deriveDeploymentId(AGENT_ADDRESS); - const recordFile = path.join(dataDir, "workflow-runs", anchorRunId, "deployment.json"); - const parsedRecord = WorkflowRunRecord(JSON.parse(await fs.readFile(recordFile, "utf8"))); - if (parsedRecord instanceof type.errors) { - throw new Error(`run record failed validation: ${parsedRecord.summary}`); - } - expect(parsedRecord.agentAddress).toBe(AGENT_ADDRESS); - - const undeploy = router.undeploy; - if (undeploy === undefined) throw new Error("router.undeploy undefined"); - await undeploy({ - type: "agent.undeploy", - agentAddress: AGENT_ADDRESS, - reason: "key-lifecycle undeploy", - }); - - // (c) Unregistered after undeploy -- no leaked key for a torn-down agent. - expect(isRegistered(transport)).toBe(false); - - // (c') Undeploy dropped the restore record so a boot-time restore will - // not re-spawn the torn-down deployment. - expect( - await fs.access(recordFile).then( - () => true, - () => false, - ), - ).toBe(false); - - await fs.rm(tempBase, { recursive: true, force: true }); - await fs.rm(dataDir, { recursive: true, force: true }); - }); -}); diff --git a/apps/sidecar/src/source-asset-delivery.test.ts b/apps/sidecar/src/source-asset-delivery.test.ts deleted file mode 100644 index 99a439f84..000000000 --- a/apps/sidecar/src/source-asset-delivery.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, test, expect, afterEach } from "bun:test"; -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import git from "isomorphic-git"; - -import type { ToolPackageManifest } from "@intx/types/tool-packages"; -import { collectReachableObjects } from "@intx/storage-isogit/node"; - -import { - assetReferenceFormats, - indexAssetPackIntoGitDir, - sourceAssetGitDir, -} from "./source-asset-delivery"; - -// The subtree-checkout path (`materializeWorkflowAssets`) is exercised -// end-to-end by the source-workflow e2e; these cover the pure classification and -// path helpers, plus `indexAssetPackIntoGitDir`'s atomic-publish semantics, in -// isolation. - -function manifest(entries: ToolPackageManifest["entries"]): ToolPackageManifest { - return { schemaVersion: "1", topLevel: [], entries }; -} - -describe("assetReferenceFormats", () => { - test("classifies each asset by the formats it is referenced with", () => { - const formats = assetReferenceFormats( - manifest([ - { - name: "@x/a", - version: "1.0.0", - source: { - kind: "asset", - assetId: "asset_src", - package: { - format: "source", - commitSha: "c1", - packageDir: ".", - treeOid: "t1", - }, - }, - }, - { - name: "@x/b", - version: "1.0.0", - source: { - kind: "asset", - assetId: "asset_src", - package: { - format: "source", - commitSha: "c1", - packageDir: "packages/b", - treeOid: "t2", - }, - }, - }, - { - name: "@x/c", - version: "1.0.0", - source: { - kind: "asset", - assetId: "asset_tar", - package: { - format: "tarball", - path: "tarballs/c.tgz", - integrity: "sha512-c", - }, - }, - }, - { - name: "left-pad", - version: "1.3.0", - source: { kind: "registry", registry: "npm", integrity: "sha512-lp" }, - }, - ]), - ); - - expect(formats.get("asset_src")).toEqual({ tarball: false, source: true }); - expect(formats.get("asset_tar")).toEqual({ tarball: true, source: false }); - // A registry entry backs no asset. - expect(formats.has("npm")).toBe(false); - expect(formats.size).toBe(2); - }); - - test("marks an asset referenced both ways as needing both checkouts", () => { - const formats = assetReferenceFormats( - manifest([ - { - name: "@x/a", - version: "1.0.0", - source: { - kind: "asset", - assetId: "asset_dual", - package: { - format: "source", - commitSha: "c1", - packageDir: ".", - treeOid: "t1", - }, - }, - }, - { - name: "@x/b", - version: "1.0.0", - source: { - kind: "asset", - assetId: "asset_dual", - package: { - format: "tarball", - path: "tarballs/b.tgz", - integrity: "sha512-b", - }, - }, - }, - ]), - ); - - expect(formats.get("asset_dual")).toEqual({ tarball: true, source: true }); - }); -}); - -describe("sourceAssetGitDir", () => { - test("joins a safe assetId under the gitDir root", () => { - expect(sourceAssetGitDir("/root/gits", "ast_wf-1.2")).toBe( - path.join("/root/gits", "ast_wf-1.2"), - ); - }); - - test.each([ - ["a/b", "slash"], - ["../escape", "parent traversal"], - ["with space", "space"], - ["", "empty"], - // All-dots ids satisfy SAFE_ASSET_ID (which permits ".") but escape or - // collapse the per-asset dir, so they must be rejected explicitly. - ["..", "bare parent"], - [".", "current dir"], - ["...", "triple dot"], - ])("rejects an unsafe assetId (%s)", (assetId) => { - expect(() => sourceAssetGitDir("/root/gits", assetId)).toThrow(/unsafe assetId/); - }); -}); - -describe("indexAssetPackIntoGitDir", () => { - const tempDirs: string[] = []; - - afterEach(async () => { - const dirs = tempDirs.splice(0); - await Promise.all(dirs.map((d) => fsp.rm(d, { recursive: true, force: true }))); - }); - - async function tempDir(): Promise { - const d = await fsp.mkdtemp(path.join(os.tmpdir(), "source-delivery-test-")); - tempDirs.push(d); - return d; - } - - // Pack a one-commit repo and return its commit sha plus a pack of every - // object reachable from it. - async function buildPack(): Promise<{ commitSha: string; pack: Uint8Array }> { - const dir = await tempDir(); - await git.init({ fs, dir, defaultBranch: "main" }); - await fsp.writeFile(path.join(dir, "package.json"), '{"name":"x"}\n'); - await git.add({ fs, dir, filepath: "package.json" }); - const commitSha = await git.commit({ - fs, - dir, - message: "t", - author: { name: "t", email: "t@t.dev" }, - }); - const oids = await collectReachableObjects(dir, commitSha); - const { packfile } = await git.packObjects({ fs, dir, oids }); - if (packfile === undefined) { - throw new Error("source-delivery test: packObjects returned no packfile"); - } - return { commitSha, pack: packfile }; - } - - test("retains the durable gitDir on success", async () => { - const root = await tempDir(); - const gitDir = path.join(root, "asset-ok"); - const { commitSha, pack } = await buildPack(); - - await indexAssetPackIntoGitDir({ pack, commitSha, gitDir }); - - expect(fs.existsSync(path.join(gitDir, ".git"))).toBe(true); - }); - - test("leaves no durable gitDir when indexing fails", async () => { - // The pin is absent from the pack, so indexing throws. The atomic - // temp+rename build means the final gitDir is never created -- restore's - // dir-exists gate must not find a partial store. - const root = await tempDir(); - const gitDir = path.join(root, "asset-bad"); - const { pack } = await buildPack(); - - await expect( - indexAssetPackIntoGitDir({ pack, commitSha: "0".repeat(40), gitDir }), - ).rejects.toThrow(/not found in the pack/); - - expect(fs.existsSync(gitDir)).toBe(false); - }); -}); diff --git a/apps/sidecar/src/step-agent-tools.test.ts b/apps/sidecar/src/step-agent-tools.test.ts deleted file mode 100644 index 45a33f3db..000000000 --- a/apps/sidecar/src/step-agent-tools.test.ts +++ /dev/null @@ -1,683 +0,0 @@ -// LSP-lifecycle seam test for the workflow-process child's tool-bearing -// agent factory. -// -// What this asserts, precisely: -// - The agent factory built by `createToolBearingAgentFactory` runs -// each materialized plugin factory when it builds the step's agent -// (the plugin chain mirrors `default-harness.ts`). -// - The plugin's `dispose` runs when `agent.close()` is called, and -// `agent.close()` is what the step-invoker adapter calls in its -// `finally` on every exit path. -// -// What this does NOT assert: a real language server protocol exchange. -// The real LSP plugin (`@intx/tools-lsp` `createLSPPlugin`) spawns its -// server subprocess LAZILY -- only when a tool touches a file -- and its -// `dispose` chains to `lsp.dispose()`, which terminates whatever server -// subprocesses were spawned. This test stands in a plugin whose factory -// spawns a REAL subprocess eagerly and whose `dispose` kills it, so the -// load-bearing seam under test -- "the child's agent.close() tears down -// the plugin's subprocess" -- is exercised against a real OS process -// without depending on a language-server binary being present in CI. -// The LSP-specific lazy-spawn behavior is covered by the `tools-lsp` -// package's own tests; what is sidecar-specific (and new in Phase 2) is -// the close -> plugin-dispose wiring proven here. - -import { describe, test, expect, afterEach } from "bun:test"; - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { - definePlugin, - defineTool, - type AnnotatedToolFactory, - type BaseEnv, - type ToolBundle, - type ToolDeclaration, -} from "@intx/agent"; -import { createDefaultDirectorRegistry } from "@intx/agent"; -import { evaluateGrants } from "@intx/authz"; -import type { HostCredentialCapability } from "@intx/harness"; -import { createIsogitStore } from "@intx/storage-isogit/node"; -import { noopAuditStore } from "@intx/agent/testing"; -import type { GrantRule } from "@intx/types/authz"; -import type { InferenceSource } from "@intx/types/runtime"; -import { - createRuntimeCapabilities, - type RuntimeCapabilities, -} from "@intx/types/runtime-capabilities"; -import type { LoadedToolFactory } from "@intx/tool-packaging"; - -import { - attachStepTools, - createToolBearingAgentFactory, - deriveToolMarkFloorGrants, - rewrapStepToolFactory, - stepDeployTreeDir, - type StepToolMaterialization, -} from "./step-agent-tools"; - -const tempDirs: string[] = []; - -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((d) => fs.promises.rm(d, { recursive: true, force: true })), - ); -}); - -async function tempDir(): Promise { - const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), "step-agent-tools-test-")); - tempDirs.push(d); - return d; -} - -const SOURCE: InferenceSource = { - id: "anthropic:mock-model", - provider: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: "sk-test", - model: "mock-model", -}; - -async function buildStepEnv(): Promise { - const dir = await tempDir(); - const workdir = path.join(dir, "workspace"); - await fs.promises.mkdir(workdir, { recursive: true }); - const storage = await createIsogitStore(dir); - return { - sources: [SOURCE], - defaultSource: SOURCE.id, - storage, - workdir, - audit: noopAuditStore(), - authorize: async () => ({ - effect: "allow", - matchingGrants: [], - resolvedBy: null, - }), - directors: createDefaultDirectorRegistry(), - }; -} - -/** - * Returns `true` if a process with the given pid is alive. `kill(pid, 0)` - * throws ESRCH when the process does not exist and EPERM when it exists - * but is not signalable by this user; either non-throw / EPERM means - * "alive", ESRCH means "gone". - */ -function isAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - if (err instanceof Error && "code" in err && err.code === "EPERM") { - return true; - } - return false; - } -} - -describe("createToolBearingAgentFactory plugin/LSP lifecycle", () => { - test("agent.close() runs the plugin disposer and tears down its subprocess", async () => { - // A plugin standing in for the LSP plugin: its factory spawns a REAL - // subprocess (a sleeping shell) and its `dispose` kills it. This is - // the same shape `createLSPPlugin` produces -- a `ToolPlugin` whose - // `dispose` terminates a server subprocess -- minus the lazy spawn. - let spawnedPid: number | undefined; - let disposeCalls = 0; - const lspLikePlugin = definePlugin({ - id: "@intx/tools-lsp-fake/sidecar-bundle", - factory: () => { - const proc = Bun.spawn(["sleep", "120"], { - stdout: "ignore", - stderr: "ignore", - }); - spawnedPid = proc.pid; - return { - tools: [], - dispose: () => { - disposeCalls += 1; - proc.kill(); - }, - }; - }, - }); - - // A trivial tool factory so the agent has at least one tool bundle. - const noopTool = defineTool({ - id: "@intx/test-tool/sidecar-bundle", - requires: [], - definitions: [], - factory: (): ToolBundle => ({ - definitions: [], - run: (call) => Promise.resolve({ callId: call.id, content: "" }), - }), - }); - - const materialization: StepToolMaterialization = { - factories: [ - { - packageName: "@intx/test-tool", - declaredCredentials: [], - factory: noopTool, - }, - ], - pluginFactories: [lspLikePlugin], - }; - - const env = await buildStepEnv(); - attachStepTools(env, materialization); - // The step-invoker adapter spreads the env (`{ ...envBase, authorize }`) - // before handing it to the agent factory; replicate that spread so the - // test exercises the symbol-slot-survives-spread path. - const spreadEnv: BaseEnv = { ...env }; - - const agentFactory = createToolBearingAgentFactory(); - const def = { - id: "agent-lsp-lifecycle", - systemPrompt: "lsp lifecycle test", - toolFactories: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "mock-model" }] }, - } as const; - - const agent = await agentFactory(def, spreadEnv); - - // The plugin factory ran during agent build: its subprocess is live. - if (spawnedPid === undefined) { - throw new Error("plugin factory did not spawn a subprocess"); - } - expect(isAlive(spawnedPid)).toBe(true); - expect(disposeCalls).toBe(0); - - // Closing the agent must run the plugin disposer, which kills the - // subprocess. This is the exact call the step-invoker adapter makes - // in its `finally`. - await agent.close(); - - expect(disposeCalls).toBe(1); - // The process is reaped; allow a brief moment for the OS to reflect - // the kill. - let alive = isAlive(spawnedPid); - for (let i = 0; i < 50 && alive; i += 1) { - await new Promise((r) => setTimeout(r, 20)); - alive = isAlive(spawnedPid); - } - expect(alive).toBe(false); - }); - - test("a plugin factory that throws mid-chain disposes the already-built plugins", async () => { - // Mirrors `default-harness.ts`'s plugin-construction rollback: if a - // later plugin factory throws, every earlier plugin instance must be - // disposed so a partial-success chain does not leak (the LSP server - // subprocess being the resource that would leak in production). - let spawnedPid: number | undefined; - let disposed = false; - const firstPlugin = definePlugin({ - id: "@intx/first-plugin/sidecar-bundle", - factory: () => { - const proc = Bun.spawn(["sleep", "120"], { - stdout: "ignore", - stderr: "ignore", - }); - spawnedPid = proc.pid; - return { - tools: [], - dispose: () => { - disposed = true; - proc.kill(); - }, - }; - }, - }); - const throwingPlugin = definePlugin({ - id: "@intx/throwing-plugin/sidecar-bundle", - factory: () => { - throw new Error("plugin construction failure"); - }, - }); - - const materialization: StepToolMaterialization = { - factories: [], - pluginFactories: [firstPlugin, throwingPlugin], - }; - - const env = await buildStepEnv(); - attachStepTools(env, materialization); - - const agentFactory = createToolBearingAgentFactory(); - const def = { - id: "agent-plugin-rollback", - systemPrompt: "plugin rollback test", - toolFactories: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "mock-model" }] }, - } as const; - - await expect(agentFactory(def, { ...env })).rejects.toThrow(/plugin construction failure/); - - expect(disposed).toBe(true); - if (spawnedPid === undefined) { - throw new Error("first plugin factory did not spawn a subprocess"); - } - let alive = isAlive(spawnedPid); - for (let i = 0; i < 50 && alive; i += 1) { - await new Promise((r) => setTimeout(r, 20)); - alive = isAlive(spawnedPid); - } - expect(alive).toBe(false); - }); - - test("agent.close() rejects with an AggregateError when a disposer fails, and still runs the rest", async () => { - // The underlying agent close succeeds; a plugin disposer (standing in - // for the LSP subprocess kill) then throws. That failure must surface - // through close() rather than be swallowed -- a leaked LSP subprocess - // is otherwise invisible -- and one failing disposer must not strand - // the others. - const disposeError = new Error("lsp dispose boom"); - let survivorDisposed = 0; - const throwingPlugin = definePlugin({ - id: "@intx/throwing-disposer/sidecar-bundle", - factory: () => ({ - tools: [], - dispose: () => { - throw disposeError; - }, - }), - }); - const survivorPlugin = definePlugin({ - id: "@intx/survivor-disposer/sidecar-bundle", - factory: () => ({ - tools: [], - dispose: () => { - survivorDisposed += 1; - }, - }), - }); - - const materialization: StepToolMaterialization = { - factories: [], - pluginFactories: [throwingPlugin, survivorPlugin], - }; - - const env = await buildStepEnv(); - attachStepTools(env, materialization); - - const agentFactory = createToolBearingAgentFactory(); - const def = { - id: "agent-disposer-aggregate", - systemPrompt: "disposer aggregate test", - toolFactories: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "mock-model" }] }, - } as const; - - const agent = await agentFactory(def, { ...env }); - - let thrown: unknown; - try { - await agent.close(); - } catch (cause) { - thrown = cause; - } - expect(thrown).toBeInstanceOf(AggregateError); - if (!(thrown instanceof AggregateError)) { - throw new Error("expected an AggregateError from a failing teardown"); - } - expect(thrown.errors).toContain(disposeError); - // The loop kept going after the first disposer threw: the other ran. - expect(survivorDisposed).toBe(1); - }); - - test("a disposer that throws during construction rollback does not mask the construction error", async () => { - // On a construction-failure rollback the pending construction error is - // the one worth surfacing; a disposer failure during that rollback is - // logged but must never replace it. - const firstPlugin = definePlugin({ - id: "@intx/rollback-throwing-disposer/sidecar-bundle", - factory: () => ({ - tools: [], - dispose: () => { - throw new Error("rollback dispose boom"); - }, - }), - }); - const throwingPlugin = definePlugin({ - id: "@intx/rollback-construction-throw/sidecar-bundle", - factory: () => { - throw new Error("plugin construction failure"); - }, - }); - - const materialization: StepToolMaterialization = { - factories: [], - pluginFactories: [firstPlugin, throwingPlugin], - }; - - const env = await buildStepEnv(); - attachStepTools(env, materialization); - - const agentFactory = createToolBearingAgentFactory(); - const def = { - id: "agent-rollback-dispose-mask", - systemPrompt: "rollback dispose mask test", - toolFactories: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "mock-model" }] }, - } as const; - - await expect(agentFactory(def, { ...env })).rejects.toThrow(/plugin construction failure/); - }); - - test("a second close() after a failed teardown neither re-runs disposers nor rethrows", async () => { - let disposeCalls = 0; - const throwingPlugin = definePlugin({ - id: "@intx/double-close-disposer/sidecar-bundle", - factory: () => ({ - tools: [], - dispose: () => { - disposeCalls += 1; - throw new Error("dispose boom"); - }, - }), - }); - - const materialization: StepToolMaterialization = { - factories: [], - pluginFactories: [throwingPlugin], - }; - - const env = await buildStepEnv(); - attachStepTools(env, materialization); - - const agentFactory = createToolBearingAgentFactory(); - const def = { - id: "agent-double-close", - systemPrompt: "double close test", - toolFactories: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "mock-model" }] }, - } as const; - - const agent = await agentFactory(def, { ...env }); - - await expect(agent.close()).rejects.toBeInstanceOf(AggregateError); - expect(disposeCalls).toBe(1); - // wrapAgentClose guards teardown behind `tornDown`, so a second close - // is a no-op: it neither re-runs the disposer nor re-surfaces the - // failure. - await agent.close(); - expect(disposeCalls).toBe(1); - }); -}); - -describe("stepDeployTreeDir base-step resolution", () => { - const dataDir = "/data"; - const mailboxAddress = "run_dep-map@example.com"; - - test("a map iteration resolves the base step's deploy tree", () => { - // Deploy stages one deploy tree per base step; every map iteration - // `[]` must read that one tree, not a per-iteration address - // that was never staged. - const base = stepDeployTreeDir({ - dataDir, - mailboxAddress, - stepId: "summarize", - stepCount: 2, - }); - const iter0 = stepDeployTreeDir({ - dataDir, - mailboxAddress, - stepId: "summarize[0]", - stepCount: 2, - }); - const iter1 = stepDeployTreeDir({ - dataDir, - mailboxAddress, - stepId: "summarize[1]", - stepCount: 2, - }); - expect(iter0).toBe(base); - expect(iter1).toBe(base); - }); - - test("distinct base steps still resolve distinct deploy trees", () => { - const a = stepDeployTreeDir({ - dataDir, - mailboxAddress, - stepId: "alpha[0]", - stepCount: 2, - }); - const b = stepDeployTreeDir({ - dataDir, - mailboxAddress, - stepId: "beta[0]", - stepCount: 2, - }); - expect(a).not.toBe(b); - }); -}); - -describe("rewrapStepToolFactory", () => { - test("preserves the source factory's static definitions on the re-wrap", () => { - const source = defineTool({ - id: "@intx/test-tool/sidecar-bundle", - requires: ["transport"], - definitions: [{ name: "alpha" }, { name: "beta" }], - factory: (): ToolBundle => ({ - definitions: [], - run: (call) => Promise.resolve({ callId: call.id, content: "" }), - }), - }); - - // This test only inspects the re-wrapped factory's static metadata; - // it never invokes the factory, so the disposer-capture callback - // must not fire. - const rewrapped = rewrapStepToolFactory( - source, - () => { - throw new Error("onDispose must not be called: factory is not invoked"); - }, - undefined, - ); - - expect(rewrapped.definitions).toEqual(source.definitions); - expect(rewrapped.id).toBe(source.id); - expect(rewrapped.requires).toEqual(source.requires); - }); - - test("layers the given credentials capability onto the bundle's env", async () => { - let seenEnv: BaseEnv | undefined; - const source = defineTool({ - id: "@intx/test-tool/sidecar-bundle", - requires: ["capabilities"], - definitions: [], - factory: (factoryEnv): ToolBundle => { - seenEnv = factoryEnv; - return { - definitions: [], - run: (call) => Promise.resolve({ callId: call.id, content: "" }), - }; - }, - }); - const capability: HostCredentialCapability = { - resolve: () => Promise.reject(new Error("resolve unused in this test")), - dispose: () => Promise.resolve(), - }; - - const rewrapped = rewrapStepToolFactory( - source, - () => { - /* the bundle returns no disposer, so this never fires */ - }, - capability, - ); - - const env = await buildStepEnv(); - // The base bag buildEnv would set; an empty one suffices to prove the - // credentials key is layered on top of it for this bundle. - Reflect.set(env, "capabilities", createRuntimeCapabilities({})); - rewrapped(env); - - if (seenEnv === undefined) { - throw new Error("the bundle factory was not invoked"); - } - // The bundle sees a layered bag resolving THIS package's capability -- the - // seam that must never hand a bundle another package's capability. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the slot is the RuntimeCapabilities resolver this test set - const bag = Reflect.get(seenEnv, "capabilities") as RuntimeCapabilities; - expect(bag.resolve("credentials")).toBe(capability); - }); - - test("passes the base env through unchanged when no capability is layered", async () => { - let seenEnv: BaseEnv | undefined; - const source = defineTool({ - id: "@intx/test-tool/sidecar-bundle", - requires: ["capabilities"], - definitions: [], - factory: (factoryEnv): ToolBundle => { - seenEnv = factoryEnv; - return { - definitions: [], - run: (call) => Promise.resolve({ callId: call.id, content: "" }), - }; - }, - }); - - const rewrapped = rewrapStepToolFactory( - source, - () => { - /* no disposer captured in this test */ - }, - undefined, - ); - - const env = await buildStepEnv(); - Reflect.set(env, "capabilities", createRuntimeCapabilities({})); - rewrapped(env); - - if (seenEnv === undefined) { - throw new Error("the bundle factory was not invoked"); - } - // No capability -> the exact base env is passed through, no credentials key - // layered on, so a resolve fails closed as not-provided. - expect(seenEnv).toBe(env); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the slot is the RuntimeCapabilities resolver this test set - const bag = Reflect.get(seenEnv, "capabilities") as RuntimeCapabilities; - expect(() => bag.resolve("credentials")).toThrow(); - }); -}); - -describe("deriveToolMarkFloorGrants", () => { - // A loaded tool factory carrying only the static metadata the floor - // deriver reads (`definitions`), shaped like the pinned factories the - // child's loader hands back. Never invoked here. - function loadedFactory(id: string, definitions: readonly ToolDeclaration[]): LoadedToolFactory { - const factory: AnnotatedToolFactory = Object.assign( - (_env: BaseEnv) => ({ - definitions: [], - run: () => Promise.resolve({ callId: "", content: "", isError: false as const }), - }), - { id, requires: [] as readonly string[], definitions }, - ); - return factory; - } - - // Mirror the sidecar's grant evaluator merge: the credentials snapshot's - // grants plus the derived floor, resolved via `evaluateGrants`. This is - // the exact composition the `evaluateGrantsAdapter` performs. - async function resolveWithFloor( - toolName: string, - factories: readonly LoadedToolFactory[], - snapshotGrants: readonly GrantRule[], - ): Promise<"allow" | "deny" | "ask" | null> { - const floor = deriveToolMarkFloorGrants(factories); - const result = await evaluateGrants( - [...snapshotGrants, ...floor], - `tool:${toolName}`, - "invoke", - ); - return result.effect; - } - - test("a pinned ask-marked tool resolves to ask on its floor alone", async () => { - const factories = [ - loadedFactory("@intx/tools-posix/sidecar-bundle", [{ name: "run_shell", approval: "ask" }]), - ]; - expect(await resolveWithFloor("run_shell", factories, [])).toBe("ask"); - }); - - test("a pinned unmarked tool resolves to allow on its floor alone", async () => { - const factories = [loadedFactory("@intx/tools-mail/sidecar-bundle", [{ name: "mail_send" }])]; - expect(await resolveWithFloor("mail_send", factories, [])).toBe("allow"); - }); - - test("a declared deny still beats the derived floor", async () => { - const factories = [ - loadedFactory("@intx/tools-posix/sidecar-bundle", [{ name: "run_shell", approval: "ask" }]), - ]; - // A credentials-snapshot grant explicitly denying the tool at equal - // specificity. `deny` (priority 2) outranks the derived `ask`, so the - // floor never overrides an explicit denial. - const declaredDeny: GrantRule = { - id: "declared-deny", - resource: "tool:run_shell", - action: "invoke", - effect: "deny", - origin: "creator", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }; - expect(await resolveWithFloor("run_shell", factories, [declaredDeny])).toBe("deny"); - }); - - test("an allow grant at equal specificity does not drop the ask floor", async () => { - const factories = [ - loadedFactory("@intx/tools-posix/sidecar-bundle", [{ name: "run_shell", approval: "ask" }]), - ]; - // A run grant explicitly ALLOWING the tool at the SAME specificity as the - // derived floor (both `tool:run_shell`/`invoke`, exact match). `ask` - // (priority 1) outranks `allow` (priority 0) at equal specificity, so the - // floor holds -- a workflow cannot declare its way below a tool's - // approval gate. This is the `ask > allow` half of the effect ordering in - // packages/authz/src/evaluate.ts. - const declaredAllow: GrantRule = { - id: "declared-allow", - resource: "tool:run_shell", - action: "invoke", - effect: "allow", - origin: "creator", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }; - expect(await resolveWithFloor("run_shell", factories, [declaredAllow])).toBe("ask"); - }); - - test("a broader allow glob loses to the exact ask floor on specificity", async () => { - const factories = [ - loadedFactory("@intx/tools-posix/sidecar-bundle", [{ name: "run_shell", approval: "ask" }]), - ]; - // A run grant allowing EVERY tool via a `tool:*` glob. The glob is far - // less specific than the exact `tool:run_shell` floor, so specificity -- - // ranked before effect -- resolves to the exact floor regardless of - // effect. The exact `ask` wins; the broad `allow` never enters the effect - // tie-break. - const broadAllow: GrantRule = { - id: "broad-allow", - resource: "tool:*", - action: "invoke", - effect: "allow", - origin: "creator", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }; - expect(await resolveWithFloor("run_shell", factories, [broadAllow])).toBe("ask"); - }); -}); diff --git a/apps/sidecar/src/step-credential-capabilities.test.ts b/apps/sidecar/src/step-credential-capabilities.test.ts deleted file mode 100644 index 847ad00e9..000000000 --- a/apps/sidecar/src/step-credential-capabilities.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import { toolConsumer, type GrantRule } from "@intx/authz"; -import { defineTool, type ToolBundle } from "@intx/agent"; -import type { CredentialProvider, CredentialShapeContext } from "@intx/types"; -import type { CredentialDelivery } from "@intx/types/sidecar"; -import type { ToolCredentialDeclaration } from "@intx/types/package-json"; -import { createCredentialProviderRegistry } from "@intx/harness"; - -import { buildCredentialCapabilities } from "./step-credential-capabilities"; -import type { StepToolFactory } from "./tool-materialization"; - -// A provider that records every shape context it is handed, so a test can -// reach the `readCurrentMaterial` closure a shaped handle reads through. -function trackingProvider(): { - provider: CredentialProvider; - shapes: CredentialShapeContext[]; -} { - const shapes: CredentialShapeContext[] = []; - const provider: CredentialProvider = { - key: "fake", - shape(ctx) { - shapes.push(ctx); - return { - kind: "http", - fetch: async () => new Response(), - dispose() { - /* no-op: the fake http handle holds nothing to release */ - }, - }; - }, - }; - return { provider, shapes }; -} - -function grant( - overrides: Partial & Pick, -): GrantRule { - return { - id: "grt_test", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - ...overrides, - }; -} - -// A StepToolFactory whose `factory` is a never-invoked stub: the assembly reads -// only `packageName` and `declaredCredentials`. -function fac( - packageName: string, - declaredCredentials: ToolCredentialDeclaration[], -): StepToolFactory { - return { - packageName, - declaredCredentials, - factory: defineTool({ - id: `${packageName}/bundle`, - requires: [], - definitions: [], - factory: (): ToolBundle => ({ - definitions: [], - // Never invoked: the assembly reads only packageName + declarations. - run: () => Promise.reject(new Error("stub tool bundle: run is unused")), - }), - }), - }; -} - -const ORIGIN = "https://api.example.com"; - -describe("buildCredentialCapabilities", () => { - test("a credential granted to one package cannot be resolved by another (confused deputy)", async () => { - const track = trackingProvider(); - // The same credential is bound for both packages, but only pkg-a holds the - // credential:c1/use grant scoped to its consumer. - const cell = { - current: { - bindings: [ - { - handle: "shared", - credentialId: "c1", - consumer: toolConsumer("@intx/pkg-a"), - }, - { - handle: "shared", - credentialId: "c1", - consumer: toolConsumer("@intx/pkg-b"), - }, - ], - materials: [ - { - credentialId: "c1", - providerKey: track.provider.key, - origin: ORIGIN, - secret: "sk-1", - }, - ], - }, - }; - const caps = buildCredentialCapabilities([fac("@intx/pkg-a", []), fac("@intx/pkg-b", [])], { - materialCell: cell, - resolveGrants: () => [ - grant({ - resource: "credential:c1", - action: "use", - effect: "allow", - conditions: { tool: toolConsumer("@intx/pkg-a") }, - }), - ], - providers: createCredentialProviderRegistry([track.provider]), - }); - - const capA = caps.get("@intx/pkg-a"); - const capB = caps.get("@intx/pkg-b"); - if (capA === undefined || capB === undefined) { - throw new Error("expected a capability for both packages"); - } - - // pkg-a's grant authorizes it; pkg-b binds the same credential but holds no - // grant for it, so Gate 2 refuses -- the credential does not leak sideways. - await expect(capA.resolve("shared")).resolves.toMatchObject({ - kind: "http", - }); - await expect(capB.resolve("shared")).rejects.toThrow(/not authorized/); - }); - - test("a declared handle no binding resolves fails the build closed", () => { - const track = trackingProvider(); - const cell = { current: { bindings: [], materials: [] } }; - expect(() => - buildCredentialCapabilities([fac("@intx/pkg-a", [{ handle: "needed" }])], { - materialCell: cell, - resolveGrants: () => [], - providers: createCredentialProviderRegistry([track.provider]), - }), - ).toThrow(/declares credential handle\(s\) that no binding resolves: needed/); - }); - - test("dropping a credential's material starves an already-shaped handle", async () => { - const track = trackingProvider(); - const cell: { current: CredentialDelivery | null } = { - current: { - bindings: [ - { - handle: "cred", - credentialId: "c1", - consumer: toolConsumer("@intx/pkg-a"), - }, - ], - materials: [ - { - credentialId: "c1", - providerKey: track.provider.key, - origin: ORIGIN, - secret: "sk-1", - }, - ], - }, - }; - const caps = buildCredentialCapabilities([fac("@intx/pkg-a", [])], { - materialCell: cell, - resolveGrants: () => [ - grant({ - resource: "credential:c1", - action: "use", - effect: "allow", - conditions: { tool: toolConsumer("@intx/pkg-a") }, - }), - ], - providers: createCredentialProviderRegistry([track.provider]), - }); - const capA = caps.get("@intx/pkg-a"); - if (capA === undefined) throw new Error("expected a capability for pkg-a"); - - await capA.resolve("cred"); - const shaped = track.shapes[0]; - if (shaped === undefined) throw new Error("expected the handle to be shaped"); - - // Live read works while the material is delivered. - expect(shaped.readCurrentMaterial()).toEqual({ secret: "sk-1" }); - - // A re-push that revokes pkg-a drops c1's material: the already-shaped - // handle starves on its next read rather than serving a stale secret. - cell.current = { bindings: [], materials: [] }; - expect(() => shaped.readCurrentMaterial()).toThrow(/no longer delivered/); - - // An emptied cell (no delivery at all) fails closed the same way. - cell.current = null; - expect(() => shaped.readCurrentMaterial()).toThrow(/cell is empty/); - }); - - test("a material whose provider or origin drifted under a shaped handle is refused", async () => { - const track = trackingProvider(); - const bindings = [ - { - handle: "cred", - credentialId: "c1", - consumer: toolConsumer("@intx/pkg-a"), - }, - ]; - const cell: { current: CredentialDelivery | null } = { - current: { - bindings, - materials: [ - { - credentialId: "c1", - providerKey: track.provider.key, - origin: ORIGIN, - secret: "sk-1", - }, - ], - }, - }; - const caps = buildCredentialCapabilities([fac("@intx/pkg-a", [])], { - materialCell: cell, - resolveGrants: () => [ - grant({ - resource: "credential:c1", - action: "use", - effect: "allow", - conditions: { tool: toolConsumer("@intx/pkg-a") }, - }), - ], - providers: createCredentialProviderRegistry([track.provider]), - }); - const capA = caps.get("@intx/pkg-a"); - if (capA === undefined) throw new Error("expected a capability for pkg-a"); - await capA.resolve("cred"); - const shaped = track.shapes[0]; - if (shaped === undefined) throw new Error("expected the handle to be shaped"); - - // A rotation may change the secret, never the provider/origin. A live entry - // whose origin drifted is refused rather than followed. - cell.current = { - bindings, - materials: [ - { - credentialId: "c1", - providerKey: track.provider.key, - origin: "https://api.attacker.example", - secret: "sk-2", - }, - ], - }; - expect(() => shaped.readCurrentMaterial()).toThrow(/changed provider\/origin/); - }); - - test("a descriptor with no backing material fails the build closed", () => { - const track = trackingProvider(); - const cell = { - current: { - bindings: [ - { - handle: "cred", - credentialId: "c1", - consumer: toolConsumer("@intx/pkg-a"), - }, - ], - materials: [], - }, - }; - expect(() => - buildCredentialCapabilities([fac("@intx/pkg-a", [])], { - materialCell: cell, - resolveGrants: () => [], - providers: createCredentialProviderRegistry([track.provider]), - }), - ).toThrow(/delivery is malformed/); - }); - - test("a package that declares and binds no credential gets no capability", () => { - const track = trackingProvider(); - // A credential is delivered, but only for a different package's consumer. - const cell = { - current: { - bindings: [ - { - handle: "x", - credentialId: "c1", - consumer: toolConsumer("@intx/other"), - }, - ], - materials: [ - { - credentialId: "c1", - providerKey: track.provider.key, - origin: ORIGIN, - secret: "sk-1", - }, - ], - }, - }; - const caps = buildCredentialCapabilities([fac("@intx/pkg-a", [])], { - materialCell: cell, - resolveGrants: () => [], - providers: createCredentialProviderRegistry([track.provider]), - }); - expect(caps.has("@intx/pkg-a")).toBe(false); - }); - - // The grants thunk must be read only when a package actually needs a - // capability. This pins the self-discovery-resume fix: a toolless resume - // precedes the grants barrier, so resolving grants for it would fault on a - // snapshot that is not present yet. The "no capability" test above passes a - // benign `() => []`, so it stays green whether grants resolve lazily or - // eagerly -- these two use a throwing thunk to catch a laziness regression. - const throwingGrants = (): readonly GrantRule[] => { - throw new Error("resolveGrants must not be called for a step needing none"); - }; - - test("a step whose packages need no capability never resolves grants", () => { - const track = trackingProvider(); - const cell = { current: { bindings: [], materials: [] } }; - expect(() => - buildCredentialCapabilities([fac("@intx/pkg-a", [])], { - materialCell: cell, - resolveGrants: throwingGrants, - providers: createCredentialProviderRegistry([track.provider]), - }), - ).not.toThrow(); - }); - - test("grants are resolved once a package needs a capability", () => { - const track = trackingProvider(); - const cell = { - current: { - bindings: [ - { - handle: "cred", - credentialId: "c1", - consumer: toolConsumer("@intx/pkg-a"), - }, - ], - materials: [ - { - credentialId: "c1", - providerKey: track.provider.key, - origin: ORIGIN, - secret: "sk-1", - }, - ], - }, - }; - expect(() => - buildCredentialCapabilities([fac("@intx/pkg-a", [])], { - materialCell: cell, - resolveGrants: throwingGrants, - providers: createCredentialProviderRegistry([track.provider]), - }), - ).toThrow(/resolveGrants must not be called/); - }); -}); diff --git a/apps/sidecar/src/tool-materialization.test.ts b/apps/sidecar/src/tool-materialization.test.ts deleted file mode 100644 index 15ee9aac6..000000000 --- a/apps/sidecar/src/tool-materialization.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { describe, test, expect, afterEach } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { - clearDirtyMarker, - materializeToolPackages, - parseActiveDeployId, - persistActiveDeployIdWithFallback, -} from "./tool-materialization"; - -const tempDirs: string[] = []; - -async function tempDir(): Promise { - const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), "default-harness-test-")); - tempDirs.push(d); - return d; -} - -afterEach(async () => { - const dirs = tempDirs.splice(0); - await Promise.all(dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true }))); -}); - -describe("materializeToolPackages — manifest.invalid gate", () => { - test("returns empty factories when no manifest bytes supplied", async () => { - const storeDir = await tempDir(); - const result = await materializeToolPackages({ - assetMounts: new Map(), - rawManifestBytes: undefined, - storeDir, - agentAddress: "agent-no-manifest", - cacheRoot: "/unused-by-manifest-invalid-gate", - cacheMaxBytes: 1024 * 1024, - registryMaxTarballBytes: 10 * 1024 * 1024, - }); - expect(result.factories).toEqual([]); - expect(result.pluginFactories).toEqual([]); - }); - - test("JSON.parse failure throws and persists the raw bytes verbatim", async () => { - const storeDir = await tempDir(); - const corrupt = "{this is not valid json"; - let caught: unknown; - try { - await materializeToolPackages({ - assetMounts: new Map(), - rawManifestBytes: corrupt, - storeDir, - agentAddress: "agent-corrupt", - cacheRoot: "/unused-by-manifest-invalid-gate", - cacheMaxBytes: 1024 * 1024, - registryMaxTarballBytes: 10 * 1024 * 1024, - }); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(Error); - expect(String(caught)).toMatch(/manifest\.invalid/); - expect(String(caught)).toMatch(/JSON\.parse failed/); - - const auditRoot = path.join(storeDir, "audit", "rejected-applies"); - const attemptDirs = await fs.promises.readdir(auditRoot); - expect(attemptDirs).toHaveLength(1); - const attemptDirName = attemptDirs[0]; - if (attemptDirName === undefined) { - throw new Error("no attempt dir was created"); - } - const attemptDir = path.join(auditRoot, attemptDirName); - - // The manifest file must contain the exact corrupt bytes, not a - // JSON-encoded string literal wrapping them — the audit-trail - // writer must not push the bytes through JSON.stringify, which - // would double-encode the string and erase the original-input - // evidence the audit file exists to preserve. - const persisted = await fs.promises.readFile(path.join(attemptDir, "manifest.json"), "utf-8"); - expect(persisted).toBe(corrupt); - - const errorJson = JSON.parse( - await fs.promises.readFile(path.join(attemptDir, "error.json"), "utf-8"), - ); - expect(errorJson.category).toBe("manifest.invalid"); - expect(errorJson.message).toMatch(/JSON\.parse failed/); - expect(errorJson.previousDeployId).toBe("none"); - expect(typeof errorJson.attemptId).toBe("string"); - expect(errorJson.attemptId.length).toBeGreaterThan(0); - }); - - test("schema failure throws and persists the raw bytes verbatim", async () => { - const storeDir = await tempDir(); - // Valid JSON but wrong shape — arktype rejects it. - const wrongShape = JSON.stringify({ schemaVersion: 99 }); - let caught: unknown; - try { - await materializeToolPackages({ - assetMounts: new Map(), - rawManifestBytes: wrongShape, - storeDir, - agentAddress: "agent-schema", - cacheRoot: "/unused-by-manifest-invalid-gate", - cacheMaxBytes: 1024 * 1024, - registryMaxTarballBytes: 10 * 1024 * 1024, - }); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(Error); - expect(String(caught)).toMatch(/manifest\.invalid/); - expect(String(caught)).toMatch(/schema validation failed/); - - const auditRoot = path.join(storeDir, "audit", "rejected-applies"); - const attemptDirs = await fs.promises.readdir(auditRoot); - expect(attemptDirs).toHaveLength(1); - const attemptDirName = attemptDirs[0]; - if (attemptDirName === undefined) { - throw new Error("no attempt dir was created"); - } - const attemptDir = path.join(auditRoot, attemptDirName); - - // The audit trail records the bytes-as-supplied for both - // failure modes, so a future investigator can replay the - // same input against a newer validator. - const persisted = await fs.promises.readFile(path.join(attemptDir, "manifest.json"), "utf-8"); - expect(persisted).toBe(wrongShape); - - const errorJson = JSON.parse( - await fs.promises.readFile(path.join(attemptDir, "error.json"), "utf-8"), - ); - expect(errorJson.category).toBe("manifest.invalid"); - }); - - describe("parseActiveDeployId", () => { - test("accepts a v1-prefixed id", () => { - expect(parseActiveDeployId("v1:abc-123", "/dummy")).toBe("abc-123"); - }); - - test("accepts a bare id for backward compatibility with pre-versioned files", () => { - expect(parseActiveDeployId("legacy-deploy-id", "/dummy")).toBe("legacy-deploy-id"); - }); - - test("rejects an unknown version prefix loudly", () => { - expect(() => parseActiveDeployId("v2:abc", "/dummy")).toThrow(/unknown version prefix/); - }); - - test("rejects a v1-prefixed but empty id", () => { - expect(() => parseActiveDeployId("v1:", "/dummy")).toThrow(/carries the v1 prefix but no id/); - }); - - test("rejects an empty file", () => { - expect(() => parseActiveDeployId("", "/dummy")).toThrow(/is empty/); - }); - }); - - describe("persistActiveDeployIdWithFallback", () => { - test("returns degraded=false and removes a stale dirty marker on full success", async () => { - const instanceDir = await tempDir(); - const activeIdFile = path.join(instanceDir, "active-deploy-id"); - const dirtyFile = `${activeIdFile}.dirty`; - await fs.promises.writeFile(dirtyFile, "v1:stale-id"); - - const outcome = await persistActiveDeployIdWithFallback( - instanceDir, - activeIdFile, - "fresh-id", - ); - expect(outcome.degraded).toBe(false); - const recorded = await fs.promises.readFile(activeIdFile, "utf-8"); - expect(recorded).toBe("v1:fresh-id"); - // The dirty marker must be cleared so the next boot reads the - // freshly-recorded id directly, not the stale marker that - // pre-dated the successful persist. - let dirtyExists = false; - try { - await fs.promises.access(dirtyFile); - dirtyExists = true; - } catch { - dirtyExists = false; - } - expect(dirtyExists).toBe(false); - }); - - test("the degraded-persist code path clears a stale dirty marker via the shared helper", async () => { - // The fsync'd primary persist and the no-fsync fallback persist - // both delegate to `clearDirtyMarker` after writing the recorded - // id. The fallback branch is hard to drive end-to-end without - // injecting an `fs` failure mode that distinguishes - // `fs.promises.open(path, "w")` from `fs.promises.writeFile(path)` - // — they share flags and permissions on every supported FS — so - // the contract is pinned at the helper level: when called against - // an instance dir that holds a stale marker, the marker is gone - // afterward. The wrapper's no-fsync branch routes through this - // helper, which gives the boot reader the freshly-recorded id on - // the next boot. - const instanceDir = await tempDir(); - const activeIdFile = path.join(instanceDir, "active-deploy-id"); - const dirtyFile = `${activeIdFile}.dirty`; - await fs.promises.writeFile(dirtyFile, "v1:stale-from-prior-apply"); - await clearDirtyMarker(activeIdFile, "test"); - let dirtyExists = false; - try { - await fs.promises.access(dirtyFile); - dirtyExists = true; - } catch { - dirtyExists = false; - } - expect(dirtyExists).toBe(false); - }); - - test("clearing a missing dirty marker is a no-op", async () => { - // ENOENT is the normal case when no prior apply wrote a marker. - // The helper must not throw or log when there is nothing to - // remove; the wrapper relies on the helper being safe to call - // unconditionally on every successful persist. - const instanceDir = await tempDir(); - const activeIdFile = path.join(instanceDir, "active-deploy-id"); - await clearDirtyMarker(activeIdFile, "test"); - }); - - test("writes the dirty marker when the primary persist cannot open the file", async () => { - // Simulate a primary persist failure by making activeIdFile itself - // a directory. Both the fsync'd open() and the no-fsync - // writeFile() will then fail with EISDIR, exercising the dirty- - // marker path. - const instanceDir = await tempDir(); - const activeIdFile = path.join(instanceDir, "active-deploy-id"); - await fs.promises.mkdir(activeIdFile); - - const outcome = await persistActiveDeployIdWithFallback( - instanceDir, - activeIdFile, - "new-id-42", - ); - expect(outcome.degraded).toBe(true); - expect(outcome.error).toBeInstanceOf(Error); - - const dirtyContents = await fs.promises.readFile(`${activeIdFile}.dirty`, "utf-8"); - expect(dirtyContents).toBe("v1:new-id-42"); - }); - }); - - test("boot reconciliation records the dirty marker as previousDeployId", async () => { - // Seed an instance where the prior apply could not durably record - // the committed deploy id and a dirty marker carries the truth. - // The next apply (any apply — here, a manifest-invalid one) must - // surface the marker's id as `previousDeployId` in the audit - // record, not the stale recorded id and not "none". - const storeDir = await tempDir(); - const instanceDir = path.join(storeDir, "tool-packages"); - await fs.promises.mkdir(instanceDir, { recursive: true }); - await fs.promises.writeFile(path.join(instanceDir, "active-deploy-id"), "v1:stale-recorded"); - await fs.promises.writeFile( - path.join(instanceDir, "active-deploy-id.dirty"), - "v1:truth-from-marker", - ); - - let caught: unknown; - try { - await materializeToolPackages({ - assetMounts: new Map(), - rawManifestBytes: "{ not json", - storeDir, - agentAddress: "agent-dirty-marker", - cacheRoot: "/unused-by-manifest-invalid-gate", - cacheMaxBytes: 1024 * 1024, - registryMaxTarballBytes: 10 * 1024 * 1024, - }); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(Error); - - const auditRoot = path.join(storeDir, "audit", "rejected-applies"); - const attemptDirs = await fs.promises.readdir(auditRoot); - expect(attemptDirs).toHaveLength(1); - const attemptDirName = attemptDirs[0]; - if (attemptDirName === undefined) { - throw new Error("no attempt dir was created"); - } - const errorJson = JSON.parse( - await fs.promises.readFile(path.join(auditRoot, attemptDirName, "error.json"), "utf-8"), - ); - expect(errorJson.previousDeployId).toBe("truth-from-marker"); - }); - - test("manifest.invalid throws and writes an audit entry", async () => { - const storeDir = await tempDir(); - let caught: unknown; - try { - await materializeToolPackages({ - assetMounts: new Map(), - rawManifestBytes: "{ not json", - storeDir, - agentAddress: "agent-throws", - cacheRoot: "/unused-by-manifest-invalid-gate", - cacheMaxBytes: 1024 * 1024, - registryMaxTarballBytes: 10 * 1024 * 1024, - }); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(Error); - expect(String(caught)).toMatch(/manifest\.invalid/); - - const auditRoot = path.join(storeDir, "audit", "rejected-applies"); - const attemptDirs = await fs.promises.readdir(auditRoot); - expect(attemptDirs).toHaveLength(1); - }); -}); diff --git a/apps/sidecar/src/workflow-closure-apply.test.ts b/apps/sidecar/src/workflow-closure-apply.test.ts deleted file mode 100644 index 1cf3089f1..000000000 --- a/apps/sidecar/src/workflow-closure-apply.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { promises as fs } from "node:fs"; -import { createHash } from "node:crypto"; -import os from "node:os"; -import path from "node:path"; - -import type { RegistryConfig, TarballFetcher } from "@intx/tool-packaging"; -import type { ToolPackageManifest } from "@intx/types/tool-packages"; - -import { applyFrozenWorkflowClosure } from "./workflow-closure-apply"; - -const REGISTRY_NAME = "test-registry"; -const WORKFLOW_PACKAGE_NAME = "@fixture/frozen-workflow"; -const WORKFLOW_PACKAGE_VERSION = "1.4.2"; -const WORKFLOW_ID = "fixture-closure-workflow"; - -// The pinned code the frozen closure carries: an ESM entry whose default -// export is a plain, envelope-valid workflow definition. It imports nothing so -// the closure needs no dependency entries, keeping the fixture hermetic while -// still exercising the real fetch -> SRI-verify -> extract -> layout -> import -// path. -const WORKFLOW_ENTRY_SOURCE = `export default { - id: ${JSON.stringify(WORKFLOW_ID)}, - triggers: [], - steps: { done: { kind: "sleep", id: "done", durationMs: 1 } }, - stepOrder: ["done"], -}; -`; - -let scratchRoot: string; -let cacheRoot: string; -let instanceDir: string; - -beforeEach(async () => { - scratchRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sidecar-wf-closure-apply-")); - cacheRoot = path.join(scratchRoot, "cache"); - instanceDir = path.join(scratchRoot, "instance"); - await fs.mkdir(cacheRoot, { recursive: true }); - await fs.mkdir(instanceDir, { recursive: true }); -}); - -afterEach(async () => { - await fs.rm(scratchRoot, { recursive: true, force: true }); -}); - -/** - * Pack the fixture workflow package into an npm-style tarball (with the - * `package/` prefix the loader strips) and return its bytes plus the SRI - * integrity string the frozen manifest pins. - */ -async function packWorkflowFixture(): Promise<{ - bytes: Uint8Array; - integrity: string; -}> { - const stagingDir = path.join(scratchRoot, "fixture-source"); - const packageDir = path.join(stagingDir, "package"); - await fs.mkdir(packageDir, { recursive: true }); - await fs.writeFile( - path.join(packageDir, "package.json"), - JSON.stringify({ - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - type: "module", - interchange: { workflow: "index.js" }, - }), - ); - await fs.writeFile(path.join(packageDir, "index.js"), WORKFLOW_ENTRY_SOURCE); - - const tarballPath = path.join(stagingDir, "out.tgz"); - const proc = Bun.spawnSync(["tar", "-czf", tarballPath, "-C", stagingDir, "package"]); - if (!proc.success) { - throw new Error(`tar failed to pack the fixture: ${new TextDecoder().decode(proc.stderr)}`); - } - const bytes = await fs.readFile(tarballPath); - const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; - return { bytes, integrity }; -} - -function registries(): ReadonlyMap { - return new Map([[REGISTRY_NAME, { url: "https://registry.invalid" }]]); -} - -describe("applyFrozenWorkflowClosure", () => { - test("materializes the pinned closure and loads the pinned code", async () => { - const fixture = await packWorkflowFixture(); - const manifest: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - source: { - kind: "registry", - registry: REGISTRY_NAME, - integrity: fixture.integrity, - }, - }, - ], - }; - - // Record every fetch so the test can prove the sidecar fetched EXACTLY the - // frozen concrete version -- no packument round-trip, no re-resolution. - const fetched: string[] = []; - const fetchTarball: TarballFetcher = async (entry) => { - fetched.push(`${entry.name}@${entry.version}`); - return fixture.bytes; - }; - - const applied = await applyFrozenWorkflowClosure({ - source: { kind: "registry", registry: REGISTRY_NAME }, - closure: manifest, - instanceDir, - cacheRoot, - cacheMaxBytes: 10_000_000, - registryMaxTarballBytes: 10_000_000, - registries: registries(), - fetchTarball, - }); - - // The pinned code was evaluated to its validated definition. - expect(applied.definition.id).toBe(WORKFLOW_ID); - - // The frozen closure was applied byte-for-byte: the workflow package's - // package.json is present in the materialized store directory. - const pkgJson = JSON.parse( - await fs.readFile(path.join(applied.packageDir, "package.json"), "utf8"), - ); - expect(pkgJson.name).toBe(WORKFLOW_PACKAGE_NAME); - expect(pkgJson.version).toBe(WORKFLOW_PACKAGE_VERSION); - - // No re-resolution: the only fetch was for the exact pinned concrete - // version the frozen closure named. - expect(fetched).toEqual([`${WORKFLOW_PACKAGE_NAME}@${WORKFLOW_PACKAGE_VERSION}`]); - }); - - test("rejects fetched bytes whose SRI does not match the frozen closure", async () => { - const fixture = await packWorkflowFixture(); - const manifest: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - // A pinned integrity that does not describe the returned bytes. - source: { - kind: "registry", - registry: REGISTRY_NAME, - integrity: `sha512-${createHash("sha512").update("tampered").digest("base64")}`, - }, - }, - ], - }; - const fetchTarball: TarballFetcher = async () => fixture.bytes; - - await expect( - applyFrozenWorkflowClosure({ - source: { kind: "registry", registry: REGISTRY_NAME }, - closure: manifest, - instanceDir, - cacheRoot, - cacheMaxBytes: 10_000_000, - registryMaxTarballBytes: 10_000_000, - registries: registries(), - fetchTarball, - }), - ).rejects.toThrow(/integrity\.mismatch/); - }); - - test("rejects a closure that does not pin exactly one top-level package", async () => { - const manifest: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [], - entries: [], - }; - await expect( - applyFrozenWorkflowClosure({ - source: { kind: "registry", registry: REGISTRY_NAME }, - closure: manifest, - instanceDir, - cacheRoot, - cacheMaxBytes: 10_000_000, - registryMaxTarballBytes: 10_000_000, - registries: registries(), - }), - ).rejects.toThrow(/exactly one top-level pin/); - }); - - test("rejects a source registry the sidecar is not configured for", async () => { - const manifest: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - source: { - kind: "registry", - registry: REGISTRY_NAME, - integrity: `sha512-${createHash("sha512").update("x").digest("base64")}`, - }, - }, - ], - }; - await expect( - applyFrozenWorkflowClosure({ - source: { kind: "registry", registry: "unconfigured-registry" }, - closure: manifest, - instanceDir, - cacheRoot, - cacheMaxBytes: 10_000_000, - registryMaxTarballBytes: 10_000_000, - registries: registries(), - }), - ).rejects.toThrow(/is not in the sidecar registry config/); - }); - - test("materializes an asset-sourced closure from a mounted asset root", async () => { - // This is the durable-store read path: the deploy checked the source asset - // out at //, and both deploy and restore materialize - // from there with no HTTP fetch. Stage the tarball as a plain file where the - // loader resolves a kind:"asset" entry, then apply. - const fixture = await packWorkflowFixture(); - const assetId = "asset_deploy"; - const mountPath = "source-assets/asset_deploy/"; - const tarballRel = "tarballs/wf.tgz"; - const assetRoot = path.join(scratchRoot, "asset-store"); - const tarballAbs = path.join(assetRoot, mountPath, tarballRel); - await fs.mkdir(path.dirname(tarballAbs), { recursive: true }); - await fs.writeFile(tarballAbs, fixture.bytes); - - const manifest: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - source: { - kind: "asset", - assetId, - package: { - format: "tarball", - path: tarballRel, - integrity: fixture.integrity, - }, - }, - }, - ], - }; - - const applied = await applyFrozenWorkflowClosure({ - source: { kind: "asset", assetId, package: { format: "tarball" } }, - closure: manifest, - instanceDir, - cacheRoot, - cacheMaxBytes: 10_000_000, - registryMaxTarballBytes: 10_000_000, - registries: registries(), - assetRoot, - assetMounts: new Map([[assetId, mountPath]]), - gitDirs: new Map(), - }); - - expect(applied.definition.id).toBe(WORKFLOW_ID); - }); -}); diff --git a/apps/sidecar/src/workflow-closure-materialization.test.ts b/apps/sidecar/src/workflow-closure-materialization.test.ts deleted file mode 100644 index 7772ef1e4..000000000 --- a/apps/sidecar/src/workflow-closure-materialization.test.ts +++ /dev/null @@ -1,609 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import fsNode, { promises as fs } from "node:fs"; -import { createHash, randomUUID } from "node:crypto"; -import os from "node:os"; -import path from "node:path"; -import git from "isomorphic-git"; - -import { base64Encode } from "@intx/types"; -import type { RegistryConfig, TarballFetcher } from "@intx/tool-packaging"; -import type { WorkflowProbeRequestFrame } from "@intx/types/sidecar"; -import type { ToolPackageManifest } from "@intx/types/tool-packages"; - -import { createWorkflowClosureMaterializer } from "./workflow-closure-materialization"; - -const REGISTRY_NAME = "test-registry"; -const WORKFLOW_PACKAGE_NAME = "@fixture/wf-probe"; -const WORKFLOW_PACKAGE_VERSION = "1.0.0"; -const DEP_PACKAGE_NAME = "@fixture/dep"; -const DEP_PACKAGE_VERSION = "1.0.0"; -const WORKFLOW_ENTRY = "index.js"; - -// The pinned workflow entry writes a sentinel file as an import-time side -// effect. The materializer lays the closure out WITHOUT importing any author -// code, so the sentinel must never appear -- its absence proves no module was -// evaluated on the host. -const SENTINEL_ENV = "PROBE_MATERIALIZER_SENTINEL"; -const WORKFLOW_ENTRY_SOURCE = ` -import { writeFileSync } from "node:fs"; -const sentinel = process.env[${JSON.stringify(SENTINEL_ENV)}]; -if (sentinel !== undefined) writeFileSync(sentinel, "imported"); -export default { - id: "probe-materializer-fixture", - triggers: [], - steps: { done: { kind: "sleep", id: "done", durationMs: 1 } }, - stepOrder: ["done"], -}; -`; - -let scratchRoot: string; -let cacheRoot: string; -let materializerScratch: string; -let fixtureSourceRoot: string; - -beforeEach(async () => { - scratchRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sidecar-wf-probe-materialize-")); - cacheRoot = path.join(scratchRoot, "cache"); - materializerScratch = path.join(scratchRoot, "probe-closures"); - fixtureSourceRoot = path.join(scratchRoot, "fixture-source"); - await fs.mkdir(cacheRoot, { recursive: true }); - await fs.mkdir(materializerScratch, { recursive: true }); - await fs.mkdir(fixtureSourceRoot, { recursive: true }); -}); - -afterEach(async () => { - await fs.rm(scratchRoot, { recursive: true, force: true }); -}); - -/** - * Pack a package directory into an npm-style tarball (with the `package/` - * prefix the loader strips) and return its bytes plus the SRI integrity the - * frozen manifest pins. - */ -async function packFixture( - pkgJson: Record, - files: Record, -): Promise<{ bytes: Uint8Array; integrity: string }> { - const stagingDir = path.join( - fixtureSourceRoot, - `${String(pkgJson.name).replace("/", "_")}-${String(pkgJson.version)}`, - ); - const packageDir = path.join(stagingDir, "package"); - await fs.mkdir(packageDir, { recursive: true }); - await fs.writeFile(path.join(packageDir, "package.json"), JSON.stringify(pkgJson)); - for (const [rel, contents] of Object.entries(files)) { - const dest = path.join(packageDir, rel); - await fs.mkdir(path.dirname(dest), { recursive: true }); - await fs.writeFile(dest, contents); - } - - const tarballPath = path.join(stagingDir, "out.tgz"); - const proc = Bun.spawnSync(["tar", "-czf", tarballPath, "-C", stagingDir, "package"]); - if (!proc.success) { - throw new Error(`tar failed to pack the fixture: ${new TextDecoder().decode(proc.stderr)}`); - } - const bytes = await fs.readFile(tarballPath); - const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; - return { bytes, integrity }; -} - -function registries(): ReadonlyMap { - return new Map([[REGISTRY_NAME, { url: "https://registry.invalid" }]]); -} - -function materializerConfig(fetchTarball?: TarballFetcher) { - return { - cacheRoot, - cacheMaxBytes: 10_000_000, - registryMaxTarballBytes: 10_000_000, - maxAssetPayloadBytes: 50_000_000, - registries: registries(), - scratchRoot: materializerScratch, - ...(fetchTarball !== undefined ? { fetchTarball } : {}), - }; -} - -/** - * Build a git packfile whose single commit's tree holds `files`, mirroring the - * `createPack` output the hub delivers for an asset. Returns the pack bytes and - * the commit sha the probe frame pins. - */ -async function buildAssetPack( - files: Record, -): Promise<{ pack: Uint8Array; commitSha: string }> { - const sourceDir = path.join(fixtureSourceRoot, `asset-${randomUUID()}`); - await fs.mkdir(sourceDir, { recursive: true }); - await git.init({ fs: fsNode, dir: sourceDir, defaultBranch: "main" }); - for (const [rel, content] of Object.entries(files)) { - const abs = path.join(sourceDir, rel); - await fs.mkdir(path.dirname(abs), { recursive: true }); - await fs.writeFile(abs, content); - await git.add({ fs: fsNode, dir: sourceDir, filepath: rel }); - } - const commitSha = await git.commit({ - fs: fsNode, - dir: sourceDir, - message: "asset", - author: { name: "test", email: "test@test.dev" }, - }); - const oids = new Set([commitSha]); - const { commit } = await git.readCommit({ - fs: fsNode, - dir: sourceDir, - oid: commitSha, - }); - oids.add(commit.tree); - async function walkTree(treeOid: string): Promise { - const { tree } = await git.readTree({ - fs: fsNode, - dir: sourceDir, - oid: treeOid, - }); - for (const entry of tree) { - oids.add(entry.oid); - if (entry.type === "tree") await walkTree(entry.oid); - } - } - await walkTree(commit.tree); - const result = await git.packObjects({ - fs: fsNode, - dir: sourceDir, - oids: [...oids], - write: false, - }); - if (result.packfile === undefined) { - throw new Error("packObjects produced no packfile"); - } - return { pack: result.packfile, commitSha }; -} - -function probeFrame(closure: ToolPackageManifest, entry: string): WorkflowProbeRequestFrame { - return { - type: "workflow.probe.request", - requestId: "req-1", - source: { kind: "registry", registry: REGISTRY_NAME }, - closure, - entry, - }; -} - -describe("createWorkflowClosureMaterializer", () => { - test("lays out the frozen closure, resolves node_modules, and imports no author code", async () => { - const workflow = await packFixture( - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - type: "module", - interchange: { workflow: WORKFLOW_ENTRY }, - dependencies: { [DEP_PACKAGE_NAME]: DEP_PACKAGE_VERSION }, - }, - { [WORKFLOW_ENTRY]: WORKFLOW_ENTRY_SOURCE }, - ); - const dep = await packFixture( - { - name: DEP_PACKAGE_NAME, - version: DEP_PACKAGE_VERSION, - type: "module", - }, - { "index.js": "export const x = 1;\n" }, - ); - - const closure: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - source: { - kind: "registry", - registry: REGISTRY_NAME, - integrity: workflow.integrity, - }, - }, - { - name: DEP_PACKAGE_NAME, - version: DEP_PACKAGE_VERSION, - source: { - kind: "registry", - registry: REGISTRY_NAME, - integrity: dep.integrity, - }, - }, - ], - }; - - const fetched: string[] = []; - const fetchTarball: TarballFetcher = async (entry) => { - fetched.push(`${entry.name}@${entry.version}`); - if (entry.name === WORKFLOW_PACKAGE_NAME) return workflow.bytes; - if (entry.name === DEP_PACKAGE_NAME) return dep.bytes; - throw new Error(`unexpected fetch for ${entry.name}@${entry.version}`); - }; - - const sentinelPath = path.join(scratchRoot, "import-sentinel"); - process.env[SENTINEL_ENV] = sentinelPath; - try { - const materialize = createWorkflowClosureMaterializer(materializerConfig(fetchTarball)); - const materialized = await materialize(probeFrame(closure, WORKFLOW_ENTRY)); - - // The workflow package's own package.json is present in the laid-out - // store directory. - const pkgJson = JSON.parse( - await fs.readFile(path.join(materialized.packageDir, "package.json"), "utf8"), - ); - expect(pkgJson.name).toBe(WORKFLOW_PACKAGE_NAME); - expect(pkgJson.version).toBe(WORKFLOW_PACKAGE_VERSION); - - // The dependency resolves through the laid-out node_modules graph. - const depPkgJson = JSON.parse( - await fs.readFile( - path.join(materialized.packageDir, "node_modules", "@fixture", "dep", "package.json"), - "utf8", - ), - ); - expect(depPkgJson.name).toBe(DEP_PACKAGE_NAME); - - // Both frozen entries were fetched exactly once, at their pinned - // concrete versions. - expect(fetched.sort()).toEqual([ - `${DEP_PACKAGE_NAME}@${DEP_PACKAGE_VERSION}`, - `${WORKFLOW_PACKAGE_NAME}@${WORKFLOW_PACKAGE_VERSION}`, - ]); - - // No author code ran: the entry module's import-time sentinel was - // never written. - await expect(fs.stat(sentinelPath)).rejects.toThrow(); - - // Cleanup removes the ephemeral scratch tree. - await materialized.cleanup(); - await expect(fs.stat(materialized.packageDir)).rejects.toThrow(); - } finally { - Reflect.deleteProperty(process.env, SENTINEL_ENV); - } - }); - - test("fails loud when the closure pins no top-level package", async () => { - const materialize = createWorkflowClosureMaterializer(materializerConfig()); - const closure: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [], - entries: [], - }; - await expect(materialize(probeFrame(closure, WORKFLOW_ENTRY))).rejects.toThrow( - /exactly one top-level package/, - ); - }); - - test("fails loud when the closure pins more than one top-level package", async () => { - const materialize = createWorkflowClosureMaterializer(materializerConfig()); - const closure: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [ - { name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }, - { name: DEP_PACKAGE_NAME, version: DEP_PACKAGE_VERSION }, - ], - entries: [], - }; - await expect(materialize(probeFrame(closure, WORKFLOW_ENTRY))).rejects.toThrow( - /exactly one top-level package/, - ); - }); - - test("materializes an asset-sourced closure from an inline-delivered pack", async () => { - const workflow = await packFixture( - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - type: "module", - interchange: { workflow: WORKFLOW_ENTRY }, - }, - { [WORKFLOW_ENTRY]: WORKFLOW_ENTRY_SOURCE }, - ); - const tarballPath = "tarballs/wf-probe-1.0.0.tgz"; - const assetId = "asset_probe"; - const mountPath = "package-registries/fixture/"; - const { pack, commitSha } = await buildAssetPack({ - [tarballPath]: workflow.bytes, - }); - - const closure: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - source: { - kind: "asset", - assetId, - package: { - format: "tarball", - path: tarballPath, - integrity: workflow.integrity, - }, - }, - }, - ], - }; - - const materialize = createWorkflowClosureMaterializer(materializerConfig()); - const materialized = await materialize({ - type: "workflow.probe.request", - requestId: "req-asset", - source: { kind: "asset", assetId, package: { format: "tarball" } }, - closure, - entry: WORKFLOW_ENTRY, - assets: [ - { - assetId, - mountPath, - pack: base64Encode(pack), - ref: "refs/heads/main", - commitSha, - }, - ], - }); - - try { - // The workflow package was laid out from the asset-delivered tarball, - // SRI-verified against the frozen closure entry -- no HTTP fetch. - const pkgJson = JSON.parse( - await fs.readFile(path.join(materialized.packageDir, "package.json"), "utf8"), - ); - expect(pkgJson.name).toBe(WORKFLOW_PACKAGE_NAME); - expect(pkgJson.version).toBe(WORKFLOW_PACKAGE_VERSION); - } finally { - await materialized.cleanup(); - } - }); - - test("fails loud when the inline asset payload exceeds the cap", async () => { - const workflow = await packFixture( - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - type: "module", - interchange: { workflow: WORKFLOW_ENTRY }, - }, - { [WORKFLOW_ENTRY]: WORKFLOW_ENTRY_SOURCE }, - ); - const assetId = "asset_probe"; - const { pack, commitSha } = await buildAssetPack({ - "tarballs/wf-probe-1.0.0.tgz": workflow.bytes, - }); - - const closure: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - source: { - kind: "asset", - assetId, - package: { - format: "tarball", - path: "tarballs/wf-probe-1.0.0.tgz", - integrity: workflow.integrity, - }, - }, - }, - ], - }; - - const materialize = createWorkflowClosureMaterializer({ - ...materializerConfig(), - maxAssetPayloadBytes: 16, - }); - await expect( - materialize({ - type: "workflow.probe.request", - requestId: "req-cap", - source: { kind: "asset", assetId, package: { format: "tarball" } }, - closure, - entry: WORKFLOW_ENTRY, - assets: [ - { - assetId, - mountPath: "package-registries/fixture/", - pack: base64Encode(pack), - ref: "refs/heads/main", - commitSha, - }, - ], - }), - ).rejects.toThrow(/inline asset payload exceeds the 16-byte cap/); - }); - - test("rejects an asset-delivered tarball that fails its pinned integrity", async () => { - const workflow = await packFixture( - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - type: "module", - interchange: { workflow: WORKFLOW_ENTRY }, - }, - { [WORKFLOW_ENTRY]: WORKFLOW_ENTRY_SOURCE }, - ); - const assetId = "asset_probe"; - const tarballPath = "tarballs/wf-probe-1.0.0.tgz"; - const { pack, commitSha } = await buildAssetPack({ - [tarballPath]: workflow.bytes, - }); - - const closure: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - // A pinned integrity that does not describe the delivered tarball. - source: { - kind: "asset", - assetId, - package: { - format: "tarball", - path: tarballPath, - integrity: `sha512-${createHash("sha512").update("tampered").digest("base64")}`, - }, - }, - }, - ], - }; - - const materialize = createWorkflowClosureMaterializer(materializerConfig()); - await expect( - materialize({ - type: "workflow.probe.request", - requestId: "req-sri", - source: { kind: "asset", assetId, package: { format: "tarball" } }, - closure, - entry: WORKFLOW_ENTRY, - assets: [ - { - assetId, - mountPath: "package-registries/fixture/", - pack: base64Encode(pack), - ref: "refs/heads/main", - commitSha, - }, - ], - }), - ).rejects.toThrow(/did not match pinned integrity/); - }); - - test("rejects an asset source whose asset was not delivered", async () => { - const assetId = "asset_probe"; - const closure: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - source: { - kind: "asset", - assetId, - package: { - format: "tarball", - path: "tarballs/wf.tgz", - integrity: `sha512-${createHash("sha512").update("x").digest("base64")}`, - }, - }, - }, - ], - }; - const materialize = createWorkflowClosureMaterializer(materializerConfig()); - await expect( - materialize({ - type: "workflow.probe.request", - requestId: "req-undelivered", - source: { kind: "asset", assetId, package: { format: "tarball" } }, - closure, - entry: WORKFLOW_ENTRY, - assets: [], - }), - ).rejects.toThrow(/was not among the delivered assets/); - }); - - test("rejects a frame that delivers the same assetId twice", async () => { - const workflow = await packFixture( - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - type: "module", - interchange: { workflow: WORKFLOW_ENTRY }, - }, - { [WORKFLOW_ENTRY]: WORKFLOW_ENTRY_SOURCE }, - ); - const assetId = "asset_probe"; - const { pack, commitSha } = await buildAssetPack({ - "tarballs/wf-probe-1.0.0.tgz": workflow.bytes, - }); - const encoded = base64Encode(pack); - // The closure references the delivered asset so the first delivery clears - // the "delivered but referenced by no closure entry" guard and the second - // delivery is what trips the duplicate-delivery guard under test. - const closure: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - source: { - kind: "asset", - assetId, - package: { - format: "tarball", - path: "tarballs/wf-probe-1.0.0.tgz", - integrity: "sha512-placeholder", - }, - }, - }, - ], - }; - const materialize = createWorkflowClosureMaterializer(materializerConfig()); - await expect( - materialize({ - type: "workflow.probe.request", - requestId: "req-dup", - source: { kind: "asset", assetId, package: { format: "tarball" } }, - closure, - entry: WORKFLOW_ENTRY, - assets: [ - { - assetId, - mountPath: "package-registries/a/", - pack: encoded, - ref: "refs/heads/main", - commitSha, - }, - { - assetId, - mountPath: "package-registries/b/", - pack: encoded, - ref: "refs/heads/main", - commitSha, - }, - ], - }), - ).rejects.toThrow(/is delivered more than once/); - }); - - test("fails loud when the frame entry disagrees with interchange.workflow", async () => { - const workflow = await packFixture( - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - type: "module", - interchange: { workflow: WORKFLOW_ENTRY }, - }, - { [WORKFLOW_ENTRY]: WORKFLOW_ENTRY_SOURCE }, - ); - const closure: ToolPackageManifest = { - schemaVersion: "1", - topLevel: [{ name: WORKFLOW_PACKAGE_NAME, version: WORKFLOW_PACKAGE_VERSION }], - entries: [ - { - name: WORKFLOW_PACKAGE_NAME, - version: WORKFLOW_PACKAGE_VERSION, - source: { - kind: "registry", - registry: REGISTRY_NAME, - integrity: workflow.integrity, - }, - }, - ], - }; - const fetchTarball: TarballFetcher = async () => workflow.bytes; - - const materialize = createWorkflowClosureMaterializer(materializerConfig(fetchTarball)); - await expect(materialize(probeFrame(closure, "./does-not-match.js"))).rejects.toThrow( - /does not match the materialized package's interchange.workflow/, - ); - }); -}); diff --git a/apps/sidecar/src/workflow-host-wiring-deploy-failure.test.ts b/apps/sidecar/src/workflow-host-wiring-deploy-failure.test.ts deleted file mode 100644 index 689967adf..000000000 --- a/apps/sidecar/src/workflow-host-wiring-deploy-failure.test.ts +++ /dev/null @@ -1,456 +0,0 @@ -// Pins H-A1: a deploy-router rejection must not leave the -// `DeploymentAddressRegistry` populated. The multi-step branch defers -// `registerDeployment` until every step that can throw (asset -// materialization, `supervisor.spawn`) has succeeded. The link's -// `handleAgentDeploy` catches a rejection and sends `agent.error` -// without invoking `deployRouter.undeploy(frame)`, so a premature -// registration would retain a `(anchorRunId -> agentAddress)` mapping -// for a deployment that does not exist. The multi-step test drives that -// failure through a subprocess spawner that throws synchronously. -// -// The first test pins the router's frame-shape guard: a frame carrying -// neither `provisionStep` nor a workflow definition is rejected before -// any deploy work, so a malformed frame cannot leak registry state. - -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join as pathJoin } from "node:path"; - -import { describe, test, expect } from "bun:test"; -import { createInMemoryTransport } from "@intx/mail-memory"; -import { createEd25519Crypto, createNoopCredentialCipher, generateKeyPair } from "@intx/crypto"; -import type { RepoId, RepoStore } from "@intx/hub-sessions"; -import type { AgentDeployFrame } from "@intx/types/sidecar"; -import type { WorkflowDefinition } from "@intx/workflow"; -import type { SubprocessSpawner } from "@intx/workflow-host"; - -import { - createDeploymentAddressRegistry, - createMultistepDrainRouter, - createMultistepMailRouter, - createMultistepSignalRouter, -} from "./workflow-run-pack-client"; -import { createSidecarDeployRouter } from "./workflow-host-wiring"; - -function stubKeyStore(): Parameters[0]["keyStore"] { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub - return { - async loadOrGenerateKey() { - // The single-step multi-step branch registers the agent's signing - // key on the host transport before `spawn()` (OUTBOUND half of - // mailbox ownership). Return a real keypair so that registration - // succeeds and the SPAWNER failure remains the failure this test - // exercises. - return { keyPair: await generateKeyPair(), isNew: false }; - }, - recordHubKey() { - /* no-op */ - }, - verifyDeployCommit() { - return true; - }, - forgetAgent() { - /* no-op */ - }, - } as unknown as Parameters[0]["keyStore"]; -} - -function stubFailingSessions(): Parameters[0]["sessions"] { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub - return { - async provisionAgent() { - throw new Error("provisionAgent forced failure"); - }, - } as unknown as Parameters[0]["sessions"]; -} - -// Source-ref is the only deploy lineage: the router derives the runnable -// definition by materializing the pin's closure through this dependency. These -// spawn-failure tests need the closure to SUCCEED so the deploy reaches the -// spawn it exercises, so the stub returns a valid single-step live definition -// (its one step carries an agent so it survives `projectLiveToInert`), keyed to -// the frame's single source id `s1`. -function stubApplyFrozenWorkflowClosure( - definitionId: string, -): NonNullable[0]["applyFrozenWorkflowClosure"]> { - return (applyArgs) => - Promise.resolve({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- a hand-built live definition cannot satisfy the full WorkflowDefinition nominal type; it stands in for a real closure evaluation - definition: { - id: definitionId, - triggers: [{ type: "manual" }], - stepOrder: ["s1"], - steps: { - s1: { - kind: "step", - id: "s1", - agent: { - id: "agent-s1", - systemPrompt: "sys", - capabilities: [], - toolFactories: [], - inference: { sources: [] }, - }, - }, - }, - } as unknown as WorkflowDefinition, - packageDir: pathJoin(applyArgs.instanceDir, "package"), - deployDir: pathJoin(applyArgs.instanceDir, "deploy"), - }); -} - -function makeRouterDeps() { - const registry = createDeploymentAddressRegistry(); - const mailRouter = createMultistepMailRouter(); - const signalRouter = createMultistepSignalRouter(); - const drainRouter = createMultistepDrainRouter(); - const transport = createInMemoryTransport(); - return { registry, mailRouter, signalRouter, drainRouter, transport }; -} - -describe("deploy-failure registry leak", () => { - test("a frame carrying neither provisionStep nor a workflow is rejected before any deploy work", async () => { - const { registry, mailRouter, signalRouter, drainRouter, transport } = makeRouterDeps(); - - let spawnerInvoked = false; - const router = createSidecarDeployRouter({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- an unsupported frame throws before sessions is touched - sessions: {} as Parameters[0]["sessions"], - keyStore: stubKeyStore(), - senderKeyCache: { - get: () => undefined, - put: async () => undefined, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }, - transport, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- an unsupported frame throws before any repoStore usage - repoStore: {} as Parameters[0]["repoStore"], - signingKeySeed: new Uint8Array(32), - credentialCipher: createNoopCredentialCipher(), - createAgentCrypto: createEd25519Crypto, - assertSourceBuildable: () => undefined, - registerDeployment: ({ runId, agentAddress }) => { - registry.record(runId, agentAddress); - }, - unregisterDeployment: ({ runId }) => { - registry.unregister(runId); - }, - multistepMailRouter: mailRouter, - multistepSignalRouter: signalRouter, - multistepDrainRouter: drainRouter, - multistepSubprocessSpawner: () => { - spawnerInvoked = true; - throw new Error("spawner must not run for an unsupported frame"); - }, - }); - - // Neither `provisionStep` nor a workflow definition: the router has - // no path to stage this frame through the substrate, so it rejects on - // shape before reaching any deploy work. - const frame: AgentDeployFrame = { - type: "agent.deploy", - agentAddress: "agent-unsupported@x.example", - agentId: "agent-unsupported", - hubPublicKey: "00".repeat(32), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- config is irrelevant; the frame is rejected on shape before config is read - config: {} as unknown as AgentDeployFrame["config"], - }; - - await expect(router.deploy(frame)).rejects.toThrow(/unsupported deploy frame/); - // The throw fires before any deploy work: no spawn, and the registry - // is never touched. - expect(spawnerInvoked).toBe(false); - const slug = "agent-unsupported-x-example"; - expect(registry.resolve(slug)).toBeNull(); - }); - - test("multi-step deploy: spawn-time failure leaves registry clean", async () => { - const { registry, mailRouter, signalRouter, drainRouter, transport } = makeRouterDeps(); - const sessions = stubFailingSessions(); - const keyStore = stubKeyStore(); - - const failingSpawner: SubprocessSpawner = () => { - throw new Error("spawner forced failure"); - }; - - const tmpDir = mkdtempSync(pathJoin(tmpdir(), "h-a1-deploy-failure-")); - - // The grants bridge writes `state/grants.json` to each step's - // agent-state repo before `spawn()`; supply a minimal RepoStore that - // honors `getRepoDir` + `writeTree` so the bridge succeeds and the - // SPAWNER failure is the one this test exercises. - const repoStoreStub: Partial = { - getRepoDir(repoId: RepoId): string { - return pathJoin(tmpDir, repoId.kind, repoId.id); - }, - writeTree(_p, repoId, _ref, content) { - const dir = pathJoin(tmpDir, repoId.kind, repoId.id); - for (const [relPath, contents] of Object.entries(content.files)) { - const full = pathJoin(dir, relPath); - mkdirSync(dirname(full), { recursive: true }); - writeFileSync(full, contents); - } - return Promise.resolve({ - commitSha: "stub-sha", - newlyTerminalRuns: [], - }); - }, - }; - - const router = createSidecarDeployRouter({ - sessions, - keyStore, - senderKeyCache: { - get: () => undefined, - put: async () => undefined, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }, - transport, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub: only getRepoDir + writeTree are exercised before the spawn-time failure - repoStore: repoStoreStub as RepoStore, - signingKeySeed: new Uint8Array(32), - credentialCipher: createNoopCredentialCipher(), - createAgentCrypto: createEd25519Crypto, - assertSourceBuildable: () => undefined, - registerDeployment: ({ runId, agentAddress }) => { - registry.record(runId, agentAddress); - }, - unregisterDeployment: ({ runId }) => { - registry.unregister(runId); - }, - multistepMailRouter: mailRouter, - multistepSignalRouter: signalRouter, - multistepDrainRouter: drainRouter, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: tmpDir, - SIDECAR_SIGNING_PUBLIC_KEY: "00".repeat(32), - SIDECAR_SIGNING_PRIVATE_KEY: "00".repeat(32), - HUB_WS_URL: "ws://test", - SIDECAR_ID: "sc", - SIDECAR_TOKEN: "tok", - PATH: "/usr/bin", - // Source-ref materialization reads both byte caps from the substrate env. - SIDECAR_CACHE_MAX_BYTES: "1000000", - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: "1000000", - }, - multistepSubprocessSpawner: failingSpawner, - applyFrozenWorkflowClosure: stubApplyFrozenWorkflowClosure("wf-1"), - }); - - const frame: AgentDeployFrame = { - type: "agent.deploy", - // Single-step projection: the deploy router parses the frame - // address into the legacy agent-state repo id, so it must carry the - // canonical `run_@` shape. - agentAddress: "run_mstep@x.example", - agentId: "mstep", - hubPublicKey: "00".repeat(32), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- multi-step branch does not consult config before failing - config: { - agentAddress: "run_mstep@x.example", - agentId: "mstep", - sessionId: "s", - sources: [], - defaultSource: "primary", - grants: [], - } as unknown as AgentDeployFrame["config"], - workflow: { - // Production always stamps the hub-approved wire hash; a placeholder - // satisfies the deploy path's fail-loud guard so this test reaches the - // spawn-failure behavior it exercises. - approvedWireHash: "a".repeat(64), - // Source-ref is the only deploy lineage: the frame carries no inline - // definition, only the pin the sidecar re-materializes (the injected - // closure stub above evaluates it to a single-step `s1` definition). - sourceRef: { - source: { kind: "registry", registry: "test-registry" }, - closure: { schemaVersion: "1", topLevel: [], entries: [] }, - }, - sources: { - s1: [ - { - id: "primary", - provider: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: "sk-x", - model: "claude-3-5", - }, - ], - }, - }, - }; - - let threw = false; - try { - await router.deploy(frame); - } catch { - threw = true; - } - expect(threw).toBe(true); - - const slug = "run_mstep-x-example"; - expect(registry.resolve(slug)).toBeNull(); - }); - - test("single-step spawn failure reverses the recorded hub key but preserves the head repo", async () => { - const { registry, mailRouter, signalRouter, drainRouter, transport } = makeRouterDeps(); - - const initedRepos: string[] = []; - const removedRepos: string[] = []; - const recordedHubKeys: string[] = []; - const forgottenAgents: string[] = []; - - // A sessions stub whose `initRepo` succeeds (so the single-step head - // proceeds to record the hub key and then fail at spawn) and whose - // `deleteAgentDir` is spied: the unwind must NOT call it, because the - // head repo directory also holds the durable identity keypair. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub: only initRepo runs on the happy path; deleteAgentDir is spied to prove it is NOT called - const sessions = { - initRepo: async (address: string) => { - initedRepos.push(address); - }, - deleteAgentDir: async (address: string) => { - removedRepos.push(address); - }, - } as unknown as Parameters[0]["sessions"]; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub - const keyStore = { - async loadOrGenerateKey() { - return { keyPair: await generateKeyPair(), isNew: false }; - }, - recordHubKey(address: string) { - recordedHubKeys.push(address); - }, - verifyDeployCommit() { - return true; - }, - forgetAgent(address: string) { - forgottenAgents.push(address); - }, - } as unknown as Parameters[0]["keyStore"]; - - const failingSpawner: SubprocessSpawner = () => { - throw new Error("spawner forced failure"); - }; - - const tmpDir = mkdtempSync(pathJoin(tmpdir(), "single-step-unwind-")); - - const repoStoreStub: Partial = { - getRepoDir(repoId: RepoId): string { - return pathJoin(tmpDir, repoId.kind, repoId.id); - }, - writeTree(_p, repoId, _ref, content) { - const dir = pathJoin(tmpDir, repoId.kind, repoId.id); - for (const [relPath, contents] of Object.entries(content.files)) { - const full = pathJoin(dir, relPath); - mkdirSync(dirname(full), { recursive: true }); - writeFileSync(full, contents); - } - return Promise.resolve({ - commitSha: "stub-sha", - newlyTerminalRuns: [], - }); - }, - }; - - const router = createSidecarDeployRouter({ - sessions, - keyStore, - senderKeyCache: { - get: () => undefined, - put: async () => undefined, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }, - transport, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub: only getRepoDir + writeTree are exercised before the spawn-time failure - repoStore: repoStoreStub as RepoStore, - signingKeySeed: new Uint8Array(32), - credentialCipher: createNoopCredentialCipher(), - createAgentCrypto: createEd25519Crypto, - assertSourceBuildable: () => undefined, - registerDeployment: ({ runId, agentAddress }) => { - registry.record(runId, agentAddress); - }, - unregisterDeployment: ({ runId }) => { - registry.unregister(runId); - }, - multistepMailRouter: mailRouter, - multistepSignalRouter: signalRouter, - multistepDrainRouter: drainRouter, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: tmpDir, - SIDECAR_SIGNING_PUBLIC_KEY: "00".repeat(32), - SIDECAR_SIGNING_PRIVATE_KEY: "00".repeat(32), - HUB_WS_URL: "ws://test", - SIDECAR_ID: "sc", - SIDECAR_TOKEN: "tok", - PATH: "/usr/bin", - // Source-ref materialization reads both byte caps from the substrate env. - SIDECAR_CACHE_MAX_BYTES: "1000000", - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: "1000000", - }, - multistepSubprocessSpawner: failingSpawner, - applyFrozenWorkflowClosure: stubApplyFrozenWorkflowClosure("wf-single"), - }); - - const frame: AgentDeployFrame = { - type: "agent.deploy", - agentAddress: "run_single@x.example", - agentId: "single", - hubPublicKey: "00".repeat(32), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- single-step branch does not consult config before failing at spawn - config: { - agentAddress: "run_single@x.example", - agentId: "single", - sessionId: "s", - sources: [], - defaultSource: "primary", - grants: [], - } as unknown as AgentDeployFrame["config"], - workflow: { - // Placeholder hub-approved wire hash so the deploy path's fail-loud - // guard passes; production always stamps it. - approvedWireHash: "a".repeat(64), - // Source-ref is the only deploy lineage: the frame carries no inline - // definition, only the pin the sidecar re-materializes (the injected - // closure stub above evaluates it to a single-step `s1` definition). - sourceRef: { - source: { kind: "registry", registry: "test-registry" }, - closure: { schemaVersion: "1", topLevel: [], entries: [] }, - }, - sources: { - s1: [ - { - id: "primary", - provider: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: "sk-x", - model: "claude-3-5", - }, - ], - }, - }, - }; - - await expect(router.deploy(frame)).rejects.toThrow(); - - // The single-step head initialized its repo and recorded the hub key - // before the spawn failed. - expect(initedRepos).toEqual(["run_single@x.example"]); - expect(recordedHubKeys).toEqual(["run_single@x.example"]); - // The unwind reversed the in-memory hub key... - expect(forgottenAgents).toEqual(["run_single@x.example"]); - // ...but did NOT remove the head repo directory (it holds the durable - // identity keypair a rerouted head must keep across a failed redeploy). - expect(removedRepos).toEqual([]); - // Registry untouched. - expect(registry.resolve("run_single-x-example")).toBeNull(); - }); -}); diff --git a/apps/sidecar/src/workflow-host-wiring-source-assets.test.ts b/apps/sidecar/src/workflow-host-wiring-source-assets.test.ts deleted file mode 100644 index 3d2bd7aae..000000000 --- a/apps/sidecar/src/workflow-host-wiring-source-assets.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { promises as fs } from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import type { SourceRefPin } from "@intx/types/sidecar"; - -import { resolveDeploymentAssetMounts } from "./workflow-host-wiring"; - -// The durable source-asset store is what makes an asset-sourced deployment -// survive a restart: the deploy checks the assets out once, and both the deploy -// apply and the boot-time restore re-materialize from the store using mounts -// derived from the pin ALONE (restore has no re-delivery). These tests pin that -// derivation and its fail-loud presence check without spinning up a deployment. - -const DEPLOYMENT_ID = "dep-1"; - -function assetPin(assetIds: readonly string[]): SourceRefPin { - return { - source: { - kind: "asset", - assetId: assetIds[0] ?? "asset_top", - package: { format: "tarball" }, - }, - closure: { - schemaVersion: "1", - topLevel: [{ name: "@x/wf", version: "1.0.0" }], - entries: assetIds.map((assetId, i) => ({ - name: i === 0 ? "@x/wf" : `@x/dep-${String(i)}`, - version: "1.0.0", - source: { - kind: "asset", - assetId, - package: { - format: "tarball", - path: "tarballs/x.tgz", - integrity: "sha512-placeholder", - }, - }, - })), - }, - }; -} - -// A source-format pin: every member is a `format:"source"` entry backed by one -// asset checked out into the durable indexed-`.git` store. Restore derives the -// `assetId -> gitDir` map from this pin alone. -function sourcePin(assetId: string, packageDirs: readonly string[]): SourceRefPin { - return { - source: { - kind: "asset", - assetId, - package: { format: "source", commitSha: "commit-abc" }, - }, - closure: { - schemaVersion: "1", - topLevel: [{ name: "@x/wf", version: "1.0.0" }], - entries: packageDirs.map((packageDir, i) => ({ - name: i === 0 ? "@x/wf" : `@x/member-${String(i)}`, - version: "1.0.0", - source: { - kind: "asset", - assetId, - package: { - format: "source", - commitSha: "commit-abc", - packageDir, - treeOid: `tree-${String(i)}`, - }, - }, - })), - }, - }; -} - -let dataDir: string; - -beforeEach(async () => { - dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "wf-deploy-source-assets-")); -}); - -afterEach(async () => { - await fs.rm(dataDir, { recursive: true, force: true }); -}); - -describe("resolveDeploymentAssetMounts", () => { - test("derives the pin's mount map when the store is populated", async () => { - const assetId = "asset_top"; - // Populate the durable store exactly as a deploy checkout would; restore - // reads only this, deriving the mount from the pin. - await fs.mkdir( - path.join(dataDir, "workflow-definition-sources", DEPLOYMENT_ID, "source-assets", assetId), - { recursive: true }, - ); - - const { assetRoot, assetMounts } = await resolveDeploymentAssetMounts( - dataDir, - DEPLOYMENT_ID, - assetPin([assetId]), - ); - - expect(assetRoot).toBe(path.join(dataDir, "workflow-definition-sources", DEPLOYMENT_ID)); - expect([...assetMounts]).toEqual([[assetId, `source-assets/${assetId}/`]]); - }); - - test("dedupes many entries backed by one asset into a single mount", async () => { - const assetId = "asset_top"; - await fs.mkdir( - path.join(dataDir, "workflow-definition-sources", DEPLOYMENT_ID, "source-assets", assetId), - { recursive: true }, - ); - // Two closure entries, same backing asset. - const { assetMounts } = await resolveDeploymentAssetMounts( - dataDir, - DEPLOYMENT_ID, - assetPin([assetId, assetId]), - ); - expect(assetMounts.size).toBe(1); - }); - - test("fails loud when a pinned asset is absent from the store", async () => { - await expect( - resolveDeploymentAssetMounts(dataDir, DEPLOYMENT_ID, assetPin(["asset_missing"])), - ).rejects.toThrow(/is not present in the durable store/); - }); - - test("derives the pin's gitDir map when the durable git store is populated", async () => { - const assetId = "asset_top"; - // Populate the durable indexed-`.git` store exactly as a deploy checkout - // would; restore reads only this, deriving the gitDir from the pin. Two - // members share one backing asset, so both resolve to one gitDir. - await fs.mkdir(path.join(dataDir, "workflow-definition-source-gits", DEPLOYMENT_ID, assetId), { - recursive: true, - }); - - const { gitDirs, assetMounts } = await resolveDeploymentAssetMounts( - dataDir, - DEPLOYMENT_ID, - sourcePin(assetId, [".", "packages/member-1"]), - ); - - // No tarball entries, so no plain-file mounts. - expect(assetMounts.size).toBe(0); - expect([...gitDirs]).toEqual([ - [assetId, path.join(dataDir, "workflow-definition-source-gits", DEPLOYMENT_ID, assetId)], - ]); - }); - - test("fails loud when a source pin's git store is absent", async () => { - await expect( - resolveDeploymentAssetMounts(dataDir, DEPLOYMENT_ID, sourcePin("asset_missing", ["."])), - ).rejects.toThrow(/has no indexed git store/); - }); - - test("returns an empty mount map for a registry-sourced pin", async () => { - const registryPin: SourceRefPin = { - source: { kind: "registry", registry: "npmjs" }, - closure: { - schemaVersion: "1", - topLevel: [{ name: "@x/wf", version: "1.0.0" }], - entries: [ - { - name: "@x/wf", - version: "1.0.0", - source: { - kind: "registry", - registry: "npmjs", - integrity: "sha512-placeholder", - }, - }, - ], - }, - }; - - const { assetMounts, gitDirs } = await resolveDeploymentAssetMounts( - dataDir, - DEPLOYMENT_ID, - registryPin, - ); - expect(assetMounts.size).toBe(0); - expect(gitDirs.size).toBe(0); - }); -}); diff --git a/apps/sidecar/src/workflow-host-wiring-spawner.test.ts b/apps/sidecar/src/workflow-host-wiring-spawner.test.ts deleted file mode 100644 index 4245353b1..000000000 --- a/apps/sidecar/src/workflow-host-wiring-spawner.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -// End-to-end exercise of the Bun.spawn-backed `defaultSubprocessSpawner` -// the wiring module exports. The supervisor's `wireChild` consumes -// four behaviours from the handle: -// -// 1. The control channel surfaces NDJSON lines the child writes -// to its stdout. A real child writes a signed `ready` envelope -// here; the test stub writes a sentinel NDJSON line. -// 2. The handle's `exited` future resolves with the child's -// terminal exit code so the supervisor can race spawn-time -// crashes against `readyPromise`. -// 3. A `Bun.spawn` that fails immediately (binary missing) settles -// `exited` with a non-zero code rapidly enough for that race -// to fire. -// 4. The child's runtime env is exactly the supervisor-supplied -// env -- unrelated `process.env` entries do not leak in. - -import { describe, test, expect, beforeAll, afterAll } from "bun:test"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { defaultSubprocessSpawner } from "./workflow-host-wiring"; - -let tmpRoot: string; - -beforeAll(async () => { - tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "intx-spawner-test-")); -}); - -afterAll(async () => { - await fs.rm(tmpRoot, { recursive: true, force: true }); -}); - -async function writeChildScript(body: string): Promise { - const file = path.join(tmpRoot, `child-${String(Date.now())}.ts`); - await fs.writeFile(file, body, "utf-8"); - return file; -} - -describe("defaultSubprocessSpawner (Bun.spawn-backed)", () => { - test("surfaces the child's NDJSON output on controlReader and resolves exited with the exit code", async () => { - // Inline child: - // - reads CHILD_TOKEN out of the env and echoes it - // - reads SHOULD_NOT_LEAK to prove env isolation (not set by - // this test's spawner call, so the child must observe it - // as the sentinel string) - // - writes one byte sequence to fd 3 (event channel) - // - emits one NDJSON line on stdout - // - exits 0 - const childScript = ` -import fs from "node:fs"; -const token = process.env.CHILD_TOKEN ?? "MISSING"; -const leaked = process.env.SHOULD_NOT_LEAK ?? "UNSET"; -const event = new TextEncoder().encode("event-byte"); -const eventStream = fs.createWriteStream("", { fd: 3 }); -eventStream.write(event, () => { - process.stdout.write( - JSON.stringify({ probe: "ready", token, leaked }) + "\\n", - () => process.exit(0), - ); -}); -`; - const scriptPath = await writeChildScript(childScript); - - // The wiring module's spawner is a bare Bun.spawn binding; the - // production callsite invokes the binary directly. The script - // path here points to a TypeScript file which requires Bun's - // shebang to execute; the production binary at - // `apps/sidecar/bin/workflow-child` is - // `#!/usr/bin/env bun`, so the wiring - // module spawns it as a bare argv entry. The - // test mirrors that by routing the spawn through a tiny - // wrapper script that points at Bun's runtime. - const wrapperPath = path.join(tmpRoot, `wrapper-${String(Date.now())}.sh`); - await fs.writeFile( - wrapperPath, - `#!/bin/sh\nexec "${process.execPath}" "${scriptPath}"\n`, - "utf-8", - ); - await fs.chmod(wrapperPath, 0o755); - - // Set SHOULD_NOT_LEAK on the test process; the spawner must NOT - // forward it to the child because the env arg below excludes - // it. The child observes the unset variable as "UNSET". - process.env.SHOULD_NOT_LEAK = "leaked"; - try { - const handle = defaultSubprocessSpawner({ - binaryPath: wrapperPath, - env: { CHILD_TOKEN: "spawner-test-value" }, - }); - - const ctrlIter = handle.controlReader.read(); - const first = await ctrlIter.next(); - expect(first.done).toBeFalsy(); - if (first.value === undefined) { - throw new Error("control reader yielded undefined first value"); - } - const parsed: unknown = JSON.parse(first.value); - if ( - typeof parsed !== "object" || - parsed === null || - !("probe" in parsed) || - !("token" in parsed) || - !("leaked" in parsed) - ) { - throw new Error(`child NDJSON payload missing expected fields: ${JSON.stringify(parsed)}`); - } - expect(parsed.probe).toBe("ready"); - expect(parsed.token).toBe("spawner-test-value"); - expect(parsed.leaked).toBe("UNSET"); - - // Drain one frame off the event channel. The child wrote one - // byte sequence to fd 3 before exiting; the supervisor's - // FrameReader contract yields one Uint8Array per kernel- - // delivered chunk. - const eventIter = handle.eventReader.read(); - const chunk = await eventIter.next(); - expect(chunk.done).toBeFalsy(); - if (chunk.value === undefined) { - throw new Error("event reader yielded undefined value"); - } - expect(new TextDecoder().decode(chunk.value)).toBe("event-byte"); - - const code = await handle.exited; - expect(code).toBe(0); - } finally { - delete process.env.SHOULD_NOT_LEAK; - } - }); - - test("spawn-time failure surfaces fast enough for the supervisor's readyPromise race", async () => { - // The supervisor's `wireChild` races `handle.exited` against - // the child's `readyPromise`. A child that fails to reach - // `ready` (the production binary's failure mode for malformed - // env: throws in `parseSpawnTimeEnv` before opening the - // control channel) must surface as `exited` settling with a - // non-zero code -- otherwise the supervisor wedges in - // `starting`. The inline child below exits 1 without writing - // a single byte; the spawner's `exited` future must observe - // the terminal code. - const childScript = `process.exit(7);`; - const scriptPath = await writeChildScript(childScript); - const wrapperPath = path.join(tmpRoot, `wrapper-fail-${String(Date.now())}.sh`); - await fs.writeFile( - wrapperPath, - `#!/bin/sh\nexec "${process.execPath}" "${scriptPath}"\n`, - "utf-8", - ); - await fs.chmod(wrapperPath, 0o755); - - const handle = defaultSubprocessSpawner({ - binaryPath: wrapperPath, - env: {}, - }); - const code = await handle.exited; - expect(code).toBe(7); - }); - - test("Bun.spawn synchronous throws on a missing binary surface to the caller", () => { - // The other half of the spawn-time race: a binary path that - // does not exist on disk surfaces as a synchronous throw from - // `Bun.spawn`, which the supervisor's `spawn(opts)` propagates - // to its caller. The supervisor never reaches the - // readyPromise race in this branch; the throw IS the failure - // signal. Pin the synchronous-throw behaviour here so a future - // Bun release that swaps to an async failure mode does not - // silently change the supervisor's spawn-error surface. - expect(() => { - defaultSubprocessSpawner({ - binaryPath: "/nonexistent-binary-for-spawner-test", - env: {}, - }); - }).toThrow(); - }); - - test("kill() forwards the recycle path's SIGTERM and SIGKILL signals", async () => { - // The supervisor's recycle path calls `handle.kill("SIGTERM")` - // and then `handle.kill("SIGKILL")`. Assert both crossings - // succeed; the bytes the supervisor sends are these exact - // strings. - const childScript = ` -import fs from "node:fs"; -process.stdout.write(JSON.stringify({ probe: "ready" }) + "\\n"); -const eventStream = fs.createWriteStream("", { fd: 3 }); -void eventStream; -await new Promise((r) => setTimeout(r, 5000)); -process.exit(0); -`; - const scriptPath = await writeChildScript(childScript); - const wrapperPath = path.join(tmpRoot, `wrapper-kill-${String(Date.now())}.sh`); - await fs.writeFile( - wrapperPath, - `#!/bin/sh\nexec "${process.execPath}" "${scriptPath}"\n`, - "utf-8", - ); - await fs.chmod(wrapperPath, 0o755); - - const handle = defaultSubprocessSpawner({ - binaryPath: wrapperPath, - env: {}, - }); - - const iter = handle.controlReader.read(); - const ready = await iter.next(); - expect(ready.done).toBeFalsy(); - - handle.kill("SIGTERM"); - handle.kill("SIGKILL"); - - const code = await handle.exited; - expect(typeof code).toBe("number"); - }); -}); diff --git a/apps/sidecar/src/workflow-host-wiring-undeploy-supervisor.test.ts b/apps/sidecar/src/workflow-host-wiring-undeploy-supervisor.test.ts deleted file mode 100644 index eedfc36ea..000000000 --- a/apps/sidecar/src/workflow-host-wiring-undeploy-supervisor.test.ts +++ /dev/null @@ -1,456 +0,0 @@ -// Pins C-A: the multi-step undeploy hook must shut down the per- -// deployment supervisor so the workflow-process child, its IPC pipes, -// and its event-channel fd are released. Without the shutdown the -// supervisor's reference outlives every other piece of routing state -// the undeploy hook tears down, leaking the child for the life of the -// sidecar. -// -// The harness drives the multi-step deploy through the same spawn -// handshake the existing wiring tests use, captures the -// `SubprocessHandle.kill` call, and asserts that `undeploy(frame)` -// invokes it and awaits the handle's `exited` settlement. - -import { describe, test, expect } from "bun:test"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { createEd25519Crypto, createNoopCredentialCipher, generateKeyPair } from "@intx/crypto"; -import { hexEncode } from "@intx/types"; -import { createInMemoryTransport } from "@intx/mail-memory"; -import type { RepoId, RepoStore } from "@intx/hub-sessions"; -import { - createControlChannelSender, - type FrameReader, - type NdjsonReader, - type NdjsonWriter, - type SubprocessHandle, - type SubprocessSpawner, -} from "@intx/workflow-host"; -import type { AgentDeployFrame } from "@intx/types/sidecar"; -import type { WorkflowDefinition } from "@intx/workflow"; - -import { createSidecarDeployRouter, deriveDeploymentId } from "./workflow-host-wiring"; -import { - createMultistepDrainRouter, - createMultistepMailRouter, - createMultistepSignalRouter, -} from "./workflow-run-pack-client"; - -function createMemoryNdjsonStream() { - const buffer: string[] = []; - let waiter: (() => void) | null = null; - let done = false; - function wake() { - const w = waiter; - waiter = null; - if (w) w(); - } - const reader: NdjsonReader = { - read(): AsyncIterableIterator { - return (async function* () { - while (true) { - if (buffer.length > 0) { - const next = buffer.shift(); - if (next === undefined) { - throw new Error("buffer shift returned undefined"); - } - yield next; - continue; - } - if (done) return; - await new Promise((resolve) => { - waiter = resolve; - }); - } - })(); - }, - }; - const writer: NdjsonWriter = { - write(line: string) { - buffer.push(line.replace(/\n$/, "")); - wake(); - return Promise.resolve(); - }, - }; - return { - writer, - reader, - inject(line: string) { - buffer.push(line.replace(/\n$/, "")); - wake(); - }, - close() { - done = true; - wake(); - }, - }; -} - -function createMemoryFrameStream() { - const buffer: Uint8Array[] = []; - let waiter: (() => void) | null = null; - let done = false; - function wake() { - const w = waiter; - waiter = null; - if (w) w(); - } - const reader: FrameReader = { - read(): AsyncIterableIterator { - return (async function* () { - while (true) { - if (buffer.length > 0) { - const next = buffer.shift(); - if (next === undefined) { - throw new Error("frame buffer shift returned undefined"); - } - yield next; - continue; - } - if (done) return; - await new Promise((resolve) => { - waiter = resolve; - }); - } - })(); - }, - }; - return { - reader, - close() { - done = true; - wake(); - }, - }; -} - -function createSpawnTestRepoStore(tempBase: string): RepoStore { - const stub: Partial = { - getRepoDir(repoId: RepoId): string { - return path.join(tempBase, repoId.kind, repoId.id); - }, - async writeTreePreservingPrefix(_p, _id, _ref, args) { - await args.merge(new Map()); - return { commitSha: "stub-sha", newlyTerminalRuns: [] }; - }, - // The deploy router's grants bridge writes `state/grants.json` to - // each step's agent-state repo before `spawn()`. Mirror the - // `getRepoDir` layout so the write lands where the subsequent - // `assembleCredentialsSnapshot` working-tree read looks for it. - async writeTree(_p, repoId, _ref, content) { - const dir = path.join(tempBase, repoId.kind, repoId.id); - for (const [relPath, contents] of Object.entries(content.files)) { - const full = path.join(dir, relPath); - await fs.mkdir(path.dirname(full), { recursive: true }); - await fs.writeFile(full, contents); - } - return { commitSha: "stub-sha", newlyTerminalRuns: [] }; - }, - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub - return new Proxy(stub as RepoStore, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (value !== undefined) return value; - return () => { - throw new Error(`stub RepoStore: ${String(prop)} not implemented for this test`); - }; - }, - }); -} - -describe("createSidecarDeployRouter multi-step undeploy shuts the supervisor down", () => { - test("undeploy invokes the spawned child's kill and awaits exited", async () => { - // Per-spawn tracking. - type Spawn = { - handle: SubprocessHandle; - childToSupervisor: ReturnType; - supervisorToChild: ReturnType; - eventChildToSupervisor: ReturnType; - env: Record; - killed: boolean; - exitedResolved: boolean; - resolveExited: (code: number) => void; - }; - const spawns: Spawn[] = []; - - const spawner: SubprocessSpawner = ({ env }) => { - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - const entry: Spawn = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- assigned below - handle: undefined as unknown as SubprocessHandle, - supervisorToChild, - childToSupervisor, - eventChildToSupervisor, - env, - killed: false, - exitedResolved: false, - resolveExited: (code) => { - entry.exitedResolved = true; - resolveExit?.(code); - }, - }; - const handle: SubprocessHandle = { - pid: 5100 + spawns.length, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - entry.killed = true; - childToSupervisor.close(); - eventChildToSupervisor.close(); - entry.resolveExited(0); - }, - exited, - }; - entry.handle = handle; - spawns.push(entry); - return handle; - }; - - const transport = createInMemoryTransport(); - const keyPair = await generateKeyPair(); - const tempBase = await fs.mkdtemp(path.join(os.tmpdir(), "sidecar-undeploy-supervisor-")); - const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "sidecar-undeploy-supervisor-data-")); - const repoStore = createSpawnTestRepoStore(tempBase); - - const mailRouter = createMultistepMailRouter(); - const signalRouter = createMultistepSignalRouter(); - const drainRouter = createMultistepDrainRouter(); - - const router = createSidecarDeployRouter({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the single-step branch invokes only initRepo (head deploy-tree repo); provisionAgent/persistHubPublicKey stay unused (the supervised child mints its own key and persists no hub-agent config) - sessions: { - provisionAgent: async () => { - throw new Error("single-step branch must not invoke provisionAgent"); - }, - persistHubPublicKey: async () => { - throw new Error("single-step branch must not invoke persistHubPublicKey"); - }, - initRepo: async () => undefined, - } as unknown as Parameters[0]["sessions"], - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the single-step branch registers the agent's signing key (loadOrGenerateKey) and records the hub key (recordHubKey) at the head before spawn - keyStore: { - recordHubKey: () => undefined, - loadOrGenerateKey: async () => ({ - keyPair: await generateKeyPair(), - isNew: false, - }), - } as unknown as Parameters[0]["keyStore"], - senderKeyCache: { - get: () => undefined, - put: async () => undefined, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }, - transport, - repoStore, - signingKeySeed: keyPair.privateKey, - credentialCipher: createNoopCredentialCipher(), - createAgentCrypto: createEd25519Crypto, - assertSourceBuildable: () => undefined, - registerDeployment: () => { - /* no-op */ - }, - unregisterDeployment: () => { - /* no-op */ - }, - multistepSubprocessSpawner: spawner, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: dataDir, - // Source-ref materialization reads both byte caps from the substrate env. - SIDECAR_CACHE_MAX_BYTES: "1000000", - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: "1000000", - }, - // Source-ref is the only deploy lineage: the router derives the runnable - // definition by materializing the pin's closure through this dependency. - // The stub returns a valid single-step `step-1` live definition (its step - // carries an agent so it survives `projectLiveToInert`) so the deploy - // reaches the spawn/undeploy behavior this test exercises. - applyFrozenWorkflowClosure: (applyArgs) => - Promise.resolve({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- a hand-built live definition cannot satisfy the full WorkflowDefinition nominal type; it stands in for a real closure evaluation - definition: { - id: "wf-undeploy-supervisor", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { - "step-1": { - kind: "step", - id: "step-1", - agent: { - id: "agent-step-1", - systemPrompt: "sys", - capabilities: [], - toolFactories: [], - inference: { sources: [] }, - }, - }, - }, - } as unknown as WorkflowDefinition, - packageDir: path.join(applyArgs.instanceDir, "package"), - deployDir: path.join(applyArgs.instanceDir, "deploy"), - }), - multistepMailRouter: mailRouter, - multistepSignalRouter: signalRouter, - multistepDrainRouter: drainRouter, - }); - - const frame: AgentDeployFrame = { - type: "agent.deploy", - // Single-step projection: the deploy router derives the sole - // step's agent-state repo from `parseAgentId(agentAddress)`, which - // requires the canonical `run_@` instance shape. - agentAddress: "run_undeploy-supervisor@example.com", - agentId: "undeploy-supervisor-agent", - hubPublicKey: "hub-pk", - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the multi-step branch does not read config - config: {} as AgentDeployFrame["config"], - workflow: { - // Placeholder hub-approved wire hash so the deploy path's fail-loud - // guard passes; production always stamps it. - approvedWireHash: "a".repeat(64), - // Source-ref is the only deploy lineage: the frame carries no inline - // definition, only the pin the sidecar re-materializes (the injected - // closure stub above evaluates it to a single-step `step-1` definition). - sourceRef: { - source: { kind: "registry", registry: "test-registry" }, - closure: { schemaVersion: "1", topLevel: [], entries: [] }, - }, - sources: { - "step-1": [ - { - id: "step-1", - provider: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: "sk-step-1", - model: "claude-3-5", - }, - ], - }, - }, - }; - - const deployPromise = router.deploy(frame); - - while (spawns.length === 0) { - await new Promise((r) => setTimeout(r, 1)); - } - const spawn = spawns[0]; - if (spawn === undefined) throw new Error("unreachable"); - - const channelId = spawn.env.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID missing from spawn env"); - } - const childIpcKeyPair = await generateKeyPair(); - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - spawn.childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: spawn.handle.pid, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - - await deployPromise; - - expect(spawn.killed).toBe(false); - expect(spawn.exitedResolved).toBe(false); - - const undeploy = router.undeploy; - if (undeploy === undefined) { - throw new Error("router.undeploy is undefined"); - } - - // Pre-seed the on-disk per-step scratch the child roots under - // `/workflow-step-state//` and the durable - // conversation under `/agent-conversation-state//`. - // The warm subtree is the stable per-agent workspace (one dir, not - // one-per-message); a stale cold `runs//` subtree models a - // multi-step leftover the per-run cleanup did not drop. An unrelated - // deployment's step-state subtree must survive the undeploy sweep. - const anchorRunId = deriveDeploymentId(frame.agentAddress); - const stepStateRoot = path.join(dataDir, "workflow-step-state"); - const warmWorkspaceFile = path.join( - stepStateRoot, - anchorRunId, - "warm", - encodeURIComponent("step-1"), - "workspace", - "notes.txt", - ); - const coldLeftoverFile = path.join( - stepStateRoot, - anchorRunId, - "runs", - "run-stale", - "steps", - "step-1", - "attempt-1", - "workspace", - "scratch.txt", - ); - const otherDeploymentFile = path.join( - stepStateRoot, - "other-deployment", - "warm", - "step-1", - "workspace", - "keep.txt", - ); - const durableConversationFile = path.join( - dataDir, - "agent-conversation-state", - anchorRunId, - encodeURIComponent("step-1"), - "checkpoint.json", - ); - for (const file of [ - warmWorkspaceFile, - coldLeftoverFile, - otherDeploymentFile, - durableConversationFile, - ]) { - await fs.mkdir(path.dirname(file), { recursive: true }); - await fs.writeFile(file, "x"); - } - - await undeploy({ - type: "agent.undeploy", - agentAddress: frame.agentAddress, - reason: "test undeploy", - }); - - expect(spawn.killed).toBe(true); - expect(spawn.exitedResolved).toBe(true); - - // The deployment's whole step-state subtree is reclaimed -- warm - // stable workspace AND any cold leftover -- now that its supervisor - // and child are torn down. - await expect(fs.stat(path.join(stepStateRoot, anchorRunId))).rejects.toThrow(); - // A different deployment's scratch is untouched: the sweep is scoped - // to this deployment's `` subtree only. - expect(await fs.readFile(otherDeploymentFile, "utf8")).toBe("x"); - // The durable conversation lives under a DIFFERENT root and must - // survive so a re-deploy restores the prior conversation. - expect(await fs.readFile(durableConversationFile, "utf8")).toBe("x"); - }); -}); diff --git a/apps/sidecar/src/workflow-host-wiring.test.ts b/apps/sidecar/src/workflow-host-wiring.test.ts deleted file mode 100644 index 375fefa49..000000000 --- a/apps/sidecar/src/workflow-host-wiring.test.ts +++ /dev/null @@ -1,3917 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { type } from "arktype"; - -import { createEd25519Crypto, createNoopCredentialCipher, generateKeyPair } from "@intx/crypto"; -import { hexEncode } from "@intx/types"; -import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; -import { createInMemoryTransport } from "@intx/mail-memory"; -import type { RepoId, RepoStore } from "@intx/hub-sessions"; -import { - createControlChannelSender, - type EventPayload, - type FrameReader, - type NdjsonReader, - type NdjsonWriter, - type SubprocessHandle, - type SubprocessSpawner, -} from "@intx/workflow-host"; -import type { WorkflowDefinition } from "@intx/workflow"; -import type { AgentDeployFrame } from "@intx/types/sidecar"; - -import { - assembleRunCredentialsSnapshot, - createSidecarDeployRouter, - createSidecarWorkflowSupervisor, - deriveDeploymentId, - STEP_INFERENCE_SOURCES_ENV_KEY, - validateWorkflowProjection, -} from "./workflow-host-wiring"; -import { - createDeploymentAddressRegistry, - createMultistepGrantsRouter, - createMultistepMailRouter, - createMultistepSourcesRouter, - type MultistepGrantsRouter, - type MultistepMailRouter, - type MultistepSourcesRouter, -} from "./workflow-run-pack-client"; -import { - scanWorkflowRunRecords, - writeWorkflowRunRecord, - type WorkflowRunRecord, -} from "./workflow-run-record"; - -function createMinimalStubRepoStore(): RepoStore { - const stub: Partial = { - getRepoDir(_repoId: RepoId): string { - return "/tmp/unused"; - }, - async writeTreePreservingPrefix(_p, _id, _ref, args) { - // The wiring test exercises signature attribution by driving a - // requestCancel; the merge callback runs once with an empty - // pre-image. - await args.merge(new Map()); - return { commitSha: "stub-sha", newlyTerminalRuns: [] }; - }, - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub; the wiring test exercises only getRepoDir + writeTreePreservingPrefix - return new Proxy(stub as RepoStore, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (value !== undefined) return value; - return () => { - throw new Error(`stub RepoStore: ${String(prop)} not implemented for this test`); - }; - }, - }); -} - -describe("createSidecarWorkflowSupervisor", () => { - test("constructs the supervisor with the sidecar's bindings and signs CancelRequested via the host's signing key", async () => { - const transport = createInMemoryTransport(); - const keyPair = await generateKeyPair(); - const repoStore = createMinimalStubRepoStore(); - - const spawner: SubprocessSpawner = () => { - throw new Error("spawner not invoked in this test"); - }; - - const wired = createSidecarWorkflowSupervisor({ - transport, - repoStore, - signingKeySeed: keyPair.privateKey, - workflowRunRepoId: { kind: "workflow-run", id: "wire-test" }, - workflowRunRef: "refs/heads/main", - runId: "wire-test", - stepCount: 1, - stepOrder: ["step1"], - deploymentMailAddress: "wire-test@example.com", - deriveStepAddress: ({ runId, stepId }) => `${runId}-${stepId}@example.com`, - substrateEnv: { DATA_DIR: "/tmp/wire" }, - dynamicSpawnEnv: () => ({}), - subprocessSpawner: spawner, - }); - - expect(typeof wired.supervisor.spawn).toBe("function"); - expect(wired.getCredentialsSnapshot()).toBeNull(); - - const result = await wired.supervisor.requestCancel({ - runId: "r-wire-1", - origin: "supervisor-operator", - reason: "wiring test", - at: "2026-01-01T00:00:00.000Z", - }); - expect(result.commitSha).toBe("stub-sha"); - expect(result.seq).toBe(0); - }); - - test("routeInbound rejects when no subscriber is registered so undelivered mail is withheld", async () => { - const transport = createInMemoryTransport(); - // generateKeyPair is async; this test only exercises the - // mail-routing path so we synthesize a 32-byte seed without - // calling crypto. - const fakeSeed = new Uint8Array(32); - const repoStore = createMinimalStubRepoStore(); - const wired = createSidecarWorkflowSupervisor({ - transport, - repoStore, - signingKeySeed: fakeSeed, - workflowRunRepoId: { kind: "workflow-run", id: "inbound" }, - workflowRunRef: "refs/heads/main", - runId: "inbound", - stepCount: 1, - stepOrder: ["step1"], - deploymentMailAddress: "inbound@example.com", - deriveStepAddress: ({ runId, stepId }) => `${runId}-${stepId}@example.com`, - substrateEnv: {}, - dynamicSpawnEnv: () => ({}), - subprocessSpawner: () => { - throw new Error("spawner not invoked in this test"); - }, - }); - // Without a subscriber the delivery is not durably accepted, so - // routeInbound rejects: the hub-link then withholds the ack and the hub - // redelivers, rather than silently dropping (which under the ack model - // would be an acked loss). - await expect(wired.routeInbound(new TextEncoder().encode("hello"))).rejects.toThrow( - /no active mail subscriber/, - ); - }); - - test("onRunStart fails a poisoned run and passes an unpoisoned one", async () => { - // A poisoned run (its `run.grants` write failed) must be rejected at the - // barrier rather than started under the deploy-time grant set. An - // unpoisoned run with a per-run grants file on disk resolves normally. - const tempBase = await createTempBaseDir("sidecar-poison-barrier-"); - const anchorRunId = "dep-poison"; - const cleanRunId = "run-clean"; - const grantsDir = path.join(tempBase, "workflow-run", anchorRunId, "runs", cleanRunId); - await fs.mkdir(grantsDir, { recursive: true }); - const runGrants = [{ id: "g1", resource: "tool:send-mail", effect: "allow" }]; - await fs.writeFile(path.join(grantsDir, "grants.json"), JSON.stringify({ grants: runGrants })); - - const readStub: Partial = { - getRepoDir(repoId: RepoId): string { - return path.join(tempBase, repoId.kind, repoId.id); - }, - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub; onRunStart reads only via getRepoDir working-tree reads - const repoStore = new Proxy(readStub as RepoStore, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (value !== undefined) return value; - return () => { - throw new Error(`stub RepoStore: ${String(prop)} not implemented`); - }; - }, - }); - - const wired = createSidecarWorkflowSupervisor({ - transport: createInMemoryTransport(), - repoStore, - signingKeySeed: new Uint8Array(32), - workflowRunRepoId: { kind: "workflow-run", id: anchorRunId }, - workflowRunRef: "refs/heads/main", - runId: anchorRunId, - stepCount: 1, - stepOrder: ["step-1"], - deploymentMailAddress: `${anchorRunId}@example.com`, - deriveStepAddress: ({ runId: dep, stepId }) => `${dep}-${stepId}@example.com`, - substrateEnv: {}, - dynamicSpawnEnv: () => ({}), - subprocessSpawner: () => { - throw new Error("spawner not invoked in this test"); - }, - isRunPoisoned: (runId) => runId === "run-poisoned", - }); - - await expect(wired.onRunStart({ runId: "run-poisoned", anchorRunId })).rejects.toThrow( - /grants write failed/, - ); - - const snapshot = await wired.onRunStart({ - runId: cleanRunId, - anchorRunId, - }); - expect(snapshot.steps).toHaveLength(1); - expect(snapshot.steps[0]?.grants).toEqual(runGrants); - }); -}); - -describe("createSidecarDeployRouter provision-step (no-spawn) mode", () => { - test("a provisionStep frame inits the repo and records the hub key without spawning", async () => { - const transport = createInMemoryTransport(); - const keyPair = await generateKeyPair(); - - const initRepoCalls: string[] = []; - const recordHubKeyCalls: { address: string; hubKey: string }[] = []; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- provisionStep touches no RepoStore method; the Proxy throws if it ever does - const repoStore = new Proxy({} as RepoStore, { - get(_target, prop) { - return () => { - throw new Error(`stub RepoStore: ${String(prop)} not implemented`); - }; - }, - }); - - const router = createSidecarDeployRouter({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- provisionStep exercises only initRepo - sessions: { - initRepo: async (a: string) => { - initRepoCalls.push(a); - }, - } as unknown as Parameters[0]["sessions"], - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- provisionStep exercises only recordHubKey - keyStore: { - recordHubKey: (a: string, h: string) => { - recordHubKeyCalls.push({ address: a, hubKey: h }); - }, - } as unknown as Parameters[0]["keyStore"], - senderKeyCache: { - get: () => undefined, - put: async () => undefined, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }, - transport, - repoStore, - signingKeySeed: keyPair.privateKey, - credentialCipher: createNoopCredentialCipher(), - createAgentCrypto: createEd25519Crypto, - assertSourceBuildable: () => undefined, - registerDeployment: () => undefined, - unregisterDeployment: () => undefined, - }); - - const STEP_ADDR = "run_abc-step1@example.com"; - const HUB_KEY = "aa".repeat(32); - const result = await router.deploy({ - type: "agent.deploy", - agentAddress: STEP_ADDR, - agentId: "run_abc-step1", - hubPublicKey: HUB_KEY, - provisionStep: true, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- provisionStep never inspects config - config: {} as unknown as Parameters< - ReturnType["deploy"] - >[0]["config"], - }); - - // The step's agent-state repo is initialized and the hub key recorded, - // so the follow-up full-closure deploy pack applies into a repo and - // verifies against the recorded key. - expect(initRepoCalls).toEqual([STEP_ADDR]); - expect(recordHubKeyCalls).toEqual([{ address: STEP_ADDR, hubKey: HUB_KEY }]); - - // The ack carries the sidecar principal key (the hub discards it for a - // workflow-derived per-step address). - expect(result.publicKey).toMatch(/^[0-9a-f]{64}$/); - - // Nothing spawned: no supervisor, so no active address. - expect(router.activeAddresses()).toEqual([]); - }); -}); - -// -------------------------------------------------------------------- -// Multi-step branch tests -// -------------------------------------------------------------------- - -function createMemoryNdjsonStream() { - const buffer: string[] = []; - let waiter: (() => void) | null = null; - let done = false; - function wake() { - const w = waiter; - waiter = null; - if (w) w(); - } - const reader: NdjsonReader = { - read(): AsyncIterableIterator { - return (async function* () { - while (true) { - if (buffer.length > 0) { - const next = buffer.shift(); - if (next === undefined) { - throw new Error("buffer shift returned undefined"); - } - yield next; - continue; - } - if (done) return; - await new Promise((resolve) => { - waiter = resolve; - }); - } - })(); - }, - }; - const writer: NdjsonWriter = { - write(line: string) { - buffer.push(line.replace(/\n$/, "")); - wake(); - return Promise.resolve(); - }, - }; - return { - writer, - reader, - inject(line: string) { - buffer.push(line.replace(/\n$/, "")); - wake(); - }, - flushed(): readonly string[] { - return buffer.slice(); - }, - close() { - done = true; - wake(); - }, - }; -} - -function createMemoryFrameStream() { - const buffer: Uint8Array[] = []; - let waiter: (() => void) | null = null; - let done = false; - function wake() { - const w = waiter; - waiter = null; - if (w) w(); - } - const reader: FrameReader = { - read(): AsyncIterableIterator { - return (async function* () { - while (true) { - if (buffer.length > 0) { - const next = buffer.shift(); - if (next === undefined) { - throw new Error("frame buffer shift returned undefined"); - } - yield next; - continue; - } - if (done) return; - await new Promise((resolve) => { - waiter = resolve; - }); - } - })(); - }, - }; - return { - reader, - inject(bytes: Uint8Array) { - buffer.push(bytes); - wake(); - }, - close() { - done = true; - wake(); - }, - }; -} - -function createTempBaseDir(prefix: string): Promise { - return fs.mkdtemp(path.join(os.tmpdir(), prefix)); -} - -// The supervisor's `parked-correlations.request` control-frame discriminator. -// The re-emit-parked-correlations tests assert this reaches the downstream -// (supervisor -> child) stream when Trigger B fires. -const PARKED_REQUEST_TYPE = "parked-correlations.request"; - -// One downstream control line is the JSON serialization of a signed envelope: -// `{ envelope: { seq, channelId, payload }, sig }`. The re-emit tests inspect -// `envelope.payload.type` to detect a `parked-correlations.request` without -// verifying the signature (the supervisor's IPC public key is not exposed to -// the test). Structurally validate the envelope-carrying shape and return the -// payload discriminator, or `undefined` when the line is not a typed frame. -const DownstreamFrameShape = type({ - envelope: { - payload: { - "type?": "string", - "+": "ignore", - }, - "+": "ignore", - }, - "+": "ignore", -}); - -function downstreamPayloadType(line: string): string | undefined { - const parsed: unknown = JSON.parse(line); - const validated = DownstreamFrameShape(parsed); - if (validated instanceof type.errors) return undefined; - return validated.envelope.payload.type; -} - -/** - * Build a stub RepoStore whose `getRepoDir` resolves under the supplied - * tempBase. The multi-step branch's `assembleCredentialsSnapshot` - * reads `state/grants.json` from disk -- missing files are treated as - * empty grants, so a freshly-created tempBase produces an empty - * credentials snapshot which is what the wiring test wants. - */ -function createSpawnTestRepoStore(tempBase: string): RepoStore { - const stub: Partial = { - getRepoDir(repoId: RepoId): string { - return path.join(tempBase, repoId.kind, repoId.id); - }, - async writeTreePreservingPrefix(_p, _id, _ref, args) { - await args.merge(new Map()); - return { commitSha: "stub-sha", newlyTerminalRuns: [] }; - }, - // The deploy router's grants bridge writes `state/grants.json` to - // each step's agent-state repo before `spawn()`. Mirror the - // `getRepoDir` layout so the write lands where the subsequent - // `assembleCredentialsSnapshot` working-tree read looks for it. - async writeTree(_p, repoId, _ref, content) { - const dir = path.join(tempBase, repoId.kind, repoId.id); - for (const [relPath, contents] of Object.entries(content.files)) { - const full = path.join(dir, relPath); - await fs.mkdir(path.dirname(full), { recursive: true }); - await fs.writeFile(full, contents); - } - return { commitSha: "stub-sha", newlyTerminalRuns: [] }; - }, - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub; only getRepoDir + writeTreePreservingPrefix + writeTree exercised - return new Proxy(stub as RepoStore, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (value !== undefined) return value; - return () => { - throw new Error(`stub RepoStore: ${String(prop)} not implemented for this test`); - }; - }, - }); -} - -type WorkflowProjection = NonNullable; -// A single source: each step's `sources` value is an ordered failover chain, -// so the fixture element type is the chain's member. -type InferenceSourceFixture = WorkflowProjection["sources"][string][number]; - -type MultistepDeployArgs = { - sources: WorkflowProjection["sources"]; - definition: { - id: string; - triggers: unknown[]; - stepOrder: string[]; - steps: Record; - }; - /** - * Override the deploy frame's `agentAddress`. Single-step projections - * are the agent-launch identity path: the deploy router derives the - * sole step's agent-state repo from `parseAgentId(agentAddress)`, which - * requires the canonical `run_@` shape. Tests that drive a - * single-step projection supply a valid instance address here; the - * default keeps the historical multi-step address for the multi-step - * tests (whose derived per-step repos do not parse the frame address). - */ - agentAddress?: string; - /** - * Hub-approved wire hash to stamp on the deploy frame's workflow -- the - * child's `DEFINITION_HASH`. Production always stamps it, so the helper - * defaults to a placeholder when unset; a test exercising re-verify passes a - * real computed hash here instead. - */ - approvedWireHash?: string; - /** - * Build a frame with NO `approvedWireHash` to exercise the deploy path's - * fail-loud guard (the sidecar refuses to recompute a hub-authority hash). - * Overrides the helper's default placeholder. - */ - omitApprovedWireHash?: boolean; -}; - -function makeInferenceSource(id: string): InferenceSourceFixture { - return { - id, - provider: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: `sk-${id}`, - model: "claude-3-5", - }; -} - -/** - * Live definitions the source-ref deploy/restore path reconstructs, keyed by - * derived deployment id. `makeMultistepFrame` registers one per frame it builds; - * `buildMultistepFixture`'s default closure stub returns the matching entry so a - * deploy or restore evaluates the exact topology the test described. Module-level - * so a restore fixture built over the same on-disk data dir (a simulated - * restart) reads the same entry the deploy fixture registered. - */ -const deployDefinitionRegistry = new Map(); - -/** - * Upgrade a test's inert-ish definition to a valid LIVE definition: every step - * gets a real agent so the definition survives `projectLiveToInert` (a bare - * `{ kind: "step" }` with no agent throws). `stepOrder` is preserved verbatim, - * so a definition whose `stepOrder` names a step absent from `steps` still - * projects to the same "no such entry" throw the deploy path rejects on. - */ -function toLiveClosureDefinition( - definition: MultistepDeployArgs["definition"], -): WorkflowDefinition { - const steps: Record = {}; - for (const stepId of Object.keys(definition.steps)) { - steps[stepId] = { - kind: "step", - id: stepId, - agent: { - id: `agent-${stepId}`, - systemPrompt: "sys", - capabilities: [], - toolFactories: [], - inference: { sources: [] }, - }, - }; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- a hand-built live definition cannot satisfy the full `WorkflowDefinition` nominal type; it stands in for a real closure evaluation, exactly as the restore test's `closureDefinition` does - return { - id: definition.id, - triggers: definition.triggers, - stepOrder: definition.stepOrder, - steps, - } as unknown as WorkflowDefinition; -} - -/** - * Build a source-ref deploy frame. The deploy lineage is source-ref only, so the - * frame carries NO inline `definition`: it pins each step's inference sources, - * the hub-approved wire hash, and a placeholder source-ref pin. The runnable - * definition is decoupled from the frame exactly as production decouples it -- - * the sidecar re-materializes it through the injected `applyFrozenWorkflowClosure`. - * The helper registers the intended live definition (the caller's `definition` - * arg, upgraded so each step carries a valid agent) under the frame's derived - * deployment id; `buildMultistepFixture`'s default closure stub looks it up by - * that id, so a deploy or restore reconstructs the exact topology the caller - * described without threading it through the frame. - */ -function makeMultistepFrame(args: MultistepDeployArgs): AgentDeployFrame { - const agentAddress = args.agentAddress ?? "multi@example.com"; - deployDefinitionRegistry.set( - deriveDeploymentId(agentAddress), - toLiveClosureDefinition(args.definition), - ); - return { - type: "agent.deploy", - agentAddress, - agentId: "multi-agent", - hubPublicKey: "hub-pk", - // The wire-side HarnessConfig has many required fields. On the - // workflow deploy path the router reads only `config.sessionId` - // and `config.grants`, both of which tolerate the empty - // placeholder (they resolve to `undefined`), so an opaque `{}` - // satisfies the surface contract for these tests. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the workflow path reads only config.sessionId/config.grants, which tolerate undefined - config: {} as AgentDeployFrame["config"], - workflow: { - sources: args.sources, - // Placeholder source-ref pin. The deploy/restore path re-materializes the - // definition through the injected closure stub, which keys off the - // deployment id -- not this pin's contents -- so the pin only has to be a - // well-formed `SourceRefPin`. - sourceRef: { - source: { kind: "registry", registry: "test-registry" }, - closure: { schemaVersion: "1", topLevel: [], entries: [] }, - }, - // Production always stamps the hub-approved hash; default a placeholder - // so a deploy test need not compute one, and only omit it when a test - // explicitly exercises the fail-loud guard. - ...(args.omitApprovedWireHash - ? {} - : { approvedWireHash: args.approvedWireHash ?? "a".repeat(64) }), - }, - }; -} - -function defaultMultistepSources(): WorkflowProjection["sources"] { - return { - "step-1": [makeInferenceSource("step-1")], - "step-2": [makeInferenceSource("step-2")], - }; -} - -describe("validateWorkflowProjection", () => { - test("rejects an empty stepOrder", () => { - expect(() => - validateWorkflowProjection({ - definition: { id: "w-1", stepOrder: [], steps: {} }, - sources: {}, - }), - ).toThrow(/stepOrder must be a non-empty array/); - }); - - test("rejects a stepId that violates STEP_ID_PATTERN", () => { - expect(() => - validateWorkflowProjection({ - definition: { - id: "w-1", - stepOrder: ["bad.step"], - steps: { "bad.step": {} }, - }, - sources: { "bad.step": {} }, - }), - ).toThrow(/must match \^/); - }); - - test("rejects a missing sources entry for a stepOrder id", () => { - expect(() => - validateWorkflowProjection({ - definition: { - id: "w-1", - stepOrder: ["step-1"], - steps: { "step-1": {} }, - }, - sources: {}, - }), - ).toThrow(/sources is missing entry/); - }); - - test("rejects an empty sources chain for a stepOrder id", () => { - expect(() => - validateWorkflowProjection({ - definition: { - id: "w-1", - stepOrder: ["step-1"], - steps: { "step-1": {} }, - }, - sources: { "step-1": [] }, - }), - ).toThrow(/must be a non-empty array/); - }); - - test("rejects a non-array sources entry for a stepOrder id", () => { - expect(() => - validateWorkflowProjection({ - definition: { - id: "w-1", - stepOrder: ["step-1"], - steps: { "step-1": {} }, - }, - sources: { "step-1": {} }, - }), - ).toThrow(/must be a non-empty array/); - }); - - test("accepts a well-formed projection", () => { - expect(() => - validateWorkflowProjection({ - definition: { - id: "w-1", - stepOrder: ["step-1", "step-2"], - steps: { "step-1": {}, "step-2": {} }, - }, - sources: { "step-1": [{}], "step-2": [{}] }, - }), - ).not.toThrow(); - }); -}); - -describe("createSidecarDeployRouter multi-step branch", () => { - async function buildMultistepFixture(opts: { - spawner: SubprocessSpawner; - publishWorkflowInferenceEvent?: ( - address: string, - event: EventPayload, - sessionId: string | undefined, - ) => void; - multistepBinaryPath?: string; - multistepSubstrateEnv?: Record; - multistepMailRouter?: MultistepMailRouter; - multistepGrantsRouter?: MultistepGrantsRouter; - /** - * Injectable sender-key cache. The co-delivery tests pass a spy (or a - * throwing stub) to observe the grants handler's cache write; omitted, a - * no-op cache satisfies the dependency without persisting anything. - */ - senderKeyCache?: Parameters[0]["senderKeyCache"]; - multistepSourcesRouter?: MultistepSourcesRouter; - registerDeployment?: (args: { runId: string; agentAddress: string }) => void; - /** - * Injectable deployment-address unregister hook. Defaults to a no-op. - * The self-termination retention test wires this to a real - * `deploymentAddressRegistry` so it can assert the reclaim does NOT - * remove the mapping (the buggy reclaim called this hook, which would - * strand the supervisor's terminal `RunFailed` commit). - */ - unregisterDeployment?: (args: { runId: string; agentAddress: string }) => void; - assertSourceBuildable?: Parameters< - typeof createSidecarDeployRouter - >[0]["assertSourceBuildable"]; - /** - * Reuse an existing transport instead of a fresh one. The restore - * tests deploy through one fixture, then build a SECOND fixture over - * the same on-disk data dir with a FRESH transport to model a sidecar - * process restart (the in-memory transport is process-local, so a - * restart starts with an empty registration table). - */ - transport?: ReturnType; - /** - * Spawn ready-handshake timeout (ms) threaded to every supervisor the - * router constructs. The ready-timeout test uses a small value with a - * spawner that never drives `ready`, asserting the deploy rejects with - * the threaded value echoed in the message. - */ - readyTimeoutMs?: number; - /** - * Fixed keypair the keyStore's `loadOrGenerateKey` returns for the head. - * The B-key test pins the single-step deploy ack to this agent key; when - * omitted a fresh keypair is minted per call as before. - */ - headKeyPair?: Awaited>; - /** - * Injectable deployment-record writer. The rotation-interleave tests - * pass a blockable/failing stub so a recycle can be driven into the - * rotation's persist window; omitted, the router uses the real writer. - */ - writeWorkflowRunRecord?: typeof writeWorkflowRunRecord; - /** - * Injectable closure materializer. A source-ref deploy/restore test passes - * a stub so the path runs without a live registry; omitted, the router - * uses the real `applyFrozenWorkflowClosure`. - */ - applyFrozenWorkflowClosure?: Parameters< - typeof createSidecarDeployRouter - >[0]["applyFrozenWorkflowClosure"]; - }) { - const transport = opts.transport ?? createInMemoryTransport(); - const keyPair = await generateKeyPair(); - const tempBase = await createTempBaseDir("sidecar-multistep-"); - const repoStore = createSpawnTestRepoStore(tempBase); - // The deploy router's source-ref branch materializes the pinned closure - // under `${SIDECAR_DATA_DIR}/workflow-definition-closures//` before - // invoking the spawner. The test fixture defaults the data dir to a - // per-test mkdtemp so the wiring tests do not have to touch a real /tmp - // path; callers can override `SIDECAR_DATA_DIR` (and any other key) by - // passing `multistepSubstrateEnv`. - const defaultSubstrateEnv: Record = { - SIDECAR_DATA_DIR: await createTempBaseDir("sidecar-multistep-data-"), - // Source-ref is the only deploy lineage: every deploy materializes the - // pin's frozen closure, and the materializer requires both substrate byte - // caps in the env. Default them so a deploy test need not thread them; a - // test that overrides `multistepSubstrateEnv` keeps these unless it sets - // its own. - SIDECAR_CACHE_MAX_BYTES: "1000000", - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: "1000000", - }; - const mergedSubstrateEnv: Record = { - ...defaultSubstrateEnv, - ...(opts.multistepSubstrateEnv ?? {}), - }; - // The source-ref deploy/restore path derives the runnable definition by - // materializing the pin's closure through this injected dependency. The - // default stub returns the live definition `makeMultistepFrame` registered - // under the deployment id (the last segment of the per-deployment - // instance dir), so the deploy/restore evaluates the exact topology the - // frame described. A test that hand-writes a record (no frame) or wants a - // bespoke closure result passes its own `applyFrozenWorkflowClosure`. - const defaultApplyFrozenWorkflowClosure: NonNullable< - Parameters[0]["applyFrozenWorkflowClosure"] - > = (applyArgs) => { - const deploymentId = path.basename(applyArgs.instanceDir); - const definition = deployDefinitionRegistry.get(deploymentId); - if (definition === undefined) { - throw new Error( - `test default applyFrozenWorkflowClosure: no registered definition for deploymentId ${deploymentId} (instanceDir ${applyArgs.instanceDir}). Build the deploy frame via makeMultistepFrame/singleStepFrame, or pass an explicit applyFrozenWorkflowClosure when hand-writing a record.`, - ); - } - return Promise.resolve({ - definition, - packageDir: path.join(tempBase, "closure-package", deploymentId), - deployDir: path.join(tempBase, "closure-deploy", deploymentId), - }); - }; - const router = createSidecarDeployRouter({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the workflow path never invokes provisionAgent/persistHubPublicKey (single-step uses the narrow initRepo; the child mints its own key); the stubs throw if it does. initRepo is a no-op for the single-step head repo. - sessions: { - provisionAgent: async () => { - throw new Error("workflow branch must not invoke provisionAgent"); - }, - persistHubPublicKey: async () => { - throw new Error("workflow branch must not invoke persistHubPublicKey"); - }, - initRepo: async () => undefined, - } as unknown as Parameters[0]["sessions"], - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub; the single-step head deploy records the hub key for pack verification - keyStore: { - recordHubKey: () => undefined, - loadOrGenerateKey: async () => ({ - keyPair: opts.headKeyPair ?? (await generateKeyPair()), - isNew: false, - }), - // A single-step spawn failure unwinds the recorded hub key via - // forgetAgent; the fixture exercises that unwind, so the stub must - // honor the call. - forgetAgent: () => undefined, - } as unknown as Parameters[0]["keyStore"], - senderKeyCache: opts.senderKeyCache ?? { - get: () => undefined, - put: async () => undefined, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }, - transport, - repoStore, - signingKeySeed: keyPair.privateKey, - credentialCipher: createNoopCredentialCipher(), - createAgentCrypto: createEd25519Crypto, - assertSourceBuildable: opts.assertSourceBuildable ?? (() => undefined), - registerDeployment: opts.registerDeployment ?? (() => undefined), - unregisterDeployment: - opts.unregisterDeployment ?? - (() => { - /* no-op */ - }), - multistepSubprocessSpawner: opts.spawner, - ...(opts.multistepBinaryPath !== undefined - ? { multistepBinaryPath: opts.multistepBinaryPath } - : {}), - multistepSubstrateEnv: mergedSubstrateEnv, - ...(opts.publishWorkflowInferenceEvent !== undefined - ? { - publishWorkflowInferenceEvent: opts.publishWorkflowInferenceEvent, - } - : {}), - ...(opts.multistepMailRouter !== undefined - ? { multistepMailRouter: opts.multistepMailRouter } - : {}), - ...(opts.multistepGrantsRouter !== undefined - ? { multistepGrantsRouter: opts.multistepGrantsRouter } - : {}), - ...(opts.multistepSourcesRouter !== undefined - ? { multistepSourcesRouter: opts.multistepSourcesRouter } - : {}), - ...(opts.readyTimeoutMs !== undefined ? { readyTimeoutMs: opts.readyTimeoutMs } : {}), - ...(opts.writeWorkflowRunRecord !== undefined - ? { - writeWorkflowRunRecord: opts.writeWorkflowRunRecord, - } - : {}), - applyFrozenWorkflowClosure: - opts.applyFrozenWorkflowClosure ?? defaultApplyFrozenWorkflowClosure, - }); - return { - router, - tempBase, - keyPair, - substrateEnv: mergedSubstrateEnv, - transport, - }; - } - - test("validates the projection, constructs SpawnOpts from the frame, drives spawn, and acks the deployment address's public key", async () => { - const supervisorIpcKeyPair = await generateKeyPair(); - const childIpcKeyPair = await generateKeyPair(); - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let observedBinary: string | undefined; - let observedEnv: Record | undefined; - const spawner: SubprocessSpawner = ({ binaryPath, env }) => { - observedBinary = binaryPath; - observedEnv = env; - const handle: SubprocessHandle = { - pid: 7321, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - return handle; - }; - - const multiDataDir = await createTempBaseDir("sidecar-multi-data-"); - // The deployment address's own key -- what `loadOrGenerateKey` mints. - // Pin it so the ack assertion below is deterministic. - const deploymentKeyPair = await generateKeyPair(); - const { router } = await buildMultistepFixture({ - spawner, - headKeyPair: deploymentKeyPair, - multistepBinaryPath: "/fake/bin/multistep-workflow-child", - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: multiDataDir, - }, - }); - - // Hijack the supervisor's ipc keypair factory by routing through - // the test-construction surface: the router constructs the - // supervisor via createSidecarWorkflowSupervisor which does not - // expose ipcKeyPairFactory. The supervisor's default keypair is - // generated with generateKeyPair; the test signs the `ready` frame - // with whatever channelId the spawn-time env carries plus the - // child's keypair, and the supervisor accepts a bootstrap - // signature from any childPublicKey carried in the `ready` payload. - - const sources = defaultMultistepSources(); - const definition = { - id: "wf-router-test", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - // The sidecar sources DEFINITION_HASH from the frame's hub-approved hash - // verbatim (never a recompute), so stamp the real wire hash and assert the - // child receives it. - const approvedWireHash = await computeWireDefinitionHash(definition); - const frame = makeMultistepFrame({ definition, sources, approvedWireHash }); - - const deployPromise = router.deploy(frame); - - // Wait until the spawner has been invoked. - while (observedEnv === undefined) { - await new Promise((r) => setTimeout(r, 1)); - } - - const env = observedEnv; - expect(observedBinary).toBe("/fake/bin/multistep-workflow-child"); - expect(env).toMatchObject({ - SIDECAR_DATA_DIR: multiDataDir, - DEPLOYMENT_ID: "multi-example-com", - MAILBOX_ADDRESS: "multi@example.com", - }); - expect(env.DEFINITION_HASH).toBe(approvedWireHash); - expect(env[STEP_INFERENCE_SOURCES_ENV_KEY]).toBe(JSON.stringify(sources)); - expect(env.IPC_CHANNEL_ID).toMatch(/^[0-9a-f]{32}$/); - - // Drive the `ready` handshake. - const channelId = env.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID not set in spawn-time env"); - } - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: 7321, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - - const result = await deployPromise; - // Every deployment -- single- or multi-step -- acks the deployment - // address's own public key, so the hub can verify ownership on - // reconnect. A multi-step deployment previously acked the supervisor - // principal key, which the hub discarded. The hex is a 64-character - // lowercase string. - expect(result.publicKey).toMatch(/^[0-9a-f]{64}$/); - expect(result.publicKey).toBe(hexEncode(deploymentKeyPair.publicKey)); - - // Teardown: kill the child so the spawn-time pumps unwind. - // Use unused supervisorToChild to silence the linter. - void supervisorToChild; - void supervisorIpcKeyPair; - }); - - test("sources the child's DEFINITION_HASH from the frame's hub-approved wire hash, not a sidecar recompute", async () => { - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let observedEnv: Record | undefined; - const spawner: SubprocessSpawner = ({ env }) => { - observedEnv = env; - return { - pid: 9001, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - }; - - const multiDataDir = await createTempBaseDir("sidecar-approved-hash-"); - const { router } = await buildMultistepFixture({ - spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: multiDataDir }, - }); - - const sources = defaultMultistepSources(); - const definition = { - id: "wf-approved-hash", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - // A sentinel that is deliberately NOT the wire hash of `definition`, so an - // assertion that the child received it proves the value came from the - // frame (the hub authority) and was not recomputed at the sidecar. - const HUB_APPROVED_HASH = "hub-approved-sentinel-hash"; - const recomputed = await computeWireDefinitionHash(definition); - expect(HUB_APPROVED_HASH).not.toBe(recomputed); - - const frame = makeMultistepFrame({ - definition, - sources, - approvedWireHash: HUB_APPROVED_HASH, - }); - const deployPromise = router.deploy(frame); - - while (observedEnv === undefined) { - await new Promise((r) => setTimeout(r, 1)); - } - const env = observedEnv; - // The child's DEFINITION_HASH is the frame's hub-approved hash, verbatim. - expect(env.DEFINITION_HASH).toBe(HUB_APPROVED_HASH); - - // Drive the child to exit so the deploy's spawn pumps unwind, then let the - // deploy settle (it rejects once the child dies mid-handshake, which is - // fine -- the env assertion above is the subject under test). - const channelId = env.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID not set in spawn-time env"); - } - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - await deployPromise.catch(() => undefined); - void supervisorToChild; - }); - - test("a second same-address deploy is rejected mid-spawn and never deletes the live run record", async () => { - // Pins the synchronous single-flight reservation guard. The first - // deploy runs its durable writes and then suspends inside - // supervisor.spawn awaiting the child's `ready` handshake -- the window - // in which its reservation is held but `activeSupervisors` is not yet - // populated. A second same-address frame arriving in that window must be - // rejected at the reservation guard (its own message, distinct from the - // spawn-core backstop) before it touches durable state, so it cannot - // delete the first deploy's live record via the soft-fail catch. - const childIpcKeyPair = await generateKeyPair(); - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let spawnCount = 0; - let observedEnv: Record | undefined; - const spawner: SubprocessSpawner = ({ env }) => { - spawnCount += 1; - observedEnv = env; - const handle: SubprocessHandle = { - pid: 4242, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - return handle; - }; - - const multiDataDir = await createTempBaseDir("sidecar-concurrent-deploy-"); - const registered: string[] = []; - const { router } = await buildMultistepFixture({ - spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: multiDataDir }, - registerDeployment: ({ agentAddress }) => { - registered.push(agentAddress); - }, - }); - - const sources = defaultMultistepSources(); - const definition = { - id: "wf-concurrent", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - const frame = makeMultistepFrame({ definition, sources }); - const anchorRunId = deriveDeploymentId(frame.agentAddress); - const recordFile = path.join(multiDataDir, "workflow-runs", anchorRunId, "deployment.json"); - - const firstDeploy = router.deploy(frame); - // Wait until the first deploy has spawned: its record is on disk and it - // is now suspended in the ready handshake with the reservation held. - while (observedEnv === undefined) { - await new Promise((r) => setTimeout(r, 1)); - } - const recordBefore = await fs.readFile(recordFile, "utf8"); - expect(recordBefore.length).toBeGreaterThan(0); - - // The loser is rejected at the reservation guard, not the spawn-core - // backstop -- the guard's message is the one asserted here. - await expect(router.deploy(frame)).rejects.toThrow( - /is already deployed; undeploy it before redeploying/, - ); - // It never reached the spawner and never deleted the live record. - expect(spawnCount).toBe(1); - const recordAfter = await fs.readFile(recordFile, "utf8"); - expect(recordAfter).toBe(recordBefore); - - // Drive the first deploy's ready handshake so it completes, then confirm - // the winner is the live, registered deployment. - const channelId = observedEnv.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID not set in spawn-time env"); - } - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: 4242, - childPublicKey: Buffer.from(childIpcKeyPair.publicKey).toString("hex"), - }, - }); - - const result = await firstDeploy; - expect(result.publicKey).toMatch(/^[0-9a-f]{64}$/); - expect(registered).toEqual([frame.agentAddress]); - expect(router.activeAddresses()).toEqual([frame.agentAddress]); - - void supervisorToChild; - }); - - test("registers a multistepMailRouter handler against the deployment address once spawn succeeds", async () => { - // Drives the spawn handshake the same way the first multi-step - // test does, but injects a `multistepMailRouter` and asserts the - // deploy router registered a handler against the deployment's - // mail address by the time `deploy(frame)` resolves. The handler - // is what the sidecar hub-link's `mail.inbound` path dispatches - // through; without this registration, an inbound mail aimed at - // the deployment address falls into the legacy session path, - // which has no transport entry and no `sessions` row for the - // deployment address. - const childIpcKeyPair = await generateKeyPair(); - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let observedEnv: Record | undefined; - const spawner: SubprocessSpawner = ({ env }) => { - observedEnv = env; - const handle: SubprocessHandle = { - pid: 9123, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - return handle; - }; - - const mailRouter = createMultistepMailRouter(); - const { router } = await buildMultistepFixture({ - spawner, - multistepMailRouter: mailRouter, - }); - - const sources = defaultMultistepSources(); - const definition = { - id: "wf-mail-router-test", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - const frame = makeMultistepFrame({ definition, sources }); - - const deployPromise = router.deploy(frame); - - while (observedEnv === undefined) { - await new Promise((r) => setTimeout(r, 1)); - } - - const channelId = observedEnv.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID not set in spawn-time env"); - } - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: 9123, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - - await deployPromise; - - // The handler must be installed against the deployment's mail - // address (`frame.agentAddress`), and tryRoute must claim it. - const claimed = mailRouter.tryRoute(frame.agentAddress, new Uint8Array([1, 2, 3])); - expect(claimed).not.toBeNull(); - // Settle the durable promise so its resolution/rejection is not left as an - // unhandled rejection; this assertion only checks the address is claimed, - // not the enqueue outcome for this synthetic payload. - await claimed?.catch(() => undefined); - - // Teardown. - void supervisorToChild; - }); - - test("a run.grants frame writes the run's grants to runs//grants.json in the workflow-run repo", async () => { - // Drives the same spawn handshake as the mail-router test, then routes - // a `run.grants` frame through the injected `multistepGrantsRouter` and - // asserts the handler the deploy router installed wrote the run's - // grants to `runs//grants.json` inside the deployment's - // `workflow-run` repo -- sibling to the run's `runs//events/` - // subtree. Nothing reads the grants back yet; the assertion is on the - // on-disk write (right repo, right path, right content). - const childIpcKeyPair = await generateKeyPair(); - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let observedEnv: Record | undefined; - const spawner: SubprocessSpawner = ({ env }) => { - observedEnv = env; - const handle: SubprocessHandle = { - pid: 9124, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - return handle; - }; - - const grantsRouter = createMultistepGrantsRouter(); - const { router, tempBase } = await buildMultistepFixture({ - spawner, - multistepGrantsRouter: grantsRouter, - }); - - const sources = defaultMultistepSources(); - const definition = { - id: "wf-grants-router-test", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - const frame = makeMultistepFrame({ definition, sources }); - - const deployPromise = router.deploy(frame); - - while (observedEnv === undefined) { - await new Promise((r) => setTimeout(r, 1)); - } - - const channelId = observedEnv.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID not set in spawn-time env"); - } - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: 9124, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - - await deployPromise; - - const runId = "run-abc"; - const stepGrants = [ - { - id: "grant-1", - resource: "tool:send-mail", - action: "invoke", - effect: "allow", - origin: "creator", - conditions: null, - expiresAt: null, - roleId: null, - principalId: "prn_deployment", - }, - ]; - const routed = await grantsRouter.tryRoute({ - type: "run.grants", - agentAddress: frame.agentAddress, - runId, - stepGrants, - }); - expect(routed).toBe(true); - - // The write lands in the deployment's workflow-run repo at - // `runs//grants.json`, the run-owned sibling of `events/`. - const anchorRunId = deriveDeploymentId(frame.agentAddress); - const grantsFile = path.join( - tempBase, - "workflow-run", - anchorRunId, - "runs", - runId, - "grants.json", - ); - const onDisk: unknown = JSON.parse(await fs.readFile(grantsFile, "utf8")); - expect(onDisk).toEqual({ grants: stepGrants }); - - // Teardown. - void supervisorToChild; - }); - - // Deploy a multi-step deployment through the full spawn/ready handshake so - // the deploy router installs its grants handler, then hand back the pieces a - // co-delivery test needs to route a `run.grants` frame and inspect the write. - async function deployMultistepForGrants( - definitionId: string, - senderKeyCache: Parameters[0]["senderKeyCache"], - ): Promise<{ - grantsRouter: MultistepGrantsRouter; - agentAddress: string; - anchorRunId: string; - tempBase: string; - }> { - const childIpcKeyPair = await generateKeyPair(); - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let observedEnv: Record | undefined; - const spawner: SubprocessSpawner = ({ env }) => { - observedEnv = env; - return { - pid: 9124, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - }; - - const grantsRouter = createMultistepGrantsRouter(); - const { router, tempBase } = await buildMultistepFixture({ - spawner, - multistepGrantsRouter: grantsRouter, - senderKeyCache, - }); - - const definition = { - id: definitionId, - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - const frame = makeMultistepFrame({ - definition, - sources: defaultMultistepSources(), - }); - const deployPromise = router.deploy(frame); - - while (observedEnv === undefined) { - await new Promise((r) => setTimeout(r, 1)); - } - const channelId = observedEnv.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID not set in spawn-time env"); - } - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: 9124, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - await deployPromise; - void supervisorToChild; - - return { - grantsRouter, - agentAddress: frame.agentAddress, - anchorRunId: deriveDeploymentId(frame.agentAddress), - tempBase, - }; - } - - test("caches each co-delivered sender key before the run's grants land", async () => { - // The grants handler must cache the sender key BEFORE writing grants.json, - // so a durable grant is never missing the key its recipient verifies - // against. The spy records whether grants.json already exists when its - // `put` runs; it must not. - // A holder the spy reads at `put` time; its path is filled in only once the - // deploy resolves the run's on-disk location, so it starts empty. - const grantsFileRef: { path: string | undefined } = { path: undefined }; - const puts: { address: string; grantsExisted: boolean }[] = []; - const senderKeyCache = { - get: () => undefined, - put: async (address: string) => { - const grantsExisted = - grantsFileRef.path !== undefined && - (await fs - .stat(grantsFileRef.path) - .then(() => true) - .catch(() => false)); - puts.push({ address, grantsExisted }); - }, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }; - - const { grantsRouter, agentAddress, anchorRunId, tempBase } = await deployMultistepForGrants( - "wf-sender-key-order", - senderKeyCache, - ); - - const runId = "run-codeliver"; - const grantsFilePath = path.join( - tempBase, - "workflow-run", - anchorRunId, - "runs", - runId, - "grants.json", - ); - grantsFileRef.path = grantsFilePath; - const routed = await grantsRouter.tryRoute({ - type: "run.grants", - agentAddress, - runId, - stepGrants: [], - senderIdentities: [ - { - address: "sender@peer.example", - publicKey: hexEncode(new Uint8Array(32)), - }, - ], - }); - - expect(routed).toBe(true); - expect(puts).toEqual([{ address: "sender@peer.example", grantsExisted: false }]); - const grantsLanded = await fs - .stat(grantsFilePath) - .then(() => true) - .catch(() => false); - expect(grantsLanded).toBe(true); - }); - - test("a sender-key cache-write failure fails the run's grants", async () => { - // The cache write gates the grants write: if the key cannot be cached, the - // run must not start under a grant whose sender the recipient cannot - // verify. The handler's throw propagates and grants.json never lands. - const senderKeyCache = { - get: () => undefined, - put: async () => { - throw new Error("sender-key disk full"); - }, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }; - - const { grantsRouter, agentAddress, anchorRunId, tempBase } = await deployMultistepForGrants( - "wf-sender-key-fault", - senderKeyCache, - ); - - const runId = "run-cache-fault"; - await expect( - grantsRouter.tryRoute({ - type: "run.grants", - agentAddress, - runId, - stepGrants: [], - senderIdentities: [ - { - address: "sender@peer.example", - publicKey: hexEncode(new Uint8Array(32)), - }, - ], - }), - ).rejects.toThrow("sender-key disk full"); - - const grantsFilePath = path.join( - tempBase, - "workflow-run", - anchorRunId, - "runs", - runId, - "grants.json", - ); - const grantsLanded = await fs - .stat(grantsFilePath) - .then(() => true) - .catch(() => false); - expect(grantsLanded).toBe(false); - }); - - test("skips a malformed co-delivered key without failing the run's grants", async () => { - // A malformed key is a hub-side defect, keyless from the sidecar's view. - // Unlike a disk fault it must NOT poison the run -- otherwise a persistently - // bad key would wedge the run on every replay. The valid siblings still - // cache and the grants still land. - const puts: string[] = []; - const senderKeyCache = { - get: () => undefined, - put: async (address: string) => { - puts.push(address); - }, - evict: async () => undefined, - addresses: () => [], - rotatableAddresses: () => [], - }; - - const { grantsRouter, agentAddress, anchorRunId, tempBase } = await deployMultistepForGrants( - "wf-sender-key-malformed", - senderKeyCache, - ); - - const runId = "run-malformed"; - const routed = await grantsRouter.tryRoute({ - type: "run.grants", - agentAddress, - runId, - stepGrants: [], - senderIdentities: [ - // Wrong length: valid hex, but 16 bytes rather than 32. - { - address: "bad@peer.example", - publicKey: hexEncode(new Uint8Array(16)), - }, - { - address: "good@peer.example", - publicKey: hexEncode(new Uint8Array(32)), - }, - ], - }); - - expect(routed).toBe(true); - expect(puts).toEqual(["good@peer.example"]); - const grantsFilePath = path.join( - tempBase, - "workflow-run", - anchorRunId, - "runs", - runId, - "grants.json", - ); - const grantsLanded = await fs - .stat(grantsFilePath) - .then(() => true) - .catch(() => false); - expect(grantsLanded).toBe(true); - }); - - test("does not register a multistepMailRouter handler if spawn rejects", async () => { - const mailRouter = createMultistepMailRouter(); - const crashSpawner: SubprocessSpawner = () => { - throw new Error("ENOENT: binary missing"); - }; - const { router } = await buildMultistepFixture({ - spawner: crashSpawner, - multistepMailRouter: mailRouter, - }); - - const frame = makeMultistepFrame({ - agentAddress: "run_crash-noreg@example.com", - definition: { - id: "wf-crash-noreg", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { "step-1": { kind: "step" } }, - }, - sources: { "step-1": [makeInferenceSource("step-1")] }, - }); - - await expect(router.deploy(frame)).rejects.toThrow(/ENOENT: binary missing/); - - expect(mailRouter.tryRoute(frame.agentAddress, new Uint8Array([1]))).toBeNull(); - }); - - test("a soft-failed deploy (spawn rejects) leaves no restore record", async () => { - const crashSpawner: SubprocessSpawner = () => { - throw new Error("ENOENT: binary missing"); - }; - const { router, substrateEnv } = await buildMultistepFixture({ - spawner: crashSpawner, - }); - const agentAddress = "run_softfail@example.com"; - const frame = makeMultistepFrame({ - agentAddress, - definition: { - id: "wf-softfail", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { "step-1": { kind: "step" } }, - }, - sources: { "step-1": [makeInferenceSource("step-1")] }, - }); - - await expect(router.deploy(frame)).rejects.toThrow(/ENOENT/); - - // The record is written before the spawn, so the soft-failure catch must - // delete it -- a boot-time restore must not re-spawn a deploy that never - // completed. (A hard crash mid-spawn, by contrast, deliberately leaves - // the record for the restore to re-drive.) - const dataDir = substrateEnv.SIDECAR_DATA_DIR; - if (dataDir === undefined) throw new Error("fixture SIDECAR_DATA_DIR unset"); - const recordFile = path.join( - dataDir, - "workflow-runs", - deriveDeploymentId(agentAddress), - "deployment.json", - ); - expect( - await fs.access(recordFile).then( - () => true, - () => false, - ), - ).toBe(false); - }); - - test("rejects a deploy whose step pins an unbuildable provider before spawning", async () => { - // The source-admission gate runs before any state is claimed or the - // child is spawned. A step whose pinned source names a provider the - // sidecar cannot build must reject the whole deploy synchronously -- - // the admission control property -- rather than spawning a child that - // fails when the step's inference first resolves. - let spawnCount = 0; - const trackingSpawner: SubprocessSpawner = () => { - spawnCount++; - throw new Error("spawn must not be reached for an inadmissible source"); - }; - const { router } = await buildMultistepFixture({ - spawner: trackingSpawner, - assertSourceBuildable: (source) => { - if (source.provider === "ghost-provider") { - throw new Error(`Source provider "${source.provider}" is not registered`); - } - }, - }); - - const frame = makeMultistepFrame({ - agentAddress: "run_unbuildable@example.com", - definition: { - id: "wf-unbuildable", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { "step-1": { kind: "step" } }, - }, - sources: { - "step-1": [ - { - ...makeInferenceSource("step-1"), - provider: "ghost-provider", - }, - ], - }, - }); - - await expect(router.deploy(frame)).rejects.toThrow(/ghost-provider.*not registered/); - expect(spawnCount).toBe(0); - }); - - test("a spawner that throws synchronously surfaces a structured rejection rather than hanging in starting", async () => { - // Simulates `Bun.spawn` failing to launch (binary missing, - // permissions error). The router must surface the rejection - // through `deploy(frame)` without leaving the supervisor wedged. - const crashSpawner: SubprocessSpawner = () => { - throw new Error("ENOENT: binary missing"); - }; - - const { router } = await buildMultistepFixture({ spawner: crashSpawner }); - - const frame = makeMultistepFrame({ - agentAddress: "run_crash@example.com", - definition: { - id: "wf-crash", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { "step-1": { kind: "step" } }, - }, - sources: { - "step-1": [makeInferenceSource("step-1")], - }, - }); - - await expect(router.deploy(frame)).rejects.toThrow(/ENOENT: binary missing/); - }); - - test("rejects a malformed workflow projection at the router boundary before spawn fires", async () => { - let spawnerInvoked = false; - const spawner: SubprocessSpawner = () => { - spawnerInvoked = true; - throw new Error("spawner must not run for an invalid projection"); - }; - - const { router } = await buildMultistepFixture({ spawner }); - - const frame = makeMultistepFrame({ - definition: { - id: "wf-bad", - triggers: [{ type: "manual" }], - // stepOrder mentions a step that has no steps[] entry - stepOrder: ["step-1", "step-missing"], - steps: { "step-1": { kind: "step" } }, - }, - sources: { - "step-1": [makeInferenceSource("step-1")], - }, - }); - - // The closure-derived definition is structurally invalid: `stepOrder` names - // `step-missing`, which the `steps` record does not define. Source-ref is the - // only deploy lineage, so the definition is projected from the materialized - // closure BEFORE the projection guard; the live->inert projector rejects the - // dangling stepOrder entry at the router boundary, before any spawn fires. - await expect(router.deploy(frame)).rejects.toThrow( - /stepOrder names "step-missing" .* the steps record has no such entry/, - ); - expect(spawnerInvoked).toBe(false); - }); - - test("refuses to deploy a workflow frame carrying no approvedWireHash rather than recomputing it", async () => { - // The child re-verifies its own recompute against the HUB-approved wire - // hash. A frame that carries none is a wiring bug, not a legacy case: the - // sidecar must fail loud rather than substitute its own recompute, which - // would collapse the re-verify to a self-check. Production always stamps - // it, so only a malformed frame reaches this guard. Source-ref is the only - // deploy lineage, so the durable run record the deploy path builds before - // the spawn is the first gate to reject the missing hash. - let spawnerInvoked = false; - const spawner: SubprocessSpawner = () => { - spawnerInvoked = true; - throw new Error("spawner must not run for a hash-less frame"); - }; - - const { router } = await buildMultistepFixture({ spawner }); - - const frame = makeMultistepFrame({ - // A single-step deploy parses the frame address as a run address, so use - // the canonical `run_@` shape to reach the approved-wire-hash - // guard rather than tripping address parsing first. - agentAddress: "run_nohash@example.com", - definition: { - id: "wf-no-hash", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { "step-1": { kind: "step" } }, - }, - sources: { "step-1": [makeInferenceSource("step-1")] }, - omitApprovedWireHash: true, - }); - - await expect(router.deploy(frame)).rejects.toThrow(/must carry approvedWireHash/); - expect(spawnerInvoked).toBe(false); - }); - - test("does not drop the first upstream control frame the child sends after ready", async () => { - // The supervisor's `pumpUpstreamControl` consumes the same - // control-receive iterator `waitForReady` initialised. A buggy - // `waitForReady` that finalised the iterator on `ready` would - // silently drop the next upstream frame; a correct handoff - // surfaces a `recycle.request` as a real supervisor.recycle() - // call, which the supervisor implements by spawning a new child - // via the injected subprocessSpawner. Counting spawner - // invocations is the cleanest observable: 1 means the upstream - // frame was dropped; >=2 means the pump consumed it. - // - // The mock spawner serves a fresh control/event pair per call so - // the recycle path's own ready handshake completes; the test's - // child sender signs `ready` once per spawn. - type SpawnFixture = { - supervisorToChild: ReturnType; - childToSupervisor: ReturnType; - eventChildToSupervisor: ReturnType; - env: Record; - childIpcKeyPair: { privateKey: Uint8Array; publicKey: Uint8Array }; - }; - const spawns: SpawnFixture[] = []; - let resolveSpawnAdded: (() => void) | null = null; - const spawnAdded = (): Promise => - new Promise((resolve) => { - resolveSpawnAdded = resolve; - }); - const spawner: SubprocessSpawner = ({ env }) => { - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - const handle: SubprocessHandle = { - pid: 4400 + spawns.length, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - // Capture the per-spawn streams synchronously so the test can - // drive the child side once the supervisor has wired the - // receiver. - const fixture: SpawnFixture = { - supervisorToChild, - childToSupervisor, - eventChildToSupervisor, - env, - // Mint a fresh child keypair per spawn; the supervisor's - // receiveControlChannel opens in bootstrap mode and pins on - // the per-spawn ready frame's `childPublicKey`. - childIpcKeyPair: { - publicKey: new Uint8Array(), - privateKey: new Uint8Array(), - }, - }; - spawns.push(fixture); - const r = resolveSpawnAdded; - resolveSpawnAdded = null; - if (r) r(); - return handle; - }; - - const { router } = await buildMultistepFixture({ spawner }); - - const sources = defaultMultistepSources(); - const definition = { - id: "wf-handoff", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - const frame = makeMultistepFrame({ definition, sources }); - - // Helper to drive the child side of one spawn fixture's ready - // handshake, optionally chaining an upstream `recycle.request`. - async function driveReady( - fixture: SpawnFixture, - opts: { sendRecycleRequest: boolean }, - ): Promise { - const channelId = fixture.env.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID not set in spawn-time env"); - } - const childIpcKeyPair = await generateKeyPair(); - fixture.childIpcKeyPair = childIpcKeyPair; - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - fixture.childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: 4400 + spawns.length, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - if (opts.sendRecycleRequest) { - await childSender.send({ - type: "recycle.request", - data: { reason: "iterator-handoff-test" }, - }); - } - } - - const deployPromise = router.deploy(frame); - - // Wait for the first spawn to land. - while (spawns.length === 0) { - await spawnAdded(); - } - const first = spawns[0]; - if (first === undefined) throw new Error("unreachable"); - // Drive ready + immediate recycle.request on the first spawn. - await driveReady(first, { sendRecycleRequest: true }); - - // The initial deploy's spawn() resolves once `ready` lands. The - // supervisor's pump consumes the recycle.request and kicks off a - // recycle, which calls the spawner a second time. - await deployPromise; - - // Wait for the recycle's respawn. - while (spawns.length < 2) { - await spawnAdded(); - } - const second = spawns[1]; - if (second === undefined) throw new Error("unreachable"); - // Drive ready on the second (recycle's) spawn so the recycle path - // unwinds cleanly. We do not assert on this spawn's effects; the - // assertion below covers the iterator-handoff invariant. - await driveReady(second, { sendRecycleRequest: false }); - // Allow the recycle path to settle its post-ready work. - await new Promise((r) => setTimeout(r, 25)); - - expect(spawns.length).toBeGreaterThanOrEqual(2); - }); - - test("multistepSubstrateEnv carries HUB_WS_URL, SIDECAR_ID, SIDECAR_TOKEN through to the spawn-time env", async () => { - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let observedEnv: Record | undefined; - const spawner: SubprocessSpawner = ({ env }) => { - observedEnv = env; - const handle: SubprocessHandle = { - pid: 7600, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - return handle; - }; - const bootEdgeDataDir = await createTempBaseDir("sidecar-boot-edge-data-"); - const { router } = await buildMultistepFixture({ - spawner, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: bootEdgeDataDir, - HUB_WS_URL: "ws://hub.example/sidecar-boot", - SIDECAR_ID: "sidecar-boot-1", - SIDECAR_TOKEN: "boot-token-abc", - }, - }); - const sources = defaultMultistepSources(); - const definition = { - id: "wf-boot-edge", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - const frame = makeMultistepFrame({ definition, sources }); - const deployPromise = router.deploy(frame); - while (observedEnv === undefined) { - await new Promise((r) => setTimeout(r, 1)); - } - expect(observedEnv.HUB_WS_URL).toBe("ws://hub.example/sidecar-boot"); - expect(observedEnv.SIDECAR_ID).toBe("sidecar-boot-1"); - expect(observedEnv.SIDECAR_TOKEN).toBe("boot-token-abc"); - expect(observedEnv.SIDECAR_DATA_DIR).toBe(bootEdgeDataDir); - // Round out the spawn so the test exits cleanly. - const channelId = observedEnv.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID missing from spawn env"); - } - const childIpcKeyPair = await generateKeyPair(); - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: 7600, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - await deployPromise; - }); - - test("a registerDeployment failure before spawn unwinds the slug and leaves the address claimable", async () => { - // The multi-step partial-state unwind for the address-registry step. - // `registerDeployment` runs BEFORE `supervisor.spawn` -- the replay the - // spawn kicks off writes through the pack-pushing facade, which must - // resolve the deployment-address mapping, so the mapping has to exist - // before the spawn. A `registerDeployment` failure therefore throws - // before any child is spawned; the unwind must release the slug (and - // reverse nothing else, since nothing after it ran). The observable - // evidence is that (a) NO child was spawned for the failed deploy and - // (b) a subsequent deploy on the SAME address succeeds, which is only - // possible if the slug was released. - const childIpcKeyPair = await generateKeyPair(); - const spawnedHandles: { - pid: number; - killed: boolean; - supervisorToChild: ReturnType; - childToSupervisor: ReturnType; - eventChildToSupervisor: ReturnType; - }[] = []; - const observedEnvs: Record[] = []; - const spawner: SubprocessSpawner = ({ env }) => { - observedEnvs.push(env); - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - const record = { - pid: 9000 + spawnedHandles.length, - killed: false, - supervisorToChild, - childToSupervisor, - eventChildToSupervisor, - }; - spawnedHandles.push(record); - const handle: SubprocessHandle = { - pid: record.pid, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - record.killed = true; - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - return handle; - }; - - let registerCallCount = 0; - const multiDataDir = await createTempBaseDir("sidecar-multi-unwind-"); - const { router } = await buildMultistepFixture({ - spawner, - multistepBinaryPath: "/fake/bin/multistep-workflow-child", - multistepSubstrateEnv: { SIDECAR_DATA_DIR: multiDataDir }, - registerDeployment: () => { - registerCallCount += 1; - if (registerCallCount === 1) { - throw new Error("registerDeployment failure (synthetic)"); - } - }, - }); - - async function driveReadyFor(handleIndex: number, childPid: number): Promise { - while (spawnedHandles.length <= handleIndex) { - await new Promise((r) => setTimeout(r, 1)); - } - const env = observedEnvs[handleIndex]; - const channelId = env?.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID missing in observed env"); - } - const record = spawnedHandles[handleIndex]; - if (record === undefined) { - throw new Error(`spawnedHandles[${String(handleIndex)}] missing`); - } - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - record.childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - } - - const sources = defaultMultistepSources(); - const definition = { - id: "wf-unwind-test", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - const frame = makeMultistepFrame({ definition, sources }); - - // The first deploy throws at `registerDeployment`, which now runs before - // `supervisor.spawn`, so it rejects WITHOUT spawning a child -- no ready - // handshake to drive. - let firstCaught: unknown; - try { - await router.deploy(frame); - } catch (err) { - firstCaught = err; - } - expect(firstCaught).toBeInstanceOf(Error); - expect(firstCaught instanceof Error && firstCaught.message).toMatch( - /registerDeployment failure \(synthetic\)/, - ); - - // No child was spawned for the failed deploy: the throw preceded spawn. - expect(spawnedHandles).toHaveLength(0); - - // Re-deploy on the SAME address must succeed. If the unwind missed the - // slug release, the second deploy would surface a phantom collision. The - // router's public contract is that a failed deploy leaves the address - // claimable again. This is the first deploy that actually spawns, so its - // ready handshake is at index 0. - const secondDeploy = router.deploy(frame); - await driveReadyFor(0, 9000); - const secondResult = await secondDeploy; - expect(secondResult.publicKey).toMatch(/^[0-9a-f]{64}$/); - expect(registerCallCount).toBe(2); - }); - - // ------------------------------------------------------------------ - // Boot-time restore of persisted workflow deployments - // ------------------------------------------------------------------ - - // A mock spawner that serves a fresh control/event channel per spawn and - // lets the test complete each child's `ready` handshake. Both `deploy` and - // `restoreWorkflowRuns` block on `supervisor.spawn` until `ready` - // lands, so every spawned child needs its handshake driven. - function makeReadyDrivingSpawner(pidBase: number) { - type Spawn = { - env: Record; - childToSupervisor: ReturnType; - eventChildToSupervisor: ReturnType; - childSender?: ReturnType; - }; - const spawns: Spawn[] = []; - // One-shot spawn failure. When armed, the NEXT spawner invocation throws - // instead of returning a handle, then disarms. Used to fail a recycle - // respawn so the supervisor tears down to `stopped` (a self-termination) - // without waiting on a ready-handshake timeout. - let failNext = false; - const spawner: SubprocessSpawner = ({ env }) => { - if (failNext) { - failNext = false; - throw new Error("makeReadyDrivingSpawner: armed spawn failure"); - } - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - spawns.push({ env, childToSupervisor, eventChildToSupervisor }); - const handle: SubprocessHandle = { - pid: pidBase + spawns.length, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - return handle; - }; - async function driveReadyFor( - index: number, - opts?: { sendRecycleRequest?: boolean }, - ): Promise { - while (spawns.length <= index) { - await new Promise((r) => setTimeout(r, 1)); - } - const spawn = spawns[index]; - if (spawn === undefined) { - throw new Error(`spawn ${String(index)} missing`); - } - const channelId = spawn.env.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID missing in spawn env"); - } - const childIpcKeyPair = await generateKeyPair(); - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - spawn.childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - // Retain the sender so a later recycle.request (recycleRequestFor) - // signs with the SAME keypair the supervisor pinned from this ready. - spawn.childSender = childSender; - await childSender.send({ - type: "ready", - data: { - childPid: pidBase + index, - childPublicKey: Buffer.from(childIpcKeyPair.publicKey).toString("hex"), - }, - }); - if (opts?.sendRecycleRequest === true) { - // Model the child asking to recycle; the supervisor's upstream pump - // consumes it and respawns. - await childSender.send({ - type: "recycle.request", - data: { reason: "sources-rotation-recycle" }, - }); - } - } - return { - spawner, - driveReadyFor, - spawnCount: () => spawns.length, - envFor: (index: number): Record | undefined => spawns[index]?.env, - async recycleRequestFor(index: number): Promise { - const sender = spawns[index]?.childSender; - if (sender === undefined) { - throw new Error(`spawn ${String(index)} has no sender; drive its ready first`); - } - await sender.send({ - type: "recycle.request", - data: { reason: "sources-rotation-recycle" }, - }); - }, - // Arm a one-shot spawn failure for the next spawner invocation. - failNextSpawn(): void { - failNext = true; - }, - }; - } - - function isRegistered( - transport: ReturnType, - address: string, - ): boolean { - try { - transport.getTransportFor(address); - return true; - } catch { - return false; - } - } - - function recordExists(dataDir: string, anchorRunId: string): Promise { - return fs.access(path.join(dataDir, "workflow-runs", anchorRunId, "deployment.json")).then( - () => true, - () => false, - ); - } - - function singleStepFrame(agentAddress: string, definitionId: string): AgentDeployFrame { - return makeMultistepFrame({ - agentAddress, - definition: { - id: definitionId, - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { "step-1": { kind: "step" } }, - }, - sources: { "step-1": [makeInferenceSource("step-1")] }, - }); - } - - test("restore re-spawns a persisted single-step deployment and re-registers its head on a fresh transport", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-restart-data-"); - const head = "run_restart@example.com"; - - // First process: deploy a single-step workflow. The deploy persists a - // restore record under `dataDir` and materializes its `workflow.json`. - const first = makeReadyDrivingSpawner(9100); - const { router: routerA } = await buildMultistepFixture({ - spawner: first.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - const deployPromise = routerA.deploy(singleStepFrame(head, "wf-restart")); - await first.driveReadyFor(0); - await deployPromise; - - // Second process (simulated restart): a FRESH transport (empty - // registration table) and fresh in-memory router state over the SAME - // on-disk data dir. - const second = makeReadyDrivingSpawner(9200); - const freshTransport = createInMemoryTransport(); - const { router: routerB } = await buildMultistepFixture({ - spawner: second.spawner, - transport: freshTransport, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - // Nothing is registered before restore -- the restart started clean. - expect(isRegistered(freshTransport, head)).toBe(false); - - const restorePromise = routerB.restoreWorkflowRuns(); - await second.driveReadyFor(0); - await restorePromise; - - // The deployment was re-spawned exactly once and its head is live again. - expect(second.spawnCount()).toBe(1); - expect(isRegistered(freshTransport, head)).toBe(true); - }); - - test("restore soft-fails a record whose closure will not materialize and restores the rest", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-softfail-data-"); - const goodHead = "run_good@example.com"; - const badHead = "run_bad@example.com"; - - const first = makeReadyDrivingSpawner(9300); - const { router: routerA } = await buildMultistepFixture({ - spawner: first.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - const deployGood = routerA.deploy(singleStepFrame(goodHead, "wf-good")); - await first.driveReadyFor(0); - await deployGood; - const deployBad = routerA.deploy(singleStepFrame(badHead, "wf-bad")); - await first.driveReadyFor(1); - await deployBad; - - // Source-ref is the only deploy lineage: a restore re-materializes each - // record's pinned closure to derive its definition. Make the bad - // deployment's closure materialization fault (the pinned code no longer - // resolves) so its restore soft-fails, while the good one materializes and - // re-spawns. - const badDeploymentId = deriveDeploymentId(badHead); - const second = makeReadyDrivingSpawner(9400); - const freshTransport = createInMemoryTransport(); - const { router: routerB } = await buildMultistepFixture({ - spawner: second.spawner, - transport: freshTransport, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - applyFrozenWorkflowClosure: (applyArgs) => { - const deploymentId = path.basename(applyArgs.instanceDir); - if (deploymentId === badDeploymentId) { - throw new Error( - `closure materialization faulted for ${deploymentId}: pinned code did not resolve`, - ); - } - const definition = deployDefinitionRegistry.get(deploymentId); - if (definition === undefined) { - throw new Error( - `test applyFrozenWorkflowClosure: no registered definition for ${deploymentId}`, - ); - } - return Promise.resolve({ - definition, - packageDir: path.join(dataDir, "closure-package", deploymentId), - deployDir: path.join(dataDir, "closure-deploy", deploymentId), - }); - }, - }); - - // The good deployment re-spawns (exactly one handshake to drive); - // scan order is filesystem-dependent, but only the good record spawns. - const restorePromise = routerB.restoreWorkflowRuns(); - await second.driveReadyFor(0); - await restorePromise; - - expect(second.spawnCount()).toBe(1); - expect(isRegistered(freshTransport, goodHead)).toBe(true); - expect(isRegistered(freshTransport, badHead)).toBe(false); - // The failed record is KEPT on disk -- never deleted, unlike a - // soft-failed deploy -- so a later boot can retry it. - expect(await recordExists(dataDir, deriveDeploymentId(badHead))).toBe(true); - }); - - test("restore rejects a closure-derived definition whose stepOrder names a step with no matching entry", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-validator-data-"); - const head = "run_validator@example.com"; - const anchorRunId = deriveDeploymentId(head); - - // Write a well-formed source-ref record: it clears the record schema and the - // scan boundary, so restore reaches the projection gate. The closure it - // re-materializes, though, evaluates to a structurally invalid definition -- - // its `stepOrder` names a step the `steps` record does not define. The - // source-ref restore arm projects the closure-derived definition - // (`projectLiveToInert`) before spawning, so it rejects the dangling - // stepOrder entry and never spawns a child for a broken definition. - const record: WorkflowRunRecord = { - version: 1, - agentAddress: head, - definitionId: "wf-missing-step", - sources: { "step-1": [makeInferenceSource("step-1")] }, - hubPublicKey: "hub-pk", - approvedWireHash: "a".repeat(64), - lineage: "source-ref", - sourceRef: { - source: { kind: "registry", registry: "test-registry" }, - closure: { schemaVersion: "1", topLevel: [], entries: [] }, - }, - }; - await writeWorkflowRunRecord(dataDir, anchorRunId, record, createNoopCredentialCipher()); - - const spawner = makeReadyDrivingSpawner(9500); - const freshTransport = createInMemoryTransport(); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - transport: freshTransport, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - applyFrozenWorkflowClosure: (applyArgs) => - Promise.resolve({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- a hand-built live definition cannot satisfy the full WorkflowDefinition nominal type; this stands in for a closure that evaluates to a structurally invalid definition - definition: { - id: "wf-missing-step", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-missing"], - steps: { - "step-1": { - kind: "step", - id: "step-1", - agent: { - id: "agent-step-1", - systemPrompt: "sys", - capabilities: [], - toolFactories: [], - inference: { sources: [] }, - }, - }, - }, - } as unknown as WorkflowDefinition, - packageDir: path.join(applyArgs.instanceDir, "package"), - deployDir: path.join(applyArgs.instanceDir, "deploy"), - }), - }); - - await router.restoreWorkflowRuns(); - - expect(spawner.spawnCount()).toBe(0); - expect(isRegistered(freshTransport, head)).toBe(false); - }); - - test("restore soft-skips a malformed source-ref record (missing sourceRef) at the scan boundary", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-srcref-bad-"); - const head = "run_srcref@example.com"; - const deploymentId = deriveDeploymentId(head); - - // A source-ref record MUST carry a sourceRef pin + approvedWireHash -- the - // record schema's discriminated union on `lineage` requires them. Write a - // raw malformed one (lineage source-ref, none of the required fields) - // straight to the record path, bypassing the typed writer. - // `scanWorkflowRunRecords` validates against the schema and - // soft-skips it as corruption, so restore never reaches a spawn -- there is - // no bespoke source-ref guard in the restore loop to lean on. - const recordPath = path.join(dataDir, "workflow-runs", deploymentId, "deployment.json"); - await fs.mkdir(path.dirname(recordPath), { recursive: true }); - await fs.writeFile( - recordPath, - JSON.stringify({ - version: 1, - agentAddress: head, - definitionId: "wf-srcref", - sources: { "step-1": [makeInferenceSource("step-1")] }, - hubPublicKey: "hub-pk", - lineage: "source-ref", - }), - "utf8", - ); - - const spawner = makeReadyDrivingSpawner(9550); - const freshTransport = createInMemoryTransport(); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - transport: freshTransport, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - await router.restoreWorkflowRuns(); - - // Rejected at the scan boundary: no child spawned, nothing registered. - expect(spawner.spawnCount()).toBe(0); - expect(isRegistered(freshTransport, head)).toBe(false); - }); - - test("restore re-materializes a source-ref deployment's closure and re-spawns it as source-ref", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-srcref-ok-"); - const head = "run_srcref_ok@example.com"; - const deploymentId = deriveDeploymentId(head); - - // A well-formed source-ref record: lineage source-ref with a sourceRef pin - // (source + closure) + approvedWireHash, exactly what the deploy path - // persists. - const record: WorkflowRunRecord = { - version: 1, - agentAddress: head, - definitionId: "wf-srcref", - sources: { "step-1": [makeInferenceSource("step-1")] }, - hubPublicKey: "hub-pk", - approvedWireHash: "a".repeat(64), - lineage: "source-ref", - sourceRef: { - source: { kind: "registry", registry: "test-registry" }, - closure: { schemaVersion: "1", topLevel: [], entries: [] }, - }, - }; - await writeWorkflowRunRecord(dataDir, deploymentId, record, createNoopCredentialCipher()); - - // The source-ref restore arm reconstructs the definition from the - // re-materialized closure, NOT from the on-disk inert workflow.json. Write a - // DELIBERATELY-CORRUPT workflow.json to prove the arm never reads it: were - // the restore path to parse this file it would throw and soft-skip the - // record, so a successful restore below is proof the closure is the source - // of truth for the definition on this lineage. - const workflowJsonPath = path.join(dataDir, "assets", "workflow", "wf-srcref", "workflow.json"); - await fs.mkdir(path.dirname(workflowJsonPath), { recursive: true }); - await fs.writeFile(workflowJsonPath, "}{ not valid json", "utf8"); - - // Stub the closure materializer: record its inputs, and return a fake - // package dir plus the evaluated live definition the restore arm now - // projects to the inert wire shape -- the SAME - // `WorkflowProjectionDefinition(projectLiveToInert(...))` computation the - // deploy path applies. The definition (not the on-disk workflow.json) is the - // source of truth for the restored definition, so it must be a valid live - // definition whose projection covers the record's sources (`step-1`). - const fakePackageDir = path.join(dataDir, "fake-closure-package"); - const closureDefinition = { - id: "wf-srcref", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { - "step-1": { - kind: "step", - id: "step-1", - agent: { - id: "agent-1", - systemPrompt: "sys", - capabilities: [], - toolFactories: [], - inference: { sources: [] }, - }, - }, - }, - }; - const applyCalls: { registry: string; entryCount: number }[] = []; - const applyStub: NonNullable< - Parameters[0]["applyFrozenWorkflowClosure"] - > = (args) => { - if (args.source.kind !== "registry") { - throw new Error(`applyStub expected a registry source, got ${args.source.kind}`); - } - applyCalls.push({ - registry: args.source.registry, - entryCount: args.closure.entries.length, - }); - return Promise.resolve({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the stub stands in for a real closure evaluation; a minimal hand-built live definition cannot satisfy the full `WorkflowDefinition` nominal type - definition: closureDefinition as unknown as WorkflowDefinition, - packageDir: fakePackageDir, - deployDir: path.join(dataDir, "fake-deploy-dir"), - }); - }; - - const spawner = makeReadyDrivingSpawner(9560); - const freshTransport = createInMemoryTransport(); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - transport: freshTransport, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: dataDir, - SIDECAR_CACHE_MAX_BYTES: "1000000", - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: "1000000", - }, - applyFrozenWorkflowClosure: applyStub, - }); - - const restorePromise = router.restoreWorkflowRuns(); - await spawner.driveReadyFor(0); - await restorePromise; - - // Re-materialized (the frozen closure fed to the materializer) and spawned. - expect(applyCalls).toEqual([{ registry: "test-registry", entryCount: 0 }]); - expect(spawner.spawnCount()).toBe(1); - expect(isRegistered(freshTransport, head)).toBe(true); - // The child came back on the source-ref (evaluate-the-closure) load path: - // the spawn env carries the freshly re-materialized closure package dir, so - // the child evaluates the pinned code rather than reading a definition off - // disk. Source-ref is the only lineage now, so the env always carries it. - const spawnEnv = spawner.envFor(0); - expect(spawnEnv?.CLOSURE_PACKAGE_DIR).toBe(fakePackageDir); - }); - - test("restore is a no-op for a deployment already live in this process", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-guard-data-"); - const head = "run_guard@example.com"; - - const spawner = makeReadyDrivingSpawner(9600); - const { router, transport } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - const deployPromise = router.deploy(singleStepFrame(head, "wf-guard")); - await spawner.driveReadyFor(0); - await deployPromise; - expect(spawner.spawnCount()).toBe(1); - - // The record is on disk and the address is live in this same process. A - // restore pass must NOT spawn a second child for an address the core's - // double-spawn guard already owns (the transition guard the B-reroute - // follow-up leans on). - await router.restoreWorkflowRuns(); - - expect(spawner.spawnCount()).toBe(1); - expect(isRegistered(transport, head)).toBe(true); - }); - - test("a second deploy for a live address is rejected without orphaning its restore record", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-dup-data-"); - const head = "run_dup@example.com"; - const anchorRunId = deriveDeploymentId(head); - - const spawner = makeReadyDrivingSpawner(9700); - const { router, transport } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - const deployPromise = router.deploy(singleStepFrame(head, "wf-dup")); - await spawner.driveReadyFor(0); - await deployPromise; - expect(await recordExists(dataDir, anchorRunId)).toBe(true); - - // A second deploy for the already-live address must be rejected WITHOUT - // touching the running deployment's durable state. The reject fires - // before any overwrite; without it, deployMultiStep's catch would delete - // the live deployment's record and release its slug, silently breaking - // the next restart for a still-running agent. - await expect(router.deploy(singleStepFrame(head, "wf-dup"))).rejects.toThrow( - /already deployed/, - ); - expect(spawner.spawnCount()).toBe(1); - expect(await recordExists(dataDir, anchorRunId)).toBe(true); - expect(isRegistered(transport, head)).toBe(true); - }); - - test("a self-terminated deployment is reclaimed so its address redeploys without a manual undeploy", async () => { - const dataDir = await createTempBaseDir("sidecar-self-term-redeploy-data-"); - const head = "run_selfterm@example.com"; - const anchorRunId = deriveDeploymentId(head); - - const spawner = makeReadyDrivingSpawner(9750); - const { router, transport } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - // Deploy and bring the supervisor to `running`. - const deployPromise = router.deploy(singleStepFrame(head, "wf-selfterm")); - await spawner.driveReadyFor(0); - await deployPromise; - expect(router.activeAddresses()).toEqual([head]); - expect(isRegistered(transport, head)).toBe(true); - - // Drive a self-termination: a child-initiated recycle whose respawn spawn - // fails tears the supervisor down to `stopped` through the recycle-failure - // path, which fires `onSelfTerminate`. That drives the reclaim. - spawner.failNextSpawn(); - await spawner.recycleRequestFor(0); - - // The reclaim drops the address from the active map and releases its - // transport registration. The self-termination runs asynchronously off the - // supervisor's control pump, so poll for the reclaim to settle. - const deadline = Date.now() + 5_000; - while (router.activeAddresses().includes(head) && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 1)); - } - expect(router.activeAddresses()).toEqual([]); - expect(isRegistered(transport, head)).toBe(false); - - // The redeploy succeeds with no prior undeploy: the map slot is free and - // the transport is no longer registered (a stale registration would make - // the spawn core's `transport.register` throw "already registered"). The - // self-terminated deployment left its durable record and its step-state - // scratch behind; the redeploy overwrites the record destructively. - expect(await recordExists(dataDir, anchorRunId)).toBe(true); - const redeployPromise = router.deploy(singleStepFrame(head, "wf-selfterm")); - await spawner.driveReadyFor(1); - await redeployPromise; - expect(router.activeAddresses()).toEqual([head]); - expect(isRegistered(transport, head)).toBe(true); - expect(spawner.spawnCount()).toBe(2); - }); - - test("a reclaimed self-terminated address survives a following operator undeploy", async () => { - const dataDir = await createTempBaseDir("sidecar-self-term-undeploy-data-"); - const head = "run_selfterm_undeploy@example.com"; - - const spawner = makeReadyDrivingSpawner(9760); - const { router, transport } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - const deployPromise = router.deploy(singleStepFrame(head, "wf-st-undeploy")); - await spawner.driveReadyFor(0); - await deployPromise; - - spawner.failNextSpawn(); - await spawner.recycleRequestFor(0); - const deadline = Date.now() + 5_000; - while (router.activeAddresses().includes(head) && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 1)); - } - expect(router.activeAddresses()).toEqual([]); - - // An operator undeploy following the reclaim is a clean no-op: the reclaim - // already dropped the supervisor and the transport registration, so - // undeploy's idempotent unregisters neither throw nor double-remove. This - // is the observable form of the reclaim/undeploy race resolution -- both - // are "if present, drop", so the loser no-ops. - const undeploy = router.undeploy; - if (undeploy === undefined) { - throw new Error("router.undeploy is undefined"); - } - await expect( - undeploy({ - type: "agent.undeploy", - agentAddress: head, - reason: "operator undeploy after self-termination", - }), - ).resolves.toBeUndefined(); - expect(router.activeAddresses()).toEqual([]); - expect(isRegistered(transport, head)).toBe(false); - }); - - test("the reclaim retains the deployment-address mapping so the terminal RunFailed commit can still resolve its run address", async () => { - // Regression guard for the reclaim/terminal-commit ordering hazard. The - // crash-loop latch commits its `RunFailed` tombstone AFTER teardown fires - // `onSelfTerminate`, and that commit resolves the deployment-address - // mapping to route the outbound pack push. A reclaim that dropped the - // mapping (via `unregisterDeployment`) stranded the commit with "no run - // address registered". This wires a REAL `deploymentAddressRegistry` - // through the register/unregister hooks -- the same registry the sidecar - // wires in `index.ts` -- and asserts the mapping survives the reclaim, so - // a subsequent `resolve` still returns the address the commit needs. - const dataDir = await createTempBaseDir("sidecar-self-term-mapping-data-"); - const head = "run_selfterm_mapping@example.com"; - const runId = deriveDeploymentId(head); - - const registry = createDeploymentAddressRegistry(); - - const spawner = makeReadyDrivingSpawner(9770); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - registerDeployment: ({ runId: id, agentAddress }) => { - registry.record(id, agentAddress); - }, - unregisterDeployment: ({ runId: id }) => { - registry.unregister(id); - }, - }); - - const deployPromise = router.deploy(singleStepFrame(head, "wf-st-mapping")); - await spawner.driveReadyFor(0); - await deployPromise; - // The deploy recorded the mapping before spawn (the replay's pack push - // resolves it), so it is resolvable while the supervisor is live. - expect(registry.resolve(runId)).toBe(head); - - // Drive the supervisor to a self-termination via the recycle-failure path. - spawner.failNextSpawn(); - await spawner.recycleRequestFor(0); - const deadline = Date.now() + 5_000; - while (router.activeAddresses().includes(head) && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 1)); - } - expect(router.activeAddresses()).toEqual([]); - - // The reclaim dropped the redeploy gate but RETAINED the address mapping: - // the supervisor's own terminal `RunFailed` commit is the sole remaining - // consumer and must still resolve the address. A reclaim that unregistered - // the mapping would fail this assertion (and strand that commit). - expect(registry.resolve(runId)).toBe(head); - }); - - test("restore skips a record whose address does not derive its directory name", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-mismatch-data-"); - const head = "run_mismatch@example.com"; - // A record filed under a directory that is NOT its own derived slug -- - // a corrupt or misplaced record that must not be restored under the - // wrong slug. - const wrongDir = "not-the-right-slug"; - // Otherwise-valid source-ref record so the scan admits it and the restore - // loop reaches (and rejects on) the address-vs-directory mismatch -- not the - // schema. Source-ref is the only lineage, so it must carry the pin + hash. - const record: WorkflowRunRecord = { - version: 1, - agentAddress: head, - definitionId: "wf-mismatch", - sources: { "step-1": [makeInferenceSource("step-1")] }, - hubPublicKey: "hub-pk", - approvedWireHash: "a".repeat(64), - lineage: "source-ref", - sourceRef: { - source: { kind: "registry", registry: "test-registry" }, - closure: { schemaVersion: "1", topLevel: [], entries: [] }, - }, - }; - await writeWorkflowRunRecord(dataDir, wrongDir, record, createNoopCredentialCipher()); - - const spawner = makeReadyDrivingSpawner(9800); - const freshTransport = createInMemoryTransport(); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - transport: freshTransport, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - await router.restoreWorkflowRuns(); - - expect(spawner.spawnCount()).toBe(0); - expect(isRegistered(freshTransport, head)).toBe(false); - // The record is kept on a skip, not deleted. - expect(await recordExists(dataDir, wrongDir)).toBe(true); - }); - - test("restore soft-fails and keeps the record when the pinned source is no longer buildable", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-unbuildable-data-"); - const head = "run_unbuildable_restore@example.com"; - const anchorRunId = deriveDeploymentId(head); - - // First process: a permissive gate lets the deploy through, persisting - // the record and its workflow.json. - const first = makeReadyDrivingSpawner(9900); - const { router: routerA } = await buildMultistepFixture({ - spawner: first.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - const deployPromise = routerA.deploy(singleStepFrame(head, "wf-unbuildable-restore")); - await first.driveReadyFor(0); - await deployPromise; - - // Restart with a gate that now rejects the pinned provider. - const second = makeReadyDrivingSpawner(10000); - const freshTransport = createInMemoryTransport(); - const { router: routerB } = await buildMultistepFixture({ - spawner: second.spawner, - transport: freshTransport, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - assertSourceBuildable: (source) => { - throw new Error(`Source provider "${source.provider}" is not registered`); - }, - }); - - await routerB.restoreWorkflowRuns(); - - expect(second.spawnCount()).toBe(0); - expect(isRegistered(freshTransport, head)).toBe(false); - // The record survives so a later boot, once the provider is buildable - // again, can retry it. - expect(await recordExists(dataDir, anchorRunId)).toBe(true); - }); - - test("restore isolates an unbuildable-provider record: it keeps the record and surfaces the failure while the healthy deployment still restores", async () => { - const dataDir = await createTempBaseDir("sidecar-restore-unbuildable-isolate-data-"); - const healthyHead = "run_healthy_isolate@example.com"; - const unbuildableHead = "run_unbuildable_isolate@example.com"; - const healthyId = deriveDeploymentId(healthyHead); - const unbuildableId = deriveDeploymentId(unbuildableHead); - - // The unbuildable deployment pins a source whose provider the restart's - // gate will reject; the healthy deployment keeps the default `anthropic` - // source the gate admits. Distinguishing on `provider` lets one - // `assertSourceBuildable` reject exactly one of the two restored records. - const unbuildableProvider = "phantom-provider"; - function unbuildableSingleStepFrame(): AgentDeployFrame { - return makeMultistepFrame({ - agentAddress: unbuildableHead, - definition: { - id: "wf-unbuildable-isolate", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { "step-1": { kind: "step" } }, - }, - sources: { - "step-1": [{ ...makeInferenceSource("step-1"), provider: unbuildableProvider }], - }, - }); - } - - // First process: a permissive gate lets BOTH deploys through, persisting - // each record and its workflow.json. - const first = makeReadyDrivingSpawner(11400); - const { router: routerA } = await buildMultistepFixture({ - spawner: first.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - const deployHealthy = routerA.deploy(singleStepFrame(healthyHead, "wf-healthy-isolate")); - await first.driveReadyFor(0); - await deployHealthy; - const deployUnbuildable = routerA.deploy(unbuildableSingleStepFrame()); - await first.driveReadyFor(1); - await deployUnbuildable; - expect(await recordExists(dataDir, healthyId)).toBe(true); - expect(await recordExists(dataDir, unbuildableId)).toBe(true); - - // Restart: a fresh transport plus a gate that rejects ONLY the phantom - // provider. Capture the module's warn output through the default console - // sink (threshold "warning" routes `logger.warn` to `console.warn`) so we - // can assert the failure is surfaced loudly rather than silently dropped. - const warnCaptured: string[] = []; - // eslint-disable-next-line no-console -- intentionally spy on the default sink's warn target to prove the restore failure is surfaced - const originalWarn = console.warn; - // eslint-disable-next-line no-console - console.warn = (...parts: unknown[]) => { - warnCaptured.push(parts.map((part) => String(part)).join(" ")); - }; - try { - const second = makeReadyDrivingSpawner(11500); - const freshTransport = createInMemoryTransport(); - const { router: routerB } = await buildMultistepFixture({ - spawner: second.spawner, - transport: freshTransport, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - assertSourceBuildable: (source) => { - if (source.provider === unbuildableProvider) { - throw new Error(`Source provider "${source.provider}" is not registered`); - } - }, - }); - - // Only the healthy deployment spawns, so its handshake is the sole one - // to drive; the unbuildable record faults before its spawner is ever - // reached. Restore is serial and per-record isolated, so scan order does - // not change the outcome. - const restorePromise = routerB.restoreWorkflowRuns(); - await second.driveReadyFor(0); - await restorePromise; - - // The unbuildable record did NOT strand the healthy one: it re-spawned - // exactly once and its head is routable again on the fresh transport. - expect(second.spawnCount()).toBe(1); - expect(isRegistered(freshTransport, healthyHead)).toBe(true); - - // The unbuildable deployment was not stood up: no spawn, no route. - expect(isRegistered(freshTransport, unbuildableHead)).toBe(false); - - // Its record survives -- restore keeps an unrestorable record so a later - // boot with the provider restored can retry it. - expect(await recordExists(dataDir, unbuildableId)).toBe(true); - - // The failure is surfaced loudly: a warning naming the failed deployment - // and the provider rejection reason, not a silent drop. - const failureWarn = warnCaptured.find( - (line) => - line.includes(unbuildableId) && - line.includes(unbuildableProvider) && - line.includes("is not registered"), - ); - expect(failureWarn).toBeDefined(); - } finally { - // eslint-disable-next-line no-console - console.warn = originalWarn; - } - }); - - test("a deploy whose child never signals ready times out and rejects", async () => { - const dataDir = await createTempBaseDir("sidecar-ready-timeout-data-"); - const head = "run_readytimeout@example.com"; - const anchorRunId = deriveDeploymentId(head); - - // A spawner whose child is created but never driven through the `ready` - // handshake. With a small threaded readyTimeoutMs the supervisor times - // out, kills the child, and rejects the spawn. The message echoes the - // threaded value, so this also proves readyTimeoutMs reaches the - // supervisor across the router's forwarding. - const spawner = makeReadyDrivingSpawner(10100); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - readyTimeoutMs: 40, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - await expect(router.deploy(singleStepFrame(head, "wf-readytimeout"))).rejects.toThrow( - /did not emit ready within 40ms/, - ); - - // The deploy soft-failed, so its restore record was cleaned up -- a - // wedged deploy leaves nothing for a later boot to re-spawn. - expect(await recordExists(dataDir, anchorRunId)).toBe(false); - }); - - test("a single-step deploy acks the agent key, not the supervisor key", async () => { - // The single-step head is the deployed agent identity, so the ack must - // surface the agent key rather than the supervisor principal key. - const headKeyPair = await generateKeyPair(); - const spawner = makeReadyDrivingSpawner(10300); - const { router, keyPair: fixtureKeyPair } = await buildMultistepFixture({ - spawner: spawner.spawner, - headKeyPair, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: await createTempBaseDir("sidecar-bkey-data-"), - }, - }); - - const deployPromise = router.deploy(singleStepFrame("run_bkey@example.com", "wf-bkey")); - await spawner.driveReadyFor(0); - const result = await deployPromise; - - // The supervisor principal key is derived from the fixture's signing seed - // (fixtureKeyPair); the head's agent key is the distinct headKeyPair. - expect(result.publicKey).toBe(Buffer.from(headKeyPair.publicKey).toString("hex")); - expect(result.publicKey).not.toBe(Buffer.from(fixtureKeyPair.publicKey).toString("hex")); - }); - - test("registers a sources-rotation handler for a single-step deployment", async () => { - const sourcesRouter = createMultistepSourcesRouter(); - const spawner = makeReadyDrivingSpawner(10600); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSourcesRouter: sourcesRouter, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: await createTempBaseDir("sidecar-sources-single-"), - }, - }); - - const deployPromise = router.deploy( - singleStepFrame("run_srcsingle@example.com", "wf-srcsingle"), - ); - await spawner.driveReadyFor(0); - await deployPromise; - - // The single-step deploy registered a rotation handler, so an inbound - // sources.update for its address routes. - expect( - await sourcesRouter.tryRoute({ - type: "sources.update", - agentAddress: "run_srcsingle@example.com", - sources: [makeInferenceSource("primary")], - defaultSource: "primary", - }), - ).toBe(true); - }); - - test("does not register a sources-rotation handler for a multi-step deployment", async () => { - const sourcesRouter = createMultistepSourcesRouter(); - const spawner = makeReadyDrivingSpawner(10700); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSourcesRouter: sourcesRouter, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: await createTempBaseDir("sidecar-sources-multi-"), - }, - }); - - const frame = makeMultistepFrame({ - definition: { - id: "wf-srcmulti", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }, - sources: { - "step-1": [makeInferenceSource("step-1")], - "step-2": [makeInferenceSource("step-2")], - }, - }); - const deployPromise = router.deploy(frame); - await spawner.driveReadyFor(0); - await deployPromise; - - // A multi-step deployment has no single warm agent to rotate, so no - // handler is registered and the inbound rotation is unrouted. - expect( - await sourcesRouter.tryRoute({ - type: "sources.update", - agentAddress: frame.agentAddress, - sources: [makeInferenceSource("primary")], - defaultSource: "primary", - }), - ).toBe(false); - }); - - test("a source rotation survives a recycle respawn", async () => { - // End-to-end guard for the rotation-survives-recycle fix: the single-step - // rotation handler mutates `currentSources`, `dynamicSpawnEnv` - // re-serializes it, and the recycle respawn's STEP_INFERENCE_SOURCES - // carries the ROTATED table -- not the frozen deploy-time one. - const sourcesRouter = createMultistepSourcesRouter(); - const spawner = makeReadyDrivingSpawner(10800); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSourcesRouter: sourcesRouter, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: await createTempBaseDir("sidecar-rot-survive-"), - }, - }); - - const addr = "run_rotsurvive@example.com"; - const deployPromise = router.deploy(singleStepFrame(addr, "wf-rotsurvive")); - await spawner.driveReadyFor(0); - await deployPromise; - - // The initial spawn carries the deploy-time source table. - const initialEnv = spawner.envFor(0); - if (initialEnv === undefined) throw new Error("initial spawn env missing"); - expect(JSON.parse(initialEnv[STEP_INFERENCE_SOURCES_ENV_KEY] ?? "null")).toEqual({ - "step-1": [makeInferenceSource("step-1")], - }); - - // Rotate the single-step deployment's sources in place. - const rotated = makeInferenceSource("rotated"); - expect( - await sourcesRouter.tryRoute({ - type: "sources.update", - agentAddress: addr, - sources: [rotated], - defaultSource: "rotated", - }), - ).toBe(true); - - // The child asks to recycle; the supervisor respawns. - await spawner.recycleRequestFor(0); - while (spawner.spawnCount() < 2) { - await new Promise((r) => setTimeout(r, 1)); - } - await spawner.driveReadyFor(1); - - // The recycle respawn's sources are the ROTATED table, proving the - // rotation survived the recycle (before the fix it reverted to the - // deploy-time list frozen in substrateEnv). - const respawnEnv = spawner.envFor(1); - if (respawnEnv === undefined) throw new Error("respawn env missing"); - expect(JSON.parse(respawnEnv[STEP_INFERENCE_SOURCES_ENV_KEY] ?? "null")).toEqual({ - "step-1": [rotated], - }); - }); - - test("a source rotation survives a full sidecar restart", async () => { - // Restart-durability: a rotation is persisted into the run record, - // so a fresh sidecar process (a restore over the same data dir) respawns - // the deployment on the ROTATED sources, not the deploy-time ones. - const dataDir = await createTempBaseDir("sidecar-rot-restart-"); - const addr = "run_rotrestart@example.com"; - - // First process: deploy a single-step deployment and rotate its sources. - const sourcesRouter = createMultistepSourcesRouter(); - const first = makeReadyDrivingSpawner(10900); - const { router: routerA } = await buildMultistepFixture({ - spawner: first.spawner, - multistepSourcesRouter: sourcesRouter, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - const deployPromise = routerA.deploy(singleStepFrame(addr, "wf-rotrestart")); - await first.driveReadyFor(0); - await deployPromise; - - const rotated = makeInferenceSource("rotated"); - expect( - await sourcesRouter.tryRoute({ - type: "sources.update", - agentAddress: addr, - sources: [rotated], - defaultSource: "rotated", - }), - ).toBe(true); - - // Second process (simulated restart): a fresh router over the SAME data - // dir restores the deployment from its durable record. - const second = makeReadyDrivingSpawner(11000); - const { router: routerB } = await buildMultistepFixture({ - spawner: second.spawner, - transport: createInMemoryTransport(), - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - const restorePromise = routerB.restoreWorkflowRuns(); - await second.driveReadyFor(0); - await restorePromise; - - // The restored spawn carries the ROTATED sources, read back from the - // durable record -- not the deploy-time list. - const restoredEnv = second.envFor(0); - if (restoredEnv === undefined) { - throw new Error("restored spawn env missing"); - } - expect(JSON.parse(restoredEnv[STEP_INFERENCE_SOURCES_ENV_KEY] ?? "null")).toEqual({ - "step-1": [rotated], - }); - }); - - test("a rotation whose persist fails leaves no partial effect", async () => { - // Atomicity: if the durable write rejects, the rotation takes NO net - // effect. The handler swaps currentSources synchronously, then rolls it - // back on a failed persist, so once the handler throws currentSources is - // on the deploy-time table (a recycle respawn AFTER the failure is - // unrotated) and deliverSources is never reached. Rolling the in-memory - // hint back keeps currentSources and the record in agreement in this - // no-interleaved-recycle failure case. - const dataDir = await createTempBaseDir("sidecar-rot-failatomic-"); - const addr = "run_rotfailatomic@example.com"; - const sourcesRouter = createMultistepSourcesRouter(); - const spawner = makeReadyDrivingSpawner(11100); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSourcesRouter: sourcesRouter, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - const deployPromise = router.deploy(singleStepFrame(addr, "wf-rotfail")); - await spawner.driveReadyFor(0); - await deployPromise; - - // Replace the run record file with a directory so the rotation's - // writeWorkflowRunRecord rejects (EISDIR) -- a deterministic, - // root-immune write fault (a chmod guard would be bypassed under root). - const recordFile = path.join( - dataDir, - "workflow-runs", - deriveDeploymentId(addr), - "deployment.json", - ); - await fs.rm(recordFile); - await fs.mkdir(recordFile); - - // The persist rejects, so the route rejects and nothing after the write - // runs. - await expect( - sourcesRouter.tryRoute({ - type: "sources.update", - agentAddress: addr, - sources: [makeInferenceSource("rotated")], - defaultSource: "rotated", - }), - ).rejects.toThrow(); - - // currentSources was rolled back: a recycle respawn AFTER the failed - // rotation carries the DEPLOY-TIME sources, proving the failed rotation - // left no net in-memory effect. - await spawner.recycleRequestFor(0); - while (spawner.spawnCount() < 2) { - await new Promise((r) => setTimeout(r, 1)); - } - await spawner.driveReadyFor(1); - const respawnEnv = spawner.envFor(1); - if (respawnEnv === undefined) throw new Error("respawn env missing"); - expect(JSON.parse(respawnEnv[STEP_INFERENCE_SOURCES_ENV_KEY] ?? "null")).toEqual({ - "step-1": [makeInferenceSource("step-1")], - }); - }); - - test("a recycle interleaving the rotation persist respawns on the rotated sources", async () => { - // Interleave guard: the rotation swaps currentSources synchronously - // BEFORE the durable persist, so a recycle that lands inside the persist - // window respawns the child on the ROTATED sources -- consistent with - // what is being persisted -- rather than the stale deploy-time table. - const dataDir = await createTempBaseDir("sidecar-rot-interleave-ok-"); - const addr = "run_rotinterok@example.com"; - const sourcesRouter = createMultistepSourcesRouter(); - const spawner = makeReadyDrivingSpawner(11200); - - // A persist that blocks on a test-controlled gate once armed, so a recycle - // can be driven into the rotation's persist window. The deploy's own - // persist (before the gate is armed) passes straight through to the real - // writer. - let gate: Promise | null = null; - let release: () => void = () => undefined; - const persist: typeof writeWorkflowRunRecord = async (d, id, rec, cipher) => { - if (gate !== null) await gate; - await writeWorkflowRunRecord(d, id, rec, cipher); - }; - - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSourcesRouter: sourcesRouter, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - writeWorkflowRunRecord: persist, - }); - - const deployPromise = router.deploy(singleStepFrame(addr, "wf-rotinterok")); - await spawner.driveReadyFor(0); - await deployPromise; - - // Arm the gate so the rotation's persist blocks mid-window. - gate = new Promise((resolve) => { - release = resolve; - }); - - // Start the rotation but do not await it: the handler swaps currentSources - // synchronously, then parks on the blocked persist before deliverSources. - const rotated = makeInferenceSource("rotated"); - const rotatePromise = sourcesRouter.tryRoute({ - type: "sources.update", - agentAddress: addr, - sources: [rotated], - defaultSource: "rotated", - }); - - // Drive a recycle while the persist is blocked. The respawn env must carry - // the ROTATED sources, because the synchronous swap already ran. - await spawner.recycleRequestFor(0); - while (spawner.spawnCount() < 2) { - await new Promise((r) => setTimeout(r, 1)); - } - await spawner.driveReadyFor(1); - const respawnEnv = spawner.envFor(1); - if (respawnEnv === undefined) throw new Error("respawn env missing"); - expect(JSON.parse(respawnEnv[STEP_INFERENCE_SOURCES_ENV_KEY] ?? "null")).toEqual({ - "step-1": [rotated], - }); - - // Release the persist; the rotation completes without throwing and the - // durable record converges on the rotated sources. - release(); - await rotatePromise; - const scanned = await scanWorkflowRunRecords(dataDir, createNoopCredentialCipher()); - const record = scanned.find((s) => s.runId === deriveDeploymentId(addr)); - expect(record?.record.sources).toEqual({ "step-1": [rotated] }); - }); - - test("a recycle interleaving a failed rotation persist respawns on new sources then rolls back", async () => { - // The benign residual, pinned: a persist that fails WHILE a recycle - // interleaves leaves the just-respawned child transiently ahead on the - // rotated sources (the intended, self-healing direction), while - // currentSources rolls back to the deploy-time table so the NEXT recycle - // reverts the child to durable truth. - const dataDir = await createTempBaseDir("sidecar-rot-interleave-fail-"); - const addr = "run_rotinterfail@example.com"; - const sourcesRouter = createMultistepSourcesRouter(); - const spawner = makeReadyDrivingSpawner(11300); - - let gate: Promise | null = null; - let release: () => void = () => undefined; - const persist: typeof writeWorkflowRunRecord = async (d, id, rec, cipher) => { - if (gate !== null) { - await gate; - throw new Error("rotation persist boom"); - } - await writeWorkflowRunRecord(d, id, rec, cipher); - }; - - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSourcesRouter: sourcesRouter, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - writeWorkflowRunRecord: persist, - }); - - const deployPromise = router.deploy(singleStepFrame(addr, "wf-rotinterfail")); - await spawner.driveReadyFor(0); - await deployPromise; - - gate = new Promise((resolve) => { - release = resolve; - }); - - const rotated = makeInferenceSource("rotated"); - const rotatePromise = sourcesRouter.tryRoute({ - type: "sources.update", - agentAddress: addr, - sources: [rotated], - defaultSource: "rotated", - }); - - // A recycle interleaves the about-to-fail persist: the respawn still - // carries the ROTATED sources, because the swap already ran. - await spawner.recycleRequestFor(0); - while (spawner.spawnCount() < 2) { - await new Promise((r) => setTimeout(r, 1)); - } - await spawner.driveReadyFor(1); - const respawnEnv = spawner.envFor(1); - if (respawnEnv === undefined) throw new Error("respawn env missing"); - expect(JSON.parse(respawnEnv[STEP_INFERENCE_SOURCES_ENV_KEY] ?? "null")).toEqual({ - "step-1": [rotated], - }); - - // Release the persist so it rejects; the rotation rolls currentSources - // back and rethrows. - release(); - await expect(rotatePromise).rejects.toThrow(/rotation persist boom/); - - // A SECOND recycle now respawns on the ROLLED-BACK deploy-time sources, - // healing the transient down to durable truth. - await spawner.recycleRequestFor(1); - while (spawner.spawnCount() < 3) { - await new Promise((r) => setTimeout(r, 1)); - } - await spawner.driveReadyFor(2); - const secondRespawnEnv = spawner.envFor(2); - if (secondRespawnEnv === undefined) { - throw new Error("second respawn env missing"); - } - expect(JSON.parse(secondRespawnEnv[STEP_INFERENCE_SOURCES_ENV_KEY] ?? "null")).toEqual({ - "step-1": [makeInferenceSource("step-1")], - }); - }); - - test("two addresses whose deriveDeploymentId slugs collide are rejected at the second deploy", async () => { - // deriveDeploymentId substitutes every disallowed character with - // `-`, so two distinct addresses can collapse to the same slug. The slug - // IS the workflow-run repoId, so a silent collision would let the second - // deploy overwrite the first deploy's repo state. claimSlug rejects the - // second deploy at the router edge, before any spawn or repo write. - const spawner = makeReadyDrivingSpawner(10400); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSubstrateEnv: { - SIDECAR_DATA_DIR: await createTempBaseDir("sidecar-collision-data-"), - }, - }); - - // `run_col.a@example.com` and `run_col-a@example.com` both project to - // `run_col-a-example-com` under the slug derivation. - const deployPromise = router.deploy(singleStepFrame("run_col.a@example.com", "wf-collide")); - await spawner.driveReadyFor(0); - await deployPromise; - - await expect( - router.deploy(singleStepFrame("run_col-a@example.com", "wf-collide")), - ).rejects.toThrow(/deriveDeploymentId collision/); - expect(spawner.spawnCount()).toBe(1); - }); - - test("reEmitParkedCorrelations reaches the live supervisor and sends a parked-correlations.request downstream", async () => { - // Trigger B: the hub-reconnect fan-out. After a deploy populates - // `activeSupervisors` for the deployment address, the router's - // address-dispatch wrapper must reach the live supervisor's own no-arg - // `reEmitParkedCorrelations`, which the supervisor implements by sending a - // `parked-correlations.request` control frame down to the child. The mock - // child never answers, so the supervisor's watchdog eventually fires; the - // router's fire-and-forget contract means the request frame lands on the - // downstream stream regardless, which is the observable evidence here. - const childIpcKeyPair = await generateKeyPair(); - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let observedEnv: Record | undefined; - const spawner: SubprocessSpawner = ({ env }) => { - observedEnv = env; - const handle: SubprocessHandle = { - pid: 10500, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - return handle; - }; - - const dataDir = await createTempBaseDir("sidecar-reemit-data-"); - const { router } = await buildMultistepFixture({ - spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - const sources = defaultMultistepSources(); - const definition = { - id: "wf-reemit", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - const frame = makeMultistepFrame({ definition, sources }); - - const deployPromise = router.deploy(frame); - while (observedEnv === undefined) { - await new Promise((r) => setTimeout(r, 1)); - } - const channelId = observedEnv.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID not set in spawn-time env"); - } - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: 10500, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - await deployPromise; - - // `activeSupervisors` is keyed by the frame's run address; the router - // routes the re-emit through that same key. - expect(router.activeAddresses()).toEqual([frame.agentAddress]); - - // Each downstream line is a signed envelope `{ envelope: { seq, channelId, - // payload }, sig }`; read `envelope.payload.type` without verifying the - // signature (the supervisor's IPC public key is not exposed to the test). - // The supervisor emits its own `parked-correlations.request` on spawn (a - // fresh child becoming addressable), so the trigger is observed as an - // increase in the downstream request count, not its first appearance. - function parkedRequestCount(): number { - return supervisorToChild - .flushed() - .filter((line) => downstreamPayloadType(line) === PARKED_REQUEST_TYPE).length; - } - - // Let the spawn-time Trigger A re-emit settle so the baseline captures it; - // the assertion below then proves the Trigger B call adds a request on top. - await new Promise((r) => setTimeout(r, 50)); - const baseline = parkedRequestCount(); - - router.reEmitParkedCorrelations(frame.agentAddress); - - // Fire-and-forget: poll the downstream stream until the router's request - // frame lands on top of the spawn-time baseline, bounded so a wiring break - // fails the test instead of hanging. - const deadline = Date.now() + 2000; - while (parkedRequestCount() <= baseline) { - if (Date.now() > deadline) { - throw new Error( - "reEmitParkedCorrelations did not add a parked-correlations.request to the downstream control stream", - ); - } - await new Promise((r) => setTimeout(r, 1)); - } - expect(parkedRequestCount()).toBeGreaterThan(baseline); - }); - - test("reEmitParkedCorrelations for an address with no active supervisor is a no-op", async () => { - // The edge boundary: the hub reports an address routable, but no live - // supervisor owns it (a torn-down or not-yet-respawned deployment). The - // router must skip it -- neither throw nor drive any downstream frame. - const childIpcKeyPair = await generateKeyPair(); - const supervisorToChild = createMemoryNdjsonStream(); - const childToSupervisor = createMemoryNdjsonStream(); - const eventChildToSupervisor = createMemoryFrameStream(); - let resolveExit: ((code: number) => void) | undefined; - const exited = new Promise((resolve) => { - resolveExit = resolve; - }); - let observedEnv: Record | undefined; - const spawner: SubprocessSpawner = ({ env }) => { - observedEnv = env; - const handle: SubprocessHandle = { - pid: 10600, - controlWriter: supervisorToChild.writer, - controlReader: childToSupervisor.reader, - eventReader: eventChildToSupervisor.reader, - kill: () => { - childToSupervisor.close(); - eventChildToSupervisor.close(); - resolveExit?.(0); - }, - exited, - }; - return handle; - }; - - const dataDir = await createTempBaseDir("sidecar-reemit-miss-data-"); - const { router } = await buildMultistepFixture({ - spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - - const sources = defaultMultistepSources(); - const definition = { - id: "wf-reemit-miss", - triggers: [{ type: "manual" }], - stepOrder: ["step-1", "step-2"], - steps: { "step-1": { kind: "step" }, "step-2": { kind: "step" } }, - }; - const frame = makeMultistepFrame({ definition, sources }); - - const deployPromise = router.deploy(frame); - while (observedEnv === undefined) { - await new Promise((r) => setTimeout(r, 1)); - } - const channelId = observedEnv.IPC_CHANNEL_ID; - if (channelId === undefined) { - throw new Error("IPC_CHANNEL_ID not set in spawn-time env"); - } - const childSender = createControlChannelSender({ - privateKeySeed: childIpcKeyPair.privateKey, - channelId, - writer: { - write(line: string) { - childToSupervisor.inject(line); - return Promise.resolve(); - }, - }, - }); - await childSender.send({ - type: "ready", - data: { - childPid: 10600, - childPublicKey: hexEncode(childIpcKeyPair.publicKey), - }, - }); - await deployPromise; - - const missAddress = "run_nonexistent@wf.example"; - expect(router.activeAddresses()).not.toContain(missAddress); - - // The deployed supervisor emits its own `parked-correlations.request` on - // spawn; the miss-address re-emit must add nothing on top of that - // baseline. Count downstream requests before and after the no-op call. - function parkedRequestCount(): number { - return supervisorToChild - .flushed() - .filter((line) => downstreamPayloadType(line) === PARKED_REQUEST_TYPE).length; - } - // Settle the spawn-time Trigger A re-emit so it is folded into the baseline - // rather than racing the post-call window below. - await new Promise((r) => setTimeout(r, 50)); - const baseline = parkedRequestCount(); - - // The no-op skip must not throw for an address with no live supervisor. - expect(() => router.reEmitParkedCorrelations(missAddress)).not.toThrow(); - - // Give any errant fire-and-forget a chance to land, then confirm the miss - // added no downstream request for the missing address. - await new Promise((r) => setTimeout(r, 50)); - expect(parkedRequestCount()).toBe(baseline); - }); -}); - -describe("assembleRunCredentialsSnapshot", () => { - // getRepoDir mirrors the production `//` layout that - // `createSpawnTestRepoStore` uses, so a file written at - // `/workflow-run//runs//grants.json` lands where - // the sink's working-tree read looks, and a step's deploy-time grants land - // at `/agent-state//state/grants.json`. - function createReadStubRepoStore(tempBase: string): RepoStore { - const stub: Partial = { - getRepoDir(repoId: RepoId): string { - return path.join(tempBase, repoId.kind, repoId.id); - }, - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub; the sink reads only via getRepoDir working-tree reads - return new Proxy(stub as RepoStore, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (value !== undefined) return value; - return () => { - throw new Error(`stub RepoStore: ${String(prop)} not implemented for this test`); - }; - }, - }); - } - - const anchorRunId = "dep-run-grants"; - const runId = "run-xyz"; - const stepOrder = ["step-1", "step-2"]; - const deriveStepAddress = ({ runId: dep, stepId }: { runId: string; stepId: string }) => - `${dep}-${stepId}@example.com`; - - async function writeRunGrantsFile(tempBase: string, contents: string): Promise { - const dir = path.join(tempBase, "workflow-run", anchorRunId, "runs", runId); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, "grants.json"), contents); - } - - async function writeDeployTimeStepGrants( - tempBase: string, - stepId: string, - contents: string, - ): Promise { - const dir = path.join(tempBase, "agent-state", `${anchorRunId}-${stepId}`, "state"); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(path.join(dir, "grants.json"), contents); - } - - test("a run with a per-run grants file reads those grants over deploy-time", async () => { - const tempBase = await createTempBaseDir("sidecar-run-grants-"); - const repoStore = createReadStubRepoStore(tempBase); - - const runGrants = [{ id: "run-grant", resource: "tool:send-mail", effect: "allow" }]; - await writeRunGrantsFile(tempBase, JSON.stringify({ grants: runGrants })); - // Deploy-time grants differ; the per-run read must win, so these are - // never surfaced. - await writeDeployTimeStepGrants( - tempBase, - "step-1", - JSON.stringify({ grants: [{ id: "deploy-grant" }] }), - ); - - const snapshot = await assembleRunCredentialsSnapshot({ - repoStore, - anchorRunId, - runId, - stepOrder, - deriveStepAddress, - }); - - // Every step carries the single flat per-run grant set, keyed on the - // deploy-time per-step address, with a shared content hash. - expect(snapshot.steps).toHaveLength(2); - for (const step of snapshot.steps) { - expect(step.grants).toEqual(runGrants); - expect(step.address).toBe(`${anchorRunId}-${step.stepId}@example.com`); - } - expect(snapshot.steps[0]?.stepId).toBe("step-1"); - expect(snapshot.steps[1]?.stepId).toBe("step-2"); - expect(snapshot.steps[0]?.contentHash).toBe(snapshot.steps[1]?.contentHash ?? ""); - }); - - test("a run without a per-run grants file fails closed", async () => { - const tempBase = await createTempBaseDir("sidecar-run-grants-"); - const repoStore = createReadStubRepoStore(tempBase); - - // No per-run file. A deploy-time file is present so a silent fallback - // would surface deploy-time grants rather than failing; the run must fail - // closed instead of running against them. - await writeDeployTimeStepGrants( - tempBase, - "step-1", - JSON.stringify({ grants: [{ id: "deploy-grant-1" }] }), - ); - - await expect( - assembleRunCredentialsSnapshot({ - repoStore, - anchorRunId, - runId, - stepOrder, - deriveStepAddress, - }), - ).rejects.toThrow(/has no grants file/); - }); - - test("a run with a malformed per-run grants file throws", async () => { - const tempBase = await createTempBaseDir("sidecar-run-grants-"); - const repoStore = createReadStubRepoStore(tempBase); - - // The file exists but is not valid JSON. A deploy-time file is present so - // a swallowed error would silently fall back rather than surface. - await writeRunGrantsFile(tempBase, "{ not valid json"); - await writeDeployTimeStepGrants( - tempBase, - "step-1", - JSON.stringify({ grants: [{ id: "deploy-grant" }] }), - ); - - await expect( - assembleRunCredentialsSnapshot({ - repoStore, - anchorRunId, - runId, - stepOrder, - deriveStepAddress, - }), - ).rejects.toThrow(/is not valid JSON/); - - // A file that is valid JSON but violates the `{ grants: [] }` envelope - // also throws, rather than falling back -- the presence of the file - // implies a grants frame was delivered. - await writeRunGrantsFile(tempBase, JSON.stringify({ grants: "not-array" })); - await expect( - assembleRunCredentialsSnapshot({ - repoStore, - anchorRunId, - runId, - stepOrder, - deriveStepAddress, - }), - ).rejects.toThrow(/failed validation/); - }); - - test("a re-dispatched run re-reads the durable per-run grants, not the deploy-time fallback", async () => { - // On a child respawn the supervisor's `replayProcessingToInbox` moves an - // already-consumed run's orphaned `processing/` entry back to `inbox/`, - // so the fresh dispatch loop re-dequeues it and the `onRunStart` grants - // barrier fires AGAIN for the same runId. The per-run grants file is a - // durable commit -- `readRunGrants` never deletes it -- so this second - // resolution must still read `runs//grants.json` and win over the - // deploy-time fallback, rather than inheriting the deployment's grants as - // if the run had never carried its own. - const tempBase = await createTempBaseDir("sidecar-run-grants-"); - const repoStore = createReadStubRepoStore(tempBase); - - const runGrants = [{ id: "run-grant", resource: "tool:send-mail", effect: "allow" }]; - await writeRunGrantsFile(tempBase, JSON.stringify({ grants: runGrants })); - // A deploy-time file is present so a spurious fallback (a deleted or - // missed per-run file on the second read) would surface as the wrong - // grants rather than an empty set. - await writeDeployTimeStepGrants( - tempBase, - "step-1", - JSON.stringify({ grants: [{ id: "deploy-grant" }] }), - ); - - const first = await assembleRunCredentialsSnapshot({ - repoStore, - anchorRunId, - runId, - stepOrder, - deriveStepAddress, - }); - - // The re-dispatch: resolve the same run a second time with no rewrite of - // the grants file in between. - const second = await assembleRunCredentialsSnapshot({ - repoStore, - anchorRunId, - runId, - stepOrder, - deriveStepAddress, - }); - - for (const snapshot of [first, second]) { - expect(snapshot.steps).toHaveLength(2); - for (const step of snapshot.steps) { - expect(step.grants).toEqual(runGrants); - } - } - // The second resolution is byte-identical to the first: same flat grant - // set, same content hash across every step. - expect(second.steps.map((s) => s.contentHash)).toEqual(first.steps.map((s) => s.contentHash)); - }); -}); diff --git a/apps/sidecar/src/workflow-probe-handler.test.ts b/apps/sidecar/src/workflow-probe-handler.test.ts deleted file mode 100644 index d73b2b685..000000000 --- a/apps/sidecar/src/workflow-probe-handler.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { hexDecode, hexEncode } from "@intx/types"; -import type { WorkflowProbeRequestFrame } from "@intx/types/sidecar"; -import { encodeEnvelope, signHmac, type FrameEnvelope } from "@intx/workflow-host"; - -import { - createWorkflowProbeExecutor, - enrichProbeError, - type MaterializeWorkflowClosure, - type ProbeChildSpawner, -} from "./workflow-probe-handler"; - -describe("enrichProbeError", () => { - test("adds a dependencies/devDependencies hint to a module-not-found", () => { - const enriched = enrichProbeError( - new Error("Cannot find module '@wf/lib' from '/x/workflow.mjs'"), - ); - expect(enriched).toMatch(/could not resolve "@wf\/lib"/); - expect(enriched).toMatch(/"dependencies" rather than "devDependencies"/); - }); - - test("matches the 'Cannot find package' phrasing too", () => { - const enriched = enrichProbeError( - new Error('Cannot find package "left-pad" imported from /x/workflow.mjs'), - ); - expect(enriched).toMatch(/could not resolve "left-pad"/); - }); - - test("passes a non-resolution error through unchanged", () => { - const enriched = enrichProbeError(new Error("boom, author code threw")); - expect(enriched).toBe("boom, author code threw"); - }); -}); - -function probeFrame(): WorkflowProbeRequestFrame { - return { - type: "workflow.probe.request", - requestId: "probe-req-1", - source: { kind: "registry", registry: "npmjs" }, - closure: { schemaVersion: "1", topLevel: [], entries: [] }, - entry: "./workflow.js", - }; -} - -describe("createWorkflowProbeExecutor", () => { - test("returns a written result even when the child exit resolves before the read", async () => { - // A one-shot child writes its result line and then exits promptly, so both - // the buffered line and `handle.exited` become ready together. A handle - // whose `exited` is ALREADY resolved and whose stdout carries a valid signed - // result reproduces that race deterministically: the former `exit` race arm - // would win and discard the written result; racing only the line must - // return it. - const projection = { - id: "race-fixture", - triggers: [], - stepOrder: [], - steps: {}, - }; - const raceSpawner: ProbeChildSpawner = ({ env }) => { - const channelId = env["PROBE_IPC_CHANNEL_ID"]; - const hmacHex = env["PROBE_IPC_HMAC_KEY"]; - if (channelId === undefined || hmacHex === undefined) { - throw new Error("probe spawn env missing channel id / hmac key"); - } - const hmacKey = hexDecode(hmacHex); - const stdout = new ReadableStream({ - async start(controller) { - const payload = { - ok: true as const, - projection, - grants: ["cap:probe-race"], - grantWalkSnapshot: { - perStep: [{ stepId: "s1", grants: ["cap:probe-race"], grantEffects: {} }], - grantRequirements: [], - }, - wireHash: "a".repeat(64), - }; - const envelope: FrameEnvelope = { seq: 0, channelId, payload }; - const mac = hexEncode(await signHmac(encodeEnvelope(envelope), hmacKey)); - controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ envelope, mac })}\n`)); - controller.close(); - }, - }); - return { - pid: 4242, - stdout, - exited: Promise.resolve(0), - kill: () => { - /* mock handle: already exited, nothing to reap */ - }, - }; - }; - - const materialize: MaterializeWorkflowClosure = () => - Promise.resolve({ - packageDir: "/unused-by-the-race-spawner", - cleanup: () => Promise.resolve(), - }); - - const executor = createWorkflowProbeExecutor({ - materialize, - spawnProbeChild: raceSpawner, - }); - - const result = await executor.probe(probeFrame()); - expect(result.projection).toEqual(projection); - expect(result.grants).toEqual(["cap:probe-race"]); - expect(result.grantWalkSnapshot).toEqual({ - perStep: [{ stepId: "s1", grants: ["cap:probe-race"], grantEffects: {} }], - grantRequirements: [], - }); - expect(result.wireHash).toBe("a".repeat(64)); - }); -}); diff --git a/apps/sidecar/src/workflow-run-pack-client-undeploy.test.ts b/apps/sidecar/src/workflow-run-pack-client-undeploy.test.ts deleted file mode 100644 index e33b12533..000000000 --- a/apps/sidecar/src/workflow-run-pack-client-undeploy.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -// Pins the symmetric unregister contract on the multi-step routers and -// the `DeploymentAddressRegistry`. After a deployment is torn down, the -// boot-edge undeploy path must invoke `unregister` on every router and -// the registry so subsequent `signal.deliver` / `drain.deliver` / -// `mail.inbound` frames aimed at the dead deployment address are -// rejected by the router (`tryRoute` returns false) rather than -// dispatched into the orphaned supervisor handler. - -import { describe, test, expect } from "bun:test"; - -import { - createDeploymentAddressRegistry, - createMultistepDrainRouter, - createMultistepMailRouter, - createMultistepSignalRouter, -} from "./workflow-run-pack-client"; - -describe("multistep router lifecycle: unregister", () => { - test("MailRouter.unregister drops the handler so stale frames are not claimed", () => { - const router = createMultistepMailRouter(); - const delivered: Uint8Array[] = []; - router.register("dep-A@x.example", async (msg) => { - delivered.push(msg); - }); - - router.unregister("dep-A@x.example"); - - const claimed = router.tryRoute("dep-A@x.example", new Uint8Array([1])); - expect(claimed).toBeNull(); - expect(delivered).toHaveLength(0); - }); - - test("MailRouter.unregister of an unknown address is a no-op", () => { - const router = createMultistepMailRouter(); - expect(() => { - router.unregister("never-registered@x.example"); - }).not.toThrow(); - }); - - test("SignalRouter.unregister drops the handler so stale frames are not claimed", async () => { - const router = createMultistepSignalRouter(); - const delivered: string[] = []; - router.register("dep-A@x.example", async (args) => { - delivered.push(args.signalId); - }); - - router.unregister("dep-A@x.example"); - - const claimed = await router.tryRoute({ - type: "signal.deliver", - agentAddress: "dep-A@x.example", - runId: "run-stale", - signalName: "approve", - signalId: "sig-stale", - payload: null, - }); - expect(claimed).toBe(false); - expect(delivered).toEqual([]); - }); - - test("DrainRouter.unregister drops the handler so stale frames are not claimed", async () => { - const router = createMultistepDrainRouter(); - const delivered: number[] = []; - router.register("dep-A@x.example", async (args) => { - delivered.push(args.deadlineMs); - }); - - router.unregister("dep-A@x.example"); - - const claimed = await router.tryRoute({ - type: "drain.deliver", - agentAddress: "dep-A@x.example", - deadlineMs: 999, - }); - expect(claimed).toBe(false); - expect(delivered).toEqual([]); - }); - - test("DeploymentAddressRegistry exposes a removal API that breaks the anchorRunId mapping", () => { - const registry = createDeploymentAddressRegistry(); - registry.record("dep-A", "agent-a@x.example"); - - expect(registry.resolve("dep-A")).toBe("agent-a@x.example"); - registry.unregister("dep-A"); - expect(registry.resolve("dep-A")).toBeNull(); - }); - - test("DeploymentAddressRegistry.unregister of an unknown anchorRunId is a no-op", () => { - const registry = createDeploymentAddressRegistry(); - expect(() => { - registry.unregister("never-recorded"); - }).not.toThrow(); - }); - - test("DeploymentAddressRegistry's public surface includes the removal API", () => { - const registry = createDeploymentAddressRegistry(); - const keys = Object.keys(registry).sort(); - expect(keys).toEqual(["record", "resolve", "unregister"]); - }); -}); diff --git a/apps/sidecar/src/workflow-run-pack-client.test.ts b/apps/sidecar/src/workflow-run-pack-client.test.ts deleted file mode 100644 index b2976d8d8..000000000 --- a/apps/sidecar/src/workflow-run-pack-client.test.ts +++ /dev/null @@ -1,1052 +0,0 @@ -import { describe, test, expect } from "bun:test"; - -import type { InferenceSource } from "@intx/types/runtime"; -import type { CredentialDelivery } from "@intx/types/sidecar"; -import type { RepoId, RepoStore } from "@intx/hub-sessions"; - -import { - createDeploymentAddressRegistry, - createMultistepDrainRouter, - createMultistepMailRouter, - createMultistepSignalRouter, - createMultistepSourcesRouter, - createMultistepCredentialsRouter, - createWorkflowRunPackClient, - createWorkflowRunPackPushingRepoStore, -} from "./workflow-run-pack-client"; - -function createRecordingUnderlyingRepoStore(): { - store: RepoStore; - preserveCalls: { - principal: { kind: string }; - repoId: RepoId; - ref: string; - }[]; - packs: { principal: { kind: string }; repoId: RepoId; ref: string }[]; - packedTipCommits: { repoId: RepoId; ref: string; commitSha: string }[]; -} { - const preserveCalls: { - principal: { kind: string }; - repoId: RepoId; - ref: string; - }[] = []; - const packs: { - principal: { kind: string }; - repoId: RepoId; - ref: string; - }[] = []; - const packedTipCommits: { - repoId: RepoId; - ref: string; - commitSha: string; - }[] = []; - const stub: Partial = { - getRepoDir(_repoId: RepoId): string { - return "/tmp/unused"; - }, - async writeTreePreservingPrefix(principal, repoId, ref, args) { - preserveCalls.push({ principal, repoId, ref }); - await args.merge(new Map()); - return { - commitSha: `sha-${String(preserveCalls.length)}`, - newlyTerminalRuns: [], - }; - }, - async resolveRef(_principal, _repoId, _ref) { - // The client's empty-delta guard compares the current ref tip against - // the last commit it acked. Return a fixed tip distinct from the - // `createPack` sha so the guard never short-circuits these tests: the - // client acks `stub-pack-sha`, so a tip of `stub-tip-sha` always has - // un-shipped work. - return "stub-tip-sha"; - }, - async createPack(principal, repoId, ref) { - packs.push({ principal, repoId, ref }); - return { - pack: new Uint8Array([0xab, 0xcd]), - commitSha: "stub-pack-sha", - ref, - }; - }, - commitPackedTip(repoId, ref, commitSha) { - packedTipCommits.push({ repoId, ref, commitSha }); - }, - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- in-test stub; the unused RepoStore methods are guarded by the Proxy below - const store = new Proxy(stub as RepoStore, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (value !== undefined) return value; - return () => { - throw new Error(`stub RepoStore: ${String(prop)} not implemented for this test`); - }; - }, - }); - return { store, preserveCalls, packs, packedTipCommits }; -} - -describe("createWorkflowRunPackClient", () => { - test("push builds a pack under the supervisor principal, forwards it to the hub link, and commits the packed tip on the ack", async () => { - const { store, packs, packedTipCommits } = createRecordingUnderlyingRepoStore(); - const sent: { - agentAddress: string; - repoId: RepoId; - pack: Uint8Array; - ref: string; - commitSha: string; - }[] = []; - const client = createWorkflowRunPackClient({ - substrate: store, - hubLink: { - async pushWorkflowRunPack(opts) { - sent.push(opts); - }, - }, - }); - - await client.push({ - agentAddress: "agent@example.com", - repoId: { kind: "workflow-run", id: "agent-example-com" }, - ref: "refs/heads/main", - }); - - expect(packs).toHaveLength(1); - expect(packs[0]?.principal.kind).toBe("supervisor"); - expect(packs[0]?.repoId.kind).toBe("workflow-run"); - expect(sent).toHaveLength(1); - expect(sent[0]?.agentAddress).toBe("agent@example.com"); - expect(sent[0]?.commitSha).toBe("stub-pack-sha"); - expect(sent[0]?.ref).toBe("refs/heads/main"); - // The ack (pushWorkflowRunPack resolving) commits the packed tip - // for the shipped commit, so the next createPack ships incrementally. - expect(packedTipCommits).toHaveLength(1); - expect(packedTipCommits[0]?.repoId.id).toBe("agent-example-com"); - expect(packedTipCommits[0]?.ref).toBe("refs/heads/main"); - expect(packedTipCommits[0]?.commitSha).toBe("stub-pack-sha"); - }); - - test("push does not commit the packed tip when the hub link rejects the transfer", async () => { - const { store, packedTipCommits } = createRecordingUnderlyingRepoStore(); - const client = createWorkflowRunPackClient({ - substrate: store, - hubLink: { - pushWorkflowRunPack: () => Promise.reject(new Error("transfer cancelled: Connection lost")), - }, - }); - - await expect( - client.push({ - agentAddress: "agent@example.com", - repoId: { kind: "workflow-run", id: "agent-example-com" }, - ref: "refs/heads/main", - }), - ).rejects.toThrow(/Connection lost/); - - // A cancelled/rejected transfer never acks, so the packed tip must - // not advance: the retry rebuild re-includes the un-acked commits. - expect(packedTipCommits).toHaveLength(0); - }); - - test("push rejects when given a non-workflow-run repoId", async () => { - const { store } = createRecordingUnderlyingRepoStore(); - const client = createWorkflowRunPackClient({ - substrate: store, - hubLink: { - pushWorkflowRunPack: () => Promise.resolve(), - }, - }); - await expect( - client.push({ - agentAddress: "a@example.com", - repoId: { kind: "agent-state", id: "a@example.com" }, - ref: "refs/heads/deploy", - }), - ).rejects.toThrow(/workflow-run/); - }); -}); - -describe("createWorkflowRunPackPushingRepoStore", () => { - test("writeTreePreservingPrefix against a workflow-run repo fires the push hook", async () => { - const { store, preserveCalls } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - registry.record("dep-1", "agent-1@example.com"); - const pushed: { agentAddress: string; repoId: RepoId; ref: string }[] = []; - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { - async push(opts) { - pushed.push(opts); - }, - }, - registry, - }); - - const repoId: RepoId = { kind: "workflow-run", id: "dep-1" }; - const result = await facade.writeTreePreservingPrefix( - { kind: "supervisor" }, - repoId, - "refs/heads/main", - { - preservePrefix: "runs/r-1/events/", - merge: async () => ({ "runs/r-1/events/0.json": "{}" }), - message: "append RunStarted", - }, - ); - await facade.flushWorkflowRunPushes(repoId, "refs/heads/main"); - - expect(result.commitSha).toBe("sha-1"); - expect(preserveCalls).toHaveLength(1); - expect(pushed).toHaveLength(1); - expect(pushed[0]?.agentAddress).toBe("agent-1@example.com"); - expect(pushed[0]?.repoId.id).toBe("dep-1"); - expect(pushed[0]?.ref).toBe("refs/heads/main"); - }); - - test("writeTreePreservingPrefix returns before the pack push finishes", async () => { - // The facade is required to return from writeTreePreservingPrefix - // as soon as the local commit lands; the pack push runs - // asynchronously. This is the throughput-critical behaviour the - // fifo-mail load test depends on -- without it, every - // supervisor write pays a full hub-ack round-trip in series and - // the dispatch loop's per-mail wall-clock balloons to ~15-25s/mail - // under sustained pressure. - const { store } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - registry.record("dep-pipeline", "agent-pipeline@example.com"); - let resolvePush: () => void = () => { - throw new Error("test: gate resolver was not captured before use"); - }; - const gate = new Promise((resolve) => { - resolvePush = resolve; - }); - const pushOrder: string[] = []; - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { - async push() { - pushOrder.push("enter"); - await gate; - pushOrder.push("exit"); - }, - }, - registry, - }); - - const repoId: RepoId = { kind: "workflow-run", id: "dep-pipeline" }; - await facade.writeTreePreservingPrefix({ kind: "supervisor" }, repoId, "refs/heads/main", { - preservePrefix: "runs/r-1/events/", - merge: async () => ({ "runs/r-1/events/0.json": "{}" }), - message: "first", - }); - // Yield to the microtask queue so the push's `enter` log lands. - // With a serialised wrap the write would not resolve until - // `exit`; pipelining is the property under test, so we assert - // the write returned while the push is still parked inside - // packClient.push. - await new Promise((r) => setTimeout(r, 0)); - expect(pushOrder).toEqual(["enter"]); - resolvePush(); - await facade.flushWorkflowRunPushes(repoId, "refs/heads/main"); - expect(pushOrder).toEqual(["enter", "exit"]); - }); - - test("a burst of writes against the same (repoId, ref) coalesces into at most 2 pushes", async () => { - // The coalescing invariant: while a push is in flight, follow-on - // writes mark the slot as dirty rather than enqueueing a new - // push. After the in-flight push exits, the loop runs one more - // push that captures whichever commits arrived during the - // window. This collapses N hub-ack round-trips into 2 for a - // burst of N back-to-back writes, which is the load-bearing - // throughput win for the fifo-mail load test. - const { store } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - registry.record("dep-burst", "agent-burst@example.com"); - let resolveFirst: () => void = () => { - throw new Error("test: first gate not captured"); - }; - const firstGate = new Promise((resolve) => { - resolveFirst = resolve; - }); - let pushCount = 0; - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { - async push() { - const idx = pushCount; - pushCount += 1; - if (idx === 0) await firstGate; - }, - }, - registry, - }); - - const repoId: RepoId = { kind: "workflow-run", id: "dep-burst" }; - // Five back-to-back writes. The first triggers a push; the next - // four land while the first push is in flight and all flip the - // slot's `dirty` flag, but only one coalesced follow-up push - // runs after the first exits. - for (let i = 0; i < 5; i += 1) { - await facade.writeTreePreservingPrefix({ kind: "supervisor" }, repoId, "refs/heads/main", { - preservePrefix: "runs/r-1/events/", - merge: async () => ({ - [`runs/r-1/events/${String(i)}.json`]: "{}", - }), - message: `write-${String(i)}`, - }); - } - expect(pushCount).toBe(1); - resolveFirst(); - await facade.flushWorkflowRunPushes(repoId, "refs/heads/main"); - // First push covered write 0 (the only commit landed when it - // started); the four follow-up writes coalesced into ONE - // additional push regardless of count. - expect(pushCount).toBe(2); - }); - - test("a failed pipelined push surfaces on the next writeTreePreservingPrefix call", async () => { - // The facade swallows the failed push at fire time but latches - // the error on the per-(repoId, ref) chain; the next - // writeTreePreservingPrefix on the same (repoId, ref) re-throws - // it. The defensive-coding rule says errors must surface; this - // is how they surface from a pipelined writer. - const { store } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - registry.record("dep-fail", "agent-fail@example.com"); - let pushCount = 0; - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { - async push() { - pushCount += 1; - if (pushCount === 1) { - throw new Error("hub_rejected: non_fast_forward"); - } - }, - }, - registry, - }); - const repoId: RepoId = { kind: "workflow-run", id: "dep-fail" }; - await facade.writeTreePreservingPrefix({ kind: "supervisor" }, repoId, "refs/heads/main", { - preservePrefix: "runs/r/events/", - merge: async () => ({ "runs/r/events/0.json": "{}" }), - message: "first", - }); - // Wait for the failed push to settle on the chain without - // consuming the latched error; flush would also surface the - // error, but the contract being pinned here is that the NEXT - // writeTreePreservingPrefix surfaces it -- ordinary supervisor - // code does not call flush between writes. - await new Promise((resolve) => setTimeout(resolve, 10)); - await expect( - facade.writeTreePreservingPrefix({ kind: "supervisor" }, repoId, "refs/heads/main", { - preservePrefix: "runs/r/events/", - merge: async () => ({ "runs/r/events/1.json": "{}" }), - message: "second", - }), - ).rejects.toThrow(/non_fast_forward/); - }); - - test("flushWorkflowRunPushes resolves immediately when no pushes are pending", async () => { - const { store } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { push: () => Promise.resolve() }, - registry, - }); - await facade.flushWorkflowRunPushes( - { kind: "workflow-run", id: "never-touched" }, - "refs/heads/main", - ); - }); - - test("writeTreePreservingPrefix against a non-workflow-run repo bypasses the push hook", async () => { - const { store } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - const pushed: { agentAddress: string; repoId: RepoId; ref: string }[] = []; - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { - async push(opts) { - pushed.push(opts); - }, - }, - registry, - }); - - await facade.writeTreePreservingPrefix( - { kind: "hub" }, - { kind: "agent-state", id: "a-1" }, - "refs/heads/deploy", - { - preservePrefix: "deploy/", - merge: async () => ({ "deploy/prompt.md": "hi" }), - message: "deploy", - }, - ); - - expect(pushed).toEqual([]); - }); - - test("workflow-run write surfaces a structured error when no run address is registered", async () => { - const { store } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { - push: () => Promise.resolve(), - }, - registry, - }); - - await expect( - facade.writeTreePreservingPrefix( - { kind: "supervisor" }, - { kind: "workflow-run", id: "missing-dep" }, - "refs/heads/main", - { - preservePrefix: "runs/r/events/", - merge: async () => ({}), - message: "append", - }, - ), - ).rejects.toThrow(/no run address registered/); - }); - - test("markAddressUnroutable holds a push until notifyAddressRoutable resumes it", async () => { - // The reconnect ordering contract. A WS disconnect blocks the address: - // a write that lands while blocked schedules no wire push, because a - // push shipped on the fresh, not-yet-registered connection is dropped by - // the hub as "unrouted". The reconnect route announcement lifts the block, - // and the held push ships after the hub has re-routed the address. - const { store } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - registry.record("dep-blocked", "agent-blocked@example.com"); - let pushCount = 0; - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { - async push() { - pushCount += 1; - }, - }, - registry, - }); - const repoId: RepoId = { kind: "workflow-run", id: "dep-blocked" }; - - facade.markAddressUnroutable("agent-blocked@example.com"); - await facade.writeTreePreservingPrefix({ kind: "supervisor" }, repoId, "refs/heads/main", { - preservePrefix: "runs/r/events/", - merge: async () => ({ "runs/r/events/0.json": "{}" }), - message: "append while blocked", - }); - // Yield: a wire push would have run by now if the block were not held. - await new Promise((r) => setTimeout(r, 0)); - expect(pushCount).toBe(0); - - facade.notifyAddressRoutable("agent-blocked@example.com"); - await facade.flushWorkflowRunPushes(repoId, "refs/heads/main"); - expect(pushCount).toBe(1); - }); - - test("notifyAddressRoutable re-drives a push a disconnect cancelled with no fresh write", async () => { - // The liveness contract a synchronous single-step run depends on. The - // first push rejects "Connection lost" (the disconnect cancelled the - // in-flight transfer) and latches its error. There is no later local - // write to re-arm the coalescing loop, so without the routable-again - // re-drive the run would strand forever. notifyAddressRoutable re-ships - // the un-acked commits once reconnect registration re-routes the address. - const { store } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - registry.record("dep-cancelled", "agent-cancelled@example.com"); - let pushCount = 0; - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { - async push() { - pushCount += 1; - if (pushCount === 1) { - throw new Error("transfer cancelled: Connection lost"); - } - }, - }, - registry, - }); - const repoId: RepoId = { kind: "workflow-run", id: "dep-cancelled" }; - - await facade.writeTreePreservingPrefix({ kind: "supervisor" }, repoId, "refs/heads/main", { - preservePrefix: "runs/r/events/", - merge: async () => ({ "runs/r/events/0.json": "{}" }), - message: "single batch", - }); - // Let the first push settle and latch "Connection lost" without a - // second write to re-arm the loop. - await new Promise((r) => setTimeout(r, 10)); - expect(pushCount).toBe(1); - - facade.notifyAddressRoutable("agent-cancelled@example.com"); - await facade.flushWorkflowRunPushes(repoId, "refs/heads/main"); - // The re-drive re-shipped the un-acked commits; the second attempt - // succeeds, so the latched error clears and flush resolves cleanly. - expect(pushCount).toBe(2); - }); - - test("notifyAddressRoutable is a no-op for a slot with nothing un-shipped", async () => { - // A clean, already-acked slot (no dirty work, no latched error) has - // nothing to re-ship. This pins the facade's re-drive gating: a - // routable-again notification for such a slot does not re-drive, so it - // does not call push again. Only slots with pending work or a latched - // failure re-drive. - const { store } = createRecordingUnderlyingRepoStore(); - const registry = createDeploymentAddressRegistry(); - registry.record("dep-clean", "agent-clean@example.com"); - let pushCount = 0; - const facade = createWorkflowRunPackPushingRepoStore({ - underlying: store, - packClient: { - async push() { - pushCount += 1; - }, - }, - registry, - }); - const repoId: RepoId = { kind: "workflow-run", id: "dep-clean" }; - - await facade.writeTreePreservingPrefix({ kind: "supervisor" }, repoId, "refs/heads/main", { - preservePrefix: "runs/r/events/", - merge: async () => ({ "runs/r/events/0.json": "{}" }), - message: "append", - }); - await facade.flushWorkflowRunPushes(repoId, "refs/heads/main"); - expect(pushCount).toBe(1); - - facade.notifyAddressRoutable("agent-clean@example.com"); - await facade.flushWorkflowRunPushes(repoId, "refs/heads/main"); - expect(pushCount).toBe(1); - }); -}); - -describe("createMultistepMailRouter", () => { - test("tryRoute returns null when no handler is registered", () => { - const router = createMultistepMailRouter(); - expect(router.tryRoute("dep@integration.interchange", new Uint8Array([1]))).toBeNull(); - }); - - test("a registered handler receives the inbound message and tryRoute returns its settlement", async () => { - const router = createMultistepMailRouter(); - const received: Uint8Array[] = []; - router.register("dep@integration.interchange", async (msg) => { - received.push(msg); - }); - const message = new Uint8Array([1, 2, 3, 4]); - const durable = router.tryRoute("dep@integration.interchange", message); - expect(durable).not.toBeNull(); - await durable; - expect(received).toHaveLength(1); - expect(received[0]).toEqual(message); - }); - - test("tryRoute propagates the handler's rejection as the withhold signal", async () => { - const router = createMultistepMailRouter(); - router.register("dep@integration.interchange", async () => { - throw new Error("durable write failed"); - }); - const durable = router.tryRoute("dep@integration.interchange", new Uint8Array([1])); - expect(durable).not.toBeNull(); - await expect(durable).rejects.toThrow(/durable write failed/); - }); - - test("registration is per-address; an unrelated address falls through", () => { - const router = createMultistepMailRouter(); - const received: Uint8Array[] = []; - router.register("dep-a@integration.interchange", async (msg) => { - received.push(msg); - }); - expect(router.tryRoute("dep-b@integration.interchange", new Uint8Array([9]))).toBeNull(); - expect(received).toHaveLength(0); - }); - - test("unregister removes the handler", () => { - const router = createMultistepMailRouter(); - const received: Uint8Array[] = []; - router.register("dep@integration.interchange", async (msg) => { - received.push(msg); - }); - router.unregister("dep@integration.interchange"); - expect(router.tryRoute("dep@integration.interchange", new Uint8Array([1]))).toBeNull(); - expect(received).toHaveLength(0); - }); - - test("re-registering an address replaces the prior handler", async () => { - const router = createMultistepMailRouter(); - const first: Uint8Array[] = []; - const second: Uint8Array[] = []; - router.register("dep@integration.interchange", async (msg) => { - first.push(msg); - }); - router.register("dep@integration.interchange", async (msg) => { - second.push(msg); - }); - await router.tryRoute("dep@integration.interchange", new Uint8Array([7])); - expect(first).toHaveLength(0); - expect(second).toHaveLength(1); - }); -}); - -describe("createMultistepCredentialsRouter", () => { - const delivery = { - bindings: [ - { - handle: "example-api", - credentialId: "cred_1", - consumer: "tool:@intx/tools-example", - }, - ], - materials: [ - { - credentialId: "cred_1", - providerKey: "http", - origin: "https://api.example.com", - secret: "sk-secret", - }, - ], - }; - const frame = { - type: "credentials.update" as const, - agentAddress: "dep@integration.interchange", - delivery, - }; - - test("tryRoute returns false when no handler is registered", async () => { - const router = createMultistepCredentialsRouter(); - expect(await router.tryRoute(frame)).toBe(false); - }); - - test("a registered handler receives the delivery and tryRoute returns true", async () => { - const router = createMultistepCredentialsRouter(); - const received: { delivery: typeof delivery }[] = []; - router.register("dep@integration.interchange", async (args) => { - received.push(args); - }); - expect(await router.tryRoute(frame)).toBe(true); - expect(received).toHaveLength(1); - expect(received[0]?.delivery).toEqual(delivery); - }); - - test("a frame's revoke list threads through to the handler", async () => { - const router = createMultistepCredentialsRouter(); - const received: { delivery: typeof delivery; revoke?: string[] }[] = []; - router.register("dep@integration.interchange", async (args) => { - received.push(args); - }); - expect(await router.tryRoute({ ...frame, revoke: ["cred_1"] })).toBe(true); - expect(received).toHaveLength(1); - expect(received[0]?.revoke).toEqual(["cred_1"]); - }); - - test("registration is per-address; an unrelated address falls through", async () => { - const router = createMultistepCredentialsRouter(); - router.register("dep-a@integration.interchange", async () => undefined); - expect( - await router.tryRoute({ - ...frame, - agentAddress: "dep-b@integration.interchange", - }), - ).toBe(false); - }); - - test("unregister removes the handler", async () => { - const router = createMultistepCredentialsRouter(); - router.register("dep@integration.interchange", async () => undefined); - router.unregister("dep@integration.interchange"); - expect(await router.tryRoute(frame)).toBe(false); - }); - - test("rejects a malformed delivery for a registered address without dispatching", async () => { - const router = createMultistepCredentialsRouter(); - let called = false; - router.register("dep@integration.interchange", async () => { - called = true; - }); - // A malformed delivery would crash the child's control-channel receiver on - // its `CredentialsUpdateFrame` narrow, so the router rejects before - // dispatch and the hub-link turns the throw into a truthful session.error. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- deliberately malformed delivery to exercise the pre-dispatch validation - const malformed = { - bindings: [{ handle: "x" }], - materials: [], - } as unknown as CredentialDelivery; - await expect( - router.tryRoute({ - type: "credentials.update", - agentAddress: "dep@integration.interchange", - delivery: malformed, - }), - ).rejects.toThrow(); - expect(called).toBe(false); - }); - - test("propagates the handler's rejection (deliverCredentials throwing) verbatim", async () => { - const router = createMultistepCredentialsRouter(); - router.register("dep@integration.interchange", async () => { - throw new Error("deliverCredentials failed in a recycling phase"); - }); - await expect(router.tryRoute(frame)).rejects.toThrow(/deliverCredentials failed/); - }); -}); - -describe("createMultistepSourcesRouter", () => { - const source: InferenceSource = { - id: "primary", - provider: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: "sk-x", - model: "claude-test", - }; - const frame = { - type: "sources.update" as const, - agentAddress: "dep@integration.interchange", - sources: [source], - defaultSource: "primary", - }; - - test("tryRoute returns false when no handler is registered", async () => { - const router = createMultistepSourcesRouter(); - expect(await router.tryRoute(frame)).toBe(false); - }); - - test("a registered handler receives the rotation and tryRoute returns true", async () => { - const router = createMultistepSourcesRouter(); - const received: { sources: InferenceSource[]; defaultSource: string }[] = []; - router.register("dep@integration.interchange", async (args) => { - received.push(args); - }); - expect(await router.tryRoute(frame)).toBe(true); - expect(received).toHaveLength(1); - expect(received[0]?.sources).toEqual([source]); - expect(received[0]?.defaultSource).toBe("primary"); - }); - - test("registration is per-address; an unrelated address falls through", async () => { - const router = createMultistepSourcesRouter(); - router.register("dep-a@integration.interchange", async () => undefined); - expect( - await router.tryRoute({ - ...frame, - agentAddress: "dep-b@integration.interchange", - }), - ).toBe(false); - }); - - test("unregister removes the handler", async () => { - const router = createMultistepSourcesRouter(); - router.register("dep@integration.interchange", async () => undefined); - router.unregister("dep@integration.interchange"); - expect(await router.tryRoute(frame)).toBe(false); - }); - - test("rejects a rotation with duplicate ids for a registered address without dispatching", async () => { - const router = createMultistepSourcesRouter(); - let called = false; - router.register("dep@integration.interchange", async () => { - called = true; - }); - // Duplicate ids would crash the child's control-channel receiver on - // its narrow, so the router rejects before dispatch. - await expect(router.tryRoute({ ...frame, sources: [source, { ...source }] })).rejects.toThrow( - /unique ids/, - ); - expect(called).toBe(false); - }); - - test("rejects a rotation whose default is not the head source", async () => { - const router = createMultistepSourcesRouter(); - let called = false; - router.register("dep@integration.interchange", async () => { - called = true; - }); - const second = { ...source, id: "secondary" }; - await expect( - router.tryRoute({ - ...frame, - sources: [source, second], - defaultSource: "secondary", - }), - ).rejects.toThrow(/first element is the default/); - expect(called).toBe(false); - }); - - test("an invalid rotation for an unregistered address is unrouted, not rejected", async () => { - const router = createMultistepSourcesRouter(); - // Registration is checked before validation: an unregistered address - // reports `false` and its (here invalid) payload is never inspected. - expect( - await router.tryRoute({ - ...frame, - agentAddress: "unregistered@integration.interchange", - sources: [source, { ...source }], - }), - ).toBe(false); - }); -}); - -describe("createMultistepDrainRouter", () => { - test("tryRoute resolves to false when no handler is registered", async () => { - const router = createMultistepDrainRouter(); - const claimed = await router.tryRoute({ - type: "drain.deliver", - agentAddress: "dep@integration.interchange", - deadlineMs: 1_000, - }); - expect(claimed).toBe(false); - }); - - test("a registered handler receives the deadline and tryRoute resolves to true", async () => { - const router = createMultistepDrainRouter(); - const received: { deadlineMs: number }[] = []; - router.register("dep@integration.interchange", async (args) => { - received.push({ deadlineMs: args.deadlineMs }); - }); - const claimed = await router.tryRoute({ - type: "drain.deliver", - agentAddress: "dep@integration.interchange", - deadlineMs: 3_500, - }); - expect(claimed).toBe(true); - expect(received).toEqual([{ deadlineMs: 3_500 }]); - }); - - test("registration is per-address; an unrelated address falls through", async () => { - const router = createMultistepDrainRouter(); - const received: number[] = []; - router.register("dep-a@integration.interchange", async (args) => { - received.push(args.deadlineMs); - }); - const claimed = await router.tryRoute({ - type: "drain.deliver", - agentAddress: "dep-b@integration.interchange", - deadlineMs: 9_000, - }); - expect(claimed).toBe(false); - expect(received).toHaveLength(0); - }); - - test("unregister removes the handler", async () => { - const router = createMultistepDrainRouter(); - const received: number[] = []; - router.register("dep@integration.interchange", async (args) => { - received.push(args.deadlineMs); - }); - router.unregister("dep@integration.interchange"); - const claimed = await router.tryRoute({ - type: "drain.deliver", - agentAddress: "dep@integration.interchange", - deadlineMs: 1_000, - }); - expect(claimed).toBe(false); - expect(received).toHaveLength(0); - }); - - test("re-registering an address replaces the prior handler", async () => { - const router = createMultistepDrainRouter(); - const first: number[] = []; - const second: number[] = []; - router.register("dep@integration.interchange", async (args) => { - first.push(args.deadlineMs); - }); - router.register("dep@integration.interchange", async (args) => { - second.push(args.deadlineMs); - }); - await router.tryRoute({ - type: "drain.deliver", - agentAddress: "dep@integration.interchange", - deadlineMs: 4_200, - }); - expect(first).toHaveLength(0); - expect(second).toEqual([4_200]); - }); - - test("handler rejection propagates through tryRoute", async () => { - const router = createMultistepDrainRouter(); - router.register("dep@integration.interchange", async () => { - throw new Error("supervisor.drain failed"); - }); - await expect( - router.tryRoute({ - type: "drain.deliver", - agentAddress: "dep@integration.interchange", - deadlineMs: 1_000, - }), - ).rejects.toThrow(/supervisor\.drain failed/); - }); - - test("drain.deliver for a never-registered deployment id drops cleanly without throwing", async () => { - // Pins the defensive contract for an inbound drain.deliver frame - // that names a anchorRunId the sidecar's supervisor never spawned - // (e.g. an in-flight frame outracing the deploy ack, or a hub-side - // stale-state retry). The router must not throw; the hub-link's - // handleDrainDeliver then logs and drops, leaving sibling - // deployments unaffected. - const router = createMultistepDrainRouter(); - router.register("dep-known@integration.interchange", async () => { - throw new Error("known handler must not be invoked"); - }); - const claimed = await router.tryRoute({ - type: "drain.deliver", - agentAddress: "dep-unknown@integration.interchange", - deadlineMs: 1_000, - }); - expect(claimed).toBe(false); - }); -}); - -describe("createMultistepSignalRouter", () => { - test("tryRoute resolves to false when no handler is registered", async () => { - const router = createMultistepSignalRouter(); - const claimed = await router.tryRoute({ - type: "signal.deliver", - agentAddress: "dep@integration.interchange", - runId: "run-1", - signalName: "approve", - signalId: "sig-1", - payload: { ok: true }, - }); - expect(claimed).toBe(false); - }); - - test("a registered handler receives the signal and tryRoute resolves to true", async () => { - const router = createMultistepSignalRouter(); - const received: { - runId: string; - signalName: string; - signalId: string; - payload: unknown; - }[] = []; - router.register("dep@integration.interchange", async (args) => { - received.push(args); - }); - const claimed = await router.tryRoute({ - type: "signal.deliver", - agentAddress: "dep@integration.interchange", - runId: "run-42", - signalName: "approve", - signalId: "sig-42", - payload: { ok: true }, - }); - expect(claimed).toBe(true); - expect(received).toEqual([ - { - runId: "run-42", - signalName: "approve", - signalId: "sig-42", - payload: { ok: true }, - }, - ]); - }); - - test("registration is per-address; an unrelated address falls through", async () => { - const router = createMultistepSignalRouter(); - const received: string[] = []; - router.register("dep-a@integration.interchange", async (args) => { - received.push(args.signalId); - }); - const claimed = await router.tryRoute({ - type: "signal.deliver", - agentAddress: "dep-b@integration.interchange", - runId: "run-1", - signalName: "approve", - signalId: "sig-1", - payload: null, - }); - expect(claimed).toBe(false); - expect(received).toHaveLength(0); - }); - - test("unregister removes the handler", async () => { - const router = createMultistepSignalRouter(); - const received: string[] = []; - router.register("dep@integration.interchange", async (args) => { - received.push(args.signalId); - }); - router.unregister("dep@integration.interchange"); - const claimed = await router.tryRoute({ - type: "signal.deliver", - agentAddress: "dep@integration.interchange", - runId: "run-1", - signalName: "approve", - signalId: "sig-1", - payload: null, - }); - expect(claimed).toBe(false); - expect(received).toHaveLength(0); - }); - - test("re-registering an address replaces the prior handler", async () => { - // Pins the contract that drives the "stale-cohort signal" edge - // case. A signal frame in flight while the deploy router re-binds - // the deployment address (the only legitimate path that swaps the - // handler today) must route to the most-recently-registered - // handler; the prior cohort's handler is unreachable once - // replaced. The router does not carry a cohortId on the wire, so - // "live registration wins" is the contract that captures the - // intent. - const router = createMultistepSignalRouter(); - const first: string[] = []; - const second: string[] = []; - router.register("dep@integration.interchange", async (args) => { - first.push(args.signalId); - }); - router.register("dep@integration.interchange", async (args) => { - second.push(args.signalId); - }); - await router.tryRoute({ - type: "signal.deliver", - agentAddress: "dep@integration.interchange", - runId: "run-1", - signalName: "approve", - signalId: "sig-late", - payload: null, - }); - expect(first).toHaveLength(0); - expect(second).toEqual(["sig-late"]); - }); - - test("handler rejection propagates through tryRoute", async () => { - const router = createMultistepSignalRouter(); - router.register("dep@integration.interchange", async () => { - throw new Error("supervisor.deliverSignal failed"); - }); - await expect( - router.tryRoute({ - type: "signal.deliver", - agentAddress: "dep@integration.interchange", - runId: "run-1", - signalName: "approve", - signalId: "sig-1", - payload: null, - }), - ).rejects.toThrow(/supervisor\.deliverSignal failed/); - }); - - test("signal.deliver for a never-registered deployment id drops cleanly without throwing", async () => { - // Pins the defensive contract for an inbound signal.deliver frame - // that names a anchorRunId the sidecar's supervisor never spawned - // (e.g. an in-flight frame outracing the deploy ack, or a hub-side - // stale-state retry). The router must not throw; the hub-link's - // handleSignalDeliver then logs and drops, leaving sibling - // deployments unaffected. - const router = createMultistepSignalRouter(); - router.register("dep-known@integration.interchange", async () => { - throw new Error("known handler must not be invoked"); - }); - const claimed = await router.tryRoute({ - type: "signal.deliver", - agentAddress: "dep-unknown@integration.interchange", - runId: "run-1", - signalName: "approve", - signalId: "sig-1", - payload: null, - }); - expect(claimed).toBe(false); - }); -}); diff --git a/apps/sidecar/src/workflow-run-pack-restore.test.ts b/apps/sidecar/src/workflow-run-pack-restore.test.ts deleted file mode 100644 index 95c9410e9..000000000 --- a/apps/sidecar/src/workflow-run-pack-restore.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { expect, test } from "bun:test"; -import { promises as fs } from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { generateKeyPair } from "@intx/crypto"; -import { - createAgentRepoStore, - enqueueInbox, - WORKFLOW_RUN_GITIGNORE_PATH, - type Principal, - type RepoId, - type WorkflowRunSupervisorPrincipal, -} from "@intx/hub-sessions"; -import { deriveWorkflowRunRepoId } from "@intx/workflow-deploy"; - -import { createWorkflowRunPackClient } from "./workflow-run-pack-client"; -import { createWorkflowRunPackRestorer } from "./workflow-run-pack-restore"; - -test("restored refs survive replacement and the next sidecar commit fast-forwards the Hub", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "wfr-restore-")); - try { - const sourceKey = await generateKeyPair(); - const targetKey = await generateKeyPair(); - const source = createAgentRepoStore({ - dataDir: path.join(root, "hub"), - signingKey: sourceKey, - }); - const target = createAgentRepoStore({ - dataDir: path.join(root, "replacement"), - signingKey: targetKey, - }); - const agentAddress = "run_restore@workflow.test"; - const repoId: RepoId = { - kind: "workflow-run", - id: deriveWorkflowRunRepoId(agentAddress), - }; - const hubPrincipal: Principal = { kind: "hub" }; - const supervisorPrincipal: WorkflowRunSupervisorPrincipal = { - kind: "supervisor", - anchorRunId: repoId.id, - }; - - await source.repoStore.writeTree(hubPrincipal, repoId, "refs/heads/main", { - files: { [WORKFLOW_RUN_GITIGNORE_PATH]: "" }, - message: "Initialize workflow run", - }); - await source.repoStore.writeTree(hubPrincipal, repoId, "refs/heads/main", { - files: { - "runs/run-before-replacement/grants.json": JSON.stringify({ - grants: [], - }), - }, - message: "Persist run grants", - }); - await enqueueInbox(source.repoStore, hubPrincipal, repoId, { - address: agentAddress, - messageId: "message-before-replacement", - receivedAt: 1, - mailAuditRef: { store: "mail", path: "before" }, - }); - - const pushed: string[] = []; - const packClient = createWorkflowRunPackClient({ - substrate: target.repoStore, - hubLink: { - async pushWorkflowRunPack(pack) { - pushed.push(pack.commitSha); - const expectedOldSha = await source.repoStore.resolveRef( - hubPrincipal, - pack.repoId, - pack.ref, - ); - await source.repoStore.receivePack( - hubPrincipal, - pack.repoId, - pack.ref, - pack.pack, - pack.commitSha, - expectedOldSha, - ); - }, - }, - }); - const restore = createWorkflowRunPackRestorer({ - substrate: target.repoStore, - markRestored: packClient.markRestored, - }); - - for (const ref of ["refs/heads/main", "refs/heads/events"] as const) { - const pack = await source.repoStore.createPack(hubPrincipal, repoId, ref); - await restore({ agentAddress, repoId, ...pack }); - expect(await target.repoStore.resolveRef(hubPrincipal, repoId, ref)).toBe(pack.commitSha); - } - const restoredInbox = path.join( - target.repoStore.getRepoDir(repoId), - "addresses", - encodeURIComponent(agentAddress), - "inbox", - "1-message-before-replacement.json", - ); - expect(JSON.parse(await fs.readFile(restoredInbox, "utf8"))).toMatchObject({ - messageId: "message-before-replacement", - address: agentAddress, - }); - expect( - JSON.parse( - await fs.readFile( - path.join( - target.repoStore.getRepoDir(repoId), - "runs", - "run-before-replacement", - "grants.json", - ), - "utf8", - ), - ), - ).toEqual({ grants: [] }); - - // A reconnect at the restored tip is a no-op, not an empty pack that the - // Hub would reject because it contains no declared tip object. - await packClient.push({ - agentAddress, - repoId, - ref: "refs/heads/events", - }); - expect(pushed).toEqual([]); - - await enqueueInbox(target.repoStore, supervisorPrincipal, repoId, { - address: agentAddress, - messageId: "message-after-replacement", - receivedAt: 2, - mailAuditRef: { store: "mail", path: "after" }, - }); - await packClient.push({ - agentAddress, - repoId, - ref: "refs/heads/events", - }); - - expect(pushed).toHaveLength(1); - expect(await source.repoStore.resolveRef(hubPrincipal, repoId, "refs/heads/events")).toBe( - await target.repoStore.resolveRef(hubPrincipal, repoId, "refs/heads/events"), - ); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } -}); diff --git a/apps/sidecar/src/workflow-substrate-factory-abort.test.ts b/apps/sidecar/src/workflow-substrate-factory-abort.test.ts deleted file mode 100644 index 07da44a10..000000000 --- a/apps/sidecar/src/workflow-substrate-factory-abort.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -// A parent abort of an in-process child tears it down LOCALLY, settling it -// FAILED, not cancelled. An in-process child writes through the workflow-run -// proxy substrate, which cannot sign the supervisor `CancelRequested` a -// control-plane cancel needs (the kind handler refuses a workflow-process-signed -// cancel), so the child never self-cancels: the parent abort aborts the child's -// own controller directly, its parked step fails, and the run settles `failed` -// under its own principal with no `CancelRequested` in the log. -// -// Both in-process spawners are covered: the terminal-only `createSidecarRunChild` -// and the suspendable `createSidecarSpawnSuspendableChild`. Each abort is driven -// while the child is live and parked on an approval -- the reachable drain-window -// path -- so the test exercises the real teardown, not a child that already -// settled. - -import { describe, test, expect, afterAll, beforeAll } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { generateKeyPair } from "@intx/crypto"; -import type { ApprovalSnapshot, KeyPair } from "@intx/types/runtime"; -import { defineAgent } from "@intx/agent"; -import { builtinCredentialProviders, createCredentialProviderRegistry } from "@intx/harness"; -import { evaluateGrants } from "@intx/authz"; -import type { GrantRule } from "@intx/authz"; -import { - createRepoStore, - workflowRunKindHandler, - WORKFLOW_RUN_GITIGNORE_PATH, -} from "@intx/hub-sessions"; -import type { AuthorizeFn, RepoId, WorkflowRunWorkflowProcessPrincipal } from "@intx/hub-sessions"; -import { createWorkflowRunRepoStore } from "@intx/workflow-host"; -import { - createInMemoryScheduler, - createInMemoryRepoStore, - defineWorkflow, - step, - type WorkflowDefinition, - type WorkflowEvent, -} from "@intx/workflow"; - -import { - createSidecarRunChild, - createSidecarSpawnSuspendableChild, - type SidecarChildStepInvoker, -} from "./workflow-substrate-factory"; -import { runGrantsPath } from "./run-grants"; - -const REF = "refs/heads/main"; -const DEPLOYMENT_ID = "deployment-abort"; -const WORKFLOW_RUN_REPO_ID: RepoId = { - kind: "workflow-run", - id: DEPLOYMENT_ID, -}; -const allowAll: AuthorizeFn = () => ({ allowed: true }); -const PRINCIPAL: WorkflowRunWorkflowProcessPrincipal = { - kind: "workflow-process", - anchorRunId: DEPLOYMENT_ID, -}; -const BODY_STEP_AGENT_ID = "wallet-spend"; -const CORRELATION_ID = "corr-abort-1"; -const SNAPSHOT: ApprovalSnapshot = { - name: BODY_STEP_AGENT_ID, - description: "spend from the shared wallet", - inputSchema: { amount: "number" }, - arguments: { amount: 100 }, -}; - -const tempDirs: string[] = []; -let signingKey: KeyPair; -// `buildChildRunEnv` reads each spawned run's `assets/workflow//sources.json` -// eagerly; the park-forever invoker never resolves inference, but the read still -// happens, so stage a minimal sources file for the body definition id. -let bodySourcesDataDir: string; - -const noopOnEvent = (): void => { - /* the event sink is not asserted in these tests */ -}; - -beforeAll(async () => { - signingKey = await generateKeyPair(); - bodySourcesDataDir = await makeTempDir("abort-assets-"); - const dir = path.join(bodySourcesDataDir, "assets", "workflow", "body"); - await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile( - path.join(dir, "sources.json"), - JSON.stringify({ - s: [ - { - id: "anthropic:m", - provider: "anthropic", - baseURL: "http://localhost:1", - credentialId: "sk-x", - model: "m", - }, - ], - }), - ); -}); - -afterAll(async () => { - for (const d of tempDirs.splice(0)) { - await fs.promises.rm(d, { recursive: true, force: true }).catch(() => { - /* best effort */ - }); - } -}); - -async function makeTempDir(prefix: string): Promise { - const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(d); - return d; -} - -function grant(resource: string, action: string): GrantRule { - return { - id: `grant-${resource}-${action}`, - resource, - action, - effect: "allow", - origin: "creator", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }; -} - -function bodyDefinition(id: string): WorkflowDefinition { - const agent = defineAgent({ - id: BODY_STEP_AGENT_ID, - systemPrompt: "s", - tools: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "m" }] }, - }); - return defineWorkflow({ - id, - trigger: { type: "manual" }, - steps: { s: step({ agent }) }, - }); -} - -// An invoker that parks the body step on an approval and never returns an -// output: the child stays parked until the parent abort cancels it. -const parkForever: SidecarChildStepInvoker = async () => ({ - suspend: { - correlationId: CORRELATION_ID, - kind: "approval", - approvalSnapshot: SNAPSHOT, - }, -}); - -const evaluateGrantsAdapter: Parameters< - typeof createSidecarRunChild ->[0]["evaluateGrants"] = async ({ resource, action, grants }) => { - const result = await evaluateGrants( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the snapshot's grants are typed unknown[] at the workflow-host boundary; the sidecar owns the GrantRule grammar, so the seeded rows narrow here - [...(grants as readonly GrantRule[])], - resource, - action, - ); - return { effect: result.effect, matchingGrants: [], resolvedBy: null }; -}; - -async function makeSubstrate(prefix: string): Promise> { - const dataDir = await makeTempDir(prefix); - const substrate = createRepoStore({ - dataDir, - signingKey, - handlers: { "workflow-run": workflowRunKindHandler }, - authorize: allowAll, - }); - await substrate.writeTree({ kind: "hub" }, WORKFLOW_RUN_REPO_ID, REF, { - files: { [WORKFLOW_RUN_GITIGNORE_PATH]: "" }, - message: "genesis", - }); - return substrate; -} - -async function seedRunGrants( - substrate: ReturnType, - runId: string, - grants: readonly GrantRule[], -): Promise { - await substrate.writeTree({ kind: "hub" }, WORKFLOW_RUN_REPO_ID, REF, { - files: { - [runGrantsPath(runId)]: JSON.stringify({ grants }, null, 2), - }, - message: `seed grants for ${runId}`, - }); -} - -function sharedDeps(substrate: ReturnType) { - return { - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - workflowRunRef: REF, - principal: PRINCIPAL, - scheduler: createInMemoryScheduler({ - repoStore: createInMemoryRepoStore(), - clock: () => new Date(), - }), - invokeStep: parkForever, - evaluateGrants: evaluateGrantsAdapter, - dataDir: bodySourcesDataDir, - bodySources: {}, - credentialProviders: createCredentialProviderRegistry(builtinCredentialProviders()), - }; -} - -function reader(substrate: ReturnType) { - return createWorkflowRunRepoStore({ - substrate, - repoId: WORKFLOW_RUN_REPO_ID, - principal: PRINCIPAL, - ref: REF, - }); -} - -// Poll the child's durable log until its step parks (a SignalAwaited is -// committed), so the abort lands while the child is genuinely in flight. -async function waitForChildPark( - substrate: ReturnType, - childRunId: string, -): Promise { - const r = reader(substrate); - for (let i = 0; i < 300; i += 1) { - const events = await r.read(childRunId); - if (events.some((e) => e.kind === "SignalAwaited")) return; - await new Promise((res) => setTimeout(res, 10)); - } - throw new Error(`timed out waiting for child ${childRunId} to park`); -} - -// Assert the child's durable log shows a LOCAL fail teardown: a StepFailed and -// the terminal RunFailed, and NO CancelRequested (the in-process child cannot -// sign one, so the teardown never writes one). -async function expectLocalFailTeardown( - substrate: ReturnType, - childRunId: string, -): Promise { - const events: readonly WorkflowEvent[] = await reader(substrate).read(childRunId); - expect(events.some((e) => e.kind === "StepFailed")).toBe(true); - expect(events.some((e) => e.kind === "RunFailed")).toBe(true); - expect(events.some((e) => e.kind === "CancelRequested")).toBe(false); -} - -describe("in-process child parent-abort", () => { - test("createSidecarRunChild settles failed via local teardown", async () => { - const substrate = await makeSubstrate("abort-terminal-"); - const parentRunId = "run-parent"; - await seedRunGrants(substrate, parentRunId, [grant(`tool:${BODY_STEP_AGENT_ID}`, "invoke")]); - - const runChild = createSidecarRunChild(sharedDeps(substrate)); - const abort = new AbortController(); - const childRunId = "run-body-terminal"; - const settled = runChild( - { - definition: bodyDefinition("body"), - definitionRef: REF, - childRunId, - input: null, - parentRunId, - parentStepId: "s", - signal: abort.signal, - depth: 1, - maxChildSpawnDepth: 32, - }, - noopOnEvent, - ); - - await waitForChildPark(substrate, childRunId); - abort.abort(); - - const result = await settled; - expect(result.terminalStatus).toBe("failed"); - await expectLocalFailTeardown(substrate, childRunId); - }); - - test("createSidecarSpawnSuspendableChild settles failed via local teardown", async () => { - const substrate = await makeSubstrate("abort-suspendable-"); - const parentRunId = "run-parent"; - await seedRunGrants(substrate, parentRunId, [grant(`tool:${BODY_STEP_AGENT_ID}`, "invoke")]); - - const spawn = createSidecarSpawnSuspendableChild(sharedDeps(substrate)); - const abort = new AbortController(); - const childRunId = "run-body-suspendable"; - const handle = await spawn( - { - definition: bodyDefinition("body"), - definitionRef: REF, - childRunId, - input: { text: "event-0" }, - parentRunId, - parentStepId: "section", - signal: abort.signal, - depth: 0, - maxChildSpawnDepth: 32, - }, - () => undefined, - ); - - // The body parked on an approval and the handle surfaced it: the child is - // live. Abort the parent now. - const parked = await handle.next(); - expect(parked.kind).toBe("park"); - abort.abort(); - - const terminal = await handle.next(); - expect(terminal.kind).toBe("terminal"); - if (terminal.kind !== "terminal") throw new Error("expected a terminal"); - expect(terminal.terminalStatus).toBe("failed"); - await expectLocalFailTeardown(substrate, childRunId); - }); -}); diff --git a/apps/sidecar/src/workflow-substrate-factory-adapter-load.test.ts b/apps/sidecar/src/workflow-substrate-factory-adapter-load.test.ts deleted file mode 100644 index 03d1af299..000000000 --- a/apps/sidecar/src/workflow-substrate-factory-adapter-load.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import type { LastCycleSource } from "@intx/types/runtime"; -import type { AdapterFactory, ProviderAdapter } from "@intx/inference"; -import { loadAdapterRegistry } from "@intx/inference/providers"; - -import { parseAdapterManifest } from "./workflow-substrate-factory"; - -// Failure-isolation hedge for the cross-process custom-adapter path: -// proves the child-side load path (parse the serialized manifest, then -// `loadAdapterRegistry` with an injected importer, then resolve) in -// isolation, so a red forked-child integration test points at the -// spawn/IPC plumbing rather than at adapter loading. No fixture module -// is loaded from disk -- the importer is injected. - -function createStubAdapter(url: string): ProviderAdapter { - return { - buildRequest: () => ({ url, headers: {}, body: "" }), - parseResponse: () => [], - parseJSONResponse: () => [], - }; -} - -function createSource(provider: string): LastCycleSource { - return { sourceId: "src-1", provider, model: "test-model" }; -} - -describe("child-side adapter manifest load path", () => { - test("parses a serialized manifest and resolves the custom adapter", async () => { - const customAdapter = createStubAdapter("https://custom.invalid"); - const make: AdapterFactory = () => customAdapter; - - const manifest = parseAdapterManifest( - JSON.stringify([{ provider: "custom", specifier: "custom-pkg", export: "make" }]), - ); - const registry = await loadAdapterRegistry(manifest, { - import: (specifier) => { - expect(specifier).toBe("custom-pkg"); - return Promise.resolve({ make }); - }, - }); - - expect(registry.resolve(createSource("custom"))).toBe(customAdapter); - // Built-ins remain resolvable alongside the custom provider. - expect(typeof registry.resolve(createSource("anthropic")).buildRequest).toBe("function"); - }); - - test("an empty serialized manifest yields just the built-ins", async () => { - const registry = await loadAdapterRegistry(parseAdapterManifest("[]")); - - expect(registry.has("anthropic")).toBe(true); - expect(registry.has("custom")).toBe(false); - }); - - test("a malformed manifest fails loud naming the env key", () => { - expect(() => parseAdapterManifest("{not json")).toThrow( - /SIDECAR_ADAPTER_MANIFEST is not valid JSON/, - ); - expect(() => parseAdapterManifest(JSON.stringify([{ provider: 1 }]))).toThrow( - /SIDECAR_ADAPTER_MANIFEST failed validation/, - ); - }); -}); diff --git a/apps/sidecar/src/workflow-substrate-factory-child-depth.test.ts b/apps/sidecar/src/workflow-substrate-factory-child-depth.test.ts deleted file mode 100644 index 849274429..000000000 --- a/apps/sidecar/src/workflow-substrate-factory-child-depth.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -// The childWorkflow spawn-depth ceiling on the REAL in-process spawn path. -// -// `createSidecarRunChild` recurses on itself: a childWorkflow runs its child in -// the same process against the same shared workflow-run repo, threading `depth` -// by value through each rung (no serialization, no reset). So this harness -- -// a real on-disk substrate plus the real `createSidecarRunChild` / -// `createInMemorySpawnChild` / `runtimeRun` seam -- exercises the identical spawn -// path a deployed chain runs; the hub deploy-frame and the workflow-host -// subprocess wrapper are the only things a full roundtrip would add, and neither -// is where the depth guard lives. -// -// A nested chain lets depth ACCUMULATE rung over rung until the guard fires, -// rather than pre-loading a single `depth + 1 > max` arithmetic check (that is -// covered by the runLocal and child-depth unit tests). With the ceiling lowered -// to 2, the parent (depth 0) spawns outer (1) and outer spawns mid (2) for real -// -- proving depth climbs past 0 -- and only mid spawning leaf (depth 3) trips -// the guard, landing a clean `StepFailed` that names the offending depth. - -import { describe, test, expect, afterAll, beforeAll } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { generateKeyPair } from "@intx/crypto"; -import type { KeyPair } from "@intx/types/runtime"; -import { defineAgent } from "@intx/agent"; -import { builtinCredentialProviders, createCredentialProviderRegistry } from "@intx/harness"; -import { evaluateGrants } from "@intx/authz"; -import type { GrantRule } from "@intx/authz"; -import { - createRepoStore, - workflowRunKindHandler, - WORKFLOW_RUN_GITIGNORE_PATH, -} from "@intx/hub-sessions"; -import type { AuthorizeFn, RepoId, WorkflowRunWorkflowProcessPrincipal } from "@intx/hub-sessions"; -import { createWorkflowRunRepoStore } from "@intx/workflow-host"; -import { - createInMemoryScheduler, - createInMemoryRepoStore, - childWorkflow, - defineWorkflow, - step, - type WorkflowDefinition, - type WorkflowEvent, -} from "@intx/workflow"; - -import { createSidecarRunChild, type SidecarChildStepInvoker } from "./workflow-substrate-factory"; -import { runGrantsPath } from "./run-grants"; - -const REF = "refs/heads/main"; -const DEPLOYMENT_ID = "deployment-child-depth"; -const WORKFLOW_RUN_REPO_ID: RepoId = { - kind: "workflow-run", - id: DEPLOYMENT_ID, -}; -const allowAll: AuthorizeFn = () => ({ allowed: true }); -const PRINCIPAL: WorkflowRunWorkflowProcessPrincipal = { - kind: "workflow-process", - anchorRunId: DEPLOYMENT_ID, -}; - -const tempDirs: string[] = []; -let signingKey: KeyPair; -// `buildChildRunEnv` reads each spawned rung's `sources.json` eagerly, keyed by -// the rung's rewritten ref -- the `__` handle the deploy -// mints, which accumulates as the chain descends. The intermediate rungs carry -// no agent step, but the read still happens, so stage a minimal file for every -// rung that runs its env: the top (`depth-parent`), outer -// (`depth-parent__spawn`), and mid (`depth-parent__spawn__spawn`). The leaf rung -// is never reached because the guard fires before mid spawns it. -const RUNG_SOURCE_REFS = [ - "depth-parent", - "depth-parent__spawn", - "depth-parent__spawn__spawn", -] as const; -let sourcesDataDir: string; - -// The guard fires before any step runs, so no invoker is ever called. -const noopInvoker: SidecarChildStepInvoker = () => { - throw new Error("child-depth: no step should run before the guard fires"); -}; -const noopOnEvent = (): void => { - /* the event sink is not asserted in this test */ -}; - -beforeAll(async () => { - signingKey = await generateKeyPair(); - sourcesDataDir = await makeTempDir("child-depth-assets-"); - for (const ref of RUNG_SOURCE_REFS) { - await stageSources(ref); - } -}); - -afterAll(async () => { - for (const d of tempDirs.splice(0)) { - await fs.promises.rm(d, { recursive: true, force: true }).catch(() => { - /* best effort */ - }); - } -}); - -async function makeTempDir(prefix: string): Promise { - const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(d); - return d; -} - -async function stageSources(ref: string): Promise { - const dir = path.join(sourcesDataDir, "assets", "workflow", ref); - await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile( - path.join(dir, "sources.json"), - JSON.stringify({ - work: [ - { - id: "anthropic:m", - provider: "anthropic", - baseURL: "http://localhost:1", - credentialId: "sk-x", - model: "m", - }, - ], - }), - ); -} - -function grant(resource: string, action: string): GrantRule { - return { - id: `grant-${resource}-${action}`, - resource, - action, - effect: "allow", - origin: "creator", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }; -} - -const evaluateGrantsAdapter: Parameters< - typeof createSidecarRunChild ->[0]["evaluateGrants"] = async ({ resource, action, grants }) => { - const result = await evaluateGrants( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the snapshot's grants are typed unknown[] at the workflow-host boundary; the sidecar owns the GrantRule grammar, so the seeded rows narrow here - [...(grants as readonly GrantRule[])], - resource, - action, - ); - return { effect: result.effect, matchingGrants: [], resolvedBy: null }; -}; - -// parent -> outer -> mid -> leaf, each spawning the next inline child. Depths: -// parent 0, outer 1, mid 2, leaf 3. Only the leaf carries an agent step; the -// guard fires before it ever runs. -function nestedChain(): WorkflowDefinition { - const leaf = defineWorkflow({ - id: "depth-leaf", - trigger: { type: "manual" }, - steps: { - work: step({ - agent: defineAgent({ - id: "depth-leaf-agent", - systemPrompt: "s", - tools: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "m" }] }, - }), - }), - }, - }); - const mid = defineWorkflow({ - id: "depth-mid", - trigger: { type: "manual" }, - steps: { spawn: childWorkflow({ definition: leaf }) }, - }); - const outer = defineWorkflow({ - id: "depth-outer", - trigger: { type: "manual" }, - steps: { spawn: childWorkflow({ definition: mid }) }, - }); - return defineWorkflow({ - id: "depth-parent", - trigger: { type: "manual" }, - steps: { spawn: childWorkflow({ definition: outer }) }, - }); -} - -async function makeSubstrate(): Promise> { - const dataDir = await makeTempDir("child-depth-substrate-"); - const substrate = createRepoStore({ - dataDir, - signingKey, - handlers: { "workflow-run": workflowRunKindHandler }, - authorize: allowAll, - }); - await substrate.writeTree({ kind: "hub" }, WORKFLOW_RUN_REPO_ID, REF, { - files: { [WORKFLOW_RUN_GITIGNORE_PATH]: "" }, - message: "genesis", - }); - return substrate; -} - -async function seedRunGrants( - substrate: ReturnType, - runId: string, - grants: readonly GrantRule[], -): Promise { - await substrate.writeTree({ kind: "hub" }, WORKFLOW_RUN_REPO_ID, REF, { - files: { - [runGrantsPath(runId)]: JSON.stringify({ grants }, null, 2), - }, - message: `seed grants for ${runId}`, - }); -} - -function reader(substrate: ReturnType) { - return createWorkflowRunRepoStore({ - substrate, - repoId: WORKFLOW_RUN_REPO_ID, - principal: PRINCIPAL, - ref: REF, - }); -} - -// The runId of the single child a rung spawned (its `ChildSpawned`). -async function spawnedChildRunId( - substrate: ReturnType, - runId: string, -): Promise { - const events: readonly WorkflowEvent[] = await reader(substrate).read(runId); - const spawned = events.find((e) => e.kind === "ChildSpawned"); - if (spawned === undefined || spawned.kind !== "ChildSpawned") { - throw new Error(`run ${runId} has no ChildSpawned event`); - } - return spawned.childRunId; -} - -describe("createSidecarRunChild spawn-depth ceiling", () => { - test("a chain past the ceiling fails loud naming the offending depth", async () => { - const substrate = await makeSubstrate(); - const parentRunId = "run-parent"; - await seedRunGrants(substrate, parentRunId, [grant("inference.source:anthropic:m", "invoke")]); - - const runChild = createSidecarRunChild({ - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - workflowRunRef: REF, - principal: PRINCIPAL, - scheduler: createInMemoryScheduler({ - repoStore: createInMemoryRepoStore(), - clock: () => new Date(), - }), - invokeStep: noopInvoker, - evaluateGrants: evaluateGrantsAdapter, - dataDir: sourcesDataDir, - bodySources: {}, - credentialProviders: createCredentialProviderRegistry(builtinCredentialProviders()), - }); - - // Run the top of the chain at depth 0 with the ceiling lowered to 2. - const topRunId = "run-top"; - const result = await runChild( - { - definition: nestedChain(), - definitionRef: REF, - childRunId: topRunId, - input: null, - parentRunId, - parentStepId: "s", - signal: new AbortController().signal, - depth: 0, - maxChildSpawnDepth: 2, - }, - noopOnEvent, - ); - - // The top run settled failed: the deep spawn was rejected and the failure - // propagated up every rung's spawn step. - expect(result.terminalStatus).toBe("failed"); - - // Depth climbed for real: the parent spawned outer (depth 1) and outer - // spawned mid (depth 2) before the guard fired -- two successful in-process - // spawns, not a single pre-loaded arithmetic trip. - const outerRunId = await spawnedChildRunId(substrate, topRunId); - const midRunId = await spawnedChildRunId(substrate, outerRunId); - - // mid (depth 2) spawning leaf (depth 3) tripped the guard: its spawn step - // failed loud, and the error names the offending depth and ceiling verbatim. - // mid never committed a ChildSpawned (the guard fires before it). - const midEvents: readonly WorkflowEvent[] = await reader(substrate).read(midRunId); - expect(midEvents.some((e) => e.kind === "ChildSpawned")).toBe(false); - const midStepFailed = midEvents.find((e) => e.kind === "StepFailed"); - if (midStepFailed === undefined || midStepFailed.kind !== "StepFailed") { - throw new Error(`mid run ${midRunId} has no StepFailed event`); - } - expect(midStepFailed.error.message).toContain( - 'childWorkflow spawn depth 3 exceeds the maximum 2 at step "spawn"', - ); - }); -}); diff --git a/apps/sidecar/src/workflow-substrate-factory-child-grants.test.ts b/apps/sidecar/src/workflow-substrate-factory-child-grants.test.ts deleted file mode 100644 index 5d59d608d..000000000 --- a/apps/sidecar/src/workflow-substrate-factory-child-grants.test.ts +++ /dev/null @@ -1,441 +0,0 @@ -// A spawned child inherits the grants of the run that spawned it, CAPPED at -// what the child body itself declares. -// -// `createSidecarRunChild` reads the parent run's -// `runs//grants.json` as the ceiling, re-walks the child body to -// learn what it declares, and binds the child's `env.authorize` to the -// intersection: a parent grant the child body declares survives, a parent-only -// grant the child never declares is dropped. It persists that same capped set -// under the child's own `runs//grants.json`, so a grandchild's -// ceiling is the capped set -- not the raw parent set. A child whose parent has -// no grants file fails closed at spawn. -// -// The substrate here is a real on-disk workflow-run repo. The child's injected -// `invokeStep` calls the credentials-backed `authorize` the runtime env -// carries, so a declared resource resolves `allow` and an undeclared one -// resolves fail-closed -- exercising the cap end to end. - -import { describe, test, expect, afterAll, beforeAll } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { generateKeyPair } from "@intx/crypto"; -import type { KeyPair } from "@intx/types/runtime"; -import { defineAgent } from "@intx/agent"; -import { builtinCredentialProviders, createCredentialProviderRegistry } from "@intx/harness"; -import { evaluateGrants } from "@intx/authz"; -import type { GrantRule } from "@intx/authz"; -import { - createRepoStore, - workflowRunKindHandler, - WORKFLOW_RUN_GITIGNORE_PATH, -} from "@intx/hub-sessions"; -import type { AuthorizeFn, RepoId, WorkflowRunWorkflowProcessPrincipal } from "@intx/hub-sessions"; -import { - createInMemoryScheduler, - createInMemoryRepoStore, - defineWorkflow, - step, - type StepInvokeResult, - type WorkflowDefinition, -} from "@intx/workflow"; - -import { createSidecarRunChild, type SidecarChildStepInvoker } from "./workflow-substrate-factory"; -import { readRunGrants, runGrantsPath } from "./run-grants"; - -const REF = "refs/heads/main"; -const DEPLOYMENT_ID = "deployment-child-grants"; -const WORKFLOW_RUN_REPO_ID: RepoId = { - kind: "workflow-run", - id: DEPLOYMENT_ID, -}; -const allowAll: AuthorizeFn = () => ({ allowed: true }); -const PRINCIPAL: WorkflowRunWorkflowProcessPrincipal = { - kind: "workflow-process", - anchorRunId: DEPLOYMENT_ID, -}; - -// The child body's single agent declares this inference source, so the -// capability walk emits it as a grant the child body declares -- a parent -// grant for it survives the cap. -const DECLARED_RESOURCE = "inference.source:anthropic:m"; -// The child body declares nothing that covers this, so a parent grant for it -// is dropped from the child's inherited set. -const UNDECLARED_RESOURCE = "tool:parent-only"; - -const tempDirs: string[] = []; -let signingKey: KeyPair; -// The real child runtime resolves each step's inference source from a staged -// `assets/workflow//sources.json`. These tests use a recording invoker -// that ignores the sources, but `buildChildRunEnv` reads the file eagerly, so -// stage a minimal one per child definition id these tests spawn. -let childSourcesDataDir: string; - -// These tests assert grant capping, not event emission, so the sink is noop. -const noopOnEvent = (): void => { - /* the event sink is not asserted in these tests */ -}; - -async function stageChildSources(defId: string): Promise { - const dir = path.join(childSourcesDataDir, "assets", "workflow", defId); - await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile( - path.join(dir, "sources.json"), - JSON.stringify({ - s: [ - { - id: "anthropic:m", - provider: "anthropic", - baseURL: "http://localhost:1", - credentialId: "sk-x", - model: "m", - }, - ], - }), - ); -} - -beforeAll(async () => { - signingKey = await generateKeyPair(); - childSourcesDataDir = await makeTempDir("child-grants-assets-"); - await stageChildSources("child-wf"); - await stageChildSources("grandchild-wf"); -}); - -afterAll(async () => { - for (const d of tempDirs.splice(0)) { - await fs.promises.rm(d, { recursive: true, force: true }).catch(() => { - /* best effort */ - }); - } -}); - -async function makeTempDir(prefix: string): Promise { - const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(d); - return d; -} - -function grant(resource: string, action: string): GrantRule { - return { - id: `grant-${resource}-${action}`, - resource, - action, - effect: "allow", - origin: "creator", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }; -} - -// The one-step child definition every spawn in these tests runs. Its single -// agent is toolless and declares the anthropic:m inference source, so -// `DECLARED_RESOURCE` is the one grant the child body declares. -const CHILD_STEP_AGENT_ID = "wallet-spend"; -function childDefinition(id: string): WorkflowDefinition { - const agent = defineAgent({ - id: CHILD_STEP_AGENT_ID, - systemPrompt: "s", - tools: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "m" }] }, - }); - return defineWorkflow({ - id, - trigger: { type: "manual" }, - steps: { s: step({ agent }) }, - }); -} - -// Grant evaluator that delegates the decision to `@intx/authz` against the -// credentials snapshot's grants alone. Unlike the production adapter it does -// NOT merge any per-step tool-mark floor grants, so a decision here reflects -// only the capped grant set the child inherited. -const evaluateGrantsAdapter: SidecarRunChildDepsEvaluator = async ({ - resource, - action, - grants, -}) => { - const result = await evaluateGrants( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the snapshot's grants are typed unknown[] at the workflow-host boundary; the sidecar owns the GrantRule grammar, so the seeded rows narrow here - [...(grants as readonly GrantRule[])], - resource, - action, - ); - return { effect: result.effect, matchingGrants: [], resolvedBy: null }; -}; -type SidecarRunChildDepsEvaluator = Parameters[0]["evaluateGrants"]; - -// Build a real on-disk workflow-run substrate and seed its genesis tree. -async function makeSubstrate(prefix: string): Promise> { - const dataDir = await makeTempDir(prefix); - const substrate = createRepoStore({ - dataDir, - signingKey, - handlers: { "workflow-run": workflowRunKindHandler }, - authorize: allowAll, - }); - await substrate.writeTree({ kind: "hub" }, WORKFLOW_RUN_REPO_ID, REF, { - files: { [WORKFLOW_RUN_GITIGNORE_PATH]: "" }, - message: "genesis", - }); - return substrate; -} - -// Seed a run's grants file the way the hub's `run.grants` delivery does: a -// single `runs//grants.json` under the workflow-run repo. -async function seedRunGrants( - substrate: ReturnType, - runId: string, - grants: readonly GrantRule[], -): Promise { - await substrate.writeTree({ kind: "hub" }, WORKFLOW_RUN_REPO_ID, REF, { - files: { - [runGrantsPath(runId)]: JSON.stringify({ grants }, null, 2), - }, - message: `seed grants for ${runId}`, - }); -} - -// An invoker that authorizes a fixed list of resources against the child's env -// authorize and records each decision, so a test can assert exactly which -// inherited (capped) grants the child holds. -function recordingInvoker( - record: { decisions: { resource: string; effect: string | null }[] }, - probeResources: readonly string[], -): SidecarChildStepInvoker { - return async (req, authorize, _sourcesRef, _onEvent): Promise => { - for (const resource of probeResources) { - const decision = await authorize(resource, "invoke", req.authzContext); - record.decisions.push({ resource, effect: decision.effect }); - } - return { output: null }; - }; -} - -function makeRunChild( - substrate: ReturnType, - invokeStep: SidecarChildStepInvoker, -): ReturnType { - return createSidecarRunChild({ - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - workflowRunRef: REF, - principal: PRINCIPAL, - scheduler: createInMemoryScheduler({ - repoStore: createInMemoryRepoStore(), - clock: () => new Date(), - }), - invokeStep, - evaluateGrants: evaluateGrantsAdapter, - dataDir: childSourcesDataDir, - bodySources: {}, - credentialProviders: createCredentialProviderRegistry(builtinCredentialProviders()), - }); -} - -describe("createSidecarRunChild grant capping", () => { - test("a child's inherited grants are capped at what its body declares", async () => { - const substrate = await makeSubstrate("child-grants-cap-"); - const parentRunId = "run-parent"; - // Parent holds a grant the child body declares and one it does not. - await seedRunGrants(substrate, parentRunId, [ - grant(DECLARED_RESOURCE, "invoke"), - grant(UNDECLARED_RESOURCE, "invoke"), - ]); - - const record = { - decisions: [] as { resource: string; effect: string | null }[], - }; - const runChild = makeRunChild( - substrate, - recordingInvoker(record, [DECLARED_RESOURCE, UNDECLARED_RESOURCE]), - ); - - const childRunId = "run-child"; - const result = await runChild( - { - definition: childDefinition("child-wf"), - definitionRef: REF, - childRunId, - input: null, - parentRunId, - parentStepId: "s", - signal: new AbortController().signal, - depth: 1, - maxChildSpawnDepth: 32, - }, - noopOnEvent, - ); - - expect(result.terminalStatus).toBe("completed"); - // The declared resource resolves `allow`; the undeclared one was dropped - // from the child's inherited set and resolves fail-closed `null`. The - // positive control (declared -> allow) proves the null is a genuine cap, - // not an empty grant view that would deny everything. - expect(record.decisions).toEqual([ - { resource: DECLARED_RESOURCE, effect: "allow" }, - { resource: UNDECLARED_RESOURCE, effect: null }, - ]); - // The child persisted ONLY the declared grant as its own file, so a - // grandchild inherits the capped set rather than the raw parent set. - const childGrants = await readRunGrants({ - repoStore: substrate, - anchorRunId: DEPLOYMENT_ID, - runId: childRunId, - }); - expect(childGrants).toEqual([grant(DECLARED_RESOURCE, "invoke")]); - }); - - test("the grandchild ceiling is the capped set, not the raw parent set", async () => { - const substrate = await makeSubstrate("child-grants-multihop-"); - const parentRunId = "run-parent"; - await seedRunGrants(substrate, parentRunId, [ - grant(DECLARED_RESOURCE, "invoke"), - grant(UNDECLARED_RESOURCE, "invoke"), - ]); - - const record = { - decisions: [] as { resource: string; effect: string | null }[], - }; - const runChild = makeRunChild( - substrate, - recordingInvoker(record, [DECLARED_RESOURCE, UNDECLARED_RESOURCE]), - ); - - // Hop 1: parent -> child. Writes runs/run-child/grants.json (capped). - const childRunId = "run-child"; - await runChild( - { - definition: childDefinition("child-wf"), - definitionRef: REF, - childRunId, - input: null, - parentRunId, - parentStepId: "s", - signal: new AbortController().signal, - depth: 1, - maxChildSpawnDepth: 32, - }, - noopOnEvent, - ); - - // Hop 2: child -> grandchild. The grandchild's parent is the child, so it - // reads the child's capped grants file as its ceiling. - const grandchildRunId = "run-grandchild"; - const grandResult = await runChild( - { - definition: childDefinition("grandchild-wf"), - definitionRef: REF, - childRunId: grandchildRunId, - input: null, - parentRunId: childRunId, - parentStepId: "s", - signal: new AbortController().signal, - depth: 1, - maxChildSpawnDepth: 32, - }, - noopOnEvent, - ); - - expect(grandResult.terminalStatus).toBe("completed"); - // Both hops authorize the declared resource `allow`; the undeclared one is - // absent at BOTH hops -- dropped at hop 1 and never reachable at hop 2, - // because the grandchild's ceiling is the child's capped file. - expect(record.decisions).toEqual([ - { resource: DECLARED_RESOURCE, effect: "allow" }, - { resource: UNDECLARED_RESOURCE, effect: null }, - { resource: DECLARED_RESOURCE, effect: "allow" }, - { resource: UNDECLARED_RESOURCE, effect: null }, - ]); - const grandchildGrants = await readRunGrants({ - repoStore: substrate, - anchorRunId: DEPLOYMENT_ID, - runId: grandchildRunId, - }); - expect(grandchildGrants).toEqual([grant(DECLARED_RESOURCE, "invoke")]); - }); - - test("an existing child grants file is read back, not recomputed (write-once)", async () => { - const substrate = await makeSubstrate("child-grants-write-once-"); - const parentRunId = "run-parent"; - // The parent holds the grant the child body declares, so a RECOMPUTE would - // cap to and persist `[DECLARED_RESOURCE]`. - await seedRunGrants(substrate, parentRunId, [grant(DECLARED_RESOURCE, "invoke")]); - - // Pre-seed the CHILD's own grants file with a sentinel the cap would never - // produce (the child body declares `anthropic:m`, not this). A run's - // authorization ceiling is fixed at birth, so a re-spawn against the same - // childRunId must READ THIS BACK rather than recompute -- the property that - // keeps a resume re-drive from racing/clobbering the run's event subtree. - const childRunId = "run-child"; - const sentinel = grant("inference.source:sentinel:preseeded", "invoke"); - await seedRunGrants(substrate, childRunId, [sentinel]); - - const record = { - decisions: [] as { resource: string; effect: string | null }[], - }; - const runChild = makeRunChild(substrate, recordingInvoker(record, [DECLARED_RESOURCE])); - - const result = await runChild( - { - definition: childDefinition("child-wf"), - definitionRef: REF, - childRunId, - input: null, - parentRunId, - parentStepId: "s", - signal: new AbortController().signal, - depth: 1, - maxChildSpawnDepth: 32, - }, - noopOnEvent, - ); - - expect(result.terminalStatus).toBe("completed"); - // The persisted file is untouched -- the sentinel, not a recomputed - // `[DECLARED_RESOURCE]`. - const childGrants = await readRunGrants({ - repoStore: substrate, - anchorRunId: DEPLOYMENT_ID, - runId: childRunId, - }); - expect(childGrants).toEqual([sentinel]); - // The child authorized against the read-back sentinel, so the body's - // declared resource -- which the sentinel does NOT cover -- fails closed. - expect(record.decisions).toEqual([{ resource: DECLARED_RESOURCE, effect: null }]); - }); - - test("a child whose parent has no grants file fails closed at spawn", async () => { - const substrate = await makeSubstrate("child-grants-absent-"); - // No grants file seeded for the parent run. - const parentRunId = "run-parent-ungranted"; - - const record = { - decisions: [] as { resource: string; effect: string | null }[], - }; - const runChild = makeRunChild(substrate, recordingInvoker(record, [DECLARED_RESOURCE])); - - await expect( - runChild( - { - definition: childDefinition("child-wf"), - definitionRef: REF, - childRunId: "run-child", - input: null, - parentRunId, - parentStepId: "s", - signal: new AbortController().signal, - depth: 1, - maxChildSpawnDepth: 32, - }, - noopOnEvent, - ), - ).rejects.toThrow(/has no grants file/); - // The child never ran a step, so no authorize decision was recorded. - expect(record.decisions).toEqual([]); - }); -}); diff --git a/apps/sidecar/src/workflow-substrate-factory-parked-approval.test.ts b/apps/sidecar/src/workflow-substrate-factory-parked-approval.test.ts deleted file mode 100644 index 81571f6fd..000000000 --- a/apps/sidecar/src/workflow-substrate-factory-parked-approval.test.ts +++ /dev/null @@ -1,395 +0,0 @@ -// `loadParkedApproval` durable reads: the sidecar binding recovers a parked -// correlation's approval snapshot for the child's re-registration enumeration. -// -// Two layouts, verified against the production read helpers: -// - COLD (multi-step): the snapshot lives in the per-attempt isogit step -// store on disk; the read loads it, and returns undefined (without -// manufacturing a repo) when the store dir is absent. -// - WARM (single-step): the snapshot lives in the durable conversation -// store, mirrored to the workflow-run substrate under `agent-state//`. -// The read reconstructs it from the substrate WITHOUT going through the live -// registry -- proving a respawned child (whose live store is unbuilt) still -// recovers the snapshot. - -import { describe, test, expect } from "bun:test"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import type { ApprovalSnapshot, PendingOperation } from "@intx/types/runtime"; -import type { Principal, RepoId, RepoStore } from "@intx/hub-sessions/substrate"; -import { createIsogitStore } from "@intx/storage-isogit/node"; - -import { - readColdParkedApprovalSnapshot, - readColdParkedPendingOperations, - readWarmParkedApprovalSnapshot, - readWarmParkedPendingOperations, - stepStorageRoot, - toParkedApprovalOps, -} from "./workflow-substrate-factory"; -import { createDurableConversationStore } from "./conversation-state"; - -const WORKFLOW_RUN_REPO_ID: RepoId = { - kind: "workflow-run", - id: "parked-approval", -}; -const WORKFLOW_RUN_REF = "refs/heads/main"; -const PRINCIPAL: Principal = { kind: "workflow-process" }; -const EMPTY_USAGE = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - thinking: 0, -}; - -const snapshot: ApprovalSnapshot = { - name: "charge_card", - description: "Charge the customer's card", - inputSchema: { type: "object" }, - arguments: { amount: 100 }, -}; - -function pendingApproval( - correlationId: string, - approvalSnapshot?: ApprovalSnapshot, -): PendingOperation { - return { - correlationId, - kind: "approval", - registeredAt: 0, - gateId: `gate-${correlationId}`, - ...(approvalSnapshot !== undefined ? { approvalSnapshot } : {}), - }; -} - -async function makeTempDir(): Promise { - return fs.mkdtemp(path.join(os.tmpdir(), "parked-approval-")); -} - -const testSigner = (payload: string): Promise => Promise.resolve(`sig:${payload.length}`); - -describe("readColdParkedApprovalSnapshot", () => { - test("loads the snapshot from a parked step's on-disk store", async () => { - const dataDir = await makeTempDir(); - const coordinate = { - dataDir, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - runId: "run-1", - stepId: "s", - attempt: 1, - }; - const store = await createIsogitStore(stepStorageRoot(coordinate), testSigner); - await store.writeMetadata({ - pendingOperations: [pendingApproval("corr-1", snapshot)], - tokenUsage: EMPTY_USAGE, - }); - - const got = await readColdParkedApprovalSnapshot({ - ...coordinate, - correlationId: "corr-1", - }); - expect(got).toEqual(snapshot); - }); - - test("returns undefined without creating a repo when the store is absent", async () => { - const dataDir = await makeTempDir(); - const coordinate = { - dataDir, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - runId: "missing", - stepId: "s", - attempt: 1, - }; - - const got = await readColdParkedApprovalSnapshot({ - ...coordinate, - correlationId: "corr-x", - }); - expect(got).toBeUndefined(); - // The read is a read: a missing store is never manufactured on disk. - await expect(fs.stat(stepStorageRoot(coordinate))).rejects.toThrow(); - }); - - test("returns undefined for a correlation with no matching pending op", async () => { - const dataDir = await makeTempDir(); - const coordinate = { - dataDir, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - runId: "run-2", - stepId: "s", - attempt: 1, - }; - const store = await createIsogitStore(stepStorageRoot(coordinate), testSigner); - await store.writeMetadata({ - pendingOperations: [pendingApproval("corr-a", snapshot)], - tokenUsage: EMPTY_USAGE, - }); - - const got = await readColdParkedApprovalSnapshot({ - ...coordinate, - correlationId: "corr-other", - }); - expect(got).toBeUndefined(); - }); - - test("returns undefined for a matching op that carries no snapshot", async () => { - const dataDir = await makeTempDir(); - const coordinate = { - dataDir, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - runId: "run-3", - stepId: "s", - attempt: 1, - }; - const store = await createIsogitStore(stepStorageRoot(coordinate), testSigner); - await store.writeMetadata({ - pendingOperations: [pendingApproval("corr-1")], - tokenUsage: EMPTY_USAGE, - }); - - const got = await readColdParkedApprovalSnapshot({ - ...coordinate, - correlationId: "corr-1", - }); - expect(got).toBeUndefined(); - }); -}); - -/** - * Read every file under `/` into a path->bytes map, keyed by - * the repo-relative path, so a `writeTreePreservingPrefix` merge callback sees - * the prior subtree the way the real substrate presents it. - */ -async function readPrefixEntries( - repoDir: string, - prefix: string, -): Promise> { - const entries = new Map(); - const prefixDir = path.join(repoDir, prefix); - let names: string[]; - try { - names = await fs.readdir(prefixDir, { recursive: true }); - } catch { - return entries; - } - for (const name of names) { - const full = path.join(prefixDir, name); - if (!(await fs.stat(full)).isFile()) continue; - entries.set(`${prefix}${name}`, await fs.readFile(full)); - } - return entries; -} - -/** - * A substrate stub that persists `writeTreePreservingPrefix` to disk under - * `getRepoDir`, so a durable-conversation mirror round-trips through the real - * checkpoint/WAL layout the read reconstructs from. Any other method surfaces - * as a precise failure. - */ -function createStubSubstrate(baseDir: string): RepoStore { - const repoDirFor = (repoId: RepoId): string => path.join(baseDir, repoId.kind, repoId.id); - const stub: Partial = { - getRepoDir: repoDirFor, - async writeTreePreservingPrefix(_principal, repoId, _ref, args) { - const repoDir = repoDirFor(repoId); - const existing = await readPrefixEntries(repoDir, args.preservePrefix); - const merged = await args.merge(existing); - await fs.rm(path.join(repoDir, args.preservePrefix), { - recursive: true, - force: true, - }); - for (const [relPath, content] of Object.entries(merged)) { - const full = path.join(repoDir, relPath); - await fs.mkdir(path.dirname(full), { recursive: true }); - await fs.writeFile(full, content); - } - return { commitSha: "deadbeefcafef00d", newlyTerminalRuns: [] }; - }, - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub; missing methods surface as a precise failure via the proxy - return new Proxy(stub as RepoStore, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (value !== undefined) return value; - return () => { - throw new Error(`stub substrate: ${String(prop)} not implemented for this test`); - }; - }, - }); -} - -describe("readWarmParkedApprovalSnapshot", () => { - test("reconstructs the snapshot from the durable substrate mirror", async () => { - const substrate = createStubSubstrate(await makeTempDir()); - const store = await createDurableConversationStore({ - localStoreDir: await makeTempDir(), - signer: testSigner, - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - workflowRunRef: WORKFLOW_RUN_REF, - principal: PRINCIPAL, - agentKey: "s", - }); - // Suspend-time state: the parked approval's pending op, then mirrored to - // the substrate at the run boundary. No live registry is involved on the - // read side, mirroring a respawned child whose warm store is unbuilt. - await store.storage.writeMetadata({ - pendingOperations: [pendingApproval("corr-1", snapshot)], - tokenUsage: EMPTY_USAGE, - }); - await store.mirrorToSubstrate(); - - const got = await readWarmParkedApprovalSnapshot({ - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - stepId: "s", - correlationId: "corr-1", - }); - expect(got).toEqual(snapshot); - }); - - test("returns undefined when no durable state exists for the agent", async () => { - const substrate = createStubSubstrate(await makeTempDir()); - - const got = await readWarmParkedApprovalSnapshot({ - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - stepId: "never-ran", - correlationId: "corr-x", - }); - expect(got).toBeUndefined(); - }); - - test("returns undefined for a matching op that carries no snapshot", async () => { - const substrate = createStubSubstrate(await makeTempDir()); - const store = await createDurableConversationStore({ - localStoreDir: await makeTempDir(), - signer: testSigner, - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - workflowRunRef: WORKFLOW_RUN_REF, - principal: PRINCIPAL, - agentKey: "s", - }); - await store.storage.writeMetadata({ - pendingOperations: [pendingApproval("corr-1")], - tokenUsage: EMPTY_USAGE, - }); - await store.mirrorToSubstrate(); - - const got = await readWarmParkedApprovalSnapshot({ - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - stepId: "s", - correlationId: "corr-1", - }); - expect(got).toBeUndefined(); - }); -}); - -// The pending-operations readers back the resume classifier's `readParkedApprovalOps` -// binding (the crash-mid-park recovery hook), where the snapshot readers back the -// re-registration enumeration. Both project the same durable store; these pin the -// enumeration variant returns every parked op, not just one matched by correlationId. -describe("readColdParkedPendingOperations", () => { - test("returns every pending operation from a parked step's on-disk store", async () => { - const dataDir = await makeTempDir(); - const coordinate = { - dataDir, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - runId: "run-1", - stepId: "s", - attempt: 1, - }; - const store = await createIsogitStore(stepStorageRoot(coordinate), testSigner); - await store.writeMetadata({ - pendingOperations: [pendingApproval("corr-1", snapshot)], - tokenUsage: EMPTY_USAGE, - }); - - const got = await readColdParkedPendingOperations(coordinate); - expect(got).toEqual([pendingApproval("corr-1", snapshot)]); - }); - - test("returns an empty list without creating a repo when the store is absent", async () => { - const dataDir = await makeTempDir(); - const coordinate = { - dataDir, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - runId: "missing", - stepId: "s", - attempt: 1, - }; - - const got = await readColdParkedPendingOperations(coordinate); - expect(got).toEqual([]); - await expect(fs.stat(stepStorageRoot(coordinate))).rejects.toThrow(); - }); -}); - -describe("readWarmParkedPendingOperations", () => { - test("reconstructs the pending operations from the durable substrate mirror", async () => { - const substrate = createStubSubstrate(await makeTempDir()); - const store = await createDurableConversationStore({ - localStoreDir: await makeTempDir(), - signer: testSigner, - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - workflowRunRef: WORKFLOW_RUN_REF, - principal: PRINCIPAL, - agentKey: "s", - }); - await store.storage.writeMetadata({ - pendingOperations: [pendingApproval("corr-1", snapshot)], - tokenUsage: EMPTY_USAGE, - }); - await store.mirrorToSubstrate(); - - const got = await readWarmParkedPendingOperations({ - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - stepId: "s", - }); - expect(got).toEqual([pendingApproval("corr-1", snapshot)]); - }); - - test("returns an empty list when no durable state exists for the agent", async () => { - const substrate = createStubSubstrate(await makeTempDir()); - - const got = await readWarmParkedPendingOperations({ - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - stepId: "never-ran", - }); - expect(got).toEqual([]); - }); -}); - -describe("toParkedApprovalOps", () => { - test("projects approval ops to correlationId plus the epoch-ms deadline", async () => { - const withDeadline: PendingOperation = { - correlationId: "corr-timeout", - kind: "approval", - registeredAt: 0, - gateId: "gate-corr-timeout", - timeoutAt: 1_700_000_000_000, - }; - - const got = toParkedApprovalOps([pendingApproval("corr-1", snapshot), withDeadline]); - - expect(got).toEqual([ - { correlationId: "corr-1" }, - { correlationId: "corr-timeout", timeoutAtMs: 1_700_000_000_000 }, - ]); - }); - - test("omits timeoutAtMs for an indefinite-hold park", async () => { - const got = toParkedApprovalOps([pendingApproval("corr-1")]); - expect(got).toEqual([{ correlationId: "corr-1" }]); - const first = got[0]; - if (first === undefined) throw new Error("unreachable"); - expect("timeoutAtMs" in first).toBe(false); - }); -}); diff --git a/apps/sidecar/src/workflow-substrate-factory-step-storage.test.ts b/apps/sidecar/src/workflow-substrate-factory-step-storage.test.ts deleted file mode 100644 index 50cb92eb1..000000000 --- a/apps/sidecar/src/workflow-substrate-factory-step-storage.test.ts +++ /dev/null @@ -1,364 +0,0 @@ -// Per-step scratch keying for `createSidecarStepBuildEnv` (#3 leak fix). -// -// `stepStorageRoot` rooted every step invocation's workspace + tool -// scratch under the per-message `runId`, and nothing ever reclaimed it, -// so a long-lived deployment's `workflow-step-state/` grew without -// bound. The fix keys the warm single-step agent's scratch STABLY per -// agent (so the cached agent reuses one workspace across runs and the -// warm case is bounded to one dir per agent) while the cold/multi-step -// path keeps its per-run keying (reclaimed at run completion / undeploy -// elsewhere). -// -// These tests pin the keying directly off the production `buildEnv` the -// substrate factory wires: -// - WARM (`durableConversation` present): two different runIds produce -// the SAME `env.workdir`, and a file written for run-1 is visible in -// the env built for run-2 -- the workspace-continuity the stable key -// buys. -// - COLD (no `durableConversation`): two different runIds produce -// DIFFERENT `env.workdir`s, each under that run's subtree -- the -// per-run keying the run-completion cleanup reclaims. - -import { describe, test, expect } from "bun:test"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import type { - AuditStore, - ContextStore, - InferenceSource, - PendingOperation, - ToolCall, -} from "@intx/types/runtime"; -import type { RepoId } from "@intx/hub-sessions"; -import { createBuiltinRegistry } from "@intx/inference/providers"; -import { createIsogitStore } from "@intx/storage-isogit/node"; -import type { ChildOutboundMailBridge, SourcesSnapshotRef, StepEnvBase } from "@intx/workflow-host"; -import { scopedStepId, type StepInvokeRequest } from "@intx/workflow"; - -import { - createSidecarStepBuildEnv, - stepStorageRoot, - type SidecarStepBuildEnvDeps, -} from "./workflow-substrate-factory"; -import type { DurableConversationRegistry } from "./conversation-state"; - -const STEP_ID = "step-1"; -const WORKFLOW_RUN_REPO_ID: RepoId = { - kind: "workflow-run", - id: "deployment-keying", -}; - -const SOURCE: InferenceSource = { - id: STEP_ID, - provider: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: "sk-keying", - model: "claude-keying", -}; - -function stubOutboundMailBridge(): ChildOutboundMailBridge { - return { - submit: () => Promise.reject(new Error("unused in buildEnv keying test")), - handleResult: () => undefined, - cancelAll: () => undefined, - pendingCount: 0, - }; -} - -// A warm `durableConversation` whose `acquire(stepId)` returns a storage -// the env builder files into `env.storage`. `buildEnv` never invokes the -// store's methods, so a structural double-cast stub is sufficient and is -// the documented test-stub escape hatch for a wide library interface. -function stubDurableConversationRegistry(): DurableConversationRegistry { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- buildEnv only files `.storage` into the env; it never calls the store's methods, so a structural stub cannot be satisfied field-by-field - const storage = {} as ContextStore & AuditStore; - const store = { - storage, - mirrorToSubstrate: () => Promise.resolve(), - restoreFromSubstrate: () => Promise.resolve(), - }; - return { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the registry's full DurableConversationStore surface is not exercised by buildEnv - acquire: () => Promise.resolve(store as never), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- same: only `.storage` is read - get: () => store as never, - }; -} - -function buildDeps(opts: { - dataDir: string; - durableConversation?: DurableConversationRegistry; -}): SidecarStepBuildEnvDeps { - return { - dataDir: opts.dataDir, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - signer: (payload: string) => Promise.resolve(`sig:${payload.length}`), - mailboxAddress: "run_deployment-keying@example.com", - stepCount: 1, - outboundMailBridge: stubOutboundMailBridge(), - cache: { cacheMaxBytes: 1_000_000, registryMaxTarballBytes: 1_000_000 }, - adapters: createBuiltinRegistry(), - recordToolMarkFloor: () => undefined, - sourceTools: false, - ...(opts.durableConversation !== undefined - ? { durableConversation: opts.durableConversation } - : {}), - }; -} - -function sourcesRefFor(chain: InferenceSource[] = [SOURCE]): SourcesSnapshotRef { - return { current: { [STEP_ID]: chain } }; -} - -function requestForRun(runId: string): StepInvokeRequest { - return { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- buildEnv reads only authzContext; the agent definition is never consulted here - agent: {} as StepInvokeRequest["agent"], - input: null, - authzContext: { stepId: STEP_ID, runId, attempt: 1 }, - signal: new AbortController().signal, - }; -} - -async function makeTempDir(): Promise { - return fs.mkdtemp(path.join(os.tmpdir(), "sidecar-step-keying-")); -} - -describe("createSidecarStepBuildEnv per-step scratch keying", () => { - test("warm path keys one stable workdir across runs and preserves files", async () => { - const dataDir = await makeTempDir(); - const buildEnv = createSidecarStepBuildEnv( - buildDeps({ - dataDir, - durableConversation: stubDurableConversationRegistry(), - }), - ); - - const sourcesRef = sourcesRefFor(); - const env1: StepEnvBase = await buildEnv(requestForRun("run-1"), sourcesRef); - // A file the agent would write during run-1's turn. - const marker = path.join(env1.workdir, "turn-1.txt"); - await fs.writeFile(marker, "carried"); - - const env2: StepEnvBase = await buildEnv(requestForRun("run-2"), sourcesRef); - - // Stable keying: a different runId resolves to the SAME workdir, so - // the warm case is bounded to one dir per agent (not one-per-message) - // and the workspace survives across runs/respawn. - expect(env2.workdir).toBe(env1.workdir); - // The warm workdir lives under the stable `warm//` sub-root, - // never under any run's `runs//` subtree. - expect(env1.workdir).toContain( - path.join("workflow-step-state", WORKFLOW_RUN_REPO_ID.id, "warm"), - ); - expect(env1.workdir).not.toContain(path.join("runs", "run-1")); - // Continuity: the file written in run-1 is visible in run-2's env. - expect(await fs.readFile(path.join(env2.workdir, "turn-1.txt"), "utf8")).toBe("carried"); - }); - - test("cold path keys a distinct per-run workdir under that run's subtree", async () => { - const dataDir = await makeTempDir(); - const buildEnv = createSidecarStepBuildEnv(buildDeps({ dataDir })); - const sourcesRef = sourcesRefFor(); - - const env1: StepEnvBase = await buildEnv(requestForRun("run-1"), sourcesRef); - const env2: StepEnvBase = await buildEnv(requestForRun("run-2"), sourcesRef); - - // Per-run keying: each run gets its own workdir, rooted under that - // run's `runs//` subtree -- exactly what the run-completion - // cleanup reclaims at run granularity. - expect(env2.workdir).not.toBe(env1.workdir); - expect(env1.workdir).toContain( - path.join("workflow-step-state", WORKFLOW_RUN_REPO_ID.id, "runs", "run-1"), - ); - expect(env2.workdir).toContain(path.join("runs", "run-2")); - // The cold path never parks scratch under the warm sub-root. - expect(env1.workdir).not.toContain(path.join("warm", STEP_ID)); - }); - - test("feeds the reactor the whole failover chain with the head pinned as default", async () => { - // The reactor resolves its initial source by `defaultSource` and fails - // over forward across `sources`; the child must hand it the full ordered - // chain, not just the active source, or cross-provider failover is lost. - const failoverSource: InferenceSource = { - id: "failover", - provider: "openai", - baseURL: "https://api.openai.com", - credentialId: "sk-failover", - model: "gpt-failover", - }; - const chain = [SOURCE, failoverSource]; - const dataDir = await makeTempDir(); - const buildEnv = createSidecarStepBuildEnv(buildDeps({ dataDir })); - - const env: StepEnvBase = await buildEnv(requestForRun("run-1"), sourcesRefFor(chain)); - - // The whole chain reaches the reactor, ordered, with element 0 as the - // initial source. - expect(env.sources).toEqual(chain); - expect(env.defaultSource).toBe(SOURCE.id); - }); - - test("resolves sources from the mutable ref at build time", async () => { - // The build reads the source table through the ref, so a rotation that - // writes the ref before a build is reflected in the agent that build - // constructs (the cold-window path; a warm already-built agent swaps - // sources through the warm cache, not here). - const dataDir = await makeTempDir(); - const buildEnv = createSidecarStepBuildEnv(buildDeps({ dataDir })); - const sourcesRef = sourcesRefFor(); - - const before: StepEnvBase = await buildEnv(requestForRun("run-1"), sourcesRef); - expect(before.sources).toEqual([SOURCE]); - - const rotated: InferenceSource = { - id: "rotated", - provider: "openai", - baseURL: "https://api.openai.com", - credentialId: "sk-rotated", - model: "gpt-rotated", - }; - sourcesRef.current = { [STEP_ID]: [rotated] }; - - const after: StepEnvBase = await buildEnv(requestForRun("run-2"), sourcesRef); - expect(after.sources).toEqual([rotated]); - expect(after.defaultSource).toBe(rotated.id); - }); - - test("a map iteration resolves the base step's source while keeping scratch scoped", async () => { - // Deploy pins one source per base step, keyed by the base step id; a map - // iteration runs under a scoped step id `[]`. The source - // table holds only the base id, so without base resolution the build - // would throw "no InferenceSource pinned". The scratch, by contrast, - // stays keyed by the scoped id so concurrent iterations never collide. - const dataDir = await makeTempDir(); - const buildEnv = createSidecarStepBuildEnv(buildDeps({ dataDir })); - const scopedId = scopedStepId(STEP_ID, 0); - - const scopedRequest: StepInvokeRequest = { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- buildEnv reads only authzContext; the agent definition is never consulted here - agent: {} as StepInvokeRequest["agent"], - input: null, - authzContext: { stepId: scopedId, runId: "run-1", attempt: 1 }, - signal: new AbortController().signal, - }; - const env: StepEnvBase = await buildEnv(scopedRequest, sourcesRefFor()); - - // The scoped iteration resolves the base step's pinned source. - expect(env.sources).toEqual([SOURCE]); - expect(env.defaultSource).toBe(SOURCE.id); - // Per-iteration scratch stays keyed by the scoped id, not the base. - expect(env.workdir).toContain(path.join("steps", scopedId)); - }); -}); - -// A re-dispatchable ask-rail approval gate always carries a `suspendedCall` -// (the call to re-run on approval). An async-tool pending marker uses the -// same `kind: "approval"` but never sets `suspendedCall`, and on resume the -// reactor clears its gate WITHOUT re-dispatching. The cold-path resume -// keying assertion must therefore find a `suspendedCall`-bearing op for the -// resumed correlationId, mirroring the reactor's own discriminator. -const SUSPENDED_CALL: ToolCall = { - id: "call-1", - name: "charge_card", - arguments: { amount: 100 }, -}; - -function approvalOp( - correlationId: string, - opts: { suspendedCall?: ToolCall } = {}, -): PendingOperation { - return { - correlationId, - kind: "approval", - registeredAt: 0, - gateId: `gate-${correlationId}`, - ...(opts.suspendedCall !== undefined ? { suspendedCall: opts.suspendedCall } : {}), - }; -} - -async function seedColdStore( - dataDir: string, - runId: string, - pendingOperations: PendingOperation[], -): Promise { - const store = await createIsogitStore( - stepStorageRoot({ - dataDir, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - runId, - stepId: STEP_ID, - attempt: 1, - }), - (payload: string) => Promise.resolve(`sig:${payload.length}`), - ); - await store.writeMetadata({ - pendingOperations, - tokenUsage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - thinking: 0, - }, - }); -} - -function approvalResumeRequest(runId: string, correlationId: string): StepInvokeRequest { - return { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- buildEnv reads only authzContext and resume; the agent definition is never consulted here - agent: {} as StepInvokeRequest["agent"], - input: null, - authzContext: { stepId: STEP_ID, runId, attempt: 1 }, - resume: { - correlationId, - decision: { outcome: "approved" }, - kind: "approval", - }, - signal: new AbortController().signal, - }; -} - -describe("createSidecarStepBuildEnv cold-path approval-resume keying", () => { - test("rejects a resume whose matching pending op carries no suspendedCall", async () => { - const dataDir = await makeTempDir(); - // A pending op that matches the correlationId but is not a - // re-dispatchable gate (no suspendedCall) -- an async-tool marker, not - // the ask-rail approval the resume must find. - await seedColdStore(dataDir, "run-1", [approvalOp("corr-1")]); - const buildEnv = createSidecarStepBuildEnv(buildDeps({ dataDir })); - - await expect( - buildEnv(approvalResumeRequest("run-1", "corr-1"), sourcesRefFor()), - ).rejects.toThrow(/keying violation/); - }); - - test("accepts a resume that finds a suspendedCall-bearing approval gate", async () => { - const dataDir = await makeTempDir(); - await seedColdStore(dataDir, "run-1", [ - approvalOp("corr-1", { suspendedCall: SUSPENDED_CALL }), - ]); - const buildEnv = createSidecarStepBuildEnv(buildDeps({ dataDir })); - - const env: StepEnvBase = await buildEnv( - approvalResumeRequest("run-1", "corr-1"), - sourcesRefFor(), - ); - - expect(env.workdir).toContain(path.join("runs", "run-1")); - }); - - test("rejects a resume whose correlationId has no pending op at all", async () => { - const dataDir = await makeTempDir(); - // A real gate exists, but for a different correlationId: the resumed - // correlationId finds nothing -- the wrong-attempt forever-hang guard. - await seedColdStore(dataDir, "run-1", [approvalOp("other", { suspendedCall: SUSPENDED_CALL })]); - const buildEnv = createSidecarStepBuildEnv(buildDeps({ dataDir })); - - await expect( - buildEnv(approvalResumeRequest("run-1", "corr-1"), sourcesRefFor()), - ).rejects.toThrow(/keying violation/); - }); -}); diff --git a/apps/sidecar/src/workflow-substrate-factory-suspendable-child.test.ts b/apps/sidecar/src/workflow-substrate-factory-suspendable-child.test.ts deleted file mode 100644 index 7ca236be4..000000000 --- a/apps/sidecar/src/workflow-substrate-factory-suspendable-child.test.ts +++ /dev/null @@ -1,364 +0,0 @@ -// An onTrigger body child, spawned through the real sidecar seam, parks on -// an approval and resumes on the correlated grant. -// -// `createSidecarSpawnSuspendableChild` runs the body definition against a -// real on-disk workflow-run substrate and hands back the live handle -// `runOnTrigger` drives. This test exercises that handle end to end: the -// body step's injected `invokeStep` suspends as an approval, so the child -// runtime parks on `signalName(correlationId)`; `handle.next()` surfaces the -// park with the step's snapshot; `handle.resume` delivers the grant on the -// child's own signal channel, and the re-invoked step completes the run. -// -// A second case aborts the parent signal while the body is parked and proves -// the child cancels and the handle surfaces a terminal rather than hanging -- -// the abort threads through `handle.cancel` and the run settles. - -import { describe, test, expect, afterAll, beforeAll } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { generateKeyPair } from "@intx/crypto"; -import type { ApprovalSnapshot, KeyPair } from "@intx/types/runtime"; -import { defineAgent } from "@intx/agent"; -import { builtinCredentialProviders, createCredentialProviderRegistry } from "@intx/harness"; -import { evaluateGrants } from "@intx/authz"; -import type { GrantRule } from "@intx/authz"; -import { - createRepoStore, - workflowRunKindHandler, - WORKFLOW_RUN_GITIGNORE_PATH, -} from "@intx/hub-sessions"; -import type { AuthorizeFn, RepoId, WorkflowRunWorkflowProcessPrincipal } from "@intx/hub-sessions"; -import { - childWorkflow, - createInMemoryScheduler, - createInMemoryRepoStore, - defineWorkflow, - step, - type WorkflowDefinition, - type WorkflowEvent, -} from "@intx/workflow"; - -import { createWorkflowRunRepoStore } from "@intx/workflow-host"; - -import { - createSidecarSpawnSuspendableChild, - type SidecarChildStepInvoker, -} from "./workflow-substrate-factory"; -import { runGrantsPath } from "./run-grants"; - -const REF = "refs/heads/main"; -const DEPLOYMENT_ID = "deployment-suspendable-child"; -const WORKFLOW_RUN_REPO_ID: RepoId = { - kind: "workflow-run", - id: DEPLOYMENT_ID, -}; -const allowAll: AuthorizeFn = () => ({ allowed: true }); -const PRINCIPAL: WorkflowRunWorkflowProcessPrincipal = { - kind: "workflow-process", - anchorRunId: DEPLOYMENT_ID, -}; - -const BODY_STEP_AGENT_ID = "wallet-spend"; -const CORRELATION_ID = "corr-approval-1"; -const SNAPSHOT: ApprovalSnapshot = { - name: BODY_STEP_AGENT_ID, - description: "spend from the shared wallet", - inputSchema: { amount: "number" }, - arguments: { amount: 100 }, -}; - -const tempDirs: string[] = []; -let signingKey: KeyPair; -// `buildChildRunEnv` reads the body run's `assets/workflow//sources.json` -// eagerly; the suspend/resume mock invoker ignores inference, but the read still -// happens, so stage a minimal sources file for the body definition id. -let bodySourcesDataDir: string; - -beforeAll(async () => { - signingKey = await generateKeyPair(); - bodySourcesDataDir = await makeTempDir("suspendable-assets-"); - // The approval-park body ("body-wf") carries an agent step "s"; the - // depth body ("depth-body") carries only a childWorkflow step (no - // inference), but `buildChildRunEnv` reads its sources eagerly all the same. - await stageBodySources("body-wf"); - await stageBodySources("depth-body"); -}); - -async function stageBodySources(id: string): Promise { - const dir = path.join(bodySourcesDataDir, "assets", "workflow", id); - await fs.promises.mkdir(dir, { recursive: true }); - await fs.promises.writeFile( - path.join(dir, "sources.json"), - JSON.stringify({ - s: [ - { - id: "anthropic:m", - provider: "anthropic", - baseURL: "http://localhost:1", - credentialId: "sk-x", - model: "m", - }, - ], - }), - ); -} - -afterAll(async () => { - for (const d of tempDirs.splice(0)) { - await fs.promises.rm(d, { recursive: true, force: true }).catch(() => { - /* best effort */ - }); - } -}); - -async function makeTempDir(prefix: string): Promise { - const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(d); - return d; -} - -function grant(resource: string, action: string): GrantRule { - return { - id: `grant-${resource}-${action}`, - resource, - action, - effect: "allow", - origin: "creator", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }; -} - -// The one-step body definition every spawn runs. Its single step's injected -// invoker suspends as an approval on the first invocation and completes on -// the resume re-invocation. -function bodyDefinition(id: string): WorkflowDefinition { - const agent = defineAgent({ - id: BODY_STEP_AGENT_ID, - systemPrompt: "s", - tools: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "m" }] }, - }); - return defineWorkflow({ - id, - trigger: { type: "manual" }, - steps: { s: step({ agent }) }, - }); -} - -const evaluateGrantsAdapter: Parameters< - typeof createSidecarSpawnSuspendableChild ->[0]["evaluateGrants"] = async ({ resource, action, grants }) => { - const result = await evaluateGrants( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the snapshot's grants are typed unknown[] at the workflow-host boundary; the sidecar owns the GrantRule grammar, so the seeded rows narrow here - [...(grants as readonly GrantRule[])], - resource, - action, - ); - return { effect: result.effect, matchingGrants: [], resolvedBy: null }; -}; - -async function makeSubstrate(prefix: string): Promise> { - const dataDir = await makeTempDir(prefix); - const substrate = createRepoStore({ - dataDir, - signingKey, - handlers: { "workflow-run": workflowRunKindHandler }, - authorize: allowAll, - }); - await substrate.writeTree({ kind: "hub" }, WORKFLOW_RUN_REPO_ID, REF, { - files: { [WORKFLOW_RUN_GITIGNORE_PATH]: "" }, - message: "genesis", - }); - return substrate; -} - -async function seedRunGrants( - substrate: ReturnType, - runId: string, - grants: readonly GrantRule[], -): Promise { - await substrate.writeTree({ kind: "hub" }, WORKFLOW_RUN_REPO_ID, REF, { - files: { - [runGrantsPath(runId)]: JSON.stringify({ grants }, null, 2), - }, - message: `seed grants for ${runId}`, - }); -} - -// An invoker that suspends as an approval on its first invocation and, on the -// resume re-invocation, records the delivered decision and completes. -function suspendThenComplete(record: { resumeDecision?: unknown }): SidecarChildStepInvoker { - return async (req) => { - if (req.resume === undefined) { - return { - suspend: { - correlationId: CORRELATION_ID, - kind: "approval", - approvalSnapshot: SNAPSHOT, - }, - }; - } - record.resumeDecision = req.resume.decision; - return { output: { echoed: req.resume.decision } }; - }; -} - -function makeSpawner( - substrate: ReturnType, - invokeStep: SidecarChildStepInvoker, -): ReturnType { - return createSidecarSpawnSuspendableChild({ - substrate, - workflowRunRepoId: WORKFLOW_RUN_REPO_ID, - workflowRunRef: REF, - principal: PRINCIPAL, - scheduler: createInMemoryScheduler({ - repoStore: createInMemoryRepoStore(), - clock: () => new Date(), - }), - invokeStep, - evaluateGrants: evaluateGrantsAdapter, - dataDir: bodySourcesDataDir, - bodySources: {}, - credentialProviders: createCredentialProviderRegistry(builtinCredentialProviders()), - }); -} - -describe("createSidecarSpawnSuspendableChild", () => { - test("surfaces a body approval park, resumes on the grant, and completes", async () => { - const substrate = await makeSubstrate("suspendable-approval-"); - const parentRunId = "run-parent"; - await seedRunGrants(substrate, parentRunId, [grant(`tool:${BODY_STEP_AGENT_ID}`, "invoke")]); - - const record: { resumeDecision?: unknown } = {}; - const spawn = makeSpawner(substrate, suspendThenComplete(record)); - - const handle = await spawn( - { - definition: bodyDefinition("body-wf"), - definitionRef: REF, - childRunId: "run-body-0", - input: { text: "event-0" }, - parentRunId, - parentStepId: "section", - signal: new AbortController().signal, - depth: 0, - maxChildSpawnDepth: 32, - }, - () => undefined, - ); - - // The body step suspended -> the handle surfaces the approval park on the - // reserved correlation, carrying the step's snapshot. - const parked = await handle.next(); - expect(parked.kind).toBe("park"); - if (parked.kind !== "park") throw new Error("expected a park"); - expect(parked.park.correlationId).toBe(CORRELATION_ID); - expect(parked.park.approvalSnapshot).toEqual(SNAPSHOT); - - // Grant it: resume delivers the decision on the child's own signal - // channel, unblocking the parked step. - const decision = { outcome: "approved" as const, note: "ok" }; - await handle.resume(CORRELATION_ID, decision); - - // The re-invoked step ran with the delivered decision and the run - // completed. - const terminal = await handle.next(); - expect(terminal.kind).toBe("terminal"); - if (terminal.kind !== "terminal") throw new Error("expected a terminal"); - expect(terminal.terminalStatus).toBe("completed"); - expect(record.resumeDecision).toEqual(decision); - }); - - test("threads depth so a body's childWorkflow grandchild trips the ceiling", async () => { - const substrate = await makeSubstrate("suspendable-depth-"); - const parentRunId = "run-parent"; - await seedRunGrants(substrate, parentRunId, [grant("inference.source:anthropic:m", "invoke")]); - - // The invoker must never run: the depth guard fires before the grandchild's - // agent step is reached. - const spawn = makeSpawner(substrate, () => { - throw new Error("depth: no step should run before the guard fires"); - }); - - // Spawn the body AT depth 1 with the ceiling lowered to 1. The body runs at - // depth 1 (passed through unchanged), so its `childWorkflow` spawns the - // grandchild at depth 2 > 1 and trips the guard. WITHOUT depth threading the - // body would run at depth 0 and the grandchild at depth 1 <= 1 -- no trip -- - // so this failure proves the container's depth reached the body run. - const childRunId = "run-depth-body"; - const handle = await spawn( - { - definition: childSpawningBody(), - definitionRef: REF, - childRunId, - input: null, - parentRunId, - parentStepId: "section", - signal: new AbortController().signal, - depth: 1, - maxChildSpawnDepth: 1, - }, - () => undefined, - ); - - // The body's childWorkflow step failed loud, so the body run settles failed. - const terminal = await handle.next(); - expect(terminal.kind).toBe("terminal"); - if (terminal.kind !== "terminal") throw new Error("expected a terminal"); - expect(terminal.terminalStatus).toBe("failed"); - - // The guard fired before the body committed a ChildSpawned, and the - // StepFailed names the tripped depth 2 (body depth 1 + 1), not the depth 1 a - // reset-at-the-body-boundary would give. - const bodyEvents: readonly WorkflowEvent[] = await reader(substrate).read(childRunId); - expect(bodyEvents.some((e) => e.kind === "ChildSpawned")).toBe(false); - const stepFailed = bodyEvents.find((e) => e.kind === "StepFailed"); - if (stepFailed === undefined || stepFailed.kind !== "StepFailed") { - throw new Error(`body run ${childRunId} has no StepFailed event`); - } - expect(stepFailed.error.message).toContain( - 'childWorkflow spawn depth 2 exceeds the maximum 1 at step "spawn"', - ); - }); -}); - -// A body whose single step spawns a `childWorkflow` grandchild. The grandchild -// carries an agent step that is never reached: the depth guard fires first. -function childSpawningBody(): WorkflowDefinition { - const leaf = defineWorkflow({ - id: "depth-leaf", - trigger: { type: "manual" }, - steps: { - work: step({ - agent: defineAgent({ - id: "depth-leaf-agent", - systemPrompt: "s", - tools: [], - capabilities: [], - inference: { sources: [{ provider: "anthropic", model: "m" }] }, - }), - }), - }, - }); - return defineWorkflow({ - id: "depth-body", - trigger: { type: "manual" }, - steps: { spawn: childWorkflow({ definition: leaf }) }, - }); -} - -function reader(substrate: ReturnType) { - return createWorkflowRunRepoStore({ - substrate, - repoId: WORKFLOW_RUN_REPO_ID, - principal: PRINCIPAL, - ref: REF, - }); -} diff --git a/apps/web/bunfig.toml b/apps/web/bunfig.toml deleted file mode 100644 index 3471b992a..000000000 --- a/apps/web/bunfig.toml +++ /dev/null @@ -1,2 +0,0 @@ -[test] -preload = ["./src/dom-environment.ts"] diff --git a/apps/web/package.json b/apps/web/package.json index 8178130da..f8e860990 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,7 +39,6 @@ "react-joyride": "^2.9.3" }, "devDependencies": { - "@happy-dom/global-registrator": "^20.11.2", "@intx/inference": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@types/bun": "catalog:", diff --git a/apps/web/src/action-command-dispatch-race.test.tsx b/apps/web/src/action-command-dispatch-race.test.tsx deleted file mode 100644 index e20fd447a..000000000 --- a/apps/web/src/action-command-dispatch-race.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// Regression: firing a palette action off-route used to dispatch a "create" -// event before the target page's listener mounted. Now it goes through a -// pending-flag (pending-dialog-request.ts) the target page consumes on mount. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import { resetPendingDialogRequests, runActionCommand } from "./command-palette-actions"; -import { SkillsPage } from "./pages/skills-page"; -import { TestQueryProvider } from "./test-query-provider"; - -let container: HTMLDivElement | null = null; -let root: Root | null = null; -const realFetch = globalThis.fetch; - -afterEach(() => { - if (root) act(() => root?.unmount()); - container?.remove(); - container = null; - root = null; - globalThis.fetch = realFetch; - resetPendingDialogRequests(); -}); - -describe("runActionCommand off-route dispatch ordering", () => { - test("new-skill fired from another page opens the create dialog once the Skills section mounts", async () => { - // Palette invoked while on /library; the Skills settings section is not - // mounted yet, so no listener exists for "workbench:skills:create" the - // instant the command runs. - const navigated: string[] = []; - await act(async () => { - await runActionCommand("new-skill", { - path: "/library", - navigate: (to) => { - navigated.push(to); - }, - tenantId: "tenant-1", - cycleTheme: () => undefined, - closeCanvas: () => undefined, - }); - }); - expect(navigated).toEqual(["/skills"]); - - // Serve an empty skill-asset list so the test exercises the - // pending-flag path, not a network failure. - globalThis.fetch = (async () => - new Response(JSON.stringify([]), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch; - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - - - , - ); - }); - await act(async () => { - await Promise.resolve(); - }); - - // The create dialog (Radix, portaled to document.body) should have - // opened as a result of the pending flag the section consumed on mount. - expect(document.body.textContent).toContain("Create skill"); - }); -}); diff --git a/apps/web/src/activatable-row.test.ts b/apps/web/src/activatable-row.test.ts deleted file mode 100644 index 8e64967ac..000000000 --- a/apps/web/src/activatable-row.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { isAdditiveSelectClick, isRowActivationKey, rowActivationProps } from "./activatable-row"; - -// happy-dom reports `navigator.platform` as the host OS, so each test pins -// the platform it means to exercise to stay deterministic on any OS. -const originalPlatform = Object.getOwnPropertyDescriptor(navigator, "platform"); - -function stubPlatform(platform: string): void { - Object.defineProperty(navigator, "platform", { - configurable: true, - value: platform, - }); -} - -afterEach(() => { - if (originalPlatform !== undefined) { - Object.defineProperty(navigator, "platform", originalPlatform); - } -}); - -describe("isAdditiveSelectClick", () => { - test("cmd-click is additive", () => { - expect(isAdditiveSelectClick({ metaKey: true, ctrlKey: false })).toBe(true); - }); - - test("ctrl-click is not additive on Mac (it's the context-menu gesture)", () => { - stubPlatform("MacIntel"); - expect(isAdditiveSelectClick({ metaKey: false, ctrlKey: true })).toBe(false); - }); - - test("ctrl-click is additive on non-Mac", () => { - stubPlatform("Linux x86_64"); - expect(isAdditiveSelectClick({ metaKey: false, ctrlKey: true })).toBe(true); - }); - - test("a plain click is not additive", () => { - expect(isAdditiveSelectClick({ metaKey: false, ctrlKey: false })).toBe(false); - }); -}); - -describe("isRowActivationKey", () => { - test("Enter and Space activate", () => { - expect(isRowActivationKey("Enter")).toBe(true); - expect(isRowActivationKey(" ")).toBe(true); - }); - - test("other keys do not activate", () => { - expect(isRowActivationKey("Tab")).toBe(false); - expect(isRowActivationKey("a")).toBe(false); - }); -}); - -describe("rowActivationProps", () => { - test("carries a button role and a tab stop", () => { - const props = rowActivationProps(() => {}); - expect(props.role).toBe("button"); - expect(props.tabIndex).toBe(0); - }); - - test("Enter/Space fire onSelect and prevent default; other keys do not", () => { - let calls = 0; - const props = rowActivationProps(() => { - calls += 1; - }); - let prevented = false; - const event = (key: string) => - ({ - key, - preventDefault: () => { - prevented = true; - }, - }) as unknown as Parameters[0]; - - props.onKeyDown(event("Enter")); - expect(calls).toBe(1); - expect(prevented).toBe(true); - - prevented = false; - props.onKeyDown(event("a")); - expect(calls).toBe(1); - expect(prevented).toBe(false); - }); - - test("clicking fires onSelect", () => { - let calls = 0; - const props = rowActivationProps(() => { - calls += 1; - }); - props.onClick(); - expect(calls).toBe(1); - }); -}); diff --git a/apps/web/src/activatable-row.ts b/apps/web/src/activatable-row.ts index 3c2f2802f..63f46f21d 100644 --- a/apps/web/src/activatable-row.ts +++ b/apps/web/src/activatable-row.ts @@ -16,8 +16,8 @@ export function isAdditiveSelectClick(event: { } function isMacPlatform(): boolean { - // Browsers report "MacIntel"; happy-dom (our test DOM) reports - // "X11; Darwin arm64" — both are the same Ctrl-click-is-context-menu OS. + // Browsers report "MacIntel" or a string containing "Darwin"; both are + // the Ctrl-click-is-context-menu OS. return typeof navigator !== "undefined" && /mac|darwin/i.test(navigator.platform); } diff --git a/apps/web/src/agent-deploy.test.ts b/apps/web/src/agent-deploy.test.ts deleted file mode 100644 index 86acbc41c..000000000 --- a/apps/web/src/agent-deploy.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { MCP_TOOLS_PACKAGE } from "@corbits/myra/workflow-ids"; -import { describe, expect, test } from "bun:test"; - -import { - agentDeploySourceAssetName, - agentSlugFromSourceAssetName, - buildAgentDefinitionJson, - buildScheduledRunBody, - resolveMcpServerDeployments, -} from "./agent-deploy"; -import type { McpServer } from "./mcp-servers"; - -describe("buildAgentDefinitionJson", () => { - test("the JSON projection agrees with the bundle input it is pushed alongside", () => { - // pushAgentSource renders both from the same args, but nothing else - // typechecks that they describe the same run — this pins that they do. - const args = { - slug: "research-buddy", - systemPrompt: "You research things.", - triggerAddress: "research-buddy@example.test", - declaredSources: [{ provider: "anthropic", model: "claude-test" }], - hubCredentialId: "crd_000000000000000000000000000000ab", - mcpServers: [], - }; - const projection = buildAgentDefinitionJson(args) as { - id: string; - triggers: readonly { to: string }[]; - steps: Record; - }; - const buildInput = { - workflowId: args.slug, - triggerAddress: args.triggerAddress, - inferencePreferences: args.declaredSources, - systemPrompt: args.systemPrompt, - hubCredentialId: args.hubCredentialId, - }; - - expect(projection.id).toBe(buildInput.workflowId); - expect(projection.triggers[0]?.to).toBe(buildInput.triggerAddress); - expect(Object.values(projection.steps)[0]?.agent.systemPrompt).toBe(buildInput.systemPrompt); - }); -}); - -describe("resolveMcpServerDeployments", () => { - const linear: McpServer = { - credentialId: "c-linear", - providerId: "p-linear", - handle: "linear", - name: "Linear", - url: "https://mcp.linear.app/mcp", - auth: "token", - tools: [ - { - name: "list_issues", - description: "List issues", - inputSchema: {}, - annotations: { readOnlyHint: true }, - }, - { - name: "create_issue", - description: "Create an issue", - inputSchema: {}, - annotations: {}, - }, - ], - }; - - test("a chosen handle becomes a deployment of the catalog server", () => { - const [deployment] = resolveMcpServerDeployments([linear], ["linear"]); - expect(deployment?.handle).toBe("linear"); - expect(deployment?.credentialId).toBe("c-linear"); - // Ask-gated except the read-only-annotated tool. - expect(deployment?.allowWithoutAsk).toEqual(["linear.list_issues"]); - }); - - test("an unknown handle fails closed instead of deploying short a server", () => { - expect(() => resolveMcpServerDeployments([linear], ["asana"])).toThrow("asana"); - }); - - test("bindings and use requirements land in the definition JSON", () => { - const deployments = resolveMcpServerDeployments([linear], ["linear"]); - const projection = buildAgentDefinitionJson({ - slug: "linear-buddy", - systemPrompt: "You triage.", - triggerAddress: "linear-buddy@example.test", - declaredSources: [{ provider: "anthropic", model: "claude-test" }], - hubCredentialId: "crd_000000000000000000000000000000ab", - mcpServers: deployments, - }) as { - credentialBindings: readonly { package: string; handle: string }[]; - grantRequirements: readonly unknown[]; - }; - expect( - projection.credentialBindings.some( - (binding) => binding.package === MCP_TOOLS_PACKAGE && binding.handle === "linear", - ), - ).toBe(true); - expect(JSON.stringify(projection.grantRequirements)).toContain("c-linear"); - }); -}); - -describe("buildScheduledRunBody", () => { - test("asks the agent to mail the deploying person's address", () => { - const body = buildScheduledRunBody("alice@example.test"); - expect(body).toContain("alice@example.test"); - expect(body).toContain("`to` list"); - }); - - test("falls back to a bare reply instruction when no address is known", () => { - const body = buildScheduledRunBody(undefined); - expect(body).not.toContain("@"); - expect(body).toContain("Reply with the result."); - }); -}); - -describe("agentSlugFromSourceAssetName", () => { - test("recovers the slug agentDeploySourceAssetName wrapped", () => { - expect(agentSlugFromSourceAssetName(agentDeploySourceAssetName("echo-bot"))).toBe("echo-bot"); - }); - - test("is null for a name this pipeline didn't produce", () => { - expect(agentSlugFromSourceAssetName("echo-bot")).toBeNull(); - expect(agentSlugFromSourceAssetName("agent-agent-echo-bot-source-source")).toBe( - "agent-echo-bot-source", - ); - }); -}); diff --git a/apps/web/src/approval-actions.test.ts b/apps/web/src/approval-actions.test.ts deleted file mode 100644 index 192125f0e..000000000 --- a/apps/web/src/approval-actions.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { QueryClient } from "@tanstack/react-query"; -import { CHAT_STRINGS } from "@/chat"; - -import { createChatApprovalActions } from "./approval-actions"; - -describe("createChatApprovalActions copy", () => { - const realFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = realFetch; - }); - - function stubStatus(status: number): void { - globalThis.fetch = (() => - Promise.resolve( - new Response(JSON.stringify({ message: "nope" }), { - status, - headers: { "content-type": "application/json" }, - }), - )) as unknown as typeof fetch; - } - - function actions() { - return createChatApprovalActions( - "tenant-1", - new QueryClient({ defaultOptions: { queries: { retry: false } } }), - ); - } - - test("approve 403 uses approve forbidden copy, not deny", async () => { - stubStatus(403); - const result = await actions().approve("appr_1"); - expect(result).toEqual({ - kind: "forbidden", - message: CHAT_STRINGS.blockApproveActionForbidden, - }); - expect(CHAT_STRINGS.blockApproveActionForbidden).not.toBe( - CHAT_STRINGS.blockDenyActionForbidden, - ); - }); - - test("reject 403 uses deny forbidden copy, not approve", async () => { - stubStatus(403); - const result = await actions().reject("appr_1"); - expect(result).toEqual({ - kind: "forbidden", - message: CHAT_STRINGS.blockDenyActionForbidden, - }); - expect(CHAT_STRINGS.blockDenyActionForbidden).not.toBe( - CHAT_STRINGS.blockApproveActionForbidden, - ); - }); -}); diff --git a/apps/web/src/auto-workbench-title.test.ts b/apps/web/src/auto-workbench-title.test.ts deleted file mode 100644 index ef3396ff5..000000000 --- a/apps/web/src/auto-workbench-title.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AUTO_WORKBENCH_TITLE_MAX, - autoNameFromFirstMessage, - NEW_WORKBENCH_TITLE, - titleFromFirstMessage, -} from "./auto-workbench-title"; - -describe("titleFromFirstMessage", () => { - test("trims and collapses whitespace into a single-line title", () => { - expect(titleFromFirstMessage(" Help me\nplan Q3 ")).toBe("Help me plan Q3"); - }); - - test("returns undefined for blank or whitespace-only input", () => { - expect(titleFromFirstMessage("")).toBeUndefined(); - expect(titleFromFirstMessage(" \n\t ")).toBeUndefined(); - }); - - test("keeps a short message intact", () => { - expect(titleFromFirstMessage("Draft a launch checklist")).toBe("Draft a launch checklist"); - }); - - test("truncates a long message at a word boundary with an ellipsis", () => { - const long = - "Help me write a detailed competitive analysis of every agent coding tool shipping this quarter"; - const title = titleFromFirstMessage(long); - expect(title).toBeDefined(); - if (title === undefined) return; - expect(title.endsWith("…")).toBe(true); - expect(title.length).toBeLessThanOrEqual(AUTO_WORKBENCH_TITLE_MAX + 1); - expect(title).not.toContain("shipping"); - }); -}); - -describe("autoNameFromFirstMessage", () => { - test("names an ad-hoc New Workbench from the first message", () => { - expect(autoNameFromFirstMessage(NEW_WORKBENCH_TITLE, "Plan the Q3 launch")).toBe( - "Plan the Q3 launch", - ); - }); - - test("leaves prefab and already-renamed titles alone", () => { - expect(autoNameFromFirstMessage("Code review", "Review the auth PR")).toBeUndefined(); - expect(autoNameFromFirstMessage("My research bench", "Dig into pricing")).toBeUndefined(); - }); - - test("returns undefined when the first message has no usable text", () => { - expect(autoNameFromFirstMessage(NEW_WORKBENCH_TITLE, " ")).toBeUndefined(); - }); -}); diff --git a/apps/web/src/bench-context.test.ts b/apps/web/src/bench-context.test.ts deleted file mode 100644 index 3a26dbe0b..000000000 --- a/apps/web/src/bench-context.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { resolveSelection } from "./bench-context"; -import type { Principal } from "./api"; - -function membership(overrides: Partial & { tenantId: string }): Principal { - return { - principalId: `prn_${overrides.tenantId}`, - tenantName: overrides.tenantId, - tenantSlug: overrides.tenantId, - kind: "user", - status: "active", - roles: [], - ...overrides, - }; -} - -/** `null` = a top-level tenant (a bench); a string = its parent's id (a - * workbench, or any other child tenant). Missing entries model a tenant whose - * detail hasn't loaded yet. */ -function parents(entries: Record): ReadonlyMap { - return new Map(Object.entries(entries)); -} - -describe("resolveSelection", () => { - test("a bench sorting first wins over a workbench, regardless of name", () => { - const memberships = [ - membership({ tenantId: "tnt_bench", tenantName: "Growth Team" }), - membership({ tenantId: "tnt_workbench", tenantName: "Launch Planning" }), - ]; - const parentByTenantId = parents({ tnt_bench: null, tnt_workbench: "tnt_bench" }); - - const resolved = resolveSelection(memberships, null, parentByTenantId); - - expect(resolved?.tenantId).toBe("tnt_bench"); - }); - - test("a workbench sorting first is skipped in favor of the first bench", () => { - const memberships = [ - membership({ tenantId: "tnt_workbench", tenantName: "Launch Planning" }), - membership({ tenantId: "tnt_bench", tenantName: "Growth Team" }), - ]; - const parentByTenantId = parents({ tnt_workbench: "tnt_bench", tnt_bench: null }); - - const resolved = resolveSelection(memberships, null, parentByTenantId); - - expect(resolved?.tenantId).toBe("tnt_bench"); - }); - - test("a stored selection that still names a bench wins over the first membership", () => { - const memberships = [ - membership({ tenantId: "tnt_bench_a" }), - membership({ tenantId: "tnt_bench_b" }), - ]; - const parentByTenantId = parents({ tnt_bench_a: null, tnt_bench_b: null }); - - const resolved = resolveSelection(memberships, "tnt_bench_b", parentByTenantId); - - expect(resolved?.tenantId).toBe("tnt_bench_b"); - }); - - test("a stored selection naming a workbench falls through to the first bench — a workbench can never be selected even when it is the stored id", () => { - const memberships = [ - membership({ tenantId: "tnt_workbench" }), - membership({ tenantId: "tnt_bench" }), - ]; - const parentByTenantId = parents({ tnt_workbench: "tnt_bench", tnt_bench: null }); - - const resolved = resolveSelection(memberships, "tnt_workbench", parentByTenantId); - - expect(resolved?.tenantId).toBe("tnt_bench"); - }); - - test("a stored selection for a tenant no longer in memberships falls through", () => { - const memberships = [membership({ tenantId: "tnt_bench" })]; - const parentByTenantId = parents({ tnt_bench: null }); - - const resolved = resolveSelection(memberships, "tnt_gone", parentByTenantId); - - expect(resolved?.tenantId).toBe("tnt_bench"); - }); - - test("undefined when every membership is a workbench", () => { - const memberships = [membership({ tenantId: "tnt_workbench" })]; - const parentByTenantId = parents({ tnt_workbench: "tnt_primary" }); - - const resolved = resolveSelection(memberships, null, parentByTenantId); - - expect(resolved).toBeUndefined(); - }); - - test("undefined while a tenant's parent hasn't loaded yet — never guessed as a bench", () => { - const memberships = [membership({ tenantId: "tnt_unknown" })]; - const parentByTenantId = parents({}); - - const resolved = resolveSelection(memberships, null, parentByTenantId); - - expect(resolved).toBeUndefined(); - }); -}); diff --git a/apps/web/src/bench/membership.test.ts b/apps/web/src/bench/membership.test.ts deleted file mode 100644 index 2e9dbb352..000000000 --- a/apps/web/src/bench/membership.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { isRawIdentifier } from "./membership"; - -describe("isRawIdentifier", () => { - test("recognizes every platform id prefix this surface must never render", () => { - expect(isRawIdentifier("ins_71f5c0c9c30026859014ccd9df8b1")).toBe(true); - expect(isRawIdentifier("tnt_abc123")).toBe(true); - expect(isRawIdentifier("prn_abc123")).toBe(true); - expect(isRawIdentifier("role_abc123")).toBe(true); - expect(isRawIdentifier("grant_abc123")).toBe(true); - }); - - test("leaves a human-assigned name alone", () => { - expect(isRawIdentifier("Launch Team")).toBe(false); - expect(isRawIdentifier("Myra")).toBe(false); - }); -}); diff --git a/apps/web/src/bench/tenancy-contracts.test.ts b/apps/web/src/bench/tenancy-contracts.test.ts deleted file mode 100644 index e565011f4..000000000 --- a/apps/web/src/bench/tenancy-contracts.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - canShareWorkbenchWithinParent, - createDmWorkbenchSpec, - dmWorkbenchName, - isInterchangeRole, - validateParentId, - wouldCreateParentCycle, - type TenantParentLookup, -} from "./tenancy-contracts"; - -function memoryLookup(parents: Record): TenantParentLookup { - return { - async getParentId(id) { - if (!Object.prototype.hasOwnProperty.call(parents, id)) return null; - return parents[id] ?? null; - }, - async exists(id) { - return Object.prototype.hasOwnProperty.call(parents, id); - }, - }; -} - -describe("validateParentId", () => { - test("null parent is ok (root)", async () => { - const lookup = memoryLookup({}); - expect(await validateParentId(null, lookup)).toEqual({ ok: true }); - }); - - test("unknown parent fails", async () => { - const lookup = memoryLookup({}); - const result = await validateParentId("missing", lookup); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.code).toBe("unknown_parent"); - }); - - test("existing parent is ok", async () => { - const lookup = memoryLookup({ parent: null }); - expect(await validateParentId("parent", lookup)).toEqual({ ok: true }); - }); -}); - -describe("wouldCreateParentCycle", () => { - test("self-parent is a cycle", async () => { - const lookup = memoryLookup({ a: null }); - expect(await wouldCreateParentCycle("a", "a", lookup)).toBe(true); - }); - - test("parenting under a descendant is a cycle", async () => { - // a → b → c; reparent a under c - const lookup = memoryLookup({ a: null, b: "a", c: "b" }); - expect(await wouldCreateParentCycle("a", "c", lookup)).toBe(true); - }); - - test("parenting under an unrelated root is fine", async () => { - const lookup = memoryLookup({ a: null, other: null }); - expect(await wouldCreateParentCycle("a", "other", lookup)).toBe(false); - }); -}); - -describe("DM contract", () => { - test("names from counterparty", () => { - expect(dmWorkbenchName(" Ada ")).toBe("Ada"); - expect(dmWorkbenchName(" ")).toBe("Direct message"); - }); - - test("createDmWorkbenchSpec requires distinct members", () => { - expect(() => - createDmWorkbenchSpec({ - counterpartyDisplayName: "Ada", - memberUserIds: ["u1", "u1"], - }), - ).toThrow(/distinct/); - - const spec = createDmWorkbenchSpec({ - counterpartyDisplayName: "Ada", - memberUserIds: ["u1", "u2"], - }); - expect(spec.dm).toBe(true); - expect(spec.name).toBe("Ada"); - expect(spec.memberUserIds).toEqual(["u1", "u2"]); - }); -}); - -describe("isInterchangeRole", () => { - test("only owner/admin/member", () => { - expect(isInterchangeRole("owner")).toBe(true); - expect(isInterchangeRole("viewer")).toBe(false); - }); -}); - -describe("canShareWorkbenchWithinParent", () => { - test("siblings under same parent can share", async () => { - const lookup = memoryLookup({ - root: null, - a: "root", - b: "root", - }); - expect(await canShareWorkbenchWithinParent("a", "b", lookup)).toBe(true); - }); - - test("unrelated roots cannot", async () => { - const lookup = memoryLookup({ a: null, b: null }); - expect(await canShareWorkbenchWithinParent("a", "b", lookup)).toBe(false); - }); - - test("child and parent can share", async () => { - const lookup = memoryLookup({ root: null, child: "root" }); - expect(await canShareWorkbenchWithinParent("child", "root", lookup)).toBe(true); - }); -}); diff --git a/apps/web/src/chat-artifact-open.test.ts b/apps/web/src/chat-artifact-open.test.ts deleted file mode 100644 index c3b912933..000000000 --- a/apps/web/src/chat-artifact-open.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { ArtifactDetail } from "./api"; -import { - artifactContentFromBlob, - artifactContentFromBlobError, - artifactContentFromDetail, - artifactContentFromDetailError, -} from "./chat-artifact-open"; - -describe("artifactContentFromBlob", () => { - test("decodes a text/markdown blob through the doc renderer", () => { - const content = artifactContentFromBlob( - { name: "notes.md", mediaType: "text/markdown" }, - "blob_m1_1", - btoa("# Title\nBody"), - ); - expect(content).toEqual({ - id: "blob_m1_1", - title: "notes.md", - rendererKind: "doc", - content: "# Title\nBody", - }); - }); - - test("decodes a text/csv blob through the sheet renderer", () => { - const content = artifactContentFromBlob( - { name: "q1.csv", mediaType: "text/csv" }, - "blob_m1_2", - btoa("a,b\n1,2"), - ); - expect(content.rendererKind).toBe("sheet"); - expect(content.content).toBe("a,b\n1,2"); - }); - - test("a binary MIME type is not decoded — renders unsupported with a reason", () => { - const content = artifactContentFromBlob( - { name: "logo.png", mediaType: "image/png" }, - "blob_m1_3", - btoa("not real png bytes"), - ); - expect(content.rendererKind).toBe("unsupported"); - expect(content.content).toBe(""); - expect(content.unavailableReason).toContain("image/png"); - }); -}); - -describe("artifactContentFromBlobError", () => { - test("renders unsupported with the failure reason", () => { - const content = artifactContentFromBlobError( - { name: "report.pdf" }, - "blob_m1_4", - "The server answered 404.", - ); - expect(content.rendererKind).toBe("unsupported"); - expect(content.title).toBe("report.pdf"); - expect(content.unavailableReason).toContain("The server answered 404."); - }); -}); - -function artifactDetail(overrides: Partial): ArtifactDetail { - return { - id: "art_1", - kind: "document", - title: "Q3 report", - source: { origin: "workflow", runId: "run_1" }, - version: 1, - ownerPrincipalId: null, - metadata: null, - archivedAt: null, - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - content: "# Q3\nGrowth is up.", - ...overrides, - }; -} - -describe("artifactContentFromDetail", () => { - test("resolves the renderer kind the same way Library detail does", () => { - const content = artifactContentFromDetail("t1", artifactDetail({})); - expect(content).toEqual({ - id: "art_1", - title: "Q3 report", - rendererKind: "doc", - content: "# Q3\nGrowth is up.", - canEdit: true, - }); - }); - - test("never falls back to blob bytes — it reads the artifact's own content", () => { - const content = artifactContentFromDetail( - "t1", - artifactDetail({ kind: "csv-export", title: "Signups", content: "a,b" }), - ); - expect(content.rendererKind).toBe("sheet"); - expect(content.content).toBe("a,b"); - }); - - test("a non-text kind is never marked editable — only 'doc' co-edits", () => { - const content = artifactContentFromDetail( - "t1", - artifactDetail({ kind: "csv-export", title: "Signups", content: "a,b" }), - ); - expect(content.canEdit).toBe(false); - }); - - test("an html file artifact gets a previewSrc pointed at the sandboxed preview route", () => { - const content = artifactContentFromDetail( - "t1", - artifactDetail({ - kind: "file", - title: "landing.html", - content: "

hi

", - }), - ); - expect(content.rendererKind).toBe("html"); - expect(content.previewSrc).toBe("/api/tenants/t1/artifacts/art_1/preview"); - }); -}); - -describe("artifactContentFromDetailError", () => { - test("renders unsupported with the failure reason", () => { - const content = artifactContentFromDetailError( - { name: "Q3 report" }, - "art_1", - "The hub answered 404.", - ); - expect(content.rendererKind).toBe("unsupported"); - expect(content.title).toBe("Q3 report"); - expect(content.unavailableReason).toContain("The hub answered 404."); - }); -}); diff --git a/apps/web/src/chat/agent-part-adapter.test.ts b/apps/web/src/chat/agent-part-adapter.test.ts deleted file mode 100644 index ca588969f..000000000 --- a/apps/web/src/chat/agent-part-adapter.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { toReactUiReasoning } from "./agent-part-adapter"; - -describe("toReactUiReasoning", () => { - test("carries the text through", () => { - expect(toReactUiReasoning({ kind: "reasoning", text: "thinking" })).toEqual({ - kind: "reasoning", - text: "thinking", - }); - }); - - // chat's ReasoningPart has no duration field; react-ui's is optional, so - // it stays absent rather than being fabricated as 0. - test("omits durationMs entirely rather than inventing a zero", () => { - expect("durationMs" in toReactUiReasoning({ kind: "reasoning", text: "x" })).toBe(false); - }); -}); diff --git a/apps/web/src/chat/avatar.test.tsx b/apps/web/src/chat/avatar.test.tsx deleted file mode 100644 index 1f5b372f1..000000000 --- a/apps/web/src/chat/avatar.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AVATAR_COLORS, - avatarClassForPrincipal, - avatarColorClass, - avatarColorForPrincipal, - resolveAvatarFill, -} from "./avatar"; - -describe("avatarColorForPrincipal", () => { - test("is deterministic for the same principal", () => { - expect(avatarColorForPrincipal("prn_alice")).toBe(avatarColorForPrincipal("prn_alice")); - }); - - test("always returns a color from the approved pastel palette", () => { - const principals = ["prn_alice", "prn_bob", "prn_carla", "prn_dana", "prn_eve", "prn_frank"]; - for (const p of principals) { - expect(AVATAR_COLORS).toContain(avatarColorForPrincipal(p)); - } - }); - - test("distributes distinct principals across palette colors", () => { - const colors = new Set( - ["prn_alice", "prn_bob", "prn_carla", "prn_dana"].map(avatarColorForPrincipal), - ); - expect(colors.size).toBeGreaterThan(1); - }); -}); - -describe("avatarClassForPrincipal", () => { - test("is deterministic for the same principal", () => { - expect(avatarClassForPrincipal("prn_alice")).toBe(avatarClassForPrincipal("prn_alice")); - }); - - test("never displays the seed itself", () => { - expect(avatarClassForPrincipal("prn_super_secret_internal_id")).not.toContain( - "prn_super_secret_internal_id", - ); - }); -}); - -describe("avatar pastel tokens", () => { - test("the palette is token references, not hardcoded hex", () => { - expect(AVATAR_COLORS).toHaveLength(4); - for (const color of AVATAR_COLORS) { - expect(color.startsWith("--avatar-")).toBe(true); - expect(color).not.toContain("#"); - } - }); - - test("every class pairs its token with the readable ink", () => { - for (const color of AVATAR_COLORS) { - const cls = avatarColorClass[color]; - expect(cls).toContain(`bg-(${color})`); - expect(cls).toContain("text-black"); - expect(cls).not.toContain("#"); - } - }); -}); - -describe("resolveAvatarFill", () => { - test("a principal with no explicit image gets the generated fill", () => { - const fill = resolveAvatarFill("prn_alice"); - expect(fill.kind).toBe("generated"); - if (fill.kind === "generated") { - expect(fill.className).toBe(avatarClassForPrincipal("prn_alice")); - } - }); - - test("a principal with an explicit image still uses it", () => { - const fill = resolveAvatarFill("prn_alice", "https://example.com/a.png"); - expect(fill).toEqual({ - kind: "image", - url: "https://example.com/a.png", - }); - }); - - test("an empty image string is treated as no image", () => { - const fill = resolveAvatarFill("prn_alice", ""); - expect(fill.kind).toBe("generated"); - }); -}); diff --git a/apps/web/src/chat/failed-turn-models.test.ts b/apps/web/src/chat/failed-turn-models.test.ts deleted file mode 100644 index 2254e81ee..000000000 --- a/apps/web/src/chat/failed-turn-models.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ModelInfo } from "@/settings/inference"; - -import { failedTurnModelChoices, failedTurnToolCapableModelChoices } from "./failed-turn-models"; - -function model( - canonicalName: string, - capabilities: ModelInfo["offerings"][number]["capabilities"], - displayName = canonicalName, -): ModelInfo { - return { - id: `model-${canonicalName}`, - canonicalName, - displayName, - offerings: [ - { - offeringId: `offering-${canonicalName}`, - providerId: "provider-a", - providerName: "anthropic", - plugin: "anthropic", - priority: 0, - deploymentTags: [], - capabilities: [...capabilities], - pricing: [], - }, - ], - }; -} - -const CHAT_ONLY = model("google/gemini-2.5-flash", ["plain-text"], "Flash"); -const TOOL_CAPABLE = model( - "anthropic/claude-sonnet", - ["plain-text", "function-calling-multi-turn"], - "Sonnet", -); -const UNCATALOGED = model("qwen3:8b", [], "qwen3:8b"); - -describe("failedTurnModelChoices", () => { - test("keeps chat-capable models, including ones without tools", () => { - const choices = failedTurnModelChoices([CHAT_ONLY, TOOL_CAPABLE, UNCATALOGED]); - expect(choices.map((choice) => choice.canonicalName)).toEqual([ - "google/gemini-2.5-flash", - "anthropic/claude-sonnet", - "qwen3:8b", - ]); - }); -}); - -describe("failedTurnToolCapableModelChoices", () => { - test("keeps only models whose offerings advertise function-calling", () => { - const choices = failedTurnToolCapableModelChoices([CHAT_ONLY, TOOL_CAPABLE, UNCATALOGED]); - expect(choices.map((choice) => choice.canonicalName)).toEqual(["anthropic/claude-sonnet"]); - }); -}); diff --git a/apps/web/src/chat/inference-failure.test.ts b/apps/web/src/chat/inference-failure.test.ts deleted file mode 100644 index 5813b35f5..000000000 --- a/apps/web/src/chat/inference-failure.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - CONSUMER_INFERENCE_FAILURE_NOTICE, - consumerFacingInferenceText, - isClassifiedInferenceFailureText, -} from "./inference-failure"; - -describe("consumerFacingInferenceText", () => { - test("maps a classified credential failure to one recovery sentence", () => { - expect( - consumerFacingInferenceText( - "This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid", - ), - ).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - expect( - consumerFacingInferenceText( - "This agent could not complete your request because the API quota has been exhausted [HTTP 429]: rate limited", - ), - ).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - }); - - test("a forced [HTTP 401] dump is not consumer copy", () => { - const leaked = "[HTTP 401]: API key is invalid"; - const facing = consumerFacingInferenceText(leaked); - expect(facing).not.toContain("[HTTP"); - expect(facing).not.toContain("401"); - expect(facing).not.toContain("API key is invalid"); - }); - - test("a tools-unsupported dump is one honest sentence, never HTTP or registry copy", () => { - const text = consumerFacingInferenceText( - "This agent could not complete your request due to an unrecoverable inference error [HTTP 400]: 'tools' is not supported with this model.", - ); - expect(text).toBe("This agent's model can't use tools."); - expect(text).not.toMatch(/HTTP/i); - expect(text).not.toContain("function-calling"); - expect(text).not.toContain("unrecoverable inference error"); - }); - - test("leaves cause-aware undelivered-notice copy untouched", () => { - const notice = - "I can't reach a model right now — add or check your model key in Settings, then I'll pick this up."; - expect(consumerFacingInferenceText(notice)).toBe(notice); - }); -}); - -describe("isClassifiedInferenceFailureText", () => { - test("matches a credential_failure reply, status code included", () => { - expect( - isClassifiedInferenceFailureText( - "This agent could not complete your request due to a credential error [HTTP 401]: invalid api key", - ), - ).toBe(true); - }); - - test("a stripped credential_failure reply is recovery copy, not the classified preamble", () => { - const facing = consumerFacingInferenceText( - "This agent could not complete your request due to a credential error [HTTP 401]: invalid api key", - ); - expect(facing).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - expect(facing.toLowerCase()).not.toContain("credential error"); - expect( - isClassifiedInferenceFailureText( - "This agent could not complete your request due to a credential error [HTTP 401]: invalid api key", - ), - ).toBe(true); - }); - - test("matches a quota_exhausted reply", () => { - expect( - isClassifiedInferenceFailureText( - "This agent could not complete your request because the API quota has been exhausted [HTTP 429]: rate limited", - ), - ).toBe(true); - }); - - test("does not match a retryable/context_overflow/fatal/aborted reply", () => { - expect( - isClassifiedInferenceFailureText( - "This agent encountered a temporary error communicating with the inference provider [HTTP 503]: upstream down", - ), - ).toBe(false); - expect( - isClassifiedInferenceFailureText( - "This agent could not complete your request because the conversation exceeded the model's context limit: too long", - ), - ).toBe(false); - expect( - isClassifiedInferenceFailureText( - "This agent could not complete your request due to an unrecoverable inference error [HTTP 402]: payment required", - ), - ).toBe(false); - expect(isClassifiedInferenceFailureText("This agent's inference request was aborted")).toBe( - false, - ); - }); - - test("does not match an ordinary agent reply, even one that mentions credentials mid-sentence", () => { - expect( - isClassifiedInferenceFailureText( - "I'd due to a credential error need your API key to continue.", - ), - ).toBe(false); - expect(isClassifiedInferenceFailureText("Here are the results you asked for.")).toBe(false); - }); -}); diff --git a/apps/web/src/chat/markdown.test.tsx b/apps/web/src/chat/markdown.test.tsx deleted file mode 100644 index b4d93dd3b..000000000 --- a/apps/web/src/chat/markdown.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// DOM assertions for the safe markdown subset: bold, lists, and code -// render as real elements rather than literal `**`/`1.`/backtick text. -import { describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import { Markdown } from "./markdown"; - -function mount(text: string): HTMLDivElement { - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - act(() => { - root.render(); - }); - return container; -} - -describe("Markdown", () => { - test("renders **bold** as a strong element, not literal asterisks", () => { - const el = mount("this is **bold** text"); - expect(el.querySelector("strong")?.textContent).toBe("bold"); - expect(el.textContent).not.toContain("**"); - }); - - test("renders a numbered list as an ordered list", () => { - const el = mount("1. first\n2. second"); - const list = el.querySelector("ol"); - expect(list).not.toBeNull(); - const items = list?.querySelectorAll("li") ?? []; - expect(items.length).toBe(2); - expect(items[0]?.textContent).toBe("first"); - expect(items[1]?.textContent).toBe("second"); - }); - - test("renders a bullet list as an unordered list", () => { - const el = mount("- alpha\n- beta"); - const list = el.querySelector("ul"); - expect(list?.querySelectorAll("li").length).toBe(2); - }); - - test("renders inline code as a code element", () => { - const el = mount("run `bun test` now"); - expect(el.querySelector("code")?.textContent).toBe("bun test"); - }); - - test("renders a fenced code block as pre>code", () => { - const el = mount("```\nconst x = 1;\n```"); - expect(el.querySelector("pre > code")?.textContent).toBe("const x = 1;"); - }); - - test("renders a link opening in a new tab with no referrer leak", () => { - const el = mount("see [docs](https://example.com)"); - const link = el.querySelector("a"); - expect(link?.getAttribute("href")).toBe("https://example.com"); - expect(link?.getAttribute("target")).toBe("_blank"); - expect(link?.getAttribute("rel")).toContain("noopener"); - }); - - test("escapes raw HTML rather than rendering it", () => { - const el = mount(""); - expect(el.querySelector("img")).toBeNull(); - expect(el.textContent).toContain(" { - const el = mount("just a normal message"); - expect(el.textContent).toBe("just a normal message"); - }); -}); diff --git a/apps/web/src/chat/mentions.test.ts b/apps/web/src/chat/mentions.test.ts deleted file mode 100644 index 16ea3f40a..000000000 --- a/apps/web/src/chat/mentions.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { activeMention, applyMention, mentionedAgents } from "./mentions"; - -const roster = [{ name: "Researcher" }, { name: "Writer" }, { name: "Writer Two" }]; - -describe("mentionedAgents", () => { - test("resolves a name to its roster entry", () => { - expect(mentionedAgents("@Writer reply with one word", roster)).toEqual([{ name: "Writer" }]); - }); - - test("ignores a name nothing in the roster answers to", () => { - expect(mentionedAgents("@Nobody are you there", roster)).toEqual([]); - }); - - test("resolves several mentions, case-insensitively", () => { - expect(mentionedAgents("@researcher and @Writer, compare notes", roster)).toEqual([ - { name: "Researcher" }, - { name: "Writer" }, - ]); - }); - - test("prefers the longest matching name", () => { - expect(mentionedAgents("@Writer Two please draft it", roster)).toEqual([ - { name: "Writer Two" }, - ]); - }); - - test("does not treat an address local part as a mention", () => { - expect(mentionedAgents("mail me at carol@writer.example.com", roster)).toEqual([]); - }); -}); - -describe("activeMention / applyMention", () => { - test("reads the mention being typed at the caret", () => { - expect(activeMention("hey @wri", 8)).toEqual({ start: 4, query: "wri" }); - expect(activeMention("hey there", 9)).toBeUndefined(); - }); - - test("replaces the typed fragment with the full token", () => { - const mention = activeMention("hey @wri", 8); - expect(mention).toBeDefined(); - expect(applyMention("hey @wri", mention!, "Writer", 8)).toEqual({ - text: "hey @Writer ", - caret: 12, - }); - }); -}); diff --git a/apps/web/src/chat/strings.test.ts b/apps/web/src/chat/strings.test.ts deleted file mode 100644 index fa0d90dc0..000000000 --- a/apps/web/src/chat/strings.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { CHAT_STRINGS } from "./strings"; - -describe("toast confirmation copy", () => { - test("workbench create carries the new workbench's title", () => { - expect(CHAT_STRINGS.workbenchCreatedToast("Launch planning")).toBe("Created · Launch planning"); - }); - - test("rename confirms the title the workbench now has", () => { - expect(CHAT_STRINGS.workbenchRenamedToast("Growth")).toBe("Renamed to Growth"); - }); - - test("rename failure tells the person to try again", () => { - expect(CHAT_STRINGS.workbenchRenameError).toBe("Couldn't rename that workbench — try again."); - }); - - test("pin copy follows the state the workbench just entered", () => { - expect(CHAT_STRINGS.workbenchPinnedToast(true, "Deploy notes")).toBe("Pinned Deploy notes"); - expect(CHAT_STRINGS.workbenchPinnedToast(false, "Deploy notes")).toBe("Unpinned Deploy notes"); - }); -}); - -describe("agentsTyping copy", () => { - test("one name reads as a single typist", () => { - expect(CHAT_STRINGS.agentsTyping(["Myra"])).toBe("Myra is typing…"); - }); - - test("two names are joined with 'and'", () => { - expect(CHAT_STRINGS.agentsTyping(["Myra", "Scribe"])).toBe("Myra and Scribe are typing…"); - }); - - test("three or more collapse the rest into 'and N others'", () => { - expect(CHAT_STRINGS.agentsTyping(["Myra", "Scribe", "Tally"])).toBe( - "Myra, Scribe and 1 other are typing…", - ); - expect(CHAT_STRINGS.agentsTyping(["Myra", "Scribe", "Tally", "Nova"])).toBe( - "Myra, Scribe and 2 others are typing…", - ); - }); - - test("no names renders nothing to say", () => { - expect(CHAT_STRINGS.agentsTyping([])).toBe(""); - }); -}); - -describe("approve vs deny vs form action copy", () => { - test("deny forbidden and error are distinct from approve copy", () => { - expect(CHAT_STRINGS.blockDenyActionForbidden).toBe("You do not have permission to deny this."); - expect(CHAT_STRINGS.blockDenyActionError).toBe("Couldn't deny this request."); - expect(CHAT_STRINGS.blockDenyActionForbidden).not.toBe( - CHAT_STRINGS.blockApproveActionForbidden, - ); - expect(CHAT_STRINGS.blockDenyActionError).not.toBe(CHAT_STRINGS.blockApproveActionError); - }); - - test("form submit forbidden is respond copy, not approve copy", () => { - expect(CHAT_STRINGS.blockFormSubmitForbidden).toBe( - "You do not have permission to respond in this conversation.", - ); - expect(CHAT_STRINGS.blockFormSubmitForbidden).not.toBe( - CHAT_STRINGS.blockApproveActionForbidden, - ); - }); -}); diff --git a/apps/web/src/chat/threads-api.test.ts b/apps/web/src/chat/threads-api.test.ts deleted file mode 100644 index 2e8998734..000000000 --- a/apps/web/src/chat/threads-api.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { agentDeploySourceAssetName } from "../agent-deploy"; -import { MYRA_SOURCE_CONFIG } from "../myra-source"; -import { - displayAgentName, - listWorkbenchParticipants, - resolveAvatarName, - type WorkbenchParticipant, -} from "./threads-api"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -const json = (body: unknown) => - new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }); - -describe("displayAgentName", () => { - test("renders Myra's fixed display name for her asset", () => { - expect(displayAgentName(MYRA_SOURCE_CONFIG.assetName)).toBe(MYRA_SOURCE_CONFIG.displayName); - }); - - test("title-cases a deployed agent's slug with hyphens as spaces", () => { - expect(displayAgentName(agentDeploySourceAssetName("echo-bot"))).toBe("Echo Bot"); - expect(displayAgentName(agentDeploySourceAssetName("scribe"))).toBe("Scribe"); - }); - - test("falls back to the raw name for anything unrecognized", () => { - expect(displayAgentName("some-other-asset")).toBe("some-other-asset"); - }); -}); - -describe("listWorkbenchParticipants", () => { - test("a person's address is their refId lowercased at the workbench's own domain, never email or bare refId", async () => { - globalThis.fetch = ((input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - if (path.includes("/principals")) { - return Promise.resolve( - json({ - data: [ - { - id: "prin_1", - kind: "user", - refId: "Mk9tHH", - displayName: "Alice", - email: "alice@example.com", - status: "active", - }, - ], - nextCursor: null, - }), - ); - } - if (path.includes("/workflows/deployments")) return Promise.resolve(json([])); - if (path.includes("/assets")) return Promise.resolve(json([])); - if (path.includes("/runs")) return Promise.resolve(json({ data: [], nextCursor: null })); - throw new Error(`unexpected fetch: ${path}`); - }) as typeof fetch; - - const participants = await listWorkbenchParticipants("tnt_1", "example.com"); - expect(participants).toContainEqual({ - id: "prin_1", - kind: "person", - name: "Alice", - address: "mk9thh@example.com", - }); - }); -}); - -describe("resolveAvatarName", () => { - // A person's roster address is `@`, mixed case as stored; - // the mailbox lowercases local parts on the wire, so a header `from` - // stays mixed case while the envelope `from` comes back lowercase. - const participants: readonly WorkbenchParticipant[] = [ - { id: "p1", kind: "person", name: "alice", address: "Mk9tHH@example.com" }, - { id: "a1", kind: "agent", name: "Myra", address: "myra@example.com" }, - ]; - - test("matches the person's own turn by envelope address, case-insensitively", () => { - const name = resolveAvatarName( - { author: "me", authorName: "You", address: "mk9thh@example.com" }, - participants, - ); - expect(name).toBe("alice"); - }); - - test("uses the matching participant's name for another author", () => { - const name = resolveAvatarName( - { author: "other", authorName: "myra", address: "myra@example.com" }, - participants, - ); - expect(name).toBe("Myra"); - }); - - test("falls back to authorName when no participant matches", () => { - const name = resolveAvatarName( - { author: "other", authorName: "someone", address: "unknown@example.com" }, - participants, - ); - expect(name).toBe("someone"); - }); -}); diff --git a/apps/web/src/chat/tool-activity-view.test.tsx b/apps/web/src/chat/tool-activity-view.test.tsx deleted file mode 100644 index 947ba8d1e..000000000 --- a/apps/web/src/chat/tool-activity-view.test.tsx +++ /dev/null @@ -1,320 +0,0 @@ -// What a turn's tool activity actually renders: mounted for real, asserted -// on the text a reader sees. The standing rule under test is that no -// argument bag or tool result ever reaches the DOM as JSON, in any state. -import { beforeEach, describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import type { ToolTracePart } from "./wire/parts"; -import { toToolActivityRow, type ToolActivityRow } from "./tool-activity"; -import { LiveToolActivity, ToolActivityGroup } from "./tool-activity-view"; - -function trace(part: Partial): ToolTracePart { - return { - kind: "tool-trace", - name: "web_search", - input: {}, - status: "success", - ...part, - } as ToolTracePart; -} - -function mount(parts: readonly ToolTracePart[]): HTMLDivElement { - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - const rows = parts.map((part, index) => toToolActivityRow(part, `k${index}`)); - act(() => { - root.render(); - }); - return container; -} - -function click(element: Element | null) { - act(() => { - (element as HTMLElement).click(); - }); -} - -/** Name a screen reader would get: aria-label, else text excluding aria-hidden. */ -function accessibleName(element: Element | null): string { - if (element === null) return ""; - const labelled = element.getAttribute("aria-label"); - if (labelled !== null && labelled.trim() !== "") return labelled.trim(); - const walk = (node: Node): string => { - if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ""; - if (!(node instanceof Element)) return ""; - if (node.getAttribute("aria-hidden") === "true") return ""; - return Array.from(node.childNodes).map(walk).join(""); - }; - return walk(element).replace(/\s+/g, " ").trim(); -} - -function mountLive( - rows: readonly ToolActivityRow[], - extra?: { readonly thinking?: boolean; readonly retryCount?: number }, -): HTMLDivElement { - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - act(() => { - root.render( - , - ); - }); - return container; -} - -beforeEach(() => { - document.body.innerHTML = ""; -}); - -describe("ToolActivityGroup", () => { - test("a successful call reads as a sentence, with no identifier or JSON", () => { - const el = mount([ - trace({ - name: "slack__post_message", - input: { channel: "general", text: "shipping now" }, - output: [{ type: "text", text: "delivered" }], - }), - ]); - expect(el.textContent).toContain("Posted a message in Slack #general"); - expect(el.textContent).not.toContain("slack__post_message"); - expect(el.textContent).not.toContain("{"); - expect(el.textContent).not.toContain('"channel"'); - }); - - test("detail stays closed until asked for, then shows plain text", () => { - const el = mount([ - trace({ - name: "web_search", - input: { query: "bench pricing" }, - output: [{ type: "text", text: "Eight matching pages." }], - }), - ]); - expect(el.textContent).not.toContain("Eight matching pages."); - click(el.querySelector(".chat-tool-activity-trigger")); - expect(el.textContent).toContain("Eight matching pages."); - }); - - test("a failed call says so plainly and opens onto the reason", () => { - const el = mount([ - trace({ - name: "github__get_issue", - input: { repo: "corbitsdev/workbench" }, - status: "error", - output: [{ type: "text", text: "Repository not found" }], - }), - ]); - const marker = el.querySelector(".chat-tool-activity-marker"); - expect(marker?.getAttribute("data-status")).toBe("failed"); - click(el.querySelector(".chat-tool-activity-trigger")); - expect(el.textContent).toContain("Repository not found"); - }); - - test("a failure with nothing to say still says that much", () => { - const el = mount([trace({ name: "run_command", status: "error" })]); - click(el.querySelector(".chat-tool-activity-trigger")); - expect(el.textContent).toContain("No reason given."); - }); - - test("a call still running speaks in the present tense and offers no disclosure", () => { - const el = mount([trace({ name: "web_search", input: { query: "x" }, status: "running" })]); - expect(el.textContent).toContain('Searching the web for "x"'); - expect(el.querySelector(".chat-tool-activity-trigger")).toBeNull(); - expect(el.querySelector(".chat-tool-activity-marker")?.getAttribute("data-status")).toBe( - "running", - ); - }); - - test("consecutive calls stack as individual chips, never a count", () => { - const el = mount([ - trace({ name: "web_search", input: { query: "a" } }), - trace({ name: "read_file", input: { path: "src/app.ts" } }), - trace({ name: "write_file", input: { path: "src/app.ts" } }), - ]); - expect(el.textContent).not.toContain("steps"); - expect(el.textContent).toContain('Searched the web for "a"'); - expect(el.textContent).toContain("Read a file — app.ts"); - expect(el.textContent).toContain("Wrote a file — app.ts"); - expect(el.querySelectorAll(".chat-tool-activity-row").length).toBe(3); - }); - - test("a call still running renders alongside settled calls, not folded", () => { - const el = mount([ - trace({ name: "read_file", input: { path: "a.ts" } }), - trace({ name: "web_search", input: { query: "b" }, status: "running" }), - ]); - expect(el.textContent).toContain('Searching the web for "b"'); - expect(el.textContent).toContain("Read a file — a.ts"); - expect(el.textContent).not.toContain("2 steps"); - }); - - test("a failed call among others names its own failure, on its own chip", () => { - const el = mount([ - trace({ name: "read_file", input: { path: "a.ts" } }), - trace({ - name: "github__get_issue", - status: "error", - output: "Repository not found", - }), - ]); - expect(el.textContent).not.toContain("didn't work"); - expect(el.textContent).toContain("Retrieved an issue in GitHub"); - const failedRow = el.querySelector('[data-status="failed"]'); - expect(failedRow).not.toBeNull(); - click(failedRow?.querySelector(".chat-tool-activity-trigger") ?? null); - expect(el.textContent).toContain("Repository not found"); - }); - - test("a failed chip's accessible name includes failure", () => { - const el = mount([ - trace({ - name: "github__get_issue", - input: { repo: "corbitsdev/workbench" }, - status: "error", - output: [{ type: "text", text: "Repository not found" }], - }), - ]); - const chip = el.querySelector(".chat-tool-activity-chip"); - const name = accessibleName(chip); - expect(name).toMatch(/fail|couldn't/i); - expect(name).toContain("Retrieved an issue in GitHub"); - expect(el.querySelector('[data-status="failed"]')).not.toBeNull(); - expect(el.querySelector(".chat-tool-activity-marker")?.getAttribute("aria-hidden")).toBe( - "true", - ); - }); - - test("a disclosure chip has an accessible name", () => { - const el = mount([ - trace({ - name: "web_search", - input: { query: "bench pricing" }, - output: [{ type: "text", text: "Eight matching pages." }], - }), - ]); - const trigger = el.querySelector(".chat-tool-activity-trigger"); - expect(trigger).not.toBeNull(); - expect(accessibleName(trigger).length).toBeGreaterThan(0); - }); - - test("a known-provider chip uses brand initials, not a dash", () => { - const el = mount([trace({ name: "slack__post_message", input: { channel: "general" } })]); - expect(el.querySelector(".chat-tool-activity-tile")?.textContent).toBe("Sl"); - }); - - test("a qualified memory search chip is a layman sentence, not a package path", () => { - const el = mount([ - trace({ - name: "@corbits/memory-tools/memory:memory_search", - input: { query: "outbound" }, - }), - ]); - expect(el.textContent).toContain("Searched memory"); - expect(el.textContent).not.toContain("@corbits"); - expect(el.textContent).not.toContain("memory-tools"); - expect(el.innerHTML).not.toContain("—"); - expect(el.querySelector('[data-status="success"]')).not.toBeNull(); - expect(el.querySelector(".chat-tool-activity-tile svg")).not.toBeNull(); - }); - - test("a local tool still running keeps a status of running and a leading glyph", () => { - const el = mount([ - trace({ - name: "memory_search", - input: {}, - status: "running", - }), - ]); - expect(el.querySelector('[data-status="running"]')).not.toBeNull(); - expect(el.innerHTML).not.toContain("—"); - expect(el.querySelector(".chat-tool-activity-tile svg")).not.toBeNull(); - }); - - test("a Linear list chip uses the Linear tile and a tensed sentence, never the tautology", () => { - const el = mount([ - trace({ - name: "@corbits/linear-tools/li:linear_list_recent_issues", - }), - ]); - expect(el.textContent).toContain("Listed recent issues in Linear"); - expect(el.textContent).not.toContain("Linear list recent issues"); - expect(el.querySelector(".chat-tool-activity-tile")?.textContent).toBe("Li"); - }); -}); - -describe("LiveToolActivity", () => { - test("elapsed ticks are not inside an atomic live region", () => { - const el = mountLive([ - { - key: "k0", - toolName: "web_search", - glyph: "search", - provider: undefined, - phrase: 'Searching the web for "x"', - detail: undefined, - status: "running", - meta: "3s", - }, - ]); - const live = el.querySelector(".chat-tool-activity-live"); - const meta = el.querySelector(".chat-tool-activity-meta"); - expect(live).not.toBeNull(); - expect(meta?.textContent).toBe("3s"); - expect(meta?.closest('[role="status"]')).toBeNull(); - expect(live?.getAttribute("role")).not.toBe("status"); - }); - - test("the transcript group stays non-live", () => { - const el = mount([trace({ name: "web_search", input: { query: "x" } })]); - const group = el.querySelector(".chat-tool-activity"); - expect(group?.classList.contains("chat-tool-activity-live")).toBe(false); - expect(group?.getAttribute("role")).toBeNull(); - expect(group?.getAttribute("aria-live")).toBeNull(); - }); -}); - -const css = readFileSync( - join(dirname(fileURLToPath(import.meta.url)), "../../src/chat/styles.css"), - "utf8", -); - -function ruleBody(selector: string): string { - const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const match = new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]+)\\}`).exec(css); - return match?.[1] ?? ""; -} - -describe("tool-activity chip layout", () => { - test("a long chip ellipsizes against the message column, not a shrink-wrapped cycle", () => { - const stack = ruleBody(".chat-tool-activity"); - expect(stack).toMatch(/width:\s*100%/); - expect(stack).toMatch(/min-width:\s*0/); - expect(stack).toMatch(/max-width:\s*100%/); - - const row = ruleBody(".chat-tool-activity-row"); - expect(row).toMatch(/width:\s*100%/); - expect(row).toMatch(/min-width:\s*0/); - expect(row).toMatch(/max-width:\s*100%/); - - const chip = ruleBody(".chat-tool-activity-chip"); - expect(chip).toMatch(/width:\s*fit-content/); - expect(chip).toMatch(/min-width:\s*0/); - expect(chip).toMatch(/max-width:\s*100%/); - expect(chip).not.toMatch(/width:\s*max-content/); - - const phrase = ruleBody(".chat-tool-activity-phrase"); - expect(phrase).toMatch(/overflow:\s*hidden/); - expect(phrase).toMatch(/text-overflow:\s*ellipsis/); - expect(phrase).toMatch(/white-space:\s*nowrap/); - expect(phrase).toMatch(/min-width:\s*0/); - }); -}); diff --git a/apps/web/src/chat/tool-activity.test.ts b/apps/web/src/chat/tool-activity.test.ts deleted file mode 100644 index 7671ec43c..000000000 --- a/apps/web/src/chat/tool-activity.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -// The rule this suite exists to hold: nothing a tool call produces reaches -// a reader as an identifier or as JSON. Every assertion below is either -// "this reads like a sentence" or "this string never appears". -import { describe, expect, test } from "bun:test"; -import type { Part, ToolTracePart } from "./wire/parts"; - -import { - describeToolCall, - groupTimelineParts, - plainTextOfOutput, - providerTile, - resolveToolIdentity, - summarizeToolOutput, - toToolActivityRow, -} from "./tool-activity"; - -function trace(part: Partial): ToolTracePart { - return { - kind: "tool-trace", - name: "search", - input: {}, - status: "success", - ...part, - } as ToolTracePart; -} - -describe("resolveToolIdentity", () => { - test("reads the real tool out of the generic MCP dispatch call", () => { - expect( - resolveToolIdentity("mcp_read", { - server: "notion", - tool: "search_pages", - }), - ).toEqual({ - provider: "notion", - words: ["search", "pages"], - toolName: "search_pages", - }); - }); - - test("splits a provider-namespaced tool on its double underscore", () => { - expect(resolveToolIdentity("slack__post_message", {})).toEqual({ - provider: "slack", - words: ["post", "message"], - toolName: "post_message", - }); - }); - - test("splits a dotted tool name too", () => { - expect(resolveToolIdentity("linear.save_issue", {})).toEqual({ - provider: "linear", - words: ["save", "issue"], - toolName: "save_issue", - }); - }); - - test("a bare tool name has no provider", () => { - expect(resolveToolIdentity("webSearch", {})).toEqual({ - provider: undefined, - words: ["web", "search"], - toolName: "webSearch", - }); - }); - - test("an MCP dispatch call with a malformed argument bag still resolves", () => { - expect(resolveToolIdentity("mcp_read", { server: "notion" })).toEqual({ - provider: undefined, - words: ["mcp", "read"], - toolName: "mcp_read", - }); - }); -}); - -describe("describeToolCall", () => { - test("a web search names what was searched for, in the right tense", () => { - expect(describeToolCall("web_search", { query: "bench pricing" }, "past")).toBe( - 'Searched the web for "bench pricing"', - ); - expect(describeToolCall("web_search", { query: "bench pricing" }, "present")).toBe( - 'Searching the web for "bench pricing"', - ); - }); - - test("a provider tool reads as a sentence naming the provider, not the identifier", () => { - const phrase = describeToolCall("slack__post_message", { channel: "general" }, "past"); - expect(phrase).toBe("Posted a message in Slack #general"); - expect(phrase).not.toContain("__"); - expect(phrase).not.toContain("slack__post_message"); - }); - - test("a file tool names the file, not its full path", () => { - expect(describeToolCall("write_file", { path: "/srv/app/src/report.md" }, "past")).toBe( - "Wrote a file — report.md", - ); - }); - - test("a url tool names the host", () => { - expect(describeToolCall("fetch_page", { url: "https://www.example.com/a/b" }, "past")).toBe( - "Fetched a page on example.com", - ); - }); - - test("an MCP dispatch call describes the tool it actually invoked", () => { - expect( - describeToolCall( - "mcp_call", - { server: "linear", tool: "save_issue", query: "auth bug" }, - "past", - ), - ).toBe('Saved an issue in Linear for "auth bug"'); - }); - - test("an unknown verb still loses its underscores rather than leaking raw", () => { - const phrase = describeToolCall("acme__frobnicate_widget", {}, "past"); - expect(phrase).toBe("Frobnicate widget"); - expect(phrase).not.toContain("_"); - }); - - test("a plural object drops the article", () => { - expect(describeToolCall("list_files", {}, "past")).toBe("Listed files"); - }); - - test("a colon-namespaced GitHub tool still names the provider, not the id", () => { - expect(describeToolCall("github:get_issue", {}, "past")).toBe("Retrieved an issue in GitHub"); - expect(describeToolCall("github__get_issue", {}, "past")).toBe("Retrieved an issue in GitHub"); - }); - - test("a bare search names the query and never the identifier", () => { - expect(describeToolCall("search", { q: "x" }, "past")).toBe('Searched for "x"'); - }); - - test("an unknown colon-namespaced verb title-cases the end name, not the package", () => { - expect(describeToolCall("acme:frobnicate_widget", {}, "past")).toBe("Frobnicate widget"); - }); - - test("an Interchange memory search is a layman sentence, not a qualified id", () => { - const phrase = describeToolCall( - "@corbits/memory-tools/memory:memory_search", - { query: "outbound" }, - "past", - ); - expect(phrase).toBe('Searched memory for "outbound"'); - expect(phrase).not.toContain("@"); - expect(phrase).not.toContain("/"); - expect(phrase).not.toContain(":"); - }); - - test("an Interchange memory search still running has no query to name", () => { - expect(describeToolCall("@corbits/memory-tools/memory:memory_search", {}, "present")).toBe( - "Searching memory", - ); - }); - - test("an Interchange list-agents call pluralizes without a package path", () => { - expect(describeToolCall("@corbits/agent-directory-tools/ad:list_agents", {}, "past")).toBe( - "Listed agents", - ); - }); - - test("an Interchange ask-user call is a question, not the tool id", () => { - expect(describeToolCall("@corbits/interaction-tools/ask-user:ask_user", {}, "past")).toBe( - "Asked a question", - ); - }); - - test("a verb in the middle of the leftover name still tenses, without repeating Linear", () => { - const phrase = describeToolCall( - "@corbits/linear-tools/li:linear_list_recent_issues", - {}, - "past", - ); - expect(phrase).toBe("Listed recent issues in Linear"); - expect(phrase).not.toBe("Linear list recent issues in Linear"); - expect( - describeToolCall("@corbits/linear-tools/li:linear_list_recent_issues", {}, "present"), - ).toBe("Listing recent issues in Linear"); - }); - - test("a nameless-verb tool still tenses its query clause", () => { - expect(describeToolCall("github_activity", { query: "bench pricing" }, "past")).toBe( - 'Ran github activity for "bench pricing"', - ); - expect(describeToolCall("github_activity", { query: "bench pricing" }, "present")).toBe( - 'Running github activity for "bench pricing"', - ); - }); -}); - -describe("plainTextOfOutput", () => { - test("pulls the text out of MCP content blocks", () => { - expect( - plainTextOfOutput([ - { type: "text", text: "first" }, - { type: "text", text: "second" }, - ]), - ).toBe("first\nsecond"); - }); - - test("unwraps a result envelope's content", () => { - expect(plainTextOfOutput({ content: [{ type: "text", text: "hi" }] })).toBe("hi"); - }); - - test("returns nothing for an opaque object rather than stringifying it", () => { - expect(plainTextOfOutput({ rows: 4, cursor: "abc" })).toBeUndefined(); - }); -}); - -describe("summarizeToolOutput", () => { - test("a failure always says something, even with nothing to go on", () => { - expect(summarizeToolOutput("failed", undefined)).toBe("No reason given."); - }); - - test("a failure keeps its first line as the reason", () => { - expect(summarizeToolOutput("failed", "Repository not found\n at listIssues")).toBe( - "Repository not found", - ); - }); - - test("a success with no prose falls back to counting the results", () => { - expect(summarizeToolOutput("success", [{ id: 1 }, { id: 2 }])).toBe("2 results."); - expect(summarizeToolOutput("success", [])).toBe("Nothing found."); - }); - - test("a running call has no detail to open onto yet", () => { - expect(summarizeToolOutput("running", undefined)).toBeUndefined(); - }); - - test("an opaque success detail never becomes JSON", () => { - const detail = summarizeToolOutput("success", { cursor: "abc" }); - expect(detail).toBeUndefined(); - }); -}); - -describe("toToolActivityRow", () => { - test("a failed call is failed, and says why in plain text", () => { - const row = toToolActivityRow( - trace({ - name: "github__get_issue", - input: { repo: "corbitsdev/workbench" }, - status: "error", - output: [{ type: "text", text: "Repository not found" }], - }), - "k", - ); - expect(row.status).toBe("failed"); - expect(row.phrase).toBe("Retrieved an issue in GitHub corbitsdev/workbench"); - expect(row.detail).toBe("Repository not found"); - }); - - test("a running call speaks in the present tense", () => { - const row = toToolActivityRow( - trace({ name: "web_search", input: { query: "x" }, status: "running" }), - "k", - ); - expect(row.phrase).toBe('Searching the web for "x"'); - expect(row.detail).toBeUndefined(); - }); - - test("an Interchange memory search carries the end name, no provider, and a search glyph", () => { - const row = toToolActivityRow( - trace({ - name: "@corbits/memory-tools/memory:memory_search", - input: { query: "outbound" }, - status: "success", - }), - "k", - ); - expect(row.phrase).toBe('Searched memory for "outbound"'); - expect(row.provider).toBeUndefined(); - expect(row.toolName).toBe("memory_search"); - expect(["search", "memory"]).toContain(row.glyph); - expect(row.phrase).not.toContain("@"); - expect(row.phrase).not.toContain("/"); - expect(row.phrase).not.toContain(":"); - }); - - test("ask_user success never opens onto the model-facing instruction", () => { - const row = toToolActivityRow( - trace({ - name: "@corbits/interaction-tools/ask-user:ask_user", - status: "success", - output: "The question has been shown to the user. Do not repeat the question.", - }), - "k", - ); - expect(row.phrase).toBe("Asked a question"); - expect(row.detail).toBeUndefined(); - expect(row.toolName).toBe("ask_user"); - expect(row.glyph).toBe("ask"); - }); - - test("a JSON items payload becomes a result count, not the JSON", () => { - const json = JSON.stringify({ items: [{}, {}, {}] }); - const row = toToolActivityRow( - trace({ - name: "memory_search", - status: "success", - output: json, - }), - "k", - ); - expect(row.detail).toBe("3 results."); - expect(row.detail).not.toContain("{"); - expect(row.detail).not.toContain("items"); - }); - - test("JSON sitting inside a content-block text field is still a count, not the JSON", () => { - const row = toToolActivityRow( - trace({ - name: "memory_search", - status: "success", - output: [ - { - type: "text", - text: JSON.stringify({ items: [{}, {}] }), - }, - ], - }), - "k", - ); - expect(row.detail).toBe("2 results."); - expect(row.detail).not.toContain("{"); - }); - - test("an unknown colon leftover is not a provider", () => { - const row = toToolActivityRow( - trace({ name: "acme:frobnicate_widget", status: "success" }), - "k", - ); - expect(row.provider).toBeUndefined(); - expect(row.phrase).toBe("Frobnicate widget"); - }); - - test("an Interchange Linear list carries a Linear tile and a tensed sentence", () => { - const row = toToolActivityRow( - trace({ - name: "@corbits/linear-tools/li:linear_list_recent_issues", - status: "success", - }), - "k", - ); - expect(row.phrase).toBe("Listed recent issues in Linear"); - expect(row.provider).toBe("linear"); - expect(row.toolName).toBe("linear_list_recent_issues"); - expect(providerTile(row.provider ?? "")).toEqual({ - initials: "Li", - color: "#5e6ad2", - }); - }); -}); - -describe("providerTile", () => { - test("a known provider gets its brand initials and color", () => { - expect(providerTile("linear")).toEqual({ - initials: "Li", - color: "#5e6ad2", - }); - expect(providerTile("github")).toEqual({ - initials: "GH", - color: "#24292f", - }); - }); - - test("an unrecognized leftover is not a brand tile", () => { - expect(providerTile("acme")).toBeUndefined(); - expect(providerTile("memory")).toBeUndefined(); - }); -}); - -describe("groupTimelineParts", () => { - const text = (value: string): Part => ({ kind: "text", text: value }); - - test("consecutive tool calls fold into one round", () => { - const groups = groupTimelineParts( - [ - text("Looking into it."), - trace({ name: "web_search" }), - trace({ name: "read_file" }), - trace({ name: "write_file" }), - text("Here is what I found."), - ], - "m1", - ); - expect(groups.map((group) => group.kind)).toEqual(["part", "tool-activity", "part"]); - const round = groups[1]; - expect(round?.kind === "tool-activity" && round.rows.length).toBe(3); - }); - - test("rounds separated by prose stay separate rounds", () => { - const groups = groupTimelineParts([trace({}), text("mid"), trace({}), trace({})], "m1"); - expect(groups.map((group) => group.kind)).toEqual(["tool-activity", "part", "tool-activity"]); - }); - - test("a message with no tool calls is unchanged", () => { - const groups = groupTimelineParts([text("hello")], "m1"); - expect(groups).toEqual([{ kind: "part", part: text("hello"), key: "m1-0" }]); - }); -}); diff --git a/apps/web/src/chat/workbench-roster.test.ts b/apps/web/src/chat/workbench-roster.test.ts deleted file mode 100644 index 1460fb93f..000000000 --- a/apps/web/src/chat/workbench-roster.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { appendRoster, stripRoster } from "./workbench-roster"; - -describe("appendRoster / stripRoster", () => { - test("round-trips a message through append and strip", () => { - const body = "Please pass this to the scribe."; - const withRoster = appendRoster(body, [ - { name: "Scribe", address: "run_abc@example.com", kind: "agent" }, - { name: "Myra", address: "run_def@example.com", kind: "agent" }, - ]); - expect(withRoster).toBe( - "Please pass this to the scribe.\n\nParticipants:\nScribe \nMyra ", - ); - expect(stripRoster(withRoster)).toBe(body); - }); - - test("is a no-op with no entries", () => { - expect(appendRoster("hello", [])).toBe("hello"); - expect(stripRoster("hello")).toBe("hello"); - }); - - test("appends a cc instruction naming the person, and strips it too", () => { - const body = "Please pass this to the scribe."; - const withRoster = appendRoster(body, [ - { name: "Alice", address: "alice@example.com", kind: "person" }, - { name: "Scribe", address: "run_abc@example.com", kind: "agent" }, - ]); - expect(withRoster).toBe( - "Please pass this to the scribe.\n\n" + - "Participants:\nAlice \nScribe \n\n" + - 'When you mail another participant, pass `to` as a list with them and the person, e.g. `to: ["", "alice@example.com"]`, never one comma-joined string, and give every mail a short subject.', - ); - expect(stripRoster(withRoster)).toBe(body); - }); - - test("names every person when more than one is in the roster", () => { - const withRoster = appendRoster("hi", [ - { name: "Alice", address: "alice@example.com", kind: "person" }, - { name: "Bob", address: "bob@example.com", kind: "person" }, - { name: "Scribe", address: "run_abc@example.com", kind: "agent" }, - ]); - expect(withRoster).toContain( - '`to: ["", "alice@example.com", "bob@example.com"]`', - ); - }); -}); diff --git a/apps/web/src/command-palette-actions.test.ts b/apps/web/src/command-palette-actions.test.ts deleted file mode 100644 index e965a8a25..000000000 --- a/apps/web/src/command-palette-actions.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { - ACTION_COMMANDS, - consumePendingNewSkill, - resetPendingDialogRequests, - runActionCommand, -} from "./command-palette-actions"; -import { resetPendingLibraryUpload } from "./library-upload"; -import { NEW_WORKBENCH_PATH } from "./routes"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - resetPendingDialogRequests(); - resetPendingLibraryUpload(); - globalThis.fetch = realFetch; -}); - -function context(overrides: { - readonly path: string; - readonly navigated?: string[]; - readonly tenantId?: string | null; -}) { - const navigated: string[] = overrides.navigated ?? []; - const dispatched: string[] = []; - let themeCycled = false; - let canvasClosed = false; - const listener = (event: Event) => dispatched.push(event.type); - for (const type of ["workbench:skills:create", "workbench:tasks:create"]) { - window.addEventListener(type, listener); - } - const ctx = { - path: overrides.path, - navigate: (to: string) => navigated.push(to), - tenantId: overrides.tenantId !== undefined ? overrides.tenantId : "tenant-1", - cycleTheme: () => { - themeCycled = true; - }, - closeCanvas: () => { - canvasClosed = true; - }, - }; - return { - ctx, - navigated, - dispatched, - themeCycled: () => themeCycled, - canvasClosed: () => canvasClosed, - }; -} - -describe("ACTION_COMMANDS", () => { - test("does not include New thread — killed by owner decision", () => { - const ids: readonly string[] = ACTION_COMMANDS.map((c) => c.id); - expect(ids.includes("new-thread")).toBe(false); - }); - - test("does not include Talk to Myra — standalone chats are gone", () => { - const ids: readonly string[] = ACTION_COMMANDS.map((c) => c.id); - expect(ids.includes("talk-to-myra")).toBe(false); - }); - - test("every command has a stable id, title, and subtitle", () => { - for (const command of ACTION_COMMANDS) { - expect(command.id.length).toBeGreaterThan(0); - expect(command.title.length).toBeGreaterThan(0); - expect(command.subtitle.length).toBeGreaterThan(0); - } - }); - - test("exactly one New workbench create row — no duplicate title+destination", () => { - const newWorkbenchRows = ACTION_COMMANDS.filter((c) => c.title === "New workbench"); - expect(newWorkbenchRows).toHaveLength(1); - expect(newWorkbenchRows[0]?.id).toBe("new-workbench"); - - const titleCounts = new Map(); - for (const command of ACTION_COMMANDS) { - titleCounts.set(command.title, (titleCounts.get(command.title) ?? 0) + 1); - } - for (const [title, count] of titleCounts) { - expect(count, `duplicate title: ${title}`).toBe(1); - } - }); - - test("new-workbench speaks consumer language, not mint", () => { - const workbench = ACTION_COMMANDS.find((c) => c.id === "new-workbench"); - expect(workbench?.title).toBe("New workbench"); - expect(workbench?.subtitle).toBe("Start a new workbench"); - expect(workbench?.subtitle.toLowerCase()).not.toContain("mint"); - }); - - test("labels 'New skill' to match the app's authoring model, not 'Install skill'", () => { - const skillCommand = ACTION_COMMANDS.find((c) => c.id === "new-skill"); - expect(skillCommand?.title).toBe("New skill"); - }); -}); - -describe("runActionCommand", () => { - test("new-workbench opens the template picker — no dialog, no pending flag, no instant mint", async () => { - const { ctx, navigated, dispatched } = context({ path: "/library" }); - await runActionCommand("new-workbench", ctx); - expect(dispatched).toEqual([]); - expect(navigated).toEqual([NEW_WORKBENCH_PATH]); - }); - - test("new-skill off-route navigates and records a pending flag instead of dispatching", async () => { - const { ctx, navigated, dispatched } = context({ path: "/library" }); - await runActionCommand("new-skill", ctx); - expect(dispatched).toEqual([]); - expect(navigated).toEqual(["/skills"]); - expect(consumePendingNewSkill()).toBe(true); - }); - - test("upload-artifact navigates to /artifacts when off-route", async () => { - const { ctx, navigated } = context({ path: "/agents" }); - await runActionCommand("upload-artifact", ctx); - expect(navigated).toEqual(["/artifacts"]); - }); - - test("upload-artifact does not navigate when already on /artifacts", async () => { - const { ctx, navigated } = context({ path: "/artifacts" }); - await runActionCommand("upload-artifact", ctx); - expect(navigated).toEqual([]); - }); - - test("toggle-theme calls cycleTheme", async () => { - const { ctx, themeCycled } = context({ path: "/" }); - await runActionCommand("toggle-theme", ctx); - expect(themeCycled()).toBe(true); - }); - - test("close-canvas calls closeCanvas", async () => { - const { ctx, canvasClosed } = context({ path: "/" }); - await runActionCommand("close-canvas", ctx); - expect(canvasClosed()).toBe(true); - }); - - test("go-workbenches navigates to /c", async () => { - const { ctx, navigated } = context({ path: "/agents" }); - await runActionCommand("go-workbenches", ctx); - expect(navigated).toEqual(["/w"]); - }); -}); diff --git a/apps/web/src/command-palette-recents-drop.test.ts b/apps/web/src/command-palette-recents-drop.test.ts deleted file mode 100644 index 0e9ab5a58..000000000 --- a/apps/web/src/command-palette-recents-drop.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Keeper for the standalone-chats removal: an `entity:agents:*` recent left -// in storage must never render (and so can never be selected — -// `handleSelect` in the provider has no `entity:agents:` branch, only the -// prefixes `isKnownRecentId` accepts). The store seed below goes through the -// real per-bench store and the real group builder; the filter expression is -// the same one `CommandPaletteProvider` runs on load. - -import { afterEach, describe, expect, test } from "bun:test"; - -import { buildCommandPaletteGroups } from "./command-palette"; -import { recentsStoreForBench } from "./command-palette-recents"; -import { isKnownRecentId } from "./command-palette-provider"; - -const TENANT_ID = "tnt_recents_drop"; -const STORAGE_KEY = `workbench.cmdk-recents:${TENANT_ID}`; - -afterEach(() => { - window.localStorage.removeItem(STORAGE_KEY); -}); - -describe("palette recents-drop", () => { - test("drops legacy entity:agents ids while keeping every live prefix", () => { - expect(isKnownRecentId("entity:agents:agt_1")).toBe(false); - for (const id of [ - "route:/library", - "action:new-workbench", - "entity:workbenches:wb_1", - "entity:routines:def_1", - "entity:skills:research", - "entity:library:art_1", - ]) { - expect(isKnownRecentId(id)).toBe(true); - } - }); - - test("a seeded legacy entry never reaches the rendered recents", () => { - const store = recentsStoreForBench(TENANT_ID); - store.push({ kind: "agents", id: "entity:agents:agt_1", title: "Old Chat Agent" }); - store.push({ - kind: "workbenches", - id: "entity:workbenches:wb_1", - title: "Launch Planning", - subtitle: "Workbench", - }); - - // The exact load expression in `CommandPaletteProvider`. - const loaded = store.load().filter((entry) => isKnownRecentId(entry.id)); - expect(loaded.map((entry) => entry.id)).toEqual(["entity:workbenches:wb_1"]); - - // The exact recent mapping the provider feeds the group builder. - const groups = buildCommandPaletteGroups({ - query: "", - recents: loaded.map((entry) => - entry.subtitle === undefined - ? { id: entry.id, title: entry.title } - : { id: entry.id, title: entry.title, subtitle: entry.subtitle }, - ), - sources: [], - }); - const rendered = groups.flatMap((group) => group.items.map((item) => item.id)); - expect(rendered).toEqual(["entity:workbenches:wb_1"]); - expect(rendered.some((id) => id.startsWith("entity:agents:"))).toBe(false); - }); -}); diff --git a/apps/web/src/command-palette/detail-paths.test.ts b/apps/web/src/command-palette/detail-paths.test.ts deleted file mode 100644 index 8b40cce94..000000000 --- a/apps/web/src/command-palette/detail-paths.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { detailPath } from "./detail-paths"; - -describe("detailPath", () => { - test("an entity's own slug addresses its detail route", () => { - expect(detailPath("/agents", { slug: "weekly-digest", id: "wfd_1" })).toBe( - "/agents/weekly-digest", - ); - }); - - test("a slug is never derived from a display name", () => { - expect(detailPath("/agents", { slug: "Café Crème Bot", id: "wfd_2" })).toBe("/agents/wfd_2"); - expect(detailPath("/skills", { slug: "", id: "skill_1" })).toBe("/skills/skill_1"); - }); - - test("the id fallback survives a segment that needs escaping", () => { - expect(detailPath("/tools", { slug: "Not A Slug", id: "a/b" })).toBe("/tools/a%2Fb"); - }); -}); diff --git a/apps/web/src/command-palette/entity-search.test.ts b/apps/web/src/command-palette/entity-search.test.ts deleted file mode 100644 index 56ca44707..000000000 --- a/apps/web/src/command-palette/entity-search.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { searchEntities } from "./entity-search"; - -describe("searchEntities", () => { - const sources = [ - { - category: "workbenches", - entities: [ - { id: "chan-1", name: "Launch Planning" }, - { id: "chan-2", name: "Support Triage" }, - ], - }, - { - category: "routines", - entities: [ - { id: "rt-1", name: "Nightly Digest" }, - { id: "rt-2", name: "Launch Retro" }, - ], - }, - { - category: "agents", - entities: [ - { id: "agent-1", name: "Launch Agent" }, - { id: "agent-2", name: "Research Helper" }, - ], - }, - ]; - - test("matches by title across every source and never surfaces a raw id", () => { - const page = searchEntities({ - query: "launch", - sources, - pageSize: 10, - offset: 0, - }); - const titles = page.results.map((result) => result.title); - expect(titles).toEqual(["Launch Planning", "Launch Retro", "Launch Agent"]); - expect( - page.results.every( - (result) => - !result.title.startsWith("chan-") && - !result.title.startsWith("rt-") && - !result.title.startsWith("agent-"), - ), - ).toBe(true); - }); - - test("an empty query returns no results — the palette shows its own empty state", () => { - const page = searchEntities({ - query: "", - sources, - pageSize: 10, - offset: 0, - }); - expect(page.results).toEqual([]); - }); - - test("paginates with a page size and reports whether more results remain", () => { - const first = searchEntities({ - query: "a", - sources, - pageSize: 1, - offset: 0, - }); - expect(first.results).toHaveLength(1); - expect(first.hasMore).toBe(true); - - const second = searchEntities({ - query: "a", - sources, - pageSize: 1, - offset: 1, - }); - expect(second.results).toHaveLength(1); - }); - - test("preserves source order and categorizes results by their source", () => { - const page = searchEntities({ - query: "launch", - sources, - pageSize: 10, - offset: 0, - }); - const categories = page.results.map((result) => result.category); - expect(categories).toEqual(["workbenches", "routines", "agents"]); - }); - - test("returns nothing for a source with no matching entities", () => { - const page = searchEntities({ - query: "research", - sources, - pageSize: 10, - offset: 0, - }); - const titles = page.results.map((result) => result.title); - expect(titles).toEqual(["Research Helper"]); - expect(page.results[0]?.category).toBe("agents"); - }); - - test("handles an empty sources list without error", () => { - const page = searchEntities({ - query: "anything", - sources: [], - pageSize: 10, - offset: 0, - }); - expect(page.results).toEqual([]); - expect(page.hasMore).toBe(false); - }); -}); diff --git a/apps/web/src/command-palette/scope.test.ts b/apps/web/src/command-palette/scope.test.ts deleted file mode 100644 index 57eda6a26..000000000 --- a/apps/web/src/command-palette/scope.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { isBareScopeQuery, parsePaletteQuery } from "./scope"; - -describe("parsePaletteQuery", () => { - test("no prefix leaves the whole string unscoped", () => { - expect(parsePaletteQuery("routines")).toEqual({ - scope: null, - query: "routines", - }); - }); - - test("empty string is unscoped with an empty query", () => { - expect(parsePaletteQuery("")).toEqual({ scope: null, query: "" }); - }); - - test("# scopes to workbenches and strips the prefix", () => { - const result = parsePaletteQuery("#eng"); - expect(result.scope?.kind).toBe("workbenches"); - expect(result.query).toBe("eng"); - }); - - test("@ scopes to people and strips the prefix", () => { - const result = parsePaletteQuery("@myra"); - expect(result.scope?.kind).toBe("people"); - expect(result.query).toBe("myra"); - }); - - test("> scopes to actions and strips the prefix", () => { - const result = parsePaletteQuery(">theme"); - expect(result.scope?.kind).toBe("actions"); - expect(result.query).toBe("theme"); - }); - - test("/ scopes to pages and strips the prefix", () => { - const result = parsePaletteQuery("/library"); - expect(result.scope?.kind).toBe("pages"); - expect(result.query).toBe("library"); - }); - - test("a bare scope prefix yields an empty query", () => { - expect(parsePaletteQuery("#")).toEqual({ - scope: { prefix: "#", kind: "workbenches", label: "workbenches" }, - query: "", - }); - }); - - test("trims whitespace after the prefix", () => { - expect(parsePaletteQuery("# eng ")).toEqual({ - scope: { prefix: "#", kind: "workbenches", label: "workbenches" }, - query: "eng", - }); - }); - - test("an unrecognized leading character is not a scope", () => { - expect(parsePaletteQuery("!eng")).toEqual({ - scope: null, - query: "!eng", - }); - }); -}); - -describe("isBareScopeQuery", () => { - test("a bare prefix with nothing after it is bare", () => { - expect(isBareScopeQuery("#")).toBe(true); - expect(isBareScopeQuery("@")).toBe(true); - }); - - test("a prefix followed only by whitespace is still bare", () => { - expect(isBareScopeQuery("# ")).toBe(true); - }); - - test("a prefix with text after it is not bare", () => { - expect(isBareScopeQuery("#eng")).toBe(false); - }); - - test("no prefix at all is not a bare scope query", () => { - expect(isBareScopeQuery("")).toBe(false); - expect(isBareScopeQuery("eng")).toBe(false); - }); -}); diff --git a/apps/web/src/dom-environment.ts b/apps/web/src/dom-environment.ts deleted file mode 100644 index 3f8ce295b..000000000 --- a/apps/web/src/dom-environment.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Without a real DOM, tests are limited to `renderToStaticMarkup`, which -// never runs an effect. Registered via `bunfig.toml`'s preload. - -import { GlobalRegistrator } from "@happy-dom/global-registrator"; - -GlobalRegistrator.register({ url: "https://workbench.test/" }); - -// React only lets `act()` flush effects when it is told it is in a test -// environment; without this every effect-driven assertion silently sees a -// half-rendered tree. -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -declare global { - var IS_REACT_ACT_ENVIRONMENT: boolean; -} diff --git a/apps/web/src/insights-api.test.ts b/apps/web/src/insights-api.test.ts deleted file mode 100644 index 311b3dc15..000000000 --- a/apps/web/src/insights-api.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { insightsTopLevelRunsPath } from "./insights-api"; - -describe("insightsTopLevelRunsPath", () => { - // Not the deleted `/top-level-runs` or `/me/workflows/runs` routes. - test("hits the native tenant-scoped GET /workflows/runs listing", () => { - const path = insightsTopLevelRunsPath("tenant-1"); - expect(path.startsWith("/api/tenants/tenant-1/workflows/runs?")).toBe(true); - expect(path).not.toContain("/top-level-runs"); - expect(path).not.toContain("/me/workflows/runs"); - }); -}); diff --git a/apps/web/src/insights-deeplinks.test.ts b/apps/web/src/insights-deeplinks.test.ts deleted file mode 100644 index c8d1e4b30..000000000 --- a/apps/web/src/insights-deeplinks.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { WorkflowRun } from "./api"; -import { - workbenchInsightsPath, - INSIGHTS_RUNS_PATH, - runDeepLinkTarget, - runDetailPath, -} from "./insights-deeplinks"; - -function run(id: string): WorkflowRun { - return { - id, - tenantId: "t1", - tenantName: "Bench", - definitionId: "def", - definitionName: "research-brief", - address: "addr", - status: "running", - createdAt: "2026-01-02T00:00:00.000Z", - }; -} - -describe("runDetailPath", () => { - test("builds the /insights/runs/:id path", () => { - expect(runDetailPath("run_123")).toBe(`${INSIGHTS_RUNS_PATH}/run_123`); - }); - - test("encodes ids so a slash or space cannot break out of the segment", () => { - expect(runDetailPath("a/b c")).toBe(`${INSIGHTS_RUNS_PATH}/a%2Fb%20c`); - }); -}); - -describe("runDeepLinkTarget", () => { - test("returns the insights run-detail path for a purpose run", () => { - expect(runDeepLinkTarget(run("run_42"))).toBe(`${INSIGHTS_RUNS_PATH}/run_42`); - }); - - test("is stable across two runs that differ only by id", () => { - expect(runDeepLinkTarget(run("a"))).not.toBe(runDeepLinkTarget(run("b"))); - }); -}); - -describe("workbenchInsightsPath", () => { - test("builds the /insights/workbench/:workbenchId path", () => { - expect(workbenchInsightsPath("ch_42")).toBe("/insights/workbench/ch_42"); - }); - - test("encodes a workbench id so a slash or space cannot break out of the segment", () => { - expect(workbenchInsightsPath("a/b c")).toBe("/insights/workbench/a%2Fb%20c"); - }); -}); diff --git a/apps/web/src/insights-path.test.ts b/apps/web/src/insights-path.test.ts deleted file mode 100644 index 4b335c714..000000000 --- a/apps/web/src/insights-path.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parseInsightsPath } from "./insights-path"; - -describe("parseInsightsPath", () => { - test("resolves a workbench deep link", () => { - expect(parseInsightsPath("/insights/workbench/tnt_1")).toEqual({ - mode: "workbench", - runId: null, - workbenchId: "tnt_1", - }); - }); - - test("resolves a run deep link", () => { - expect(parseInsightsPath("/insights/runs/run_1")).toEqual({ - mode: "run", - runId: "run_1", - workbenchId: null, - }); - }); - - test("resolves the landing and runs-history paths", () => { - expect(parseInsightsPath("/insights")).toEqual({ - mode: "landing", - runId: null, - workbenchId: null, - }); - expect(parseInsightsPath("/insights/runs")).toEqual({ - mode: "runs", - runId: null, - workbenchId: null, - }); - }); - - test("a malformed escape on a workbench deep link falls back to landing, not a throw", () => { - expect(() => parseInsightsPath("/insights/workbench/%E0%A4%A")).not.toThrow(); - expect(parseInsightsPath("/insights/workbench/%E0%A4%A")).toEqual({ - mode: "landing", - runId: null, - workbenchId: null, - }); - }); - - test("a malformed escape on a run deep link falls back to landing, not a throw", () => { - expect(() => parseInsightsPath("/insights/runs/%")).not.toThrow(); - expect(parseInsightsPath("/insights/runs/%")).toEqual({ - mode: "landing", - runId: null, - workbenchId: null, - }); - }); -}); diff --git a/apps/web/src/insights-timeline.test.ts b/apps/web/src/insights-timeline.test.ts deleted file mode 100644 index d8b05eee6..000000000 --- a/apps/web/src/insights-timeline.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { WorkflowRun } from "./api"; -import { bucketRunsByDay } from "./insights-timeline"; - -function run(createdAt: string, id?: string): WorkflowRun { - return { - id: id ?? createdAt, - tenantId: "t1", - tenantName: "Bench", - definitionId: "def", - definitionName: "research-brief", - address: "addr", - status: "running", - createdAt, - }; -} - -// Fixed "now" at 2026-01-15T18:00Z so day math is deterministic regardless of -// when the suite runs. -const NOW = new Date("2026-01-15T18:00:00.000Z"); - -describe("bucketRunsByDay", () => { - test("emits one bucket per day, oldest first, with zero counts", () => { - const buckets = bucketRunsByDay([], 3, NOW); - expect(buckets).toHaveLength(3); - // Oldest first: Jan 13, Jan 14, Jan 15 (UTC days, since NOW is UTC). - expect(buckets.map((b) => b.key)).toEqual(["2026-01-13", "2026-01-14", "2026-01-15"]); - expect(buckets.every((b) => b.count === 0)).toBe(true); - }); - - test("counts runs into the correct UTC day bucket", () => { - const buckets = bucketRunsByDay( - [ - run("2026-01-15T01:00:00.000Z"), - run("2026-01-15T22:00:00.000Z"), - run("2026-01-14T12:00:00.000Z"), - run("2026-01-10T12:00:00.000Z"), // outside the window — dropped - ], - 3, - NOW, - ); - expect(buckets.map((b) => b.count)).toEqual([0, 1, 2]); - }); - - test("ignores runs older than the window", () => { - const buckets = bucketRunsByDay([run("2025-12-01T00:00:00.000Z")], 7, NOW); - expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(0); - }); - - test("labels are unique within the window", () => { - const buckets = bucketRunsByDay([run("2026-01-15T01:00:00.000Z")], 5, NOW); - const labels = buckets.map((b) => b.label); - expect(new Set(labels).size).toBe(labels.length); - }); - - test("defaults `days` to a sane default when omitted", () => { - const buckets = bucketRunsByDay([], 7, NOW); - expect(buckets).toHaveLength(7); - }); -}); diff --git a/apps/web/src/insights-workbench-scope.test.ts b/apps/web/src/insights-workbench-scope.test.ts deleted file mode 100644 index f5512dfbc..000000000 --- a/apps/web/src/insights-workbench-scope.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { Workbench } from "@/chat"; - -import { resolveWorkbenchInsightsScope } from "./insights-workbench-scope"; - -function workbench(overrides: Partial & { id: string }): Workbench { - return { - title: "Growth", - kind: "workbench", - pinned: false, - participants: [], - tenancy: null, - ...overrides, - } as Workbench; -} - -describe("resolveWorkbenchInsightsScope", () => { - test("resolves a workbench's own workbench tenant", () => { - const workbenches = [ - workbench({ - id: "ch_1", - title: "Growth", - tenancy: { tenantId: "tnt_1" }, - }), - ]; - expect(resolveWorkbenchInsightsScope(workbenches, "ch_1")).toEqual({ - kind: "ready", - tenantId: "tnt_1", - title: "Growth", - }); - }); - - test("reports a true legacy workbench (tenancy null) distinctly", () => { - const workbenches = [workbench({ id: "ch_2", tenancy: null })]; - expect(resolveWorkbenchInsightsScope(workbenches, "ch_2")).toEqual({ - kind: "legacy", - }); - }); - - test("reports not-found for an id absent from the bench's workbench list", () => { - const workbenches = [workbench({ id: "ch_1", tenancy: { tenantId: "tnt_1" } })]; - expect(resolveWorkbenchInsightsScope(workbenches, "tnt_stale")).toEqual({ - kind: "not-found", - }); - }); -}); diff --git a/apps/web/src/lib/api-query/envelope.test.ts b/apps/web/src/lib/api-query/envelope.test.ts deleted file mode 100644 index 38ae0d036..000000000 --- a/apps/web/src/lib/api-query/envelope.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { describe, expect, mock, test } from "bun:test"; - -import { - ApiQueryError, - describeApiError, - describeQueryError, - toAPIQuery, - UnauthenticatedError, -} from "./envelope"; - -describe("ApiQueryError", () => { - test("carries an optional status", () => { - const withStatus = new ApiQueryError("boom", 409); - expect(withStatus.message).toBe("boom"); - expect(withStatus.status).toBe(409); - - const withoutStatus = new ApiQueryError("network down"); - expect(withoutStatus.status).toBeUndefined(); - }); - - test("carries an optional refId from a hub error envelope", () => { - const withRef = new ApiQueryError("boom", 500, "/api/x", "ref_sink_1"); - expect(withRef.refId).toBe("ref_sink_1"); - - const withoutRef = new ApiQueryError("boom", 500, "/api/x"); - expect(withoutRef.refId).toBeUndefined(); - }); -}); - -describe("UnauthenticatedError", () => { - test("defaults to a stable name and message", () => { - const error = new UnauthenticatedError(); - expect(error.name).toBe("UnauthenticatedError"); - expect(error.message).toBe("unauthenticated"); - }); -}); - -describe("describeQueryError", () => { - test("maps a network failure (TypeError) to connectivity copy", () => { - expect(describeQueryError(new TypeError("Failed to fetch"))).toBe( - "Can't reach the server. Check your connection.", - ); - }); - - test("maps everything else to generic retry copy", () => { - expect(describeQueryError(new Error("boom"))).toBe("Something went wrong. Try again."); - expect(describeQueryError(new ApiQueryError("boom", 500))).toBe( - "Something went wrong. Try again.", - ); - }); -}); - -describe("describeApiError", () => { - const pathBearing = new ApiQueryError( - "The server answered 404 for /api/tenants/9f2c-real-tenant/artifacts/42.", - 404, - ); - - test("never echoes a path, tenant id, or status from the error message", () => { - const message = describeApiError(pathBearing, "loading this"); - expect(message).not.toMatch(/\/api\//); - expect(message).not.toContain("9f2c-real-tenant"); - expect(message).not.toContain("404"); - }); - - test("401 and 403 read as an access problem", () => { - expect(describeApiError(new ApiQueryError("boom", 401), "loading this")).toBe( - "You don't have access to this.", - ); - expect(describeApiError(new ApiQueryError("boom", 403), "loading this")).toBe( - "You don't have access to this.", - ); - }); - - test("404 reads as gone, not as a generic failure", () => { - expect(describeApiError(pathBearing, "loading this")).toBe("This isn't here anymore."); - }); - - test("5xx and network failures share the same actionable copy, named around the task", () => { - expect(describeApiError(new ApiQueryError("boom", 500), "uploading this file")).toBe( - "Something went wrong uploading this file. Try again.", - ); - expect(describeApiError(new TypeError("Failed to fetch"), "starting that task")).toBe( - "Something went wrong starting that task. Try again.", - ); - }); - - test("an error with no status falls back to the same generic copy", () => { - expect(describeApiError(new Error("boom"), "saving this")).toBe( - "Something went wrong saving this. Try again.", - ); - }); -}); - -describe("toAPIQuery", () => { - test("loading while fetch is in flight with no data", () => { - expect( - toAPIQuery({ - isLoading: true, - isError: false, - error: null, - data: undefined, - isPending: true, - fetchStatus: "fetching", - refetch: mock(() => undefined), - }), - ).toEqual({ kind: "loading" }); - }); - - test("maps UnauthenticatedError to unauthenticated", () => { - expect( - toAPIQuery({ - isLoading: false, - isError: true, - error: new UnauthenticatedError(), - data: undefined, - isPending: false, - fetchStatus: "idle", - refetch: mock(() => undefined), - }), - ).toEqual({ kind: "unauthenticated" }); - }); - - test("maps unknown errors to plain, actionable copy — never the raw message", () => { - const result = toAPIQuery({ - isLoading: false, - isError: true, - error: new Error("boom"), - data: undefined, - isPending: false, - fetchStatus: "idle", - refetch: mock(() => undefined), - }); - expect(result).toEqual({ - kind: "error", - message: "Something went wrong. Try again.", - retry: expect.any(Function), - }); - }); - - test("maps a network failure (TypeError) to connectivity copy", () => { - const result = toAPIQuery({ - isLoading: false, - isError: true, - error: new TypeError("Failed to fetch"), - data: undefined, - isPending: false, - fetchStatus: "idle", - refetch: mock(() => undefined), - }); - expect(result).toEqual({ - kind: "error", - message: "Can't reach the server. Check your connection.", - retry: expect.any(Function), - }); - }); - - test("threads an ApiQueryError's status onto the error kind", () => { - const result = toAPIQuery({ - isLoading: false, - isError: true, - error: new ApiQueryError("not found", 404), - data: undefined, - isPending: false, - fetchStatus: "idle", - refetch: mock(() => undefined), - }); - expect(result).toEqual({ - kind: "error", - message: "Something went wrong. Try again.", - retry: expect.any(Function), - status: 404, - }); - }); - - test("error's retry calls the query's own refetch", () => { - const refetch = mock(() => undefined); - const result = toAPIQuery({ - isLoading: false, - isError: true, - error: new Error("boom"), - data: undefined, - isPending: false, - fetchStatus: "idle", - refetch, - }); - if (result.kind !== "error") throw new Error("expected error kind"); - result.retry(); - expect(refetch).toHaveBeenCalledTimes(1); - }); - - test("ready when data is present", () => { - expect( - toAPIQuery({ - isLoading: false, - isError: false, - error: null, - data: { ok: true }, - isPending: false, - fetchStatus: "idle", - refetch: mock(() => undefined), - }), - ).toEqual({ kind: "ready", data: { ok: true } }); - }); - - test("disabled / idle with no data still reports loading", () => { - expect( - toAPIQuery({ - isLoading: false, - isError: false, - error: null, - data: undefined, - isPending: true, - fetchStatus: "idle", - refetch: mock(() => undefined), - }), - ).toEqual({ kind: "loading" }); - }); -}); diff --git a/apps/web/src/lib/api-query/query-view.test.tsx b/apps/web/src/lib/api-query/query-view.test.tsx deleted file mode 100644 index 84ca79840..000000000 --- a/apps/web/src/lib/api-query/query-view.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -// Asserts a failed query never strands a person with raw error text and -// nothing to do about it. - -import { describe, expect, test } from "bun:test"; -import { renderToStaticMarkup } from "react-dom/server"; - -import type { APIQuery } from "./envelope"; -import { QueryView, SignedOutNotice } from "./query-view"; - -function render(query: APIQuery): string { - return renderToStaticMarkup( - - {(data) =>
{data}
} -
, - ); -} - -describe("QueryView error state", () => { - test("never renders the raw technical message, even when it carries a path", () => { - const markup = render({ - kind: "error", - message: "The server answered 500 for /api/tenants/abc-123/benches.", - retry: () => undefined, - }); - expect(markup).toContain("Couldn't load your benches"); - expect(markup).not.toMatch(/\/api\//); - expect(markup).not.toContain("abc-123"); - expect(markup).toContain("Something went wrong loading your benches. Try again."); - }); - - test("404 reads as gone, not as a generic failure", () => { - const markup = render({ - kind: "error", - message: "The server answered 404 for /api/tenants/abc-123/benches.", - retry: () => undefined, - status: 404, - }); - expect(markup).toContain("This isn't here anymore."); - }); - - test("401/403 reads as an access problem", () => { - const markup = render({ - kind: "error", - message: "The server answered 403 for /api/tenants/abc-123/benches.", - retry: () => undefined, - status: 403, - }); - expect(markup).toContain("You don't have access to this."); - }); - - test("offers a Retry action", () => { - const markup = render({ - kind: "error", - message: "Something went wrong. Try again.", - retry: () => undefined, - }); - expect(markup).toContain("Retry"); - }); -}); - -describe("QueryView loading skeletons", () => { - test("defaults to the fixed block skeleton", () => { - const markup = render({ kind: "loading" }); - expect(markup).toContain("query-skeleton"); - expect(markup).not.toContain("query-skeleton-rows"); - expect(markup).not.toContain("query-skeleton-detail"); - }); - - test('skeleton="rows" renders purpose-shaped list-row placeholders', () => { - const markup = renderToStaticMarkup( - query={{ kind: "loading" }} label="items" skeleton="rows"> - {(data) =>
{data}
} - , - ); - expect(markup).toContain("query-skeleton-rows"); - expect(markup).toContain("query-skeleton-row"); - }); - - test('skeleton="detail" renders a header-plus-lines placeholder', () => { - const markup = renderToStaticMarkup( - query={{ kind: "loading" }} label="item" skeleton="detail"> - {(data) =>
{data}
} - , - ); - expect(markup).toContain("query-skeleton-detail"); - expect(markup).toContain("query-skeleton-detail-header"); - }); - - test("loadingContent overrides the skeleton entirely for a page-level wait", () => { - const markup = renderToStaticMarkup( - - query={{ kind: "loading" }} - label="item" - loadingContent={
Hang tight…
} - > - {(data) =>
{data}
} - , - ); - expect(markup).toContain("my-warm-loader"); - expect(markup).toContain("Hang tight…"); - expect(markup).not.toContain("query-skeleton"); - }); -}); - -describe("SignedOutNotice", () => { - test("offers a real Reload action, not just copy", () => { - const markup = renderToStaticMarkup(); - expect(markup).toContain("Sign in required"); - expect(markup).toContain("Reload"); - }); -}); diff --git a/apps/web/src/lib/client-log/index.test.ts b/apps/web/src/lib/client-log/index.test.ts deleted file mode 100644 index 4bcd9e4e6..000000000 --- a/apps/web/src/lib/client-log/index.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { - flushLogBuffer, - getLogger, - peekLogBuffer, - resetClientLogForTests, - setConsoleThreshold, -} from "./index"; - -describe("client log", () => { - beforeEach(() => resetClientLogForTests()); - afterEach(() => resetClientLogForTests()); - - test("records category, level, message, and data", () => { - const log = getLogger("onboarding.provision"); - log.error("provisioning failed", { userId: "user_1" }); - - const entries = peekLogBuffer(); - expect(entries).toHaveLength(1); - expect(entries[0]?.category).toBe("onboarding.provision"); - expect(entries[0]?.level).toBe("error"); - expect(entries[0]?.message).toBe("provisioning failed"); - expect(entries[0]?.data).toEqual({ userId: "user_1" }); - }); - - test("flushLogBuffer returns and clears; peekLogBuffer only reads", () => { - const log = getLogger("test"); - log.info("one"); - log.info("two"); - - expect(peekLogBuffer()).toHaveLength(2); - expect(peekLogBuffer()).toHaveLength(2); - - const flushed = flushLogBuffer(); - expect(flushed).toHaveLength(2); - expect(peekLogBuffer()).toHaveLength(0); - }); - - test("the ring buffer caps at 500 entries, dropping the oldest", () => { - const log = getLogger("test"); - for (let i = 0; i < 510; i += 1) log.info(`entry ${i}`); - - const entries = peekLogBuffer(); - expect(entries).toHaveLength(500); - expect(entries[0]?.message).toBe("entry 10"); - expect(entries[entries.length - 1]?.message).toBe("entry 509"); - }); - - test("console mirroring respects the configured threshold", () => { - const calls: { level: string; args: unknown[] }[] = []; - const originalLog = console.log; - const originalWarn = console.warn; - const originalError = console.error; - console.log = (...args: unknown[]) => calls.push({ level: "log", args }); - console.warn = (...args: unknown[]) => calls.push({ level: "warn", args }); - console.error = (...args: unknown[]) => calls.push({ level: "error", args }); - - try { - const log = getLogger("test"); - log.debug("quiet by default"); - expect(calls).toHaveLength(0); - - log.warn("mirrors by default"); - expect(calls).toHaveLength(1); - - setConsoleThreshold("debug"); - log.debug("now mirrors too"); - expect(calls).toHaveLength(2); - } finally { - console.log = originalLog; - console.warn = originalWarn; - console.error = originalError; - } - }); -}); diff --git a/apps/web/src/lib/slug/slug.test.ts b/apps/web/src/lib/slug/slug.test.ts deleted file mode 100644 index 310f806b6..000000000 --- a/apps/web/src/lib/slug/slug.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { isValidSlug, slugify, SLUG_MAX_LENGTH } from "./slug"; - -describe("slugify", () => { - test("kebab-cases a display name", () => { - expect(slugify("Triage Bot")).toBe("triage-bot"); - expect(slugify("Release Notes")).toBe("release-notes"); - }); - - test("folds accents to ASCII", () => { - expect(slugify("Café Crème")).toBe("cafe-creme"); - }); - - test("collapses punctuation and trims stray hyphens", () => { - expect(slugify(" --Weekly (report)! -- ")).toBe("weekly-report"); - expect(slugify("PR/issue triage")).toBe("pr-issue-triage"); - }); - - test("keeps digits", () => { - expect(slugify("Sprint 42 recap")).toBe("sprint-42-recap"); - }); - - test("caps length without leaving a trailing hyphen", () => { - const slug = slugify(`${"a".repeat(SLUG_MAX_LENGTH)} tail`); - expect(slug).toBe("a".repeat(SLUG_MAX_LENGTH)); - expect(slugify(`${"b".repeat(SLUG_MAX_LENGTH - 1)} tail`)).toBe( - "b".repeat(SLUG_MAX_LENGTH - 1), - ); - }); - - test("yields the empty string for a name with nothing sluggable", () => { - expect(slugify("—!!—")).toBe(""); - expect(slugify("")).toBe(""); - }); - - test("produces a valid slug for every non-empty result", () => { - for (const name of [ - "Triage Bot", - "Café Crème", - " --Weekly (report)! -- ", - "Sprint 42 recap", - `${"a".repeat(SLUG_MAX_LENGTH)} tail`, - ]) { - expect(isValidSlug(slugify(name))).toBe(true); - } - }); -}); - -describe("isValidSlug", () => { - test("accepts lowercase hyphen-joined words", () => { - expect(isValidSlug("triage-bot")).toBe(true); - expect(isValidSlug("sprint-42")).toBe(true); - expect(isValidSlug("a")).toBe(true); - }); - - test("rejects anything slugify would never produce", () => { - expect(isValidSlug("")).toBe(false); - expect(isValidSlug("Triage-Bot")).toBe(false); - expect(isValidSlug("wfd_1")).toBe(false); - expect(isValidSlug("-triage")).toBe(false); - expect(isValidSlug("triage-")).toBe(false); - expect(isValidSlug("triage--bot")).toBe(false); - expect(isValidSlug("triage bot")).toBe(false); - expect(isValidSlug("triage/bot")).toBe(false); - expect(isValidSlug("café")).toBe(false); - }); - - test("rejects a slug past the length cap", () => { - expect(isValidSlug("c".repeat(SLUG_MAX_LENGTH))).toBe(true); - expect(isValidSlug("c".repeat(SLUG_MAX_LENGTH + 1))).toBe(false); - }); -}); diff --git a/apps/web/src/library-upload.test.ts b/apps/web/src/library-upload.test.ts deleted file mode 100644 index 0ec02f7b5..000000000 --- a/apps/web/src/library-upload.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { - consumePendingLibraryUpload, - requestLibraryUpload, - resetPendingLibraryUpload, -} from "./library-upload"; - -afterEach(() => { - resetPendingLibraryUpload(); -}); - -describe("requestLibraryUpload", () => { - test("on library does not navigate or set pending", () => { - const navigated: string[] = []; - requestLibraryUpload({ - alreadyOnLibrary: true, - navigateToLibrary: () => { - navigated.push("/files"); - }, - }); - expect(navigated).toEqual([]); - expect(consumePendingLibraryUpload()).toBe(false); - }); - - test("off library sets pending, navigates, and consume is one-shot", () => { - const navigated: string[] = []; - requestLibraryUpload({ - alreadyOnLibrary: false, - navigateToLibrary: () => { - navigated.push("/files"); - }, - }); - expect(navigated).toEqual(["/files"]); - expect(consumePendingLibraryUpload()).toBe(true); - expect(consumePendingLibraryUpload()).toBe(false); - }); -}); diff --git a/apps/web/src/library-workbench-scope.test.ts b/apps/web/src/library-workbench-scope.test.ts deleted file mode 100644 index 17d614ddd..000000000 --- a/apps/web/src/library-workbench-scope.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { Workbench } from "@/chat"; - -import { resolveLibraryWorkbenchScope } from "./library-workbench-scope"; - -function workbench(overrides: Partial & { id: string }): Workbench { - return { - title: "Growth", - kind: "workbench", - pinned: false, - participants: [], - tenancy: null, - ...overrides, - } as Workbench; -} - -describe("resolveLibraryWorkbenchScope", () => { - test("resolves a workbench's own tenant and title", () => { - const workbenches = [ - workbench({ - id: "ch_1", - title: "Growth", - tenancy: { tenantId: "tnt_1" }, - }), - ]; - expect(resolveLibraryWorkbenchScope(workbenches, "ch_1")).toEqual({ - tenantId: "tnt_1", - title: "Growth", - }); - }); - - test("returns null with no workbench id", () => { - expect(resolveLibraryWorkbenchScope([], null)).toBeNull(); - }); - - test("returns null for an id absent from the bench's workbench list", () => { - const workbenches = [workbench({ id: "ch_1", tenancy: { tenantId: "tnt_1" } })]; - expect(resolveLibraryWorkbenchScope(workbenches, "ch_stale")).toBeNull(); - }); - - test("returns null for a true legacy workbench with no tenancy", () => { - const workbenches = [workbench({ id: "ch_2", tenancy: null })]; - expect(resolveLibraryWorkbenchScope(workbenches, "ch_2")).toBeNull(); - }); -}); diff --git a/apps/web/src/library/artifacts.test.ts b/apps/web/src/library/artifacts.test.ts deleted file mode 100644 index 62d1157d3..000000000 --- a/apps/web/src/library/artifacts.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -// The Library page's domain rules, tested at the seam: parsing, sorting, -// and filtering never touch a network call, so they are exercised directly -// against fixture `ArtifactSummary` rows. - -import { describe, expect, test } from "bun:test"; - -import { ArtifactSummary, filterArtifacts, sortArtifacts } from "./index"; - -function artifact(overrides: Partial & { readonly id: string }): ArtifactSummary { - return { - title: "Untitled", - kind: "document", - createdAt: "2026-01-01T00:00:00.000Z", - ...overrides, - }; -} - -describe("ArtifactSummary", () => { - test("parses a valid wire row", () => { - const parsed = ArtifactSummary({ - id: "art_1", - title: "Q3 report", - kind: "deck", - createdAt: "2026-01-01T00:00:00.000Z", - }); - expect(parsed).not.toBeInstanceOf(Error); - }); - - test("rejects a row missing a required field", () => { - const parsed = ArtifactSummary({ id: "art_1" }); - expect(parsed instanceof Error || "id" in Object(parsed) === false).toBeTruthy(); - }); -}); - -describe("sortArtifacts", () => { - const older = artifact({ id: "a", createdAt: "2026-01-01T00:00:00.000Z" }); - const newer = artifact({ id: "b", createdAt: "2026-02-01T00:00:00.000Z" }); - - test("newest first by default ordering", () => { - expect(sortArtifacts([older, newer], "newest").map((a) => a.id)).toEqual(["b", "a"]); - }); - - test("oldest first when asked", () => { - expect(sortArtifacts([newer, older], "oldest").map((a) => a.id)).toEqual(["a", "b"]); - }); - - test("does not mutate the input array", () => { - const input = [newer, older]; - sortArtifacts(input, "oldest"); - expect(input).toEqual([newer, older]); - }); -}); - -describe("filterArtifacts", () => { - const report = artifact({ id: "a", title: "Q3 report", kind: "deck" }); - const csv = artifact({ id: "b", title: "Signups export", kind: "csv" }); - - test("an empty query keeps every artifact", () => { - expect(filterArtifacts([report, csv], "")).toEqual([report, csv]); - }); - - test("matches on title, case-insensitively", () => { - expect(filterArtifacts([report, csv], "q3").map((a) => a.id)).toEqual(["a"]); - }); - - test("matches on kind too", () => { - expect(filterArtifacts([report, csv], "csv").map((a) => a.id)).toEqual(["b"]); - }); - - test("no match empties the result rather than falling back to all", () => { - expect(filterArtifacts([report, csv], "nonexistent")).toEqual([]); - }); -}); diff --git a/apps/web/src/library/kind-filter.test.ts b/apps/web/src/library/kind-filter.test.ts deleted file mode 100644 index 241d2f958..000000000 --- a/apps/web/src/library/kind-filter.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - artifactMatchesLibraryKindSegment, - libraryArtifactIdFromPath, - libraryArtifactPath, - libraryKindSegmentFromPath, -} from "./kind-filter"; - -describe("libraryKindSegmentFromPath", () => { - test("returns empty for the bare library path", () => { - expect(libraryKindSegmentFromPath("/artifacts")).toBe(""); - expect(libraryKindSegmentFromPath("/artifacts/")).toBe(""); - }); - - test("returns the first segment under /artifacts", () => { - expect(libraryKindSegmentFromPath("/artifacts/document")).toBe("document"); - expect(libraryKindSegmentFromPath("/artifacts/pdf/extra")).toBe("pdf"); - }); - - test("returns empty for a path outside /artifacts", () => { - expect(libraryKindSegmentFromPath("/workflows")).toBe(""); - }); -}); - -describe("artifactMatchesLibraryKindSegment", () => { - test("empty segment matches everything", () => { - expect(artifactMatchesLibraryKindSegment({ kind: "routine", title: "r" }, "")).toBe(true); - }); - - test("document segment matches kind document and file with doc-ish extension", () => { - expect( - artifactMatchesLibraryKindSegment({ kind: "document", title: "brief" }, "document"), - ).toBe(true); - expect(artifactMatchesLibraryKindSegment({ kind: "file", title: "notes.md" }, "document")).toBe( - true, - ); - expect(artifactMatchesLibraryKindSegment({ kind: "file", title: "data.csv" }, "document")).toBe( - false, - ); - }); - - test("sheet segment matches csv-export, sheet, and spreadsheet-ish files", () => { - expect(artifactMatchesLibraryKindSegment({ kind: "csv-export", title: "q1" }, "sheet")).toBe( - true, - ); - expect(artifactMatchesLibraryKindSegment({ kind: "file", title: "budget.xls" }, "sheet")).toBe( - true, - ); - }); - - test("pdf segment matches kind pdf and file with .pdf extension", () => { - expect(artifactMatchesLibraryKindSegment({ kind: "pdf", title: "contract" }, "pdf")).toBe(true); - expect(artifactMatchesLibraryKindSegment({ kind: "file", title: "contract.pdf" }, "pdf")).toBe( - true, - ); - expect(artifactMatchesLibraryKindSegment({ kind: "file", title: "contract.docx" }, "pdf")).toBe( - false, - ); - }); - - test("routine segment matches only kind routine", () => { - expect(artifactMatchesLibraryKindSegment({ kind: "routine", title: "digest" }, "routine")).toBe( - true, - ); - expect(artifactMatchesLibraryKindSegment({ kind: "file", title: "digest.md" }, "routine")).toBe( - false, - ); - }); - - test("unknown segment matches nothing", () => { - expect(artifactMatchesLibraryKindSegment({ kind: "document", title: "brief" }, "unknown")).toBe( - false, - ); - }); -}); - -describe("libraryArtifactPath / libraryArtifactIdFromPath", () => { - test("round-trips an artifact id through the deep link", () => { - const path = libraryArtifactPath("art_1"); - expect(path).toBe("/artifacts/a/art_1"); - expect(libraryArtifactIdFromPath(path)).toBe("art_1"); - }); - - test("encodes and decodes ids with reserved characters", () => { - const path = libraryArtifactPath("art/weird id"); - expect(libraryArtifactIdFromPath(path)).toBe("art/weird id"); - }); - - test("is null for a plain kind-nav path, never mistaken for a kind segment", () => { - expect(libraryArtifactIdFromPath("/artifacts/document")).toBeNull(); - expect(libraryArtifactIdFromPath("/artifacts")).toBeNull(); - expect(libraryArtifactIdFromPath("/workflows")).toBeNull(); - }); - - test("is null when the artifact segment is empty", () => { - expect(libraryArtifactIdFromPath("/artifacts/a/")).toBeNull(); - }); - - test("a malformed escape reads as no selection, not a throw", () => { - expect(() => libraryArtifactIdFromPath("/artifacts/a/%E0%A4%A")).not.toThrow(); - expect(libraryArtifactIdFromPath("/artifacts/a/%E0%A4%A")).toBeNull(); - }); -}); diff --git a/apps/web/src/library/provenance.test.ts b/apps/web/src/library/provenance.test.ts deleted file mode 100644 index 3f1bfb11e..000000000 --- a/apps/web/src/library/provenance.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { workflowRunIdFromSource } from "./provenance"; - -describe("workflowRunIdFromSource", () => { - test("returns the run id for a workflow-origin source", () => { - expect(workflowRunIdFromSource({ origin: "workflow", runId: "run_1" })).toBe("run_1"); - }); - - test("is null for a non-workflow origin", () => { - expect(workflowRunIdFromSource({ origin: "manual" })).toBeNull(); - expect(workflowRunIdFromSource({ origin: "agent", runId: "run_1" })).toBeNull(); - }); - - test("is null when workflow origin has no usable runId", () => { - expect(workflowRunIdFromSource({ origin: "workflow" })).toBeNull(); - expect(workflowRunIdFromSource({ origin: "workflow", runId: "" })).toBeNull(); - expect(workflowRunIdFromSource({ origin: "workflow", runId: 42 })).toBeNull(); - }); - - test("is null for a missing source", () => { - expect(workflowRunIdFromSource(null)).toBeNull(); - expect(workflowRunIdFromSource(undefined)).toBeNull(); - }); -}); diff --git a/apps/web/src/library/renderer-kind.test.ts b/apps/web/src/library/renderer-kind.test.ts deleted file mode 100644 index f41af7fa4..000000000 --- a/apps/web/src/library/renderer-kind.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - isTextDecodableMediaType, - resolveArtifactRendererKind, - resolveRendererKindFromMediaType, -} from "./renderer-kind"; - -describe("resolveArtifactRendererKind", () => { - test("kind document renders as doc", () => { - expect(resolveArtifactRendererKind({ kind: "document", title: "Brief" })).toBe("doc"); - }); - - test("kind file with a doc-ish extension renders as doc", () => { - expect(resolveArtifactRendererKind({ kind: "file", title: "notes.md" })).toBe("doc"); - }); - - test("kind sheet or csv-export renders as sheet", () => { - expect(resolveArtifactRendererKind({ kind: "sheet", title: "Q1" })).toBe("sheet"); - expect(resolveArtifactRendererKind({ kind: "csv-export", title: "Q1" })).toBe("sheet"); - }); - - test("kind file with a spreadsheet extension renders as sheet", () => { - expect(resolveArtifactRendererKind({ kind: "file", title: "budget.csv" })).toBe("sheet"); - }); - - test("kind pdf, or file with .pdf extension, renders as pdf", () => { - expect(resolveArtifactRendererKind({ kind: "pdf", title: "contract" })).toBe("pdf"); - expect(resolveArtifactRendererKind({ kind: "file", title: "contract.pdf" })).toBe("pdf"); - }); - - test("kind file with .html extension renders as html", () => { - expect(resolveArtifactRendererKind({ kind: "file", title: "landing.html" })).toBe("html"); - }); - - test("routine and unrecognized kinds fall back to unsupported", () => { - expect(resolveArtifactRendererKind({ kind: "routine", title: "Weekly GTM" })).toBe( - "unsupported", - ); - expect(resolveArtifactRendererKind({ kind: "image", title: "logo.png" })).toBe("unsupported"); - }); -}); - -describe("resolveRendererKindFromMediaType", () => { - test("text/markdown and text/plain render as doc", () => { - expect(resolveRendererKindFromMediaType("text/markdown", "notes.md")).toBe("doc"); - expect(resolveRendererKindFromMediaType("text/plain", "notes.txt")).toBe("doc"); - }); - - test("text/csv renders as sheet", () => { - expect(resolveRendererKindFromMediaType("text/csv", "q1.csv")).toBe("sheet"); - }); - - test("application/pdf renders as pdf", () => { - expect(resolveRendererKindFromMediaType("application/pdf", "contract.pdf")).toBe("pdf"); - }); - - test("falls back to filename extension when the MIME type is generic", () => { - expect(resolveRendererKindFromMediaType("application/octet-stream", "notes.md")).toBe("doc"); - }); - - test("an unrecognized MIME type and extension is unsupported", () => { - expect(resolveRendererKindFromMediaType("image/png", "logo.png")).toBe("unsupported"); - }); -}); - -describe("isTextDecodableMediaType", () => { - test("text-ish MIME types are decodable", () => { - expect(isTextDecodableMediaType("text/plain")).toBe(true); - expect(isTextDecodableMediaType("text/csv")).toBe(true); - expect(isTextDecodableMediaType("application/json")).toBe(true); - }); - - test("binary spreadsheet and PDF types are not decodable as text", () => { - expect(isTextDecodableMediaType("application/vnd.ms-excel")).toBe(false); - expect(isTextDecodableMediaType("application/pdf")).toBe(false); - expect(isTextDecodableMediaType("image/png")).toBe(false); - }); -}); diff --git a/apps/web/src/library/save-state.test.ts b/apps/web/src/library/save-state.test.ts deleted file mode 100644 index 7a7028aec..000000000 --- a/apps/web/src/library/save-state.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { formatSaveStateLine, formatSavedLabel } from "./save-state"; - -describe("formatSavedLabel", () => { - test("under a minute reads 'just now'", () => { - expect(formatSavedLabel(1_000, 1_000 + 30_000)).toBe("Saved just now"); - }); - - test("minutes ago", () => { - expect(formatSavedLabel(0, 5 * 60_000)).toBe("Saved 5m ago"); - }); - - test("hours ago", () => { - expect(formatSavedLabel(0, 3 * 60 * 60_000)).toBe("Saved 3h ago"); - }); - - test("a full day or more falls back to a bare 'Saved' rather than a stale hour count", () => { - expect(formatSavedLabel(0, 25 * 60 * 60_000)).toBe("Saved"); - }); -}); - -describe("formatSaveStateLine", () => { - test("read-only renders nothing", () => { - expect(formatSaveStateLine({ kind: "read-only" }, 0)).toBe(""); - }); - - test("saving reads a plain in-flight ellipsis", () => { - expect(formatSaveStateLine({ kind: "saving" }, 0)).toBe("Saving…"); - }); - - test("saved combines the relative-time label with the version, never claiming a version that wasn't confirmed", () => { - expect(formatSaveStateLine({ kind: "saved", version: 12, savedAt: 0 }, 30_000)).toBe( - "Saved just now · v12", - ); - }); - - test("unsaved is honest about not having confirmed a write yet", () => { - expect(formatSaveStateLine({ kind: "unsaved" }, 0)).toBe("Unsaved changes"); - }); -}); diff --git a/apps/web/src/login-next.test.ts b/apps/web/src/login-next.test.ts deleted file mode 100644 index b55fdb01a..000000000 --- a/apps/web/src/login-next.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// the `next` param on /login is attacker-controllable (a crafted -// `/login?next=...` link) — every case here is either a legitimate in-app -// path or an open-redirect shape that must fall back to `/`. - -import { describe, expect, test } from "bun:test"; - -import { buildLoginRedirect, validatedNextPath } from "./login-next"; - -describe("buildLoginRedirect", () => { - test("encodes the path onto /login's next param", () => { - expect(buildLoginRedirect("/files")).toBe("/login?next=%2Ffiles"); - }); - - test("encodes a nested deep link", () => { - expect(buildLoginRedirect("/w/ch_1")).toBe("/login?next=%2Fw%2Fch_1"); - }); -}); - -describe("validatedNextPath", () => { - test("no next param means home", () => { - expect(validatedNextPath("")).toBe("/"); - }); - - test("a bare in-app path round-trips", () => { - expect(validatedNextPath("?next=%2Ffiles")).toBe("/files"); - expect(validatedNextPath("?next=%2Fw%2Fch_1")).toBe("/w/ch_1"); - }); - - test("an absolute URL is rejected", () => { - expect(validatedNextPath("?next=https%3A%2F%2Fevil.example%2Fphish")).toBe("/"); - }); - - test("a protocol-relative path is rejected", () => { - expect(validatedNextPath("?next=%2F%2Fevil.example")).toBe("/"); - }); - - test("a backslash trick is rejected", () => { - expect(validatedNextPath("?next=%2F%5Cevil.example")).toBe("/"); - }); - - test("a bare host with no leading slash is rejected", () => { - expect(validatedNextPath("?next=evil.example")).toBe("/"); - }); - - test("a loop back to /login is rejected", () => { - expect(validatedNextPath("?next=%2Flogin")).toBe("/"); - expect(validatedNextPath("?next=%2Flogin%3Fnext%3D%2Ffiles")).toBe("/"); - }); -}); diff --git a/apps/web/src/login-routing.test.tsx b/apps/web/src/login-routing.test.tsx deleted file mode 100644 index 9b17b5a94..000000000 --- a/apps/web/src/login-routing.test.tsx +++ /dev/null @@ -1,211 +0,0 @@ -// `TestRoot` mirrors `main.tsx`'s `Root` history wiring so these tests -// drive real `pushState`/`popstate` traffic, not a bare `navigate` prop. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act, useCallback, useEffect, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import { App } from "./app"; -import { validatedNextPath } from "./login-next"; -import type { SessionState, SessionUser } from "./session"; - -const realFetch = globalThis.fetch; - -function stubEmptyFetch(): void { - globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => - Promise.resolve( - new Response(JSON.stringify({ data: [], nextCursor: null }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - )) as typeof fetch; -} - -afterEach(() => { - globalThis.fetch = realFetch; - window.history.replaceState(null, "", "/"); -}); - -const user: SessionUser = { - id: "user_1", - name: "Ada", - email: "ada@example.com", -}; - -// Lets a test drive the exact `handleSignedIn` wiring `Root` gives -// `AuthScreen` without simulating a real `LoginForm` submission. -let capturedHandleSignedIn: ((user: SessionUser) => void) | null = null; - -function TestRoot({ initialSession }: { readonly initialSession: SessionState }) { - const [path, setPath] = useState(window.location.pathname); - useEffect(() => { - const onPopState = () => setPath(window.location.pathname); - window.addEventListener("popstate", onPopState); - return () => window.removeEventListener("popstate", onPopState); - }, []); - const navigate = useCallback((to: string) => { - window.history.pushState(null, "", to); - setPath(new URL(to, window.location.origin).pathname); - }, []); - const [session, setSession] = useState(initialSession); - const handleSignedIn = useCallback( - (signedInUser: SessionUser) => { - setSession({ kind: "signed-in", user: signedInUser }); - navigate(validatedNextPath(window.location.search)); - }, - [navigate], - ); - useEffect(() => { - capturedHandleSignedIn = handleSignedIn; - return () => { - capturedHandleSignedIn = null; - }; - }, [handleSignedIn]); - return ( - setSession({ kind: "signed-out" })} - onRetry={() => undefined} - /> - ); -} - -async function flush(): Promise { - for (let count = 0; count < 5; count += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } -} - -async function mount( - initialPath: string, - initialSession: SessionState, -): Promise<{ container: HTMLDivElement; root: Root }> { - stubEmptyFetch(); - window.history.replaceState(null, "", initialPath); - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - await act(async () => { - root.render(); - }); - await flush(); - return { container, root }; -} - -function unmount(container: HTMLDivElement, root: Root): void { - act(() => root.unmount()); - container.remove(); -} - -describe("unauthenticated deep links redirect to /login with next=", () => { - for (const path of ["/artifacts", "/agents", "/skills", "/insights"]) { - test(`${path} bounces to /login?next=${encodeURIComponent(path)}`, async () => { - const { container, root } = await mount(path, { kind: "signed-out" }); - expect(window.location.pathname).toBe("/login"); - expect(window.location.search).toBe(`?next=${encodeURIComponent(path)}`); - expect(container.textContent).toContain("Welcome back"); - unmount(container, root); - }); - } -}); - -describe("login round trip", () => { - test("signing in from a next= redirect returns to that path", async () => { - const { container, root } = await mount("/artifacts", { kind: "signed-out" }); - expect(window.location.pathname).toBe("/login"); - expect(window.location.search).toBe("?next=%2Fartifacts"); - - // Drives the exact `handleSignedIn` wiring `main.tsx`'s `Root` gives - // `AuthScreen` — reading `next=` off the live URL — without simulating - // a full `LoginForm` submission end to end. - await act(async () => { - capturedHandleSignedIn?.(user); - }); - await flush(); - expect(window.location.pathname).toBe("/artifacts"); - unmount(container, root); - }); - - test("already-authed visits to /login bounce home", async () => { - const { container, root } = await mount("/login?next=%2Fartifacts", { - kind: "signed-in", - user, - }); - expect(window.location.pathname).toBe("/"); - unmount(container, root); - }); -}); - -describe("authed deep links render their own page", () => { - const cases: readonly [string, string][] = [ - ["/artifacts", "Artifacts"], - ["/agents", "Agents"], - ["/skills", "Skills"], - ["/insights", "Insights"], - ]; - for (const [path, label] of cases) { - test(`${path} renders while signed in, URL unchanged`, async () => { - const { container, root } = await mount(path, { - kind: "signed-in", - user, - }); - expect(window.location.pathname).toBe(path); - expect(container.textContent).toContain(label); - unmount(container, root); - }); - } - - test("a workbench deep link stays on its own URL", async () => { - const { container, root } = await mount("/w/ch_1", { - kind: "signed-in", - user, - }); - expect(window.location.pathname).toBe("/w/ch_1"); - unmount(container, root); - }); -}); - -describe("back and forward move through real history", () => { - test("three navigations, then back/back/forward land on the right URL", async () => { - const { container, root } = await mount("/artifacts", { - kind: "signed-in", - user, - }); - await act(async () => { - window.history.pushState(null, "", "/agents"); - window.dispatchEvent(new PopStateEvent("popstate")); - }); - await flush(); - await act(async () => { - window.history.pushState(null, "", "/skills"); - window.dispatchEvent(new PopStateEvent("popstate")); - }); - await flush(); - expect(window.location.pathname).toBe("/skills"); - - await act(async () => { - window.history.back(); - }); - await flush(); - expect(window.location.pathname).toBe("/agents"); - - await act(async () => { - window.history.back(); - }); - await flush(); - expect(window.location.pathname).toBe("/artifacts"); - - await act(async () => { - window.history.forward(); - }); - await flush(); - expect(window.location.pathname).toBe("/agents"); - - unmount(container, root); - }); -}); diff --git a/apps/web/src/mcp-servers.test.ts b/apps/web/src/mcp-servers.test.ts deleted file mode 100644 index fa3ba3d84..000000000 --- a/apps/web/src/mcp-servers.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -// The workspace MCP catalog: the workspace id resolves up from any -// workbench, and the built-in Exa row is stored once on the workspace — -// never on the workbench that triggered the ensure. - -import { MCP_STREAMABLE_HTTP_PROVIDER_KEY } from "@corbits/credential-mcp"; -import { EXA_MCP_SERVER, mcpCredentialName, mcpProviderName } from "@corbits/myra/workflow-ids"; -import { describe, expect, test } from "bun:test"; - -import { ensureBuiltInMcpServers, resolveWorkspaceTenantId } from "./mcp-servers"; - -const WORKSPACE_ID = "ws-1"; -const WORKBENCH_ID = "wb-1"; -const SIBLING_WORKBENCH_ID = "wb-2"; - -function pathOf(input: RequestInfo | URL): string { - if (typeof input === "string") return input; - if (input instanceof URL) return input.pathname; - return new URL(input.url).pathname; -} - -type Call = { readonly method: string; readonly path: string }; - -function tenantDetailResponse(id: string, parentId: string | null): Response { - return Response.json({ id, parentId }); -} - -describe("resolveWorkspaceTenantId", () => { - test("a top-level tenant resolves to itself", async () => { - const fetchImpl = (async () => - tenantDetailResponse(WORKSPACE_ID, null)) as unknown as typeof fetch; - await expect(resolveWorkspaceTenantId(WORKSPACE_ID, fetchImpl)).resolves.toBe(WORKSPACE_ID); - }); - - test("a workbench resolves up to its parent workspace", async () => { - const fetchImpl = (async () => - tenantDetailResponse(WORKBENCH_ID, WORKSPACE_ID)) as unknown as typeof fetch; - await expect(resolveWorkspaceTenantId(WORKBENCH_ID, fetchImpl)).resolves.toBe(WORKSPACE_ID); - }); - - test("a tenant detail without a parentId resolves to itself", async () => { - const fetchImpl = (async () => Response.json({ id: WORKSPACE_ID })) as unknown as typeof fetch; - await expect(resolveWorkspaceTenantId(WORKSPACE_ID, fetchImpl)).resolves.toBe(WORKSPACE_ID); - }); - - test("a failed tenant read throws instead of guessing", async () => { - const fetchImpl = (async () => - new Response("nope", { status: 404 })) as unknown as typeof fetch; - await expect(resolveWorkspaceTenantId(WORKBENCH_ID, fetchImpl)).rejects.toThrow(); - }); -}); - -type StoredRow = { - readonly id: string; - readonly name: string; - readonly providerId: string; - readonly metadata: { - readonly mcp: { - readonly handle: string; - readonly name: string; - readonly url: string; - readonly auth: string; - readonly tools: readonly never[]; - }; - }; -}; - -function exaRow(credentialId: string, providerId: string): StoredRow { - return { - id: credentialId, - name: mcpCredentialName(EXA_MCP_SERVER.handle), - providerId, - metadata: { - mcp: { - handle: EXA_MCP_SERVER.handle, - name: EXA_MCP_SERVER.name, - url: EXA_MCP_SERVER.url, - auth: "none", - tools: [], - }, - }, - }; -} - -function tokenRow(): StoredRow { - return { - id: "c-linear", - name: mcpCredentialName("linear"), - providerId: "p-linear-wb", - metadata: { - mcp: { - handle: "linear", - name: "Linear", - url: "https://mcp.linear.app/mcp", - auth: "token", - tools: [], - }, - }, - }; -} - -function workspaceCatalogFetch(opts: { - readonly exaPresent: boolean; - /** Legacy workbench-scoped rows, from before the catalog moved up. */ - readonly workbenchRows?: readonly StoredRow[]; -}): { - readonly fetchImpl: typeof fetch; - readonly calls: Call[]; -} { - const calls: Call[] = []; - const workspaceProviders = - opts.exaPresent === true - ? [ - { - id: "p-exa", - name: mcpProviderName(EXA_MCP_SERVER.handle), - plugin: MCP_STREAMABLE_HTTP_PROVIDER_KEY, - }, - ] - : []; - const workspaceCredentials = opts.exaPresent === true ? [exaRow("c-exa", "p-exa")] : []; - // Mutable: DELETEs drop rows, so tests can assert what moved and what stayed. - let workbenchCredentials = [...(opts.workbenchRows ?? [])]; - const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = pathOf(input); - const method = init?.method ?? "GET"; - calls.push({ method, path }); - - if (path === `/api/tenants/${WORKBENCH_ID}`) - return tenantDetailResponse(WORKBENCH_ID, WORKSPACE_ID); - if (path === `/api/tenants/${WORKSPACE_ID}`) return tenantDetailResponse(WORKSPACE_ID, null); - if (path === `/api/tenants/${SIBLING_WORKBENCH_ID}`) - return tenantDetailResponse(SIBLING_WORKBENCH_ID, WORKSPACE_ID); - if ( - path === `/api/tenants/${SIBLING_WORKBENCH_ID}/providers` || - path === `/api/tenants/${SIBLING_WORKBENCH_ID}/credentials` - ) { - return Response.json({ data: [] }); - } - if (path === `/api/tenants/${WORKBENCH_ID}/providers` && method === "GET") { - return Response.json({ - data: workbenchCredentials.map((row) => ({ - id: row.providerId, - name: mcpProviderName(row.metadata.mcp.handle), - plugin: MCP_STREAMABLE_HTTP_PROVIDER_KEY, - })), - }); - } - if (path === `/api/tenants/${WORKBENCH_ID}/credentials` && method === "GET") { - return Response.json({ data: workbenchCredentials }); - } - if ( - method === "DELETE" && - (path.startsWith(`/api/tenants/${WORKBENCH_ID}/credentials/`) || - path.startsWith(`/api/tenants/${WORKBENCH_ID}/providers/`)) - ) { - const credentialId = path.split("/").pop() ?? ""; - workbenchCredentials = workbenchCredentials.filter( - (row) => row.id !== credentialId && row.providerId !== credentialId, - ); - return Response.json({ ok: true }); - } - if (path === `/api/tenants/${WORKSPACE_ID}/providers`) { - if (method === "GET") { - return Response.json({ data: workspaceProviders }); - } - const id = `p-added-${String(workspaceProviders.length)}`; - workspaceProviders.push({ - id, - name: mcpProviderName(EXA_MCP_SERVER.handle), - plugin: MCP_STREAMABLE_HTTP_PROVIDER_KEY, - }); - return Response.json({ - id, - name: mcpProviderName(EXA_MCP_SERVER.handle), - plugin: MCP_STREAMABLE_HTTP_PROVIDER_KEY, - }); - } - if (path === `/api/tenants/${WORKSPACE_ID}/credentials`) { - if (method === "GET") { - return Response.json({ data: workspaceCredentials }); - } - const bodyText = typeof init?.body === "string" ? init.body : "{}"; - let body: { metadata?: StoredRow["metadata"]; name?: string; providerId?: string } = {}; - try { - body = JSON.parse(bodyText) as typeof body; - } catch { - body = {}; - } - const row: StoredRow = { - id: `c-added-${String(workspaceCredentials.length)}`, - name: body.name ?? mcpCredentialName(EXA_MCP_SERVER.handle), - providerId: body.providerId ?? "p-exa", - metadata: body.metadata ?? exaRow("c-added-0", "p-exa").metadata, - }; - workspaceCredentials.push(row); - return Response.json({ id: row.id, name: row.name, providerId: row.providerId }); - } - if (path === `/api/tenants/${WORKSPACE_ID}/mcp/discover`) { - return Response.json({ data: { serverInfo: {}, tools: [] } }); - } - if (path.startsWith(`/api/tenants/${WORKSPACE_ID}/credentials/`)) { - const credentialId = path.split("/").pop() ?? ""; - const row = workspaceCredentials.find((candidate) => candidate.id === credentialId); - if (method === "PATCH" && row !== undefined) { - const patchText = typeof init?.body === "string" ? init.body : "{}"; - try { - const parsed = JSON.parse(patchText) as { metadata?: StoredRow["metadata"] }; - if (parsed.metadata !== undefined) { - workspaceCredentials.splice(workspaceCredentials.indexOf(row), 1, { - ...row, - metadata: parsed.metadata, - }); - } - } catch { - // Keep the placeholder row; discovery recorded nothing new. - } - } - return Response.json({ ok: true }); - } - throw new Error(`unexpected fetch: ${method} ${path}`); - }) as typeof fetch; - return { fetchImpl, calls }; -} - -describe("ensureBuiltInMcpServers", () => { - test("from a workbench id, an existing workspace Exa is shared with no writes", async () => { - const { fetchImpl, calls } = workspaceCatalogFetch({ exaPresent: true }); - const servers = await ensureBuiltInMcpServers(WORKBENCH_ID, fetchImpl); - expect(servers.map((server) => server.handle)).toContain(EXA_MCP_SERVER.handle); - // The migration check reads the workbench, but nothing is ever stored there. - const writes = calls.filter((call) => call.method !== "GET"); - expect(writes).toEqual([]); - }); - - test("a missing Exa is created on the workspace, not the workbench", async () => { - const { fetchImpl, calls } = workspaceCatalogFetch({ exaPresent: false }); - const servers = await ensureBuiltInMcpServers(WORKBENCH_ID, fetchImpl); - expect(servers.map((server) => server.handle)).toContain(EXA_MCP_SERVER.handle); - const writes = calls.filter((call) => call.method !== "GET"); - expect(writes.length).toBeGreaterThan(0); - for (const write of writes) { - expect(write.path.startsWith(`/api/tenants/${WORKSPACE_ID}/`)).toBe(true); - } - }); - - test("a legacy workbench Exa moves up to an empty workspace exactly once", async () => { - const { fetchImpl, calls } = workspaceCatalogFetch({ - exaPresent: false, - workbenchRows: [exaRow("c-exa-wb", "p-exa-wb")], - }); - const servers = await ensureBuiltInMcpServers(WORKBENCH_ID, fetchImpl); - expect(servers.map((server) => server.handle)).toContain(EXA_MCP_SERVER.handle); - // One re-add on the workspace (POST credentials), then the workbench row - // goes away — and no second add follows, because the moved row lists back. - const workspaceAdds = calls.filter( - (call) => call.method === "POST" && call.path === `/api/tenants/${WORKSPACE_ID}/credentials`, - ); - expect(workspaceAdds).toHaveLength(1); - expect( - calls.some( - (call) => - call.method === "DELETE" && - call.path === `/api/tenants/${WORKBENCH_ID}/credentials/c-exa-wb`, - ), - ).toBe(true); - }); - - test("a legacy workbench Exa dedupes against a workspace Exa instead of moving", async () => { - const { fetchImpl, calls } = workspaceCatalogFetch({ - exaPresent: true, - workbenchRows: [exaRow("c-exa-wb", "p-exa-wb")], - }); - const servers = await ensureBuiltInMcpServers(WORKBENCH_ID, fetchImpl); - expect(servers.map((server) => server.handle)).toContain(EXA_MCP_SERVER.handle); - // No re-add: the workspace already carries Exa, so the workbench twin is - // just dropped. - expect(calls.some((call) => call.method === "POST" && call.path.endsWith("/credentials"))).toBe( - false, - ); - expect( - calls.some( - (call) => - call.method === "DELETE" && - call.path === `/api/tenants/${WORKBENCH_ID}/credentials/c-exa-wb`, - ), - ).toBe(true); - }); - - test("a legacy token row stays orphaned on the workbench — its secret cannot move", async () => { - const { fetchImpl, calls } = workspaceCatalogFetch({ - exaPresent: true, - workbenchRows: [tokenRow()], - }); - await ensureBuiltInMcpServers(WORKBENCH_ID, fetchImpl); - expect( - calls.some((call) => call.method === "DELETE" && call.path.includes("/credentials/c-linear")), - ).toBe(false); - }); - - test("a failed migration still leaves the workspace catalog ensured", async () => { - const { fetchImpl } = workspaceCatalogFetch({ exaPresent: false }); - const failing: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = pathOf(input); - if (path === `/api/tenants/${WORKBENCH_ID}/providers`) { - return new Response("gone", { status: 500 }); - } - return fetchImpl(input, init); - }) as typeof fetch; - const servers = await ensureBuiltInMcpServers(WORKBENCH_ID, failing); - expect(servers.map((server) => server.handle)).toContain(EXA_MCP_SERVER.handle); - }); - - test("concurrent ensures share one flight instead of adding Exa twice", async () => { - const { fetchImpl, calls } = workspaceCatalogFetch({ exaPresent: false }); - const [first, second] = await Promise.all([ - ensureBuiltInMcpServers(WORKBENCH_ID, fetchImpl), - ensureBuiltInMcpServers(WORKBENCH_ID, fetchImpl), - ]); - expect(first.map((server) => server.handle)).toContain(EXA_MCP_SERVER.handle); - expect(second.map((server) => server.handle)).toContain(EXA_MCP_SERVER.handle); - expect( - calls.filter( - (call) => - call.method === "POST" && call.path === `/api/tenants/${WORKSPACE_ID}/credentials`, - ), - ).toHaveLength(1); - }); - - test("sibling workbenches ensuring at once add the shared Exa once", async () => { - const { fetchImpl, calls } = workspaceCatalogFetch({ exaPresent: false }); - await Promise.all([ - ensureBuiltInMcpServers(WORKBENCH_ID, fetchImpl), - ensureBuiltInMcpServers(SIBLING_WORKBENCH_ID, fetchImpl), - ]); - expect( - calls.filter( - (call) => - call.method === "POST" && call.path === `/api/tenants/${WORKSPACE_ID}/credentials`, - ), - ).toHaveLength(1); - }); -}); diff --git a/apps/web/src/myra-deploy.test.ts b/apps/web/src/myra-deploy.test.ts deleted file mode 100644 index cd2fb3842..000000000 --- a/apps/web/src/myra-deploy.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { MCP_TOOLS_PACKAGE } from "@corbits/myra/workflow-ids"; - -import { buildMyraDefinitionJson, buildMyraDeployInput, MyraDeployError } from "./myra-deploy"; -import { MYRA_SOURCE_CONFIG } from "./myra-source"; - -describe("buildMyraDefinitionJson", () => { - test("declares the offering chain's sources so the probe approves them", () => { - const definition = buildMyraDefinitionJson( - "assistant@alice.example", - [ - { provider: "openai-compatible", model: "qwen2.5:7b" }, - { provider: "anthropic", model: "claude-sonnet-5" }, - ], - "crd_000000000000000000000000000000ab", - [], - ) as { steps: Record }; - - expect(definition.steps["assistant"]?.agent.inference.sources).toEqual([ - { provider: "openai-compatible", model: "qwen2.5:7b" }, - { provider: "anthropic", model: "claude-sonnet-5" }, - ]); - }); - - test("gives each MCP server its own binding and its own credential-use requirement", () => { - const definition = buildMyraDefinitionJson( - "assistant@alice.example", - [{ provider: "openai-compatible", model: "qwen2.5:7b" }], - "crd_000000000000000000000000000000ab", - [ - { - handle: "exa", - url: "https://mcp.example.com/mcp", - credentialId: "crd_000000000000000000000000000000cd", - providerName: "mcp-exa", - credentialName: "mcp-exa", - tools: [], - }, - ], - ) as { - credentialBindings: { package: string; handle: string; name: string }[]; - grantRequirements: { - resource: string; - action: string; - effect: string; - source: string; - conditions: { tool: string }; - }[]; - }; - - expect(definition.credentialBindings.find((binding) => binding.handle === "exa")).toMatchObject( - { package: MCP_TOOLS_PACKAGE, name: "mcp-exa" }, - ); - expect( - definition.grantRequirements.filter( - (requirement) => requirement.conditions.tool === `tool:${MCP_TOOLS_PACKAGE}`, - ), - ).toEqual([ - { - resource: "credential:crd_000000000000000000000000000000cd", - action: "use", - effect: "allow", - source: "creator", - conditions: { tool: `tool:${MCP_TOOLS_PACKAGE}` }, - }, - ]); - }); -}); - -describe("buildMyraDeployInput", () => { - test("maps the pushed commit and the operator's offering pick to a source-tree deploy", () => { - const input = buildMyraDeployInput({ - assetId: "ast_123", - commitSha: "abc123", - sourceOfferingIds: ["off_1", "off_2"], - defaultSourceOfferingId: "off_2", - }); - - expect(input).toEqual({ - source: { - kind: "asset", - assetId: "ast_123", - package: { format: "source", commitSha: "abc123" }, - }, - entry: MYRA_SOURCE_CONFIG.entryPath, - sourceOfferingIds: ["off_1", "off_2"], - defaultSourceOfferingId: "off_2", - }); - }); - - test("rejects an empty offering list", () => { - expect(() => - buildMyraDeployInput({ - assetId: "ast_123", - commitSha: "abc123", - sourceOfferingIds: [], - defaultSourceOfferingId: "off_1", - }), - ).toThrow(MyraDeployError); - }); - - test("rejects a default offering id absent from the offering list", () => { - expect(() => - buildMyraDeployInput({ - assetId: "ast_123", - commitSha: "abc123", - sourceOfferingIds: ["off_1"], - defaultSourceOfferingId: "off_2", - }), - ).toThrow(MyraDeployError); - }); -}); diff --git a/apps/web/src/needs-list.test.ts b/apps/web/src/needs-list.test.ts deleted file mode 100644 index 8713668b7..000000000 --- a/apps/web/src/needs-list.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { type } from "arktype"; - -import { - buildNeedsList, - childTenantStore, - NeedsListSchema, - parseNeedsList, - threadLinkStore, - type StringStorage, -} from "./needs-list"; - -function memoryStorage(initial: Record = {}): StringStorage { - const rows = new Map(Object.entries(initial)); - return { - getItem: (key) => rows.get(key) ?? null, - setItem: (key, value) => { - rows.set(key, value); - }, - }; -} - -describe("portable needs-list", () => { - test("represents the primary tenant, top-level Myra, and workbench children — never DMs", () => { - const manifest = buildNeedsList({ - account: { id: "usr_1", email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - workbenches: [ - { - localId: "atlas", - slug: "ada-atlas", - name: "Atlas", - principals: [ - { - kind: "user", - refId: "usr_2", - email: "bea@example.com", - roles: ["member"], - }, - ], - initialMessage: { runId: "run_atlas", content: "Kick off Atlas." }, - }, - ], - }); - - expect(manifest).toMatchObject({ - version: 1, - account: { id: "usr_1" }, - primaryTenant: { kind: "primary", want: "existing" }, - myra: { - definitionRefId: "assistant", - scope: "top-level", - want: "running", - }, - }); - expect("directMessages" in manifest).toBe(false); - expect(manifest.workbenches[0]).toMatchObject({ - kind: "workbench", - parent: "primary", - principals: [{ kind: "user", refId: "usr_2", status: "active" }], - initialMessage: { runId: "run_atlas", content: "Kick off Atlas." }, - }); - expect(parseNeedsList(manifest) instanceof type.errors).toBe(false); - expect(NeedsListSchema).toBeDefined(); - }); - - test("rejects an unknown manifest version", () => { - const manifest = buildNeedsList({ - account: { id: "usr_1", email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - }); - expect(parseNeedsList({ ...manifest, version: 2 }) instanceof type.errors).toBe(true); - }); -}); - -describe("account and hub scoped child-tenant store", () => { - test("does not leak ids between accounts or hubs and deduplicates local ids", () => { - const storage = memoryStorage(); - const ada = childTenantStore(storage, "https://one.example", "usr_ada"); - const bea = childTenantStore(storage, "https://one.example", "usr_bea"); - const otherHub = childTenantStore(storage, "https://two.example", "usr_ada"); - - ada.record({ - localId: "atlas", - tenantId: "tnt_old", - kind: "workbench", - }); - ada.record({ - localId: "atlas", - tenantId: "tnt_current", - kind: "workbench", - primaryThreadMessageId: "", - icon: "mountain", - prefs: { tone: "brief" }, - }); - - expect(ada.load()).toEqual([ - { - localId: "atlas", - tenantId: "tnt_current", - kind: "workbench", - primaryThreadMessageId: "", - icon: "mountain", - prefs: { tone: "brief" }, - }, - ]); - expect(bea.load()).toEqual([]); - expect(otherHub.load()).toEqual([]); - }); - - test("treats corrupt client state as empty", () => { - const storage = memoryStorage({ - "workbench.child-tenants:https%3A%2F%2Fone.example:usr_ada": "not json", - }); - expect(childTenantStore(storage, "https://one.example", "usr_ada").load()).toEqual([]); - }); - - test("keeps valid rows when one row is corrupt, and drops legacy DM rows", () => { - const storage = memoryStorage({ - "workbench.child-tenants:https%3A%2F%2Fone.example:usr_ada": JSON.stringify([ - { - localId: "atlas", - tenantId: "tnt_atlas", - kind: "workbench", - primaryThreadMessageId: "", - }, - { localId: "", tenantId: "tnt_bad", kind: "workbench" }, - { - localId: "dm:run_myra", - tenantId: "tnt_dm", - kind: "chat", - principalRefId: "run_myra", - }, - null, - ]), - }); - expect(childTenantStore(storage, "https://one.example", "usr_ada").load()).toEqual([ - { - localId: "atlas", - tenantId: "tnt_atlas", - kind: "workbench", - primaryThreadMessageId: "", - }, - ]); - }); -}); - -describe("account and hub scoped thread-link store", () => { - test("holds sub-thread fork links beside created ids, deduped by Message-ID", () => { - const storage = memoryStorage(); - const ada = threadLinkStore(storage, "https://one.example", "usr_ada"); - const bea = threadLinkStore(storage, "https://one.example", "usr_bea"); - - ada.record({ - workbenchLocalId: "atlas", - messageId: "", - inReplyTo: "", - references: [""], - }); - ada.record({ - workbenchLocalId: "atlas", - messageId: "", - inReplyTo: "", - references: ["", ""], - }); - - expect(ada.load()).toEqual([ - { - workbenchLocalId: "atlas", - messageId: "", - inReplyTo: "", - references: ["", ""], - }, - ]); - expect(bea.load()).toEqual([]); - }); - - test("treats corrupt client state as empty and skips bad rows", () => { - const storage = memoryStorage({ - "workbench.thread-links:https%3A%2F%2Fone.example:usr_ada": JSON.stringify([ - { workbenchLocalId: "atlas", messageId: "" }, - { workbenchLocalId: "", messageId: "" }, - null, - ]), - }); - expect(threadLinkStore(storage, "https://one.example", "usr_ada").load()).toEqual([ - { workbenchLocalId: "atlas", messageId: "" }, - ]); - expect( - threadLinkStore( - memoryStorage({ - "workbench.thread-links:https%3A%2F%2Fone.example:usr_ada": "not json", - }), - "https://one.example", - "usr_ada", - ).load(), - ).toEqual([]); - }); -}); diff --git a/apps/web/src/onboarding/ollama-tags.test.ts b/apps/web/src/onboarding/ollama-tags.test.ts deleted file mode 100644 index 57c2bd892..000000000 --- a/apps/web/src/onboarding/ollama-tags.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// fetchOllamaTags: validates the base URL actually reaches Ollama and -// parses its native /api/tags shape, on the bare origin (not /v1). - -import { afterEach, describe, expect, test } from "bun:test"; - -import { fetchOllamaTags, OllamaTagsError, ollamaOrigin } from "./ollama-tags"; - -const realFetch = globalThis.fetch; -afterEach(() => { - globalThis.fetch = realFetch; -}); - -describe("ollamaOrigin", () => { - test("strips a trailing /v1 used for chat completions", () => { - expect(ollamaOrigin("http://localhost:11434/v1")).toBe("http://localhost:11434"); - }); - test("leaves a bare origin alone", () => { - expect(ollamaOrigin("http://localhost:11434")).toBe("http://localhost:11434"); - }); -}); - -describe("fetchOllamaTags", () => { - test("hits /api/tags on the bare origin and returns model names", async () => { - let requested = ""; - globalThis.fetch = ((input: RequestInfo | URL) => { - requested = String(input); - return Promise.resolve( - new Response(JSON.stringify({ models: [{ name: "qwen2.5:14b" }, { name: "llama3" }] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - }) as typeof fetch; - - const tags = await fetchOllamaTags("http://localhost:11434/v1"); - expect(requested).toBe("http://localhost:11434/api/tags"); - expect(tags).toEqual(["qwen2.5:14b", "llama3"]); - }); - - test("rejects with a clear message when the server isn't Ollama", async () => { - globalThis.fetch = ((input: RequestInfo | URL) => { - void input; - return Promise.resolve(new Response("not found", { status: 404 })); - }) as typeof fetch; - await expect(fetchOllamaTags("http://localhost:11434/v1")).rejects.toBeInstanceOf( - OllamaTagsError, - ); - }); - - test("rejects when the response shape doesn't match", async () => { - globalThis.fetch = ((input: RequestInfo | URL) => { - void input; - return Promise.resolve( - new Response(JSON.stringify({ nope: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - }) as typeof fetch; - await expect(fetchOllamaTags("http://localhost:11434/v1")).rejects.toBeInstanceOf( - OllamaTagsError, - ); - }); -}); diff --git a/apps/web/src/pages/home-page.test.tsx b/apps/web/src/pages/home-page.test.tsx deleted file mode 100644 index a28b9d2d4..000000000 --- a/apps/web/src/pages/home-page.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -// Keeper for the workbench-only home: `/` is a hop, never a guess. A live -// last-visited id hops to `/w/:id`; a missing or stale id (and an empty -// list) falls through to the picker — array order is never treated as -// recency. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import { BenchContext, type BenchState } from "../bench-context"; -import { recordLastWorkbenchId } from "../last-workbench"; -import { NavigationProvider } from "../navigation"; -import { TestQueryProvider } from "../test-query-provider"; -import { HomeRoute } from "./home-page"; - -const TENANT_ID = "tnt_bench"; -const realFetch = globalThis.fetch; - -const benchState: BenchState = { - memberships: { - kind: "ready", - data: { - data: [ - { - principalId: "prn_1", - tenantId: TENANT_ID, - tenantName: "Growth Team Bench", - tenantSlug: "growth-team-bench", - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }, - }, - benchMemberships: [ - { - principalId: "prn_1", - tenantId: TENANT_ID, - tenantName: "Growth Team Bench", - tenantSlug: "growth-team-bench", - kind: "user", - status: "active", - roles: [], - }, - ], - selectedTenantId: TENANT_ID, - selectedPrincipalId: "prn_1", - selectTenant: () => undefined, - onBenchCreated: () => undefined, -}; - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -function ownerMembership(tenantId: string, tenantName: string) { - return { - principalId: `prn_${tenantId}`, - tenantId, - tenantName, - tenantSlug: tenantId, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }; -} - -function tenantRow(id: string, name: string, parentId: string | null) { - return { id, name, slug: id, domain: "bench.test", parentId }; -} - -/** Serves the `findOwnedTenants` chain (`/api/me/principals`, then one - * `/api/tenants/:id` per owned membership) for the given workbench ids. */ -function stubWorkbenches(workbenchIds: readonly string[]): void { - globalThis.fetch = ((input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - if (path.startsWith("/api/me/principals")) { - return Promise.resolve( - json({ - data: [ - ownerMembership(TENANT_ID, "Growth Team Bench"), - ...workbenchIds.map((id) => ownerMembership(id, `Workbench ${id}`)), - ], - nextCursor: null, - }), - ); - } - if (path === `/api/tenants/${TENANT_ID}`) { - return Promise.resolve(json(tenantRow(TENANT_ID, "Growth Team Bench", null))); - } - const match = /^\/api\/tenants\/([^/]+)$/.exec(path); - if (match?.[1] !== undefined && workbenchIds.includes(match[1])) { - return Promise.resolve(json(tenantRow(match[1], `Workbench ${match[1]}`, TENANT_ID))); - } - return Promise.resolve(json({ error: { code: "not-found" } }, 404)); - }) as typeof fetch; -} - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - container?.remove(); - container = null; - globalThis.fetch = realFetch; - window.sessionStorage.clear(); -}); - -async function navigatedTo(workbenchIds: readonly string[]): Promise { - stubWorkbenches(workbenchIds); - const navigated: string[] = []; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - - navigated.push(to)}> - - - - - , - ); - }); - for (let tick = 0; tick < 10; tick += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - return navigated; -} - -describe("HomeRoute", () => { - test("a live last id hops to that workbench", async () => { - recordLastWorkbenchId(TENANT_ID, "wb_2"); - expect(await navigatedTo(["wb_1", "wb_2"])).toEqual(["/w/wb_2"]); - }); - - test("a missing last id falls through to the picker, never the first workbench", async () => { - const navigated = await navigatedTo(["wb_1", "wb_2"]); - expect(navigated).toEqual(["/new"]); - }); - - test("a stale last id falls through to the picker", async () => { - recordLastWorkbenchId(TENANT_ID, "wb_gone"); - expect(await navigatedTo(["wb_1", "wb_2"])).toEqual(["/new"]); - }); - - test("an empty workbench list falls through to the picker", async () => { - expect(await navigatedTo([])).toEqual(["/new"]); - }); -}); diff --git a/apps/web/src/pages/insights-page-render.test.tsx b/apps/web/src/pages/insights-page-render.test.tsx deleted file mode 100644 index 30b3a9828..000000000 --- a/apps/web/src/pages/insights-page-render.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -// A malformed percent-escape on a deep link must render the same landing -// dashboard any other unrecognized path gets — never a blank page. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import type { APIQuery } from "@/lib/api-query"; - -import { InsightsPage } from "./insights-page"; -import { BenchContext } from "../bench-context"; -import type { BenchState } from "../bench-context"; -import type { InsightsRun } from "../insights-api"; -import { NavigationProvider } from "../navigation"; - -type RunsStub = { data: readonly InsightsRun[]; nextCursor: string | null }; - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - if (container !== null) { - container.remove(); - container = null; - } -}); - -const readyEmpty = (data: T): APIQuery => ({ kind: "ready", data }); - -const benchState: BenchState = { - memberships: { kind: "ready", data: { data: [], nextCursor: null } }, - benchMemberships: [], - selectedTenantId: "tnt_bench_a", - selectedPrincipalId: "prn_bench_a", - selectTenant: () => {}, - onBenchCreated: () => {}, -}; - -function InsightsPageAtPath({ - path, - runs = { data: [], nextCursor: null }, -}: { - readonly path: string; - readonly runs?: RunsStub; -}) { - return ( - {}}> - - - - - ); -} - -function render(path: string, runs?: RunsStub) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render(); - }); - return container; -} - -describe("InsightsPage with a malformed URL escape", () => { - test("a malformed run deep link still renders the landing dashboard, not run detail", () => { - const el = render("/insights/runs/%"); - expect(el.textContent).not.toBe(""); - expect(el.textContent).toContain("Insights"); - expect(el.textContent).toContain("Recent runs"); - }); -}); - -describe("InsightsPage 'Running now' strip", () => { - test("no in-flight runs: the strip renders nothing, not an empty-state fixture", () => { - const el = render("/insights", { data: [], nextCursor: null }); - expect(el.textContent).not.toContain("Running now"); - }); - - test("a genuinely running run surfaces in the strip by name", () => { - const el = render("/insights", { - data: [ - { - id: "run_1", - tenantId: "tnt_bench_a", - definitionId: "wfd_a", - definitionName: "Weekly digest", - address: "addr", - status: "running", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - routineId: null, - routineName: null, - }, - ], - nextCursor: null, - }); - expect(el.textContent).toContain("Running now"); - expect(el.textContent).toContain("1 in progress"); - expect(el.textContent).toContain("Weekly digest"); - }); - - // Liveness is not a windowed property: a run started long ago must not - // disappear from the strip or the "Running now" KPI. - test("a run started 8 days ago that is still running stays in the strip and the KPI", () => { - const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(); - const el = render("/insights", { - data: [ - { - id: "run_long_haul", - tenantId: "tnt_bench_a", - definitionId: "wfd_a", - definitionName: "Long haul", - address: "addr", - status: "running", - createdAt: eightDaysAgo, - updatedAt: eightDaysAgo, - routineId: null, - routineName: null, - }, - ], - nextCursor: null, - }); - expect(el.textContent).toContain("Running now"); - expect(el.textContent).toContain("1 in progress"); - expect(el.textContent).toContain("Long haul"); - expect(el.textContent).toContain("in flight"); - }); - - test("the elapsed label ticks forward while a run is live, not frozen at first render", async () => { - const startedAt = new Date(Date.now() - 2_000).toISOString(); - const el = render("/insights", { - data: [ - { - id: "run_ticking", - tenantId: "tnt_bench_a", - definitionId: "wfd_a", - definitionName: "Weekly digest", - address: "addr", - status: "running", - createdAt: startedAt, - updatedAt: startedAt, - routineId: null, - routineName: null, - }, - ], - nextCursor: null, - }); - const before = el.textContent; - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 1_200)); - }); - expect(el.textContent).not.toBe(before); - }); -}); diff --git a/apps/web/src/pages/insights-page.test.ts b/apps/web/src/pages/insights-page.test.ts deleted file mode 100644 index 7f133a15a..000000000 --- a/apps/web/src/pages/insights-page.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { FIRE_RUNNING_WINDOW_MS } from "@corbits/workflows/client"; - -import { elapsedLabel, isRunningNow } from "./insights-page"; -import type { InsightsRun } from "../insights-api"; - -function run(partial: Partial & Pick): InsightsRun { - return { - tenantId: "t1", - definitionId: "wfd_a", - definitionName: "Research brief", - address: "addr", - status: "running", - updatedAt: partial.createdAt, - routineId: null, - routineName: null, - ...partial, - }; -} - -describe("elapsedLabel", () => { - test("formats wall-clock time since createdAt", () => { - const now = Date.parse("2026-01-01T00:02:12.000Z"); - expect(elapsedLabel("2026-01-01T00:00:00.000Z", now)).toBe("2.2m"); - }); - - test("an invalid timestamp reads as unknown, not a fabricated duration", () => { - expect(elapsedLabel("not-a-date", Date.now())).toBe("—"); - }); -}); - -describe("isRunningNow", () => { - test("a running run still inside the fire window is in flight", () => { - expect( - isRunningNow( - run({ - id: "fresh", - createdAt: new Date().toISOString(), - status: "running", - }), - ), - ).toBe(true); - }); - - test("updating is in flight", () => { - expect( - isRunningNow( - run({ - id: "updating", - createdAt: new Date().toISOString(), - status: "updating", - }), - ), - ).toBe(true); - }); - - test("a stopped run is not in flight", () => { - expect( - isRunningNow( - run({ - id: "done", - createdAt: new Date().toISOString(), - status: "stopped", - }), - ), - ).toBe(false); - }); - - // Warm-keep: status lingers on `running` after a reply, but past the - // fire window it's not in-flight — must not stay "Running now" forever. - test("endedAt drops in-flight immediately, even while status is still running inside the window", () => { - expect( - isRunningNow( - run({ - id: "just-finished", - createdAt: new Date().toISOString(), - status: "running", - endedAt: new Date().toISOString(), - }), - ), - ).toBe(false); - }); - - test("a live running run past the fire window is still in flight", () => { - expect( - isRunningNow( - run({ - id: "stale", - createdAt: new Date(Date.now() - FIRE_RUNNING_WINDOW_MS - 1).toISOString(), - status: "running", - }), - ), - ).toBe(true); - }); -}); diff --git a/apps/web/src/pages/run-status-tone-parity.test.ts b/apps/web/src/pages/run-status-tone-parity.test.ts deleted file mode 100644 index 9a844e660..000000000 --- a/apps/web/src/pages/run-status-tone-parity.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Calls the real exported code and compares output, since a static scan -// can't tell a switch's tone from an object literal's without parsing. -import { describe, expect, test } from "bun:test"; - -import { RUN_STATUS_TONE } from "@corbits/react-ui"; - -import { statusTone } from "./insights-page"; - -describe("run-status tone parity with react-ui's RUN_STATUS_TONE", () => { - test("Insights' statusTone agrees with canonical for every shared status", () => { - // WorkflowRunStatus ("running"/"stopped") spells these two the same way - // RunStatus does — the exact pair the reviewer caught disagreeing. - expect(statusTone("running")).toBe(RUN_STATUS_TONE.running); - expect(statusTone("stopped")).toBe(RUN_STATUS_TONE.stopped); - }); -}); diff --git a/apps/web/src/path-ids.test.ts b/apps/web/src/path-ids.test.ts deleted file mode 100644 index 9d6147c45..000000000 --- a/apps/web/src/path-ids.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AGENTS_PATH_PREFIX, - detailSlugFromPath, - settingsEntityIdFromPath, - settingsSectionIdFromPath, -} from "./path-ids"; - -describe("detailSlugFromPath", () => { - test("reads the slug a detail path carries", () => { - expect(detailSlugFromPath("/agents/triage-bot", AGENTS_PATH_PREFIX)).toBe( - "triage-bot", - ); - }); - - test("rejects the bare prefix, another prefix, and a nested path", () => { - expect(detailSlugFromPath("/agents", AGENTS_PATH_PREFIX)).toBeNull(); - expect(detailSlugFromPath("/agents/", AGENTS_PATH_PREFIX)).toBeNull(); - expect(detailSlugFromPath("/skills/triage-bot", AGENTS_PATH_PREFIX)).toBeNull(); - expect(detailSlugFromPath("/agents/triage-bot/runs", AGENTS_PATH_PREFIX)).toBeNull(); - }); - - test("rejects an id-shaped segment so id deep links stay with the roster", () => { - expect(detailSlugFromPath("/agents/wfd_1", AGENTS_PATH_PREFIX)).toBeNull(); - }); - - test("rejects percent-escapes rather than decoding them", () => { - expect(detailSlugFromPath("/agents/%", AGENTS_PATH_PREFIX)).toBeNull(); - expect(detailSlugFromPath("/agents/%E0%A4%A", AGENTS_PATH_PREFIX)).toBeNull(); - expect(detailSlugFromPath("/agents/triage%2Dbot", AGENTS_PATH_PREFIX)).toBeNull(); - }); -}); - -describe("id extraction", () => { - test("a malformed escape names no entity instead of throwing", () => { - expect(settingsSectionIdFromPath("/settings/%E0%A4%A")).toBeNull(); - expect(settingsEntityIdFromPath("/settings/agents/%", "agents")).toBeNull(); - }); -}); diff --git a/apps/web/src/pending-approvals.test.tsx b/apps/web/src/pending-approvals.test.tsx deleted file mode 100644 index 894d1debd..000000000 --- a/apps/web/src/pending-approvals.test.tsx +++ /dev/null @@ -1,307 +0,0 @@ -// Covers: a run is named once no matter how many approvals it raised, a -// refused list read shows as a failure not an empty queue, and the chat -// card's status read speaks the same display model. - -import { afterEach, describe, expect, test } from "bun:test"; -import { QueryClient } from "@tanstack/react-query"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; - -import { createChatApprovalActions } from "./approval-actions"; -import { BenchContext, type BenchState } from "./bench-context"; -import { NavigationProvider } from "./navigation"; -import { usePendingApprovals } from "./pending-approvals"; -import { TestQueryProvider } from "./test-query-provider"; - -const realFetch = globalThis.fetch; -const TENANT_ID = "tnt_1"; - -const benchState: BenchState = { - memberships: { - kind: "ready", - data: { - data: [ - { - principalId: "prn_1", - tenantId: TENANT_ID, - tenantName: "Growth Team Bench", - tenantSlug: "growth-team-bench", - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }, - }, - benchMemberships: [ - { - principalId: "prn_1", - tenantId: TENANT_ID, - tenantName: "Growth Team Bench", - tenantSlug: "growth-team-bench", - kind: "user", - status: "active", - roles: [], - }, - ], - selectedTenantId: TENANT_ID, - selectedPrincipalId: "prn_1", - selectTenant: () => undefined, - onBenchCreated: () => undefined, -}; - -function approvalRow(overrides: Record = {}) { - return { - id: "apr_1", - tenantId: TENANT_ID, - anchorRunId: "run_1", - runId: "run_1", - agentAddress: "researcher@growth", - correlationId: "cor_1", - toolDefinition: { - name: "send_email", - description: "Sends an email on the tenant's behalf", - }, - toolArguments: { title: "Welcome Acme" }, - scope: null, - status: "pending", - timeoutAt: null, - resolvedAt: null, - createdAt: "2026-08-20T09:00:00.000Z", - updatedAt: "2026-08-20T09:00:00.000Z", - ...overrides, - }; -} - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -/** Every path the stub was asked for, in order — the evidence that a run is - * named once per run and not once per approval. */ -let requested: string[] = []; - -function stubFetch( - handler: (path: string) => Response | undefined, - fallback: Response = json({ items: [], data: [], nextCursor: null }), -): void { - requested = []; - globalThis.fetch = ((input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - requested.push(path); - return Promise.resolve(handler(path) ?? fallback.clone()); - }) as typeof fetch; -} - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -/** Renders exactly what the composer produced, so the assertions read the - * display model the way a card does. */ -function ApprovalNames() { - const approvals = usePendingApprovals(TENANT_ID); - if (approvals.kind !== "ready") return

{approvals.kind}

; - return ( -
    - {approvals.data.map((approval) => ( -
  • - {approval.agentName} in {approval.benchName}: {approval.headline} -
  • - ))} -
- ); -} - -describe("usePendingApprovals", () => { - let container: HTMLDivElement | null = null; - let root: Root | null = null; - - afterEach(() => { - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - container?.remove(); - container = null; - }); - - async function mount(children: React.ReactNode) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - - undefined}> - {children} - - , - ); - }); - for (let tick = 0; tick < 10; tick += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - } - return container; - } - - test("names both of one run's approvals from a single run-view read", async () => { - stubFetch((path) => { - if (path === `/api/tenants/${TENANT_ID}/approvals`) { - return json({ - data: [approvalRow(), approvalRow({ id: "apr_2" })], - nextCursor: null, - }); - } - if (path === `/api/tenants/${TENANT_ID}/runs/run_1`) { - return json({ - id: "run_1", - definitionId: "def_1", - definitionName: "Research Analyst", - tenantId: TENANT_ID, - address: "researcher@growth", - status: "running", - createdAt: "2026-08-20T08:00:00.000Z", - updatedAt: "2026-08-20T08:00:00.000Z", - }); - } - return undefined; - }); - - const el = await mount(); - - const items = [...el.querySelectorAll("li")].map((li) => li.textContent); - expect(items).toEqual([ - 'Research Analyst in Growth Team Bench: Sends an email on the tenant\'s behalf: "Welcome Acme"', - 'Research Analyst in Growth Team Bench: Sends an email on the tenant\'s behalf: "Welcome Acme"', - ]); - expect(requested.filter((path) => path.includes("/runs/run_1"))).toHaveLength(1); - }); - - test("falls back to the chat roster's name when the run view can't answer", async () => { - stubFetch((path) => { - if (path === `/api/tenants/${TENANT_ID}/approvals`) { - return json({ - data: [approvalRow({ agentAddress: "researcher@growth" })], - nextCursor: null, - }); - } - // The approver lacks a read grant on this run -- fetchAgentName reads - // it as UNNAMED_AGENT, so the roster join is the only source left. - if (path === `/api/tenants/${TENANT_ID}/runs/run_1`) { - return json({ error: { code: "forbidden" } }, 403); - } - if (path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - return json([ - { - id: "run_1", - tenantId: TENANT_ID, - definitionAssetId: "asset_1", - status: "deployed", - createdAt: "2026-08-20T08:00:00.000Z", - updatedAt: "2026-08-20T08:00:00.000Z", - }, - ]); - } - if (path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false`) { - return json([{ id: "asset_1", name: "researcher-bot" }]); - } - if (path.startsWith(`/api/tenants/${TENANT_ID}/workflows/runs`)) { - return json({ - data: [ - { - id: "run_1", - definitionId: "asset_1", - definitionName: "researcher-bot", - tenantId: TENANT_ID, - address: "researcher@growth", - status: "running", - createdAt: "2026-08-20T08:00:00.000Z", - updatedAt: "2026-08-20T08:00:00.000Z", - }, - ], - nextCursor: null, - }); - } - return undefined; - }); - - const el = await mount(); - - expect(el.textContent).toContain("researcher-bot in Growth Team Bench"); - }); - - test("a refused list read reads as a failure, never as an empty queue", async () => { - stubFetch((path) => { - if (path === `/api/tenants/${TENANT_ID}/approvals`) { - return json({ error: { code: "forbidden", message: "no" } }, 403); - } - return undefined; - }); - - function ApprovalsFailure() { - const approvals = usePendingApprovals(TENANT_ID); - if (approvals.kind === "error") return

Couldn't load approvals: {approvals.message}

; - return

Nothing waiting on you

; - } - - const el = await mount(); - - expect(el.textContent).toContain("Couldn't load approvals"); - expect(el.textContent).not.toContain("Nothing waiting on you"); - }); -}); - -describe("chat approve card status read", () => { - function actions() { - return createChatApprovalActions(TENANT_ID, new QueryClient()); - } - - test("a pending approval is actionable, named, and headlined", async () => { - stubFetch((path) => { - if (path === `/api/tenants/${TENANT_ID}/approvals/apr_1`) { - return json(approvalRow()); - } - if (path === `/api/tenants/${TENANT_ID}/runs/run_1`) { - return json({ - id: "run_1", - definitionId: "def_1", - definitionName: "Research Analyst", - tenantId: TENANT_ID, - address: "researcher@growth", - status: "running", - createdAt: "2026-08-20T08:00:00.000Z", - updatedAt: "2026-08-20T08:00:00.000Z", - }); - } - return undefined; - }); - - expect(await actions().getStatus("apr_1")).toEqual({ - kind: "ready", - status: "pending", - canAct: true, - detail: { - agentName: "Research Analyst", - headline: 'Sends an email on the tenant\'s behalf: "Welcome Acme"', - arguments: { title: "Welcome Acme" }, - }, - }); - }); - - test("a refused read is forbidden", async () => { - stubFetch(() => json({ error: { code: "forbidden" } }, 403)); - expect(await actions().getStatus("apr_1")).toEqual({ kind: "forbidden" }); - }); - - test("an unknown approval is not-found", async () => { - stubFetch(() => json({ error: { code: "not_found" } }, 404)); - expect(await actions().getStatus("apr_1")).toEqual({ kind: "not-found" }); - }); -}); diff --git a/apps/web/src/query-client.test.ts b/apps/web/src/query-client.test.ts deleted file mode 100644 index 37ee1a1bd..000000000 --- a/apps/web/src/query-client.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Query-key helpers — pure unit coverage so the TanStack cutover does not -// depend only on page-level smoke. The APIQuery adapter itself -// (`toAPIQuery`) is covered in `@/lib/api-query`. - -import { describe, expect, test } from "bun:test"; -import { QueryClient } from "@tanstack/react-query"; - -import { meKeys, pathToQueryKey, tenantKeys } from "./query-client"; - -describe("pathToQueryKey", () => { - test("maps identity-scoped hub paths onto meKeys", () => { - expect(pathToQueryKey("/api/me")).toEqual(meKeys.profile); - expect(pathToQueryKey("/api/me/principals")).toEqual(meKeys.principals); - }); - - test("maps the pending-approvals list onto a tenant-scoped key", () => { - expect(pathToQueryKey("/api/tenants/tnt_1/approvals")).toEqual( - tenantKeys.pendingApprovals("tnt_1"), - ); - }); - - test("maps tenant assets onto a tenant-scoped key", () => { - expect(pathToQueryKey("/api/tenants/tnt_1/assets")).toEqual(tenantKeys.assets("tnt_1")); - }); - - test("falls back to a path key for unknown routes", () => { - expect(pathToQueryKey("/api/mystery")).toEqual(["path", "/api/mystery"]); - }); -}); - -describe("tenantKeys.routineActivity", () => { - // Keyed by the seam, not the deleted `/top-level-runs` route, so both - // mounts share one cache entry. - test("keys routine activity per tenant without the deleted route name", () => { - expect(tenantKeys.routineActivity("tnt_1")).toEqual(["tenant", "tnt_1", "routine-activity"]); - expect((tenantKeys.routineActivity("tnt_1") as readonly unknown[]).join("/")).not.toContain( - "top-level-runs", - ); - }); -}); - -describe("tenantKeys.agents", () => { - // Pins the `chatKeys.agents` migration: the approvals roster - // (`pending-approvals.ts`) subscribes with this key while the agents page - // (`agents-api.ts`) invalidates it, so both sides must spell it the same - // way — one shared cached read, usually a hit. - test("is the exact roster key both sides share, with no chat segment left", () => { - expect(tenantKeys.agents("tnt_1")).toEqual(["tenant", "tnt_1", "agents", "roster"]); - expect((tenantKeys.agents("tnt_1") as readonly unknown[]).join("/")).not.toContain("chat"); - }); - - test("nests under tenantKeys.all so a bench switch drops it", () => { - const all = tenantKeys.all("tnt_1"); - expect(tenantKeys.agents("tnt_1").slice(0, all.length)).toEqual([...all]); - }); - - test("a write under the key is visible to a subscriber and cleared by invalidation", async () => { - const client = new QueryClient(); - client.setQueryData(tenantKeys.agents("tnt_1"), [{ name: "researcher-bot" }]); - expect( - client.getQueryData(tenantKeys.agents("tnt_1")), - ).toEqual([{ name: "researcher-bot" }]); - await client.invalidateQueries({ queryKey: tenantKeys.agents("tnt_1") }); - expect(client.getQueryState(tenantKeys.agents("tnt_1"))?.isInvalidated).toBe(true); - }); -}); diff --git a/apps/web/src/session.test.ts b/apps/web/src/session.test.ts deleted file mode 100644 index 78f3f6250..000000000 --- a/apps/web/src/session.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { signIn, signInSocial, signUp } from "./session"; - -describe("rate-limited auth responses", () => { - const realFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = realFetch; - }); - - function stub429(retryAfterSeconds: string | null): void { - globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => - Promise.resolve( - new Response( - JSON.stringify({ - message: "Too many requests. Please try again later.", - }), - { - status: 429, - headers: { - "content-type": "application/json", - ...(retryAfterSeconds === null ? {} : { "X-Retry-After": retryAfterSeconds }), - }, - }, - ), - )) as typeof fetch; - } - - test("signIn surfaces the retry countdown from X-Retry-After in consumer language", async () => { - stub429("30"); - const result = await signIn("alice@example.com", "hunter2"); - expect(result).toEqual({ - ok: false, - message: "Too many sign-in attempts. Try again in 30 seconds.", - }); - }); - - test("signUp shares the same 429 handling as signIn — same underlying bucket", async () => { - stub429("5"); - const result = await signUp("alice@example.com", "hunter2"); - expect(result).toEqual({ - ok: false, - message: "Too many sign-in attempts. Try again in 5 seconds.", - }); - }); - - test("falls back to a generic wait message when no retry countdown is given", async () => { - stub429(null); - const result = await signIn("alice@example.com", "hunter2"); - expect(result).toEqual({ - ok: false, - message: "Too many sign-in attempts. Please wait a moment and try again.", - }); - }); - - test("signInSocial gets the same consumer-language 429 message", async () => { - stub429("12"); - const result = await signInSocial("github"); - expect(result).toEqual({ - ok: false, - message: "Too many sign-in attempts. Try again in 12 seconds.", - }); - }); -}); diff --git a/apps/web/src/settings-access.test.tsx b/apps/web/src/settings-access.test.tsx deleted file mode 100644 index 04dd7437c..000000000 --- a/apps/web/src/settings-access.test.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// The app's cached settings-access probe must agree with -// `@/settings`'s mapping: evaluate effect !== allow is deny; -// a thrown probe is error, not deny. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import type { TenancyAccess } from "@/settings"; - -import { useSettingsAccess } from "./settings-access"; -import { createTestQueryClient, TestQueryProvider } from "./test-query-provider"; - -const realFetch = globalThis.fetch; - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - globalThis.fetch = realFetch; - if (root !== null) { - act(() => { - root?.unmount(); - }); - root = null; - } - container?.remove(); - container = null; -}); - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -function AccessProbe({ onAccess }: { readonly onAccess: (access: TenancyAccess) => void }) { - const access = useSettingsAccess("tnt_1", "prn_1"); - onAccess(access); - return null; -} - -async function mountProbe(): Promise { - const seen: TenancyAccess[] = []; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - const client = createTestQueryClient(); - await act(async () => { - root?.render( - - seen.push(access)} /> - , - ); - }); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); - return seen; -} - -describe("useSettingsAccess", () => { - test("a deny effect is denied", async () => { - globalThis.fetch = (async () => - json({ effect: "deny", matchingGrants: [] })) as unknown as typeof fetch; - const seen = await mountProbe(); - const last = seen[seen.length - 1]; - expect(last).toEqual({ - people: "denied", - roles: "denied", - grants: "denied", - credentials: "denied", - }); - }); - - test("a 5xx is error, not denied", async () => { - globalThis.fetch = (async () => json({}, 500)) as unknown as typeof fetch; - const seen = await mountProbe(); - const last = seen[seen.length - 1]; - expect(last).toEqual({ - people: "error", - roles: "error", - grants: "error", - credentials: "error", - }); - }); - - test("a network failure is error, not denied", async () => { - globalThis.fetch = (() => - Promise.reject(new TypeError("Failed to fetch"))) as unknown as typeof fetch; - const seen = await mountProbe(); - const last = seen[seen.length - 1]; - expect(last).toEqual({ - people: "error", - roles: "error", - grants: "error", - credentials: "error", - }); - }); -}); diff --git a/apps/web/src/settings/access.test.ts b/apps/web/src/settings/access.test.ts deleted file mode 100644 index 003128452..000000000 --- a/apps/web/src/settings/access.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -// A thrown probe (network, 5xx) must not collapse to "denied", or a -// section would vanish as if the principal were unauthorized. - -import { afterEach, describe, expect, test } from "bun:test"; - -import { coalesceSectionAccess, probeSectionAccess } from "./access"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -function stubFetch(respond: (path: string) => Response | Promise): void { - globalThis.fetch = ((input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : new URL(String(input)).pathname; - return Promise.resolve(respond(path)); - }) as typeof fetch; -} - -describe("probeSectionAccess", () => { - test("an allow effect is allowed", async () => { - stubFetch(() => json({ effect: "allow", matchingGrants: [] })); - await expect(probeSectionAccess("tnt_1", "prn_1", "principal")).resolves.toBe("allowed"); - }); - - test("a deny effect is denied", async () => { - stubFetch(() => json({ effect: "deny", matchingGrants: [] })); - await expect(probeSectionAccess("tnt_1", "prn_1", "principal")).resolves.toBe("denied"); - }); - - test("an ask effect is denied — only allow is allowed", async () => { - stubFetch(() => json({ effect: "ask", matchingGrants: [] })); - await expect(probeSectionAccess("tnt_1", "prn_1", "role")).resolves.toBe("denied"); - }); - - test("a 5xx is error, not denied", async () => { - stubFetch(() => json({}, 500)); - await expect(probeSectionAccess("tnt_1", "prn_1", "grant")).resolves.toBe("error"); - }); - - test("a network failure is error, not denied", async () => { - globalThis.fetch = (() => - Promise.reject(new TypeError("Failed to fetch"))) as unknown as typeof fetch; - await expect(probeSectionAccess("tnt_1", "prn_1", "credential")).resolves.toBe("error"); - }); -}); - -describe("coalesceSectionAccess", () => { - test("a failed probe keeps a prior allow so gated nav does not vanish", () => { - expect(coalesceSectionAccess("allowed", "error")).toBe("allowed"); - }); - - test("a failed probe keeps a prior deny so gated nav does not flash", () => { - expect(coalesceSectionAccess("denied", "error")).toBe("denied"); - }); - - test("a failed first probe is error — there is no last-known to keep", () => { - expect(coalesceSectionAccess("loading", "error")).toBe("error"); - }); - - test("a successful deny replaces a prior allow", () => { - expect(coalesceSectionAccess("allowed", "denied")).toBe("denied"); - }); -}); diff --git a/apps/web/src/settings/credentials-api.test.ts b/apps/web/src/settings/credentials-api.test.ts deleted file mode 100644 index 3e237b7c1..000000000 --- a/apps/web/src/settings/credentials-api.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Credentials API client: stub global fetch, assert request + parse. - -import { afterEach, describe, expect, test } from "bun:test"; - -import { - CredentialsApiError, - createCredential, - deleteCredential, - listCredentials, - listProviders, -} from "./credentials-api"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -type RecordedCall = { readonly path: string; readonly init?: RequestInit }; - -function stubFetch(respond: (path: string) => Response): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : new URL(String(input)).pathname; - calls.push(init === undefined ? { path } : { path, init }); - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; -} - -const json = (body: unknown, status = 200) => - new Response(body === undefined ? null : JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -const timestamps = { - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", -}; - -const credentialRow = { - id: "cred_1", - tenantId: "tnt_1", - providerId: "prov_1", - name: "OpenAI", - type: "api_key" as const, - status: "active" as const, - ...timestamps, -}; - -const providerRow = { - id: "prov_1", - tenantId: "tnt_1", - name: "OpenAI", - plugin: "openai", - ...timestamps, -}; - -describe("listCredentials", () => { - test("fetches the tenant's credentials page", async () => { - const calls = stubFetch(() => json({ data: [credentialRow], nextCursor: null })); - const rows = await listCredentials("tnt_1"); - expect(calls[0]?.path).toBe("/api/tenants/tnt_1/credentials"); - expect(rows).toHaveLength(1); - expect(rows[0]?.name).toBe("OpenAI"); - }); - - test("throws CredentialsApiError on 403", async () => { - stubFetch(() => json({ error: "nope" }, 403)); - await expect(listCredentials("tnt_1")).rejects.toBeInstanceOf(CredentialsApiError); - }); - - test("a fallback error message never leaks the raw route", async () => { - stubFetch(() => json(undefined, 401)); - try { - await listCredentials("tnt_1"); - throw new Error("expected listCredentials to reject"); - } catch (cause) { - expect(cause).toBeInstanceOf(CredentialsApiError); - expect((cause as Error).message).not.toContain("/api/"); - expect((cause as Error).message).toBe("The server answered 401 while loading credentials."); - } - }); -}); - -describe("listProviders", () => { - test("fetches the tenant's providers page", async () => { - const calls = stubFetch(() => json({ data: [providerRow], nextCursor: null })); - const rows = await listProviders("tnt_1"); - expect(calls[0]?.path).toBe("/api/tenants/tnt_1/providers"); - expect(rows[0]?.plugin).toBe("openai"); - }); -}); - -describe("createCredential", () => { - test("POSTs provider, name, type, and secret", async () => { - const calls = stubFetch(() => json(credentialRow, 201)); - const created = await createCredential("tnt_1", { - providerId: "prov_1", - name: "OpenAI", - type: "api_key", - secret: "sk-test", - }); - expect(calls[0]?.path).toBe("/api/tenants/tnt_1/credentials"); - expect(calls[0]?.init?.method).toBe("POST"); - const body = JSON.parse(String(calls[0]?.init?.body)) as { - secret: string; - providerId: string; - }; - expect(body.secret).toBe("sk-test"); - expect(body.providerId).toBe("prov_1"); - expect(created.id).toBe("cred_1"); - }); -}); - -describe("deleteCredential", () => { - test("DELETEs the credential id", async () => { - const calls = stubFetch(() => new Response(null, { status: 204 })); - await deleteCredential("tnt_1", "cred_1"); - expect(calls[0]?.path).toBe("/api/tenants/tnt_1/credentials/cred_1"); - expect(calls[0]?.init?.method).toBe("DELETE"); - }); -}); diff --git a/apps/web/src/settings/grant-preview.test.ts b/apps/web/src/settings/grant-preview.test.ts deleted file mode 100644 index 5f46fe457..000000000 --- a/apps/web/src/settings/grant-preview.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { expiryIsoFromPreset, expiryLabelFromPreset, grantPreviewSentence } from "./grant-preview"; - -describe("grantPreviewSentence", () => { - test("defaults the subject when no target is chosen", () => { - expect( - grantPreviewSentence({ - targetLabel: null, - resource: "workbench", - action: "write", - effect: "ask", - expiresLabel: null, - }), - ).toBe("Someone must ask before they can write on workbench."); - }); - - test("uses allow / deny verbs", () => { - expect( - grantPreviewSentence({ - targetLabel: "Billing", - resource: "credential", - action: "read", - effect: "allow", - expiresLabel: null, - }), - ).toBe("Billing may read on credential."); - expect( - grantPreviewSentence({ - targetLabel: "Billing", - resource: "credential", - action: "read", - effect: "deny", - expiresLabel: "in 7 days", - }), - ).toBe("Billing must not read on credential, until in 7 days."); - }); -}); - -describe("expiry presets", () => { - test("never has no iso and no label", () => { - expect(expiryIsoFromPreset("never")).toBeNull(); - expect(expiryLabelFromPreset("never")).toBeNull(); - }); - - test("24h is one day ahead of now", () => { - const now = new Date("2026-08-10T12:00:00.000Z"); - expect(expiryIsoFromPreset("24h", now)).toBe("2026-08-11T12:00:00.000Z"); - expect(expiryLabelFromPreset("24h")).toBe("in 24 hours"); - }); -}); diff --git a/apps/web/src/settings/inference/api.test.ts b/apps/web/src/settings/inference/api.test.ts deleted file mode 100644 index 21a1a3467..000000000 --- a/apps/web/src/settings/inference/api.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -// The mint chain `shadowOffering` drives — model, then a credential -// (itself needing a credential-provider row), then the tenant-local -// model-provider, then the offering that references it — against a fake -// `fetch` that records every call. Covers three things the review called -// out: (1) the exact call order (model/credential steps before the -// model-provider, which is the step that re-routes every offering under -// that provider name — see api.ts's `shadowOffering` doc), (2) that every -// `ensure*` step, including the credential POST itself, tolerates a 409 by -// resolving the row that already exists rather than failing the whole -// flow, and (3) that a freshly-minted model-provider is rolled back -// (deleted) when the offering step that was meant to follow it fails. - -import { afterEach, describe, expect, test } from "bun:test"; - -import { shadowOffering } from "./api"; - -const TENANT_ID = "tnt_1"; -const NOW = "2026-01-01T00:00:00.000Z"; - -type Call = { readonly method: string; readonly path: string }; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -function pathOf(input: RequestInfo | URL): string { - return typeof input === "string" ? input : new URL(String(input)).pathname; -} - -const INPUT = { - canonicalName: "claude-sonnet-5", - modelDisplayName: "Claude Sonnet 5", - providerName: "anthropic", - plugin: "anthropic" as const, - baseURL: "https://api.anthropic.com", - credential: { apiKey: "sk-test" }, - priority: 2, -}; - -function modelResponse(overrides: Partial> = {}) { - return { - id: "model_1", - tenantId: TENANT_ID, - canonicalName: INPUT.canonicalName, - displayName: INPUT.modelDisplayName, - disabled: false, - createdAt: NOW, - updatedAt: NOW, - ...overrides, - }; -} - -function credentialProviderResponse(overrides: Partial> = {}) { - return { - id: "provider_1", - tenantId: TENANT_ID, - name: INPUT.providerName, - plugin: INPUT.plugin, - createdAt: NOW, - updatedAt: NOW, - ...overrides, - }; -} - -function credentialResponse(overrides: Partial> = {}) { - return { - id: "credential_1", - tenantId: TENANT_ID, - providerId: "provider_1", - name: `${INPUT.providerName}-workbench`, - type: "api_key", - status: "active", - createdAt: NOW, - updatedAt: NOW, - ...overrides, - }; -} - -function modelProviderResponse(overrides: Partial> = {}) { - return { - id: "mp_1", - tenantId: TENANT_ID, - name: INPUT.providerName, - plugin: INPUT.plugin, - baseURL: INPUT.baseURL, - credentialId: "credential_1", - disabled: false, - createdAt: NOW, - updatedAt: NOW, - ...overrides, - }; -} - -function offeringResponse(overrides: Partial> = {}) { - return { - id: "offering_1", - tenantId: TENANT_ID, - modelId: "model_1", - providerId: "mp_1", - priority: INPUT.priority, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: NOW, - updatedAt: NOW, - ...overrides, - }; -} - -function paginated(data: readonly unknown[]) { - return { data, nextCursor: null }; -} - -describe("shadowOffering mint chain", () => { - test("mints model, credential-provider, credential, model-provider, offering, in that order", async () => { - const calls: Call[] = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = pathOf(input); - const method = init?.method ?? "GET"; - calls.push({ method, path }); - if (path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return Response.json(modelResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/providers`) { - return Response.json(credentialProviderResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/credentials`) { - return Response.json(credentialResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/providers`) { - return Response.json(modelProviderResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/offerings`) { - return Response.json(offeringResponse(), { status: 201 }); - } - throw new Error(`unexpected fetch: ${method} ${path}`); - }) as typeof fetch; - - const result = await shadowOffering(TENANT_ID, INPUT); - - expect(result.id).toBe("offering_1"); - // Model and credential steps run before the model-provider step: the - // provider is the step that re-routes every inherited offering under - // that provider name, so it happens last, right before the offering - // that justifies it. - expect(calls).toEqual([ - { method: "POST", path: `/api/tenants/${TENANT_ID}/catalog/models` }, - { method: "POST", path: `/api/tenants/${TENANT_ID}/providers` }, - { method: "POST", path: `/api/tenants/${TENANT_ID}/credentials` }, - { - method: "POST", - path: `/api/tenants/${TENANT_ID}/catalog/providers`, - }, - { - method: "POST", - path: `/api/tenants/${TENANT_ID}/catalog/offerings`, - }, - ]); - }); - - test("shadows at the exact priority of the offering being shadowed, not a row count", async () => { - let offeringBody: unknown; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = pathOf(input); - if (path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return Response.json(modelResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/providers`) { - return Response.json(credentialProviderResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/credentials`) { - return Response.json(credentialResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/providers`) { - return Response.json(modelProviderResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/offerings`) { - offeringBody = init?.body !== undefined ? JSON.parse(String(init.body)) : undefined; - return Response.json(offeringResponse(), { status: 201 }); - } - throw new Error(`unexpected fetch: ${path}`); - }) as typeof fetch; - - await shadowOffering(TENANT_ID, { ...INPUT, priority: 7 }); - - expect((offeringBody as { priority: number }).priority).toBe(7); - }); - - test("tolerates a 409 on every ensure* step, including the credential POST itself", async () => { - const calls: Call[] = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = pathOf(input); - const method = init?.method ?? "GET"; - calls.push({ method, path }); - - if (path === `/api/tenants/${TENANT_ID}/catalog/models`) { - if (method === "POST") return new Response(null, { status: 409 }); - return Response.json(paginated([modelResponse()])); - } - if (path === `/api/tenants/${TENANT_ID}/providers`) { - if (method === "POST") return new Response(null, { status: 409 }); - return Response.json(paginated([credentialProviderResponse()])); - } - if (path === `/api/tenants/${TENANT_ID}/credentials`) { - if (method === "POST") return new Response(null, { status: 409 }); - return Response.json(paginated([credentialResponse()])); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/providers`) { - if (method === "POST") return new Response(null, { status: 409 }); - return Response.json(paginated([modelProviderResponse()])); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/offerings`) { - if (method === "POST") return new Response(null, { status: 409 }); - return Response.json( - paginated([offeringResponse({ modelId: "model_1", providerId: "mp_1" })]), - ); - } - throw new Error(`unexpected fetch: ${method} ${path}`); - }) as typeof fetch; - - const result = await shadowOffering(TENANT_ID, INPUT); - - expect(result.id).toBe("offering_1"); - // Every POST 409'd; every step fell back to its GET list and resolved - // the existing row instead of throwing. - const postCount = calls.filter((c) => c.method === "POST").length; - const getCount = calls.filter((c) => c.method === "GET").length; - expect(postCount).toBe(5); - expect(getCount).toBe(5); - }); - - test("rolls back a freshly-minted model-provider when the offering step then fails", async () => { - const calls: Call[] = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = pathOf(input); - const method = init?.method ?? "GET"; - calls.push({ method, path }); - - if (path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return Response.json(modelResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/providers`) { - return Response.json(credentialProviderResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/credentials`) { - return Response.json(credentialResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/providers`) { - if (method === "DELETE") return new Response(null, { status: 204 }); - return Response.json(modelProviderResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/providers/mp_1`) { - if (method === "DELETE") return new Response(null, { status: 204 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/offerings`) { - return new Response(JSON.stringify({ error: { message: "server exploded" } }), { - status: 500, - }); - } - throw new Error(`unexpected fetch: ${method} ${path}`); - }) as typeof fetch; - - await expect(shadowOffering(TENANT_ID, INPUT)).rejects.toThrow(); - - const deleteCall = calls.find((c) => c.method === "DELETE"); - expect(deleteCall).toEqual({ - method: "DELETE", - path: `/api/tenants/${TENANT_ID}/catalog/providers/mp_1`, - }); - }); - - test("threads an explicit fetchImpl through every call, including the rollback DELETE, never touching the global fetch", async () => { - globalThis.fetch = (() => { - throw new Error("global fetch must not be called when fetchImpl is passed"); - }) as unknown as typeof fetch; - - const calls: Call[] = []; - const fakeFetch: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = pathOf(input); - const method = init?.method ?? "GET"; - calls.push({ method, path }); - - if (path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return Response.json(modelResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/providers`) { - return Response.json(credentialProviderResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/credentials`) { - return Response.json(credentialResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/providers`) { - return Response.json(modelProviderResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/providers/mp_1`) { - if (method === "DELETE") return new Response(null, { status: 204 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/offerings`) { - return new Response(JSON.stringify({ error: { message: "server exploded" } }), { - status: 500, - }); - } - throw new Error(`unexpected fetch: ${method} ${path}`); - }) as typeof fetch; - - await expect(shadowOffering(TENANT_ID, INPUT, fakeFetch)).rejects.toThrow(); - - const deleteCall = calls.find((c) => c.method === "DELETE"); - expect(deleteCall).toEqual({ - method: "DELETE", - path: `/api/tenants/${TENANT_ID}/catalog/providers/mp_1`, - }); - }); - - test("does not roll back an already-existing model-provider (resolved via 409) when the offering step fails", async () => { - const calls: Call[] = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = pathOf(input); - const method = init?.method ?? "GET"; - calls.push({ method, path }); - - if (path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return Response.json(modelResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/providers`) { - return Response.json(credentialProviderResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/credentials`) { - return Response.json(credentialResponse(), { status: 201 }); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/providers`) { - if (method === "POST") return new Response(null, { status: 409 }); - return Response.json(paginated([modelProviderResponse()])); - } - if (path === `/api/tenants/${TENANT_ID}/catalog/offerings`) { - return new Response(JSON.stringify({ error: { message: "server exploded" } }), { - status: 500, - }); - } - throw new Error(`unexpected fetch: ${method} ${path}`); - }) as typeof fetch; - - await expect(shadowOffering(TENANT_ID, INPUT)).rejects.toThrow(); - - expect(calls.some((c) => c.method === "DELETE")).toBe(false); - }); -}); diff --git a/apps/web/src/settings/inference/effective-list.test.ts b/apps/web/src/settings/inference/effective-list.test.ts deleted file mode 100644 index 62f537066..000000000 --- a/apps/web/src/settings/inference/effective-list.test.ts +++ /dev/null @@ -1,684 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { ModelInfo, ModelOfferingResponse } from "@intx/types"; - -import { - buildEffectiveInferenceRows, - chatCapableModels, - computeMakeDefaultPatches, - computeGlobalRoutePatches, - computeReorderPatches, - defaultModelForProvider, - providerDisplayName, - orderedGlobalInferenceRows, - restrictedOfferings, - rowsByModel, - type EffectiveInferenceRow, -} from "./effective-list"; - -describe("providerDisplayName", () => { - test("resolves a known provider slug to its own display name", () => { - expect(providerDisplayName("ollama")).toBe("Ollama (local)"); - expect(providerDisplayName("opencode-zen")).toBe("Opencode Zen"); - // The OAuth-only ids resolve too, not fall back to their slug. - expect(providerDisplayName("codex")).toBe("Codex"); - expect(providerDisplayName("xai-oauth")).toBe("xAI (Grok OAuth)"); - }); - - test("falls back to the raw slug for an unrecognized provider", () => { - expect(providerDisplayName("some-custom-provider")).toBe("some-custom-provider"); - }); -}); - -function row( - offeringId: string, - priority: number, - provenance: EffectiveInferenceRow["provenance"] = "set-here", -): EffectiveInferenceRow { - return { - offeringId, - modelId: "model-1", - canonicalName: "claude-sonnet-5", - modelDisplayName: null, - providerId: `provider-${offeringId}`, - providerName: offeringId, - plugin: "anthropic", - priority, - provenance, - }; -} - -function model(overrides: Partial = {}): ModelInfo { - return { - id: "model-1", - canonicalName: "claude-sonnet-5", - displayName: "Claude Sonnet 5", - offerings: [ - { - offeringId: "offering-a", - providerId: "provider-a", - providerName: "anthropic", - plugin: "anthropic", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - { - offeringId: "offering-b", - providerId: "provider-b", - providerName: "opencode-zen", - plugin: "openai-compatible", - priority: 1, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - ...overrides, - }; -} - -describe("defaultModelForProvider", () => { - test("picks the offering with the lowest priority number for that provider", () => { - const models: ModelInfo[] = [ - model(), - model({ - id: "model-2", - canonicalName: "gpt-4o-mini", - displayName: "GPT-4o mini", - offerings: [ - { - offeringId: "offering-c", - providerId: "provider-a", - providerName: "anthropic", - plugin: "anthropic", - priority: 5, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - ]; - // "anthropic" serves two models here (priority 0 on claude-sonnet-5, - // priority 5 on gpt-4o-mini) -- the lower number wins. - expect(defaultModelForProvider(models, "anthropic")).toEqual({ - canonicalName: "claude-sonnet-5", - displayName: "Claude Sonnet 5", - }); - }); - - test("returns null when the provider serves no offering", () => { - expect(defaultModelForProvider([model()], "groq")).toBeNull(); - }); - - test("falls back to a null displayName when the model has none", () => { - const models: ModelInfo[] = [model({ displayName: null })]; - expect(defaultModelForProvider(models, "anthropic")).toEqual({ - canonicalName: "claude-sonnet-5", - displayName: null, - }); - }); - - test("skips an embedding-capability offering tied at the same priority even when its name sorts first", () => { - // Two offerings tied at the same priority -- exactly what - // `packages/connections/src/seed-catalog.ts`'s `seedCatalog` does for a fresh - // Ollama connect. The embedding offering's real capability data (no - // "plain-text") is what routes around it; "all-minilm" sorting before - // "qwen3:8b" alone is no longer enough to make it win. - const models: ModelInfo[] = [ - model({ - id: "model-embed", - canonicalName: "all-minilm", - displayName: "all-minilm", - offerings: [ - { - offeringId: "offering-embed", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - model({ - id: "model-chat", - canonicalName: "qwen3:8b", - displayName: "qwen3:8b", - offerings: [ - { - offeringId: "offering-chat", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: ["plain-text"], - pricing: [], - }, - ], - }), - ]; - expect(defaultModelForProvider(models, "ollama")).toEqual({ - canonicalName: "qwen3:8b", - displayName: "qwen3:8b", - }); - }); - - test("an uncataloged embedding-named offering never wins even at the lowest priority", () => { - const models: ModelInfo[] = [ - model({ - id: "model-embed", - canonicalName: "all-minilm", - displayName: "all-minilm", - offerings: [ - { - offeringId: "offering-embed", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - model({ - id: "model-chat", - canonicalName: "qwen3:8b", - displayName: "qwen3:8b", - offerings: [ - { - offeringId: "offering-chat", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 1, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - ]; - expect(defaultModelForProvider(models, "ollama")).toEqual({ - canonicalName: "qwen3:8b", - displayName: "qwen3:8b", - }); - }); - - test("an offering set that is entirely uncataloged embedding-named models resolves to no default", () => { - const models: ModelInfo[] = [ - model({ - id: "model-embed-1", - canonicalName: "all-minilm", - displayName: "all-minilm", - offerings: [ - { - offeringId: "offering-embed-1", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - model({ - id: "model-embed-2", - canonicalName: "nomic-embed-text", - displayName: "nomic-embed-text", - offerings: [ - { - offeringId: "offering-embed-2", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 1, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - ]; - expect(defaultModelForProvider(models, "ollama")).toBeNull(); - }); - - test("an hf.co path never wins the Ollama default when a chat model exists", () => { - const models: ModelInfo[] = [ - model({ - id: "model-hf", - canonicalName: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", - displayName: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF:Q4_K_M", - offerings: [ - { - offeringId: "offering-hf", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: ["plain-text"], - pricing: [], - }, - ], - }), - model({ - id: "model-chat", - canonicalName: "qwen3:8b", - displayName: "qwen3:8b", - offerings: [ - { - offeringId: "offering-chat", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 1, - deploymentTags: [], - capabilities: ["plain-text"], - pricing: [], - }, - ], - }), - ]; - expect(defaultModelForProvider(models, "ollama")).toEqual({ - canonicalName: "qwen3:8b", - displayName: "qwen3:8b", - }); - }); -}); - -describe("chatCapableModels", () => { - test("drops embedding-named, hf.co, and .gguf models from picker lists", () => { - const models: ModelInfo[] = [ - model({ - id: "model-embed", - canonicalName: "all-minilm", - displayName: "all-minilm", - offerings: [ - { - offeringId: "offering-embed", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - model({ - id: "model-hf", - canonicalName: "hf.co/org/repo:Q4_K_M", - displayName: "hf.co/org/repo:Q4_K_M", - offerings: [ - { - offeringId: "offering-hf", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: ["plain-text"], - pricing: [], - }, - ], - }), - model({ - id: "model-gguf", - canonicalName: "Llama-3.2-3B-Instruct-IQ3_M.gguf", - displayName: "Llama-3.2-3B-Instruct-IQ3_M.gguf", - offerings: [ - { - offeringId: "offering-gguf", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - model({ - id: "model-chat", - canonicalName: "qwen3:8b", - displayName: "qwen3:8b", - offerings: [ - { - offeringId: "offering-chat", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: ["plain-text"], - pricing: [], - }, - ], - }), - ]; - expect(chatCapableModels(models).map((entry) => entry.canonicalName)).toEqual(["qwen3:8b"]); - }); -}); - -describe("buildEffectiveInferenceRows", () => { - test("marks an owned offering id set-here and everything else inherited", () => { - const rows = buildEffectiveInferenceRows([model()], new Set(["offering-b"])); - expect(rows).toHaveLength(2); - expect(rows[0]?.offeringId).toBe("offering-a"); - expect(rows[0]?.provenance).toBe("inherited"); - expect(rows[1]?.offeringId).toBe("offering-b"); - expect(rows[1]?.provenance).toBe("set-here"); - }); - - test("preserves each model's resolution-priority order across models", () => { - const rows = buildEffectiveInferenceRows( - [ - model(), - model({ - id: "model-2", - canonicalName: "gpt-4o-mini", - displayName: "GPT-4o mini", - offerings: [ - { - offeringId: "offering-c", - providerId: "provider-c", - providerName: "openai", - plugin: "openai", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - ], - new Set(), - ); - expect(rows.map((row) => row.offeringId)).toEqual(["offering-a", "offering-b", "offering-c"]); - }); - - test("an empty catalog yields no rows", () => { - expect(buildEffectiveInferenceRows([], new Set())).toEqual([]); - }); - - test("omits embedding, hf.co, and .gguf offerings from Settings route rows", () => { - const rows = buildEffectiveInferenceRows( - [ - model({ - id: "model-embed", - canonicalName: "nomic-embed-text", - displayName: "nomic-embed-text", - offerings: [ - { - offeringId: "offering-embed", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }), - model({ - id: "model-hf", - canonicalName: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF", - displayName: "hf.co/bartowski/Llama-3.2-1B-Instruct-GGUF", - offerings: [ - { - offeringId: "offering-hf", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: ["plain-text"], - pricing: [], - }, - ], - }), - model({ - id: "model-chat", - canonicalName: "qwen3:8b", - displayName: "qwen3:8b", - offerings: [ - { - offeringId: "offering-chat", - providerId: "provider-ollama", - providerName: "ollama", - plugin: "openai-compatible", - priority: 0, - deploymentTags: [], - capabilities: ["plain-text"], - pricing: [], - }, - ], - }), - ], - new Set(["offering-chat"]), - ); - expect(rows.map((row) => row.canonicalName)).toEqual(["qwen3:8b"]); - expect(rows[0]?.provenance).toBe("set-here"); - }); -}); - -describe("rowsByModel", () => { - test("groups rows under their model id, keeping row order", () => { - const rows = buildEffectiveInferenceRows([model()], new Set()); - const grouped = rowsByModel(rows); - expect(grouped.size).toBe(1); - expect(grouped.get("model-1")?.map((row) => row.offeringId)).toEqual([ - "offering-a", - "offering-b", - ]); - }); -}); - -describe("global inference route", () => { - test("orders every model/provider offering into one deterministic route", () => { - const rows: EffectiveInferenceRow[] = [ - { ...row("openai", 2), canonicalName: "gpt-5.6-terra" }, - { ...row("anthropic", 0), canonicalName: "claude-sonnet-5" }, - { ...row("xai", 1), canonicalName: "grok-4.6" }, - ]; - - expect(orderedGlobalInferenceRows(rows).map((entry) => entry.offeringId)).toEqual([ - "anthropic", - "xai", - "openai", - ]); - }); - - test("moving a fallback to primary normalizes the entire route", () => { - const rows: EffectiveInferenceRow[] = [row("anthropic", 0), row("xai", 1), row("openai", 2)]; - - expect(computeGlobalRoutePatches(rows, "openai", "first")).toEqual([ - { offeringId: "openai", priority: 0 }, - { offeringId: "anthropic", priority: 1 }, - { offeringId: "xai", priority: 2 }, - ]); - }); - - test("does not pretend an inherited route can be rewritten here", () => { - const rows: EffectiveInferenceRow[] = [row("anthropic", 0, "inherited"), row("openai", 1)]; - - expect(computeGlobalRoutePatches(rows, "openai", "up")).toBeNull(); - }); -}); - -function expectPatches( - patches: - | readonly [ - { readonly offeringId: string; readonly priority: number }, - { readonly offeringId: string; readonly priority: number }, - ] - | null, -): readonly [ - { readonly offeringId: string; readonly priority: number }, - { readonly offeringId: string; readonly priority: number }, -] { - if (patches === null) throw new Error("expected a patch pair, got null"); - return patches; -} - -describe("computeReorderPatches", () => { - test("swaps two distinctly-prioritized adjacent rows", () => { - const rows = buildEffectiveInferenceRows([model()], new Set(["offering-a", "offering-b"])); - const [earlier, later] = expectPatches(computeReorderPatches(rows, 1, "up")); - expect(earlier.offeringId).toBe("offering-b"); - expect(later.offeringId).toBe("offering-a"); - expect(earlier.priority).toBeLessThan(later.priority); - }); - - // Regression for the reorder-tie no-op: every seed-created or - // priority-omitted offering defaults to priority 0, so two "set-here" - // rows tied at 0 must still swap into a distinct, correctly ordered - // pair — a plain value-swap (0, 0) would leave both unchanged. - test("swapping two equal-priority rows still produces distinct, correctly ordered priorities", () => { - const rows: EffectiveInferenceRow[] = [row("a", 0), row("b", 0)]; - const [earlier, later] = expectPatches(computeReorderPatches(rows, 1, "up")); - expect(earlier.offeringId).toBe("b"); - expect(later.offeringId).toBe("a"); - expect(earlier.priority).not.toBe(later.priority); - expect(earlier.priority).toBeLessThan(later.priority); - }); - - test("keeps the swapped pair within its fixed neighbors' priorities", () => { - const rows: EffectiveInferenceRow[] = [ - row("fixed-low", 0, "inherited"), - row("a", 0), - row("b", 0), - row("fixed-high", 1, "inherited"), - ]; - const [earlier, later] = expectPatches(computeReorderPatches(rows, 2, "up")); - expect(earlier.priority).toBeGreaterThanOrEqual(0); - expect(later.priority).toBeLessThanOrEqual(1); - expect(earlier.priority).toBeLessThan(later.priority); - }); - - test("returns null when the move would run off the edge of the list", () => { - const rows = buildEffectiveInferenceRows([model()], new Set(["offering-a", "offering-b"])); - expect(computeReorderPatches(rows, 0, "up")).toBeNull(); - }); - - test("returns null when the neighbor is inherited (not shadowed yet)", () => { - const rows = buildEffectiveInferenceRows([model()], new Set(["offering-b"])); - expect(computeReorderPatches(rows, 1, "up")).toBeNull(); - }); -}); - -// UI truth: the order a member sees must be the order a launch would try. -// `resolveModelSources`'s `byPriority` (vendor/intx/db/src/model-source- -// resolution.ts) sorts priority ascending, id tiebreak only on an exact -// priority tie. `buildEffectiveInferenceRows` never re-sorts — it trusts -// the resolved catalog's own order — so this only genuinely holds once -// `computeReorderPatches` has made every row's priority distinct; while -// two rows are still tied (never reordered), the discovery route's own -// tiebreak (provider name, not id — vendor/intx/hub-api/src/routes/ -// models.ts's `composeDiscoveredModels`) can disagree with launch order. -// This guards the case the fix actually delivers: distinct priorities -// sort identically no matter which tiebreak a comparator uses, since the -// tiebreak never engages. -describe("resolution-order truth", () => { - test("with distinct priorities, row order matches priority-ascending regardless of tiebreak", () => { - const rows: EffectiveInferenceRow[] = [row("c", 5), row("a", 1), row("b", 3)]; - const byPriorityIdTiebreak = [...rows].sort( - (x, y) => x.priority - y.priority || (x.offeringId < y.offeringId ? -1 : 1), - ); - const byPriorityNameTiebreak = [...rows].sort( - (x, y) => x.priority - y.priority || x.providerName.localeCompare(y.providerName), - ); - expect(byPriorityIdTiebreak.map((r) => r.offeringId)).toEqual(["a", "b", "c"]); - expect(byPriorityNameTiebreak.map((r) => r.offeringId)).toEqual( - byPriorityIdTiebreak.map((r) => r.offeringId), - ); - }); -}); - -describe("restrictedOfferings", () => { - function offering( - overrides: Partial = {}, - ): typeof ModelOfferingResponse.infer { - return { - id: "offering-a", - tenantId: "tenant-1", - modelId: "model-1", - providerId: "provider-a", - priority: 0, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - ...overrides, - }; - } - - test("keeps only this tenant's disabled offerings", () => { - const rows = restrictedOfferings([ - offering({ id: "a", disabled: false }), - offering({ id: "b", disabled: true }), - ]); - expect(rows.map((row) => row.id)).toEqual(["b"]); - }); - - test("an empty offering list yields no restricted rows", () => { - expect(restrictedOfferings([])).toEqual([]); - }); -}); - -describe("computeMakeDefaultPatches", () => { - test("lowers the target strictly below the current lowest other priority", () => { - const rows = [row("a", 5), row("b", 2), row("c", 8)]; - expect(computeMakeDefaultPatches(rows, "c")).toEqual([{ offeringId: "c", priority: 1 }]); - }); - - // Regression for the tie case: `defaultModelForProvider` breaks ties by - // iteration order, so tying the current minimum is not enough to win — - // the target must land strictly below it. - test("breaks a tie with the current minimum by going strictly below it", () => { - const rows = [row("a", 0), row("b", 0)]; - expect(computeMakeDefaultPatches(rows, "b")).toEqual([{ offeringId: "b", priority: -1 }]); - }); - - test("is a no-op when the target is already strictly ahead of every other offering", () => { - const rows = [row("a", 5), row("b", 1)]; - expect(computeMakeDefaultPatches(rows, "b")).toEqual([]); - }); - - test("leaves every other row untouched", () => { - const rows = [row("a", 5), row("b", 2), row("c", 8)]; - const patches = computeMakeDefaultPatches(rows, "c"); - expect(patches).toEqual([{ offeringId: "c", priority: 1 }]); - expect(patches?.some((patch) => patch.offeringId !== "c")).toBe(false); - }); - - test("returns null for an inherited offering (not shadowed yet)", () => { - const rows = [row("a", 5, "inherited"), row("b", 2)]; - expect(computeMakeDefaultPatches(rows, "a")).toBeNull(); - }); - - test("returns null when the target offering isn't in the list", () => { - const rows = [row("a", 5), row("b", 2)]; - expect(computeMakeDefaultPatches(rows, "missing")).toBeNull(); - }); - - test("is a no-op for the sole offering of a provider", () => { - const rows = [row("a", 5)]; - expect(computeMakeDefaultPatches(rows, "a")).toEqual([]); - }); -}); diff --git a/apps/web/src/settings/inference/mint-offering-for-model.test.ts b/apps/web/src/settings/inference/mint-offering-for-model.test.ts deleted file mode 100644 index b752c3c96..000000000 --- a/apps/web/src/settings/inference/mint-offering-for-model.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// `mintOfferingForModel` is a non-obvious two-step (ensure a model row, -// then a fresh offering) because the stock offering PATCH has no -// `modelId` field and a model's `canonicalName` is immutable — see -// api.ts's doc on the function. It deliberately never deletes the old -// offering itself (a deployed Myra run may still pin it); that is the -// caller's job once a redeploy moves off it. Covers the two cases that -// make the mint step non-trivial: a same-name edit is a no-op, and a real -// change carries the offering's priority over to the new row without -// touching the old one. - -import { afterEach, describe, expect, test } from "bun:test"; - -import { mintOfferingForModel } from "./api"; - -const TENANT_ID = "tnt_1"; -const NOW = "2026-01-01T00:00:00.000Z"; - -const realFetch = globalThis.fetch; -afterEach(() => { - globalThis.fetch = realFetch; -}); - -function pathOf(input: RequestInfo | URL): string { - return typeof input === "string" ? input : new URL(String(input)).pathname; -} - -const OFFERING = { - id: "offering_1", - tenantId: TENANT_ID, - modelId: "model_1", - providerId: "provider_1", - priority: 5, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: NOW, - updatedAt: NOW, -}; - -describe("mintOfferingForModel", () => { - test("is a no-op when the canonical name resolves to the offering's own model", async () => { - const calls: { method: string; path: string }[] = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const method = init?.method ?? "GET"; - const path = pathOf(input); - calls.push({ method, path }); - // ensureModel's create attempt conflicts; it already exists as model_1. - if (method === "POST" && path.endsWith("/catalog/models")) { - return new Response(null, { status: 409 }); - } - if (method === "GET" && path.endsWith("/catalog/models")) { - return new Response( - JSON.stringify({ - data: [ - { - id: "model_1", - tenantId: TENANT_ID, - canonicalName: "qwen2.5:14b", - displayName: "qwen2.5:14b", - disabled: false, - createdAt: NOW, - updatedAt: NOW, - }, - ], - nextCursor: null, - }), - { status: 200 }, - ); - } - throw new Error(`unexpected call: ${method} ${path}`); - }) as typeof fetch; - - const result = await mintOfferingForModel(TENANT_ID, OFFERING, "qwen2.5:14b", "qwen2.5:14b"); - - expect(result).toBe(OFFERING); - expect(calls.some((call) => call.method === "DELETE")).toBe(false); - }); - - test("mints a sibling offering at the same priority for a new model, without deleting the old one", async () => { - const calls: { method: string; path: string }[] = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const method = init?.method ?? "GET"; - const path = pathOf(input); - calls.push({ method, path }); - if (method === "POST" && path.endsWith("/catalog/models")) { - return new Response( - JSON.stringify({ - id: "model_2", - tenantId: TENANT_ID, - canonicalName: "qwen2.5:32b", - displayName: "qwen2.5:32b", - disabled: false, - createdAt: NOW, - updatedAt: NOW, - }), - { status: 201 }, - ); - } - if (method === "POST" && path.endsWith("/catalog/offerings")) { - const body = JSON.parse(String(init?.body)) as { modelId: string; priority: number }; - return new Response( - JSON.stringify({ - ...OFFERING, - id: "offering_2", - modelId: body.modelId, - priority: body.priority, - }), - { status: 201 }, - ); - } - throw new Error(`unexpected call: ${method} ${path}`); - }) as typeof fetch; - - const result = await mintOfferingForModel(TENANT_ID, OFFERING, "qwen2.5:32b", "qwen2.5:32b"); - - expect(result.id).toBe("offering_2"); - expect(result.modelId).toBe("model_2"); - expect(result.priority).toBe(OFFERING.priority); - expect(calls.map((call) => call.method)).toEqual(["POST", "POST"]); - expect(calls.some((call) => call.method === "DELETE")).toBe(false); - }); -}); diff --git a/apps/web/src/settings/inference/usable-model.test.ts b/apps/web/src/settings/inference/usable-model.test.ts deleted file mode 100644 index 5b9f4041d..000000000 --- a/apps/web/src/settings/inference/usable-model.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ModelInfo } from "@intx/types"; -import { hasUsableModel } from "./usable-model"; - -function modelWithOfferings(offeringCount: number): ModelInfo { - return { - id: "model_1", - canonicalName: "claude-opus", - displayName: "Opus", - offerings: Array.from({ length: offeringCount }, (_, index) => ({ - offeringId: `off_${index}`, - providerId: "prov_1", - providerName: "anthropic", - plugin: "anthropic", - priority: index, - capabilities: [], - })), - } as unknown as ModelInfo; -} - -describe("hasUsableModel", () => { - test("false for an empty resolved catalog", () => { - expect(hasUsableModel([])).toBe(false); - }); - - test("false when every model resolved with zero offerings", () => { - // Mirrors a seeded `model_provider` row with no credential: the - // platform's own resolution (`resolveModelSources`) excludes it, so a - // model that somehow carries no offerings is exactly as unusable as - // no model at all — never trust the row's mere presence. - expect(hasUsableModel([modelWithOfferings(0)])).toBe(false); - }); - - test("true once at least one model resolves at least one offering", () => { - expect(hasUsableModel([modelWithOfferings(1)])).toBe(true); - }); -}); diff --git a/apps/web/src/settings/myra-model-redeploy.test.ts b/apps/web/src/settings/myra-model-redeploy.test.ts deleted file mode 100644 index c8cefa917..000000000 --- a/apps/web/src/settings/myra-model-redeploy.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -// The non-obvious core of a model-change redeploy: replacing exactly the -// old offering id (and its declared source) in Myra's existing fallback -// chain, preserving order, every other source, and which slot was -// default — or reporting there is nothing to swap at all. - -import { describe, expect, test } from "bun:test"; - -import { swapDeclaredOffering } from "./myra-model-redeploy"; - -const BEFORE = { - sourceOfferingIds: ["off_a", "off_b", "off_c"], - defaultSourceOfferingId: "off_b", - declaredSources: [ - { provider: "anthropic" as const, model: "claude-sonnet-5" }, - { provider: "openai-compatible" as const, model: "qwen2.5:14b" }, - { provider: "openai" as const, model: "gpt-5" }, - ], -}; - -describe("swapDeclaredOffering", () => { - test("replaces the old id in place and its declared source at the same position", () => { - const result = swapDeclaredOffering( - BEFORE, - "off_b", - "off_new", - "openai-compatible", - "qwen2.5:32b", - ); - - expect(result).toEqual({ - sourceOfferingIds: ["off_a", "off_new", "off_c"], - defaultSourceOfferingId: "off_new", - declaredSources: [ - { provider: "anthropic", model: "claude-sonnet-5" }, - { provider: "openai-compatible", model: "qwen2.5:32b" }, - { provider: "openai", model: "gpt-5" }, - ], - }); - }); - - test("leaves the default offering id untouched when a non-default offering is swapped", () => { - const result = swapDeclaredOffering(BEFORE, "off_a", "off_new", "anthropic", "claude-opus-5"); - - expect(result?.defaultSourceOfferingId).toBe("off_b"); - expect(result?.sourceOfferingIds).toEqual(["off_new", "off_b", "off_c"]); - }); - - test("drops the duplicate when the new offering is already declared", () => { - // The new offering is minted before the current list is read, so the - // swap would otherwise declare it twice and the hub rejects that. - const before = { - sourceOfferingIds: ["off_old", "off_new"], - defaultSourceOfferingId: "off_old", - declaredSources: [ - { provider: "openai-compatible" as const, model: "qwen2.5:7b" }, - { provider: "openai-compatible" as const, model: "llama3.2:1b" }, - ], - }; - - const result = swapDeclaredOffering( - before, - "off_old", - "off_new", - "openai-compatible", - "llama3.2:1b", - ); - - expect(result).toEqual({ - sourceOfferingIds: ["off_new"], - defaultSourceOfferingId: "off_new", - declaredSources: [{ provider: "openai-compatible", model: "llama3.2:1b" }], - }); - }); - - test("returns null when the old offering id isn't declared at all", () => { - const result = swapDeclaredOffering( - BEFORE, - "off_missing", - "off_new", - "anthropic", - "claude-opus-5", - ); - - expect(result).toBeNull(); - }); -}); diff --git a/apps/web/src/shell/chip.test.tsx b/apps/web/src/shell/chip.test.tsx deleted file mode 100644 index 13c1193da..000000000 --- a/apps/web/src/shell/chip.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import { Chip, type ChipTone } from "./chip"; - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - if (container !== null) { - container.remove(); - container = null; - } -}); - -function render(tone: ChipTone, label: string) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render({label}); - }); - return container; -} - -describe("Chip", () => { - test.each(["working", "ok", "needs-you"])( - "renders %s tone as a data-tone attribute on .chip", - (tone) => { - const el = render(tone, "Reviewing"); - const chip = el.querySelector(".chip"); - expect(chip).not.toBeNull(); - expect(chip?.getAttribute("data-tone")).toBe(tone); - expect(chip?.textContent).toBe("Reviewing"); - }, - ); -}); diff --git a/apps/web/src/shell/context-menu/lib/menu.test.ts b/apps/web/src/shell/context-menu/lib/menu.test.ts deleted file mode 100644 index b70cf88ae..000000000 --- a/apps/web/src/shell/context-menu/lib/menu.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { contextMenuItem, contextMenuSeparator, isContextMenuEmpty } from "./menu"; - -describe("contextMenuItem", () => { - test("stamps the item kind", () => { - const item = contextMenuItem({ - id: "open", - label: "Open", - onSelect: () => undefined, - }); - expect(item.kind).toBe("item"); - expect(item.id).toBe("open"); - }); - - test("carries an explicit danger flag through untouched", () => { - const item = contextMenuItem({ - id: "sign-out", - label: "Sign out", - onSelect: () => undefined, - danger: true, - }); - expect(item.danger).toBe(true); - }); - - test("danger defaults to undefined when omitted", () => { - const item = contextMenuItem({ - id: "open", - label: "Open", - onSelect: () => undefined, - }); - expect(item.danger).toBeUndefined(); - }); -}); - -describe("isContextMenuEmpty", () => { - test("true for null", () => { - expect(isContextMenuEmpty(null)).toBe(true); - }); - - test("true for a menu with only separators", () => { - expect( - isContextMenuEmpty({ - entries: [contextMenuSeparator, contextMenuSeparator], - }), - ).toBe(true); - }); - - test("true for a menu with no entries at all", () => { - expect(isContextMenuEmpty({ entries: [] })).toBe(true); - }); - - test("false once a real item is present", () => { - expect( - isContextMenuEmpty({ - entries: [ - contextMenuSeparator, - contextMenuItem({ - id: "open", - label: "Open", - onSelect: () => undefined, - }), - ], - }), - ).toBe(false); - }); -}); diff --git a/apps/web/src/shell/context-menu/targets.test.ts b/apps/web/src/shell/context-menu/targets.test.ts deleted file mode 100644 index bdacea5d4..000000000 --- a/apps/web/src/shell/context-menu/targets.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { resolveTarget } from "@/shell/context-menu/lib"; - -import { SHELL_CONTEXT_MENU_FALLBACK, SHELL_CONTEXT_MENU_TARGETS } from "./targets"; - -/** Parses `html`'s single root element and mounts it in the document, so - * `origin.closest()` walks a real ancestor chain. */ -function mount(html: string): Element { - const wrapper = document.createElement("div"); - wrapper.innerHTML = html; - const root = wrapper.firstElementChild; - if (root === null) throw new Error("mount() requires a single root element"); - document.body.appendChild(root); - return root; -} - -function resolve(origin: Element | null) { - return resolveTarget(origin, SHELL_CONTEXT_MENU_TARGETS, SHELL_CONTEXT_MENU_FALLBACK); -} - -describe("SHELL_CONTEXT_MENU_TARGETS", () => { - test("resolves a workbench row", () => { - const container = mount( - '
', - ); - expect(resolve(container.querySelector("#inner"))).toEqual({ - type: "workbench", - id: "ch-1", - title: "Launch", - pinned: true, - }); - }); - - test("workbench row defaults title to the id and pinned to false when unset", () => { - const container = mount('
'); - expect(resolve(container)).toEqual({ - type: "workbench", - id: "ch-2", - title: "ch-2", - pinned: false, - }); - }); - - test("resolves the profile face nested inside a workbench row ahead of the workbench itself", () => { - const container = mount( - '
', - ); - expect(resolve(container.querySelector("#face"))).toEqual({ - type: "profile", - address: "agent:echo", - handle: "echo", - }); - }); - - test("resolves a routine row", () => { - const container = mount( - '
', - ); - expect(resolve(container)).toEqual({ - type: "routine", - id: "rt-1", - name: "Nightly Digest", - }); - }); - - test("resolves an insights run row", () => { - const container = mount('
'); - expect(resolve(container)).toEqual({ - type: "insights-run", - id: "run-1", - }); - }); - - test("resolves the rail avatar as the account target", () => { - const container = mount(''); - expect(resolve(container)).toEqual({ type: "account" }); - }); - - test("resolves an artifact row, defaulting ids to just its own id", () => { - const container = mount('
'); - expect(resolve(container)).toEqual({ - type: "artifact", - id: "art_1", - ids: ["art_1"], - }); - }); - - test("resolves an artifact row inside an active multi-select as every selected id", () => { - const container = mount( - '
', - ); - expect(resolve(container)).toEqual({ - type: "artifact", - id: "art_2", - ids: ["art_1", "art_2", "art_3"], - }); - }); - - test("falls back to the shell target for anything unmatched", () => { - const container = mount('
'); - expect(resolve(container.querySelector("#plain"))).toEqual(SHELL_CONTEXT_MENU_FALLBACK); - }); -}); diff --git a/apps/web/src/shell/layout/breakpoints-and-canvas.test.ts b/apps/web/src/shell/layout/breakpoints-and-canvas.test.ts deleted file mode 100644 index 62d119bb4..000000000 --- a/apps/web/src/shell/layout/breakpoints-and-canvas.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - canvasColumnAllowed, - shellLayoutModeForWidth, - shellLayoutModeFromMatches, -} from "./breakpoints"; -import { - initialCanvasColumnState, - openProfileInCanvas, - resolveCanvasVisibility, -} from "./canvas-column-state"; - -type TestProfile = { readonly id: string }; -type TestArtifact = { readonly id: string }; -type TestRoutine = { readonly id: string }; - -const sampleProfile: TestProfile = { id: "profile-1" }; - -function initial() { - return initialCanvasColumnState(); -} - -describe("shellLayoutModeForWidth", () => { - test("wide desktop widths are expanded", () => { - expect(shellLayoutModeForWidth(1920)).toBe("expanded"); - expect(shellLayoutModeForWidth(1100)).toBe("expanded"); - }); - - test("tablet widths are compact", () => { - expect(shellLayoutModeForWidth(1099)).toBe("compact"); - expect(shellLayoutModeForWidth(700)).toBe("compact"); - }); - - test("phone widths are narrow", () => { - expect(shellLayoutModeForWidth(699)).toBe("narrow"); - expect(shellLayoutModeForWidth(320)).toBe("narrow"); - }); -}); - -describe("shellLayoutModeFromMatches", () => { - test("gives every combination of the two queries one mode, narrow first", () => { - expect(shellLayoutModeFromMatches(true, true)).toBe("narrow"); - expect(shellLayoutModeFromMatches(false, true)).toBe("compact"); - expect(shellLayoutModeFromMatches(false, false)).toBe("expanded"); - // Not reachable through the real queries — nothing is narrower than 700px - // without also being narrower than 1100px — but the function takes two - // independent booleans, so the narrower answer has to win regardless. - expect(shellLayoutModeFromMatches(true, false)).toBe("narrow"); - }); -}); - -describe("canvasColumnAllowed", () => { - test("only expanded has space for a fourth column", () => { - expect(canvasColumnAllowed("expanded")).toBe(true); - expect(canvasColumnAllowed("compact")).toBe(false); - expect(canvasColumnAllowed("narrow")).toBe(false); - }); -}); - -describe("canvas column state", () => { - test("starts closed", () => { - expect(initial().open).toBe(false); - }); - - test("visibility requires both demand-driven open and the viewport", () => { - const open = openProfileInCanvas(initial(), sampleProfile); - const closed = initial(); - expect(resolveCanvasVisibility(open, true)).toBe(true); - expect(resolveCanvasVisibility(open, false)).toBe(false); - expect(resolveCanvasVisibility(closed, true)).toBe(false); - expect(resolveCanvasVisibility(closed, false)).toBe(false); - }); -}); diff --git a/apps/web/src/shell/layout/breakpoints.test.ts b/apps/web/src/shell/layout/breakpoints.test.ts deleted file mode 100644 index e4c8724ed..000000000 --- a/apps/web/src/shell/layout/breakpoints.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Tests the layout-mode predicates the shell renders from. Each function is -// pure — no DOM, no matchMedia — so the decision tree is covered directly. - -import { describe, expect, test } from "bun:test"; - -import { canvasColumnAllowed, shellLayoutModeForWidth } from "./breakpoints"; - -describe("breakpoints", () => { - test("canvas column is allowed only in expanded mode", () => { - expect(canvasColumnAllowed("expanded")).toBe(true); - expect(canvasColumnAllowed("compact")).toBe(false); - expect(canvasColumnAllowed("narrow")).toBe(false); - }); - - test("width boundaries map to the expected modes", () => { - // 1280px laptop — expanded (canvas available). - expect(shellLayoutModeForWidth(1280)).toBe("expanded"); - // 1024px laptop — compact (no canvas). - expect(shellLayoutModeForWidth(1024)).toBe("compact"); - // 600px phone — narrow. The boundary is strictly < 700. - expect(shellLayoutModeForWidth(600)).toBe("narrow"); - }); -}); diff --git a/apps/web/src/shell/layout/pending-dialog-request.test.ts b/apps/web/src/shell/layout/pending-dialog-request.test.ts deleted file mode 100644 index 5537b978e..000000000 --- a/apps/web/src/shell/layout/pending-dialog-request.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createPendingDialogRequest } from "./pending-dialog-request"; - -describe("createPendingDialogRequest", () => { - test("on-route dispatches immediately and never sets a pending flag", () => { - const store = createPendingDialogRequest(); - let dispatched = 0; - let navigated = 0; - store.request({ - alreadyOnTargetRoute: true, - navigateToTargetRoute: () => navigated++, - dispatch: () => dispatched++, - }); - expect(dispatched).toBe(1); - expect(navigated).toBe(0); - expect(store.consumePending()).toBe(false); - }); - - test("off-route navigates and records a pending flag instead of dispatching", () => { - const store = createPendingDialogRequest(); - let dispatched = 0; - let navigated = 0; - store.request({ - alreadyOnTargetRoute: false, - navigateToTargetRoute: () => navigated++, - dispatch: () => dispatched++, - }); - expect(dispatched).toBe(0); - expect(navigated).toBe(1); - expect(store.consumePending()).toBe(true); - }); - - test("consumePending clears the flag — a second read reports nothing pending", () => { - const store = createPendingDialogRequest(); - store.request({ - alreadyOnTargetRoute: false, - navigateToTargetRoute: () => undefined, - dispatch: () => undefined, - }); - expect(store.consumePending()).toBe(true); - expect(store.consumePending()).toBe(false); - }); - - test("resetPending drops a pending flag without consuming it as true", () => { - const store = createPendingDialogRequest(); - store.request({ - alreadyOnTargetRoute: false, - navigateToTargetRoute: () => undefined, - dispatch: () => undefined, - }); - store.resetPending(); - expect(store.consumePending()).toBe(false); - }); - - test("two stores never share pending state", () => { - const a = createPendingDialogRequest(); - const b = createPendingDialogRequest(); - a.request({ - alreadyOnTargetRoute: false, - navigateToTargetRoute: () => undefined, - dispatch: () => undefined, - }); - expect(a.consumePending()).toBe(true); - expect(b.consumePending()).toBe(false); - }); -}); diff --git a/apps/web/src/shell/library-artifacts.test.ts b/apps/web/src/shell/library-artifacts.test.ts deleted file mode 100644 index 4d8559361..000000000 --- a/apps/web/src/shell/library-artifacts.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { UnauthenticatedError } from "@/lib/api-query"; -import { - artifactListRowToSummary, - isArtifactsUnavailableStatus, - mapArtifactListToSummaries, - uploadArtifactFiles, - type ArtifactListRow, -} from "./library-artifacts"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -const sample: ArtifactListRow = { - id: "art_1", - kind: "file", - title: "Quarterly report.pdf", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-02T00:00:00.000Z", -}; - -describe("library-artifacts", () => { - test("maps list rows onto ArtifactSummary without inventing fields", () => { - expect(artifactListRowToSummary(sample)).toEqual({ - id: "art_1", - title: "Quarterly report.pdf", - kind: "file", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-02T00:00:00.000Z", - }); - }); - - test("maps a list without inventing or dropping rows", () => { - const second: ArtifactListRow = { - ...sample, - id: "art_2", - kind: "document", - title: "notes.txt", - }; - const mapped = mapArtifactListToSummaries([sample, second]); - expect(mapped).toHaveLength(2); - expect(mapped[0]?.id).toBe("art_1"); - expect(mapped[1]?.kind).toBe("document"); - }); - - test("detects the unconfigured-plane status", () => { - expect(isArtifactsUnavailableStatus(503)).toBe(true); - expect(isArtifactsUnavailableStatus(500)).toBe(false); - expect(isArtifactsUnavailableStatus(undefined)).toBe(false); - }); - - test("uploadArtifactFiles throws an UnauthenticatedError on 401", async () => { - globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => - Promise.resolve(new Response(null, { status: 401 }))) as typeof fetch; - await expect(uploadArtifactFiles("tnt_1", [new File(["x"], "x.txt")])).rejects.toBeInstanceOf( - UnauthenticatedError, - ); - }); -}); diff --git a/apps/web/src/shell/routine-activity.test.ts b/apps/web/src/shell/routine-activity.test.ts deleted file mode 100644 index b6d43d9bc..000000000 --- a/apps/web/src/shell/routine-activity.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// See `routine-activity.ts` for why this resolves no items until a native -// fires equivalent exists. - -import { afterEach, describe, expect, test } from "bun:test"; - -import { listRoutineActivity } from "./routine-activity"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -describe("listRoutineActivity", () => { - test("resolves no routine activity", async () => { - await expect(listRoutineActivity()).resolves.toEqual([]); - }); - - test("issues no fetch", async () => { - let fetched = false; - globalThis.fetch = ((_input: RequestInfo | URL) => { - fetched = true; - return Promise.resolve(new Response("{}", { status: 200 })); - }) as typeof fetch; - - await listRoutineActivity(); - - expect(fetched).toBe(false); - }); -}); diff --git a/apps/web/src/skill-display-name.test.ts b/apps/web/src/skill-display-name.test.ts deleted file mode 100644 index 46c9c7c02..000000000 --- a/apps/web/src/skill-display-name.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { skillDisplayName } from "./skill-display-name"; - -describe("skillDisplayName", () => { - test("title-cases a kebab slug when no display title is set", () => { - expect(skillDisplayName({ name: "writing-system-prompts" })).toBe("Writing System Prompts"); - expect(skillDisplayName({ name: "triage" })).toBe("Triage"); - }); - - test("prefers an explicit displayTitle over the slug", () => { - expect( - skillDisplayName({ - name: "writing-system-prompts", - displayTitle: "Prompt writing", - }), - ).toBe("Prompt writing"); - }); - - test("prefers the native asset displayName over the slug", () => { - expect( - skillDisplayName({ - name: "summarize", - displayName: "Summarize Now!", - }), - ).toBe("Summarize Now!"); - }); - - test("treats a whitespace-only displayTitle as absent", () => { - expect(skillDisplayName({ name: "triage", displayTitle: " " })).toBe("Triage"); - }); -}); diff --git a/apps/web/src/skill-version-author.test.ts b/apps/web/src/skill-version-author.test.ts deleted file mode 100644 index 32e3d5971..000000000 --- a/apps/web/src/skill-version-author.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { skillVersionSavedBy } from "./skill-version-author"; - -describe("skillVersionSavedBy", () => { - test("a save made through the product reads as the product, not its git identity", () => { - expect(skillVersionSavedBy("interchange-hub")).toBe("Workbench"); - }); - - test("a real person's commit keeps their name", () => { - expect(skillVersionSavedBy("Grace Hopper")).toBe("Grace Hopper"); - }); - - test("surrounding whitespace does not smuggle the internal name through", () => { - expect(skillVersionSavedBy(" interchange-hub ")).toBe("Workbench"); - }); -}); diff --git a/apps/web/src/test-query-provider.tsx b/apps/web/src/test-query-provider.tsx deleted file mode 100644 index 3506c54a0..000000000 --- a/apps/web/src/test-query-provider.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// retry:false + gcTime:0 keep test failures loud and cache-free. - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; - -export function createTestQueryClient(): QueryClient { - return new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - staleTime: 0, - }, - }, - }); -} - -export function TestQueryProvider({ - children, - client = createTestQueryClient(), -}: { - readonly children: ReactNode; - readonly client?: QueryClient; -}) { - return {children}; -} diff --git a/apps/web/src/tools/mcp-servers-query.test.ts b/apps/web/src/tools/mcp-servers-query.test.ts deleted file mode 100644 index 9db37d61c..000000000 --- a/apps/web/src/tools/mcp-servers-query.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// The workspace redeploy loop: one workbench's failure never stops the rest, -// and the failures come back named so the Tools page can say which Myras are -// still running the old catalog. - -import { describe, expect, test } from "bun:test"; - -import { describeRedeployResult, redeployMyraTenants } from "./mcp-servers-query"; - -const MYRA = { id: "agent-myra", name: "Myra", assetName: "myra" }; - -describe("redeployMyraTenants", () => { - test("a failed redeploy is named, and the rest still go out", async () => { - const redeployed: string[] = []; - const result = await redeployMyraTenants( - [ - { id: "ws", name: "workspace" }, - { id: "wb-a", name: "Alpha" }, - { id: "wb-b", name: "Beta" }, - ], - { - findMyra: async () => MYRA, - redeploy: async (tenantId) => { - if (tenantId === "wb-a") throw new Error("boom"); - redeployed.push(tenantId); - }, - }, - ); - expect(result.redeployed).toBe(2); - expect(redeployed).toEqual(["ws", "wb-b"]); - expect(result.failed).toHaveLength(1); - expect(result.failed[0]).toMatchObject({ tenantId: "wb-a", workbenchName: "Alpha" }); - expect(result.failed[0]?.error).toBe("Something went wrong redeploying Myra. Try again."); - }); - - test("a workbench that cannot be read is named instead of stopping the loop", async () => { - const redeployed: string[] = []; - const result = await redeployMyraTenants( - [ - { id: "wb-a", name: "Alpha" }, - { id: "wb-b", name: "Beta" }, - ], - { - findMyra: async (tenantId) => { - if (tenantId === "wb-a") throw new Error("gone"); - return MYRA; - }, - redeploy: async (tenantId) => { - redeployed.push(tenantId); - }, - }, - ); - expect(result.redeployed).toBe(1); - expect(redeployed).toEqual(["wb-b"]); - expect(result.failed).toHaveLength(1); - expect(result.failed[0]).toMatchObject({ tenantId: "wb-a", workbenchName: "Alpha" }); - }); - - test("a workbench without Myra is skipped, not failed", async () => { - const result = await redeployMyraTenants([{ id: "wb-a", name: "Alpha" }], { - findMyra: async () => undefined, - redeploy: async () => { - throw new Error("must not be called"); - }, - }); - expect(result).toEqual({ redeployed: 0, failed: [] }); - }); -}); - -describe("describeRedeployResult", () => { - test("a clean redeploy names the count", () => { - expect(describeRedeployResult({ redeployed: 2, failed: [] })).toBe( - "Myra redeployed in 2 workbenches.", - ); - expect(describeRedeployResult({ redeployed: 1, failed: [] })).toBe( - "Myra redeployed in 1 workbench.", - ); - }); - - test("a partial failure names the stale workbenches", () => { - expect( - describeRedeployResult({ - redeployed: 1, - failed: [{ tenantId: "wb-a", workbenchName: "Alpha", error: "Something went wrong." }], - }), - ).toBe( - "Myra redeployed in 1 workbench, but the redeploy failed in Alpha — " + - "those workbenches still run the old catalog. Try the change again.", - ); - }); -}); diff --git a/apps/web/src/workbench-path.test.ts b/apps/web/src/workbench-path.test.ts deleted file mode 100644 index 582e59111..000000000 --- a/apps/web/src/workbench-path.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - workbenchIdFromPath, - workbenchPath, - workbenchSettingsPath, - workbenchSettingsSectionFromPath, - workbenchSettingsEntityIdFromPath, - isWorkbenchPath, - isWorkbenchSettingsPath, -} from "./workbench-path"; - -describe("workbenchPath helpers", () => { - test("builds canonical /w paths", () => { - expect(workbenchPath(null)).toBe("/w"); - expect(workbenchPath("ch_1")).toBe("/w/ch_1"); - expect(workbenchPath("ch/with/slash")).toBe("/w/ch%2Fwith%2Fslash"); - }); - - test("parses /w and legacy /chat deep links", () => { - expect(workbenchIdFromPath("/w")).toBeNull(); - expect(workbenchIdFromPath("/w/ch_1")).toBe("ch_1"); - expect(workbenchIdFromPath("/chat/ch_1")).toBe("ch_1"); - expect(workbenchIdFromPath("/routines")).toBeNull(); - }); - - test("workbenchIdFromPath reads a malformed escape as no selection", () => { - expect(workbenchIdFromPath("/w/%E0%A4%A")).toBeNull(); - expect(workbenchIdFromPath("/chat/%")).toBeNull(); - }); - - test("isWorkbenchPath covers both prefixes", () => { - expect(isWorkbenchPath("/w")).toBe(true); - expect(isWorkbenchPath("/w/ch_1")).toBe(true); - expect(isWorkbenchPath("/chat")).toBe(true); - expect(isWorkbenchPath("/chat/ch_1")).toBe(true); - expect(isWorkbenchPath("/")).toBe(false); - }); -}); - -describe("workbench settings path helpers", () => { - test("builds the settings stage surface path", () => { - expect(workbenchSettingsPath("ch_1")).toBe("/w/ch_1/settings"); - }); - - test("builds a section-scoped settings path", () => { - expect(workbenchSettingsPath("ch_1", "members")).toBe("/w/ch_1/settings/members"); - }); - - test("builds a section entity deep link and encodes the entity id", () => { - expect(workbenchSettingsPath("ch_1", "agents", "wfd_myra")).toBe( - "/w/ch_1/settings/agents/wfd_myra", - ); - expect(workbenchSettingsPath("ch_1", "agents", "a/b")).toBe("/w/ch_1/settings/agents/a%2Fb"); - }); - - test("workbenchIdFromPath resolves ids under /settings", () => { - expect(workbenchIdFromPath("/w/ch_1/settings")).toBe("ch_1"); - expect(workbenchIdFromPath("/chat/ch_1/settings")).toBe("ch_1"); - }); - - test("workbenchIdFromPath resolves ids under a section-scoped /settings path", () => { - expect(workbenchIdFromPath("/w/ch_1/settings/members")).toBe("ch_1"); - expect(workbenchIdFromPath("/chat/ch_1/settings/agents")).toBe("ch_1"); - }); - - test("workbenchIdFromPath resolves ids under a section entity deep link", () => { - expect(workbenchIdFromPath("/w/ch_1/settings/agents/wfd_myra")).toBe("ch_1"); - expect(workbenchIdFromPath("/chat/ch_1/settings/agents/wfd_myra")).toBe("ch_1"); - }); - - test("isWorkbenchSettingsPath is true for /settings, /settings/:section, and entity", () => { - expect(isWorkbenchSettingsPath("/w/ch_1/settings")).toBe(true); - expect(isWorkbenchSettingsPath("/w/ch_1/settings/members")).toBe(true); - expect(isWorkbenchSettingsPath("/w/ch_1/settings/agents/wfd_1")).toBe(true); - expect(isWorkbenchSettingsPath("/w/ch_1")).toBe(false); - expect(isWorkbenchSettingsPath("/w")).toBe(false); - }); - - test("workbenchSettingsSectionFromPath extracts the trailing section segment", () => { - expect(workbenchSettingsSectionFromPath("/w/ch_1/settings/members")).toBe("members"); - expect(workbenchSettingsSectionFromPath("/chat/ch_1/settings/agents")).toBe("agents"); - expect(workbenchSettingsSectionFromPath("/w/ch_1/settings")).toBeUndefined(); - expect(workbenchSettingsSectionFromPath("/w/ch_1")).toBeUndefined(); - }); - - test("workbenchSettingsSectionFromPath reads an unrecognized section id as no section", () => { - expect(workbenchSettingsSectionFromPath("/w/ch_1/settings/not-a-real-section")).toBeUndefined(); - }); - - test("workbenchSettingsSectionFromPath ignores a trailing entity id", () => { - expect(workbenchSettingsSectionFromPath("/w/ch_1/settings/agents/wfd_myra")).toBe("agents"); - expect(workbenchSettingsSectionFromPath("/chat/ch_1/settings/agents/a%2Fb")).toBe("agents"); - }); - - test("workbenchSettingsEntityIdFromPath extracts the entity under a section", () => { - expect(workbenchSettingsEntityIdFromPath("/w/ch_1/settings/agents/wfd_myra", "agents")).toBe( - "wfd_myra", - ); - expect(workbenchSettingsEntityIdFromPath("/chat/ch_1/settings/agents/a%2Fb", "agents")).toBe( - "a/b", - ); - expect(workbenchSettingsEntityIdFromPath("/w/ch_1/settings/agents", "agents")).toBeNull(); - expect( - workbenchSettingsEntityIdFromPath("/w/ch_1/settings/agents/wfd_myra", "members"), - ).toBeNull(); - expect(workbenchSettingsEntityIdFromPath("/w/ch_1/settings", "agents")).toBeNull(); - }); - - test("workbenchSettingsSectionFromPath reads a malformed escape as no section", () => { - expect(workbenchSettingsSectionFromPath("/w/ch_1/settings/%E0%A4%A")).toBeUndefined(); - }); - - test("workbenchSettingsEntityIdFromPath reads a malformed escape as no entity", () => { - expect( - workbenchSettingsEntityIdFromPath("/w/ch_1/settings/agents/%E0%A4%A", "agents"), - ).toBeNull(); - }); -}); diff --git a/bun.lock b/bun.lock index 8331296e6..ffcfbcfd5 100644 --- a/bun.lock +++ b/bun.lock @@ -158,7 +158,6 @@ "react-joyride": "^2.9.3", }, "devDependencies": { - "@happy-dom/global-registrator": "^20.11.2", "@intx/inference": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@types/bun": "catalog:", @@ -739,8 +738,6 @@ "@gilbarbara/deep-equal": ["@gilbarbara/deep-equal@0.3.1", "", {}, "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw=="], - "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.14.5" } }, "sha512-B05ID9DhSwLs6mlm1fzlkAtTIvB3duCvjJjfr19LBrlTK7VZtRjDqoTRIVv13GuYuNdhByu8LSZgThwV3Rkj7g=="], - "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], "@hono/standard-validator": ["@hono/standard-validator@0.2.3", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "hono": ">=3.9.0" } }, "sha512-bp9vHu6Va6SfMHC3D4ZLBbT/woi+AZ9CRdTXQu3kLJuLh2W/Gb9UO4hijS+BQAGFXi4EGpXdetxpzwTAawSVeg=="], @@ -1089,10 +1086,6 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], - - "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260707.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260707.2" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg=="], "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg=="], @@ -1165,8 +1158,6 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1263,8 +1254,6 @@ "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], - "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -1337,8 +1326,6 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -1821,8 +1808,6 @@ "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.3", "", {}, "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], - "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "which-typed-array": ["which-typed-array@1.1.23", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-JMh8aK+1B/0bk/YNupICmH5MgCq6yNLKYQUfsZtSDLLCCmxUkHjUY+oOlIa90lO7lHN1woAfpenLdlKpXy9o+A=="], diff --git a/packages/approvals/src/headline.test.ts b/packages/approvals/src/headline.test.ts deleted file mode 100644 index 98ce8a19a..000000000 --- a/packages/approvals/src/headline.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { expect, test } from "bun:test"; - -import { argumentsSummaryFor, headlineFor } from "./headline"; - -test("falls back to a generic label when the tool definition carries neither", () => { - expect(headlineFor({}, {})).toBe("Run a tool"); - expect(headlineFor(null, null)).toBe("Run a tool"); -}); - -test("prefers the bare tool name when no description is present", () => { - expect(headlineFor({ name: "send_email" }, {})).toBe("send_email"); -}); - -test("prefers the human-readable description over the bare name", () => { - expect( - headlineFor( - { - name: "send_email", - description: "Sends an email on the tenant's behalf", - }, - {}, - ), - ).toBe("Sends an email on the tenant's behalf"); -}); - -test("folds in the live call's title argument, when present, as the specific ask", () => { - expect( - headlineFor( - { - name: "pain_point_collateral_finalize", - description: - "Finalizes one piece of pain-point sales collateral, pending human approval, and prepares it as a Library artifact.", - }, - { - title: "Faster onboarding for Acme Corp", - painPoint: "Slow onboarding", - }, - ), - ).toBe( - 'Finalizes one piece of pain-point sales collateral, pending human approval, and prepares it as a Library artifact.: "Faster onboarding for Acme Corp"', - ); -}); - -test("ignores a blank or non-string title rather than rendering an empty quote", () => { - expect(headlineFor({ name: "send_email" }, { title: " " })).toBe("send_email"); - expect(headlineFor({ name: "send_email" }, { title: 42 })).toBe("send_email"); -}); - -test("workflow_deploy renders the package name, short sha, and declared tool pins directly, ignoring the tool's own description", () => { - expect( - headlineFor( - { name: "workflow_deploy", description: "Deploy a workflow asset..." }, - { - assetId: "asset_daily_digest", - commitSha: "abcdef1234567890", - entry: "./workflow.ts", - packageName: "daily-digest", - toolPackagePins: [ - { name: "@corbits/email-tools", version: "1.2.3" }, - { name: "@corbits/http-tools", version: "0.4.0" }, - ], - }, - ), - ).toBe( - "Deploy workflow daily-digest @ abcdef1 — tools: @corbits/email-tools@1.2.3, @corbits/http-tools@0.4.0", - ); -}); - -test("workflow_deploy with no declared pins reads as 'none declared' rather than an empty list", () => { - expect( - headlineFor( - { name: "workflow_deploy" }, - { - assetId: "asset_daily_digest", - commitSha: "abcdef1234567890", - packageName: "daily-digest", - toolPackagePins: [], - }, - ), - ).toBe("Deploy workflow daily-digest @ abcdef1 — tools: none declared"); -}); - -test("workflow_deploy falls back to the bare assetId when packageName is missing", () => { - expect( - headlineFor( - { name: "workflow_deploy" }, - { assetId: "asset_daily_digest", commitSha: "abcdef1234567890" }, - ), - ).toBe("Deploy workflow asset_daily_digest @ abcdef1 — tools: none declared"); -}); - -test("argumentsSummaryFor: write_file reads as its path plus a content preview", () => { - expect( - argumentsSummaryFor( - { name: "write_file" }, - { path: "/reports/summary.md", content: "# Q3 recap\n\nRevenue grew..." }, - ), - ).toBe('/reports/summary.md: "# Q3 recap\n\nRevenue grew..."'); -}); - -test("argumentsSummaryFor: write_file truncates a long content preview to ~120 characters", () => { - const content = "x".repeat(200); - const summary = argumentsSummaryFor({ name: "write_file" }, { path: "/a.txt", content }); - expect(summary).toBe(`/a.txt: "${"x".repeat(120)}…"`); -}); - -test("argumentsSummaryFor: write_file with no content shows just the path", () => { - expect(argumentsSummaryFor({ name: "write_file" }, { path: "/a.txt" })).toBe("/a.txt"); -}); - -test("argumentsSummaryFor: other tools render a compact key: value list", () => { - expect( - argumentsSummaryFor({ name: "send_email" }, { to: "alice@example.com", subject: "Hi" }), - ).toBe("to: alice@example.com, subject: Hi"); -}); - -test("argumentsSummaryFor: truncates a long generic argument list", () => { - const summary = argumentsSummaryFor({ name: "send_email" }, { body: "y".repeat(200) }); - expect(summary?.length).toBe(121); // 120 chars + the ellipsis - expect(summary?.endsWith("…")).toBe(true); -}); - -test("argumentsSummaryFor: undefined when there are no arguments to show", () => { - expect(argumentsSummaryFor({ name: "send_email" }, {})).toBeUndefined(); - expect(argumentsSummaryFor({ name: "send_email" }, null)).toBeUndefined(); -}); diff --git a/packages/deferred-tools/src/director.test.ts b/packages/deferred-tools/src/director.test.ts deleted file mode 100644 index 6a2638612..000000000 --- a/packages/deferred-tools/src/director.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ToolDefinition } from "@intx/types/runtime"; - -import { DeferredToolSelection } from "./director"; -import { matchesNamePattern } from "./match"; - -function definition(name: string, description: string): ToolDefinition { - return { name, description, inputSchema: {} }; -} - -const DEFINITIONS: readonly ToolDefinition[] = [ - definition("mail_send", "Send mail"), - definition("memory_write", "Remember a fact for later"), - definition("memory_search", "Recall what was remembered"), - definition("artifact_save", "Save a note as an artifact"), -]; - -const CONFIG = { visible: ["mail_*"], deferred: ["memory_*", "artifact_*"] }; - -test("a pattern's star spans any run of characters and its dots stay literal", () => { - expect(matchesNamePattern("memory_write", "memory_*")).toBe(true); - expect(matchesNamePattern("memory", "memory_*")).toBe(false); - expect(matchesNamePattern("exa.search", "exa.*")).toBe(true); - expect(matchesNamePattern("exaXsearch", "exa.*")).toBe(false); - expect(matchesNamePattern("mail_send", "mail_send")).toBe(true); -}); - -test("the first turn sends the non-deferred tools plus tool_search, and nothing deferred", () => { - const selection = new DeferredToolSelection(DEFINITIONS, CONFIG); - - expect(selection.tools().map((tool) => tool.name)).toEqual(["mail_send", "tool_search"]); -}); - -test("a search surfaces the whole namespace it matched, and no other", () => { - const selection = new DeferredToolSelection(DEFINITIONS, CONFIG); - - // "remember" only matches memory_write's description, but memory_search - // comes along so the server pays one cache miss instead of two. - selection.surface("remember"); - - expect(selection.tools().map((tool) => tool.name)).toEqual([ - "mail_send", - "tool_search", - "memory_write", - "memory_search", - ]); -}); - -test("surfacing is append-only, so each turn's tools are a prefix of the next", () => { - const selection = new DeferredToolSelection(DEFINITIONS, CONFIG); - - selection.surface("artifact"); - const afterFirst = selection.tools().map((tool) => tool.name); - selection.surface("remember"); - const afterSecond = selection.tools().map((tool) => tool.name); - selection.surface("artifact"); - - expect(afterSecond.slice(0, afterFirst.length)).toEqual(afterFirst); - expect(afterSecond).toEqual([...afterFirst, "memory_write", "memory_search"]); - // A repeat search neither reorders nor duplicates what is already surfaced. - expect(selection.tools().map((tool) => tool.name)).toEqual(afterSecond); -}); - -test("an empty query surfaces every deferred tool", () => { - const selection = new DeferredToolSelection(DEFINITIONS, CONFIG); - - selection.surface(""); - - expect(selection.tools()).toHaveLength(5); -}); - -test("a tool no pattern names stays visible rather than disappearing", () => { - const selection = new DeferredToolSelection([definition("posix_read", "Read a file")], CONFIG); - - expect(selection.tools().map((tool) => tool.name)).toEqual(["posix_read", "tool_search"]); -}); diff --git a/packages/error-sink/src/error-envelope.test.ts b/packages/error-sink/src/error-envelope.test.ts deleted file mode 100644 index 3413aed08..000000000 --- a/packages/error-sink/src/error-envelope.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { makeErrorEnvelope, parseErrorEnvelope } from "./error-envelope"; - -describe("error envelope", () => { - test("makeErrorEnvelope carries code, userMessage, and a generated refId", () => { - const envelope = makeErrorEnvelope({ - code: "provisioning_failed", - userMessage: "We're on it. Try again in a moment.", - }); - expect(envelope.error.code).toBe("provisioning_failed"); - expect(envelope.error.userMessage).toBe("We're on it. Try again in a moment."); - expect(typeof envelope.error.refId).toBe("string"); - expect(envelope.error.refId.length).toBeGreaterThan(0); - }); - - test("parseErrorEnvelope accepts a well-formed envelope", () => { - const body = { - error: { code: "x", userMessage: "y", refId: "z" }, - }; - expect(parseErrorEnvelope(body)).toEqual(body); - }); - - test("parseErrorEnvelope rejects a legacy {code, message} body", () => { - const body = { error: { code: "x", message: "raw internal detail" } }; - expect(parseErrorEnvelope(body)).toBeUndefined(); - }); - - test("parseErrorEnvelope rejects garbage", () => { - expect(parseErrorEnvelope(null)).toBeUndefined(); - expect(parseErrorEnvelope({})).toBeUndefined(); - }); -}); diff --git a/packages/error-sink/src/index.test.ts b/packages/error-sink/src/index.test.ts deleted file mode 100644 index 278da0e49..000000000 --- a/packages/error-sink/src/index.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { configureSync, resetSync } from "@intx/log"; -// `LogRecord` isn't part of `@intx/log`'s narrow re-export surface (only -// symbols an `@intx/*` consumer actually needs are re-exported there); its -// own README says to reach into `@logtape/logtape` directly for anything -// else, rather than widen that surface for a type this test alone needs. -import type { LogRecord } from "@logtape/logtape"; -import { reportError } from "./index"; - -let records: LogRecord[]; - -function installCapturingSink(): void { - records = []; - configureSync({ - reset: true, - sinks: { - capture: (record) => { - records.push(record); - }, - }, - loggers: [ - { category: ["errors"], sinks: ["capture"], lowestLevel: "debug" }, - { category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" }, - ], - }); -} - -beforeEach(() => installCapturingSink()); -afterEach(() => resetSync()); - -describe("reportError context capture", () => { - test("carries operation, tenant/workbench/agent ids, and a quotable refId", () => { - const refId = reportError(new Error("boom"), { - operation: "resolveFallbackWorkbenchId", - tenantId: "tenant_1", - workbenchId: "workbench_1", - agentId: "agent_1", - }); - - expect(records).toHaveLength(1); - const properties = records[0]?.properties as Record; - expect(properties.operation).toBe("resolveFallbackWorkbenchId"); - expect(properties.tenantId).toBe("tenant_1"); - expect(properties.workbenchId).toBe("workbench_1"); - expect(properties.agentId).toBe("agent_1"); - expect(properties.refId).toBe(refId); - expect(typeof refId).toBe("string"); - expect(refId.length).toBeGreaterThan(0); - }); - - test("mints a refId when the caller doesn't supply one, and reuses a supplied one", () => { - const minted = reportError(new Error("boom"), { operation: "op" }); - expect(minted.length).toBeGreaterThan(0); - - const reused = reportError(new Error("boom"), { - operation: "op", - refId: "support-quotable-id", - }); - expect(reused).toBe("support-quotable-id"); - }); - - test("carries redacted extra context", () => { - reportError(new Error("boom"), { - operation: "op", - extra: { apiKey: "sk-abcdefgh1234", userId: "user_1" }, - }); - - const properties = records[0]?.properties as Record; - expect(properties.extra).toEqual({ - apiKey: "[redacted]", - userId: "user_1", - }); - }); - - test('degrades an invalid context to operation "unknown" instead of throwing', () => { - // @ts-expect-error -- deliberately malformed to prove the degrade path - const refId = reportError(new Error("boom"), {}); - expect(typeof refId).toBe("string"); - const properties = records[0]?.properties as Record; - expect(properties.operation).toBe("unknown"); - }); - - test("redacts a secret in the error message before it reaches the sink", () => { - reportError(new Error("rejected Bearer abc.def.ghi"), { operation: "op" }); - const loggedError = records[0]?.message; - expect(String(loggedError)).not.toContain("abc.def.ghi"); - }); - - test("preserves the error's cause chain, redacted", () => { - const inner = new Error("rejected Bearer abc.def.ghi"); - const outer = new Error("wrapped failure", { cause: inner }); - reportError(outer, { operation: "op" }); - - const properties = records[0]?.properties as Record; - const loggedError = properties.error as Error; - expect(loggedError.cause).toBeInstanceOf(Error); - const cause = loggedError.cause as Error; - expect(cause.message).not.toContain("abc.def.ghi"); - expect(cause.message).toContain("[redacted]"); - }); - - test("caps a cyclic cause chain instead of recursing forever", () => { - const a = new Error("a"); - const b = new Error("b", { cause: a }); - a.cause = b; - - expect(() => reportError(b, { operation: "op" })).not.toThrow(); - }); -}); - -describe("reportError never throws", () => { - test("survives a sink that throws", () => { - configureSync({ - reset: true, - sinks: { - broken: () => { - throw new Error("sink exploded"); - }, - }, - loggers: [ - { category: ["errors"], sinks: ["broken"], lowestLevel: "debug" }, - { category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" }, - ], - }); - - expect(() => reportError(new Error("boom"), { operation: "op" })).not.toThrow(); - }); - - test("survives a non-Error thrown value", () => { - expect(() => reportError("plain string failure", { operation: "op" })).not.toThrow(); - expect(() => reportError(undefined, { operation: "op" })).not.toThrow(); - }); -}); diff --git a/packages/error-sink/src/ref-id.test.ts b/packages/error-sink/src/ref-id.test.ts deleted file mode 100644 index 008d29de8..000000000 --- a/packages/error-sink/src/ref-id.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { generateRefId } from "./ref-id"; - -describe("generateRefId", () => { - test("produces a non-empty string", () => { - const refId = generateRefId(); - expect(typeof refId).toBe("string"); - expect(refId.length).toBeGreaterThan(0); - }); - - test("is not constant across calls", () => { - expect(generateRefId()).not.toBe(generateRefId()); - }); -}); diff --git a/packages/url-path/src/decoded-or-null.test.ts b/packages/url-path/src/decoded-or-null.test.ts deleted file mode 100644 index f9619ded3..000000000 --- a/packages/url-path/src/decoded-or-null.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { decodedOrNull } from "./decoded-or-null"; - -describe("decodedOrNull", () => { - test("decodes a percent-escaped segment", () => { - expect(decodedOrNull("triage%20bot")).toBe("triage bot"); - }); - - test("passes through a segment with nothing to decode", () => { - expect(decodedOrNull("triage-bot")).toBe("triage-bot"); - }); - - test("returns null for a malformed escape instead of throwing", () => { - expect(decodedOrNull("%E0%A4%A")).toBeNull(); - expect(decodedOrNull("%")).toBeNull(); - expect(decodedOrNull("%zz")).toBeNull(); - }); -}); diff --git a/packages/workflows/src/catalog.test.ts b/packages/workflows/src/catalog.test.ts deleted file mode 100644 index 85d3e79e6..000000000 --- a/packages/workflows/src/catalog.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { type } from "arktype"; -import { describe, expect, test } from "bun:test"; - -import { - deliveryWorkbenchRequiredForWorkflowName, - isAutomatableWorkflowName, - isConversationalWorkflowName, - validateTriggerFieldsAtCreate, - workflowDisplayName, - workflowCatalogEntry, - WorkflowTriggerField, - WORKFLOW_CATALOG, -} from "./catalog"; - -describe("workflow catalog", () => { - test("marks the seeded assistant non-automatable", () => { - expect(isAutomatableWorkflowName("assistant")).toBe(false); - }); - - test("rejects agent handles and workbench-host names as automatable", () => { - expect(isAutomatableWorkflowName("my-researcher")).toBe(false); - expect(isAutomatableWorkflowName("workbench-host-abc")).toBe(false); - expect(isAutomatableWorkflowName("wfd_deadbeef")).toBe(false); - }); - - test("prefers catalog display names over raw asset names", () => { - expect(workflowDisplayName("assistant")).toBe("Myra"); - }); - - test("productizes the seeded assistant under the Myra display name", () => { - // The assistant workflow ships in DEFAULT_WORKFLOWS for every personal - // bench. Its catalog display name is the productized label Myra, not - // the generic "Assistant" — the routines picker and seeded asset both - // read it from here. - expect(workflowDisplayName("assistant")).toBe("Myra"); - const entry = WORKFLOW_CATALOG.find((e) => e.assetName === "assistant"); - expect(entry?.displayName).toBe("Myra"); - }); - - test("falls back to description, then humanized name — never blank", () => { - expect(workflowDisplayName("unknown-flow", " Weekly brief ")).toBe("Weekly brief"); - expect(workflowDisplayName("last-30-days")).toBe("Last 30 Days"); - }); - - describe("isConversationalWorkflowName", () => { - test("marks only the seeded assistant/Myra definition conversational", () => { - expect(isConversationalWorkflowName("assistant")).toBe(true); - }); - - test("treats a name absent from the catalog as conversational — a runtime agent-directory definition", () => { - expect(isConversationalWorkflowName("my-researcher")).toBe(true); - expect(isConversationalWorkflowName("wfd_deadbeef")).toBe(true); - }); - - test("every catalog entry declares a conversational flag", () => { - for (const entry of WORKFLOW_CATALOG) { - expect(typeof entry.conversational).toBe("boolean"); - } - }); - }); - - test("every catalog entry has a non-empty display name", () => { - for (const entry of WORKFLOW_CATALOG) { - expect(entry.displayName.trim().length).toBeGreaterThan(0); - expect(entry.assetName).toMatch(/^[a-z0-9-]+$/); - } - }); - - describe("deliveryMode", () => { - test("every catalog entry declares a delivery mode", () => { - for (const entry of WORKFLOW_CATALOG) { - expect(["workbench", "inbox"]).toContain(entry.deliveryMode); - } - }); - - test("no catalog entry currently delivers to inbox", () => { - const inboxEntries = WORKFLOW_CATALOG.filter((entry) => entry.deliveryMode === "inbox"); - expect(inboxEntries).toEqual([]); - }); - - test("deliveryWorkbenchRequiredForWorkflowName is true for every known catalog entry", () => { - for (const entry of WORKFLOW_CATALOG) { - expect(deliveryWorkbenchRequiredForWorkflowName(entry.assetName)).toBe(true); - } - }); - - test("an unknown workflow name defaults to workbench-required", () => { - expect(deliveryWorkbenchRequiredForWorkflowName("unknown-workflow")).toBe(true); - }); - }); - - test("every catalog entry carries honest demo-card copy", () => { - for (const entry of WORKFLOW_CATALOG) { - expect(entry.whatItDoes.trim().length).toBeGreaterThan(0); - expect(entry.exampleOutput.trim().length).toBeGreaterThan(0); - expect(entry.typicalDuration.trim().length).toBeGreaterThan(0); - // No fake precision or invented metrics dressed up as facts. - expect(entry.whatItDoes).not.toMatch(/%|\$\d/); - } - }); - - test("every exampleOutput is a single capitalized readout fragment with no trailing period", () => { - for (const entry of WORKFLOW_CATALOG) { - expect(entry.exampleOutput).not.toContain("\n"); - expect(entry.exampleOutput.endsWith(".")).toBe(false); - expect(entry.exampleOutput[0]).toBe(entry.exampleOutput[0]?.toUpperCase()); - } - }); - - test("workflows with no external connector requirement declare an empty list", () => { - const byAssetName = new Map(WORKFLOW_CATALOG.map((entry) => [entry.assetName, entry])); - expect(byAssetName.get("assistant")?.requiredConnections).toEqual([]); - }); - - describe("triggerFields", () => { - test("every declared triggerFields entry matches the WorkflowTriggerField shape", () => { - for (const entry of WORKFLOW_CATALOG) { - if (entry.triggerFields === undefined) continue; - const parsed = WorkflowTriggerField.array()(entry.triggerFields); - expect(parsed instanceof type.errors).toBe(false); - } - }); - - test("every triggerFields key is unique within its entry and non-blank labeled", () => { - for (const entry of WORKFLOW_CATALOG) { - if (entry.triggerFields === undefined) continue; - const keys = entry.triggerFields.map((field) => field.key); - expect(new Set(keys).size).toBe(keys.length); - for (const field of entry.triggerFields) { - expect(field.label.trim().length).toBeGreaterThan(0); - } - } - }); - - test("assistant takes no human-supplied trigger content", () => { - expect(workflowCatalogEntry("assistant")?.triggerFields).toBeUndefined(); - }); - }); - - // Two required fields, one "agent"-kind and one "text"-kind — an - // explicit local fixture, not pulled from any catalog entry, so this - // block's assertions about required/blank/non-string handling stay - // meaningful regardless of which entries the catalog happens to carry. - const AGENT_AND_PROMPT_FIELDS: readonly WorkflowTriggerField[] = [ - { key: "agent", kind: "agent", label: "Agent", required: true }, - { key: "prompt", kind: "text", label: "Prompt", required: true }, - ]; - - // Inputs bind at USE, never at creation — a scheduled - // definition (or a seed preset) must be creatable with a required - // trigger field left entirely unbound. `validateTriggerFieldsAtCreate` - // is the boundary check the schedule create path applies now: - // absence of a required field is never rejected, only a value the - // caller explicitly provided but left malformed is. - describe("validateTriggerFieldsAtCreate", () => { - const fields = AGENT_AND_PROMPT_FIELDS; - - test("a required field left entirely unbound passes at create time", () => { - expect(validateTriggerFieldsAtCreate(fields, { prompt: "Do it" })).toEqual({ ok: true }); - }); - - test("a provided-but-blank required field still fails: a caller who sets it must set it honestly", () => { - const result = validateTriggerFieldsAtCreate(fields, { - agent: " ", - prompt: "Do it", - }); - expect(result.ok).toBe(false); - }); - - test("a provided non-string value for a required field still fails", () => { - const result = validateTriggerFieldsAtCreate(fields, { - agent: 12345, - prompt: "Do it", - }); - expect(result.ok).toBe(false); - }); - - test("a fully valid input still passes", () => { - expect( - validateTriggerFieldsAtCreate(fields, { - agent: "wfd_1", - prompt: "Do it", - }), - ).toEqual({ ok: true }); - }); - }); -}); diff --git a/packages/workflows/src/deliver-when-routable.test.ts b/packages/workflows/src/deliver-when-routable.test.ts deleted file mode 100644 index b283de6a1..000000000 --- a/packages/workflows/src/deliver-when-routable.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { deliverWhenRoutable } from "./deliver-when-routable"; - -function unreachable(): Error { - return new Error("agent is unreachable: run_1@ten1.workbench.test"); -} - -describe("deliverWhenRoutable", () => { - test("retries once the address is routable after an unreachable send", async () => { - let sendCalls = 0; - let routablePolls = 0; - const sleeps: number[] = []; - - const result = await deliverWhenRoutable({ - send: async () => { - sendCalls++; - if (sendCalls === 1) { - throw unreachable(); - } - return "delivered"; - }, - isRoutable: () => { - routablePolls++; - return routablePolls >= 2; - }, - sleep: async (ms) => { - sleeps.push(ms); - }, - }); - - expect(result).toBe("delivered"); - expect(sendCalls).toBe(2); - expect(routablePolls).toBe(2); - expect(sleeps.length).toBe(1); - }); - - test("rethrows the original unreachable error once the deadline passes", async () => { - let now = 0; - const originalNow = Date.now; - Date.now = () => now; - try { - let sendCalls = 0; - await expect( - deliverWhenRoutable({ - send: async () => { - sendCalls++; - throw unreachable(); - }, - isRoutable: () => false, - deadlineMs: 500, - pollIntervalMs: 100, - sleep: async (ms) => { - now += ms; - }, - }), - ).rejects.toThrow("agent is unreachable"); - expect(sendCalls).toBe(1); - } finally { - Date.now = originalNow; - } - }); - - test("does not retry an error other than unreachable", async () => { - const err = new Error("boom"); - await expect( - deliverWhenRoutable({ - send: async () => { - throw err; - }, - isRoutable: () => true, - }), - ).rejects.toBe(err); - }); -}); diff --git a/packages/workflows/src/schedule/cron-sentence.test.ts b/packages/workflows/src/schedule/cron-sentence.test.ts deleted file mode 100644 index 99501a69e..000000000 --- a/packages/workflows/src/schedule/cron-sentence.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Contract: a sentence, in the reader's words, naming the zone only when -// there is a clock to read in it, and never the raw expression — not -// `cronstrue`'s exact phrasing. -import { describe, expect, test } from "bun:test"; - -import { cronHasWallClock, cronSentence } from "./cron-sentence"; - -const LOOKS_LIKE_CRON = /\*|\d+\s+\d+\s/; - -function expectSentence(sentence: string | null): string { - expect(sentence).not.toBeNull(); - const text = sentence as string; - expect(text).not.toMatch(LOOKS_LIKE_CRON); - expect(text.length).toBeGreaterThan(3); - return text; -} - -describe("cronHasWallClock", () => { - test("a pinned hour or minute is a clock reading", () => { - expect(cronHasWallClock("0 9 * * *")).toBe(true); - expect(cronHasWallClock("30 * * * *")).toBe(true); - }); - - test("a pure cadence has no clock, in any zone", () => { - expect(cronHasWallClock("* * * * *")).toBe(false); - expect(cronHasWallClock("*/15 * * * *")).toBe(false); - expect(cronHasWallClock("* */2 * * *")).toBe(false); - }); -}); - -describe("cronSentence", () => { - test("a weekday morning schedule reads as words naming its zone", () => { - const sentence = expectSentence(cronSentence("0 9 * * 1-5")); - expect(sentence).toStartWith("At "); - expect(sentence).toContain("Friday"); - expect(sentence).toEndWith("(UTC)"); - }); - - test("the named zone is the one the wall clock is read in", () => { - expect(cronSentence("30 14 * * *", "America/Los_Angeles")).toEndWith("(America/Los_Angeles)"); - }); - - test("a pure cadence names no zone — it is the same in every zone", () => { - const sentence = expectSentence(cronSentence("*/15 * * * *")); - expect(sentence).toStartWith("Every "); - expect(sentence).not.toContain("UTC"); - }); - - test("null for an expression that cannot be described", () => { - expect(cronSentence("not a cron")).toBeNull(); - expect(cronSentence("")).toBeNull(); - }); -}); diff --git a/packages/workflows/src/schedule/run-outcome.test.ts b/packages/workflows/src/schedule/run-outcome.test.ts deleted file mode 100644 index 87b1739f0..000000000 --- a/packages/workflows/src/schedule/run-outcome.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -// A warm-kept scheduled fire still settles. Native ScheduleTrigger -// definitions own one self-anchored workflow_run that must stay live -// across fires, so no server-side stamp ever flips it terminal -// per fire — surfaces read the fire settled through runOutcomeStatus -// past FIRE_RUNNING_WINDOW_MS instead. -import { describe, expect, test } from "bun:test"; - -import { FIRE_RUNNING_WINDOW_MS, runOutcomeStatus } from "./run-outcome"; - -const NOW = Date.parse("2026-09-15T12:00:00.000Z"); -const OLD = new Date(NOW - FIRE_RUNNING_WINDOW_MS - 1).toISOString(); -const FRESH = new Date(NOW - 1_000).toISOString(); - -describe("warm-kept scheduled fire settling", () => { - test("a fire with no in-flight turn past the window reads completed", () => { - expect( - runOutcomeStatus({ createdAt: OLD, status: "running", endedAt: null, turns: [] }, NOW), - ).toBe("completed"); - }); - - test("a fire flagged with no in-flight turn past the window reads completed", () => { - expect( - runOutcomeStatus( - { - createdAt: OLD, - status: "running", - endedAt: null, - hasInFlightTurn: false, - }, - NOW, - ), - ).toBe("completed"); - }); - - test("a fresh scheduled tick inside the window stays running", () => { - expect( - runOutcomeStatus({ createdAt: FRESH, status: "running", endedAt: null, turns: [] }, NOW), - ).toBe("running"); - }); - - test("a live tool loop stays running however old the fire", () => { - expect( - runOutcomeStatus( - { - createdAt: OLD, - status: "running", - endedAt: null, - turns: [{ status: "running" }], - }, - NOW, - ), - ).toBe("running"); - }); - - test("an endedAt stamp reads completed even inside the window", () => { - expect( - runOutcomeStatus({ createdAt: FRESH, status: "running", endedAt: FRESH, turns: [] }, NOW), - ).toBe("completed"); - }); - - test("non-running statuses pass through untouched", () => { - expect( - runOutcomeStatus({ createdAt: OLD, status: "failed", endedAt: null, turns: [] }, NOW), - ).toBe("failed"); - }); -}); diff --git a/packages/workflows/src/source.test.ts b/packages/workflows/src/source.test.ts deleted file mode 100644 index d35011c52..000000000 --- a/packages/workflows/src/source.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - readWorkflowSourceDefinition, - renderBundledWorkflowSourceTree, - RetiredWorkflowEnvelopeError, - WORKFLOW_SOURCE_DIRECTORS, - WORKFLOW_SOURCE_ENTRY, -} from "./source"; - -const WORKFLOW_JSON = JSON.stringify({ id: "wf_agent_research-buddy" }); - -function readerFor(tree: Readonly>) { - return { - readAssetBlob: (params: { assetId: string; path: string }) => { - const content = tree[params.path]; - if (content === undefined) return Promise.reject(new Error(`no blob at ${params.path}`)); - return Promise.resolve(new TextEncoder().encode(content)); - }, - }; -} - -test("the rendered tree is a manifest, the entry, and the definition projection", () => { - const tree = renderBundledWorkflowSourceTree({ - packageName: "@workbench-agent/research-buddy", - bundle: "export function build(input) { return input; }", - directorsBundle: "export const noop = null;\n", - buildExport: "build", - buildInput: { id: "wf_agent_research-buddy" }, - workflowJson: WORKFLOW_JSON, - }); - - expect(Object.keys(tree).sort()).toEqual([ - "definition.json", - "directors.js", - "package.json", - "workflow.js", - ]); - const manifest = JSON.parse(tree["package.json"] as string) as { - name: string; - interchange: { workflow: string; directors: string }; - }; - expect(manifest.name).toBe("@workbench-agent/research-buddy"); - expect(manifest.interchange.workflow).toBe(WORKFLOW_SOURCE_ENTRY); - expect(manifest.interchange.directors).toBe(WORKFLOW_SOURCE_DIRECTORS); -}); - -test("a bundled entry evaluates the bundle's build export and still projects the definition", async () => { - const tree = renderBundledWorkflowSourceTree({ - packageName: "@workbench-agent/research-buddy", - bundle: "export function build(input) { return { id: input.id }; }", - directorsBundle: "export const noop = null;\n", - buildExport: "build", - buildInput: { id: "wf_agent_research-buddy" }, - workflowJson: WORKFLOW_JSON, - }); - - // The entry is code the platform evaluates, so the projection — not a - // slice of the entry — is what readers get back. - expect(tree["workflow.js"]).toContain('export default build({"id":"wf_agent_research-buddy"});'); - expect(await readWorkflowSourceDefinition(readerFor(tree), "ast_1")).toBe(WORKFLOW_JSON); -}); - -test("an asset with no definition projection reads as the named retirement error", async () => { - await expect(readWorkflowSourceDefinition(readerFor({}), "ast_1")).rejects.toThrow( - RetiredWorkflowEnvelopeError, - ); -}); - -test("reading a source-form asset answers its serialized definition", async () => { - const tree = renderBundledWorkflowSourceTree({ - packageName: "@workbench-agent/research-buddy", - bundle: "export function build(input) { return input; }", - directorsBundle: "export const noop = null;\n", - buildExport: "build", - buildInput: { id: "wf_agent_research-buddy" }, - workflowJson: WORKFLOW_JSON, - }); - - expect(await readWorkflowSourceDefinition(readerFor(tree), "ast_1")).toBe(WORKFLOW_JSON); -}); diff --git a/packages/workflows/src/validate-push.test.ts b/packages/workflows/src/validate-push.test.ts deleted file mode 100644 index a45449366..000000000 --- a/packages/workflows/src/validate-push.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Round-trip against the real upstream validator, not our renderer's -// comments about it. `workflowKindHandler.validatePush` -// (`vendor/intx/hub-sessions/src/workflow-kind.ts`) is the only consumer of -// the tree `renderBundledWorkflowSourceTree` writes; nothing else in this -// repo checks the pair stays in sync, so a renderer/validator drift would -// otherwise surface only as a push rejection in production. - -import { expect, test } from "bun:test"; -import { workflowKindHandler } from "@intx/hub-sessions"; - -import { renderBundledWorkflowSourceTree } from "./source"; - -const WORKFLOW_JSON = JSON.stringify({ id: "wf_agent_research-buddy" }); - -function renderedTree(): Readonly> { - return renderBundledWorkflowSourceTree({ - packageName: "@workbench-agent/research-buddy", - bundle: "export function build(input) { return input; }", - directorsBundle: "export const noop = null;\n", - buildExport: "build", - buildInput: { id: "wf_agent_research-buddy" }, - workflowJson: WORKFLOW_JSON, - }); -} - -const encoder = new TextEncoder(); - -/** A minimal in-memory tree reader, matching the shape used by - * `workflow-run-kind.test.ts` upstream: `readBlob` resolves a root-relative - * POSIX path to its bytes. */ -function readBlobFor(tree: Readonly>) { - return async (path: string): Promise => { - const content = tree[path]; - if (content === undefined) throw new Error(`no such blob: ${path}`); - return encoder.encode(content); - }; -} - -const hubPrincipal = { kind: "hub" as const }; - -function push(tree: Readonly>) { - return workflowKindHandler.validatePush({ - repoId: { kind: "workflow", id: "ast_1" }, - ref: "refs/heads/main", - principal: hubPrincipal, - topLevelTreePaths: Object.keys(tree), - readBlob: readBlobFor(tree), - listDir: async () => [], - priorReadBlob: async () => null, - priorListDir: async () => [], - }); -} - -test("a rendered single-package tree passes the real validatePush", async () => { - const result = await push(renderedTree()); - - expect(result).toEqual({ ok: true }); -}); - -test("the renderer never emits an envelope-only capability-declarations.json", async () => { - expect(Object.keys(renderedTree())).not.toContain("capability-declarations.json"); -}); - -test("the renderer never commits a node_modules directory", async () => { - expect(Object.keys(renderedTree())).not.toContain("node_modules"); -}); - -test("the renderer never leaves an envelope-valid workflow.json beside the package.json", async () => { - // The renderer's only paths are package.json, workflow.js and - // definition.json; the - // retired workflow.json envelope path never appears in its output, so the - // ambiguous-tree rejection has no way to fire against what we emit. - expect(Object.keys(renderedTree())).not.toContain("workflow.json"); -}); - -test("the renderer's package.json always declares a non-empty, contained interchange.workflow entry", async () => { - const manifest = JSON.parse(renderedTree()["package.json"] as string) as { - interchange: { workflow: string }; - }; - - expect(manifest.interchange.workflow).toBe("./workflow.js"); -}); - -test("a tree missing package.json is rejected, matching the retired-envelope error", async () => { - const result = await push({ - "workflow.json": WORKFLOW_JSON, - }); - - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toContain("workflow.json envelope form is no longer supported"); -}); diff --git a/vendor/intx/workflow-host/src/adapters/step-invoker.test.ts b/vendor/intx/workflow-host/src/adapters/step-invoker.test.ts deleted file mode 100644 index b9efd2306..000000000 --- a/vendor/intx/workflow-host/src/adapters/step-invoker.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { Mail } from "@intx/types/runtime"; - -import { buildInboundMessageFromMail } from "./step-invoker"; - -function mailWithSubject(subject: string | undefined): Mail { - return { - headers: { - from: "sender@example.com", - to: ["agent@local"], - ...(subject !== undefined ? { subject } : {}), - }, - rawHeaders: {}, - parts: [{ contentType: "text/plain", ref: "part_1", text: "hi" }], - } as Mail; -} - -describe("buildInboundMessageFromMail", () => { - test("an empty Subject header is treated as absent", async () => { - const message = await buildInboundMessageFromMail( - mailWithSubject(""), - undefined, - ); - - expect(message.headers.subject).toBeUndefined(); - }); -});