diff --git a/DEV.md b/DEV.md index bc492541a..99e385957 100644 --- a/DEV.md +++ b/DEV.md @@ -260,7 +260,7 @@ All scripts live in `bin/`. The bash scripts source the bundled [opsh](https://g ## Seed Data -`bin/seed` creates the full dev dataset: users, tenants, agents, roles, grants, credentials, and offerings. It requires a running hub (the dev orchestrator handles this when `--seed` is passed). See the Quick Start section for seed account credentials. +`bin/seed` creates the full dev dataset: users, tenants, workflows, roles, grants, credentials, and offerings. It requires a running hub (the dev orchestrator handles this when `--seed` is passed). See the Quick Start section for seed account credentials. ## Project Structure diff --git a/LAYOUT.md b/LAYOUT.md index 7703c2517..24f0d41c2 100644 --- a/LAYOUT.md +++ b/LAYOUT.md @@ -19,13 +19,13 @@ Breaking changes to shared foundation packages cascade into both trees. These pa A server application backed by Postgres. The hub packages and `db` are versioned and deployed together; `hub-client` is a browser library shipped separately to UI consumers. -| Package | Purpose | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@intx/hub-api` | Hono app factory, context middleware, route group factories, per-request middleware, the better-auth wrapper, request/response helpers, the request context and session contract types, and the timeline read model. | -| `@intx/hub-sessions` | Sidecar websocket wire layer and typed event emitter, session service, event collector and registry, agent repository, hub session orchestrator, and the credential-push pipeline. Split into two layers: a db-free repo substrate (the `RepoStore`, workflow-run claim-check, event log, step-state and mailbox-index formats, and kind-handler protocol) exported from `@intx/hub-sessions/substrate`, and the db-backed services exported from the package barrel. The `./substrate` subpath is the seam the agent-runtime host consumes to read and write the agent-state and workflow-run repos without linking the control plane's database layer. | -| `@intx/hub-common` | Utilities genuinely shared across hub packages. Holds the id generator and its prefix table; see the package README for the rules that gate additions. | -| `@intx/hub-client` | Browser/UI client library. API transport, SSE event stream transforms, instance session management. Consumed by the UI, not the hub itself. | -| `@intx/db` | Drizzle schema, connection pooling, credential resolution, grant store, tenant hierarchy queries. Postgres-specific. | +| Package | Purpose | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@intx/hub-api` | Hono app factory, context middleware, route group factories, per-request middleware, the better-auth wrapper, request/response helpers, and the request context and session contract types. | +| `@intx/hub-sessions` | Sidecar websocket wire layer and typed event emitter, session service, event collector and registry, agent repository, hub session orchestrator, and the credential-push pipeline. Split into two layers: a db-free repo substrate (the `RepoStore`, workflow-run claim-check, event log, step-state and mailbox-index formats, and kind-handler protocol) exported from `@intx/hub-sessions/substrate`, and the db-backed services exported from the package barrel. The `./substrate` subpath is the seam the agent-runtime host consumes to read and write the workflow-run repos without linking the control plane's database layer. | +| `@intx/hub-common` | Utilities genuinely shared across hub packages. Holds the id generator and its prefix table; see the package README for the rules that gate additions. | +| `@intx/hub-client` | Browser/UI client library. API transport, SSE event stream transforms, run session management. Consumed by the UI, not the hub itself. | +| `@intx/db` | Drizzle schema, connection pooling, credential resolution, grant store, tenant hierarchy queries. Postgres-specific. | `apps/hub` wires `hub-api` and `hub-sessions` together and starts the Hono server. `apps/admin-ui` is the browser SPA built against `hub-client`. @@ -44,7 +44,7 @@ Uses `fetch` and `ReadableStream` exclusively. No Node APIs, no filesystem assum | `@intx/agent` | The code-driven agent composition surface (`createAgent`/`defineAgent`) over the `@intx/inference` reactor: send a message, stream events, project history, hot-swap inference sources. On the server runtime it is instantiated per workflow step inside a supervised workflow-process child (see `@intx/workflow-host`); it is also the peer surface to `@intx/harness`, which drives the same reactor from a mail transport. Bundles `storage-isogit` for context storage. | | `@intx/workflow` | Workflow state machine, definition surface, runtime, and the in-memory `runLocal` harness. Reconstructs run state from an append-only event log (`applyEvent`/`resumeFromLog`), schedules the step DAG, and drives the agent through per-step authz contexts. Depends only on `agent` and `inference`. | | `@intx/mime` | MIME message construction, multipart assembly, PGP detached signature generation, RFC 2822 formatting, and MIME parsing. Used by message transports. | -| `@intx/pack-transport` | Git pack protocol chunking and reassembly. Transfers git object data between the hub and sidecars over WebSocket or HTTP. | +| `@intx/pack-transport` | Git pack protocol chunking and reassembly. Transfers git object data between the hub and sidecars over WebSocket. | ### Environment-Specific Implementations @@ -78,14 +78,14 @@ Each package implements an interface defined in `@intx/types`. The harness accep ### Server Runtime Host -Server-only packages that compose the portable core and environment-specific implementations into a running host process. They assume a Node filesystem and process; they do not ship to constrained environments, except a subpath a row names as Node-free. Their runtime dependencies are shared-foundation and agent-runtime packages, plus the db-free `@intx/hub-sessions/substrate` seam — the repo-substrate primitives the host needs to read and write the agent-state and workflow-run repos. They do not link the control plane's db-backed services barrel. (`hub-agent` additionally carries a test-only dev dependency on `@intx/hub-sessions` to contract-test the hub WebSocket link against the real hub wire types.) +Server-only packages that compose the portable core and environment-specific implementations into a running host process. They assume a Node filesystem and process; they do not ship to constrained environments, except a subpath a row names as Node-free. Their runtime dependencies are shared-foundation and agent-runtime packages, plus the db-free `@intx/hub-sessions/substrate` seam — the repo-substrate primitives the host needs to read and write the workflow-run repos. They do not link the control plane's db-backed services barrel. (`hub-agent` additionally carries a test-only dev dependency on `@intx/hub-sessions` to contract-test the hub WebSocket link against the real hub wire types.) -| Package | Purpose | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@intx/hub-agent` | Sidecar-side orchestrator (`createSidecarOrchestrator`, the core seam `apps/sidecar` wires together). Wires the package-side pieces of the sidecar runtime — the per-agent key and repo stores, a session manager, and the hub WebSocket link — into a single start/close handle, applies deploy and asset packs received from the hub, and forwards a spawned child's verified inference events back to the hub. In-process harness construction and agent provisioning are retired: every agent runs as a supervised workflow-process child on the workflow-run substrate (see `@intx/workflow-host`). | -| `@intx/tool-packaging` | Hub-side closure resolver and sidecar-side loader for the npm-distributed tool-package format. Resolves a manifest's dependency closure, materializes tarballs into a content-addressable cache, and lays out a per-instance nested `node_modules/` so a tool package and its transitives resolve bare-specifier imports against the manifest closure. Used by both `apps/hub` and `apps/sidecar`. | -| `@intx/workflow-host` | Supervisor and child orchestration for running agents as supervised workflow-process children: the dispatch loop, inbox claim-check, the `SubprocessSpawner`/`SubprocessHandle` spawn seam, the per-step step-invoker, and recycle/drain. It also holds the deploy program, the child substrate factory, the probe protocol, durable conversation state, and the per-run grants reader. Consumes the `@intx/hub-sessions/substrate` repo primitives and takes its spawner, mail bus, and step invoker as injected bindings. Intended to stay host-agnostic so an alternate host reuses it with a different tool/agent runtime — see the open item under Dependency Rules. Its `./local` subpath (the step invoker, step tool builders, authorize closure, and local credentials snapshot) imports no Node or Bun module and bundles for a browser, so `runLocal` callers run definitions outside a sidecar. | -| `@intx/workflow-deploy` | Deploy-time validation and derivation over the workflow-run substrate: the capability walk and operator-approval gate (`walkCapabilities`, `createApprovalSetGate`), the child grant cap (`collectDeclaredResources`, `filterGrantsToDeclaredResources`), the runtime grant projections of the walk (`runtimeGrantRules`, `credentialUseGrantRules`), pure deployment-address and workflow-run repo-id derivation (`deriveRunAddress`, `deriveStepAddress`, `resolveStepAddress`, `deriveWorkflowRunRepoId`), and per-step inference-source pinning (`pickStepInferenceSource`). An agent-runtime package (it depends on `@intx/agent`, `@intx/authz`, and `@intx/workflow`) consumed by `apps/sidecar`, `@intx/hub-sessions`, and `@intx/workflow-host` (its `./local` entry) on the host side and reached into by `@intx/hub-api` on the control-plane side — one of the enumerated crossings in the Dependency Rules. | +| Package | Purpose | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@intx/hub-agent` | Sidecar-side orchestrator (`createSidecarOrchestrator`, the core seam `apps/sidecar` wires together). Wires the package-side pieces of the sidecar runtime — the per-agent key and repo stores, a session manager, and the hub WebSocket link — into a single start/close handle and forwards a spawned child's verified inference events back to the hub. In-process harness construction and agent provisioning are retired: every agent runs as a supervised workflow-process child on the workflow-run substrate (see `@intx/workflow-host`). | +| `@intx/tool-packaging` | Hub-side closure resolver and sidecar-side loader for the npm-distributed tool-package format. Resolves a manifest's dependency closure, materializes tarballs into a content-addressable cache, and lays out a per-instance nested `node_modules/` so a tool package and its transitives resolve bare-specifier imports against the manifest closure. Used by both `apps/hub` and `apps/sidecar`. | +| `@intx/workflow-host` | Supervisor and child orchestration for running agents as supervised workflow-process children: the dispatch loop, inbox claim-check, the `SubprocessSpawner`/`SubprocessHandle` spawn seam, the per-step step-invoker, and recycle/drain. It also holds the deploy program, the child substrate factory, the probe protocol, durable conversation state, and the per-run grants reader. Consumes the `@intx/hub-sessions/substrate` repo primitives and takes its spawner, mail bus, and step invoker as injected bindings. Intended to stay host-agnostic so an alternate host reuses it with a different tool/agent runtime — see the open item under Dependency Rules. Its `./local` subpath (the step invoker, step tool builders, authorize closure, and local credentials snapshot) imports no Node or Bun module and bundles for a browser, so `runLocal` callers run definitions outside a sidecar. | +| `@intx/workflow-deploy` | Deploy-time validation and derivation over the workflow-run substrate: the capability walk and operator-approval gate (`walkCapabilities`, `createApprovalSetGate`), the child grant cap (`collectDeclaredResources`, `filterGrantsToDeclaredResources`), the runtime grant projections of the walk (`runtimeGrantRules`, `credentialUseGrantRules`), pure deployment-address and workflow-run repo-id derivation (`deriveRunAddress`, `deriveWorkflowRunRepoId`), and per-step inference-source pinning (`pickStepInferenceSource`). An agent-runtime package (it depends on `@intx/agent`, `@intx/authz`, and `@intx/workflow`) consumed by `apps/sidecar`, `@intx/hub-sessions`, and `@intx/workflow-host` (its `./local` entry) on the host side and reached into by `@intx/hub-api` on the control-plane side — one of the enumerated crossings in the Dependency Rules. | ### Why the splits exist @@ -115,8 +115,8 @@ Deployable processes under `apps/`. Each wires packages from one deployment cont ## Dependency Rules -1. Agent-runtime packages depend on control-plane packages only through the db-free `@intx/hub-sessions/substrate` seam — the repo-substrate primitives (`RepoStore`, workflow-run claim-check, event log, step-state and mailbox-index formats, kind-handler protocol) the server runtime host needs to read and write the agent-state and workflow-run repos. They never link the control plane's db-backed services barrel. Test-only dev dependencies that exercise a cross-tree wire contract are also permitted (e.g. `hub-agent`'s dev dependency on `hub-sessions`). -2. The control plane reaches into the agent-runtime tree at a set of deliberate crossings introduced by the workflow-process migration: the hub is itself a git server and workflow deployer, so `@intx/hub-api` and `@intx/hub-sessions` value-import agent-runtime packages that implement that shared machinery — packages such as the git pack and timeline layers (`@intx/storage-isogit`, `@intx/pack-transport`), deployment-address and repo-id derivation (`@intx/workflow-deploy`), the agent and workflow runtimes the substrate drives (`@intx/agent`, `@intx/workflow`), the conversation checks that close an imported agent step's unanswered tool calls and verify its tool-call order (`@intx/inference`), and the message, crypto, and tool-packaging primitives the sidecar link shares (`@intx/mime`, `@intx/crypto`, `@intx/tool-packaging`). These are deliberate — the control plane co-owns the git/workflow substrate — not accidental layering rot. What stays out of bounds is the reverse and the database seam: the agent-runtime tree never links the control plane's db-backed services barrel (Rule 1), and no crossing pulls the hub's Postgres/database layer into a shipped agent-runtime artifact. +1. Agent-runtime packages depend on control-plane packages only through the db-free `@intx/hub-sessions/substrate` seam — the repo-substrate primitives (`RepoStore`, workflow-run claim-check, event log, step-state and mailbox-index formats, kind-handler protocol) the server runtime host needs to read and write the workflow-run repos. They never link the control plane's db-backed services barrel. Test-only dev dependencies that exercise a cross-tree wire contract are also permitted (e.g. `hub-agent`'s dev dependency on `hub-sessions`). +2. The control plane reaches into the agent-runtime tree at a set of deliberate crossings introduced by the workflow-process migration: the hub is itself a git server and workflow deployer, so `@intx/hub-api` and `@intx/hub-sessions` value-import agent-runtime packages that implement that shared machinery — packages such as the git pack layers (`@intx/storage-isogit`, `@intx/pack-transport`), deployment-address and repo-id derivation (`@intx/workflow-deploy`), the agent and workflow runtimes the substrate drives (`@intx/agent`, `@intx/workflow`), the conversation checks that close an imported agent step's unanswered tool calls and verify its tool-call order (`@intx/inference`), and the message, crypto, and tool-packaging primitives the sidecar link shares (`@intx/mime`, `@intx/crypto`, `@intx/tool-packaging`). These are deliberate — the control plane co-owns the git/workflow substrate — not accidental layering rot. What stays out of bounds is the reverse and the database seam: the agent-runtime tree never links the control plane's db-backed services barrel (Rule 1), and no crossing pulls the hub's Postgres/database layer into a shipped agent-runtime artifact. 3. Both trees depend on the shared foundation (`types`, `authz`, `log`, `inference-catalog`). (`@intx/inference-catalog` additionally carries test-only dev dependencies on `@intx/inference-discovery` and `@intx/inference` — its guard re-bakes offering capabilities against the discovery matrix and validates quirks against the adapter schemas. These are build/test-time only and absent from the published artifact, which has no runtime dependencies.) 4. Environment-specific packages depend on `types` for interfaces, never on each other (`tools-lsp` on `tools-posix` is the documented exception — see the tool-set table). 5. The harness depends on `types` for interfaces and `inference` for portable logic. It does not depend on any environment-specific package. diff --git a/Makefile b/Makefile index e15395a07..cfb48e9bd 100644 --- a/Makefile +++ b/Makefile @@ -81,7 +81,7 @@ test-unit: FORCE # (port 0, mkdtemp dirs, unique schemas) and none rely on fresh module state, # so sharing the registry across files is safe. test-workflow: FORCE - $(BUN) test --timeout 120000 --parallel=$(WF_PARALLEL) --no-isolate tests/workflow-deploy/multistep-signal.test.ts tests/workflow-deploy/hub-link-reconnect.test.ts tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts tests/workflow-deploy/crash-respawn-fifo.test.ts tests/workflow-deploy/crash-loop-latch.test.ts tests/workflow-deploy/on-trigger-signal-delivery.test.ts tests/workflow-deploy/on-trigger-between-events-restart.test.ts tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts tests/workflow-deploy/sleep-restart-keeps-authority.test.ts tests/workflow-deploy/sleep-restart-hub-access.test.ts tests/workflow-deploy/on-trigger-agent-body.test.ts tests/workflow-deploy/on-trigger-action-body.test.ts tests/workflow-deploy/on-trigger-loop-body.test.ts tests/workflow-deploy/single-step-real-agent.test.ts tests/workflow-deploy/map-fan-out-real-agent.test.ts tests/workflow-deploy/multistep-signed-send.test.ts tests/workflow-deploy/single-step-message-input.test.ts tests/workflow-deploy/single-step-posix-tool.test.ts tests/workflow-deploy/single-step-credential-tool.test.ts tests/workflow-deploy/credential-bound-route-deploy.test.ts tests/workflow-deploy/action-step-credential.test.ts tests/workflow-deploy/action-hub-access.test.ts tests/workflow-deploy/tool-hub-access.test.ts tests/workflow-deploy/invoker-hub-access.test.ts tests/workflow-deploy/body-hub-access.test.ts tests/workflow-deploy/revoked-creator-grant-running-run.test.ts tests/workflow-deploy/expired-run-grant-running-run.test.ts tests/workflow-deploy/tarball-route-deploy.test.ts tests/workflow-deploy/folded-tools-failover-real-agent.test.ts tests/workflow-deploy/single-step-grants-bridge.test.ts tests/workflow-deploy/single-step-pinned-ask-tool.test.ts tests/workflow-deploy/single-step-per-run-grants.test.ts tests/workflow-deploy/approval-tracer-roundtrip.test.ts tests/workflow-deploy/scope-always-roundtrip.test.ts tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts tests/workflow-deploy/run-mail-send-real-route.test.ts tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts tests/workflow-deploy/mail-federation-run-completes.test.ts tests/workflow-deploy/single-step-event-threading.test.ts tests/workflow-deploy/single-step-conversation-durability.test.ts tests/workflow-deploy/multistep-conversation-failover.test.ts tests/workflow-deploy/per-level-pipeline-real-agents.test.ts tests/workflow-deploy/dispatch-orchestrator-real-agents.test.ts tests/workflow-deploy/single-step-full-lifecycle.test.ts tests/workflow-deploy/single-step-mail-loop.test.ts tests/workflow-deploy/single-step-mail-in-reply-to.test.ts tests/workflow-deploy/single-step-mail-wait-wake.test.ts tests/workflow-deploy/single-step-mail-two-turn-thread.test.ts tests/workflow-deploy/single-step-mail-interactive-turns.test.ts tests/workflow-deploy/single-step-mail-flag-expunge.test.ts tests/workflow-deploy/cross-process-custom-adapter.test.ts tests/workflow-deploy/conversation-state-wal.test.ts tests/workflow-deploy/conversation-state-connector-seed.test.ts tests/workflow-deploy/drain-roundtrip.test.ts tests/workflow-deploy/fixtures/child-workflow.test.ts tests/workflow-deploy/child-workflow-roundtrip.test.ts tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts tests/workflow-deploy/loop-roundtrip.test.ts tests/workflow-deploy/loop-nested-roundtrip.test.ts tests/workflow-deploy/loop-action-roundtrip.test.ts tests/workflow-deploy/all-action-deploy.test.ts tests/workflow-deploy/sidecar-runs-no-workflow-code.test.ts tests/workflow-deploy/onfailure-action-roundtrip.test.ts tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts tests/workflow-deploy/childworkflow-restart-resumes.test.ts tests/workflow-deploy/body-action-restart-effect-once.test.ts tests/workflow-deploy/body-action-crash-mid-effect.test.ts tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts tests/workflow-deploy/unresolvable-director.test.ts tests/workflow-deploy/fifo-mail.test.ts tests/workflow-deploy/mail-edge-cases.test.ts tests/workflow-deploy/run-event-batching.test.ts tests/workflow-deploy/walking-skeleton.e2e.test.ts tests/workflow-deploy/asset-source.e2e.test.ts tests/workflow-deploy/source-workflow.e2e.test.ts tests/workflow-deploy/source-monorepo.e2e.test.ts tests/workflow-deploy/source-monorepo-many.e2e.test.ts tests/workflow-deploy/source-tamper-reject.e2e.test.ts tests/workflow-deploy/source-catalog.e2e.test.ts tests/workflow-deploy/source-mixed-closure.e2e.test.ts tests/workflow-deploy/source-closure-director.e2e.test.ts tests/workflow-deploy/source-credential.e2e.test.ts tests/workflow-deploy/source-inline-tool.e2e.test.ts tests/workflow-deploy/source-posix-lsp.e2e.test.ts tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts tests/workflow-deploy/on-trigger-credential-tool.test.ts tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts + $(BUN) test --timeout 120000 --parallel=$(WF_PARALLEL) --no-isolate tests/workflow-deploy/multistep-signal.test.ts tests/workflow-deploy/hub-link-reconnect.test.ts tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts tests/workflow-deploy/crash-respawn-fifo.test.ts tests/workflow-deploy/crash-loop-latch.test.ts tests/workflow-deploy/on-trigger-signal-delivery.test.ts tests/workflow-deploy/on-trigger-between-events-restart.test.ts tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts tests/workflow-deploy/sleep-restart-keeps-authority.test.ts tests/workflow-deploy/sleep-restart-hub-access.test.ts tests/workflow-deploy/on-trigger-agent-body.test.ts tests/workflow-deploy/on-trigger-action-body.test.ts tests/workflow-deploy/on-trigger-loop-body.test.ts tests/workflow-deploy/single-step-real-agent.test.ts tests/workflow-deploy/map-fan-out-real-agent.test.ts tests/workflow-deploy/multistep-signed-send.test.ts tests/workflow-deploy/single-step-message-input.test.ts tests/workflow-deploy/single-step-posix-tool.test.ts tests/workflow-deploy/single-step-credential-tool.test.ts tests/workflow-deploy/credential-bound-route-deploy.test.ts tests/workflow-deploy/action-step-credential.test.ts tests/workflow-deploy/action-hub-access.test.ts tests/workflow-deploy/tool-hub-access.test.ts tests/workflow-deploy/invoker-hub-access.test.ts tests/workflow-deploy/body-hub-access.test.ts tests/workflow-deploy/revoked-creator-grant-running-run.test.ts tests/workflow-deploy/expired-run-grant-running-run.test.ts tests/workflow-deploy/tarball-route-deploy.test.ts tests/workflow-deploy/folded-tools-failover-real-agent.test.ts tests/workflow-deploy/single-step-grants-bridge.test.ts tests/workflow-deploy/single-step-pinned-ask-tool.test.ts tests/workflow-deploy/single-step-per-run-grants.test.ts tests/workflow-deploy/approval-tracer-roundtrip.test.ts tests/workflow-deploy/scope-always-roundtrip.test.ts tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts tests/workflow-deploy/run-mail-send-real-route.test.ts tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts tests/workflow-deploy/mail-federation-run-completes.test.ts tests/workflow-deploy/single-step-event-threading.test.ts tests/workflow-deploy/single-step-conversation-durability.test.ts tests/workflow-deploy/multistep-conversation-failover.test.ts tests/workflow-deploy/per-level-pipeline-real-agents.test.ts tests/workflow-deploy/dispatch-orchestrator-real-agents.test.ts tests/workflow-deploy/single-step-full-lifecycle.test.ts tests/workflow-deploy/single-step-mail-loop.test.ts tests/workflow-deploy/single-step-mail-in-reply-to.test.ts tests/workflow-deploy/single-step-mail-wait-wake.test.ts tests/workflow-deploy/single-step-mail-two-turn-thread.test.ts tests/workflow-deploy/single-step-mail-interactive-turns.test.ts tests/workflow-deploy/single-step-mail-flag-expunge.test.ts tests/workflow-deploy/cross-process-custom-adapter.test.ts tests/workflow-deploy/conversation-state-wal.test.ts tests/workflow-deploy/conversation-state-connector-seed.test.ts tests/workflow-deploy/drain-roundtrip.test.ts tests/workflow-deploy/fixtures/child-workflow.test.ts tests/workflow-deploy/child-workflow-roundtrip.test.ts tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts tests/workflow-deploy/loop-roundtrip.test.ts tests/workflow-deploy/loop-nested-roundtrip.test.ts tests/workflow-deploy/loop-action-roundtrip.test.ts tests/workflow-deploy/all-action-deploy.test.ts tests/workflow-deploy/sidecar-runs-no-workflow-code.test.ts tests/workflow-deploy/onfailure-action-roundtrip.test.ts tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts tests/workflow-deploy/childworkflow-restart-resumes.test.ts tests/workflow-deploy/body-action-restart-effect-once.test.ts tests/workflow-deploy/body-action-crash-mid-effect.test.ts tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts tests/workflow-deploy/unresolvable-director.test.ts tests/workflow-deploy/fifo-mail.test.ts tests/workflow-deploy/mail-edge-cases.test.ts tests/workflow-deploy/run-event-batching.test.ts tests/workflow-deploy/walking-skeleton.e2e.test.ts tests/workflow-deploy/asset-source.e2e.test.ts tests/workflow-deploy/asset-tool-restarts.e2e.test.ts tests/workflow-deploy/source-workflow.e2e.test.ts tests/workflow-deploy/source-monorepo.e2e.test.ts tests/workflow-deploy/source-monorepo-many.e2e.test.ts tests/workflow-deploy/source-tamper-reject.e2e.test.ts tests/workflow-deploy/source-catalog.e2e.test.ts tests/workflow-deploy/source-mixed-closure.e2e.test.ts tests/workflow-deploy/source-closure-director.e2e.test.ts tests/workflow-deploy/source-credential.e2e.test.ts tests/workflow-deploy/source-inline-tool.e2e.test.ts tests/workflow-deploy/source-posix-lsp.e2e.test.ts tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts tests/workflow-deploy/on-trigger-credential-tool.test.ts tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts # The core integration pass: inference, hub-api, and db suites that need # real Postgres and the extended timeout. Files run in parallel diff --git a/apps/admin-ui/README.md b/apps/admin-ui/README.md index 5edd6455e..eac6fb97b 100644 --- a/apps/admin-ui/README.md +++ b/apps/admin-ui/README.md @@ -9,9 +9,9 @@ client, and renders the TanStack Router defined in `src/router.tsx`. The router gates every authenticated route behind a `/api/me` session check (redirecting to `/login` on failure) and exposes the tenant-management console: a dashboard plus per-tenant pages for -agents, instances, principals, roles, grants, credentials, wallets, +approvals, principals, roles, grants, credentials, wallets, offerings, models, model providers, model offerings, and workflows, -each with a list and detail view. +each with a list and detail view, plus a detail view for each run. Develop with `bun run dev` (Vite dev server), build with `bun run build`, and preview a production build with diff --git a/apps/hub/README.md b/apps/hub/README.md index cc4ccb110..0e910388a 100644 --- a/apps/hub/README.md +++ b/apps/hub/README.md @@ -6,7 +6,7 @@ operators run. `src/index.ts` opens the Postgres connection (`@intx/db`), constructs the better-auth wrapper and grant store, generates the -hub's deploy signing key, and builds the agent-repo, asset, and +hub's commit signing key, and builds the agent-repo, asset, and session services from `@intx/hub-sessions`. It then assembles the Hono app with `createApp` from `@intx/hub-api`, mounts the sidecar WebSocket upgrade handler, and exports a Bun server object diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index 1a963e84d..1b404a0ea 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -62,7 +62,6 @@ import { type WsHandle, } from "@intx/hub-sessions"; import { generateKeyPair } from "@intx/crypto"; -import { hexEncode } from "@intx/types"; import { MAX_SIDECAR_FRAME_BYTES } from "@intx/types/sidecar"; import { upgradeWebSocket, websocket } from "hono/bun"; import { setup, getLogger } from "@intx/log"; @@ -206,36 +205,9 @@ export async function createHubServer({ } const hubSigningKey = await generateKeyPair(); - log.info("Generated hub deploy signing key"); - - // Write-path GC for the hub's agent-state repos. Each accepted state - // pack strands the prior tip's objects and adds a pack, and each deploy - // commit strands loose objects; left alone the repo grows without bound. - // The hub reclaims on the write path once a repo crosses - // HUB_AGENT_GC_PACK_THRESHOLD packs or HUB_AGENT_GC_LOOSE_THRESHOLD loose - // objects, and warns once it crosses HUB_AGENT_GC_WARN_BYTES. Retention - // is fixed to keep-history and not operator-configurable: the hub is the - // long-term archive of an agent's state graph, and tip-only would prune - // the commit ancestry the hub's subscriber-seq and history replay derive - // from git.log. - const DEFAULT_HUB_AGENT_GC_PACK_THRESHOLD = 64; - const DEFAULT_HUB_AGENT_GC_LOOSE_THRESHOLD = 2048; - const DEFAULT_HUB_AGENT_GC_WARN_BYTES = 256 * 1024 * 1024; - - function readPositiveIntEnv(name: string, fallback: number): number { - const raw = process.env[name]; - if (raw === undefined || raw.trim() === "") return fallback; - const value = Number(raw); - if (!Number.isInteger(value) || value <= 0) { - throw new Error( - `${name} must be a positive integer; got ${JSON.stringify(raw)}`, - ); - } - return value; - } + log.info("Generated hub signing key"); - // Like `readPositiveIntEnv` but with no fallback: returns `undefined` when the - // var is unset so the caller can omit the field and let the consumer apply its + // Returns `undefined` when the var is unset so the caller can omit the field and let the consumer apply its // own default, instead of duplicating that default here. function readOptionalPositiveIntEnv(name: string): number | undefined { const raw = process.env[name]; @@ -297,30 +269,11 @@ export async function createHubServer({ const agentRepoStore = createAgentRepoStore({ dataDir: hubDataDir, signingKey: hubSigningKey, - gc: { - packThreshold: readPositiveIntEnv( - "HUB_AGENT_GC_PACK_THRESHOLD", - DEFAULT_HUB_AGENT_GC_PACK_THRESHOLD, - ), - looseThreshold: readPositiveIntEnv( - "HUB_AGENT_GC_LOOSE_THRESHOLD", - DEFAULT_HUB_AGENT_GC_LOOSE_THRESHOLD, - ), - warnBytes: readPositiveIntEnv( - "HUB_AGENT_GC_WARN_BYTES", - DEFAULT_HUB_AGENT_GC_WARN_BYTES, - ), - retention: "keep-history", - }, }); - // The asset service shares the agent-repo store's substrate so skill - // assets land under the same on-disk root and reuse the same signing - // key for commit signatures. It is consumed by the session service for - // per-attachment pack fan-out, by the smart-HTTP asset routes for clone - // and push, and by the mail-triggered run-grants materializer to hydrate - // a receiving deployment's definition. The E2E test seeds fixtures - // directly through this service object. + // The asset service shares the agent-repo store's substrate so assets land + // under the same on-disk root and reuse the same signing key for commit + // signatures. It backs the asset REST and smart-HTTP routes. const httpRegistries = new Map([ ["npmjs", { url: "https://registry.npmjs.org" }], ]); @@ -328,14 +281,6 @@ export async function createHubServer({ const assetService = createAssetService({ db, repoStore: agentRepoStore.repoStore, - // Reserve every configured HTTP registry name so a `package-registry` - // asset cannot silently shadow it at session launch. The session - // service's per-launch registry assembly applies asset-wins-on-name- - // collision (see `session-service.ts` `assetIndex` build), which - // turns a same-named asset into an opaque reroute of the public - // npm registry; rejecting at creation surfaces the collision at - // intent time instead of debugging an unexpected reroute later. - reservedPackageRegistryNames: new Set(httpRegistries.keys()), }); // Shared by pack ingestion and lifecycle recovery so recovery never claims a @@ -404,7 +349,6 @@ export async function createHubServer({ const probeTimeoutMs = readOptionalPositiveIntEnv("HUB_PROBE_TIMEOUT_MS"); const sidecarRouter = createSidecarRouter({ - hubPublicKey: hexEncode(hubSigningKey.publicKey), authenticateSidecar: async ({ token }) => sidecarCredentials.resolve(token), validateSidecarIdentity: sidecarCredentials.isCurrent, withExecutableWorkflowRun: (target, send, signal) => @@ -458,7 +402,6 @@ export async function createHubServer({ sidecarRouter, sidecarAllocationRouter: sidecarRouter, agentRepoStore, - assetService, db, toolPackageRegistries: { httpRegistries, diff --git a/apps/sidecar/README.md b/apps/sidecar/README.md index d929eb12a..cff56a6e3 100644 --- a/apps/sidecar/README.md +++ b/apps/sidecar/README.md @@ -11,15 +11,13 @@ with an in-memory mail transport (`@intx/mail-memory`), the Web Crypto provider (`@intx/crypto`), and a deploy router; on each inbound deploy the router creates an `@intx/workflow-host` supervisor for the deployment and spawns a supervised workflow-process child. -The child assembles the step's runtime across two seams. The app's -`src/step-tool-materialization.ts` materializes the pinned tool-package -closure. `@intx/workflow-host` composes the POSIX and LSP plugin chain +The child assembles the step's runtime: `@intx/workflow-host` composes the POSIX and LSP plugin chain (`packages/workflow-host/src/child/step-tools.ts`) and builds the per-step environment (`packages/workflow-host/src/child/substrate-factory.ts`) — the isomorphic-git context store and the supervisor-backed mail transport -the tools bind to. The binary closes that factory over the materializer -and `@intx/workflow-deploy`'s grant cap in +the tools bind to. The binary closes that factory over +`@intx/workflow-deploy`'s grant cap in `src/workflow-child-bindings.ts`. `src/default-harness.ts` provides only the `HarnessBuilder` source-admission check (`canBuildSource`) the deploy router uses to reject an unbuildable inference source before diff --git a/apps/sidecar/src/config.ts b/apps/sidecar/src/config.ts index 1e59fe2f6..e19c76a5c 100644 --- a/apps/sidecar/src/config.ts +++ b/apps/sidecar/src/config.ts @@ -125,8 +125,8 @@ export function parseReconnectDelayMs( // Operator-configured custom inference adapter manifest. The value is // TRUSTED operator input read only from this process's environment; // `import(specifier)` is arbitrary code execution, so a specifier must -// never originate from deploy or tenant data — the agent deploy tree -// carries only a `provider` key, never a specifier. The shape is +// never originate from deploy or tenant data — a deploy frame carries +// only a `provider` key, never a specifier. The shape is // validated by the shared reader (and re-validated at the workflow-child // spawn boundary as defense in depth), but the value itself is trusted. // diff --git a/apps/sidecar/src/index.ts b/apps/sidecar/src/index.ts index 23eec3d4f..08f0ef260 100644 --- a/apps/sidecar/src/index.ts +++ b/apps/sidecar/src/index.ts @@ -6,7 +6,6 @@ import { createEd25519Crypto, createEnvKeyCredentialCipher, generateKeyPair, - verifySSHSignature, } from "@intx/crypto"; import { createSenderKeyCache, @@ -102,9 +101,8 @@ const credentialCipher = createEnvKeyCredentialCipher( readCredentialEncryptionKey(), ); -// Resolve cache configuration at the boot edge so the workflow-child's -// per-apply loader receives a concrete path and cap through its spawn -// env rather than re-reading env at non-boundary call sites. +// Resolve cache configuration at the boot edge so nothing re-reads env at +// a non-boundary call site. const sidecarCacheDir = process.env["SIDECAR_CACHE_DIR"]; const cacheRoot = sidecarCacheDir !== undefined && sidecarCacheDir.trim() !== "" @@ -126,8 +124,8 @@ const registryMaxTarballBytes = readRegistryMaxTarballBytes(); // cannot receive this object across the fork, so the validated // manifest is serialized into the child's spawn env (see // `multistepSubstrateEnv`) and the child rebuilds an equivalent -// registry from it. Specifiers are operator-config-only — the agent -// deploy tree never contributes one. +// registry from it. Specifiers are operator-config-only — a deploy +// frame never contributes one. const adapterManifest = readAdapterManifest(); const adapters = await loadAdapterRegistry(adapterManifest); @@ -406,8 +404,7 @@ const restoreWorkflowRunPack = createWorkflowRunPackRestorer({ // Wrap the substrate's RepoStore with the boot-edge facade so a // successful supervisor write against a workflow-run repo fires the -// pack push hook before its Promise resolves. Non-workflow-run writes -// (today, the agent-state deploy-applier path) flow through +// pack push hook before its Promise resolves. Other writes flow through // unchanged. const wrappedRepoStore = createWorkflowRunPackPushingRepoStore({ isConnectionLost, @@ -443,12 +440,6 @@ const multistepSubstrateEnv: Record = { SIDECAR_SIGNING_PUBLIC_KEY: hexEncode(sidecarSigningKey.publicKey), SIDECAR_SIGNING_PRIVATE_KEY: hexEncode(sidecarSigningKey.privateKey), PATH: requireEnv("PATH"), - // Tool-loader caps the child's per-step tool materialization uses. - // Resolved once at the boot edge and threaded into the child through - // the substrate config so the child does not re-read env at a - // non-boundary site. - SIDECAR_CACHE_MAX_BYTES: String(cacheMaxBytes), - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: String(registryMaxTarballBytes), // Serialize the parent's ALREADY-VALIDATED manifest (the object // `readAdapterManifest` returned), not the raw env string, so the // child rebuilds the same custom-adapter set. Always present (defaults @@ -514,7 +505,6 @@ const orchestrator = createSidecarOrchestrator({ transport, cryptoOps: { generateKeyPair, - verifySSHSig: verifySSHSignature, }, resolveSenderCrypto, lookupInboundMailPolicy, @@ -645,8 +635,6 @@ const orchestrator = createSidecarOrchestrator({ { kind: "workflow-run", id: runId }, { last: WORKFLOW_RUN_RECORD_FILENAME }, ), - removeAgentStateRepository: (id) => - agentRepoStore.repoStore.removeRepo({ kind: "agent-state", id }), multistepMailRouter, inboundMailPolicyRegistry, multistepSignalRouter, @@ -659,6 +647,8 @@ const orchestrator = createSidecarOrchestrator({ multistepBinaryPath: SIDECAR_WORKFLOW_CHILD_BINARY, createWorkflowCache: createTarballCache, applyFrozenWorkflowClosure, + closureCacheMaxBytes: cacheMaxBytes, + registryMaxTarballBytes, readRegistries, resolveHostPlatform, resolveInboundMailPolicy, diff --git a/apps/sidecar/src/process-boot-graph.test.ts b/apps/sidecar/src/process-boot-graph.test.ts index f8dea713f..f1478f66f 100644 --- a/apps/sidecar/src/process-boot-graph.test.ts +++ b/apps/sidecar/src/process-boot-graph.test.ts @@ -9,8 +9,8 @@ import { dirname, join } from "node:path"; // sidecar orchestrator (`@intx/hub-agent`) into that graph: the child never // executes either, the database dependency is a layering violation, and // importing the orchestrator that spawns the child is a backwards dependency. -// The db-free substrate is reachable via `@intx/hub-sessions/substrate` and -// the path helpers via `@intx/hub-agent/paths`; the package barrels are not. +// The db-free substrate is reachable via `@intx/hub-sessions/substrate`; the +// package barrels are not. // The sidecar loads its orchestrator but excludes Hub services, agent execution, // broad helper barrels, and the eager combined control-payload schema. // @@ -89,7 +89,7 @@ function forbiddenReason(spec: string): string | null { return "control-plane barrel; import @intx/hub-sessions/substrate instead"; } if (spec === "@intx/hub-agent") { - return "sidecar orchestrator barrel; import @intx/hub-agent/paths instead"; + return "sidecar orchestrator barrel"; } return null; } @@ -191,8 +191,7 @@ async function expectCleanBootGraph(binary: string): Promise { `The workflow-child boot graph reached forbidden modules:\n${detail}\n\n` + "The spawned child must not evaluate the control-plane database or the " + "sidecar orchestrator at boot. Use @intx/hub-sessions/substrate for the " + - "db-free repo/substrate symbols and @intx/hub-agent/paths for the deploy-tree " + - "and address helpers.", + "db-free repo/substrate symbols.", ); } } diff --git a/apps/sidecar/src/step-tool-materialization.test.ts b/apps/sidecar/src/step-tool-materialization.test.ts deleted file mode 100644 index bee16a075..000000000 --- a/apps/sidecar/src/step-tool-materialization.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { stepDeployTreeDir } from "./step-tool-materialization"; - -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); - }); -}); diff --git a/apps/sidecar/src/step-tool-materialization.ts b/apps/sidecar/src/step-tool-materialization.ts deleted file mode 100644 index 675366df7..000000000 --- a/apps/sidecar/src/step-tool-materialization.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Pinned tool-package materialization for one workflow step. -// -// The child factory takes `materializeStepTools` as a required argument. -// This module is the sidecar process's implementation: it reads the step's -// deploy tree and loads the pinned closure. The factory never imports it. - -import path from "node:path"; - -import { agentDir, readDeployTree } from "@intx/hub-agent/paths"; -import { materializeToolPackages } from "@intx/tool-packaging"; -import { parseRunAddress } from "@intx/types/agent-address"; -import { baseStepId } from "@intx/workflow"; -import { resolveStepAddress } from "@intx/workflow-deploy/addresses"; -import type { - StepToolCacheConfig, - StepToolMaterialization, -} from "@intx/workflow-host/child"; - -import { - readRegistries, - resolveHostPlatform, -} from "./sidecar-materialization-config"; - -/** - * Resolve the on-disk directory holding a step's deploy tree. - * - * The deploy tree (`deploy/prompt.md`, `deploy/tool-packages-manifest.json`, - * `deploy/asset-mounts.json`) is shipped to the sidecar per step by the - * hub's `launchSession` deploy-pack push, which lands it in the LEGACY - * per-agent directory keyed by the step's sanitized mail address (see - * `@intx/hub-agent` `agentDir`). It is NOT in the substrate's - * `agent-state/` layout -- the multi-step deploy path never pushes - * step `agent-state` packs to the child's substrate. - * - * The step's mail address is `resolveStepAddress(...)`, the single owner - * of the head/step collapse: for a single-step deployment the lone step - * IS the head (the deployment mailbox itself), so the tree is read at the - * head; for multi-step it is `deriveStepAddress(runId, stepId, - * domain)`. The `runId`/`domain` are recovered from the deployment mailbox - * address the supervisor threaded into the child as `MAILBOX_ADDRESS` - * (`@`): the local part is the run id and the address domain - * is the deployment domain. `stepCount` is sourced from the host (via - * `substrateEnv`) so producer and consumer never derive divergent addresses. - */ -export function stepDeployTreeDir(args: { - dataDir: string; - mailboxAddress: string; - stepId: string; - stepCount: number; -}): string { - const parsed = parseRunAddress(args.mailboxAddress); - if (parsed === null) { - throw new Error( - `sidecar workflow-child step tools: deployment mailbox address ${JSON.stringify(args.mailboxAddress)} is not a parseable run address; cannot locate the step's deploy tree`, - ); - } - // A `map` iteration runs under a scoped step id `[]`, but - // deploy stages one deploy tree per base step, so the scoped id resolves - // to its base address -- every iteration reads the base step's tree. - // `baseStepId` is the identity on an unscoped id, so a plain step is - // unaffected. - const stepAddress = resolveStepAddress({ - runId: parsed.runId, - stepId: baseStepId(args.stepId), - domain: parsed.domain, - stepCount: args.stepCount, - }); - return agentDir(args.dataDir, stepAddress); -} - -/** - * Read a step's deploy tree and materialize its pinned tool-package - * closure. The tarball cache and the tool instance dir are rooted under - * the supplied per-step `storeDir` (the Phase-1 per-step state root) so - * concurrent steps/agents in one child never collide on cache or - * apply-state paths. - * - * A deploy with no tool-package manifest yields empty factories -- the - * legitimate `rawManifestBytes === undefined` case. A manifest that is - * present but fails to load surfaces loudly through - * `materializeToolPackages` (the throw path), never a silent - * empty-tools fallback that would mask a broken deploy. - */ -export async function materializeStepTools(args: { - dataDir: string; - mailboxAddress: string; - stepId: string; - stepCount: number; - /** Per-step state root; cache + instance dir + workspace live under it. */ - storeDir: string; - cache: StepToolCacheConfig; -}): Promise { - const deployTreeDir = stepDeployTreeDir({ - dataDir: args.dataDir, - mailboxAddress: args.mailboxAddress, - stepId: args.stepId, - stepCount: args.stepCount, - }); - const deployTree = await readDeployTree(deployTreeDir); - - // Root the tarball cache per step so concurrent steps in one child - // do not race on the content-addressable cache root. The cache is - // content-addressed and therefore safe to share globally, but the - // design (§3d, point 3) calls for a per-step cacheRoot so a wedged - // or partially-written apply in one step cannot corrupt another's - // view. - const cacheRoot = path.join(args.storeDir, "tarball-cache"); - - // Asset-mounted tool tarballs are staged by the hub's asset-pack push - // into the step's LEGACY agent dir workspace (the same dir the deploy - // tree lives in), not under the per-step store dir. Point the loader's - // asset resolution there while keeping the apply-state + cache rooted - // per step under `storeDir`. - // - // This `/workspace` (read-only staged assets, keyed by the - // BASE step) and the agent's read-write workdir `/workspace` - // (keyed by the SCOPED step) share a leaf name but are deliberately - // different roots -- a map iteration reads one shared deploy tree while - // each iteration writes its own scratch. Do not unify them. - const assetRoot = path.join(deployTreeDir, "workspace"); - - const materialized = await materializeToolPackages({ - rawManifestBytes: deployTree.toolPackageManifestRaw, - assetMounts: deployTree.assetMounts, - storeDir: args.storeDir, - assetRoot, - agentAddress: args.mailboxAddress, - cacheRoot, - cacheMaxBytes: args.cache.cacheMaxBytes, - registryMaxTarballBytes: args.cache.registryMaxTarballBytes, - registries: readRegistries(), - host: resolveHostPlatform(), - }); - return { - factories: materialized.factories, - pluginFactories: materialized.pluginFactories, - }; -} diff --git a/apps/sidecar/src/workflow-child-bindings.ts b/apps/sidecar/src/workflow-child-bindings.ts index fd4452c3a..05dd889a9 100644 --- a/apps/sidecar/src/workflow-child-bindings.ts +++ b/apps/sidecar/src/workflow-child-bindings.ts @@ -1,7 +1,6 @@ // Composition edge for the workflow child. // -// Closes the child factory over the sidecar's tool materializer and the -// grant cap, and re-exports the substrate-config key list the binary +// Closes the child factory over the grant cap, and re-exports the substrate-config key list the binary // forwards. The binary imports this module and the workflow-host child entry. import { @@ -14,12 +13,9 @@ import { createSidecarSubstrateFactory, } from "@intx/workflow-host/child"; -import { materializeStepTools } from "./step-tool-materialization"; - export { SIDECAR_SUBSTRATE_CONFIG_KEYS }; export const createSubstrate = createSidecarSubstrateFactory({ - materializeStepTools, collectDeclaredResources, collectDeclaredCredentialConsumers, filterGrantsToDeclaredResources, diff --git a/bin/db-reset b/bin/db-reset index c567bd097..8bf3209f5 100755 --- a/bin/db-reset +++ b/bin/db-reset @@ -37,7 +37,7 @@ fi HUB_DB_USER=$(grep -E '^DB_USER=' "$REPODIR/.env.hub" 2>/dev/null | cut -d= -f2) MIGRATE_DB_USER=$(grep -E '^DB_USER=' "$REPODIR/.env.migrate" 2>/dev/null | cut -d= -f2) -# Resolve agent-state directories the same way the runtime does, so --clean +# Resolve data directories the same way the runtime does, so --clean # wipes the dirs the hub and sidecar actually write to. The `|| true` guard # keeps a missing match from tripping set -e -o pipefail. HUB_DATA_DIR_VAL=$( { grep -E '^HUB_DATA_DIR=' "$REPODIR/.env.hub" 2>/dev/null || true; } | cut -d= -f2) diff --git a/bun.lock b/bun.lock index 90e752fed..8bf83b413 100644 --- a/bun.lock +++ b/bun.lock @@ -541,8 +541,6 @@ "name": "@intx/storage-isogit", "version": "0.5.0", "dependencies": { - "@intx/log": "workspace:*", - "@intx/mime": "workspace:*", "@intx/types": "workspace:*", "@isomorphic-git/lightning-fs": "4.7.0", "arktype": "catalog:", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5c530f185..240b7a2f6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -71,16 +71,9 @@ An agent gains at launch: ### Repository Layout -Each agent on a sidecar has a single git repository that contains both the deployed definition and the runtime state, separated by path: +Each deployment on a sidecar has one workflow-run git repository that holds the history of its runs: every run's event log, grants, and step state, separated by path. The sidecar commits here during normal operation and pushes those commits to the hub, which keeps its own copy. -- `deploy/` — hub-managed content (skills, prompt, configuration). The hub assembles this tree from the agent's source repository and any referenced skill libraries via subtree merge, producing a single flat tree with no submodule metadata. -- `state/` — sidecar-managed content (conversation context, audit records). The sidecar commits here during normal operation. - -Path disjointness is enforced: the hub only writes commits that modify `deploy/` paths, the sidecar only writes commits that modify `state/` paths, so the two never conflict. - -The git DAG encodes provenance. State commits on the sidecar descend from the initial repository commit, and the `refs/heads/deploy` ref tracks the deploy version received from the hub. A deployment keeps that deploy tree for its life; a new deploy version means a new deployment. - -Repository organization on the hub is flexible — a single repository may contain definitions for multiple agents within a tenant, or agents may have dedicated repositories depending on operational needs. The assembly step normalizes any source layout into the standard `deploy/` tree structure before pushing to sidecars. +A deployment keeps the frozen source closure it was deployed with for its life; a new version means a new deployment. ## Agent Harness @@ -105,7 +98,7 @@ The agent is unaware of which model provider is serving a given inference call. The harness consumes a single `ToolRunner` interface; the host (e.g. the sidecar) composes that runner from one or more tool packages — `@intx/tools-mail`, `@intx/tools-posix`, `@intx/tools-lsp`, plus any operator-supplied additions — and merges them through a generic name-collision-checked merger before passing the result to the harness. Each tool package receives the host services it needs (e.g. the bound `MessageTransport`) through a typed runtime-capability resolver, so packages are not statically wired to any one host. Local tools run within the agent's runtime — file system access, code execution, network requests, or custom tools the operator composes in. Remote tools are discovered through the control plane and invoke offerings exposed by other agents or services on the Interchange network. From the agent's perspective, local and remote tools share the same interface; the harness handles protocol negotiation, request routing, and wallet-based payment transparently. All tool invocations are subject to authorization policies. **Local Data** -The harness manages persistent state on behalf of the agent. Conversation context, pending operations, and audit records are stored in a git repository under `state/` — but this is an implementation detail invisible to the agent. The agent interacts with the outside world exclusively through tools and messages; it has no direct access to the storage layer, the git history, or any harness metadata. Data is isolated per-agent, except between agents that share a stock sidecar. Credentials and authorization grants are managed by the harness and explicitly hidden from the agent (see Trust Boundary below). +The harness manages persistent state on behalf of the agent. Conversation context, pending operations, and audit records are stored in a git repository — but this is an implementation detail invisible to the agent. The agent interacts with the outside world exclusively through tools and messages; it has no direct access to the storage layer, the git history, or any harness metadata. Data is isolated per-agent, except between agents that share a stock sidecar. Credentials and authorization grants are managed by the harness and explicitly hidden from the agent (see Trust Boundary below). **Environment Integration** Separate harness implementations exist for each execution environment: @@ -239,7 +232,7 @@ The harness is a security boundary between the agent (untrusted code) and the pl - **Grant rules** — The agent never learns what actions are allowed, denied, or require approval. Exposing policy would let the agent reason about circumventing restrictions. When a tool call is blocked, the agent receives a generic refusal, not the grant rule that triggered it. - **Private keys** — The agent's Ed25519 key pair is managed by the harness and used for signing on the agent's behalf. The agent cannot access, export, or influence key material. - **Audit records** — Tool invocation audit records are written by the harness to the git storage layer. The agent has no read or write access to audit data. -- **Storage internals** — The harness persists conversation context and audit records in a git repository under `state/`. The agent has no access to the git layer, the filesystem, or any harness metadata. The agent cannot create checkpoints, branches, or inspect its own history. +- **Storage internals** — The harness persists conversation context and audit records in a git repository. The agent has no access to the git layer, the filesystem, or any harness metadata. The agent cannot create checkpoints, branches, or inspect its own history. - **Authorization decisions** — The agent receives the outcome of a tool call (result or refusal), never the authorization evaluation that produced it (which grants matched, what effect was applied, which principal was evaluated). This separation is fundamental to the security model. The harness enforces policy precisely because the agent cannot observe or influence the enforcement mechanism. @@ -256,7 +249,7 @@ Every harness and every agent has its own asymmetric key pair. These keys serve Key pairs are generated at agent launch time and managed by the harness. Private keys are stored alongside the agent's persistent data and never exposed to agents or external systems, except to deployments that share a stock sidecar (see Trust Boundary). Public keys are published to the control plane and included in the agent's discovery metadata so signed content and commits remain attributable to the producing agent. Reconnect routing is authorized separately by the sidecar credential and the allocations it hosts. -**Commit signing** extends per-agent keys to the git layer. Every state commit (context checkpoints, audit records) is signed with the agent's Ed25519 key using SSH signature format. This means standard `git verify-commit` works with no custom tooling. Deploy commits are signed by the hub's own key; sidecars verify deploy signatures before accepting content. See Implementation for the wire protocol details. +**Commit signing** extends per-agent keys to the git layer. Every state commit (context checkpoints, audit records) is signed with the agent's Ed25519 key using SSH signature format. This means standard `git verify-commit` works with no custom tooling. See Implementation for the signature format. The control plane maintains a key validity history per agent — a list of `(publicKey, validFrom, validUntil)` tuples — so that historical commits remain verifiable after key rotation. When a key is retired (due to compromise, migration, or routine rotation), the old key is retained for signature verification but no longer accepted for new pushes. @@ -276,7 +269,6 @@ Signatures are attached to: - Outbound messages (agent-to-agent, agent-to-human) - Tool invocation requests and responses - State commits (context checkpoints, audit records) — signed by the agent's key -- Deploy commits (skill code, prompts, configuration) — signed by the hub's key - Registry announcements The harness verifies inbound signatures automatically. Messages with invalid or missing signatures are flagged and can be rejected according to policy. Cross-tenant messages require valid signatures as a baseline trust requirement. @@ -359,7 +351,7 @@ The control plane is the source of truth for discovery. It maintains a two-tier The control plane continuously monitors harness and agent health. It polls health endpoints, processes heartbeat messages, and maintains the operational status of all components. Unhealthy agents are removed from discovery. Unhealthy harnesses trigger agent migration to healthy harnesses. Health data feeds into the observability layer for dashboards and alerting. **Authorization Distribution** -The control plane manages how authority flows to agents via harnesses. Agent definitions declare grant requirements with source annotations (tenant, creator, invoker). When an agent is launched, the control plane resolves each requirement against the appropriate source — tenant policies, the definition creator's grants, or the invoker's grants — validates that the source has the authority to delegate, and materializes the effective grant set on the agent's principal. The harness receives the materialized grants in the deploy frame and enforces them at runtime. +The control plane manages how authority flows to agents via harnesses. Agent definitions declare grant requirements with source annotations (tenant, creator, invoker). When an agent is launched, the control plane resolves each requirement against the appropriate source — tenant policies, the definition creator's grants, or the invoker's grants — validates that the source has the authority to delegate, and materializes the effective grant set on the agent's principal. The harness receives the materialized grants in `run.grants` frames and enforces them at runtime. **Credential Storage** The control plane stores API keys, OAuth tokens, and other credentials that agents need to access external services. Operators configure integrations at the tenant or agent level; the control plane securely stores credentials and distributes them to harnesses at agent launch time. Agents never access credentials directly; the harness retrieves them from the control plane and injects them into outbound requests. @@ -404,7 +396,7 @@ The control plane and harnesses have a clear division of responsibility: The harness is the runtime; the control plane is the management layer. Harnesses register with the control plane on startup and maintain a persistent connection for receiving commands and reporting status. The control plane can instruct a harness to launch an agent, update credentials, or shut down gracefully. -On the canonical server runtime (`apps/sidecar`), the abstract harness is realized as a supervised workflow-process child. The sidecar runs an `@intx/hub-agent` orchestrator whose deploy router creates an `@intx/workflow-host` supervisor per deployment; each launched agent runs in its own workflow-process child that its deployment's supervisor spawns, drains, and recycles. Launches stage through the workflow-run substrate: `@intx/workflow-deploy` derives the deployment address and workflow-run repo id, and the db-free `@intx/hub-sessions/substrate` primitives back the agent-state and workflow-run repos the child reads and writes. `@intx/workflow-host` is meant to stay host-agnostic behind injected seams so an alternate host can supply a different tool and agent runtime; the current state of that boundary is described in LAYOUT.md's dependency rules. +On the canonical server runtime (`apps/sidecar`), the abstract harness is realized as a supervised workflow-process child. The sidecar runs an `@intx/hub-agent` orchestrator whose deploy router creates an `@intx/workflow-host` supervisor per deployment; each launched agent runs in its own workflow-process child that its deployment's supervisor spawns, drains, and recycles. Launches stage through the workflow-run substrate: `@intx/workflow-deploy` derives the deployment address and workflow-run repo id, and the db-free `@intx/hub-sessions/substrate` primitives back the workflow-run repos the child reads and writes. `@intx/workflow-host` is meant to stay host-agnostic behind injected seams so an alternate host can supply a different tool and agent runtime; the current state of that boundary is described in LAYOUT.md's dependency rules. ### Deployment Models @@ -501,7 +493,7 @@ Payments between agents in different tenants work through standard x402 flows. T ## Change History -Runtime state lives at the repository root alongside the deployed definition under `deploy/`. This unified repository means the full history — what the agent was running and what it did — is available in a single DAG. +A run's durable state lives in the deployment's workflow-run repository: its event log and each step's checkpoint and write-ahead log. Per-cycle commits stay in a per-step store on the sidecar, which undeploy reclaims. The reactor auto-commits at every cycle boundary (after each inference completes, after a batch of tool calls completes, after a compaction runs). The director's `checkpoint` action no longer decides _whether_ a commit happens; it supplies the human-meaningful message for the next auto-commit. Audit records (`state/audit/`) and error records (`state/errors/`) are still committed separately by the audit store on its existing schedule (`afterCheckpoint` and `onShutdown`). The agent has no awareness of or control over when commits occur (see Trust Boundary). @@ -526,14 +518,10 @@ Per-cycle files are overwritten each cycle; prior versions live in git commits. The control plane exposes the agent's change history to operators through the Agent Data API. Operators can list files, read content, browse commit history, and inspect individual commits. This provides visibility into what the agent was doing and when, useful for debugging, auditing, and incident response. The agent itself cannot access this history — it is a platform-level inspection surface, not an agent-facing capability. -### Garbage Collection - -Agent repos grow without bound: every accepted state or deploy pack advances a ref and strands the prior tip's objects, and the sidecar's reactor commits loose objects every cycle. iso-git has no `git gc` equivalent, so the storage layer reclaims explicitly. The collector (`runGC` in `@intx/storage-isogit`) repacks everything reachable from the repo's refs — the keep set unions reachability over `refs/heads/main` and `refs/heads/deploy`, since collecting one head alone would discard the other's live objects — into a single pack published through the same atomic staging dance receives use. It then renames superseded packs and loose-object directories into an unscanned quarantine, flushes the replacement pack and retired namespace together, deletes the quarantine, and flushes the cleanup. A crash before the first flush restores the old namespace with its bodies intact; a crash afterward restores the replacement pack, with any abandoned quarantine reclaimed by the next GC. - -**Cadence is owned by the writers, on the write path.** There is no GC daemon and no external operator job. After a successful write, the writer samples the repo's object counts — two cheap directory reads — and reclaims once it crosses a configured pack-count or loose-object threshold. When a reclaim runs it checks the `.git` byte size against a budget and warns if the repo is still over it, surfacing runaway accumulation that survives a reclaim; the recursive byte walk rides the reclaim rather than every write. Only the hub triggers this — from its repo substrate (state-pack receives and deploy-ref commits), while it holds its higher-level repo lock — so the reclaim always runs under a lock held against concurrent writers. +### Repository Growth -**Concurrency.** A sidecar agent repo is written by several drivers that share no higher-level lock — the reactor's context commits, the mail-audit commits, and deploy applies. The storage layer owns the serialization: a per-directory lock keyed by the resolved working-directory path is acquired by every object-store mutation and by the collector, so GC always observes a quiescent store. It nests harmlessly under the hub's higher-level repo lock. +Nothing collects a repo's objects: every accepted pack advances a ref and strands the prior tip's objects, so a repo grows for its whole life. A `workflow-run` repo depends on this: an expunged mail message's bytes persist only through older commits, so collecting its history would silently destroy the mailbox audit trail. -**Retention is fixed per role.** The hub configures GC with `retention: keep-history`: it is the long-term archive of an agent's state graph, so commit ancestry is preserved, and it reclaims once its operator-tunable thresholds (`HUB_AGENT_GC_PACK_THRESHOLD`, `HUB_AGENT_GC_LOOSE_THRESHOLD`, `HUB_AGENT_GC_WARN_BYTES`) are crossed. The sidecar configures no GC at all — it passes no policy at its boot edge, so the substrate never reclaims, never prunes, and never warns on a sidecar repo. A sidecar agent repo therefore grows for the life of the instance; keeping it small relies on the instance being short-lived, not on retention. +**Concurrency.** A sidecar context store is written by several drivers that share no higher-level lock — the reactor's context commits and the audit commits. The storage layer owns the serialization: a per-directory lock keyed by the resolved working-directory path is acquired by every object-store mutation. -A validation-rejected pack needs no reclaim: every receive (`applyPack` and the hub's repo substrate) stages the pack outside `objects/pack/`, validates it there, and discards it on rejection, so a flood of bad packs never reaches the scanned pack directory. Once ref promotion begins, failures retain the already-durable pack because the ref update may have become observable. +A validation-rejected pack leaves nothing behind: every receive stages the pack outside `objects/pack/`, validates it there, and discards it on rejection, so a flood of bad packs never reaches the scanned pack directory. Once ref promotion begins, failures retain the already-durable pack because the ref update may have become observable. diff --git a/docs/AUTH.md b/docs/AUTH.md index 083ad6beb..9d8ca045b 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -185,16 +185,16 @@ A declared plugin package projects as its name plus the tool declarations (name ### The re-verify barrier -At every definition **load boundary** — the top-level run (fresh, resumed, or restored), and each referenced-definition spawn — the loader recomputes the wire hash and refuses to proceed unless it matches the approved hash. A mismatch throws; there is no fallback. This catches a definition that diverged from what was approved (a redeploy/resume divergence, or a post-approval mutation of the on-disk asset that the substrate's hub-writes / sidecar-reads authorization does not itself cover for a direct filesystem write). +At the top-level definition **load boundary** — a fresh, resumed, or restored run — the loader recomputes the wire hash and refuses to proceed unless it matches the approved hash. A mismatch throws; there is no fallback. This catches a definition that diverged from what was approved (a redeploy/resume divergence, or a post-approval mutation of the on-disk asset that the substrate's hub-writes / sidecar-reads authorization does not itself cover for a direct filesystem write). -**The barrier is load-bearing only where the approved hash arrives _out-of-band_ from the bytes being checked** — from the hub's signed deploy frame or the child's spawn env, which a filesystem-level tamperer of the on-disk definition cannot also forge. Where no such out-of-band pin exists, a gate could only fail-closed-always, so it is deliberately absent and the definition's integrity rests on the asset repo's write-authorization plus push-time envelope validation. Applying this rule per load site: +**The barrier is load-bearing only where the approved hash arrives _out-of-band_ from the bytes being checked** — from the hub's deploy frame or the child's spawn env, which a filesystem-level tamperer of the on-disk definition cannot also forge. Where no such out-of-band pin exists, a gate could only fail-closed-always, so it is deliberately absent and the definition's integrity rests on the asset repo's write-authorization plus push-time envelope validation. Applying this rule per load site: - **Source-ref closure evaluation** — the airlock. Author code sourced from a registry is evaluated to a live definition and re-verified by project-then-hash against `SpawnTimeEnv.definitionHash`. Load-bearing; this is the whole point of the content-hash approval. -- **Top-level inert read** — re-verified against `SpawnTimeEnv.definitionHash` (the hub-approved hash on the signed frame). Load-bearing. -- **onTrigger bodies** — a body is a section extracted from the parent's own approved definition, so the parent's approval carries the body's hash on the signed frame (`referencedDefinitionHashes[bodyId]`). The body spawn re-verifies against it. Load-bearing. +- **Top-level inert read** — re-verified against `SpawnTimeEnv.definitionHash` (the hub-approved hash on the deploy frame). Load-bearing. +- **onTrigger bodies** — a body is a section of the parent's own approved definition, resolved in memory from the parent's re-verified closure, so the top-level re-verify covers it. The body spawn runs no separate check. - **childWorkflow spawns** — a `childWorkflow{definitionRef}` references a **separately-approved** workflow asset by id; the parent holds no hash for it and has no authority over it. There is no out-of-band pin, so the in-process spawn reads, envelope-validates, **and runs** the inert definition **without** a re-verify gate — there is no re-verify step between the read and execution on this path, so nothing here guards execution against post-approval drift. The child asset re-verifies against **its own** approved hash only when it is itself deployed as a top-level run (a separate execution), not from a parent that merely references it. -Both hub-approved pins (the top-level `approvedWireHash` and the per-body `referencedDefinitionHashes`) are persisted on the sidecar's deployment record so that a **sidecar restart** re-threads them into the restored child's spawn env and the same barriers hold across the restart — rather than a restored definition or body failing closed for want of a hash. +The hub-approved `approvedWireHash` is persisted on the sidecar's deployment record so that a **sidecar restart** re-threads it into the restored child's spawn env and the same barrier holds across the restart — rather than a restored definition failing closed for want of a hash. ## The Sidecar Is Not an Authorization Authority @@ -221,7 +221,7 @@ None of the four escalate privilege beyond that delegation, because forging the ### The child-side grant gate is defense-in-depth -At tool-invoke time the child evaluates the step's grants (`grants.json`, the operator-approved `frame.config.grants`) through a _gated capability_ before yielding credential material. That is the honest enforcement path for a **healthy** sidecar — but the sidecar is itself the process that _writes_ `grants.json` and assembles the snapshot the child reads. So the child-side gate is **defense-in-depth, not a boundary against a compromised sidecar**: a compromised sidecar could forge `grants.json`, but it gains nothing — the real boundaries are the hub's credential cipher and the closure pin, not the grant evaluation the sidecar feeds the child. +At tool-invoke time the child evaluates the step's grants (`grants.json`, the run's committed grants from its `run.grants` frames) through a _gated capability_ before yielding credential material. That is the honest enforcement path for a **healthy** sidecar — but the sidecar is itself the process that _writes_ `grants.json` and assembles the snapshot the child reads. So the child-side gate is **defense-in-depth, not a boundary against a compromised sidecar**: a compromised sidecar could forge `grants.json`, but it gains nothing — the real boundaries are the hub's credential cipher and the closure pin, not the grant evaluation the sidecar feeds the child. ### Restoring a run's authority in a new process @@ -413,7 +413,7 @@ This parallels the credential revocation model described in CREDENTIALS.md — b ## Smart-HTTP Git Tokens -The hub exposes asset and agent-state repositories over the smart-HTTP wire (`info/refs`, `git-upload-pack`, `git-receive-pack`). Stock `git` clients authenticate to those endpoints with an opaque bearer token rather than a better-auth session cookie. See `docs/GIT_ACCESS.md` for the operator walkthrough — credential-helper setup, URL grammar, `refPattern` grammar, and worked clone/push examples. +The hub exposes asset repositories over the smart-HTTP wire (`info/refs`, `git-upload-pack`, `git-receive-pack`). Stock `git` clients authenticate to those endpoints with an opaque bearer token rather than a better-auth session cookie. See `docs/GIT_ACCESS.md` for the operator walkthrough — credential-helper setup, URL grammar, `refPattern` grammar, and worked clone/push examples. ### Token model @@ -430,7 +430,7 @@ git_token name text NOT NULL kind text NOT NULL -- 'pat' | 'svc' token_hash_sha256 bytea NOT NULL UNIQUE - resource text NOT NULL -- 'asset:*', 'asset:ast_xxx', 'agent-state:run_xxx', ... + resource text NOT NULL -- 'asset:*', 'asset:ast_xxx', ... ref_pattern text NOT NULL -- simple-glob actions text[] NOT NULL -- RepoActions expires_at timestamptz NOT NULL @@ -445,7 +445,7 @@ The partial unique on `(user_id, name)` filtered by `revoked_at IS NULL` lets a Three columns bound a token's authority: -- `resource` — a single substrate authz resource string, e.g. `asset:*`, `asset:ast_xxx`, `agent-state:run_xxx`. Glob patterns are honored by the substrate; a token with `resource: "asset:*"` reaches every asset row in the tenant the token is bound to. +- `resource` — a single substrate authz resource string, e.g. `asset:*`, `asset:ast_xxx`. Glob patterns are honored by the substrate; a token with `resource: "asset:*"` reaches every asset row in the tenant the token is bound to. - `ref_pattern` — a glob restricting which refs within the resource the token may read or write. Grammar: `*` matches within a `/`-segment, `**` crosses segments. Worked examples appear in `docs/GIT_ACCESS.md`. - `actions` — the `RepoAction` vocabulary the token is allowed to invoke (`receivePack`, `createPack`, `resolveRef`, ...). The mint API accepts the user-facing aliases `can_read` (expands to `["createPack", "resolveRef"]`) and `can_push` (expands to `["receivePack"]`), and stores the canonical names so the lookup path never re-runs the alias table. - `expires_at` — required, server-enforced floor of one minute. The bearer middleware checks `expires_at > now()` on every request. diff --git a/docs/GIT_ACCESS.md b/docs/GIT_ACCESS.md index 7078a1115..682796891 100644 --- a/docs/GIT_ACCESS.md +++ b/docs/GIT_ACCESS.md @@ -1,15 +1,12 @@ # Git Access -This document is the operator-facing walkthrough for the hub's smart-HTTP git endpoints. It covers minting a token, configuring stock `git` so it presents that token to the hub, and the URL and `refPattern` grammars used by the two repo families the hub exposes: assets and agent state. +This document is the operator-facing walkthrough for the hub's smart-HTTP git endpoints. It covers minting a token, configuring stock `git` so it presents that token to the hub, and the URL and `refPattern` grammars for the assets the hub exposes. For the underlying authorization model — the relationship between session-authenticated grants and bearer-token `RepoActions` — see `docs/AUTH.md`. For the route reference, see `docs/ROUTES.md`. ## What the hub exposes over git -Two repo families speak the smart-HTTP wire: - -- **Assets.** Operator-curated, tenant-scoped repositories spanning the `skill`, `agent-state`, `package-registry`, and `workflow` kinds. Both `git clone` (upload-pack) and `git push` (receive-pack) are supported. -- **Agent state.** Two URL grammars, both read-only over HTTP. The per-run grammar exposes the runtime state of a run that the retired agent-instance launch route created (a run with an address and no deployment); the per-definition grammar exposes the deploy artifacts the hub wrote when it staged an agent for a launch. No production route creates such a run or stages such an agent now, so both grammars serve only state written before that. +Assets speak the smart-HTTP wire: operator-curated, tenant-scoped repositories spanning the `skill`, `package-registry`, and `workflow` kinds. Both `git clone` (upload-pack) and `git push` (receive-pack) are supported. Anonymous access is not supported. Every smart-HTTP request must present a hub-issued bearer token. Unauthenticated clones receive `401 Unauthorized` with a `WWW-Authenticate: Basic realm="Interchange"` challenge so stock git falls into its credential helper or askpass prompt. @@ -151,7 +148,7 @@ git clone https://hub.example/api/tenants/tnt_abc/assets/skill/greet.git ``` - `:tenantId` is the `tnt_*` ID. -- `:kind` is one of `skill`, `agent-state`, `package-registry`, or `workflow`. (The `agent-state` _kind_ is the asset family used for shared agent-state templates; it is not the same surface as the per-run and per-definition agent-state routes below.) +- `:kind` is one of `skill`, `package-registry`, or `workflow`. - `:name` is the kebab-case asset name as registered via `POST /api/tenants/:tenantId/assets`. - The `.git` suffix on the trailing segment is required — it is how the hub disambiguates the asset namespace from arbitrary tenant sub-paths. @@ -166,32 +163,6 @@ cd greet git push origin main ``` -### Agent state — per run - -``` -/api/tenants/:tenantId/workflows/runs/:runId/state.git/... -``` - -- `:runId` is the run's `run_*` ID. -- Read-only over HTTP. The run's runtime state is written by the sidecar through a separate path; HTTP is for inspection. - -``` -git clone https://hub.example/api/tenants/tnt_abc/workflows/runs/run_xyz/state.git -``` - -### Agent state — per definition - -``` -/api/tenants/:tenantId/agents/definitions/:agentId/state.git/... -``` - -- `:agentId` is the `agt_*` ID. -- Read-only over HTTP. The hub wrote the `deploy/` prefix when it staged the agent for a launch; no production route stages an agent now. - -``` -git clone https://hub.example/api/tenants/tnt_abc/agents/definitions/agt_xyz/state.git -``` - ## `refPattern` grammar The `refPattern` claim on a token restricts which refs the token may operate on. The matcher is a self-contained simple-glob — see `glob.match` in `@intx/hub-common`. @@ -263,7 +234,7 @@ A `403` from the substrate authz layer (rather than the bearer middleware) carri - **Shallow clones are not supported.** `git clone --depth=N` triggers `protocol error: unexpected 'shallow' line` because the hub does not implement the smart-HTTP shallow negotiation. Track the follow-on issue for the implementation; clone full for now. - **Anonymous access is not supported.** Every smart-HTTP request must present a bearer token. There is no public-read mode. -- **Pre-existing instances and definitions have no creator grants.** Instances and definitions created before the creator-grant seeding shipped do not carry an explicit creator grant on the asset or state repo. Operators working with that legacy data fall back to a tenant admin grant on the relevant resource. New instances and definitions seed the creator grant at creation and launch time, and need no special handling. +- **Pre-existing instances and definitions have no creator grants.** Instances and definitions created before the creator-grant seeding shipped do not carry an explicit creator grant on the asset. Operators working with that legacy data fall back to a tenant admin grant on the relevant resource. New instances and definitions seed the creator grant at creation and launch time, and need no special handling. ## Operational caveats diff --git a/docs/HARNESS_DESIGN.md b/docs/HARNESS_DESIGN.md index dbdbcf13b..53ae2e9a4 100644 --- a/docs/HARNESS_DESIGN.md +++ b/docs/HARNESS_DESIGN.md @@ -41,7 +41,7 @@ attachment (`child/step-tools.ts`). The in-process agent runtime `@intx/agent` and runs inside that child. The sidecar draws runtime capabilities from `@intx/harness`. The app supplies the concrete crypto / tool / storage / authz plugins, the Bun spawners, and the -tool-materialization binding. +closure-materialization binding. "Thin" describes the app's layering, not its file count: the app owns every binding that names a concrete dependency, so the wiring surface is wide even @@ -58,8 +58,7 @@ apps/sidecar/ │ ├── default-harness.ts # HarnessBuilder source-admission seam (canBuildSource) the deploy router consults before spawning │ ├── sidecar-materialization-config.ts # Shared registry-map and host-platform resolution for tool materialization │ ├── signing-keypair.ts # Loads or mints the sidecar's on-disk Ed25519 signing keypair -│ ├── step-tool-materialization.ts # Pinned-closure tool materialization the child factory takes as an argument -│ ├── workflow-child-bindings.ts # Closes the child factory over tool materialization and the grant cap +│ ├── workflow-child-bindings.ts # Closes the child factory over the grant cap │ ├── workflow-child-spawner.ts # Bun spawner and resolved path for bin/workflow-child │ └── workflow-probe-spawner.ts # Bun spawner and resolved path for bin/workflow-probe-child ├── package.json @@ -77,10 +76,10 @@ All communication between hub and sidecar is over a single persistent WebSocket **Hub to Sidecar:** -| Frame | Fields | Description | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | -| `agent.deploy` | `requestId`, `agentAddress`, `generation`, `agentId`, `config` (full `HarnessConfig`), `hubPublicKey`, `workflow?`, `provisionStep?` | Deploy an incarnation to this sidecar | -| `agent.undeploy` | `requestId`, `agentAddress`, `generation`, `reason` | Remove an incarnation from this sidecar | +| Frame | Fields | Description | +| ---------------- | ----------------------------------------------------------------------------- | --------------------------------------- | +| `agent.deploy` | `requestId`, `agentAddress`, `generation`, `config` (`sessionId`), `workflow` | Deploy an incarnation to this sidecar | +| `agent.undeploy` | `requestId`, `agentAddress`, `generation`, `reason` | Remove an incarnation from this sidecar | **Sidecar to Hub:** @@ -98,15 +97,15 @@ the request was refused before accepting or starting a deployment; `deployment_failed` leaves the outcome uncertain. The Hub uses the code for that distinction and the message for diagnostics. -When the Hub sends `agent.deploy`, the sidecar spawns a supervised **workflow-process child** to host the deployment and responds with `agent.deploy.ack`. The sidecar records the Hub key used for deploy-pack verification and returns the supervisor public key. The Hub publishes that key only after initialization completes under the current allocation lock; reconnect authority remains the sidecar credential, not the projected public key. Before the child is spawned, inputs a restart cannot otherwise recover are written to a per-deployment record. +When the Hub sends `agent.deploy`, the sidecar spawns a supervised **workflow-process child** to host the deployment and responds with `agent.deploy.ack`. The sidecar returns the supervisor public key. The Hub publishes that key only after initialization completes under the current allocation lock; reconnect authority remains the sidecar credential, not the projected public key. Before the child is spawned, inputs a restart cannot otherwise recover are written to a per-deployment record. When the hub sends `agent.undeploy`, the sidecar shuts the deployment's supervisor down (killing the workflow-process child and releasing its IPC pipes and event-channel handle), unregisters the deployment address from the transport and from the mail/signal/drain routers, reclaims the deployment's per-step scratch, removes its workflow-run repository, and deletes the `deployment.json` record so a later boot does not re-spawn a torn-down deployment. It marks the record `tearing-down` first and deletes it only once every other step succeeded, so a crash mid-undeploy or a step that fails leaves the deployment reported as tearing down, and the Hub retries removal while the allocation's cleanup policy permits it. A cleanup disconnect timeout resumes after reconnect; exhausted retries or a permanent cleanup rejection require operator recovery and keep a cleanup-only binding for inventory. These terminal failures do not trigger another undeploy on hello. A copy stops occupying an active slot once removal or retained storage is confirmed, even if the provider obligation remains failed. It also deletes the deployment's agent directory, key pair included, and its local conversation copy, which carries a conversation only across child respawns and sidecar restarts of the deployment that wrote it. -Credentials travel in the `agent.deploy` frame's inference **sources** — `config.sources`, and the per-step `workflow.sources` failover chains — where each `InferenceSource` carries its own API key. There is no separate credential push endpoint. +An `InferenceSource` names its credential by `credentialId`. The secrets travel separately: in the `agent.deploy` frame's `workflow.credentials`, and later in `credentials.update` frames. ## Per-Agent Key Pairs -Each agent has its own Ed25519 key pair, generated when the agent is first deployed to the sidecar and stored alongside the agent's isogit repository. The key pair persists across sidecar restarts. The public key is transmitted to the Hub in the initial `agent.deploy.ack` frame for the deployment's published identity and signed-content provenance. Sidecar credentials, not agent keys, authorize reconnect routing. +Each agent has its own Ed25519 key pair, generated when the agent is first deployed to the sidecar and stored in the agent's data directory. The key pair persists across sidecar restarts. The public key is transmitted to the Hub in the initial `agent.deploy.ack` frame for the deployment's published identity and signed-content provenance. Sidecar credentials, not agent keys, authorize reconnect routing. Keys are stored as raw 32-byte binary files under a `keys/` directory within the agent's data directory. @@ -114,8 +113,8 @@ Directory layout under `SIDECAR_DATA_DIR`: ``` SIDECAR_DATA_DIR/ - / # per-agent key custody + head deploy-tree repo - .git/ # isogit repository (deploy tree, context, audit records) + .sidecar-signing/ # the sidecar's own signing keypair + / # per-agent key custody keys/ id_ed25519 # agent private key (raw 32 bytes, mode 0600) id_ed25519.pub # agent public key (raw 32 bytes) @@ -126,11 +125,23 @@ SIDECAR_DATA_DIR/ / # per-step scratch and working stores, reclaimed on undeploy agent-conversation-state/ / # local conversation copy, reclaimed on undeploy + workflow-definition-sources/ + / + source-assets// # mounted tarball asset + workflow-definition-source-gits/ + / # retained `.git` of each source-format asset + workflow-definition-closures/ + / # laid-out closure the workflow child imports from + workflow-definition-closure-cache/ # tarball cache the deploy closure applies share + cache/tarballs/ # the probe's tarball cache (`SIDECAR_CACHE_DIR`) + workflow-probe/closures/ # per-probe scratch ``` The per-agent key directory is keyed by the sanitized run address; the workflow subtrees are keyed by the derived run id. -The `deployment.json` record stores only what a restart cannot otherwise recover: the deployment's `agentAddress` and the allocation `generation` it was deployed for, the `definitionId` naming its workflow definition on disk, each step's ordered inference-**sources** failover chain (`sources`), the optional inference `sessionId`, and — for a single-step deployment — the `hubPublicKey`. A `version` field guards the schema so a stale record can be rejected rather than parsed blindly. A deployment that no longer runs keeps its record until the Hub undeploys it, marked `stopped`, with the error that ended it unless the Hub stopped it, or `tearing-down`, so a restart reports it instead of spawning it. Its optional `retention` marker records whether keeping the copy was accepted (`kept`) or refused (`refused`); only durably kept copies leave the active capacity pool. The record deliberately does **not** duplicate the workflow definition (kept on disk under its `definitionId` and re-read at restore) or the step grants (kept in each step's agent-state repo). Because each source embeds its API key, the record is written owner-only (mode 0600). +Nothing creates or reads `agents/` under `SIDECAR_DATA_DIR` or `HUB_DATA_DIR`. Earlier versions kept agent-state repositories there; one left behind is safe to delete. + +The `deployment.json` record stores only what a restart cannot otherwise recover: the deployment's `agentAddress` and the allocation `generation` it was deployed for, the `definitionId` naming its workflow definition, the `sourceRef` pin a restore re-materializes the closure from, the sealed credential cell, each step's ordered inference-**sources** failover chain (`sources`), and the optional inference `sessionId`. A `version` field guards the schema so a stale record can be rejected rather than parsed blindly. A deployment that no longer runs keeps its record until the Hub undeploys it, marked `stopped`, with the error that ended it unless the Hub stopped it, or `tearing-down`, so a restart reports it instead of spawning it. Its optional `retention` marker records whether keeping the copy was accepted (`kept`) or refused (`refused`); only durably kept copies leave the active capacity pool. The record deliberately does **not** duplicate the workflow definition (re-materialized from its `sourceRef` closure at restore). The record is written owner-only (mode 0600). A live source rotation for a single-step deployment overwrites this record's `sources` before it takes effect. Persistence is what makes a rotation durable: a rotation whose write fails is not durable, and the deployment falls back to the last durably-recorded source list on the next recycle or restart. @@ -156,23 +167,10 @@ The router does not read `Interchange-Type`. A structured payload that names a ` Connector state has four parts: `threadRoot` (the first message's id), `lastMessageId` (the most recent message in either direction), `replyTo` (the most recent speaker — the primary recipient on the next outbound reply), and `cc` (every other participant who has spoken on the thread, deduplicated, in arrival order). `threadRoot` and `lastMessageId` are each present only when the message that set them named an id; `replyTo` is always present, because a message that names no reply address starts no thread. When the reactor emits `connector.reply`, the outbound mail is addressed to `replyTo` with `cc` carrying everyone else — whoever spoke most recently gets the direct reply and the rest stay in the loop. The reply carries `In-Reply-To` whenever the thread holds an identified ancestor — `lastMessageId`, or `threadRoot` when the most recent message named no id. With neither present the header is omitted, since RFC 5322 defines it as one or more message ids and admits no empty one. -When a hub user composes mail to an agent, the hub decides what threading headers to stamp: - -1. **Session history wins.** If the user already has prior mail in this session, the hub stamps `inReplyTo` and `references` from that session-history chain. The harness routes the message as `continue` against whatever thread the user's prior session message was part of. -2. **Connector cache fallback.** With no session history, the hub looks up the agent's cached connector state. If a thread is active, the hub stamps `inReplyTo = lastMessageId` and `references = [threadRoot]` — regardless of who else is on the thread. The user joins whatever conversation is in progress. -3. **No threading.** With no session history and no active connector, the hub sends the message threading-less. The harness routes it as `start`, establishing this user as the first participant on a new thread. - -The hub learns the cached connector state from a `connector.state.changed` frame the sidecar emits whenever the router's state mutates. Cache entries are dropped on sidecar disconnect. - -On reconnect, agents whose persisted state is non-null re-bootstrap the cache automatically: the router's `restore()` call from the reactor's first `wrappedStore.load()` fires `onStateChanged` because the state transitions from the cold-start `null` to the persisted value, and the sidecar lifts that callback onto a wire frame. Agents whose persisted state is null emit no frame — the cache stays absent until the harness produces its first real state change. The route handler treats absent and null identically. +Mail a hub user sends to a run carries no threading headers (`packages/hub-api/src/workflow-run-trigger.ts`). The bootstrap restore happens **only on the first `wrappedStore.load()`**. Subsequent loads return the store's payload but do not restore from disk; once the router emits its first state change, the harness flips an `inMemoryStateAuthoritative` bit and refuses to clobber in-memory state with a stale disk value. This closes a race where the reactor's startup `load()` lands on the same microtask boundary as the watch callback's `commit()`: without the dirty bit, the second load would reset the router's freshly committed thread state to disk's null and the next `connector.reply` would fail to compose. -Two observable windows where the cache may be empty or stale, both of which fall through to threading-less mail and self-heal on the next state change: - -1. **Between WebSocket connection and the reactor's first `wrappedStore.load()`.** A user message composed in this window finds an absent cache entry. After the load, a bootstrap frame populates the cache. -2. **Between a sidecar disconnect and the same sidecar's next `wrappedStore.load()` on reconnect.** The disconnect clears the cache. A user message composed in this window also finds an absent entry. If the cache was ahead of the persisted store at disconnect (a state mutation fired between the last `writeMetadata` cycle and the drop), the bootstrap will restore the persisted snapshot rather than the prior in-memory cache value. The cache reflects the freshest source of truth available, not a continuous history. - ## Registration and Reconnection The Hub mints a sidecar identity for each probe or allocation generation and hands it to the provisioner, which either starts capacity that authenticates with it or places the work on a sidecar it already runs. The bearer token resolves to that sidecar and every workflow probe and allocation generation it currently hosts, including cleanup bindings; there is no ambient registration pool. A sidecar hosting only probes has no incarnation to report. @@ -203,7 +201,7 @@ The sidecar's isogit repository is the source of truth for agent inference conte ## Security Model -Credentials travel in the `agent.deploy` frame's inference `sources` (each `InferenceSource` carries its own API key) and are held in memory by the running deployment. They are also persisted to disk in the deployment's `deployment.json` record — the `sources` field embeds those API keys — so the sidecar can restore a deployment on restart without re-receiving them from the hub. The record is written owner-only (mode 0600), but storing provider API keys on the sidecar's disk at all is a known limitation of the prototype that should be addressed before production use. +Credential secrets travel in the `agent.deploy` frame's `workflow.credentials` and in `credentials.update` frames, and are held in memory by the running deployment. The `deployment.json` record also keeps them, sealed with `SIDECAR_CREDENTIAL_ENCRYPTION_KEY`, so the sidecar can restore a deployment on restart without re-receiving them from the hub. The record is written owner-only (mode 0600). ## Key Rotation @@ -219,7 +217,7 @@ If a deployment the sidecar restores has no key pair on disk (for example, keys Mail is the first-class communication primitive. The sidecar persists outbound mail from agents via `mail.outbound` frames sent to the hub. The hub persists inbound mail sent by users via `POST .../workflows/runs/:runId/mail` and dispatches it to the sidecar as a `mail.delivered` agent event. -The composition-layer harness exposes the agent's reactor event stream as `harness.stream()`, an `AsyncIterable`. The sidecar's `HarnessBuilder` drains that stream and adapts each event into an `onEvent(event)` callback for the hub session channel. The stream carries inference activity, tool execution, reactor lifecycle, and fork events. `message.received` is a `ReactorInboundEvent` — it is delivered directly to the reactor director and is not forwarded to session channel subscribers. This keeps the external event stream focused on observable inference activity rather than internal routing signals. +The composition-layer harness exposes the agent's reactor event stream as `harness.stream()`, an `AsyncIterable`. The workflow child sends those events to the supervisor over the event channel, and the supervisor forwards them to the hub session channel. The stream carries inference activity, tool execution, reactor lifecycle, and fork events. `message.received` is a `ReactorInboundEvent` — it is delivered directly to the reactor director and is not forwarded to session channel subscribers. This keeps the external event stream focused on observable inference activity rather than internal routing signals. Inference traces are stored separately from mail. The hub records one `inference_turn` per inference cycle and one or more `turn_part` rows per turn. The `/turns` endpoint serves these to UI clients independently of the `/mail` endpoint. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index d45a09eb6..3e9fef1c2 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -242,7 +242,7 @@ The IMAP inbox is the source of truth for conversation history. Session channels 2. Hub redelivers mail the sidecar had not acknowledged when the connection dropped, if it is back within the mail hold 3. Sidecar loads agent context from isogit and resumes operation -A supervised deployment carries its grants in the deploy pack and refreshes them over the supervisor's IPC credentials snapshot at spawn and recycle. When a sidecar reconnects, the Hub also sends the run's committed grants in a `run.grants` frame to each deployment that it routes again, so a grant change made while the sidecar was disconnected reaches the running child. +A supervised deployment receives its runs' grants in `run.grants` frames and passes them to its child over the supervisor's IPC credentials snapshot. When a sidecar reconnects, the Hub also sends the run's committed grants in a `run.grants` frame to each deployment that it routes again, so a grant change made while the sidecar was disconnected reaches the running child. In the prototype, the Hub keeps no queue of mail sent while a sidecar is disconnected. Triggers and signals wait in `workflow_run_dispatch` rows until a sidecar takes them, and mail the sidecar had not acknowledged is held in memory for the mail hold (five minutes by default). The sidecar's isogit repository is the source of truth for agent inference context. The hub maintains a sidecar-to-agent mapping so it knows which sidecar to route messages to for a given run address. See HARNESS_DESIGN.md for the reconnection wire protocol. @@ -261,13 +261,13 @@ The sidecar WebSocket protocol includes frames for agent deployment and reconnec | Sidecar → Hub | `agent.undeploy.ack` | Confirm teardown | | Sidecar → Hub | `agent.undeploy.error` | Report that teardown failed | -Agent deployment stages through the workflow-run substrate rather than a separate provision-then-start handshake. The hub sends `agent.deploy`; the sidecar's deploy router primes the per-step repo (for a provision-step frame) or spawns the supervised workflow-process child (for a workflow frame), then acks with `agent.deploy.ack` carrying the public key. The deploy tree (prompt, skills) rides in on the follow-up deploy pack, which the child reads from the substrate. The workflow-process child starts inference itself once spawned -- there is no separate `session.start` step. +Agent deployment stages through the workflow-run substrate rather than a separate provision-then-start handshake. The hub sends `agent.deploy`; the sidecar's deploy router spawns the supervised workflow-process child, then acks with `agent.deploy.ack` carrying the public key. The workflow-process child starts inference itself once spawned -- there is no separate `session.start` step. A deployment on a sidecar is an incarnation: its address plus the allocation generation it was deployed for. `agent.deploy` and `agent.undeploy` carry a `requestId` and the `generation` they act on, and every reply echoes both, so a reply settles only the request it answers and a late reply to a request the Hub already gave up on matches nothing. The sidecar handles the frames for one address one at a time, in the order it received them. Two kinds of frame do not hold the address's turn: the Hub's answers to its own pack transfers and correlation registrations, which only settle the request they answer, and a `workflow.control`, which starts in order but frees the turn while a cooperative cancellation waits for the child. Frames for different addresses, probe requests, and heartbeat pongs do not wait on each other, so one deployment's slow deploy or undeploy delays neither another deployment nor the link's liveness. It answers each request only on the connection that carried it; a request whose connection closed before its turn is dropped, and the Hub has already failed it. An undeploy is the exception: it still runs and only its answer is dropped, because the Hub sends it for a generation it will not route again and may close the socket right behind it. A sidecar holds at most one incarnation of an address, and an address is deployed once: a deploy of an address the sidecar holds is refused with `agent.deploy.error`, and a Hub pack for it with `conflict`. -Undeploy is an acknowledged operation. The sidecar shuts the named generation's supervisor down, waits for the deployment's in-flight outbound mail to reach the link, deletes the agent directory, and responds with `agent.undeploy.ack`, or with `agent.undeploy.error` naming each step that failed. An undeploy of a generation older than the one the sidecar holds is acknowledged without touching anything. An undeploy of an address the sidecar does not hold still runs the teardown, so local state an interrupted deploy left behind is reclaimed. The teardown first marks the run record `tearing-down`, and removes the deployment's workflow-run repository and, last, the record only once every other step succeeded: after a crash mid-undeploy the next boot reports the incarnation as tearing down, a step that fails keeps it listed so. Each removed tree's parent is synced before cleanup is acknowledged. The Hub retries removal while its cleanup policy permits it; a cleanup binding leaves this to the reconciler rather than sending an untracked undeploy on hello. A multi-step deployment's per-step grants repositories and deploy trees are left on disk, since a sidecar no longer holding the deployment cannot identify them. Reports the undeployed incarnation still owed the Hub but had not sent, such as queued outbound mail and correlation-register retries, are dropped with it, so none reaches the Hub after the teardown. When the Hub releases a deployment, it drops the deployment's routes before it sends the undeploy. A deploy of an address waits until the sidecar has answered every undeploy of it sent on the same connection, bounded by the request timeout. The Hub handles that answer behind the frames the sidecar sent before it, so nothing the removed copy sends is credited to a later deploy of the address. +Undeploy is an acknowledged operation. The sidecar shuts the named generation's supervisor down, waits for the deployment's in-flight outbound mail to reach the link, deletes the agent directory, and responds with `agent.undeploy.ack`, or with `agent.undeploy.error` naming each step that failed. An undeploy of a generation older than the one the sidecar holds is acknowledged without touching anything. An undeploy of an address the sidecar does not hold still runs the teardown, so local state an interrupted deploy left behind is reclaimed. The teardown first marks the run record `tearing-down`, and removes the deployment's workflow-run repository and, last, the record only once every other step succeeded: after a crash mid-undeploy the next boot reports the incarnation as tearing down, a step that fails keeps it listed so. Each removed tree's parent is synced before cleanup is acknowledged. The Hub retries removal while its cleanup policy permits it; a cleanup binding leaves this to the reconciler rather than sending an untracked undeploy on hello. Reports the undeployed incarnation still owed the Hub but had not sent, such as queued outbound mail and correlation-register retries, are dropped with it, so none reaches the Hub after the teardown. When the Hub releases a deployment, it drops the deployment's routes before it sends the undeploy. A deploy of an address waits until the sidecar has answered every undeploy of it sent on the same connection, bounded by the request timeout. The Hub handles that answer behind the frames the sidecar sent before it, so nothing the removed copy sends is credited to a later deploy of the address. **Reconnection:** @@ -279,7 +279,7 @@ Undeploy is an acknowledged operation. The sidecar shuts the named generation's **Grant management:** -The live `grants.update` wire path has been retired. A supervised deployment receives its grants in the deploy pack and refreshes them over the supervisor's IPC credentials snapshot at spawn and recycle. The hub also sends the run's committed grants in a `run.grants` frame ahead of each signal delivery and each trigger, and to each deployment that it routes again when a sidecar reconnects, so a standing approval decision reaches a running deployment. When a change to a delegator's grants re-derives a run's rows, the hub sends the run its grants on their own after the change commits (see AUTH.md, _Grant Revocation_). +The live `grants.update` wire path has been retired. The hub sends the run's committed grants in a `run.grants` frame ahead of each signal delivery and each trigger, and to each deployment that it routes again when a sidecar reconnects, so a standing approval decision reaches a running deployment. The sidecar keeps them in the run's grants file, and the supervisor passes them to the child over its IPC credentials snapshot at spawn and recycle. When a change to a delegator's grants re-derives a run's rows, the hub sends the run its grants on their own after the change commits (see AUTH.md, _Grant Revocation_). Every connection opens with a `hello` that reports each incarnation the sidecar holds and whether it is `deploying`, `live`, `stopped`, or `tearing-down`. A `stopped` incarnation no longer runs and keeps its local state for inspection until the Hub undeploys it. One the Hub did not stop carries the error that ended it: its workflow child ended itself, or the sidecar could not restore it. The Hub then fails the deployment's live runs with that error and keeps its capacity for the retention its policy sets for failed runs, so the stopped copy can be inspected. The sidecar also marks either state in the run record, so a restart reports the incarnation instead of spawning it, and reports such a stop in a `deployment.stopped` frame once that write settles, and again after each `welcome` until retention is durably decided, with the tips of the deployment's history branches. It does not wait for its pushes of that history: a push that fails is retried on every reconnect, so that wait would have no bound. The Hub fails the deployment once it holds those tips, or a minute after it first heard of the stop, and the failure then names the history it never received. A reconnect does not restart that minute, and a sidecar that disconnects instead and stays away past its disconnect limit fails the deployment with the reason it reported. The Hub holds a reported stop only in memory until it fails the deployment: a Hub restart starts that minute again from the sidecar's next report, and if the sidecar does not come back, the deployment fails without that reason. The Hub accepts the frame when the bearer token resolves to a sidecar that is not retired or still hosts a probe, allocation generation, or cleanup binding. It restores a reported incarnation's route when the incarnation is live, its generation is the current, initialized generation of a routable allocation the sidecar hosts, and its run has not ended, keeps a stopped one of the current generation of an allocation the sidecar hosts unrouted, initialized or not, and an initialized live one whose run is terminal until lifecycle retention stops it, leaves incarnations covered by cleanup bindings to the reconciler, and sends `agent.undeploy` naming the generation of every other reported incarnation rather than rejecting the connection, so one stale deployment cannot disconnect the others. It then answers `welcome` listing the incarnations it routes, as soon as routing is settled. A hello is never left unanswered: the Hub closes the socket when the hello is invalid or its registration fails before the welcome, and the sidecar reconnects when no `welcome` arrives within 30 seconds (`welcomeTimeoutMs`). A failure after the welcome is logged, since registering again would meet it again. A sidecar whose token is valid but that hosts neither current work nor cleanup obligations is welcomed with nothing routed, after the Hub sends `agent.undeploy` for everything it reports, and stays connected, idle, for its provisioner to reuse. Until `welcome` the sidecar sends nothing the Hub must receive: it queues outbound mail and signal correlation registrations, drops best-effort events, and holds back the workflow-run pack pushes of every incarnation it holds; a push no connection could carry stays due instead of failing the deployment's next write. The queue holds 1024 frames for all the sidecar's deployments together: an outage that outlasts it drops queued correlation registrations first, which the next `welcome` re-sends, then the audit copies the Hub records sent mail from, and then fails a deployment's send rather than drop mail it has not yet routed. On `welcome` it sends the queue, re-drives its routed incarnations' pending packs and parked correlations, and reports stopped deployments whose retention is undecided. @@ -342,9 +342,8 @@ do not use this cache; the HTTP deployment route accepts asset sources. Asset packs use the exact recorded commit. A launch specification written before that commit was recorded uses the commit its source names, or the branch tip for a tarball asset. The Hub retains those commits under -`refs/interchange/workflow-definitions/`. Garbage collection treats -`refs/interchange/` as roots, git clients never see it, and only the Hub may -write to it. Only the Hub may write ref names with non-ASCII characters either, +`refs/interchange/workflow-definitions/`. Git clients never see +`refs/interchange/`, and only the Hub may write to it. Only the Hub may write ref names with non-ASCII characters either, because a case-insensitive filesystem can fold one of them into this namespace. Retained commits and completed artifacts are not expired automatically. @@ -384,7 +383,7 @@ A sidecar relays every recipient of a deployment's mail through the Hub, includi Workflow-run pack ingestion applies the fence in the reverse direction. The wire layer derives pack ownership from the authenticated socket, requires the frame's generation to be the one the connection hosts for the deployment address, routed or not, and the repository id to equal the slug derived from that address, and passes that source to the Hub lookup. Immediately before advancing the Git ref, the lookup revalidates the running anchor and the current accepted allocation generation. A pushed history must build on the Hub's tip of its branch, so a push cannot drop events or seeds the Hub already holds; only a tip that still holds nothing but the repository's initial `.gitignore` is replaced, as a deployment's first push replaces the Hub's empty start. Terminal events are projected into SQL only when their run row belongs to that anchor. -The repository store stages a workflow-run pack and validates it before it decides the ref. Under the repository lock, it writes and indexes the pack in `objects/pack-staging//`, a directory that no reader scans. It then validates each new commit through a view that also reads the staged pack, with no repository lock held. The commit step takes the repository lock again, publishes the pack into `objects/pack`, and moves the ref. A pack that fails validation is discarded, so no reader ever sees it. A crash leaves only a staging directory, and the first receive for that repository in the next Hub process removes it. When the repository is removed while its pack validates, the receive fails with `RepoRemovedDuringReceiveError` instead of a validation error, because the pack is not at fault. Every other pack receive, such as an agent-state, skill or asset push, uses the same stage, validate and publish steps in one lock span. +The repository store stages a workflow-run pack and validates it before it decides the ref. Under the repository lock, it writes and indexes the pack in `objects/pack-staging//`, a directory that no reader scans. It then validates each new commit through a view that also reads the staged pack, with no repository lock held. The commit step takes the repository lock again, publishes the pack into `objects/pack`, and moves the ref. A pack that fails validation is discarded, so no reader ever sees it. A crash leaves only a staging directory, and the first receive for that repository in the next Hub process removes it. When the repository is removed while its pack validates, the receive fails with `RepoRemovedDuringReceiveError` instead of a validation error, because the pack is not at fault. Every other pack receive, such as a skill or asset push, uses the same stage, validate and publish steps in one lock span. The Hub lookup validates a workflow-run pack before it takes the deployment's `sidecar_allocation` row lock, and it holds that row only for the ref decision. First, with no lock, it checks that the anchor is live and that the source owns the current generation of an `allocated` allocation. It rejects a fenced or stale source with `path_violation` before the repository store stages anything. Then it prepares the pack with no database transaction open, so a pack that fails validation writes no row. After that it opens the pending projection and locks the allocation row. Under the lock it runs the same ownership check and the live check again, and only then calls the commit step. Git therefore still moves only under the allocation lock. A release or a recorded stop that commits while the pack validates makes the locked check reject the pack, and the receive discards the staged pack. The commit step fails with `RepoRemovedDuringReceiveError` only before it publishes, so that receive clears its pending projection and gets `path_violation`. @@ -477,7 +476,7 @@ The settlement projection currently scans the retained consumed-mail index and w ## Workflow Process: Supervisor/Child IPC -A deployed workflow runs in two processes per active deployment. The supervisor lives in the sidecar — it holds the mail-bus identity for the deployment's addresses, owns the per-deployment Ed25519 signing key the hub trusts, and reads the per-step credential snapshots out of each step's `agent-state` repo. The workflow-process is a Bun child the supervisor spawns; it loads operator-supplied workflow code (tools, directors, agent prompts) and executes runs against the runtime adapters. The supervisor is the authoritative side; the workflow-process is the untrusted side. +A deployed workflow runs in two processes per active deployment. The supervisor lives in the sidecar — it holds the mail-bus identity for the deployment's addresses, owns the per-deployment Ed25519 signing key the hub trusts, and passes each run's grants to the child as a per-step credential snapshot. The workflow-process is a Bun child the supervisor spawns; it loads operator-supplied workflow code (tools, directors, agent prompts) and executes runs against the runtime adapters. The supervisor is the authoritative side; the workflow-process is the untrusted side. The IPC between them is the airlock between those two halves. Per-frame crypto is what makes the airlock seal: nothing the child receives is acted on until its signature verifies under the supervisor's public key, and nothing the supervisor accepts from the child is acted on until its HMAC verifies under the shared key. The implementation lives at `packages/workflow-host/src/ipc/` with the threat model at the top of `packages/workflow-host/src/ipc/index.ts`. @@ -489,7 +488,7 @@ The workflow-process runs code the operator deploys but the sidecar does not ful Each deployment gets two channels: -- **Control channel.** NDJSON over stdio, Ed25519-signed per direction. Carries the child's first `authority.set`, trigger fires, signal deliveries, drain, recycle, shutdown, grants-updated, credentials-updated, sources-updated, and back from the child its `definition` report and its `ready` signal. Low rate, high authority. Sending and receiving live in `packages/workflow-host/src/ipc/control-sender.ts` and `control-receiver.ts`; payload validation lives in `control-payloads.ts`. +- **Control channel.** NDJSON over stdio, Ed25519-signed per direction. Carries the child's first `authority.set`, trigger fires, signal deliveries, drain, shutdown, grants-updated, credentials-updated, sources-updated, and back from the child its `definition` report and its `ready` signal. Low rate, high authority. Sending and receiving live in `packages/workflow-host/src/ipc/control-sender.ts` and `control-receiver.ts`; payload validation lives in `control-payloads.ts`. - **Event channel.** A UNIX socketpair, HMAC-SHA256-authenticated with a 32-byte symmetric key derived at spawn time. Carries `InferenceEvent`s from the reactor (including the per-message `message.run.started` / `message.run.ended` brackets). High rate. Sending lives in `packages/workflow-host/src/ipc/event-sender.ts`; receiving and payload validation live in `event-channel.ts`. Each side signs control frames with its own private key and verifies incoming frames against its peer's public key, so the child cannot forge supervisor commands. Symmetric HMAC authenticates the event stream at reactor cadence — Ed25519 per frame at that rate would dominate runtime cost. The control receiver validates each payload against its message kind's schema. Those schemas and the `InferenceEvent` schema applied by `parseInferenceEvent` accept disjoint payload shapes: control schemas reject inference-event shapes, and the event schema rejects control-plane shapes, so a confused-deputy attack that smuggles a `drain` or `recycle` over the event channel fails at validation. @@ -503,13 +502,13 @@ Every frame on either channel carries `{ seq, channelId, payload }` inside the b Two complementary mechanisms run side by side: - **Monotonic seq per channelId.** The receiver tracks the highest seq seen and requires the next frame's seq to equal `highestSeq + 1` exactly. A repeat or decrease is a replay; a gap is a drop. Either crashes the receiver. -- **ChannelId rotation on every spawn and every recycle.** The supervisor mints a fresh 16-byte channelId via `crypto.randomBytes` (hex-encoded) at every spawn and at every recycle (the `generateChannelId` helper in `packages/workflow-host/src/ipc/crypto.ts`). The receiver compares the incoming channelId against the current one byte for byte; any mismatch crashes the receiver. A frame from a recycled child's predecessor, or a frame captured from a previous channelId's lifetime, carries the wrong channelId and is rejected loudly rather than silently dropped. +- **ChannelId rotation on every spawn and every recycle.** The supervisor mints a fresh 16-byte channelId via `crypto.getRandomValues` (hex-encoded) at every spawn and at every recycle (the `generateChannelId` helper in `packages/workflow-host/src/ipc/crypto.ts`). The receiver compares the incoming channelId against the current one byte for byte; any mismatch crashes the receiver. A frame from a recycled child's predecessor, or a frame captured from a previous channelId's lifetime, carries the wrong channelId and is rejected loudly rather than silently dropped. The combination is what defends against frame replay across the spawn/recycle boundary that monotonic seq alone cannot defend against. The seq counter resets per channelId. ### Trust-Anchor Bootstrap -The spawn-time environment is the only channel the supervisor uses to hand trust anchors to the child. It carries exactly three values: `HOST_PUBKEY` (the supervisor's Ed25519 32-byte public key, hex), `IPC_HMAC_KEY` (the freshly minted 32-byte HMAC key, hex), and `IPC_CHANNEL_ID` (the freshly minted channelId, hex). The supervisor's Ed25519 private key never appears in env, never appears in any IPC payload, never appears in a log line, and never appears in an audit-log frame — it lives only as a 32-byte `Uint8Array` held in closure by the signing-key callback the supervisor wraps the control channel sender around. The env is constructed as a fresh object containing only those three variables, with no `...process.env` spread that could accidentally propagate a private value the supervisor's parent put in its own env. +The spawn-time environment is the only channel the supervisor uses to hand trust anchors to the child. It carries three trust anchors, plus deployment identifiers and substrate config: `HOST_PUBKEY` (the supervisor's Ed25519 32-byte public key, hex), `IPC_HMAC_KEY` (the freshly minted 32-byte HMAC key, hex), and `IPC_CHANNEL_ID` (the freshly minted channelId, hex). The supervisor's Ed25519 private key never appears in env, never appears in any IPC payload, never appears in a log line, and never appears in an audit-log frame — it lives only as a 32-byte `Uint8Array` held in closure by the signing-key callback the supervisor wraps the control channel sender around. The env is constructed as a fresh object, with no `...process.env` spread that could accidentally propagate a private value the supervisor's parent put in its own env. ### Failure Stance @@ -538,10 +537,9 @@ object whose shape is: Production wires it against `Bun.spawn`; tests inject a mock. - Per-deployment configuration: `binaryPath`, `substrateEnv`, `workflowRunRepoId`, `workflowRunRef`, `anchorRunId`, - `deploymentMailAddress`, `readPrincipal`. -- `layoutSteps(definition)` — derives the deployment's step layout - (its step ids and how each maps to a mail address and an - `agent-state` repo) from the definition the first child verified. + `deploymentMailAddress`. +- `layoutSteps(definition)` — derives the deployment's step ids from the + definition the first child verified. The supervisor's `spawn(opts)` method: @@ -562,8 +560,8 @@ The supervisor's `spawn(opts)` method: check does not vouch for what it reports. On the first spawn the supervisor passes the projection to `layoutSteps` and keeps the layout; every later child reports the same hash. -5. Assembles the per-deployment `credentialsSnapshot` from each - step's `agent-state` repo (see next subsection), sends the child +5. Reads the run's `credentialsSnapshot` through the `onRunStart` + binding (see next subsection), sends the child its `authority.set`, and registers the deployment's mail address on the bus. 6. Awaits the child's `ready` frame on the control channel. @@ -656,12 +654,7 @@ ChannelSender` writes against; the event channel is the field keeps it and replaces only the material. - `grants-updated` -> swap the active credentialsSnapshot. - `credentials-updated` -> merge into the material cell. - - `drain` -> forward to the drain controller (no-op placeholder - in this commit; the real controller lands separately). - - `recycle` -> log-only notification on the child side; the - supervisor's recycle path drives the kill/respawn from outside - the child, so the downstream `recycle` payload exists for - symmetry with the protocol but is not the trigger. + - `drain` -> flip the drain controller's signal. - `shutdown` -> stop accepting new triggers and exit the loop. #### Trust Anchors @@ -720,7 +713,7 @@ grants until the next send, and it can ask again. - **Control channel.** Supervisor -> child. Carries every `ControlPayload` variant (`authority.set`, `trigger.fire`, - `signal.deliver`, `drain`, `recycle`, `shutdown`, `grants-updated`, + `signal.deliver`, `drain`, `shutdown`, `grants-updated`, `credentials-updated`, `sources-updated`) plus the child's upstream `ready` ride. Every inbound frame is verified against `HOST_PUBKEY`; the receiver @@ -945,21 +938,19 @@ child stays unaddressable; the parent is the resume root. #### Recycle -Recycle is the supervisor's "same deploy tree, fresh process" path, +Recycle is the supervisor's "same definition, fresh process" path, implemented as library code in `packages/workflow-host/src/supervisor/recycle.ts` and consumed by the supervisor object's `recycle()` method. -**Same deploy tree only.** Recycle uses the SAME deploy -tree -- the same materialized source closure, the same workflow-asset -repo, the same per-step `agent-state` repos. A deployment keeps that -tree for its life: a new deploy tree, a new workflow definition, or -different agents need a new deployment. The recycle module never -refetches the deploy tree, never consults an updated workflow -definition, never re-resolves agents. The module header documents the -invariant loudly. The recycle module must not grow a "maybe also -refetch the deploy tree" mode -- that would let a recycle silently -change what a deployment runs. +**Same definition only.** Recycle uses the SAME definition -- the same +materialized source closure and the same workflow-asset repo. A deployment +keeps that definition for its life: a new workflow definition or different +agents need a new deployment. The recycle module never refetches the source +closure, never consults an updated workflow definition, never re-resolves +agents. The module header documents the invariant loudly. The recycle module +must not grow a "maybe also refetch the definition" mode -- that would let a +recycle silently change what a deployment runs. **Six-step sequence (locked):** @@ -981,8 +972,7 @@ change what a deployment runs. 3. **`respawn`** -- the supervisor mints a fresh 16-byte hex channelId via the same `generateChannelId` helper the initial spawn uses, generates a new 32-byte HMAC key, mints a fresh - Ed25519 IPC keypair, re-reads per-step credentials via the - injected `RepoStore`, and spawns a new Bun child via the same + Ed25519 IPC keypair, and spawns a new Bun child via the same subprocess-spawner binding. The new IPC anchors flow through spawn-time env exactly as the initial spawn's anchors did. 4. **`self-discover`** -- the new child runs its existing @@ -1011,7 +1001,7 @@ either case is one the operator must see. Once the new child emits **ChannelId rotation per the IPC contract.** Every respawn mints a new channelId via `generateChannelId` (16 bytes from -`crypto.randomBytes`, hex-encoded). The new channel's receiver +`crypto.getRandomValues`, hex-encoded). The new channel's receiver crashes on any frame carrying the predecessor channelId -- a stale frame is by construction either a replay or a programming bug, and crashing on mismatch is the only honest response. The seq counter @@ -1064,32 +1054,21 @@ in `packages/workflow-host/src/deploy/workflow-host-wiring.ts`. Routing lives on router side of the seam; the hub-link hands the frame off and folds the returned public key into the outbound `agent.deploy.ack`. -Every deploy stages through the workflow-run substrate. The router -branches on the frame shape: - -1. **Provision-step frame (`provisionStep: true`).** The router primes - the frame's per-step `agent-state` repo and records the hub key, - without constructing a supervisor or spawning a child. The follow-up - full-closure deploy pack then applies into the primed repo and - verifies against the recorded key. -2. **Workflow frame (carries a source-ref pin).** The router checks - that it can build every inference source the frame pins, lays out - the frozen closure without evaluating it, constructs a fresh - per-deployment workflow-host supervisor and drives its `spawn(opts)` - lifecycle: key minting, workflow-process child spawn via - `subprocessSpawner`, the child's definition report, per-step - `agent-state` repo provisioning, `credentialsSnapshot` assembly, - mail-bus registration and the IPC ready handshake. The long-lived - sidecar process keeps every module it imports, so it never evaluates - the workflow's code: everything it does with the definition -- - the structural check against the frame's sources, the step layout, - the deploy-time grants write, the single-step head repo and hub - key, the inbound mail policy -- works from the projection the child - verified, after the supervisor checks it against the approved hash. - A closure that fails, throws or hangs while loading fails only its - own deploy or restore. - -A frame carrying neither shape is rejected -- there is no in-process +Every deploy stages through the workflow-run substrate. The frame carries a +source-ref pin. The router checks that it can build every inference source +the frame pins, lays out the frozen closure without evaluating it, constructs +a fresh per-deployment workflow-host supervisor and drives its `spawn(opts)` +lifecycle: key minting, workflow-process child spawn via `subprocessSpawner`, +the child's definition report, its first `authority.set`, mail-bus registration and the IPC ready +handshake. The long-lived sidecar process keeps every module it imports, so it +never evaluates the workflow's code: everything it does with the definition -- +the structural check against the frame's sources, the step ids, the +inbound mail policy -- works from the projection +the child verified, after the supervisor checks it against the approved hash. +A closure that fails, throws or hangs while loading fails only its own deploy +or restore. + +A frame without a `workflow` fails frame validation -- there is no in-process deploy path. A single agent deploys as a single-step workflow whose lone step IS the deployment head (`deriveRunAddress` in `packages/workflow-deploy/src/addresses.ts`), so single- and @@ -1122,11 +1101,11 @@ implementation can reuse it without forking the wiring. ### CredentialsSnapshot Assembly -Per the Q6.4 discovery decision, each workflow step gets its own -`agent-state` repo, and the supervisor assembles a per-step grant -snapshot at spawn time over the step ids the host hands it. The -implementation lives in -`packages/workflow-host/src/supervisor/credentials.ts`: +The sidecar's `onRunStart` binding assembles a per-step grant snapshot for a +run over the step ids the host hands it, and the supervisor sends the result in +each `authority.set`. The assembly is `assembleRunCredentialsSnapshot` in +`packages/workflow-host/src/deploy/workflow-host-wiring.ts`; the snapshot type +and its hash live in `packages/workflow-host/src/supervisor/credentials.ts`. The domain is the deployment's FLAT STEP-ID NAMESPACE, which is wider than the definition's own `stepOrder`: a `loop` body runs in-process as @@ -1135,29 +1114,21 @@ against this same snapshot under its own plain step id. The host owns that widening because it is the layer that holds the definition -- `inertFlatNamespaceStepIds` in `@intx/workflow-deploy` computes it from the inert projection the child reported, and the sidecar's deploy router -feeds the result to both the snapshot assembly and the deploy-time -per-step grants write. A step id absent from the snapshot is unrecoverable at the child's +feeds the result to the snapshot assembly. A step id absent from the snapshot is unrecoverable at the child's authorize (it throws rather than denying), so the producer, not the consumer, owns totality. An `onTrigger` section or `childWorkflow` body is NOT part of this namespace: it is lifted to its own definition and the sidecar mints it a fresh snapshot of its own when it builds the spawned child's run env. -- `defaultStepRepoId({ runId, stepId })` returns the - `agent-state` repo identity for the step. The default convention - is `-`. -- The grants file for a step rides at `state/grants.json` under the - step's repo working tree (the substrate's `getRepoDir` is a pure - path computation, mirroring the sibling production adapters' - working-tree-read pattern). -- `assembleCredentialsSnapshot` reads each step's grants, hashes - them with sha256 over the canonical JSON serialization, and - returns a `CredentialsSnapshot` with per-step `address`, `grants`, - and `contentHash`. The hash is stable across processes so the - child can detect a stale snapshot pushed after a fresher one. -- A missing grants file is treated as an empty grant array (so a - single-step deploy with no operator-supplied grants does not crash); a - malformed file fails loudly at the boundary. +- Every step holds its run's one grant set. The Hub delivers it in `run.grants` + frames, and the sidecar keeps it in the run's grants file, + `runs//grants.json`. A run with no file yet gets no snapshot, so a step + that needs grants fails closed. +- `assembleRunCredentialsSnapshot` hashes the grants with sha256 over the + canonical JSON serialization, and returns a `CredentialsSnapshot` with + per-step `grants` and `contentHash`. The hash is stable across + processes so the child can detect a stale snapshot pushed after a fresher one. ### Signed CancelRequested Authority @@ -1388,19 +1359,11 @@ Telemetry is tagged with tenant identifiers. Backends can enforce tenant-level a ## Lifecycle: Versioning and Health -Agent lifecycle operations are managed through versioned packages and health protocols. +Agent lifecycle operations are managed through pinned deployments and health protocols. -### Agent Packages +### Deployed Content -An agent package is a git tree assembled from the agent's definition repository with resolved skill dependencies (see Agent Versioning above). It contains: - -- Skills and their dependencies -- System prompt -- Context builder configuration -- Initial state -- Capability grants - -Each deploy commit represents an immutable version. The control plane tracks which deploy refs are active on which sidecars, and retains historical deploy tags for rollback. +A deployment runs what its `agent.deploy` frame pins: the workflow's frozen source closure, the definition hash the operator approved, and the assets the source mounts (see Deploy Flow below). A deployment keeps that content for its life; different content means a new deployment. ### Health Protocol @@ -1413,13 +1376,11 @@ The response's `lastCheckedAt` is currently always `null`: this is a derived rea ### Deployment Procedure -1. **Stage** - Hub sends `agent.deploy`; the sidecar's deploy router stages the deploy through the workflow-run substrate (priming the per-step repo or spawning the supervised workflow-process child) and returns the public key -2. **Assembly** - Hub resolves skill dependencies and assembles the deploy tree -3. **Pack transfer** - Hub streams the packfile to the sidecar; the workflow-process child reads the deploy tree from the substrate and begins inference (see Agent Deployment) -4. **Health gate** - New version must pass health checks before receiving traffic (see the Health Protocol above: this gate is intended flow, not yet an automated control loop) -5. **Traffic shift** - Registry updates discovery to point to new version -6. **Drain** - Old version stops accepting new work, completes in-flight operations -7. **Retirement** - Old version shuts down; deploy tag remains for rollback +1. **Stage** - Hub sends `agent.deploy`; the sidecar's deploy router lays out the frozen source closure, spawns the supervised workflow-process child, which begins inference, and returns the public key (see Deploy Flow) +2. **Health gate** - New version must pass health checks before receiving traffic (see the Health Protocol above: this gate is intended flow, not yet an automated control loop) +3. **Traffic shift** - Registry updates discovery to point to new version +4. **Drain** - Old version stops accepting new work, completes in-flight operations +5. **Retirement** - Old version shuts down ### Rollback @@ -1429,65 +1390,15 @@ If a new version fails health checks or exhibits problems in production (health- 2. Traffic shifts back to previous version 3. Failed version is marked unhealthy and stopped -Rollback is fast because the previous version's package is still available and can be restarted immediately. - -## Agent Versioning: Git-Backed Definitions - -Agent definitions are stored in git repositories on the hub, providing version control for the resources that constitute an agent. - -### Repository Contents - -The repository contains agent-specific resources: - -- Skills and skill configuration -- System prompt (`prompt.md`) -- Context builder configuration -- Initial state -- Capability grants -- Dependency references (resolved at deployment) - -External skill libraries are not stored directly. Configuration files reference them, and the hub resolves these references when assembling the deploy tree. - -### Source Composition - -At deploy time, the hub assembles a flat tree from multiple sources via subtree merge: - -- The agent's own repository provides the prompt, configuration, and agent-specific skills -- Referenced skill library repositories are merged under `deploy/skills//` prefixes - -The result is a single commit containing everything the harness needs. The sidecar receives this flat tree with no submodule metadata or external references to resolve. Provenance (which source repos and refs contributed) is recorded in the assembly commit on the hub side. - -How the hub discovers and resolves skill dependencies (registry format, version resolution, authentication to source repos) is not yet specified. - -### Skill Structure - -A skill is a directory within the deploy tree: - -``` -deploy/skills// - tool.json (required: ToolDefinition schema) - handler.ts (optional: executable implementation) - ... (additional assets as needed) -``` - -`tool.json` declares the tool's name, description, and input schema. The harness reads all `deploy/skills/*/tool.json` files at startup to build the tool list for inference calls. - -A skill with `tool.json` but no `handler.ts` is backed by the harness's built-in tool runner (e.g., `read_file`, `write_file`, `run_shell`). The tool name in `tool.json` maps to the internal implementation. - -Skills with `handler.ts` contain executable code loaded by the harness. The degree of sandboxing depends on the execution environment (see Architecture, Environment Integration). At minimum, skill handlers run in a separate isolate/process from the harness itself. - -### Repository Organization +A rollback is a new deployment of the previous version's source. -Repository structure is flexible: +## Workflow Source -- **Tenant repository** - A single repository contains definitions for all agents within a tenant, organized by directory -- **Agent repository** - Each agent has a dedicated repository - -Both approaches support standard git workflows for managing agent versions. +A workflow's source is a package on a registry, a tarball, or a codebase in a subtree of a hub git asset at a pinned commit. At deploy time the hub resolves that source to a frozen dependency closure, and the operator approves the hash of the definition it produces. The `agent.deploy` frame carries the resulting pin, `sourceRef` (see Deploy Flow). See [Workflow Authoring](./WORKFLOW_AUTHORING.md) for how a workflow is packaged. ## Agent Deployment: Git Pack Transport -Deploy content travels from the hub to sidecars as git packfiles streamed over the existing WebSocket connection. State travels back from sidecars to the hub using the same mechanism. This avoids requiring sidecars to have network access to external git hosts and reuses the authenticated WebSocket connection already in place. The protocol does not encrypt that connection; transport confidentiality depends on running it over TLS (`wss://`). +Workflow-run history travels between the hub and sidecars as git packfiles streamed over the existing WebSocket connection: a sidecar pushes each run's commits to the hub, and the hub seeds a first deploy with its copy. This avoids requiring sidecars to have network access to external git hosts and reuses the authenticated WebSocket connection already in place. The protocol does not encrypt that connection; transport confidentiality depends on running it over TLS (`wss://`). ### Wire Frames @@ -1509,52 +1420,38 @@ Packfile chunks are base64-encoded within JSON frames, consistent with how mail Pack transfers share the WebSocket with live session traffic. To prevent interference: - Pack chunks are interleaved with other frames at the WebSocket message level (messages are atomic; the receiver processes each independently) -- The sender limits unacknowledged pack data to a configurable window before pausing -- Session-critical frames (`mail.inbound`, `message.send`) are never delayed by pack transfers -- The receiver buffers incoming chunks in memory (or a temp file for large transfers) and only unpacks atomically on `repo.pack.done` +- Session-critical frames (`mail.inbound`) are never delayed by pack transfers +- The receiver buffers incoming chunks in memory and only unpacks atomically on `repo.pack.done` ### Rejection Reasons `repo.pack.reject` carries a `reason` field distinguishing failure modes: -| Reason | Meaning | Sender action | -| ------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------ | -| `signature_invalid` | Commit signature verification failed | Do not retry with same content | -| `path_violation` | Commit modifies paths outside sender's ownership, or the connection does not host that address | Do not retry | -| `backpressure` | Receiver is overwhelmed | Pause and retry after a delay | -| `conflict` | Ref update conflicts with receiver state | Reconcile before retrying | -| `corrupt` | Packfile failed index verification | Resend from scratch | -| `missing_objects` | A commit needs an object that neither the pack nor the receiver holds | Resend the full history once | +| Reason | Meaning | +| ----------------- | ---------------------------------------------------------------------------------------------- | +| `path_violation` | Commit modifies paths outside sender's ownership, or the connection does not host that address | +| `conflict` | Ref update conflicts with receiver state | +| `corrupt` | Packfile failed index verification | +| `sha_mismatch` | The applied pack's tip does not match the frame's `commitSha` | +| `missing_objects` | A commit needs an object that neither the pack nor the receiver holds | + +A `missing_objects` reject makes the sender ship the full history once. Any other reason ends the push with a failure. The Hub answers every transfer, rejecting one for an address the connection does not host rather than dropping it, and one it failed to parse or receive with `corrupt`: the sender holds a transfer open until it is answered and queues later pushes to the same repository ref behind it. ### Deploy Flow -Deployment is a two-phase operation: stage and pack delivery. There is no separate session-start phase -- the workflow-process child begins inference once it is spawned and its deploy tree has landed. - -**Phase 1: Stage** - -1. Hub sends `agent.deploy` with the deploy config (credentials, materialized grants, providers, session ID) and, for a multi-step deploy, a workflow definition -2. The sidecar's deploy router stages the deploy through the workflow-run substrate: a provision-step frame primes the per-step `agent-state` repo and records the hub key; a workflow frame spawns the supervised workflow-process child -3. Sidecar responds with `agent.deploy.ack` containing the public key (hex-encoded) -4. Hub stores the public key for future deploy-commit verification +There is no separate session-start phase -- the workflow-process child begins inference once it is spawned. -**Phase 2: Pack delivery** - -5. Hub assembles the deploy tree and produces a packfile -6. Hub sends `repo.pack.push` frames (chunked, interleaved with other traffic) -7. Hub sends `repo.pack.done` with target ref (`refs/heads/deploy`) and commit SHA -8. Sidecar validates the packfile integrity -9. Sidecar verifies the deploy commit signature against the recorded hub public key -10. Sidecar unpacks objects into the git object store -11. Sidecar updates `refs/heads/deploy` to the new commit -12. Sidecar merges `deploy` into its agent branch (fast-forward) -13. Sidecar checks out only `deploy/` paths via partial checkout (`filepaths: ["deploy/"], noUpdateHead: true`) — `state/` working-tree files are untouched because `filepaths` restricts the scope, `noUpdateHead` prevents moving the branch pointer -14. Sidecar responds with `repo.pack.ack`; the workflow-process child reads the deploy tree from the substrate and runs +1. Hub seeds a first deploy with its copy of the deployment's workflow-run history, as a `repo.pack` transfer, before the address is routed +2. Hub sends `agent.deploy` with the deploy config (credentials, providers, session ID) and the workflow: its `sourceRef` pin, the approved definition hash, and the packs of any assets the source mounts +3. The sidecar's deploy router lays out the frozen closure and spawns the supervised workflow-process child +4. Sidecar responds with `agent.deploy.ack` containing the public key (hex-encoded) +5. Hub stores the public key The agent is now running and can receive messages. -Each provisioned deployment receives the full pack for its frozen deploy tree. Reconnect restores the existing local tree; it does not negotiate deploy refs or reconcile a newer definition. +Reconnect restores the deployment's local copy; it does not negotiate refs or reconcile a newer definition. ### Undeploy Flow @@ -1579,15 +1476,13 @@ On reconnect a provisioned sidecar restores its workflow deployments from local 3. The deployment's in-flight run state is reconstructed sidecar-locally at restore; the Hub sends its copy of the workflow-run refs only to a first deploy, before a supervisor is active. 4. A `hello` that does not report the current generation of a deployment the sidecar hosts whose first deploy completed means the sidecar no longer holds it, for instance because its data directory was lost. The Hub fails the deployment and releases its capacity rather than deploying it again. -The sidecar verifies every inbound deploy pack's commit signature against the hub key it recorded at deploy time before applying it, so pack content is never applied on an unverified signature. - The sidecar generates the deployment key and returns it in `agent.deploy.ack`, but routing authority comes from the sidecar credential and the allocations it hosts. Registration checks each binding's in-memory generation fence and revalidates its durable identity before mutating routing state, and routes no reported incarnation other than a hosted allocation's anchor deployment at its current generation. #### Workflow Definition Versioning: Pinned-Forever -A workflow deployment is pinned to its deploy-time definition. It keeps that definition — the frozen source closure the child re-evaluates, the workflow-asset repo, the per-step `agent-state` repos — until it is undeployed. A definition change made on the hub does **not** reconcile onto a live deployment; it affects only deployments created after the change. +A workflow deployment is pinned to its deploy-time definition. It keeps that definition — the frozen source closure the child re-evaluates and the workflow-asset repo — until it is undeployed. A definition change made on the hub does **not** reconcile onto a live deployment; it affects only deployments created after the change. -This holds across a sidecar↔Hub disconnect. Reconnect restores routing and resumes the locally persisted frozen definition; it does not negotiate deploy refs or pull newer definition bytes. Picking up a definition change requires a new deployment. This is orthogonal to recycle, which also keeps the same deploy tree while starting a fresh process. +This holds across a sidecar↔Hub disconnect. Reconnect restores routing and resumes the locally persisted frozen definition; it does not negotiate refs or pull newer definition bytes. Picking up a definition change requires a new deployment. This is orthogonal to recycle, which also keeps the same definition while starting a fresh process. ### SSH Commit Signatures @@ -1608,25 +1503,21 @@ The signature structure: -----END SSH SIGNATURE----- ``` -State commits carry an author identity of ` ` rather than a shared harness identity, making commits attributable in standard git tooling. +State commits carry the shared author identity `interchange-harness `. ### Deploy Content Integrity -The deploy tree can contain executable code (`handler.ts` files in skills). This makes it the primary attack surface in the deployment path. Deploy commit signing is mandatory: +The frozen source closure contains executable code, which makes it the primary attack surface in the deployment path. The operator approves a definition hash, the deploy frame carries it, and the child refuses to run a definition that does not match it (see AUTH.md, _The re-verify barrier_). -- Deploy commits are signed by the hub's Ed25519 key (or an operator-delegated key) -- The sidecar verifies the signature before unpacking -- Unsigned or incorrectly signed deploy content is rejected - -The hub's signing public key is provisioned to the sidecar at registration time. A compromised hub can still push malicious code (it holds the signing key) — that threat is bounded by hub security and access controls, not by the wire protocol. +The frame itself is not signed. It travels on the sidecar's authenticated WebSocket, so a compromised hub can still push malicious code -- that threat is bounded by hub security and access controls, not by the wire protocol. ### Credential Isolation -The deploy tree never contains credentials. API keys, OAuth tokens, and other secrets travel in the `sources` field of the wire-format `HarnessConfig` from `@intx/types/runtime` on the `agent.deploy` frame, which is ephemeral (not persisted to git). The `keys/` directory is excluded from git via `.gitignore`. +A source closure never contains credentials. API keys, OAuth tokens, and other secrets travel in the `agent.deploy` frame's `workflow.credentials` and in `credentials.update` frames, which are ephemeral (not persisted to git). The `keys/` directory sits outside every git repository. ## Change History: Git-Backed Storage -Agent-local data is stored in the same git repository as the deployed definition, providing revision control as a native capability. State lives under `state/` paths on a per-agent branch, while the deployed definition lives under `deploy/` (see Agent Deployment below). +A deployment's data is stored in its workflow-run git repository, providing revision control as a native capability. Each step's state lives beside its run's event log (see Step state above). ### Storage Backends @@ -1640,45 +1531,28 @@ The harness abstracts these differences - agents interact with the same history ### Repository Structure -Each agent has a single git repository: +Each deployment has one workflow-run git repository. Its runs are sibling subtrees: an anchor run and any child runs it spawned. ``` -/ - .git/ - deploy/ # Hub-managed (read-only on sidecar) - prompt.md - config.json - skills/ - / - tool.json - handler.ts - state/ # Sidecar-managed (per-agent journal) - context.json - audit/ - / - .json - keys/ # Not tracked in git (.gitignore) - id_ed25519 - id_ed25519.pub +runs// + events/.json # the run's event log + blobs/ # content-addressed payloads + parts/ # mail parts + grants.json # the run's committed grants + steps// + state/ # checkpoint and write-ahead log ``` -The `deploy/` subtree is checked out from the deploy ref. The `state/` subtree is the agent's working data. The `keys/` directory holds the agent's Ed25519 keypair and is excluded from git via `.gitignore`. +See HARNESS_DESIGN.md for the layout under `SIDECAR_DATA_DIR`, including the deployment's `keys/` directory. ### Ref Structure ``` -refs/ - heads/ - deploy (latest deploy commit, hub-managed) - tags/ - deploy/v1 (historical deploy versions) - deploy/v2 - agents/ - (per-agent state branch) +refs/heads/ + main (each run's events, grants, and step state) + events (the inbox claim-check) ``` -The agent branch forks from the deploy ref at launch time and keeps that deploy commit for the deployment's life; a new deploy commit means a new deployment. - ### Commit Policy The harness uses a hybrid commit strategy: @@ -1698,7 +1572,7 @@ Agents create and switch branches through harness-provided tools: - `branch merge ` - Merge another branch into current - `branch delete ` - Remove a branch -The harness tracks branch metadata (creation time, purpose, parent branch) for garbage collection and auditing. +The harness tracks branch metadata (creation time, purpose, parent branch) for auditing. ### Worktree Support @@ -1717,13 +1591,3 @@ Agents query history through structured tools: - `history show ` - Show changes in a specific commit - `history diff ` - Compare two points in history - `history restore ` - Restore a file from a previous state - -### Garbage Collection - -The harness periodically cleans up: - -- Merged branches older than a retention threshold -- Orphaned worktrees -- Unreachable commits (standard git gc) - -Retention policies are configurable per-tenant or per-agent. diff --git a/docs/README.md b/docs/README.md index 4190c2d1e..82df039df 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,13 +60,13 @@ see [`LAYOUT.md`](../LAYOUT.md). hand. - [`GIT_ACCESS.md`](./GIT_ACCESS.md) — operator-facing walkthrough for the hub's smart-HTTP git endpoints. Minting tokens, configuring - stock `git`, and the URL and `refPattern` grammars for the assets - and agent-state repo families. + stock `git`, and the URL and `refPattern` grammars for assets. ## Implementation notes -- [`IMPLEMENTATION.md`](./IMPLEMENTATION.md) — implementation notes. - Currently covers the SMTP/IMAP message bus. +- [`IMPLEMENTATION.md`](./IMPLEMENTATION.md) — implementation notes, + including the message bus, allocation, step state, supervisor IPC and pack + transport. - [`inference-discovery-anthropic/docs/discovery.md`](../packages/inference-discovery-anthropic/docs/discovery.md) — observed vs documented wire behaviour of Anthropic's `/v1/messages` and `/v1/files` surfaces. Narrative companion to the discovery fixtures diff --git a/docs/ROUTES.md b/docs/ROUTES.md index c630f9de1..c4fd89234 100644 --- a/docs/ROUTES.md +++ b/docs/ROUTES.md @@ -112,21 +112,14 @@ Agent definitions declare grant requirements with source annotations (tenant, cr A subset of routes serve the git smart-HTTP wire protocol rather than JSON. These are the only routes in the control plane API that are not REST. They accept and emit pkt-line-framed binary bodies (`application/x-git-upload-pack-request`, `application/x-git-receive-pack-result`, `application/x-git-upload-pack-advertisement`, ...), and they authenticate with hub-issued bearer tokens rather than better-auth session cookies. -Three route groups expose the wire: +One route group exposes the wire, and a second mints the tokens that authenticate it: - **Asset REST + smart-HTTP** under `/api/tenants/:tenantId/assets/`. - `POST /api/tenants/:tenantId/assets` creates an asset row and initializes its backing repository with a hub-signed genesis commit. Session-authenticated; gated by `requireGrant("asset:*", "create")`. - `GET /api/tenants/:tenantId/assets/:kind/:name.git/info/refs` (with `?service=git-upload-pack` or `?service=git-receive-pack`) is the smart-HTTP advertise endpoint. - `POST /api/tenants/:tenantId/assets/:kind/:name.git/git-upload-pack` serves clone and fetch. - `POST /api/tenants/:tenantId/assets/:kind/:name.git/git-receive-pack` serves push. - - `:kind` is one of `skill`, `agent-state`, `package-registry`, or `workflow`; `:name` is the kebab-case asset name. The `.git` suffix on the trailing segment is required — it is how the URL grammar disambiguates the asset namespace from arbitrary tenant sub-paths. - -- **Agent-state per-run smart-HTTP** under `/api/tenants/:tenantId/workflows/runs/:runId/state.git/...`. - - Read-only. `info/refs` and `git-upload-pack` are served; `git-receive-pack` returns a pkt-line-framed protocol-level rejection so `git push -v` surfaces a readable error even when no `Authorization` header is present. - - The repo is lazily materialized: a never-pushed run advertises an empty repository (the `capabilities^{}` empty-repo record) so a stock `git clone` succeeds against an empty tree rather than 404'ing. - -- **Agent-state per-definition smart-HTTP** under `/api/tenants/:tenantId/agents/definitions/:agentId/state.git/...`. - - Read-only, same handler shape as the per-run grammar. The hub wrote the `deploy/` prefix when it staged the agent for a launch; no production route stages an agent now. + - `:kind` is one of `skill`, `package-registry`, or `workflow`; `:name` is the kebab-case asset name. The `.git` suffix on the trailing segment is required — it is how the URL grammar disambiguates the asset namespace from arbitrary tenant sub-paths. - **Git-tokens REST** under two scopes: - `/api/tenants/:tenantId/git-tokens` mints, lists, and revokes tenant-bound service tokens (`kind: "svc"`). The token speaks as a specific tenant member; the route group is gated by `requireGrant("git-token:*", ...)`. @@ -137,7 +130,7 @@ Three route groups expose the wire: Every smart-HTTP URL the hub exposes terminates the repo segment with `.git`. The suffix is what distinguishes a smart-HTTP request from an arbitrary tenant or instance sub-path: -- `/api/tenants/tnt_abc/workflows/runs/run_xyz/state.git/info/refs` — smart-HTTP. +- `/api/tenants/tnt_abc/assets/skill/my-skill.git/info/refs` — smart-HTTP. - `/api/tenants/tnt_abc/workflows/runs/run_xyz/events` — SSE, not git. This matches stock git's convention: `git clone` URLs end in `.git`, and the URL is the primary key by which it lives in `.git/config`. diff --git a/docs/WORKFLOW_AUTHORING.md b/docs/WORKFLOW_AUTHORING.md index 7d987f85d..c4e267136 100644 --- a/docs/WORKFLOW_AUTHORING.md +++ b/docs/WORKFLOW_AUTHORING.md @@ -217,7 +217,7 @@ const snapshotRef = { const resolveGrants = createSnapshotGrantsResolver(snapshotRef, "local run"); const authorize = createCredentialsBackedAuthorize( resolveGrants, - createGrantEvaluator({ floorFor: () => [] }), + createGrantEvaluator(), ); const invokeStep = createWorkflowStepInvoker({ workflowAuthorize: authorize, diff --git a/docs/unified-execution-host-design.md b/docs/unified-execution-host-design.md index c808bfc26..d0770aafe 100644 --- a/docs/unified-execution-host-design.md +++ b/docs/unified-execution-host-design.md @@ -177,14 +177,14 @@ threads up through every rung to the hub timeline. **A single-agent deploy** (`run_@`): -1. Hub deploy -> the deploy path mints the run address, ships - grants on disk (the 8a grants-bridge, see §3g). +1. Hub deploy -> the deploy path mints the run address and sends + `agent.deploy`; the run's grants follow in `run.grants` frames (see §3g). 2. The deploy carries a one-step workflow whose single step is the deployed agent. The DeployRouter routes it to the supervisor for the address's isolation-domain. -3. The supervisor registers the run address on the mail bus, assembles the - step's credentials snapshot from the on-disk grants, spawns (or reuses) the - child, and pushes `grants-updated`. +3. The supervisor registers the run address on the mail bus, spawns (or reuses) + the child, and sends it the run's credentials snapshot, read from the run's + grants file, in `authority.set`. 4. Inbound mail lands at the supervisor, enters the inbox claim-check, and the dispatch loop emits `trigger.fired`. The child opens a `runtimeRun` for the one-step workflow; the **real step-invoker** instantiates the agent with @@ -194,16 +194,11 @@ threads up through every rung to the hub timeline. Conversation state is committed to the workflow-run substrate (§3a, §3c), so kill+respawn resumes via `discoverInFlightRuns`. -**A multi-step workflow** (`run_`, with per-step derived addresses): -identical machinery. The +**A multi-step workflow** (`run_`): identical machinery. The supervisor owns mail at the deployment address; the dispatch loop drives `runtimeRun` over the multi-step definition; each step uses the same real -step-invoker; per-step addresses/grants come from the credentials snapshot. -The only difference from the single-agent case is the number of steps and the -address shape — both already handled by the existing derivation -(`deriveStepAddress` / `deriveStepRepoId` in -`packages/workflow-host/src/supervisor/credentials.ts`, which already -documents the single-step collapse to the deployment's own address). +step-invoker; per-step grants come from the credentials snapshot. +The only difference from the single-agent case is the number of steps. **A workflow with a `childWorkflow` / `map` fan-out / per-step isolated node**: a top-level `map` fans out by running its inner step in place through the @@ -423,13 +418,8 @@ flagged in §6. ### 3d. Tool materialization and execution in the child (the other hard one) -A workflow step's tools are materialized in the child. -`apps/sidecar/src/step-tool-materialization.ts` (`materializeStepTools`) -reads the step's deploy tree (`readDeployTree` from `@intx/hub-agent/paths`) -and calls `materializeToolPackages` -(`packages/tool-packaging/src/tool-materialization.ts`), which runs the -tool-package loader (`@intx/tool-packaging`: `createToolLoader`, -`applyAtomic`, `createTarballCache`). `attachStepTools` and +A workflow step's tools are the step agent's own evaluated tool factories, +which the child holds from the frozen source closure. `attachStepTools` and `createToolBearingAgentFactory` (`packages/workflow-host/src/child/step-tools.ts`) compose the plugin chain (posix reads `env.plugins`; the LSP plugin factory **spawns a subprocess**), @@ -446,8 +436,8 @@ from `@intx/workflow-host/child` and imports `createSubstrate` and `SIDECAR_SUBSTRATE_CONFIG_KEYS` from `apps/sidecar/src/workflow-child-bindings.ts`. That binding closes `createSidecarSubstrateFactory` -(`packages/workflow-host/src/child/substrate-factory.ts`) over -`materializeStepTools`. There is **no cross-process injection of tool +(`packages/workflow-host/src/child/substrate-factory.ts`) over the grant +cap. There is **no cross-process injection of tool factories** — there could not be, because tool factories are closures and closures do not cross a process boundary. Tool materialization and tool _execution_ both happen **inside the child process**: the loader runs there, @@ -459,12 +449,8 @@ is exactly why the isolation unit and the sandbox boundary (§3f and Decision **Decision: materialize and run tools in the child, rooted per-step.** -- **What the child runs:** `materializeToolPackages` - (`packages/tool-packaging/src/tool-materialization.ts`) and the loader - (`createToolLoader`, `createTarballCache`, `applyAtomic`), called by the - app materializer (`apps/sidecar/src/step-tool-materialization.ts`), which - reads the deploy tree through `readDeployTree` from `@intx/hub-agent/paths`. - The plugin-chain instantiation (including LSP subprocess lifecycle, posix +- **What the child runs:** the step agent's evaluated tool factories. The + plugin-chain instantiation (including LSP subprocess lifecycle, posix `env.plugins` threading, and disposer capture) is `packages/workflow-host/src/child/step-tools.ts`. The real step-invoker calls that code when building a step's agent. "In the child" means "in the @@ -473,26 +459,21 @@ is exactly why the isolation unit and the sandbox boundary (§3f and Decision `@intx/agent` `defineAgent` / `createAgent` surface, the `@intx/storage-isogit` stores, the inference runtime. None of these are re-implemented; the substrate factory the child binary runs calls them. -- **Rooting.** The tarball cache and tool instance dir root per agent/step - under the workflow-run repo's working tree (the child has `getRepoDir`). For - a single long-lived agent there is one materialization, reused across - triggers (warm-keep, §3b). For multi-step, materialization is per step - invocation as today. +- **Rooting.** A step's tools run rooted per step under the run (see + `createSidecarStepBuildEnv`). A single long-lived agent keeps its tools across + triggers (warm-keep, §3b). For multi-step, tools are built per step + invocation. **Layering — why `@intx/workflow-host` stays portable (the clarification strengthens the call).** `@intx/workflow-host` takes substrate, spawner, mail -bus, step-invoker, and `materializeStepTools` as injected bindings, and the -step-invoker accepts `buildEnv` + `agentFactory` +bus and step-invoker as injected bindings, and the step-invoker accepts `buildEnv` + `agentFactory` (`packages/workflow-host/src/adapters/step-invoker.ts`, `WorkflowStepInvokerOpts`). The package does not depend on -`@intx/tool-packaging` or `@intx/hub-agent`. The host-specific deploy-tree -read is `apps/sidecar/src/step-tool-materialization.ts`. The factory +`@intx/tool-packaging` or `@intx/hub-agent`. The factory (`packages/workflow-host/src/child/substrate-factory.ts`) and the plugin attachment (`packages/workflow-host/src/child/step-tools.ts`) are what the -child binary loads. Because the child _is_ the sidecar binary, the injected -materializer is present in the child's address space without the package -importing it. An alternative host that ships a different `bin/workflow-child` -and a different materializer reuses the orchestration package unchanged. The +child binary loads. An alternative host that ships a different +`bin/workflow-child` reuses the orchestration package unchanged. The locus (tools run _in_ the child, which is the sidecar binary) is what makes this clean: one process, one tool runtime, and a portable orchestration package that does not import that runtime. @@ -1017,17 +998,10 @@ same child/workflow-run model as the multi-step path: function (`packages/workflow-host/src/deploy/workflow-host-wiring.ts`). The read/write repo-id invariant (hub reconstructs the same slug for reads) is preserved. -- **Grants placement.** The supervisor's credentials assembly reads each step's - grants from `state/grants.json` in the step's `agent-state` repo - (`packages/workflow-host/src/supervisor/credentials.ts`, `STEP_GRANTS_PATH`). - The single-agent deploy's grants must land there. **This is exactly the - grants-on-disk bridge already built and proven** during the Commit-1 - exploration, saved as - `dispatch/workflow-launch-and-converge/8a-route_single_step_via_child/8a-groundwork.patch`. - It is reusable here verbatim: the single-step deploy writes `state/grants.json` - from `config.grants` before spawn, and the per-step `deriveStepAddress` / - `deriveStepRepoId` return the run address and the agent-state repo - id. Reuse this patch as the identity/grants foundation. +- **Grants placement.** The supervisor's `onRunStart` binding reads a run's + grants from `runs//grants.json` + (`packages/workflow-host/src/deploy/workflow-host-wiring.ts`), which the + sidecar writes from the Hub's `run.grants` frames. ### 3h. Trigger budget: how long a step lives @@ -1104,8 +1078,8 @@ re-serviced. `startSession`/`destroySession`/`abortSession` as the in-process execution surface, `restoreSessions`, `persistHubPublicKey`, `onAgentEvent` fan-out, the mail-commit queue. `createSessionManager`'s deletion is deferred to - Phase 5; today it survives, reduced to a thin repo-ops layer over the agent - repo store (deploy/asset-pack applies and teardown), and the + Phase 5; today it survives, reduced to directory teardown over the agent + repo store, and the rest of the in-process-runtime surface is gone. - `apps/sidecar/src/default-harness.ts`: the **transport/reactor ownership** — the `createHarness` call that owns transport subscription, the connector @@ -1132,15 +1106,9 @@ re-serviced. ### Reused (one implementation, in the child = the sidecar binary) -- Tool composition: `materializeToolPackages` - (`packages/tool-packaging/src/tool-materialization.ts`), called by the - app's `materializeStepTools` - (`apps/sidecar/src/step-tool-materialization.ts`), plus - `@intx/tool-packaging` (loader, tarball cache, `applyAtomic`) and the - plugin/LSP chain in `packages/workflow-host/src/child/step-tools.ts`. - The factory takes the materializer as an injected binding (§3d), so - `@intx/workflow-host` does not depend on `@intx/tool-packaging`. Connected - via the step-invoker's `buildEnv`/`agentFactory`. +- Tool composition: the plugin/LSP chain in + `packages/workflow-host/src/child/step-tools.ts`, connected via the + step-invoker's `buildEnv`/`agentFactory`. - Inference source resolution: the per-step source table the substrate factory already parses (`createSidecarStepBuildEnv`, `STEP_INFERENCE_SOURCES`). - The agent runtime: `@intx/agent` `defineAgent` / `createAgent`, the reactor, @@ -1274,7 +1242,7 @@ buildEnv, agentFactory: createAgent })`. Tools empty for now. - **Changes.** Warm-agent cache in the child (§3b); supervisor-as-sole-mail-owner with synthesized step-input delivery (§3a); conversation state committed to the workflow-run substrate (§3c); supervisor-backed outbound transport for - replies. Apply the 8a grants-bridge patch for identity/grants (§3g). + replies. - **Verification.** (1) Two sequential messages to one address reuse one warm agent and preserve conversation state. (2) Multi-turn conversation over the inbox claim-check produces a durable, resumable run log. (3) **Kill + respawn @@ -1399,19 +1367,14 @@ plaintext `assets/workflow//sources.json` for the missing bodies. **source-tools** arm the top-level source step invoker uses. Tools are available wherever inference runs; see the invariant in `packages/workflow/README.md`. The child holds the live, re-verified `WorkflowDefinition`, so each child step's -agent carries live `toolFactories`; the invoker builds its env with -`sourceTools: true` (and the parent's `closurePackageDir` for plugin -materialization), which feeds those live factories straight into the tool slot. No tool deploy tree is staged and no tool-mark floor -is recorded: a source tool's runtime name is the bare `definition.name`, for -which the capability walk already emitted a `tool:` grant into the -credentials snapshot, so the snapshot authorizes it directly (the floor exists -only to compensate a _pinned_ tool's namespaced name, which the walk never saw). -The invoker must not fall through to the pinned-tool arm — with no staged tree it -resolves the child step's address against the _parent_ deployment's mailbox and -step count and returns an empty tool set silently, a fail-quiet path a spawned -child must never take. This also keeps a body step whose id collides with a -parent step id from reading the parent step's tools: the source arm never -consults the deploy tree at all. +agent carries live `toolFactories`; the invoker builds its env with the +parent's `closurePackageDir` for plugin materialization and feeds those live +factories straight into the tool slot. A source tool's runtime name is the bare +`definition.name`, for which the capability walk already emitted a +`tool:` grant into the credentials snapshot, so the snapshot authorizes it +directly. This also keeps a body step whose id collides with a parent step id +from reading the parent step's tools: each agent's tools come from its own +evaluated definition. **Grants (least-privilege).** The operator approves the transitive capability set: the capability walk folds an inline child's grants into the spawning @@ -1634,8 +1597,8 @@ INTR-209 end state; the hardened boundaries (Phase 6) are a follow-on. (§3f). Flagged as an open question, not designed here. 8. **Credentials snapshot / grants flow to a sandboxed sub-child.** The rung-0 - supervisor assembles the credentials snapshot and pushes it over the control - IPC (`assembleCredentialsSnapshot`, `grants-updated`). For an **in-process** + supervisor reads the credentials snapshot and pushes it over the control + IPC (`onRunStart`, `grants-updated`). For an **in-process** rung the snapshot is shared from the parent (same process): each in-process child caps its parent's current grants, which trace back to the top-level run's live snapshot reference, so a grant change reaches it as described @@ -1781,8 +1744,8 @@ The unified host is proven by four classes of check: - Kill + respawn mid-conversation: `discoverInFlightRuns` resumes and conversation state is restored from the substrate. - Identity: the run keeps its `run_` address; the deploy-ack listener - persists the public key; grants resolve from `state/grants.json` and an - authorize call that depends on a granted resource succeeds while an + persists the public key; grants resolve from the run's `grants.json` and + an authorize call that depends on a granted resource succeeds while an ungranted one fails closed. 3. **Multiplex + workflow-declared isolation integration tests.** @@ -1831,9 +1794,6 @@ are explicitly **not** a go-live gate for INTR-209. the agent repo store); `HarnessBuilder` (`packages/hub-agent/src/harness-builder.ts`, now a one-method `canBuildSource` admission seam). -- Tool-package materialization: `packages/tool-packaging/src/tool-materialization.ts` - (`materializeToolPackages`). The sidecar calls it from - `apps/sidecar/src/step-tool-materialization.ts` (`materializeStepTools`). - Plugin-chain composition: `packages/workflow-host/src/child/step-tools.ts` (`createToolBearingAgentFactory`, `attachStepTools`). - Source-admission seam: `apps/sidecar/src/default-harness.ts` @@ -1848,12 +1808,11 @@ are explicitly **not** a go-live gate for INTR-209. (`createWorkflowStepInvoker`, `WorkflowStepInvokerOpts`, `invokeStep`). - Supervisor: `packages/workflow-host/src/supervisor/supervisor.ts` (mail ingestion, dispatch loop, `spawn`); `supervisor/credentials.ts` - (`assembleCredentialsSnapshot`, `deriveStepAddress`, `deriveStepRepoId`, - `STEP_GRANTS_PATH`, `defaultStepRepoId`); `supervisor/types.ts` + (`CredentialsSnapshot`, `hashGrants`); `supervisor/types.ts` (`WorkflowSupervisorBindings`, `SubprocessSpawner`, `SubprocessHandle`); `supervisor/recycle.ts`; `drain-controller.ts`. - Child: `packages/workflow-host/src/child/run-child.ts` - (`runWorkflowChild`, `loadWorkflowDefinition`, `discoverInFlightRuns`, + (`runWorkflowChild`, `loadVerifiedWorkflowDefinitionFromClosure`, `discoverInFlightRuns`, event sender, `trigger.fired` handling). - Mail bus: `packages/workflow-host/src/mail-bus/hub-transport-adapter.ts` (`routeInbound`, `subscribeMailForAddress`). @@ -1899,8 +1858,8 @@ are explicitly **not** a go-live gate for INTR-209. `packages/workflow-host/src/child/substrate-factory.ts` (`createSidecarRunChild` self-referential `runChild`, sub-namespace `runs//...`, proxy `RepoStore`); `packages/workflow-host/src/adapters/spawn-child.ts` - (`createInMemorySpawnChild`, `SpawnChildWorkflow`, `RunChildWorkflow`, - `WorkflowSpawnChildOpts`); child-process principal + (`createInMemorySpawnChild`, `SpawnChildWorkflow`, `RunChildWorkflow`); + child-process principal `WorkflowRunWorkflowProcessPrincipal`. - Identity: `packages/workflow-deploy/src/addresses.ts` (`deriveWorkflowRunRepoId`); @@ -1912,8 +1871,6 @@ are explicitly **not** a go-live gate for INTR-209. `packages/hub-sessions/src/hub-session-lookups.ts` (`resolveRoutableAddress`); sidecar reconnect identity and hosted bindings in `packages/hub-sessions/src/ws/sidecar-token-authenticator.ts`. -- Grants bridge (reusable): - `dispatch/workflow-launch-and-converge/8a-route_single_step_via_child/8a-groundwork.patch`. - Transport interface: `packages/types/src/runtime-core.ts` (`MessageTransport`). - Tool packaging: `@intx/tool-packaging` (`createToolLoader`, `applyAtomic`, `createTarballCache`). diff --git a/packages/agent/src/definition.ts b/packages/agent/src/definition.ts index afd33044f..0db3239b5 100644 --- a/packages/agent/src/definition.ts +++ b/packages/agent/src/definition.ts @@ -88,10 +88,8 @@ export interface AgentDefinition { */ readonly tags?: Readonly>; /** - * Tool-package pins the sidecar materializes for this agent, carried on the - * definition so a folded workflow asset is self-contained rather than - * depending on pins supplied only at deploy time. Plain-data mirror of the - * pins the deploy-tree tool channel consumes. + * Tool-package pins declared for this agent, carried on the definition so a + * folded workflow asset is self-contained. */ readonly toolPackagePins?: readonly ToolPackagePin[]; } diff --git a/packages/agent/src/tool.ts b/packages/agent/src/tool.ts index f89b0b123..f523dc2f4 100644 --- a/packages/agent/src/tool.ts +++ b/packages/agent/src/tool.ts @@ -164,12 +164,7 @@ export interface ToolDeclaration { * `allow`. * * This is the single canonical derivation of a tool's authorization floor - * from its declaration. Both the deploy-time capability walk (hub-side) - * and the per-step tool authorization (sidecar-side) route through here so - * a pinned tool loaded in the child derives the SAME floor the walk would - * have derived from an inline declaration. A divergence between the two - * sites would let a pinned `ask` tool authorize as `allow` (or vice - * versa), so the mapping lives in exactly one place. + * from its declaration, used by the deploy-time capability walk. */ export function toolApprovalEffect( declaration: Pick, diff --git a/packages/authz/src/tool-namespace-invariants.test.ts b/packages/authz/src/tool-namespace-invariants.test.ts index 38855fac8..fbec9e4e2 100644 --- a/packages/authz/src/tool-namespace-invariants.test.ts +++ b/packages/authz/src/tool-namespace-invariants.test.ts @@ -9,10 +9,8 @@ // so the rows it contributes are bare `tool:`. // // That asymmetry is what makes the namespaced form a step-scoping mechanism: -// a bare row cannot address a pinned tool's gate, and the authority a pinned -// tool actually needs is supplied per step by the sidecar's tool-mark floor, -// derived from the factories that step loaded. These cases pin what a wildcard -// row does to that scoping. +// a bare row cannot address a pinned tool's gate. These cases pin what a +// wildcard row does to that scoping. import { describe, test, expect } from "bun:test"; @@ -81,21 +79,3 @@ describe("a wildcard row reaches a pinned tool's namespaced gate", () => { expect(result.effect).toBe("allow"); }); }); - -describe("the tool-mark floor still outranks a wildcard row", () => { - test("an ask floor beats */* allow at higher specificity", async () => { - const floor = grant({ - resource: NAMESPACED_GATE, - action: INVOKE, - effect: "ask", - }); - const wildcard = grant({ resource: "*", action: "*", effect: "allow" }); - const result = await evaluateGrants( - [wildcard, floor], - NAMESPACED_GATE, - INVOKE, - { conditions: "pessimistic" }, - ); - expect(result.effect).toBe("ask"); - }); -}); diff --git a/packages/db/README.md b/packages/db/README.md index 979a7b61f..ec1a7f4d5 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -21,13 +21,13 @@ agent repo metadata). ```ts import { createDB } from "@intx/db"; -import { agent } from "@intx/db/schema"; +import { workflowRun } from "@intx/db/schema"; import { eq } from "drizzle-orm"; const { db } = createDB(process.env); -const row = await db.query.agent.findFirst({ - where: eq(agent.id, agentId), +const row = await db.query.workflowRun.findFirst({ + where: eq(workflowRun.id, runId), }); ``` diff --git a/packages/db/migrations/0118_drop_launch_spec_deploy_content.sql b/packages/db/migrations/0118_drop_launch_spec_deploy_content.sql new file mode 100644 index 000000000..0f28434d5 --- /dev/null +++ b/packages/db/migrations/0118_drop_launch_spec_deploy_content.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow_run_launch_spec" DROP COLUMN "deploy_content"; \ No newline at end of file diff --git a/packages/db/migrations/meta/0118_snapshot.json b/packages/db/migrations/meta/0118_snapshot.json new file mode 100644 index 000000000..0ab87406a --- /dev/null +++ b/packages/db/migrations/meta/0118_snapshot.json @@ -0,0 +1,5007 @@ +{ + "id": "080629bc-5cbc-4177-a00b-559c2e498385", + "prevId": "097d8284-88be-4b94-80ec-ed635768d45a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_trust": { + "name": "federation_trust", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_trust_tenant_id_tenant_id_fk": { + "name": "federation_trust_tenant_id_tenant_id_fk", + "tableFrom": "federation_trust", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_trust_target_tenant_id_tenant_id_fk": { + "name": "federation_trust_target_tenant_id_tenant_id_fk", + "tableFrom": "federation_trust", + "tableTo": "tenant", + "columnsFrom": ["target_tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_trust_tenant_id_target_tenant_id_unique": { + "name": "federation_trust_tenant_id_target_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "target_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tenant_domain_lower_idx": { + "name": "tenant_domain_lower_idx", + "columns": [ + { + "expression": "lower(\"domain\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_parent_id_tenant_id_fk": { + "name": "tenant_parent_id_tenant_id_fk", + "tableFrom": "tenant", + "tableTo": "tenant", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_slug_unique": { + "name": "tenant_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": { + "tenant_slug_dns_label_check": { + "name": "tenant_slug_dns_label_check", + "value": "\"tenant\".\"slug\" ~ '^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$' and length(\"tenant\".\"slug\") <= 63" + } + }, + "isRLSEnabled": false + }, + "public.principal": { + "name": "principal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "principal_tenant_id_tenant_id_fk": { + "name": "principal_tenant_id_tenant_id_fk", + "tableFrom": "principal", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "principal_tenant_id_kind_ref_id_unique": { + "name": "principal_tenant_id_kind_ref_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "kind", "ref_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_key": { + "name": "principal_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "principal_key_one_active": { + "name": "principal_key_one_active", + "columns": [ + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"principal_key\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "principal_key_principal_id_principal_id_fk": { + "name": "principal_key_principal_id_principal_id_fk", + "tableFrom": "principal_key", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "principal_key_public_key_unique": { + "name": "principal_key_public_key_unique", + "nullsNotDistinct": false, + "columns": ["public_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_role": { + "name": "agent_role", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_role_agent_id_workflow_definition_id_fk": { + "name": "agent_role_agent_id_workflow_definition_id_fk", + "tableFrom": "agent_role", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_role_role_id_role_id_fk": { + "name": "agent_role_role_id_role_id_fk", + "tableFrom": "agent_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_role_agent_id_role_id_pk": { + "name": "agent_role_agent_id_role_id_pk", + "columns": ["agent_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_role": { + "name": "principal_role", + "schema": "", + "columns": { + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "principal_role_principal_id_principal_id_fk": { + "name": "principal_role_principal_id_principal_id_fk", + "tableFrom": "principal_role", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "principal_role_role_id_role_id_fk": { + "name": "principal_role_role_id_role_id_fk", + "tableFrom": "principal_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "principal_role_principal_id_role_id_pk": { + "name": "principal_role_principal_id_role_id_pk", + "columns": ["principal_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "role_tenant_id_tenant_id_fk": { + "name": "role_tenant_id_tenant_id_fk", + "tableFrom": "role", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grant": { + "name": "grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "grant_invoker_allow_principal_idx": { + "name": "grant_invoker_allow_principal_idx", + "columns": [ + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"grant\".\"origin\" = 'invoker' and \"grant\".\"effect\" = 'allow'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "grant_tenant_id_tenant_id_fk": { + "name": "grant_tenant_id_tenant_id_fk", + "tableFrom": "grant", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grant_role_id_role_id_fk": { + "name": "grant_role_id_role_id_fk", + "tableFrom": "grant", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grant_principal_id_principal_id_fk": { + "name": "grant_principal_id_principal_id_fk", + "tableFrom": "grant", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grant_target_exactly_one": { + "name": "grant_target_exactly_one", + "value": "num_nonnulls(\"grant\".\"principal_id\", \"grant\".\"role_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.approval": { + "name": "approval", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_definition": { + "name": "tool_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_arguments": { + "name": "tool_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_tenant_status_idx": { + "name": "approval_tenant_status_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_anchor_run_idx": { + "name": "approval_anchor_run_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_run_idx": { + "name": "approval_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_tenant_id_tenant_id_fk": { + "name": "approval_tenant_id_tenant_id_fk", + "tableFrom": "approval", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_anchor_run_id_workflow_run_id_fk": { + "name": "approval_anchor_run_id_workflow_run_id_fk", + "tableFrom": "approval", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_run_id_workflow_run_id_fk": { + "name": "approval_run_id_workflow_run_id_fk", + "tableFrom": "approval", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "approval_correlation_id_unique": { + "name": "approval_correlation_id_unique", + "nullsNotDistinct": false, + "columns": ["correlation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signal_correlation": { + "name": "signal_correlation", + "schema": "", + "columns": { + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_name": { + "name": "signal_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_id": { + "name": "signal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "signal_correlation_tenant_id_tenant_id_fk": { + "name": "signal_correlation_tenant_id_tenant_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signal_correlation_anchor_run_id_workflow_run_id_fk": { + "name": "signal_correlation_anchor_run_id_workflow_run_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signal_correlation_run_id_workflow_run_id_fk": { + "name": "signal_correlation_run_id_workflow_run_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider": { + "name": "provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_base_url": { + "name": "api_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_info_url": { + "name": "user_info_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "provider_tenant_id_tenant_id_fk": { + "name": "provider_tenant_id_tenant_id_fk", + "tableFrom": "provider", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_tenant_name": { + "name": "provider_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "default_scopes": { + "name": "default_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_tenant_id_tenant_id_fk": { + "name": "oauth_client_tenant_id_tenant_id_fk", + "tableFrom": "oauth_client", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_provider_id_provider_id_fk": { + "name": "oauth_client_provider_id_provider_id_fk", + "tableFrom": "oauth_client", + "tableTo": "provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_tenant_provider": { + "name": "oauth_client_tenant_provider", + "nullsNotDistinct": false, + "columns": ["tenant_id", "provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_version": { + "name": "secret_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "refresh_secret": { + "name": "refresh_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_tenant_id_tenant_id_fk": { + "name": "credential_tenant_id_tenant_id_fk", + "tableFrom": "credential", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_principal_id_principal_id_fk": { + "name": "credential_principal_id_principal_id_fk", + "tableFrom": "credential", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "credential_provider_id_provider_id_fk": { + "name": "credential_provider_id_provider_id_fk", + "tableFrom": "credential", + "tableTo": "provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_oauth_client_id_oauth_client_id_fk": { + "name": "credential_oauth_client_id_oauth_client_id_fk", + "tableFrom": "credential", + "tableTo": "oauth_client", + "columnsFrom": ["oauth_client_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_tenant_name": { + "name": "credential_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset": { + "name": "asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_principal_id": { + "name": "creator_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "asset_tenant_id_tenant_id_fk": { + "name": "asset_tenant_id_tenant_id_fk", + "tableFrom": "asset", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_creator_principal_id_principal_id_fk": { + "name": "asset_creator_principal_id_principal_id_fk", + "tableFrom": "asset", + "tableTo": "principal", + "columnsFrom": ["creator_principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asset_tenant_kind_name": { + "name": "asset_tenant_kind_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "kind", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "transaction_wallet_id_wallet_id_fk": { + "name": "transaction_wallet_id_wallet_id_fk", + "tableFrom": "transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_run_id_workflow_run_id_fk": { + "name": "transaction_run_id_workflow_run_id_fk", + "tableFrom": "transaction", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backend_type": { + "name": "backend_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_tenant_id_tenant_id_fk": { + "name": "wallet_tenant_id_tenant_id_fk", + "tableFrom": "wallet", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.offering": { + "name": "offering", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "offering_agent_id_workflow_definition_id_fk": { + "name": "offering_agent_id_workflow_definition_id_fk", + "tableFrom": "offering", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "offering_tenant_id_tenant_id_fk": { + "name": "offering_tenant_id_tenant_id_fk", + "tableFrom": "offering", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model": { + "name": "model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_name": { + "name": "canonical_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_tenant_id_tenant_id_fk": { + "name": "model_tenant_id_tenant_id_fk", + "tableFrom": "model", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_tenant_canonical_name": { + "name": "model_tenant_canonical_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "canonical_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_offering": { + "name": "model_offering", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deployment_tags": { + "name": "deployment_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "quirks": { + "name": "quirks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_offering_tenant_id_tenant_id_fk": { + "name": "model_offering_tenant_id_tenant_id_fk", + "tableFrom": "model_offering", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_offering_model_id_model_id_fk": { + "name": "model_offering_model_id_model_id_fk", + "tableFrom": "model_offering", + "tableTo": "model", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_offering_provider_id_model_provider_id_fk": { + "name": "model_offering_provider_id_model_provider_id_fk", + "tableFrom": "model_offering", + "tableTo": "model_provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_offering_tenant_model_provider": { + "name": "model_offering_tenant_model_provider", + "nullsNotDistinct": false, + "columns": ["tenant_id", "model_id", "provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_pricing": { + "name": "model_pricing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offering_id": { + "name": "offering_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_token_price": { + "name": "input_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output_token_price": { + "name": "output_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_read_token_price": { + "name": "cache_read_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_write_token_price": { + "name": "cache_write_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thinking_token_price": { + "name": "thinking_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_request_fee": { + "name": "per_request_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_image_fee": { + "name": "per_image_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_audio_fee": { + "name": "per_audio_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_from": { + "name": "effective_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_pricing_tenant_id_tenant_id_fk": { + "name": "model_pricing_tenant_id_tenant_id_fk", + "tableFrom": "model_pricing", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_pricing_offering_id_model_offering_id_fk": { + "name": "model_pricing_offering_id_model_offering_id_fk", + "tableFrom": "model_pricing", + "tableTo": "model_offering", + "columnsFrom": ["offering_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_pricing_offering_currency_effective_from": { + "name": "model_pricing_offering_currency_effective_from", + "nullsNotDistinct": false, + "columns": ["offering_id", "currency", "effective_from"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_provider": { + "name": "model_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_provider_tenant_id_tenant_id_fk": { + "name": "model_provider_tenant_id_tenant_id_fk", + "tableFrom": "model_provider", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_provider_credential_id_credential_id_fk": { + "name": "model_provider_credential_id_credential_id_fk", + "tableFrom": "model_provider", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "model_provider_wallet_id_wallet_id_fk": { + "name": "model_provider_wallet_id_wallet_id_fk", + "tableFrom": "model_provider", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_provider_tenant_name": { + "name": "model_provider_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": { + "model_provider_auth_xor": { + "name": "model_provider_auth_xor", + "value": "(\"model_provider\".\"credential_id\" is not null) <> (\"model_provider\".\"wallet_id\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.sidecar": { + "name": "sidecar", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "provisioner_id": { + "name": "provisioner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_api_version": { + "name": "provisioner_api_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provisioner_binding_fingerprint": { + "name": "provisioner_binding_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "adapter_manifest_digest": { + "name": "adapter_manifest_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reconnect_window_ended_at": { + "name": "reconnect_window_ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'online'" + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sidecar_token_hash_sha256_unique": { + "name": "sidecar_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sidecar_allocation": { + "name": "sidecar_allocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_id": { + "name": "provisioner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_api_version": { + "name": "provisioner_api_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provisioner_binding_fingerprint": { + "name": "provisioner_binding_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_manifest_digest": { + "name": "adapter_manifest_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ensure_accepted_generation": { + "name": "ensure_accepted_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciliation_lease_id": { + "name": "reconciliation_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconciliation_lease_expires_at": { + "name": "reconciliation_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "initialization_lease_id": { + "name": "initialization_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ensure_attempts": { + "name": "ensure_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "destroy_attempts": { + "name": "destroy_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deployment_cleanup_confirmed": { + "name": "deployment_cleanup_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_hold_released": { + "name": "provider_hold_released", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "connect_deadline": { + "name": "connect_deadline", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "max_disconnected_ms": { + "name": "max_disconnected_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "first_deploy_failed_at": { + "name": "first_deploy_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sidecar_allocation_anchor_run_idx": { + "name": "sidecar_allocation_anchor_run_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_sidecar_idx": { + "name": "sidecar_allocation_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_reconciliation_idx": { + "name": "sidecar_allocation_reconciliation_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing') and \"sidecar_allocation\".\"next_attempt_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_dispatchable_anchor_idx": { + "name": "sidecar_allocation_dispatchable_anchor_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sidecar_allocation_anchor_run_id_workflow_run_id_fk": { + "name": "sidecar_allocation_anchor_run_id_workflow_run_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sidecar_allocation_tenant_id_tenant_id_fk": { + "name": "sidecar_allocation_tenant_id_tenant_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sidecar_allocation_sidecar_id_sidecar_id_fk": { + "name": "sidecar_allocation_sidecar_id_sidecar_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sidecar_allocation_status_check": { + "name": "sidecar_allocation_status_check", + "value": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing', 'destroy_failed', 'released', 'failed')" + }, + "sidecar_allocation_generation_check": { + "name": "sidecar_allocation_generation_check", + "value": "\"sidecar_allocation\".\"generation\" >= 0" + }, + "sidecar_allocation_max_disconnected_check": { + "name": "sidecar_allocation_max_disconnected_check", + "value": "\"sidecar_allocation\".\"max_disconnected_ms\" > 0" + }, + "sidecar_allocation_accepted_generation_check": { + "name": "sidecar_allocation_accepted_generation_check", + "value": "\"sidecar_allocation\".\"ensure_accepted_generation\" is null or \"sidecar_allocation\".\"ensure_accepted_generation\" <= \"sidecar_allocation\".\"generation\"" + }, + "sidecar_allocation_initialization_check": { + "name": "sidecar_allocation_initialization_check", + "value": "\"sidecar_allocation\".\"initialization_lease_id\" is null or (\"sidecar_allocation\".\"status\" = 'allocated' and \"sidecar_allocation\".\"ensure_accepted_generation\" is not null and \"sidecar_allocation\".\"ensure_accepted_generation\" = \"sidecar_allocation\".\"generation\")" + } + }, + "isRLSEnabled": false + }, + "public.agent_session": { + "name": "agent_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_session_tenant_id_tenant_id_fk": { + "name": "agent_session_tenant_id_tenant_id_fk", + "tableFrom": "agent_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_session_agent_id_workflow_definition_id_fk": { + "name": "agent_session_agent_id_workflow_definition_id_fk", + "tableFrom": "agent_session", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "agent_session_principal_id_principal_id_fk": { + "name": "agent_session_principal_id_principal_id_fk", + "tableFrom": "agent_session", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_asset": { + "name": "session_asset", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mount_path": { + "name": "mount_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_pack_sha": { + "name": "asset_pack_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_commit_sha": { + "name": "source_commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "materialized_at": { + "name": "materialized_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_asset_pack_sha_idx": { + "name": "session_asset_pack_sha_idx", + "columns": [ + { + "expression": "asset_pack_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "session_asset_instance_id_mount_path_pk": { + "name": "session_asset_instance_id_mount_path_pk", + "columns": ["instance_id", "mount_path"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inference_turn": { + "name": "inference_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inference_turn_instance_id_started_at_idx": { + "name": "inference_turn_instance_id_started_at_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inference_turn_session_id_agent_session_id_fk": { + "name": "inference_turn_session_id_agent_session_id_fk", + "tableFrom": "inference_turn", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inference_turn_tenant_id_tenant_id_fk": { + "name": "inference_turn_tenant_id_tenant_id_fk", + "tableFrom": "inference_turn", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_mail": { + "name": "session_mail", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "raw": { + "name": "raw", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_mail_instance_id_created_at_idx": { + "name": "session_mail_instance_id_created_at_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_mail_session_id_created_at_idx": { + "name": "session_mail_session_id_created_at_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_mail_session_id_agent_session_id_fk": { + "name": "session_mail_session_id_agent_session_id_fk", + "tableFrom": "session_mail", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_mail_tenant_id_tenant_id_fk": { + "name": "session_mail_tenant_id_tenant_id_fk", + "tableFrom": "session_mail", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.turn_part": { + "name": "turn_part", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "turn_part_turn_id_inference_turn_id_fk": { + "name": "turn_part_turn_id_inference_turn_id_fk", + "tableFrom": "turn_part", + "tableTo": "inference_turn", + "columnsFrom": ["turn_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "turn_part_session_id_agent_session_id_fk": { + "name": "turn_part_session_id_agent_session_id_fk", + "tableFrom": "turn_part", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_token": { + "name": "git_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_pattern": { + "name": "ref_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "git_token_user_id_name_active_idx": { + "name": "git_token_user_id_name_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"git_token\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "git_token_tenant_id_tenant_id_fk": { + "name": "git_token_tenant_id_tenant_id_fk", + "tableFrom": "git_token", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "git_token_user_id_user_id_fk": { + "name": "git_token_user_id_user_id_fk", + "tableFrom": "git_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_token_principal_id_principal_id_fk": { + "name": "git_token_principal_id_principal_id_fk", + "tableFrom": "git_token", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_token_token_hash_sha256_unique": { + "name": "git_token_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_token": { + "name": "run_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consumer": { + "name": "consumer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "sealed_token": { + "name": "sealed_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_token_principal_consumer_idx": { + "name": "run_token_principal_consumer_idx", + "columns": [ + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "consumer", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_token_principal_id_principal_id_fk": { + "name": "run_token_principal_id_principal_id_fk", + "tableFrom": "run_token", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "run_token_token_hash_sha256_unique": { + "name": "run_token_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_definition": { + "name": "workflow_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_principal_id": { + "name": "creator_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wire_hash": { + "name": "wire_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_requirements": { + "name": "grant_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_requirements": { + "name": "model_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_bindings": { + "name": "credential_bindings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lifecycle_policy": { + "name": "lifecycle_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_version": { + "name": "current_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'deployed'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_tenant_idx": { + "name": "workflow_definition_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_definition_asset_wire_hash_idx": { + "name": "workflow_definition_asset_wire_hash_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wire_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_tenant_id_tenant_id_fk": { + "name": "workflow_definition_tenant_id_tenant_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_definition_creator_principal_id_principal_id_fk": { + "name": "workflow_definition_creator_principal_id_principal_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "principal", + "columnsFrom": ["creator_principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_definition_asset_id_asset_id_fk": { + "name": "workflow_definition_asset_id_asset_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_definition_version": { + "name": "workflow_definition_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "approved_wire_hash": { + "name": "approved_wire_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_snapshot": { + "name": "grant_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_version_definition_idx": { + "name": "workflow_definition_version_definition_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_version_definition_id_workflow_definition_id_fk": { + "name": "workflow_definition_version_definition_id_workflow_definition_id_fk", + "tableFrom": "workflow_definition_version", + "tableTo": "workflow_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_definition_version_definition_version": { + "name": "workflow_definition_version_definition_version", + "nullsNotDistinct": false, + "columns": ["definition_id", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run": { + "name": "workflow_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kernel_id": { + "name": "kernel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_preferences": { + "name": "model_preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_refs": { + "name": "credential_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lifecycle_policy": { + "name": "lifecycle_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancellation_requested_at": { + "name": "cancellation_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "operator_cancellation_requested_at": { + "name": "operator_cancellation_requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancellation_deadline": { + "name": "cancellation_deadline", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capacity_release_at": { + "name": "capacity_release_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "infrastructure_failed_at": { + "name": "infrastructure_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "legacy_mail_accept": { + "name": "legacy_mail_accept", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_definition_idx": { + "name": "workflow_run_definition_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_address_idx": { + "name": "workflow_run_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_run\".\"address\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_anchor_idx": { + "name": "workflow_run_anchor_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run\".\"id\" = \"workflow_run\".\"anchor_run_id\"", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_live_anchor_idx": { + "name": "workflow_run_live_anchor_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run\".\"id\" = \"workflow_run\".\"anchor_run_id\" and \"workflow_run\".\"status\" in ('deployed', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_infrastructure_failed_idx": { + "name": "workflow_run_infrastructure_failed_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run\".\"infrastructure_failed_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_sidecar_idx": { + "name": "workflow_run_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run\".\"sidecar_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_definition_id_workflow_definition_id_fk": { + "name": "workflow_run_definition_id_workflow_definition_id_fk", + "tableFrom": "workflow_run", + "tableTo": "workflow_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_tenant_id_tenant_id_fk": { + "name": "workflow_run_tenant_id_tenant_id_fk", + "tableFrom": "workflow_run", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_principal_id_principal_id_fk": { + "name": "workflow_run_principal_id_principal_id_fk", + "tableFrom": "workflow_run", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_run_sidecar_id_sidecar_id_fk": { + "name": "workflow_run_sidecar_id_sidecar_id_fk", + "tableFrom": "workflow_run", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_run_legacy_mail_accept_check": { + "name": "workflow_run_legacy_mail_accept_check", + "value": "\"workflow_run\".\"legacy_mail_accept\" is null or \"workflow_run\".\"legacy_mail_accept\" in ('invoker-correspondent', 'correspondent')" + } + }, + "isRLSEnabled": false + }, + "public.workflow_run_dispatch": { + "name": "workflow_run_dispatch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mail'" + }, + "sender_address": { + "name": "sender_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_message": { + "name": "raw_message", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "step_grants": { + "name": "step_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "acknowledged_generation": { + "name": "acknowledged_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "delivery_lease_id": { + "name": "delivery_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivery_lease_expires_at": { + "name": "delivery_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_dispatch_anchor_message_idx": { + "name": "workflow_run_dispatch_anchor_message_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_delivery_idx": { + "name": "workflow_run_dispatch_delivery_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run_dispatch\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_anchor_status_idx": { + "name": "workflow_run_dispatch_anchor_status_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_unresolved_anchor_idx": { + "name": "workflow_run_dispatch_unresolved_anchor_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run_dispatch\".\"status\" in ('pending', 'acknowledged') or (\"workflow_run_dispatch\".\"status\" = 'abandoned' and \"workflow_run_dispatch\".\"next_attempt_at\" is not null)", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_dispatch_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_dispatch_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_dispatch", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_run_dispatch_status_check": { + "name": "workflow_run_dispatch_status_check", + "value": "\"workflow_run_dispatch\".\"status\" in ('pending', 'acknowledged', 'abandoned', 'settled', 'failed')" + }, + "workflow_run_dispatch_kind_check": { + "name": "workflow_run_dispatch_kind_check", + "value": "\"workflow_run_dispatch\".\"kind\" in ('mail', 'signal')" + }, + "workflow_run_dispatch_attempt_count_check": { + "name": "workflow_run_dispatch_attempt_count_check", + "value": "\"workflow_run_dispatch\".\"attempt_count\" >= 0" + }, + "workflow_run_dispatch_acknowledged_generation_check": { + "name": "workflow_run_dispatch_acknowledged_generation_check", + "value": "\"workflow_run_dispatch\".\"acknowledged_generation\" is null or \"workflow_run_dispatch\".\"acknowledged_generation\" >= 0" + }, + "workflow_run_dispatch_acknowledged_state_check": { + "name": "workflow_run_dispatch_acknowledged_state_check", + "value": "\"workflow_run_dispatch\".\"status\" <> 'acknowledged' or \"workflow_run_dispatch\".\"acknowledged_generation\" is not null" + }, + "workflow_run_dispatch_pending_schedule_check": { + "name": "workflow_run_dispatch_pending_schedule_check", + "value": "\"workflow_run_dispatch\".\"status\" <> 'pending' or \"workflow_run_dispatch\".\"next_attempt_at\" is not null" + }, + "workflow_run_dispatch_mail_sender_check": { + "name": "workflow_run_dispatch_mail_sender_check", + "value": "\"workflow_run_dispatch\".\"kind\" <> 'mail' or \"workflow_run_dispatch\".\"status\" in ('abandoned', 'settled', 'failed') or \"workflow_run_dispatch\".\"sender_address\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.workflow_pending_projection": { + "name": "workflow_pending_projection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "history_required": { + "name": "history_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "workflow_pending_projection_anchor_idx": { + "name": "workflow_pending_projection_anchor_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_pending_projection_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_pending_projection_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_pending_projection", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run_launch_spec": { + "name": "workflow_run_launch_spec", + "schema": "", + "columns": { + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_domain": { + "name": "deployment_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_authority_principal_id": { + "name": "source_authority_principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frozen_approval_bundle": { + "name": "frozen_approval_bundle", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "definition_artifact_id": { + "name": "definition_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_snapshot": { + "name": "grant_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_offering_ids": { + "name": "source_offering_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_source_offering_id": { + "name": "default_source_offering_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_package_pins": { + "name": "tool_package_pins", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_run_launch_spec_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_launch_spec_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_launch_spec_source_authority_principal_id_principal_id_fk": { + "name": "workflow_run_launch_spec_source_authority_principal_id_principal_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "principal", + "columnsFrom": ["source_authority_principal_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workflow_run_launch_spec_definition_artifact_id_workflow_definition_artifact_id_fk": { + "name": "workflow_run_launch_spec_definition_artifact_id_workflow_definition_artifact_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "workflow_definition_artifact", + "columnsFrom": ["definition_artifact_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run_execution": { + "name": "workflow_run_execution", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_execution_run_id_id_idx": { + "name": "workflow_run_execution_run_id_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_execution_status_idx": { + "name": "workflow_run_execution_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_execution_run_id_workflow_run_id_fk": { + "name": "workflow_run_execution_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_execution", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_probe": { + "name": "workflow_probe", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_asset_id": { + "name": "definition_asset_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "entry": { + "name": "entry", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pin": { + "name": "pin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "provisioner_id": { + "name": "provisioner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_api_version": { + "name": "provisioner_api_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provisioner_binding_fingerprint": { + "name": "provisioner_binding_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_manifest_digest": { + "name": "adapter_manifest_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_probe_active_idx": { + "name": "workflow_probe_active_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_probe\".\"status\" in ('pending', 'provisioning', 'probing', 'releasing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_probe_sidecar_idx": { + "name": "workflow_probe_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_probe_tenant_id_tenant_id_fk": { + "name": "workflow_probe_tenant_id_tenant_id_fk", + "tableFrom": "workflow_probe", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workflow_probe_definition_asset_id_asset_id_fk": { + "name": "workflow_probe_definition_asset_id_asset_id_fk", + "tableFrom": "workflow_probe", + "tableTo": "asset", + "columnsFrom": ["definition_asset_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workflow_probe_sidecar_id_sidecar_id_fk": { + "name": "workflow_probe_sidecar_id_sidecar_id_fk", + "tableFrom": "workflow_probe", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_probe_status_check": { + "name": "workflow_probe_status_check", + "value": "\"workflow_probe\".\"status\" in ('pending', 'provisioning', 'probing', 'releasing', 'succeeded', 'failed')" + }, + "workflow_probe_succeeded_result_check": { + "name": "workflow_probe_succeeded_result_check", + "value": "\"workflow_probe\".\"status\" <> 'succeeded' or \"workflow_probe\".\"result\" is not null" + }, + "workflow_probe_generation_check": { + "name": "workflow_probe_generation_check", + "value": "\"workflow_probe\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_definition_artifact": { + "name": "workflow_definition_artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revision_id": { + "name": "revision_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_artifact_revision_idx": { + "name": "workflow_definition_artifact_revision_idx", + "columns": [ + { + "expression": "revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_artifact_revision_id_workflow_definition_revision_id_fk": { + "name": "workflow_definition_artifact_revision_id_workflow_definition_revision_id_fk", + "tableFrom": "workflow_definition_artifact", + "tableTo": "workflow_definition_revision", + "columnsFrom": ["revision_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_definition_artifact_state_check": { + "name": "workflow_definition_artifact_state_check", + "value": "(\"workflow_definition_artifact\".\"result\" is null and \"workflow_definition_artifact\".\"lease_owner\" is not null and \"workflow_definition_artifact\".\"lease_expires_at\" is not null) or (\"workflow_definition_artifact\".\"result\" is not null and \"workflow_definition_artifact\".\"lease_owner\" is null and \"workflow_definition_artifact\".\"lease_expires_at\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.workflow_definition_revision": { + "name": "workflow_definition_revision", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_commit_sha": { + "name": "asset_commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "entry": { + "name": "entry", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "closure": { + "name": "closure", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_definition_revision_tenant_id_tenant_id_fk": { + "name": "workflow_definition_revision_tenant_id_tenant_id_fk", + "tableFrom": "workflow_definition_revision", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "workflow_definition_revision_asset_id_asset_id_fk": { + "name": "workflow_definition_revision_asset_id_asset_id_fk", + "tableFrom": "workflow_definition_revision", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 834b7001f..e68972bd5 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -820,6 +820,13 @@ "when": 1793571455962, "tag": "0117_grant_invoker_allow_principal_index", "breakpoints": true + }, + { + "idx": 118, + "version": "7", + "when": 1793729632421, + "tag": "0118_drop_launch_spec_deploy_content", + "breakpoints": true } ] } diff --git a/packages/db/src/parse-row.test.ts b/packages/db/src/parse-row.test.ts index df92c1add..5a7689676 100644 --- a/packages/db/src/parse-row.test.ts +++ b/packages/db/src/parse-row.test.ts @@ -34,7 +34,7 @@ function makeRow(overrides: Partial = {}): GitTokenRow { name: "laptop", kind: "pat", tokenHashSha256: new Uint8Array(32), - resource: "agent-state:run_test", + resource: "asset:ast_test", refPattern: "refs/heads/*", actions: ["receivePack", "createPack", "resolveRef"], expiresAt: new Date("2027-01-01T00:00:00Z"), @@ -57,7 +57,7 @@ describe("parseGitTokenRow", () => { expect(parsed.kind).toBe("pat"); expect(parsed.tokenHashSha256).toBe(row.tokenHashSha256); expect(parsed.actions).toEqual(["receivePack", "createPack", "resolveRef"]); - expect(parsed.resource).toBe("agent-state:run_test"); + expect(parsed.resource).toBe("asset:ast_test"); expect(parsed.refPattern).toBe("refs/heads/*"); expect(parsed.expiresAt).toBe(row.expiresAt); expect(parsed.revokedAt).toBeNull(); diff --git a/packages/db/src/parse-row.ts b/packages/db/src/parse-row.ts index d56158161..ea0480f39 100644 --- a/packages/db/src/parse-row.ts +++ b/packages/db/src/parse-row.ts @@ -248,7 +248,6 @@ export function parseWorkflowRunLaunchSpecRow( ? null : GrantWalkSnapshot.assert(row.grantSnapshot), sourceOfferingIds, - deployContent: JSONObject.assert(row.deployContent), toolPackagePins: row.toolPackagePins !== null ? ToolPackagePinArray.assert(row.toolPackagePins) diff --git a/packages/db/src/schema/git-tokens.ts b/packages/db/src/schema/git-tokens.ts index 86973f9d3..c62a34130 100644 --- a/packages/db/src/schema/git-tokens.ts +++ b/packages/db/src/schema/git-tokens.ts @@ -26,7 +26,7 @@ import { tenant } from "./tenants"; // (`receivePack`, `createPack`, `resolveRef`, ...). The mint API // translates user-facing aliases to canonical names before insert. // `resource` is the single substrate authz resource string -// (e.g. `agent-state:run_xxx`, `asset:ast_yyy`) that the token grants +// (e.g. `asset:ast_yyy`) that the token grants // access to. `refPattern` is a glob restricting which refs within the // resource the token may read or write. // diff --git a/packages/db/src/schema/session-assets.ts b/packages/db/src/schema/session-assets.ts index cff8072a2..0f0b4dec9 100644 --- a/packages/db/src/schema/session-assets.ts +++ b/packages/db/src/schema/session-assets.ts @@ -7,39 +7,20 @@ import { } from "drizzle-orm/pg-core"; // session_asset rows record per-(instance, materialization) pack -// acknowledgments. A row exists iff the sidecar acked the pack that -// materialized that asset for that instance. The launchSession flow -// inserts each row before the corresponding pack send and rolls it -// back if that single send fails, so each row reflects an ack the -// hub actually observed for that attachment. +// acknowledgments from past launches: a row exists iff the sidecar acked the +// pack that materialized that asset for that instance. Nothing writes this +// table now; it only holds those rows. // -// Assets reach an instance through the tool-package resolver: it picks -// package-registry assets out of the tenant-visible set, and the launch -// fan-out materializes each into the workspace. The mount path and -// source commit are recorded so forensic queries can trace every -// materialization. -// -// Caveat on multi-attachment partial-success: when a session attaches -// N assets and the fan-out succeeds for attachments 1..k-1 and fails -// on attachment k, only attachment k's row is rolled back. Rows -// 1..k-1 stay. The session as a whole never reached the running -// state -- attemptCleanup runs sendAgentUndeploy -- but the per- -// attachment ack invariant holds for the rows that remain. -// Forensics queries should treat row count as "packs the sidecar -// acked during this instance's launch," not "sessions that ran with -// assets." Pairing with agent_instance.status (or its successor -// session-lifecycle signal) is necessary to distinguish a row left -// behind by a partially-successful failed launch from one belonging -// to a fully-running session. +// The mount path and source commit trace each materialization. A launch that +// failed partway kept the rows of the attachments acked before the failure, so +// treat row count as "packs the sidecar acked during this instance's launch," +// not "sessions that ran with assets." export const sessionAsset = pgTable( "session_asset", { - // The endpoint this materialization is for: a legacy agent_instance id or a - // folded workflow_run id, from one shared id space. A polymorphic reference - // carrying no foreign key (mirroring inference_turn.runId); the launch - // layer owns the invariant. NOT NULL -- a materialization always names its - // endpoint -- and part of the (instance_id, mount_path) key. A folded run - // writes its run id here, which the dropped agent_instance FK would reject. + // The endpoint this materialization was for: a legacy agent_instance id or + // a folded workflow_run id, from one shared id space. A polymorphic + // reference carrying no foreign key (mirroring inference_turn.runId). runId: text("instance_id").notNull(), mountPath: text("mount_path").notNull(), assetPackSha: text("asset_pack_sha").notNull(), diff --git a/packages/db/src/schema/workflow-run-launch-spec.ts b/packages/db/src/schema/workflow-run-launch-spec.ts index d0b417a0a..b9e206114 100644 --- a/packages/db/src/schema/workflow-run-launch-spec.ts +++ b/packages/db/src/schema/workflow-run-launch-spec.ts @@ -27,7 +27,6 @@ export const workflowRunLaunchSpec = pgTable("workflow_run_launch_spec", { grantSnapshot: jsonb("grant_snapshot"), sourceOfferingIds: jsonb("source_offering_ids").notNull(), defaultSourceOfferingId: text("default_source_offering_id").notNull(), - deployContent: jsonb("deploy_content").notNull(), toolPackagePins: jsonb("tool_package_pins"), createdAt: timestamp("created_at").notNull().defaultNow(), }); diff --git a/packages/hub-agent/README.md b/packages/hub-agent/README.md index 07c8dc64c..1e2df5411 100644 --- a/packages/hub-agent/README.md +++ b/packages/hub-agent/README.md @@ -3,16 +3,13 @@ Sidecar-side orchestrator. Wires the package-side pieces of the sidecar runtime — the per-agent key and repo stores, a session manager, and the hub WebSocket link — into a single start/close -handle, applies deploy and asset packs received from the hub, and -forwards a spawned child's verified inference events back to the hub. +handle, and forwards a spawned child's verified inference events back +to the hub. In-process harness construction and agent provisioning are retired: every agent now runs as a supervised workflow-process child on the -workflow-run substrate. What remains in `createSessionManager` is a -thin serialization layer over the agent repo store (deploy/asset-pack -applies and directory teardown); -operations run one at a time per agent so a teardown never races an -in-flight git op. +workflow-run substrate. What remains in `createSessionManager` is +directory teardown over the agent repo store. Consumed by `apps/sidecar` as the orchestrator that turns a sidecar process into a host for hub-deployed agents. diff --git a/packages/hub-agent/package.json b/packages/hub-agent/package.json index 111cc282b..20e922aa2 100644 --- a/packages/hub-agent/package.json +++ b/packages/hub-agent/package.json @@ -9,11 +9,6 @@ "intx-src": "./src/index.ts", "types": "./dist/index.d.ts", "default": "./dist/index.js" - }, - "./paths": { - "intx-src": "./src/paths.ts", - "types": "./dist/paths.d.ts", - "default": "./dist/paths.js" } }, "dependencies": { diff --git a/packages/hub-agent/src/agent-key-store.test.ts b/packages/hub-agent/src/agent-key-store.test.ts index 891c9e588..f727610a9 100644 --- a/packages/hub-agent/src/agent-key-store.test.ts +++ b/packages/hub-agent/src/agent-key-store.test.ts @@ -31,14 +31,6 @@ function makeKeyPair(seed: number): KeyPair { return { privateKey, publicKey }; } -const stubCrypto = { - verifySSHSig: async ( - _payload: string, - _signature: string, - _publicKey: Uint8Array, - ) => true, -}; - describe("AgentKeyStore — load-or-generate", () => { test("first call mints and persists a new keypair", async () => { const dataDir = await tempDir(); @@ -49,7 +41,6 @@ describe("AgentKeyStore — load-or-generate", () => { calls++; return makeKeyPair(7); }, - ...stubCrypto, }); const result = await store.loadOrGenerateKey("agent@local"); @@ -71,7 +62,6 @@ describe("AgentKeyStore — load-or-generate", () => { calls++; return makeKeyPair(42); }, - ...stubCrypto, }); const first = await store.loadOrGenerateKey("agent@local"); @@ -89,7 +79,6 @@ describe("AgentKeyStore — load-or-generate", () => { const store = createAgentKeyStore({ dataDir, generateKeyPair: async () => makeKeyPair(1), - ...stubCrypto, }); await store.loadOrGenerateKey("agent@local"); diff --git a/packages/hub-agent/src/agent-key-store.ts b/packages/hub-agent/src/agent-key-store.ts index 80ef13c2f..7ffcd5c5b 100644 --- a/packages/hub-agent/src/agent-key-store.ts +++ b/packages/hub-agent/src/agent-key-store.ts @@ -1,19 +1,11 @@ // Per-agent Ed25519 key custody. // // Persists key pairs as raw 32-byte binary files on disk and produces -// them on demand. The cryptographic primitives (keypair generation, -// deploy-commit verification) are host-supplied so the package does +// them on demand. Keypair generation is host-supplied so the package does // not pin a particular crypto backend. -// -// In addition to the on-disk persistence, the store keeps an in-memory -// cache of the keypair and the paired hub public key for every agent -// that has been loaded or recorded during the process lifetime. The -// cache backs the per-frame crypto operations the wire layer needs: -// verifyDeployCommit for incoming deploy packs. import fsp from "node:fs/promises"; import { hasCode } from "@intx/types/has-code"; -import { hexDecode } from "@intx/types/hex"; import type { KeyPair } from "@intx/types/runtime"; import { keysDir, privateKeyPath, publicKeyPath } from "./agent-paths"; @@ -21,53 +13,20 @@ import { keysDir, privateKeyPath, publicKeyPath } from "./agent-paths"; export type AgentKeyStoreDeps = { dataDir: string; generateKeyPair: () => Promise; - /** - * Verify an SSH signature block against the supplied public key. - * Used by verifyDeployCommit. - */ - verifySSHSig: ( - payload: string, - signature: string, - publicKey: Uint8Array, - ) => Promise; }; export type AgentKeyStore = { /** * Load the existing keypair for an agent, or mint and persist a new - * one. The keypair is also cached in memory. The `isNew` flag is true - * when the keypair was just generated. + * one. The `isNew` flag is true when the keypair was just generated. */ loadOrGenerateKey( address: string, ): Promise<{ keyPair: KeyPair; isNew: boolean }>; - /** - * Record the hub public key the agent has been paired with. Cached in - * memory only; the deploy path re-records it on every deploy, so it - * does not need to survive a restart. - */ - recordHubKey(address: string, hexHubPublicKey: string): void; - /** - * Verify an SSH signature against the cached hub public key for the - * given address. Throws when no hub key is cached — a deploy pack - * cannot be verified without one. - */ - verifyDeployCommit( - address: string, - payload: string, - signature: string, - ): Promise; - /** - * Drop the in-memory caches for an agent. Called on undeploy. - */ - forgetAgent(address: string): void; }; export function createAgentKeyStore(deps: AgentKeyStoreDeps): AgentKeyStore { - const { dataDir, generateKeyPair, verifySSHSig } = deps; - - const agentKeys = new Map(); - const hubKeys = new Map(); + const { dataDir, generateKeyPair } = deps; async function loadKeyFromDisk(address: string): Promise { const privPath = privateKeyPath(dataDir, address); @@ -95,7 +54,6 @@ export function createAgentKeyStore(deps: AgentKeyStoreDeps): AgentKeyStore { privateKey: new Uint8Array(privateKey), publicKey: new Uint8Array(publicKey), }; - agentKeys.set(address, keyPair); return keyPair; } @@ -113,38 +71,11 @@ export function createAgentKeyStore(deps: AgentKeyStoreDeps): AgentKeyStore { }), fsp.writeFile(publicKeyPath(dataDir, address), keyPair.publicKey), ]); - agentKeys.set(address, keyPair); return { keyPair, isNew: true }; } - function recordHubKey(address: string, hexHubPublicKey: string): void { - hubKeys.set(address, hexDecode(hexHubPublicKey)); - } - - async function verifyDeployCommit( - address: string, - payload: string, - signature: string, - ): Promise { - const hubKey = hubKeys.get(address); - if (hubKey === undefined) { - throw new Error( - `signature_invalid: no hub public key recorded for "${address}"`, - ); - } - return verifySSHSig(payload, signature, hubKey); - } - - function forgetAgent(address: string): void { - agentKeys.delete(address); - hubKeys.delete(address); - } - return { loadOrGenerateKey, - recordHubKey, - verifyDeployCommit, - forgetAgent, }; } diff --git a/packages/hub-agent/src/agent-repo-store.test.ts b/packages/hub-agent/src/agent-repo-store.test.ts index 33e5ec77b..44913f07e 100644 --- a/packages/hub-agent/src/agent-repo-store.test.ts +++ b/packages/hub-agent/src/agent-repo-store.test.ts @@ -9,7 +9,7 @@ describe("AgentRepoStore.remove", () => { test("removes an agent directory and treats a missing one as removed", async () => { const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "agent-repo-")); const store = createAgentRepoStore({ dataDir }); - await store.initRepo("agent@local.test"); + await fs.mkdir(store.getAgentDir("agent@local.test"), { recursive: true }); await store.remove("agent@local.test"); await store.remove("agent@local.test"); @@ -25,7 +25,7 @@ describe("AgentRepoStore.remove", () => { ); const store = createAgentRepoStore({ dataDir }); const address = "agent@local.test"; - await store.initRepo(address); + await fs.mkdir(store.getAgentDir(address), { recursive: true }); const parent = path.dirname(store.getAgentDir(address)); const open = fs.open; let syncAttempts = 0; diff --git a/packages/hub-agent/src/agent-repo-store.ts b/packages/hub-agent/src/agent-repo-store.ts index ae328b1f2..91bb9b502 100644 --- a/packages/hub-agent/src/agent-repo-store.ts +++ b/packages/hub-agent/src/agent-repo-store.ts @@ -1,31 +1,17 @@ -// Per-agent on-disk repository layout. +// Per-agent on-disk directory layout. // -// Owns the isogit repo wrapper and the deploy-pack apply flow. Key custody -// lives in AgentKeyStore alongside this store; both share the -// directory-layout helpers in agent-paths. +// Owns the removal of an agent's directory. Key custody lives in +// AgentKeyStore alongside this store; both share the directory-layout +// helpers in agent-paths. import fsp from "node:fs/promises"; import { getLogger } from "@intx/log"; -import { - initAgentRepo, - applyPack, - syncRemovedPathDurable, - type CommitVerifier, -} from "@intx/storage-isogit/node"; +import { syncRemovedPathDurable } from "@intx/storage-isogit/node"; import { agentDir } from "./agent-paths"; const logger = getLogger(["interchange", "hub-agent", "repo-store"]); -export type ApplyDeployPackArgs = { - address: string; - pack: Uint8Array; - ref: string; - commitSha: string; - transferId: string; - verifyCommit?: CommitVerifier; -}; - export type AgentRepoStore = { /** * Resolve the on-disk directory for an agent. Exposed for integration @@ -33,8 +19,6 @@ export type AgentRepoStore = { * the (intentionally opaque) directory naming scheme. */ getAgentDir(address: string): string; - initRepo(address: string): Promise; - applyDeployPack(args: ApplyDeployPackArgs): Promise; remove(address: string): Promise; }; @@ -47,23 +31,6 @@ export function createAgentRepoStore(config: { return agentDir(dataDir, address); } - async function initRepo(address: string): Promise { - await initAgentRepo(getAgentDir(address)); - } - - async function applyDeployPackImpl(args: ApplyDeployPackArgs): Promise { - const { address, pack, ref, commitSha, transferId, verifyCommit } = args; - await applyPack( - getAgentDir(address), - pack, - ref, - commitSha, - transferId, - verifyCommit, - ); - logger.info`Applied deploy pack for ${address} at ${commitSha.slice(0, 8)}`; - } - async function remove(address: string): Promise { await fsp.rm(getAgentDir(address), { recursive: true, force: true }); await syncRemovedPathDurable(getAgentDir(address)); @@ -72,8 +39,6 @@ export function createAgentRepoStore(config: { return { getAgentDir, - initRepo, - applyDeployPack: applyDeployPackImpl, remove, }; } diff --git a/packages/hub-agent/src/apply-asset-pack.ts b/packages/hub-agent/src/apply-asset-pack.ts index f0751107d..d08163c99 100644 --- a/packages/hub-agent/src/apply-asset-pack.ts +++ b/packages/hub-agent/src/apply-asset-pack.ts @@ -1,18 +1,13 @@ // Materialize an asset pack as plain files under a workspace mount path. // -// Asset packs are git packfiles produced by the hub for assets attached -// to an agent (today only `skill`). The sidecar receives the pack at -// session start and writes its tree contents under -// `//`. The workspace is plain files, not a -// git working tree — asset packs must not share the agent's deploy -// `.git/` (separate ref namespaces, separate object lifecycles), so we -// index the pack against a scratch git directory and copy the tree out. +// Asset packs are git packfiles produced by the hub for the assets a +// workflow source mounts. The sidecar receives them on the deploy frame and +// writes their tree contents under `//`. The +// workspace is plain files, not a git working tree, so we index the pack +// against a scratch git directory and copy the tree out. // -// Asset packs in v1 are unsigned: they originate from the hub itself -// (synthetic content authored via the asset service) and are validated -// by the kind handler's `validatePush` on the hub-side write path. -// The cryptographic signature scheme that `applyDeployPack` enforces -// does not apply. +// Asset packs are unsigned: they originate from the hub itself and are +// validated by the kind handler's `validatePush` on the hub-side write path. import fs from "node:fs"; import fsp from "node:fs/promises"; diff --git a/packages/hub-agent/src/deploy-tree.test.ts b/packages/hub-agent/src/deploy-tree.test.ts deleted file mode 100644 index ce158f696..000000000 --- a/packages/hub-agent/src/deploy-tree.test.ts +++ /dev/null @@ -1,146 +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 { readDeployTree } from "./deploy-tree"; - -const tempDirs: string[] = []; - -async function tempDir(): Promise { - const d = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "deploy-tree-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("readDeployTree", () => { - test("returns undefined prompt when no deploy dir exists", async () => { - const dir = await tempDir(); - const result = await readDeployTree(dir); - expect(result.systemPrompt).toBeUndefined(); - }); - - test("reads prompt.md from deploy directory", async () => { - const dir = await tempDir(); - await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); - await fs.promises.writeFile( - path.join(dir, "deploy", "prompt.md"), - "You are a test agent.", - ); - - const result = await readDeployTree(dir); - expect(result.systemPrompt).toBe("You are a test agent."); - }); - - test("treats empty prompt.md as undefined", async () => { - const dir = await tempDir(); - await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); - await fs.promises.writeFile(path.join(dir, "deploy", "prompt.md"), ""); - - const result = await readDeployTree(dir); - expect(result.systemPrompt).toBeUndefined(); - }); - - test("returns undefined toolPackageManifestRaw when no manifest file exists", async () => { - const dir = await tempDir(); - const result = await readDeployTree(dir); - expect(result.toolPackageManifestRaw).toBeUndefined(); - }); - - test("returns tool-packages-manifest.json bytes as a raw string", async () => { - const dir = await tempDir(); - await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); - // The fixture is shaped to round-trip through the - // ToolPackageManifest arktype validator (post-INTR-108: asset - // sources carry both `assetId` and `path`). The reader itself - // does no validation, but a fixture that the schema would reject - // is a misleading example for future readers. - const manifestBytes = JSON.stringify({ - schemaVersion: "1", - topLevel: [{ name: "@intx/tools-posix", version: "1.2.3" }], - entries: [ - { - name: "@intx/tools-posix", - version: "1.2.3", - integrity: "sha512-AAAA", - source: { - kind: "asset", - assetId: "ast_x", - path: "packages/intx-tools-posix-1.2.3.tgz", - }, - }, - ], - }); - await fs.promises.writeFile( - path.join(dir, "deploy", "tool-packages-manifest.json"), - manifestBytes, - ); - const result = await readDeployTree(dir); - expect(result.toolPackageManifestRaw).toBe(manifestBytes); - }); - - test("returns an empty assetMounts map when no asset-mounts.json file exists", async () => { - const dir = await tempDir(); - const result = await readDeployTree(dir); - expect(result.assetMounts.size).toBe(0); - }); - - test("parses asset-mounts.json into a ReadonlyMap of assetId to mount path", async () => { - const dir = await tempDir(); - await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); - await fs.promises.writeFile( - path.join(dir, "deploy", "asset-mounts.json"), - JSON.stringify({ - assetMounts: { - ast_a: "skills/skill-a", - ast_b: "packages/builtins", - }, - }), - ); - const result = await readDeployTree(dir); - expect(result.assetMounts.size).toBe(2); - expect(result.assetMounts.get("ast_a")).toBe("skills/skill-a"); - expect(result.assetMounts.get("ast_b")).toBe("packages/builtins"); - }); - - test("throws when asset-mounts.json fails schema validation", async () => { - const dir = await tempDir(); - await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); - await fs.promises.writeFile( - path.join(dir, "deploy", "asset-mounts.json"), - JSON.stringify({ assetMounts: { ast_a: 42 } }), - ); - let caught: unknown = null; - try { - await readDeployTree(dir); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(Error); - const msg = caught instanceof Error ? caught.message : String(caught); - expect(msg).toContain("asset-mounts.json failed validation"); - }); - - test("returns corrupt-JSON bytes as-is for the caller to handle", async () => { - // The reader does not parse; the caller's loader gate produces - // the manifest.invalid failure the same apply-rejection way as - // every other category. - const dir = await tempDir(); - await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); - const garbage = "{this is not valid json"; - await fs.promises.writeFile( - path.join(dir, "deploy", "tool-packages-manifest.json"), - garbage, - ); - const result = await readDeployTree(dir); - expect(result.toolPackageManifestRaw).toBe(garbage); - }); -}); diff --git a/packages/hub-agent/src/deploy-tree.ts b/packages/hub-agent/src/deploy-tree.ts deleted file mode 100644 index 3c2aedc9b..000000000 --- a/packages/hub-agent/src/deploy-tree.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Deploy tree reader: extracts the system prompt, the tool-package -// manifest, and the asset-mounts map from the deploy directory of an -// agent's git repository. -// -// The deploy tree is written by `applyDeployPack` and contains: -// deploy/prompt.md — system prompt for inference -// deploy/tool-packages-manifest.json — optional, full pinned closure -// of NPM-distributed tool -// packages -// deploy/asset-mounts.json — optional, assetId → mount -// path map covering every -// `kind: "asset"` entry in -// the manifest - -import fs from "node:fs"; -import path from "node:path"; -import { type } from "arktype"; - -import { hasCode } from "@intx/types/has-code"; - -const AssetMountsFile = type({ - assetMounts: type({ "[string]": "string" }), -}); - -export type DeployTree = { - systemPrompt: string | undefined; - /** - * Raw, un-parsed bytes of `deploy/tool-packages-manifest.json`. - * JSON parsing and arktype validation are the caller's - * responsibility — the sidecar's harness builder does both inside - * `materializeToolPackages` so a corrupt or schema-invalid manifest - * fails the apply loudly (category `manifest.invalid`) the same way - * as every other apply-time failure. - * - * Undefined means the manifest file is not present. - */ - toolPackageManifestRaw: string | undefined; - /** - * Parsed `deploy/asset-mounts.json`, validated by arktype. The map - * is empty when the file is absent — that is the legitimate shape - * for a deploy with no asset-sourced tool packages, and the loader's - * own gating raises if a manifest entry asks for a missing assetId. - */ - assetMounts: ReadonlyMap; -}; - -/** - * Read the system prompt and tool-package manifest bytes from the - * deploy directory. Each field is independently optional — undefined - * means the corresponding file is not present in the materialized - * deploy. An agent that has not yet received a deploy pack returns - * both as undefined. - * - * No parsing or validation of the manifest happens here; the caller - * runs both inside the loader boundary so parse errors and schema - * errors land on the same failure path. - */ -export async function readDeployTree(dir: string): Promise { - const promptPath = path.join(dir, "deploy", "prompt.md"); - const manifestPath = path.join(dir, "deploy", "tool-packages-manifest.json"); - const assetMountsPath = path.join(dir, "deploy", "asset-mounts.json"); - - let systemPrompt: string | undefined; - try { - const raw = await fs.promises.readFile(promptPath, "utf-8"); - systemPrompt = raw.trim() === "" ? undefined : raw; - } catch (e) { - if (hasCode(e) && e.code === "ENOENT") { - systemPrompt = undefined; - } else { - throw e; - } - } - - let toolPackageManifestRaw: string | undefined; - try { - toolPackageManifestRaw = await fs.promises.readFile(manifestPath, "utf-8"); - } catch (e) { - if (hasCode(e) && e.code === "ENOENT") { - toolPackageManifestRaw = undefined; - } else { - throw e; - } - } - - let assetMounts: ReadonlyMap = new Map(); - try { - const raw = await fs.promises.readFile(assetMountsPath, "utf-8"); - const parsed: unknown = JSON.parse(raw); - const validated = AssetMountsFile(parsed); - if (validated instanceof type.errors) { - throw new Error( - `deploy/asset-mounts.json failed validation: ${validated.summary}`, - ); - } - assetMounts = new Map(Object.entries(validated.assetMounts)); - } catch (e) { - if (hasCode(e) && e.code === "ENOENT") { - assetMounts = new Map(); - } else { - throw e; - } - } - - return { systemPrompt, toolPackageManifestRaw, assetMounts }; -} diff --git a/packages/hub-agent/src/index.ts b/packages/hub-agent/src/index.ts index 168f4f0f5..9fb50cc40 100644 --- a/packages/hub-agent/src/index.ts +++ b/packages/hub-agent/src/index.ts @@ -1,8 +1,4 @@ -export { - createAgentRepoStore, - type AgentRepoStore, - type ApplyDeployPackArgs, -} from "./agent-repo-store"; +export { createAgentRepoStore, type AgentRepoStore } from "./agent-repo-store"; export { createAgentKeyStore, type AgentKeyStore, @@ -63,5 +59,3 @@ export { sourceAssetGitDir, MAX_INLINE_ASSET_PAYLOAD_BYTES, } from "./source-asset-delivery"; -export { readDeployTree, type DeployTree } from "./deploy-tree"; -export { agentDir, sanitizeAddress } from "./agent-paths"; diff --git a/packages/hub-agent/src/paths.ts b/packages/hub-agent/src/paths.ts deleted file mode 100644 index d0a313ae7..000000000 --- a/packages/hub-agent/src/paths.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Dependency-light entry for an agent's on-disk layout helpers. -// -// `@intx/hub-agent` is the sidecar orchestrator. Importing its barrel -// (`index.ts`) evaluates the orchestrator's own module graph -- the session -// manager, the hub-link WebSocket layer, the sidecar orchestrator -- plus -// `@intx/pack-transport`. A few consumers need only the small filesystem -// helpers for an agent's on-disk deploy state -- reading the deploy tree -// under an agent's directory and deriving that directory's name from the -// run address. For the spawned workflow-child loading the orchestrator to -// get them is both wasted module-evaluation and a backwards dependency on the -// very component that launches it. Neither helper's module imports the -// orchestrator graph, so this entry exposes them without it. - -export { readDeployTree, type DeployTree } from "./deploy-tree"; -export { agentDir } from "./agent-paths"; diff --git a/packages/hub-agent/src/session-manager.test.ts b/packages/hub-agent/src/session-manager.test.ts deleted file mode 100644 index 5f156e25e..000000000 --- a/packages/hub-agent/src/session-manager.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, test, expect, afterEach } from "bun:test"; -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import path from "node:path"; -import os from "node:os"; -import git from "isomorphic-git"; - -import { createSessionManager } from "./session-manager"; -import type { AgentRepoStore } from "./agent-repo-store"; - -const tempDirs: string[] = []; - -async function tempDir(): Promise { - const d = await fsp.mkdtemp(path.join(os.tmpdir(), "session-manager-test-")); - tempDirs.push(d); - return d; -} - -afterEach(async () => { - const dirs = tempDirs.splice(0); - await Promise.all( - dirs.map((d) => fsp.rm(d, { recursive: true, force: true })), - ); -}); - -function makeStubRepoStore(opts: { - dataDir: string; - applyDeployPack: AgentRepoStore["applyDeployPack"]; - remove: AgentRepoStore["remove"]; -}): AgentRepoStore { - return { - getAgentDir: (address) => path.join(opts.dataDir, address), - initRepo: () => { - throw new Error("initRepo is not exercised by this test"); - }, - applyDeployPack: opts.applyDeployPack, - remove: opts.remove, - }; -} - -function applyEmptyDeployPack( - manager: ReturnType, -): Promise { - return manager.applyDeployPack( - "agent@local", - new Uint8Array(), - "refs/heads/main", - "0".repeat(40), - "transfer-1", - ); -} - -function makeManagerWithRepoStore( - repoStore: AgentRepoStore, -): ReturnType { - return createSessionManager({ repoStore }); -} - -async function buildAssetPack( - files: Record, -): Promise<{ pack: Uint8Array; commitSha: string }> { - const sourceDir = await tempDir(); - await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); - - for (const [rel, content] of Object.entries(files)) { - const abs = path.join(sourceDir, rel); - await fsp.mkdir(path.dirname(abs), { recursive: true }); - await fsp.writeFile(abs, content); - await git.add({ fs, dir: sourceDir, filepath: rel }); - } - - const commitSha = await git.commit({ - fs, - dir: sourceDir, - message: "asset", - author: { name: "test", email: "test@test.dev" }, - }); - - const oids = new Set([commitSha]); - const { commit } = await git.readCommit({ - fs, - dir: sourceDir, - oid: commitSha, - }); - oids.add(commit.tree); - async function walkTree(treeOid: string): Promise { - const { tree } = await git.readTree({ fs, 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, - dir: sourceDir, - oids: [...oids], - write: false, - }); - if (result.packfile === undefined) { - throw new Error("packObjects produced no packfile"); - } - return { pack: result.packfile, commitSha }; -} - -describe("SessionManager.applyAssetPack", () => { - test("materializes the pack under /workspace//", async () => { - const dataDir = await tempDir(); - const { pack, commitSha } = await buildAssetPack({ - "greet/SKILL.md": "---\nname: greet\n---\nbody\n", - }); - - // The wrapper's only logic over `applyAssetPackFn` is the workspace-root - // composition: `/workspace`. Prove the pack lands there. - const repoStore = makeStubRepoStore({ - dataDir, - applyDeployPack: () => - Promise.reject(new Error("applyDeployPack not exercised by this test")), - remove: () => - Promise.reject(new Error("remove not exercised by this test")), - }); - const manager = makeManagerWithRepoStore(repoStore); - - await manager.applyAssetPack( - "agent@local", - "skills/example/", - pack, - "refs/heads/main", - commitSha, - ); - - const materialized = path.join( - dataDir, - "agent@local", - "workspace", - "skills/example", - "greet/SKILL.md", - ); - expect(fs.existsSync(materialized)).toBe(true); - }); -}); - -describe("SessionManager repo-operation serialization", () => { - test("deleteAgentDir removes the directory only after an in-flight deploy-pack apply completes", async () => { - const dataDir = await tempDir(); - const events: string[] = []; - - let releaseApply!: () => void; - const applyGate = new Promise((resolve) => { - releaseApply = resolve; - }); - - const repoStore = makeStubRepoStore({ - dataDir, - async applyDeployPack() { - events.push("applyDeployPack:start"); - await applyGate; - events.push("applyDeployPack:end"); - }, - async remove() { - events.push("remove"); - }, - }); - - const manager = makeManagerWithRepoStore(repoStore); - - const rejections: unknown[] = []; - const onRejection = (reason: unknown) => rejections.push(reason); - process.on("unhandledRejection", onRejection); - try { - const applied = applyEmptyDeployPack(manager); - const deletion = manager.deleteAgentDir("agent@local"); - - // Let the apply enter its gate and the deletion reach its drain await. - // With the gate still closed, the removal must not run. - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(events).toEqual(["applyDeployPack:start"]); - - releaseApply(); - await Promise.all([applied, deletion]); - - expect(events).toEqual([ - "applyDeployPack:start", - "applyDeployPack:end", - "remove", - ]); - } finally { - process.off("unhandledRejection", onRejection); - } - - // A pending unhandled rejection surfaces on the next macrotask. - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(rejections).toEqual([]); - }); - - test("a rejecting repo operation propagates to its caller without poisoning the chain", async () => { - const dataDir = await tempDir(); - let calls = 0; - - const repoStore = makeStubRepoStore({ - dataDir, - async applyDeployPack() { - calls += 1; - if (calls === 1) { - throw new Error("deploy pack boom"); - } - }, - async remove() { - /* unused in this test */ - }, - }); - - const manager = makeManagerWithRepoStore(repoStore); - - const rejections: unknown[] = []; - const onRejection = (reason: unknown) => rejections.push(reason); - process.on("unhandledRejection", onRejection); - try { - // The failing op's rejection reaches its own caller. - await expect(applyEmptyDeployPack(manager)).rejects.toThrow( - "deploy pack boom", - ); - - // The chain is not poisoned: the next op runs and resolves normally. - await applyEmptyDeployPack(manager); - expect(calls).toBe(2); - } finally { - process.off("unhandledRejection", onRejection); - } - - // The rejection-swallowing tail must not surface as an unhandled - // rejection on the next macrotask. - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(rejections).toEqual([]); - }); -}); diff --git a/packages/hub-agent/src/session-manager.ts b/packages/hub-agent/src/session-manager.ts index 38e1d9b02..779bed7db 100644 --- a/packages/hub-agent/src/session-manager.ts +++ b/packages/hub-agent/src/session-manager.ts @@ -1,55 +1,17 @@ -// Per-agent on-disk repo operations for supervised deployments. +// The directory teardown the deploy path and the hub-link call. // // The in-process session runtime -- harness construction, agent // provisioning, disk restore, and per-agent mail audit -- has been // retired: every agent now runs as a supervised workflow-process child -// on the workflow-run substrate. What remains here is the thin -// serialization layer over the agent repo store that the deploy path -// and the hub-link still call: deploy/asset-pack applies and directory -// teardown, each run one-at-a-time per agent so a teardown never races an -// in-flight git op. - -import path from "node:path"; +// on the workflow-run substrate. import type { AgentRepoStore } from "./agent-repo-store"; -import { applyAssetPack as applyAssetPackFn } from "./apply-asset-pack"; export type SessionManagerConfig = { repoStore: AgentRepoStore; }; export type SessionManager = { - /** - * Initialize the on-disk deploy-tree repo for an address. A single-step - * workflow deploy uses this at the head so the follow-up deploy-pack - * apply has a repo to apply into. - */ - initRepo(address: string): Promise; - /** - * Apply a deploy pack to the agent's repo. Thin wrapper around - * AgentRepoStore for callers that already have a SessionManager handle. - */ - applyDeployPack( - agentAddress: string, - pack: Uint8Array, - ref: string, - commitSha: string, - transferId: string, - verifyCommit?: (payload: string, signature: string) => Promise, - ): Promise; - /** - * Materialize an asset pack at `//` for the - * agent. The workspace root is per-agent; this is distinct from the - * agent's deploy git tree. Asset packs are unsigned in v1 -- no - * `verifyCommit` parameter. - */ - applyAssetPack( - agentAddress: string, - mountPath: string, - pack: Uint8Array, - ref: string, - commitSha: string, - ): Promise; deleteAgentDir(agentAddress: string): Promise; /** * Session id for an address' outbound mail forwarding. Always undefined @@ -64,112 +26,8 @@ export function createSessionManager( ): SessionManager { const { repoStore } = config; - // Per-agent promise chain that serializes the operations against an agent's - // on-disk directory -- deploy/asset-pack applies run one-at-a-time per agent. - // The chain exists for teardown: drainRepoOps awaits it before deleting the - // directory, so an operation that was valid when it started never runs - // against a path that has since vanished underneath it. Serializing - // additionally keeps two deploy-pack applies from interleaving in the agent's - // `.git/` object store, which isogit, lacking a cross-process lock, would - // otherwise allow. Asset-pack applies are on the chain only for the teardown - // reason -- they materialize into a workspace subtree, not the agent repo's - // object store. - const repoOpQueues = new Map>(); - - function runRepoOp( - agentAddress: string, - fn: () => Promise, - ): Promise { - const prev = repoOpQueues.get(agentAddress) ?? Promise.resolve(); - // Run fn once prev settles. prev is either the initial Promise.resolve() - // or the rejection-swallowing tail stored below, so it never rejects; - // passing fn as both the fulfilled and rejected handler keeps this op - // independent of that detail and guarantees fn runs exactly once after the - // previous op completes. - const result = prev.then(fn, fn); - // Store a rejection-swallowing tail so one failed op does not poison the - // chain for the next caller. The caller still observes this op's own - // result or rejection through `result`. - repoOpQueues.set( - agentAddress, - result.then( - () => undefined, - () => undefined, - ), - ); - return result; - } - - // Await the agent's current operation chain so teardown removes the - // directory only after in-flight git work finishes. Capturing the tail and - // clearing the entry means an op enqueued AFTER this point starts a fresh - // chain this drain does not await. That is safe only because every caller - // invokes runRepoOp synchronously, before its first await, while handling a - // frame for the agent's address, and the link handles one address's frames - // one at a time -- so by the time a later agent.undeploy frame for the - // address reaches deleteAgentDir, every racing op is already on the chain. A - // handler that deferred its runRepoOp call past an await would reopen the - // delete-under-in-flight-op race. - async function drainRepoOps(agentAddress: string): Promise { - const inflight = repoOpQueues.get(agentAddress); - repoOpQueues.delete(agentAddress); - if (inflight !== undefined) await inflight; - } - - async function applyDeployPack( - agentAddress: string, - pack: Uint8Array, - ref: string, - commitSha: string, - transferId: string, - verifyCommit?: (payload: string, signature: string) => Promise, - ): Promise { - const args = - verifyCommit !== undefined - ? { - address: agentAddress, - pack, - ref, - commitSha, - transferId, - verifyCommit, - } - : { address: agentAddress, pack, ref, commitSha, transferId }; - await runRepoOp(agentAddress, () => repoStore.applyDeployPack(args)); - } - - async function applyAssetPack( - agentAddress: string, - mountPath: string, - pack: Uint8Array, - ref: string, - commitSha: string, - ): Promise { - const workspaceRoot = path.join( - repoStore.getAgentDir(agentAddress), - "workspace", - ); - await runRepoOp(agentAddress, () => - applyAssetPackFn({ - workspaceRoot, - mountPath, - pack, - ref, - commitSha, - }), - ); - } - - async function deleteAgentDir(agentAddress: string): Promise { - await drainRepoOps(agentAddress); - await repoStore.remove(agentAddress); - } - return { - initRepo: (address: string) => repoStore.initRepo(address), - applyDeployPack, - applyAssetPack, - deleteAgentDir, + deleteAgentDir: (agentAddress: string) => repoStore.remove(agentAddress), getSessionId: () => undefined, }; } diff --git a/packages/hub-agent/src/sidecar-orchestrator-probe.test.ts b/packages/hub-agent/src/sidecar-orchestrator-probe.test.ts index 1c26f9643..0760b2c6d 100644 --- a/packages/hub-agent/src/sidecar-orchestrator-probe.test.ts +++ b/packages/hub-agent/src/sidecar-orchestrator-probe.test.ts @@ -22,7 +22,7 @@ import { type WsHandle, } from "@intx/hub-sessions"; import { createInMemoryTransport } from "@intx/mail-memory"; -import { generateKeyPair, verifySSHSignature } from "@intx/crypto"; +import { generateKeyPair } from "@intx/crypto"; import type { ToolPackageManifest } from "@intx/types/tool-packages"; import type { WorkflowProbeRequestFrame } from "@intx/types/sidecar"; import { waitUntil } from "@intx/types/testing"; @@ -163,7 +163,6 @@ describe("createSidecarOrchestrator workflow-probe threading", () => { transport: createInMemoryTransport(), cryptoOps: { generateKeyPair, - verifySSHSig: verifySSHSignature, }, resolveSenderCrypto: () => undefined, lookupInboundMailPolicy: () => resolveInboundMailPolicy(undefined), diff --git a/packages/hub-agent/src/sidecar-orchestrator.ts b/packages/hub-agent/src/sidecar-orchestrator.ts index 51843b91a..57fff739c 100644 --- a/packages/hub-agent/src/sidecar-orchestrator.ts +++ b/packages/hub-agent/src/sidecar-orchestrator.ts @@ -49,11 +49,6 @@ const log = getLogger(["interchange", "hub-agent", "orchestrator"]); export type SidecarCryptoOps = { generateKeyPair(): Promise; - verifySSHSig( - payload: string, - signature: string, - publicKey: Uint8Array, - ): Promise; }; /** @@ -152,11 +147,11 @@ export type SidecarOrchestratorConfig = { */ createDeployRouter: CreateDeployRouter; /** - * Optional pre-fallback mail dispatcher the link consults on every + * Optional mail dispatcher the link consults on every * inbound `mail.inbound` frame. Production wires this against the * sidecar's multi-step deployment mail handler registry so a * deployment-address inbound flows into the supervisor's mail-bus - * subscription instead of the legacy session path. The orchestrator + * subscription. The orchestrator * forwards the binding unchanged to `createHubLink`. */ mailInboundRouter?: MailInboundRouter; @@ -299,7 +294,6 @@ export function createSidecarOrchestrator( const keyStore = createAgentKeyStore({ dataDir, generateKeyPair: cryptoOps.generateKeyPair, - verifySSHSig: cryptoOps.verifySSHSig, }); // Sink the multi-step deploy path routes a spawned child's verified @@ -383,7 +377,6 @@ export function createSidecarOrchestrator( token, transport, sessions, - keyStore, resolveSenderCrypto, lookupInboundMailPolicy, cacheSenderKey, diff --git a/packages/hub-agent/src/ws/hub-link-deploy-across-reconnect.test.ts b/packages/hub-agent/src/ws/hub-link-deploy-across-reconnect.test.ts index 6223799b1..175f2c469 100644 --- a/packages/hub-agent/src/ws/hub-link-deploy-across-reconnect.test.ts +++ b/packages/hub-agent/src/ws/hub-link-deploy-across-reconnect.test.ts @@ -10,10 +10,9 @@ import { } from "@intx/hub-sessions"; import { createInMemoryTransport } from "@intx/mail-memory"; import type { HarnessConfig } from "@intx/types/runtime"; -import type { HostedIncarnation } from "@intx/types/sidecar"; +import type { AgentDeployFrame, HostedIncarnation } from "@intx/types/sidecar"; import { waitUntil } from "@intx/types/testing"; -import type { AgentKeyStore } from "../agent-key-store"; import type { SessionManager } from "../session-manager"; import { DEFAULT_SIDECAR_CAPACITY } from "@intx/types/sidecar"; import { createHubLink, type DeployRouter } from "./hub-link"; @@ -44,16 +43,18 @@ function binding( }; } +const TEST_WORKFLOW: AgentDeployFrame["workflow"] = { + sources: {}, + sourceRef: { + source: { kind: "registry", registry: "npmjs" }, + closure: { schemaVersion: "1", topLevel: [], entries: [] }, + }, +}; + function config(agentAddress: string): HarnessConfig { return { sessionId: "ses-across", - agentId: "workflow", - tenantId: "tenant-1", - principalId: "principal-1", agentAddress, - systemPrompt: "test", - tools: [], - grants: [], sources: [ { id: "test-source", @@ -77,7 +78,6 @@ const router = createSidecarRouter({ // No generation here finished initializing, so a reconnect takes back no // reported route and the Hub undeploys what the sidecar reports. validateSidecarIdentity: async (_identity, use) => use !== "reclaim", - hubPublicKey: "a".repeat(64), requestTimeoutMs: 10_000, }); @@ -126,28 +126,8 @@ afterAll(async () => { await server.stop(true); }); -function keyStore(): AgentKeyStore { - return { - async loadOrGenerateKey() { - throw new Error("unused"); - }, - recordHubKey() { - // These deployments verify no deploy commits. - }, - async verifyDeployCommit() { - return false; - }, - forgetAgent() { - // No key was ever cached for an address. - }, - }; -} - function sessions(): SessionManager { return { - initRepo: () => Promise.resolve(), - applyDeployPack: () => Promise.resolve(), - applyAssetPack: () => Promise.resolve(), deleteAgentDir: () => Promise.resolve(), getSessionId: () => undefined, }; @@ -211,7 +191,6 @@ function connectLink( token: "token", transport: createInMemoryTransport(), sessions: sessions(), - keyStore: keyStore(), resolveSenderCrypto: () => undefined, lookupInboundMailPolicy: () => resolveInboundMailPolicy(undefined), cacheSenderKey: async () => undefined, @@ -275,6 +254,7 @@ describe("a deploy still running when the sidecar's link drops", () => { { allocationId: "replaced", generation: 1 }, ADDRESS, config(ADDRESS), + TEST_WORKFLOW, ) .then( () => "deployed", @@ -302,6 +282,7 @@ describe("a deploy still running when the sidecar's link drops", () => { { allocationId: "replaced", generation: 2 }, ADDRESS, config(ADDRESS), + TEST_WORKFLOW, ); // On the sidecar the undeploy and the newer deploy wait behind the @@ -347,6 +328,7 @@ describe("a deploy still running when the sidecar's link drops", () => { { allocationId: "released", generation: 1 }, ADDRESS, config(ADDRESS), + TEST_WORKFLOW, ) .then( () => "deployed", diff --git a/packages/hub-agent/src/ws/hub-link-frame-lanes.test.ts b/packages/hub-agent/src/ws/hub-link-frame-lanes.test.ts index aae20c2ba..849b8c83c 100644 --- a/packages/hub-agent/src/ws/hub-link-frame-lanes.test.ts +++ b/packages/hub-agent/src/ws/hub-link-frame-lanes.test.ts @@ -12,7 +12,6 @@ import { SidecarFrame, } from "@intx/types/sidecar"; -import type { AgentKeyStore } from "../agent-key-store"; import type { SessionManager } from "../session-manager"; import { createHubLink, type HubLinkConfig } from "./hub-link"; import { resolveInboundMailPolicy } from "./inbound-signature"; @@ -110,13 +109,7 @@ afterAll(async () => { function config(agentAddress: string): HarnessConfig { return { sessionId: "ses-lanes", - agentId: "workflow", - tenantId: "tenant-1", - principalId: "principal-1", agentAddress, - systemPrompt: "test", - tools: [], - grants: [], sources: [ { id: "test-source", @@ -138,9 +131,14 @@ function deploy(agentAddress: string) { requestId: `deploy-${String(deploys)}`, agentAddress, generation: 1, - agentId: "workflow", config: config(agentAddress), - hubPublicKey: "a".repeat(64), + workflow: { + sources: {}, + sourceRef: { + source: { kind: "registry", registry: "npmjs" }, + closure: { schemaVersion: "1", topLevel: [], entries: [] }, + }, + }, }; } @@ -185,28 +183,8 @@ function hold() { return { released: released.promise, release: () => released.resolve(true) }; } -function keyStore(): AgentKeyStore { - return { - async loadOrGenerateKey() { - throw new Error("unused"); - }, - recordHubKey() { - // These deployments verify no deploy commits. - }, - async verifyDeployCommit() { - return false; - }, - forgetAgent() { - // No key was ever cached for an address. - }, - }; -} - function sessions(): SessionManager { return { - initRepo: () => Promise.resolve(), - applyDeployPack: () => Promise.resolve(), - applyAssetPack: () => Promise.resolve(), deleteAgentDir: () => Promise.resolve(), getSessionId: () => undefined, }; @@ -223,7 +201,6 @@ async function connectLink( token: "token", transport: createInMemoryTransport(), sessions: sessions(), - keyStore: keyStore(), resolveSenderCrypto: () => undefined, lookupInboundMailPolicy: () => resolveInboundMailPolicy(undefined), cacheSenderKey: async () => undefined, diff --git a/packages/hub-agent/src/ws/hub-link-mail-body-cap.test.ts b/packages/hub-agent/src/ws/hub-link-mail-body-cap.test.ts index 2cd5054f1..9ece601eb 100644 --- a/packages/hub-agent/src/ws/hub-link-mail-body-cap.test.ts +++ b/packages/hub-agent/src/ws/hub-link-mail-body-cap.test.ts @@ -32,7 +32,6 @@ import { configureSync, getConfig } from "@intx/log"; import { createHubLink, type DeployRouter } from "./hub-link"; import { resolveInboundMailPolicy } from "./inbound-signature"; -import type { AgentKeyStore } from "../agent-key-store"; import type { SessionManager } from "../session-manager"; // A transport that captures the two send handlers `createHubLink` registers and @@ -73,18 +72,9 @@ function createCapturingTransport(): { }; } -// The deploy/session/key deps are required by the config but never reached by +// The deploy/session deps are required by the config but never reached by // the send-cap paths; they throw if misused rather than model behavior. const unusedSessions: SessionManager = { - initRepo: () => { - throw new Error("sessions not used"); - }, - applyDeployPack: () => { - throw new Error("sessions not used"); - }, - applyAssetPack: () => { - throw new Error("sessions not used"); - }, deleteAgentDir: () => { throw new Error("sessions not used"); }, @@ -92,21 +82,6 @@ const unusedSessions: SessionManager = { getSessionId: () => undefined, }; -const unusedKeyStore: AgentKeyStore = { - loadOrGenerateKey: () => { - throw new Error("keyStore not used"); - }, - recordHubKey: () => { - throw new Error("keyStore not used"); - }, - verifyDeployCommit: () => { - throw new Error("keyStore not used"); - }, - forgetAgent: () => { - throw new Error("keyStore not used"); - }, -}; - const unusedDeployRouter: DeployRouter = { deploy: () => { throw new Error("deployRouter not used"); @@ -122,7 +97,6 @@ function makeLink(hostedSenders = ["s@example.test"]) { token: "test-token", transport: capture.transport, sessions: unusedSessions, - keyStore: unusedKeyStore, resolveSenderCrypto: () => undefined, lookupInboundMailPolicy: () => resolveInboundMailPolicy(undefined), cacheSenderKey: async () => undefined, diff --git a/packages/hub-agent/src/ws/hub-link-mail-router.test.ts b/packages/hub-agent/src/ws/hub-link-mail-router.test.ts index 56d48ba63..2cf23a2aa 100644 --- a/packages/hub-agent/src/ws/hub-link-mail-router.test.ts +++ b/packages/hub-agent/src/ws/hub-link-mail-router.test.ts @@ -22,17 +22,8 @@ import { } from "@intx/hub-sessions"; import { createInMemoryTransport } from "@intx/mail-memory"; import { base64Encode } from "@intx/types"; -import type { - HarnessConfig, - InboundMessage, - KeyPair, -} from "@intx/types/runtime"; -import { - generateKeyPair, - verifySSHSignature, - createEd25519Crypto, -} from "@intx/crypto"; -import { hexDecode } from "@intx/types"; +import type { HarnessConfig, InboundMessage } from "@intx/types/runtime"; +import { generateKeyPair, createEd25519Crypto } from "@intx/crypto"; import { assembleSignedContent, assembleMessage, @@ -54,62 +45,24 @@ import { createInboundMailPolicyLookup, } from "./inbound-mail-policy-registry"; import { createPublicKeyCrypto } from "../sender-crypto"; -import type { AgentKeyStore } from "../agent-key-store"; import type { SessionManager } from "../session-manager"; -function createTestKeyStore(): AgentKeyStore & { - registerKey(address: string, kp: KeyPair): void; -} { - const agentKeys = new Map(); - const hubKeys = new Map(); - return { - registerKey(address, kp) { - agentKeys.set(address, kp); - }, - async loadOrGenerateKey(address) { - const existing = agentKeys.get(address); - if (existing !== undefined) return { keyPair: existing, isNew: false }; - throw new Error(`No key registered for ${address} in test store`); - }, - recordHubKey(address, hexHubPublicKey) { - hubKeys.set(address, hexDecode(hexHubPublicKey)); - }, - verifyDeployCommit(address, payload, signature) { - const hubKey = hubKeys.get(address); - if (hubKey === undefined) { - throw new Error( - `signature_invalid: no hub public key for "${address}"`, - ); - } - return verifySSHSignature(payload, signature, hubKey); - }, - forgetAgent(address) { - agentKeys.delete(address); - hubKeys.delete(address); - }, - }; -} - -function createTestDeployRouter(keyStore: AgentKeyStore): DeployRouter { +function createTestDeployRouter(): DeployRouter { return { - async deploy(frame) { - keyStore.recordHubKey(frame.agentAddress, frame.hubPublicKey); + async deploy() { return { publicKey: "aa".repeat(32) }; }, }; } function withTestDeployBindings(): { - keyStore: AgentKeyStore & { registerKey(address: string, kp: KeyPair): void }; deployRouter: DeployRouter; resolveSenderCrypto: () => undefined; cacheSenderKey: () => Promise; evictSenderKey: () => Promise; } { - const keyStore = createTestKeyStore(); return { - keyStore, - deployRouter: createTestDeployRouter(keyStore), + deployRouter: createTestDeployRouter(), resolveSenderCrypto: () => undefined, cacheSenderKey: async () => undefined, evictSenderKey: async () => undefined, @@ -149,9 +102,6 @@ function createMockSessionManager(): SessionManager & { provisionedAddresses: [] as string[], shouldThrow: null as string | null, - initRepo: (_address: string) => Promise.resolve(), - applyDeployPack: () => Promise.resolve(), - applyAssetPack: () => Promise.resolve(), deleteAgentDir: () => Promise.resolve(), getSessionId: (_agentAddress: string) => undefined, }; @@ -202,7 +152,6 @@ function startTestServer(): TestEnv { resolveSidecarBindings: async (sidecarId) => [ensureIdentity(sidecarId)], validateSidecarIdentity: async () => true, requestTimeoutMs: 5000, - hubPublicKey: "a".repeat(64), }); const app = new Hono(); @@ -265,20 +214,6 @@ afterAll(async () => { await env.server.stop(true); }); -/** - * Wire a workflow deployment for the reconnect path: mint a keypair and - * register it in the sidecar keyStore so the deploy path picks up the - * pinned key. The deployment address then routes once the hub's - * `welcome` routes it on (re)connect. - */ -async function provisionDeploymentKey( - keyStore: ReturnType, - address: string, -): Promise { - const kp = await generateKeyPair(); - keyStore.registerKey(address, kp); -} - describe("hub-link mail.inbound throwing router", () => { test("a throwing mailInboundRouter does not wedge subsequent frames", async () => { const transport = createInMemoryTransport(); @@ -299,7 +234,6 @@ describe("hub-link mail.inbound throwing router", () => { }; const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, deploymentAddress); const policyRegistry = createInboundMailPolicyRegistry(); policyRegistry.register(deploymentAddress, ADMIT_ALL_INBOUND_MAIL_POLICY); const client = createHubLink({ @@ -419,8 +353,6 @@ describe("hub-link mail.inbound acknowledgement across an undeploy", () => { }, }; const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, leaving); - await provisionDeploymentKey(bindings.keyStore, staying); const policyRegistry = createInboundMailPolicyRegistry(); policyRegistry.register(leaving, ADMIT_ALL_INBOUND_MAIL_POLICY); policyRegistry.register(staying, ADMIT_ALL_INBOUND_MAIL_POLICY); @@ -640,7 +572,6 @@ describe("hub-link mail.inbound signature enforcement", () => { } const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, deploymentAddress); const client = createHubLink({ capacity: DEFAULT_SIDECAR_CAPACITY, hubURL: `ws://localhost:${env.server.port}/ws`, diff --git a/packages/hub-agent/src/ws/hub-link-sender-key-refresh.test.ts b/packages/hub-agent/src/ws/hub-link-sender-key-refresh.test.ts index a0f1dbfa5..63a901d18 100644 --- a/packages/hub-agent/src/ws/hub-link-sender-key-refresh.test.ts +++ b/packages/hub-agent/src/ws/hub-link-sender-key-refresh.test.ts @@ -44,25 +44,13 @@ import { waitUntil } from "@intx/types/testing"; import { createHubLink, type DeployRouter } from "./hub-link"; import { resolveInboundMailPolicy } from "./inbound-signature"; import { createSenderKeyCache } from "../sender-key-cache"; -import type { AgentKeyStore } from "../agent-key-store"; import type { SessionManager } from "../session-manager"; // --------------------------------------------------------------------------- // Minimal link dependencies. The refresh arm touches only `cacheSenderKey`, so -// the deploy/session/key stubs exist only to satisfy the required config. +// the deploy/session stubs exist only to satisfy the required config. // --------------------------------------------------------------------------- -function createStubKeyStore(): AgentKeyStore { - return { - async loadOrGenerateKey(address: string) { - throw new Error(`No key registered for ${address} in test store`); - }, - recordHubKey: () => undefined, - verifyDeployCommit: () => Promise.resolve(true), - forgetAgent: () => undefined, - }; -} - function createStubDeployRouter(): DeployRouter { return { async deploy() { @@ -73,9 +61,6 @@ function createStubDeployRouter(): DeployRouter { function createStubSessionManager(): SessionManager { return { - initRepo: () => Promise.resolve(), - applyDeployPack: () => Promise.resolve(), - applyAssetPack: () => Promise.resolve(), deleteAgentDir: () => Promise.resolve(), getSessionId: () => undefined, }; @@ -287,7 +272,6 @@ function createTestLink( token: "test-token", transport: createInMemoryTransport(), sessions: createStubSessionManager(), - keyStore: createStubKeyStore(), resolveSenderCrypto: () => undefined, lookupInboundMailPolicy: () => resolveInboundMailPolicy(undefined), cacheSenderKey, diff --git a/packages/hub-agent/src/ws/hub-link-sender-key-rotation.test.ts b/packages/hub-agent/src/ws/hub-link-sender-key-rotation.test.ts index f1ad09668..a2a67b579 100644 --- a/packages/hub-agent/src/ws/hub-link-sender-key-rotation.test.ts +++ b/packages/hub-agent/src/ws/hub-link-sender-key-rotation.test.ts @@ -33,25 +33,13 @@ import { DEFAULT_SIDECAR_CAPACITY } from "@intx/types/sidecar"; import { createHubLink, type DeployRouter } from "./hub-link"; import { resolveInboundMailPolicy } from "./inbound-signature"; import { createSenderKeyCache } from "../sender-key-cache"; -import type { AgentKeyStore } from "../agent-key-store"; import type { SessionManager } from "../session-manager"; // --------------------------------------------------------------------------- // Minimal link dependencies. The rotation loop touches only the sender cache -// wiring, so the deploy/session/key stubs exist only to satisfy the config. +// wiring, so the deploy/session stubs exist only to satisfy the config. // --------------------------------------------------------------------------- -function createStubKeyStore(): AgentKeyStore { - return { - async loadOrGenerateKey(address: string) { - throw new Error(`No key registered for ${address} in test store`); - }, - recordHubKey: () => undefined, - verifyDeployCommit: () => Promise.resolve(true), - forgetAgent: () => undefined, - }; -} - function createStubDeployRouter(): DeployRouter { return { async deploy() { @@ -62,9 +50,6 @@ function createStubDeployRouter(): DeployRouter { function createStubSessionManager(): SessionManager { return { - initRepo: () => Promise.resolve(), - applyDeployPack: () => Promise.resolve(), - applyAssetPack: () => Promise.resolve(), deleteAgentDir: () => Promise.resolve(), getSessionId: () => undefined, }; @@ -141,7 +126,6 @@ function startRotationServer( authenticateSidecar: async ({ sidecarId }) => ({ sidecarId }), resolveSidecarBindings: async (sidecarId) => [ensureIdentity(sidecarId)], validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 5000, lookups: { resolveSenderKeyStrict }, }); @@ -247,7 +231,6 @@ describe("hub-link sender-key rotation on reconnect", () => { token: "test-token", transport: createInMemoryTransport(), sessions: createStubSessionManager(), - keyStore: createStubKeyStore(), resolveSenderCrypto: () => undefined, lookupInboundMailPolicy: () => resolveInboundMailPolicy(undefined), cacheSenderKey: (address, publicKey) => @@ -280,7 +263,6 @@ describe("hub-link sender-key rotation on reconnect", () => { token: "test-token", transport: createInMemoryTransport(), sessions: createStubSessionManager(), - keyStore: createStubKeyStore(), resolveSenderCrypto: () => undefined, lookupInboundMailPolicy: () => resolveInboundMailPolicy(undefined), cacheSenderKey: (address, publicKey) => @@ -350,7 +332,6 @@ describe("hub-link sender-key rotation on reconnect", () => { token: "test-token", transport: createInMemoryTransport(), sessions: createStubSessionManager(), - keyStore: createStubKeyStore(), resolveSenderCrypto: () => undefined, lookupInboundMailPolicy: () => resolveInboundMailPolicy(undefined), cacheSenderKey: (address, publicKey) => @@ -389,7 +370,6 @@ describe("hub-link sender-key rotation on reconnect", () => { token: "test-token", transport: createInMemoryTransport(), sessions: createStubSessionManager(), - keyStore: createStubKeyStore(), resolveSenderCrypto: () => undefined, lookupInboundMailPolicy: () => resolveInboundMailPolicy(undefined), cacheSenderKey: (address, publicKey) => diff --git a/packages/hub-agent/src/ws/hub-link.test.ts b/packages/hub-agent/src/ws/hub-link.test.ts index 44edc6bb9..9b0e59905 100644 --- a/packages/hub-agent/src/ws/hub-link.test.ts +++ b/packages/hub-agent/src/ws/hub-link.test.ts @@ -10,17 +10,13 @@ import { type WsHandle, } from "@intx/hub-sessions"; import { createInMemoryTransport } from "@intx/mail-memory"; -import { - createEd25519Crypto, - generateKeyPair, - verifySSHSignature, -} from "@intx/crypto"; -import { base64Encode, hexEncode } from "@intx/types"; +import { createEd25519Crypto, generateKeyPair } from "@intx/crypto"; +import { base64Encode } from "@intx/types"; import type { HarnessConfig } from "@intx/types/runtime"; import { answerMalformedRequestFrame, - classifyAssetPackRejectReason, + classifyPackRejectReason, cleartextTransportWarning, createHubLink, type DeployRouter, @@ -40,8 +36,6 @@ import { } from "@intx/types/sidecar"; import type { RepoId } from "@intx/types/repo"; -type TestSendPackOptions = { mountPath?: string; repoId?: RepoId }; -import type { AgentKeyStore } from "../agent-key-store"; import type { SessionManager } from "../session-manager"; import type { ResolvedInboundMailPolicy } from "./inbound-signature"; @@ -135,7 +129,7 @@ function sendAgentDeploy( router: ReturnType, agentAddress: string, config: HarnessConfig, - workflow?: AgentDeployFrame["workflow"], + workflow: AgentDeployFrame["workflow"] = TEST_WORKFLOW, ) { return router.sendAgentDeployToAllocation( allocationTargetFor(router, agentAddress), @@ -145,59 +139,32 @@ function sendAgentDeploy( ); } -function sendPack( - router: ReturnType, - agentAddress: string, - pack: Uint8Array, - ref: string, - commitSha: string, - options?: TestSendPackOptions, -) { - return router.sendPackToAllocation( - allocationTargetFor(router, agentAddress), - agentAddress, - pack, - ref, - commitSha, - options, - ); -} - /** - * Test-only deploy router modelling the current deploy path: record the - * hub pairing key so `verifyDeployCommit` can accept the deployment's - * packs, and surface a public key on the ack. Production stages the - * deploy through the workflow-run substrate; the tests here exercise the - * link's surface against the router directly. + * Test-only deploy router: surface a public key on the ack. Production stages the deploy through the workflow-run + * substrate; the tests here exercise the link's surface against the router + * directly. */ -function createTestDeployRouter(keyStore: AgentKeyStore): DeployRouter { +function createTestDeployRouter(): DeployRouter { return { - async deploy(frame) { - keyStore.recordHubKey(frame.agentAddress, frame.hubPublicKey); + async deploy() { return { publicKey: "aa".repeat(32) }; }, }; } /** - * Convenience spread for `createHubLink({ ... })` call sites that - * use a freshly-constructed `createTestKeyStore()` and a sessions - * mock: returns the `keyStore` and a matching `deployRouter` so the - * call site does not have to name a temporary binding for the - * keyStore-router pairing. + * Convenience spread for `createHubLink({ ... })` call sites that use a + * sessions mock. */ function withTestDeployBindings(): { - keyStore: AgentKeyStore & { registerKey(address: string, kp: KeyPair): void }; deployRouter: DeployRouter; resolveSenderCrypto: () => undefined; lookupInboundMailPolicy: () => ResolvedInboundMailPolicy; cacheSenderKey: () => Promise; evictSenderKey: () => Promise; } { - const keyStore = createTestKeyStore(); return { - keyStore, - deployRouter: createTestDeployRouter(keyStore), + deployRouter: createTestDeployRouter(), // These tests do not exercise the inbound signature compare, so the verify // resolves no cached key and the refresh/evict sinks are no-ops. resolveSenderCrypto: () => undefined, @@ -206,57 +173,10 @@ function withTestDeployBindings(): { evictSenderKey: async () => undefined, }; } -import type { KeyPair } from "@intx/types/runtime"; -import { hexDecode } from "@intx/types"; import { waitUntil } from "@intx/types/testing"; -// In-memory AgentKeyStore for tests. Tests that exercise deploy-commit -// verification register keys via the public AgentKeyStore methods -// (loadOrGenerateKey, recordHubKey); the stub satisfies the interface -// and uses real @intx/crypto primitives so signatures round-trip -// through the production verify path. -function createTestKeyStore(): AgentKeyStore & { - registerKey(address: string, kp: KeyPair): void; -} { - const agentKeys = new Map(); - const hubKeys = new Map(); - return { - registerKey(address, kp) { - agentKeys.set(address, kp); - }, - async loadOrGenerateKey(address) { - const existing = agentKeys.get(address); - if (existing !== undefined) return { keyPair: existing, isNew: false }; - throw new Error(`No key registered for ${address} in test store`); - }, - recordHubKey(address, hexHubPublicKey) { - hubKeys.set(address, hexDecode(hexHubPublicKey)); - }, - verifyDeployCommit(address, payload, signature) { - const hubKey = hubKeys.get(address); - if (hubKey === undefined) { - throw new Error( - `signature_invalid: no hub public key for "${address}"`, - ); - } - return verifySSHSignature(payload, signature, hubKey); - }, - forgetAgent(address) { - agentKeys.delete(address); - hubKeys.delete(address); - }, - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - function createMockSessionManager(): SessionManager { return { - initRepo: (_address: string) => Promise.resolve(), - applyDeployPack: () => Promise.resolve(), - applyAssetPack: () => Promise.resolve(), deleteAgentDir: () => Promise.resolve(), getSessionId: (_agentAddress: string) => undefined, }; @@ -264,13 +184,7 @@ function createMockSessionManager(): SessionManager { const TEST_CONFIG: HarnessConfig = { sessionId: "ses_test-session-1", - agentId: "agent-1", - tenantId: "tenant-1", - principalId: "prin_test-principal-1", agentAddress: "agent-1@test.interchange", - systemPrompt: "You are a test agent", - tools: [], - grants: [], sources: [ { id: "anthropic:claude-sonnet-5", @@ -283,6 +197,14 @@ const TEST_CONFIG: HarnessConfig = { defaultSource: "anthropic:claude-sonnet-5", }; +const TEST_WORKFLOW: AgentDeployFrame["workflow"] = { + sources: {}, + sourceRef: { + source: { kind: "registry", registry: "npmjs" }, + closure: { schemaVersion: "1", topLevel: [], entries: [] }, + }, +}; + const VALID_MESSAGE = new TextEncoder().encode( [ "From: external@remote.interchange", @@ -328,7 +250,6 @@ function startTestServer(): TestEnv { ...acceptAnySidecar, validateSidecarIdentity: async () => true, requestTimeoutMs: 5000, - hubPublicKey: "a".repeat(64), lookups: { readWorkflowRunRefTips: async () => STOPPED_REF_TIPS }, }); router.events.on("agent.event", ({ agentAddress, sessionId, event }) => { @@ -480,21 +401,6 @@ afterAll(async () => { await env.server.stop(true); }); -/** - * Wire a workflow deployment for the reconnect path: mint an Ed25519 - * keypair and register it in the sidecar's keyStore so the deploy path - * picks up the pinned key. After this, the deployment address named in - * `getIncarnations` routes once the hub's `welcome` routes it on - * (re)connect. - */ -async function provisionDeploymentKey( - keyStore: ReturnType, - address: string, -): Promise { - const kp = await generateKeyPair(); - keyStore.registerKey(address, kp); -} - describe("sidecar↔hub integration", () => { test("advertises configured capacity in hello", async () => { const capacity = { @@ -562,9 +468,8 @@ describe("sidecar↔hub integration", () => { requestId: "deploy-capacity", agentAddress: "run_capacity@example.test", generation: 1, - agentId: "run_capacity", config: TEST_CONFIG, - hubPublicKey: "a".repeat(64), + workflow: TEST_WORKFLOW, }); expect(await answered.promise).toEqual({ type: "agent.deploy.error", @@ -998,7 +903,6 @@ describe("sidecar↔hub integration", () => { const deploymentAddress = "run_disc1@integration.interchange"; const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, deploymentAddress); const client = createHubLink({ capacity: DEFAULT_SIDECAR_CAPACITY, hubURL: `ws://localhost:${env.server.port}/ws`, @@ -1025,310 +929,6 @@ describe("sidecar↔hub integration", () => { expect(env.router.getRoutableAddresses()).not.toContain(deploymentAddress); }); - test("repo.pack.reject sent when applyDeployPack throws signature_invalid", async () => { - const transport = createInMemoryTransport(); - const sessions = createMockSessionManager(); - const client = createHubLink({ - capacity: DEFAULT_SIDECAR_CAPACITY, - hubURL: `ws://localhost:${env.server.port}/ws`, - sidecarId: "sc-pack-reject", - token: "test-token", - transport, - sessions, - ...withTestDeployBindings(), - }); - - client.connect(); - try { - await waitUntil(() => - env.router.getConnectedSidecars().includes("sc-pack-reject"), - ); - - await sendAgentDeploy( - env.router, - "pack-agent@test.interchange", - TEST_CONFIG, - ); - - sessions.applyDeployPack = () => { - throw new Error("signature_invalid: bad signature"); - }; - - await expect( - sendPack( - env.router, - "pack-agent@test.interchange", - new Uint8Array([1, 2, 3]), - "refs/heads/deploy", - "a".repeat(40), - ), - ).rejects.toThrow("Pack rejected: signature_invalid"); - } finally { - client.close(); - await waitUntil( - () => !env.router.getConnectedSidecars().includes("sc-pack-reject"), - ); - } - }); - - test("signature_unsigned errors also map to repo.pack.reject signature_invalid", async () => { - const transport = createInMemoryTransport(); - const sessions = createMockSessionManager(); - const client = createHubLink({ - capacity: DEFAULT_SIDECAR_CAPACITY, - hubURL: `ws://localhost:${env.server.port}/ws`, - sidecarId: "sc-pack-unsigned", - token: "test-token", - transport, - sessions, - ...withTestDeployBindings(), - }); - - client.connect(); - try { - await waitUntil(() => - env.router.getConnectedSidecars().includes("sc-pack-unsigned"), - ); - - await sendAgentDeploy( - env.router, - "unsigned-agent@test.interchange", - TEST_CONFIG, - ); - - sessions.applyDeployPack = () => { - throw new Error("signature_unsigned: no signature found"); - }; - - await expect( - sendPack( - env.router, - "unsigned-agent@test.interchange", - new Uint8Array([1, 2, 3]), - "refs/heads/deploy", - "a".repeat(40), - ), - ).rejects.toThrow("Pack rejected: signature_invalid"); - } finally { - client.close(); - await waitUntil( - () => !env.router.getConnectedSidecars().includes("sc-pack-unsigned"), - ); - } - }); - - test("malformed hubPublicKey in deploy frame sends agent.deploy.error", async () => { - const transport = createInMemoryTransport(); - const sessions = createMockSessionManager(); - - // Stand up a hub router with an odd-length hex key to trigger hexDecode. - const badRouter = createSidecarRouter({ - withExecutableWorkflowRun: async (_target, send) => send(), - ...acceptAnySidecar, - validateSidecarIdentity: async () => true, - requestTimeoutMs: 5000, - hubPublicKey: "abc", // odd length — hexDecode should throw - }); - - const badApp = new Hono(); - badApp.get( - "/ws", - upgradeWebSocket((_c) => { - let handle: WsHandle; - return { - onOpen(_evt, ws) { - handle = { - send(data: string) { - ws.send(data); - }, - close() { - ws.close(); - }, - }; - badRouter.handleOpen(handle); - }, - onMessage(evt, _ws) { - if (typeof evt.data === "string") { - prepareAllocationFrame(badRouter, evt.data); - badRouter.handleMessage(handle, evt.data); - } - }, - onClose(_evt, _ws) { - badRouter.handleClose(handle); - }, - }; - }), - ); - - const badServer = Bun.serve({ - fetch: badApp.fetch, - websocket, - port: 0, - }); - - const client = createHubLink({ - capacity: DEFAULT_SIDECAR_CAPACITY, - hubURL: `ws://localhost:${badServer.port}/ws`, - sidecarId: "sc-bad-hex", - token: "test-token", - transport, - sessions, - ...withTestDeployBindings(), - }); - - client.connect(); - try { - await waitUntil(() => - badRouter.getConnectedSidecars().includes("sc-bad-hex"), - ); - - // Deploy should fail because hexDecode throws on the odd-length key. - await expect( - sendAgentDeploy(badRouter, "bad-hex@test.interchange", TEST_CONFIG), - ).rejects.toThrow("odd-length"); - } finally { - client.close(); - await badServer.stop(true); - } - }); - - test("a deploy records the hub key so verifyCommit is bound to it", async () => { - const { generateKeyPair, createSSHSignature } = await import( - "@intx/crypto" - ); - - // Hub keypair — the key whose public half the sidecar stores to verify - // deploy-commit signatures. - const hubKp = await generateKeyPair(); - const deployedAddress = "deployed@test.interchange"; - const hubPublicKeyHex = hexEncode(hubKp.publicKey); - - const deployHubRouter = createSidecarRouter({ - withExecutableWorkflowRun: async (_target, send) => send(), - ...acceptAnySidecar, - validateSidecarIdentity: async () => true, - requestTimeoutMs: 5000, - hubPublicKey: hubPublicKeyHex, - }); - - const deployApp = new Hono(); - deployApp.get( - "/ws", - upgradeWebSocket((_c) => { - let handle: WsHandle; - return { - onOpen(_evt, ws) { - handle = { - send(data: string) { - ws.send(data); - }, - close() { - ws.close(); - }, - }; - deployHubRouter.handleOpen(handle); - }, - onMessage(evt, _ws) { - if (typeof evt.data === "string") { - prepareAllocationFrame(deployHubRouter, evt.data); - deployHubRouter.handleMessage(handle, evt.data); - } - }, - onClose(_evt, _ws) { - deployHubRouter.handleClose(handle); - }, - }; - }), - ); - - const deployServer = Bun.serve({ - fetch: deployApp.fetch, - websocket, - port: 0, - }); - - const transport = createInMemoryTransport(); - const sessions = createMockSessionManager(); - const keyStore = createTestKeyStore(); - - const client = createHubLink({ - capacity: DEFAULT_SIDECAR_CAPACITY, - hubURL: `ws://localhost:${deployServer.port}/ws`, - sidecarId: "sc-deploy-key", - token: "test-token", - transport, - sessions, - keyStore, - resolveSenderCrypto: () => undefined, - lookupInboundMailPolicy: () => admitAllInboundMailPolicy, - cacheSenderKey: async () => undefined, - evictSenderKey: async () => undefined, - deployRouter: createTestDeployRouter(keyStore), - }); - - client.connect(); - try { - await waitUntil(() => - deployHubRouter.getConnectedSidecars().includes("sc-deploy-key"), - ); - - // The hub's deploy frame carries hubPublicKeyHex; the deploy router - // records it via keyStore.recordHubKey, so a later pack's verifyCommit - // is bound to the hub key. - await sendAgentDeploy(deployHubRouter, deployedAddress, { - ...TEST_CONFIG, - agentAddress: deployedAddress, - }); - - let capturedVerifyCommit: - | ((p: string, s: string) => Promise) - | undefined; - sessions.applyDeployPack = async ( - _addr: string, - _pack: Uint8Array, - _ref: string, - _sha: string, - _tid: string, - verifyCommit?: (payload: string, signature: string) => Promise, - ) => { - capturedVerifyCommit = verifyCommit; - }; - - await sendPack( - deployHubRouter, - deployedAddress, - new Uint8Array([1, 2, 3]), - "refs/heads/deploy", - "b".repeat(40), - ); - - expect(capturedVerifyCommit).toBeFunction(); - - // A signature from the hub's key round-trips through the recorded - // verifyCommit callback. - const payload = "tree abc\nauthor t 0 +0000\n\ntest\n"; - const sig = await createSSHSignature( - payload, - hubKp.privateKey, - hubKp.publicKey, - ); - expect(await capturedVerifyCommit!(payload, sig)).toBe(true); - - // A signature from a different key fails, proving the callback is bound - // to the specific hub key the deploy recorded. - const wrongKp = await generateKeyPair(); - const wrongSig = await createSSHSignature( - payload, - wrongKp.privateKey, - wrongKp.publicKey, - ); - expect(await capturedVerifyCommit!(payload, wrongSig)).toBe(false); - } finally { - client.close(); - await deployServer.stop(true); - } - }); - test("sidecar sends pings and hub responds with pongs", async () => { const pingEnv = startTestServer(); @@ -1371,81 +971,67 @@ describe("sidecar↔hub integration", () => { } }); - test("repo.pack.done with mountPath routes through applyAssetPack", async () => { - const transport = createInMemoryTransport(); - const sessions = createMockSessionManager(); - const deployCalls: string[] = []; - const assetCalls: { address: string; mountPath: string }[] = []; - sessions.applyDeployPack = (addr: string) => { - deployCalls.push(addr); - return Promise.resolve(); - }; - sessions.applyAssetPack = (addr: string, mountPath: string) => { - assetCalls.push({ address: addr, mountPath }); - return Promise.resolve(); - }; - + test("a Hub pack for any other repo kind is rejected and not applied", async () => { + const restored: unknown[] = []; + const hub = startWelcomingHub(); const client = createHubLink({ capacity: DEFAULT_SIDECAR_CAPACITY, - hubURL: `ws://localhost:${env.server.port}/ws`, - sidecarId: "sc-asset-route", + hubURL: `ws://localhost:${hub.server.port}/ws`, + sidecarId: "sc-other-kind-pack", token: "test-token", - transport, - sessions, + transport: createInMemoryTransport(), + sessions: createMockSessionManager(), ...withTestDeployBindings(), + applyWorkflowRunPack: async (args) => { + restored.push(args); + }, }); + const rejects = () => + hub.frames + .map((s): { type: string; reason?: string; detail?: string } => + JSON.parse(s), + ) + .filter((frame) => frame.type === "repo.pack.reject"); client.connect(); try { await waitUntil(() => - env.router.getConnectedSidecars().includes("sc-asset-route"), - ); - - await sendAgentDeploy( - env.router, - "route-agent@test.interchange", - TEST_CONFIG, - ); - - // Deploy pack (no mountPath) → applyDeployPack. - await sendPack( - env.router, - "route-agent@test.interchange", - new Uint8Array([1, 2, 3]), - "refs/heads/deploy", - "a".repeat(40), + hub.frames.some((s) => JSON.parse(s).type === "hello"), ); - expect(deployCalls).toEqual(["route-agent@test.interchange"]); - expect(assetCalls).toEqual([]); + const transfer = { + agentAddress: "run_other_kind@workflow.test", + generation: 1, + repoId: { kind: "skill", id: "asset-1" }, + transferId: "transfer-other-kind", + }; + hub.send({ + type: "repo.pack.push", + ...transfer, + seq: 0, + data: base64Encode(new Uint8Array([7, 8, 9])), + }); + hub.send({ + type: "repo.pack.done", + ...transfer, + ref: "refs/heads/main", + commitSha: "c".repeat(40), + }); - // Asset pack (mountPath set) → applyAssetPack. - await sendPack( - env.router, - "route-agent@test.interchange", - new Uint8Array([4, 5, 6]), - "refs/heads/main", - "b".repeat(40), - { mountPath: "skills/example/" }, - ); - expect(deployCalls).toEqual(["route-agent@test.interchange"]); - expect(assetCalls).toEqual([ - { - address: "route-agent@test.interchange", - mountPath: "skills/example/", - }, - ]); + await waitUntil(() => rejects().length > 0); + expect(rejects()[0]).toMatchObject({ + reason: "corrupt", + detail: expect.stringContaining("not skill"), + }); + expect(restored).toEqual([]); } finally { client.close(); - await waitUntil( - () => !env.router.getConnectedSidecars().includes("sc-asset-route"), - ); + await hub.server.stop(true); } }); - test("workflow-run packs route through the restore boundary instead of the deploy tree", async () => { + test("workflow-run packs route through the restore boundary", async () => { const transport = createInMemoryTransport(); const sessions = createMockSessionManager(); - const deployCalls: string[] = []; const restoreCalls: { agentAddress: string; repoId: RepoId; @@ -1453,10 +1039,6 @@ describe("sidecar↔hub integration", () => { commitSha: string; pack: Uint8Array; }[] = []; - sessions.applyDeployPack = (address) => { - deployCalls.push(address); - return Promise.resolve(); - }; const address = "run_restore@workflow.test"; const repoId: RepoId = { kind: "workflow-run", @@ -1481,19 +1063,16 @@ describe("sidecar↔hub integration", () => { await waitUntil(() => env.router.getConnectedSidecars().includes("sc-workflow-restore-route"), ); - await sendAgentDeploy(env.router, address, TEST_CONFIG); const pack = new Uint8Array([7, 8, 9]); - await sendPack( - env.router, + await env.router.sendWorkflowRunPackToAllocation( + allocationTargetFor(env.router, address), address, pack, "refs/heads/events", "c".repeat(40), - { repoId }, ); - expect(deployCalls).toEqual([]); expect(restoreCalls).toEqual([ { agentAddress: address, @@ -1514,52 +1093,6 @@ describe("sidecar↔hub integration", () => { } }); - test("asset_materialization_failed reports as pack.reject corrupt", async () => { - const transport = createInMemoryTransport(); - const sessions = createMockSessionManager(); - sessions.applyAssetPack = () => { - throw new Error("asset_materialization_failed: pack index error"); - }; - - const client = createHubLink({ - capacity: DEFAULT_SIDECAR_CAPACITY, - hubURL: `ws://localhost:${env.server.port}/ws`, - sidecarId: "sc-asset-fail", - token: "test-token", - transport, - sessions, - ...withTestDeployBindings(), - }); - - client.connect(); - try { - await waitUntil(() => - env.router.getConnectedSidecars().includes("sc-asset-fail"), - ); - await sendAgentDeploy( - env.router, - "fail-asset@test.interchange", - TEST_CONFIG, - ); - - await expect( - sendPack( - env.router, - "fail-asset@test.interchange", - new Uint8Array([1, 2, 3]), - "refs/heads/main", - "a".repeat(40), - { mountPath: "skills/example/" }, - ), - ).rejects.toThrow("Pack rejected: corrupt"); - } finally { - client.close(); - await waitUntil( - () => !env.router.getConnectedSidecars().includes("sc-asset-fail"), - ); - } - }); - test("close cancels a pending reconnect scheduled by the previous disconnect", async () => { const reconnectEnv = startTestServer(); @@ -1692,7 +1225,6 @@ describe("sidecar↔hub integration", () => { }; const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, deploymentAddress); const client = createHubLink({ capacity: DEFAULT_SIDECAR_CAPACITY, hubURL: `ws://localhost:${env.server.port}/ws`, @@ -1752,7 +1284,6 @@ describe("sidecar↔hub integration", () => { }; const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, deploymentAddress); const client = createHubLink({ capacity: DEFAULT_SIDECAR_CAPACITY, hubURL: `ws://localhost:${env.server.port}/ws`, @@ -1837,7 +1368,6 @@ describe("sidecar↔hub integration", () => { }; const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, deploymentAddress); const client = createHubLink({ capacity: DEFAULT_SIDECAR_CAPACITY, hubURL: `ws://localhost:${env.server.port}/ws`, @@ -1882,7 +1412,6 @@ describe("sidecar↔hub integration", () => { }; const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, deploymentAddress); const client = createHubLink({ capacity: DEFAULT_SIDECAR_CAPACITY, hubURL: `ws://localhost:${env.server.port}/ws`, @@ -1927,7 +1456,6 @@ describe("sidecar↔hub integration", () => { }; const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, deploymentAddress); const client = createHubLink({ capacity: DEFAULT_SIDECAR_CAPACITY, hubURL: `ws://localhost:${env.server.port}/ws`, @@ -1965,7 +1493,6 @@ describe("sidecar↔hub integration", () => { const deploymentAddress = "run_srcnorouter@integration.interchange"; const bindings = withTestDeployBindings(); - await provisionDeploymentKey(bindings.keyStore, deploymentAddress); const client = createHubLink({ capacity: DEFAULT_SIDECAR_CAPACITY, hubURL: `ws://localhost:${env.server.port}/ws`, @@ -2019,7 +1546,6 @@ describe("sidecar↔hub integration", () => { ...acceptAnySidecar, validateSidecarIdentity: async () => true, requestTimeoutMs: 5000, - hubPublicKey: "a".repeat(64), lookups: { async receiveWorkflowRunPack(_repoId, _pack, _ref, _commitSha) { receiveCount += 1; @@ -2187,7 +1713,6 @@ describe("sidecar↔hub integration", () => { ...acceptAnySidecar, validateSidecarIdentity: async () => true, requestTimeoutMs: 5000, - hubPublicKey: "a".repeat(64), lookups: { async receiveWorkflowRunPack() { return { accepted: false, reason }; @@ -3147,10 +2672,8 @@ describe("initial handshake on connect", () => { requestId, agentAddress: address, generation, - agentId: TEST_CONFIG.agentId, config: TEST_CONFIG, - hubPublicKey: "a".repeat(64), - provisionStep: true, + workflow: TEST_WORKFLOW, }); }; const undeploy = (requestId: string, generation: number) => { @@ -3238,10 +2761,8 @@ describe("initial handshake on connect", () => { requestId: "deploy-1", agentAddress: address, generation: 1, - agentId: TEST_CONFIG.agentId, config: TEST_CONFIG, - hubPublicKey: "a".repeat(64), - provisionStep: true, + workflow: TEST_WORKFLOW, }); await deploying.promise; // The undeploy waits on the address's lane behind the deploy, and the @@ -3854,7 +3375,6 @@ describe("answerMalformedRequestFrame", () => { requestId: "req-deploy", agentAddress: "run_deploy@example.com", generation: 3, - agentId: "x", }, "config must be an object", (frame) => sent.push(frame), @@ -4061,55 +3581,26 @@ describe("answerMalformedRequestFrame", () => { }); }); -describe("classifyAssetPackRejectReason", () => { - test("classifies a symlink rejection as path_violation, not corrupt", () => { - expect( - classifyAssetPackRejectReason( - "asset_materialization_failed: writeTreeToDisk: symlink at link is not supported", - ), - ).toBe("path_violation"); - }); - - test("classifies a submodule rejection as path_violation", () => { - expect( - classifyAssetPackRejectReason( - "asset_materialization_failed: writeTreeToDisk: submodule reference at sub is not supported", - ), - ).toBe("path_violation"); +describe("classifyPackRejectReason", () => { + test("keeps sha_mismatch", () => { + expect(classifyPackRejectReason("sha_mismatch: ...")).toBe("sha_mismatch"); }); - test("classifies an escaping mountPath as path_violation", () => { + test("classifies a seed that would move existing history as conflict", () => { expect( - classifyAssetPackRejectReason( - 'asset_materialization_failed: mountPath "x/../y" normalizes to a workspace-root or escaping path', + classifyPackRejectReason( + "workflow_run_restore_conflict: refs/heads/main of run@example.com is already at abc here", ), - ).toBe("path_violation"); + ).toBe("conflict"); }); - test("classifies a missing-commit (bad bytes) rejection as corrupt", () => { + test("classifies anything else as corrupt", () => { expect( - classifyAssetPackRejectReason( - "asset_materialization_failed: indexPackIntoGitDir: expected commit abc not found in the pack", + classifyPackRejectReason( + "workflow_run_restore_invalid: a Hub pack must restore a workflow-run repo, not skill", ), ).toBe("corrupt"); }); - - test("keeps sha_mismatch and signature reasons", () => { - expect(classifyAssetPackRejectReason("sha_mismatch: ...")).toBe( - "sha_mismatch", - ); - expect(classifyAssetPackRejectReason("signature_unsigned: ...")).toBe( - "signature_invalid", - ); - }); - - test("classifies a seed that would move existing history as conflict", () => { - expect( - classifyAssetPackRejectReason( - "workflow_run_restore_conflict: refs/heads/main of run@example.com is already at abc here", - ), - ).toBe("conflict"); - }); }); describe("cleartextTransportWarning", () => { diff --git a/packages/hub-agent/src/ws/hub-link.ts b/packages/hub-agent/src/ws/hub-link.ts index bfaed8fe9..0586a28af 100644 --- a/packages/hub-agent/src/ws/hub-link.ts +++ b/packages/hub-agent/src/ws/hub-link.ts @@ -2,9 +2,7 @@ // // Connects to the hub, sends the hello frame, forwards outbound // mail and inference events, and handles inbound agent lifecycle -// commands. Per-agent key material lives on AgentKeyStore; the link calls into -// the store for deploy-commit verification and hub-key bookkeeping. The wire layer itself never -// touches raw key bytes. +// commands. The wire layer itself never touches raw key bytes. import { getLogger } from "@intx/log"; import type { HubTransport } from "@intx/mail-memory"; @@ -71,7 +69,6 @@ import type { InferenceEvent, } from "@intx/types/runtime"; -import type { AgentKeyStore } from "../agent-key-store"; import { isSenderPublicKeyHex } from "../sender-key-cache"; import type { SessionManager } from "../session-manager"; @@ -169,28 +166,13 @@ const PACK_REJECT_REQUEST_TYPES: ReadonlySet = new Set([ * and drops, because there is nothing to answer. */ /** - * Classify an `applyAssetPack` failure message into a `repo.pack.reject` reason. - * A structural rejection -- a symlink or submodule the checkout cannot reproduce - * faithfully, or a mountPath that escapes -- is a `path_violation`, distinct from - * `corrupt` (bad or incomplete bytes), and a workflow-run seed that would move - * history the sidecar already has is a `conflict`. The match is on the messages - * `writeTreeToDisk`, the mountPath guard and the seed guard raise; a wording - * drift only reverts the reason to `corrupt`, never misclassifies bytes as a - * path issue. The raw message rides on the frame's `detail` regardless, so the - * operator always sees the specific cause. + * Classify a pack apply failure message into a `repo.pack.reject` reason. The + * raw message rides on the frame's `detail` regardless, so the operator always + * sees the specific cause. */ -export function classifyAssetPackRejectReason(msg: string): PackRejectReason { +export function classifyPackRejectReason(msg: string): PackRejectReason { if (msg.startsWith("sha_mismatch")) return "sha_mismatch"; if (msg.startsWith("workflow_run_restore_conflict")) return "conflict"; - if ( - msg.startsWith("signature_invalid") || - msg.startsWith("signature_unsigned") - ) { - return "signature_invalid"; - } - if (/symlink at |submodule reference at |mountPath|escaping path/.test(msg)) { - return "path_violation"; - } return "corrupt"; } @@ -587,12 +569,6 @@ export type HubLinkConfig = { token: string; transport: HubTransport; sessions: SessionManager; - /** - * Key custody and per-frame crypto. HubLink calls into the store for - * deploy-commit verification, hub-key recording, and per-agent forgetting; - * it does not maintain its own copy of those tables. - */ - keyStore: AgentKeyStore; /** * Resolves a sender address to the crypto whose public key verifies that * sender's inbound mail, or `undefined` when no key is known. The inbound @@ -635,11 +611,9 @@ export type HubLinkConfig = { */ evictSenderKey: (address: string) => Promise; /** - * Routes every inbound `agent.deploy` frame. Production wiring + * Handles every inbound `agent.deploy` frame. Production wiring * supplies a router that stages each deploy through the workflow-run - * substrate: a provision-step frame primes a per-step repo, and a - * workflow frame spawns the supervised workflow-process child. The - * router owns the routing decision; the link does not re-decide. + * substrate and spawns the supervised workflow-process child. */ deployRouter: DeployRouter; /** @@ -860,7 +834,6 @@ export function createHubLink(config: HubLinkConfig): HubLink { token, transport, sessions, - keyStore, resolveSenderCrypto, lookupInboundMailPolicy, cacheSenderKey, @@ -1197,7 +1170,6 @@ export function createHubLink(config: HubLinkConfig): HubLink { } } - keyStore.forgetAgent(frame.agentAddress); forgetIncarnation(frame.agentAddress, frame.generation); if (failures.length > 0) { @@ -1316,9 +1288,9 @@ export function createHubLink(config: HubLinkConfig): HubLink { return; } - // A Hub pack stages a step or seeds a deployment's history before the - // deployment is deployed. Applying one over a held incarnation would - // rewrite what that deployment is running on. + // A Hub pack seeds a deployment's history before the deployment is + // deployed. Applying one over a held incarnation would rewrite what that + // deployment is running on. const held = incarnationOf(frame.agentAddress); if (held !== undefined) { reply({ @@ -1333,49 +1305,23 @@ export function createHubLink(config: HubLinkConfig): HubLink { } try { - if (frame.repoId.kind === "workflow-run") { - if (frame.mountPath !== undefined) { - throw new Error( - "workflow_run_restore_invalid: workflow-run packs cannot carry mountPath", - ); - } - if (applyWorkflowRunPack === undefined) { - throw new Error( - "workflow_run_restore_unconfigured: no workflow-run pack applier is configured", - ); - } - await applyWorkflowRunPack({ - agentAddress: frame.agentAddress, - repoId: frame.repoId, - pack: result.pack, - ref: result.ref, - commitSha: result.commitSha, - }); - } else if (frame.mountPath !== undefined) { - // Asset pack: route to the workspace materializer. Use - // frame.agentAddress for destination routing — frame.repoId.id - // names the source asset at the hub, which is a different - // entity than the destination agent. - await sessions.applyAssetPack( - frame.agentAddress, - frame.mountPath, - result.pack, - result.ref, - result.commitSha, + if (frame.repoId.kind !== "workflow-run") { + throw new Error( + `workflow_run_restore_invalid: a Hub pack must restore a workflow-run repo, not ${frame.repoId.kind}`, ); - } else { - const verifyCommit = (payload: string, signature: string) => - keyStore.verifyDeployCommit(frame.agentAddress, payload, signature); - - await sessions.applyDeployPack( - frame.agentAddress, - result.pack, - result.ref, - result.commitSha, - frame.transferId, - verifyCommit, + } + if (applyWorkflowRunPack === undefined) { + throw new Error( + "workflow_run_restore_unconfigured: no workflow-run pack applier is configured", ); } + await applyWorkflowRunPack({ + agentAddress: frame.agentAddress, + repoId: frame.repoId, + pack: result.pack, + ref: result.ref, + commitSha: result.commitSha, + }); reply({ type: "repo.pack.ack", agentAddress: frame.agentAddress, @@ -1384,7 +1330,7 @@ export function createHubLink(config: HubLinkConfig): HubLink { }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - const reason = classifyAssetPackRejectReason(msg); + const reason = classifyPackRejectReason(msg); logger.warn`Pack apply failed for ${frame.agentAddress}: ${msg}`; reply({ type: "repo.pack.reject", diff --git a/packages/hub-api/src/app.test.ts b/packages/hub-api/src/app.test.ts index 9784200d7..6156cb0ca 100644 --- a/packages/hub-api/src/app.test.ts +++ b/packages/hub-api/src/app.test.ts @@ -39,9 +39,6 @@ const sidecarRouter = createSidecarRouter({ validateSidecarIdentity: async () => true, }); const sessionService: SessionService = { - stageWorkflowStep(_params) { - throw new Error("mock: sessionService.stageWorkflowStep not implemented"); - }, endSession(_addr, _reason) { throw new Error("mock: sessionService.endSession not implemented"); }, diff --git a/packages/hub-api/src/app.ts b/packages/hub-api/src/app.ts index ec83c0907..604458dc4 100644 --- a/packages/hub-api/src/app.ts +++ b/packages/hub-api/src/app.ts @@ -76,11 +76,6 @@ import { ASSET_OPENAPI_EXCLUDE_GLOBS, createAssetRoutes, } from "./routes/assets"; -import { - AGENT_STATE_OPENAPI_EXCLUDE_GLOBS, - createAgentStateRunGitRoutes, - createAgentStateReceivePackDeny, -} from "./routes/agent-state-git"; import { createGitTokenAuth } from "./middleware/git-token-auth"; import { createRunTokenAuth } from "./middleware/run-token-auth"; @@ -287,22 +282,6 @@ export function mountHubRoutes( ); } - // Agent-state smart-HTTP read routes also use bearer auth. The - // receive-pack denial middleware mounts BEFORE bearer auth so an - // unauthenticated `git push -v` parses the pkt-line ERR record - // rather than a generic 401. The bearer middleware then gates the - // upload-pack half (advertise + POST) on a valid token. - if (repoStore !== null) { - app.use( - "/api/tenants/:tenantId/workflows/runs/:runId/state.git/*", - createAgentStateReceivePackDeny(), - ); - app.use( - "/api/tenants/:tenantId/workflows/runs/:runId/state.git/*", - createGitTokenAuth({ db }), - ); - } - // A workflow run token authenticates as its run principal. It mounts ahead // of resolveTenant, which then skips the session lookup, and it refuses // every route outside its allow-list. Any other request passes through. @@ -558,22 +537,6 @@ export function mountHubRoutes( ); } - if (repoStore !== null) { - // The folded run's agent-state clone surface. Mounts at `/workflows/runs` - // alongside the run-management routes; the git sub-paths (`:runId/state.git`) - // are disjoint from the run routes, and the literal `runs` segment - // out-ranks the `/workflows/:runId` deploy router. - app.route( - "/api/tenants/:tenantId/workflows/runs", - createAgentStateRunGitRoutes({ - db, - repoStore, - grantStore, - conditionRegistry, - }), - ); - } - if (sidecarWsHandler) { app.get("/api/sidecars/ws", sidecarWsHandler); } @@ -695,7 +658,6 @@ export function createApp({ "/status", "/api/auth/**", ...ASSET_OPENAPI_EXCLUDE_GLOBS, - ...AGENT_STATE_OPENAPI_EXCLUDE_GLOBS, ], }), ); diff --git a/packages/hub-api/src/git-http/advertise-refs.test.ts b/packages/hub-api/src/git-http/advertise-refs.test.ts index f3db41005..1e45f15af 100644 --- a/packages/hub-api/src/git-http/advertise-refs.test.ts +++ b/packages/hub-api/src/git-http/advertise-refs.test.ts @@ -11,7 +11,7 @@ import { type RefSource, } from "./advertise-refs"; -const REPO_ID: RepoId = { kind: "agent-state", id: "test" }; +const REPO_ID: RepoId = { kind: "skill", id: "test" }; function principalWith(refPattern: string): AdvertisePrincipal { return { diff --git a/packages/hub-api/src/git-http/receive-pack.test.ts b/packages/hub-api/src/git-http/receive-pack.test.ts index 93c83478d..565f4ed6b 100644 --- a/packages/hub-api/src/git-http/receive-pack.test.ts +++ b/packages/hub-api/src/git-http/receive-pack.test.ts @@ -3,7 +3,7 @@ import type { Principal, RepoId, RepoStore } from "@intx/hub-sessions"; import { handleReceivePack } from "./receive-pack"; -const REPO_ID: RepoId = { kind: "agent-state", id: "test" }; +const REPO_ID: RepoId = { kind: "skill", id: "test" }; const ZERO_OID = "0".repeat(40); function hex4(n: number): string { diff --git a/packages/hub-api/src/git-http/upload-pack.test.ts b/packages/hub-api/src/git-http/upload-pack.test.ts index 26d3db4f0..dd08aa745 100644 --- a/packages/hub-api/src/git-http/upload-pack.test.ts +++ b/packages/hub-api/src/git-http/upload-pack.test.ts @@ -12,7 +12,7 @@ import { } from "./upload-pack"; import type { RefEntry } from "./advertise-refs"; -const REPO_ID: RepoId = { kind: "agent-state", id: "test" }; +const REPO_ID: RepoId = { kind: "skill", id: "test" }; const tempDirs: string[] = []; diff --git a/packages/hub-api/src/route-registration.test.ts b/packages/hub-api/src/route-registration.test.ts index 2bf45dc74..06defb223 100644 --- a/packages/hub-api/src/route-registration.test.ts +++ b/packages/hub-api/src/route-registration.test.ts @@ -21,9 +21,6 @@ import { RUN_TOKEN_ROUTES } from "./middleware/run-token-auth"; const mockDb = {} as unknown as DB["db"]; const sessionService: SessionService = { - stageWorkflowStep(_params) { - throw new Error("mock: sessionService.stageWorkflowStep not implemented"); - }, endSession(_addr, _reason) { throw new Error("mock: sessionService.endSession not implemented"); }, @@ -181,18 +178,6 @@ const GRANT_EXEMPTIONS: GrantExemption[] = [ reason: "resolveSmartHttp (assets): git token, asset: verdict after the asset lookup", }, - { - method: "GET", - path: "/workflows/runs/:runId/state.git/info/refs", - reason: - "resolveSmartHttp (agent-state-git): git token, agent-state: verdict after the run lookup", - }, - { - method: "POST", - path: "/workflows/runs/:runId/state.git/git-upload-pack", - reason: - "resolveSmartHttp (agent-state-git): git token, agent-state: verdict after the run lookup", - }, ]; /** @@ -342,7 +327,6 @@ describe("tenant route grant checks", () => { "POST /workflows/deployments", "GET /git-tokens", "GET /assets", - "GET /workflows/runs/:runId/state.git/info/refs", ].map((route) => { const [method, path] = route.split(" "); return `${method} ${TENANT_PREFIX}${path}`; diff --git a/packages/hub-api/src/routes/agent-state-git.ts b/packages/hub-api/src/routes/agent-state-git.ts deleted file mode 100644 index 3dd0eb4f0..000000000 --- a/packages/hub-api/src/routes/agent-state-git.ts +++ /dev/null @@ -1,450 +0,0 @@ -/** - * Agent-state smart-HTTP route group. - * - * One URL grammar exposed under a Hono sub-app: - * - * /api/tenants/:tenantId/workflows/runs/:runId/state.git/... - * -> RepoId { kind: "agent-state", id: runId } - * (a folded run's runtime state, written by the sidecar's first - * state pack to `agents/`) - * - * The grammar is READ-ONLY over HTTP. Upload-pack - * (`info/refs?service=git-upload-pack` and `POST /git-upload-pack`) - * runs behind the same bearer middleware the asset routes use, with - * a pre-resolved authz verdict on the constructed UserPrincipal. - * Receive-pack - * (`info/refs?service=git-receive-pack` and `POST /git-receive-pack`) - * is denied at the edge with pkt-line-framed responses so a - * `git push -v` parses the protocol-level rejection even when no - * Authorization header is present. The receive-pack denial - * middleware is mounted BEFORE bearer middleware in the app layer; - * the substrate's `handleReceivePack` is NOT imported here — agent - * state never accepts writes over HTTP. - * - * The resolver verifies the folded run belongs to `:tenantId` and - * 404s otherwise. The per-run repo is lazily-materialised: on a - * never-pushed run, `listRefs` returns the empty list and the - * advertise layer emits the `capabilities^{}` empty-repo record so a - * stock `git clone` succeeds against an empty tree rather than 404ing. - */ - -import { and, eq, isNull, isNotNull } from "drizzle-orm"; -import { Hono, type Context } from "hono"; -import { createMiddleware } from "hono/factory"; -import type { MiddlewareHandler } from "hono"; - -import { workflowRun } from "@intx/db/schema"; -import type { DB } from "@intx/db"; -import { getLogger } from "@intx/log"; -import type { RepoId, RepoStore, UserPrincipal } from "@intx/hub-sessions"; -import type { RepoAction } from "@intx/types/repo"; -import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; - -import type { - GitTokenClaims, - TenantGitTokenEnv, -} from "../middleware/git-token-auth"; -import { advertiseUploadPack } from "../git-http/advertise-refs"; -import { handleUploadPack } from "../git-http/upload-pack"; -import { writePktLine, writeFlush } from "../git-http/pkt-line"; -import { errorResponse } from "../error-response"; -import { - buildUserPrincipal, - makeRefSource, - makeUploadPackStore, - resolveAuthzVerdict, -} from "./git-user-principal"; - -const log = getLogger(["hub", "agent-state-git"]); - -// ----- Receive-pack denial: pkt-line responses ----------------------- -// -// The advertise denial body is locked to: -// -// # service=git-receive-pack\n0000ERR agent-state is read-only over HTTP\n -// -// The leading `# service=` line and the `0000` flush packet mirror the -// shape stock git emits for a successful advertise; the trailing -// `ERR ...` substring is what `git push -v` surfaces as the visible -// rejection reason. The body is emitted verbatim (not pkt-line -// framed beyond the literal `0000` flush in the middle). -const RECEIVE_PACK_ADVERTISE_DENY_BODY = - "# service=git-receive-pack\n0000ERR agent-state is read-only over HTTP\n"; - -// The POST denial reason that surfaces in the `unpack` and per-ref -// `ng` pkt-lines. Short, stable, support-recognisable. -const RECEIVE_PACK_POST_DENY_REASON = "agent-state-readonly"; - -const RECEIVE_PACK_RESULT_CONTENT_TYPE = - "application/x-git-receive-pack-result"; -const RECEIVE_PACK_ADVERTISEMENT_CONTENT_TYPE = - "application/x-git-receive-pack-advertisement"; - -function parseHex4(buf: Uint8Array, off: number): number { - let v = 0; - for (let i = 0; i < 4; i++) { - const c = buf[off + i]; - if (c === undefined) { - throw new Error("truncated pkt-line: short header"); - } - let d: number; - if (c >= 0x30 && c <= 0x39) { - d = c - 0x30; - } else if (c >= 0x61 && c <= 0x66) { - d = c - 0x61 + 10; - } else if (c >= 0x41 && c <= 0x46) { - d = c - 0x41 + 10; - } else { - throw new Error("malformed pkt-line length"); - } - v = (v << 4) | d; - } - return v; -} - -/** - * Minimal parse of the receive-pack request body to extract the list - * of ref names. The full request grammar includes old/new shas and - * optional capability tail; we only care about the ref name for the - * `ng ` lines. Capabilities and packfile bytes that - * follow the flush packet are ignored. - */ -function extractReceivePackRefs(body: Uint8Array): string[] { - const decoder = new TextDecoder(); - const refs: string[] = []; - let off = 0; - while (off + 4 <= body.length) { - const length = parseHex4(body, off); - off += 4; - if (length === 0) { - // flush: end of commands - break; - } - if (length < 4) { - throw new Error(`reserved pkt-line length: ${length}`); - } - const bodyLen = length - 4; - if (off + bodyLen > body.length) { - throw new Error("truncated receive-pack pkt-line body"); - } - const line = decoder.decode(body.subarray(off, off + bodyLen)); - off += bodyLen; - // strip optional capability tail after \0 and trailing \n - const nulIdx = line.indexOf("\0"); - const head = nulIdx === -1 ? line : line.substring(0, nulIdx); - const trimmed = head.endsWith("\n") ? head.slice(0, -1) : head; - const parts = trimmed.split(" "); - if (parts.length < 3) continue; - const ref = parts.slice(2).join(" "); - if (ref.length > 0) refs.push(ref); - } - return refs; -} - -async function writeReceivePackDenyReport( - writer: WritableStreamDefaultWriter, - refs: readonly string[], -): Promise { - await writePktLine(writer, `unpack ${RECEIVE_PACK_POST_DENY_REASON}\n`); - for (const ref of refs) { - await writePktLine(writer, `ng ${ref} ${RECEIVE_PACK_POST_DENY_REASON}\n`); - } - await writeFlush(writer); -} - -function buildReceivePackPostDenyStream( - refs: readonly string[], -): ReadableStream { - return new ReadableStream({ - async start(controller) { - const sink = new WritableStream({ - write(chunk) { - controller.enqueue(chunk); - }, - }); - const writer = sink.getWriter(); - try { - await writeReceivePackDenyReport(writer, refs); - await writer.close(); - controller.close(); - } catch (cause) { - await writer.abort(cause).catch(() => undefined); - controller.error(cause); - } - }, - }); -} - -// ----- Receive-pack deny middleware --------------------------------- -// -// Mounted ahead of the bearer middleware. Intercepts: -// - GET .../info/refs?service=git-receive-pack -> 403 with locked body -// - POST .../git-receive-pack -> 200 pkt-line denial -// Other requests (upload-pack info/refs and POST) call next() so the -// subsequent bearer + upload-pack handlers run normally. - -function urlPathEndsWith(path: string, suffix: string): boolean { - return path === suffix || path.endsWith(suffix); -} - -/** - * Hono middleware that denies any receive-pack request reaching the - * agent-state route surface, regardless of bearer-token presence. - * Mount BEFORE `createGitTokenAuth` so unauthenticated `git push -v` - * sees the locked pkt-line ERR rather than a 401. - */ -export function createAgentStateReceivePackDeny(): MiddlewareHandler { - return createMiddleware(async (c, next) => { - const method = c.req.method.toUpperCase(); - const path = c.req.path; - - if (method === "GET" && urlPathEndsWith(path, "/info/refs")) { - const service = c.req.query("service"); - if (service === "git-receive-pack") { - log.info("receive-pack advertise denied {path}", { path }); - return new Response(RECEIVE_PACK_ADVERTISE_DENY_BODY, { - status: 403, - headers: { - "content-type": RECEIVE_PACK_ADVERTISEMENT_CONTENT_TYPE, - "cache-control": "no-cache", - }, - }); - } - // upload-pack advertise: fall through. - await next(); - return; - } - - if (method === "POST" && urlPathEndsWith(path, "/git-receive-pack")) { - log.info("receive-pack POST denied {path}", { path }); - let refs: string[] = []; - try { - const body = new Uint8Array(await c.req.raw.arrayBuffer()); - refs = extractReceivePackRefs(body); - } catch (err) { - // Malformed body still gets a deny report with no per-ref lines. - log.info("receive-pack POST: body parse failed {err}", { - err: err instanceof Error ? err.message : String(err), - }); - refs = []; - } - const stream = buildReceivePackPostDenyStream(refs); - return new Response(stream, { - status: 200, - headers: { - "content-type": RECEIVE_PACK_RESULT_CONTENT_TYPE, - "cache-control": "no-cache", - }, - }); - } - - await next(); - }); -} - -// User-principal construction helpers shared with the asset smart-HTTP -// group live in ./git-user-principal. - -// ----- Resolver shape ----------------------------------------------- - -type SmartHttpResolved = { - principal: UserPrincipal; - repoId: RepoId; -}; - -type ResolveResult = - | { ok: true; resolved: SmartHttpResolved } - | { - ok: false; - status: 400 | 403 | 404; - code: string; - message: string; - }; - -async function resolveAgentStateId( - db: DB["db"], - tenantId: string, - paramId: string, -): Promise<{ ok: true; id: string } | { ok: false; reason: string }> { - // Only a folded launch run owns state at `agents/`: it is born with a - // routing address and no deployment (`anchorRunId IS NULL AND address IS NOT - // NULL`), so its sidecar persists via the plain-address path. A deployment - // anchor/child run keeps its state under `workflow-runs/` instead, so - // the shape gate keeps those out of this route. Deliberately NOT gated on - // status: a stopped run's final state is exactly what a read-only clone - // serves, matching the legacy instance route -- do not add a `running` guard. - const row = await db.query.workflowRun.findFirst({ - where: and( - eq(workflowRun.id, paramId), - eq(workflowRun.tenantId, tenantId), - isNull(workflowRun.anchorRunId), - isNotNull(workflowRun.address), - ), - }); - if (row === undefined) { - return { ok: false, reason: `no run ${paramId} in tenant` }; - } - return { ok: true, id: row.id }; -} - -type ResolveSmartHttpDeps = { - db: DB["db"]; - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; -}; - -// The smart-HTTP routes check their grant here, after the URL names the -// run; see GRANT_EXEMPTIONS in route-registration.test.ts. -async function resolveSmartHttp( - deps: ResolveSmartHttpDeps, - c: Context, - action: RepoAction, -): Promise { - const tenantRow = c.get("tenant"); - const principalRow = c.get("principal"); - const claims: GitTokenClaims = c.get("git-token-claims"); - // The typed env makes this unreachable today, but if the route - // module is ever mounted without the bearer middleware ahead of - // it, surface a misconfiguration rather than a downstream - // TypeError. A 401 would imply the client was unauthenticated; - // a missing claims object means the server is misconfigured. - if (claims === undefined) { - throw new Error( - "smart-HTTP route handler invoked without bearer middleware; check the mount order in app.ts", - ); - } - if (!claims.actions.includes(action)) { - return { - ok: false, - status: 403, - code: "forbidden", - message: `token claims do not include action ${action}`, - }; - } - const paramId = c.req.param("runId"); - if (paramId === undefined) { - return { - ok: false, - status: 400, - code: "bad_request", - message: "missing :runId in URL", - }; - } - const tenantId = tenantRow.id; - const resolved = await resolveAgentStateId(deps.db, tenantId, paramId); - if (!resolved.ok) { - return { - ok: false, - status: 404, - code: "not_found", - message: resolved.reason, - }; - } - const authz = await resolveAuthzVerdict({ - grantStore: deps.grantStore, - conditionRegistry: deps.conditionRegistry, - principalId: principalRow.id, - tenantId, - resource: `agent-state:${resolved.id}`, - action, - // Only a run token carries a consumer, and the git token middleware - // refuses run tokens. - consumer: undefined, - }); - if (authz.effect !== "allow") { - log.info( - "agent-state authz denied {tenantId} run={id} principal={principalId}", - { - tenantId, - id: resolved.id, - principalId: principalRow.id, - }, - ); - return { - ok: false, - status: 403, - code: "forbidden", - message: "authz denied", - }; - } - const principal = buildUserPrincipal({ - principalId: principalRow.id, - tenantId, - authz, - claims, - }); - const repoId: RepoId = { kind: "agent-state", id: resolved.id }; - return { ok: true, resolved: { principal, repoId } }; -} - -// ----- Upload-pack route factory ------------------------------------ - -export type CreateAgentStateGitRoutesDeps = { - db: DB["db"]; - repoStore: RepoStore; - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; -}; - -export function createAgentStateRunGitRoutes( - deps: CreateAgentStateGitRoutesDeps, -): Hono { - const app = new Hono(); - - app.get("/:runId/state.git/info/refs", async (c) => { - const service = c.req.query("service"); - if (service !== "git-upload-pack") { - // The receive-pack case is handled by the deny middleware above; - // anything else is a bad request. - return errorResponse( - c, - "bad_request", - "info/refs requires service=git-upload-pack", - ); - } - const r = await resolveSmartHttp(deps, c, "resolveRef"); - if (!r.ok) { - return c.json({ error: { code: r.code, message: r.message } }, r.status); - } - const refSource = makeRefSource(deps.repoStore, r.resolved.principal); - const stream = await advertiseUploadPack( - refSource, - r.resolved.principal, - r.resolved.repoId, - ); - return new Response(stream, { - status: 200, - headers: { - "content-type": "application/x-git-upload-pack-advertisement", - "cache-control": "no-cache", - }, - }); - }); - - app.post("/:runId/state.git/git-upload-pack", async (c) => { - const r = await resolveSmartHttp(deps, c, "createPack"); - if (!r.ok) { - return c.json({ error: { code: r.code, message: r.message } }, r.status); - } - return handleUploadPack( - makeUploadPackStore(deps.repoStore, r.resolved.principal), - r.resolved.principal, - r.resolved.repoId, - c.req.raw, - ); - }); - - return app; -} - -/** - * Smart-HTTP paths excluded from the OpenAPI document. The agent-state - * routes serve binary git wire vocabulary; advertising them in the - * generated spec would invite client codegen to treat them as JSON - * endpoints. - */ -export const AGENT_STATE_OPENAPI_EXCLUDE_GLOBS = [ - "/api/tenants/*/workflows/runs/*/state.git/info/refs", - "/api/tenants/*/workflows/runs/*/state.git/git-upload-pack", - "/api/tenants/*/workflows/runs/*/state.git/git-receive-pack", -] as const; diff --git a/packages/hub-api/src/routes/approvals.test.ts b/packages/hub-api/src/routes/approvals.test.ts index 0f46348ee..820bf78b7 100644 --- a/packages/hub-api/src/routes/approvals.test.ts +++ b/packages/hub-api/src/routes/approvals.test.ts @@ -311,7 +311,6 @@ function createMockSessionService(): SessionService { throw new Error(`mock: sessionService.${name} not implemented`); } return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/packages/hub-api/src/routes/assets.test.ts b/packages/hub-api/src/routes/assets.test.ts index a29ad131e..8ec210383 100644 --- a/packages/hub-api/src/routes/assets.test.ts +++ b/packages/hub-api/src/routes/assets.test.ts @@ -105,7 +105,7 @@ afterAll(async () => { type AssetRow = { id: string; tenantId: string; - kind: "agent-state" | "skill" | "package-registry" | "workflow"; + kind: "skill" | "package-registry" | "workflow"; name: string; displayName: string | null; creatorPrincipalId: string | null; @@ -295,9 +295,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => { - throw new Error("mock: sessionService.stageWorkflowStep not implemented"); - }, endSession: () => { throw new Error("mock: sessionService.endSession not implemented"); }, diff --git a/packages/hub-api/src/routes/assets.ts b/packages/hub-api/src/routes/assets.ts index 0f4a519a7..f42578f1e 100644 --- a/packages/hub-api/src/routes/assets.ts +++ b/packages/hub-api/src/routes/assets.ts @@ -110,12 +110,7 @@ export const SANE_GITIGNORE = [ // REST contract ----------------------------------------------------- -const KIND_VALUES = [ - "agent-state", - "skill", - "package-registry", - "workflow", -] as const; +const KIND_VALUES = ["skill", "package-registry", "workflow"] as const; const CreateAsset = type({ kind: type.enumerated(...KIND_VALUES), @@ -143,12 +138,7 @@ const AssetResponseSchema = type({ const ASSET_NAME_URL_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; function parseKind(raw: string): RepoKind | null { - if ( - raw === "agent-state" || - raw === "skill" || - raw === "package-registry" || - raw === "workflow" - ) { + if (raw === "skill" || raw === "package-registry" || raw === "workflow") { return raw; } return null; @@ -159,8 +149,7 @@ function stripGitSuffix(raw: string): string | null { return raw.slice(0, -".git".length); } -// User-principal construction helpers shared with the agent-state -// smart-HTTP group live in ./git-user-principal. +// User-principal construction helpers live in ./git-user-principal. type AssetLookup = { id: string; @@ -863,8 +852,7 @@ export function createAssetRoutes({ }; } let narrowedKind: RepoKind; - if (row.kind === "agent-state") narrowedKind = "agent-state"; - else if (row.kind === "skill") narrowedKind = "skill"; + if (row.kind === "skill") narrowedKind = "skill"; else if (row.kind === "package-registry") narrowedKind = "package-registry"; else if (row.kind === "workflow") narrowedKind = "workflow"; else { diff --git a/packages/hub-api/src/routes/catalog-routes.test.ts b/packages/hub-api/src/routes/catalog-routes.test.ts index e6dc82426..3c59978e3 100644 --- a/packages/hub-api/src/routes/catalog-routes.test.ts +++ b/packages/hub-api/src/routes/catalog-routes.test.ts @@ -222,9 +222,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => { - throw new Error("mock: sessionService.stageWorkflowStep not implemented"); - }, endSession: () => { throw new Error("mock: sessionService.endSession not implemented"); }, diff --git a/packages/hub-api/src/routes/git-tokens.test.ts b/packages/hub-api/src/routes/git-tokens.test.ts index 766c7f545..88faa5283 100644 --- a/packages/hub-api/src/routes/git-tokens.test.ts +++ b/packages/hub-api/src/routes/git-tokens.test.ts @@ -229,9 +229,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => { - throw new Error("mock: sessionService.stageWorkflowStep not implemented"); - }, endSession: () => { throw new Error("mock: sessionService.endSession not implemented"); }, diff --git a/packages/hub-api/src/routes/git-user-principal.ts b/packages/hub-api/src/routes/git-user-principal.ts index d1b5798d6..64d9f725e 100644 --- a/packages/hub-api/src/routes/git-user-principal.ts +++ b/packages/hub-api/src/routes/git-user-principal.ts @@ -1,17 +1,10 @@ /** - * Shared smart-HTTP glue for the bearer-token git route groups - * (`assets.ts` and `agent-state-git.ts`). + * Smart-HTTP glue for the bearer-token git routes in `assets.ts`. * - * Both route groups resolve a URL to a `RepoId`, pre-resolve the - * authz verdict against the grant store, and construct a - * `UserPrincipal` carrying that verdict so the substrate's authorize - * gate only sanity-checks rather than re-querying. The verdict - * resolution, principal construction, and the two substrate adapters - * are identical between the groups — the only per-kind input is the - * verdict's `resource` string (`asset:` vs - * `agent-state:`). Keeping them here, rather than copy-pasted - * into each route file, stops the security-critical claim/verdict - * cross-check boundary from drifting between kinds. + * The routes resolve a URL to a `RepoId`, pre-resolve the authz verdict + * against the grant store, and construct a `UserPrincipal` carrying that + * verdict so the substrate's authorize gate only sanity-checks rather than + * re-querying. * * Bearer-claim `expiresAt` is a `Date` on the wire; the substrate's * `UserPrincipal.tokenClaims.expiresAt` is a `number`. The Date → @@ -36,7 +29,7 @@ function dateToNumber(d: Date): number { /** * Pre-resolve the grant verdict for a user-token-authenticated * smart-HTTP request. `resource` is the full verdict resource string - * for this kind (`asset:`, `agent-state:`); the grant verb is + * for this kind (`asset:`); the grant verb is * derived from `action`. The route layer attaches the returned * verdict to the constructed `UserPrincipal`; the substrate does NOT * re-query the grant store. diff --git a/packages/hub-api/src/routes/runs.test.ts b/packages/hub-api/src/routes/runs.test.ts index cbdc7de7c..22ffcc31d 100644 --- a/packages/hub-api/src/routes/runs.test.ts +++ b/packages/hub-api/src/routes/runs.test.ts @@ -335,9 +335,6 @@ function createMockSessionService(): SessionService { throw new Error(`mock: sessionService.${name} not implemented`); } return { - stageWorkflowStep(_params) { - return notImpl("stageWorkflowStep"); - }, endSession(_addr, _reason) { return notImpl("endSession"); }, diff --git a/packages/hub-api/src/routes/workflow-definitions.test.ts b/packages/hub-api/src/routes/workflow-definitions.test.ts index fbf92b756..c6b67846f 100644 --- a/packages/hub-api/src/routes/workflow-definitions.test.ts +++ b/packages/hub-api/src/routes/workflow-definitions.test.ts @@ -193,7 +193,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/packages/hub-api/src/routes/workflows.test.ts b/packages/hub-api/src/routes/workflows.test.ts index 1ac07a405..2d27d5272 100644 --- a/packages/hub-api/src/routes/workflows.test.ts +++ b/packages/hub-api/src/routes/workflows.test.ts @@ -640,7 +640,6 @@ function createMockSessionService(): SessionService { throw new Error(`mock: sessionService.${name} not implemented`); } return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/packages/hub-api/src/routes/workflows.ts b/packages/hub-api/src/routes/workflows.ts index b991c5485..fbaced06e 100644 --- a/packages/hub-api/src/routes/workflows.ts +++ b/packages/hub-api/src/routes/workflows.ts @@ -439,7 +439,6 @@ export function createWorkflowRoutes({ sourceAuthorityPrincipalId: c.get("principal").id, sourceOfferingIds: body.sourceOfferingIds, defaultSourceOfferingId: body.defaultSourceOfferingId, - deployContent: { systemPrompt: "" }, ...(body.stepState !== undefined ? { stepState: body.stepState } : {}), diff --git a/packages/hub-api/src/run-grant-materialization.ts b/packages/hub-api/src/run-grant-materialization.ts index ffd636a99..0e3e739e1 100644 --- a/packages/hub-api/src/run-grant-materialization.ts +++ b/packages/hub-api/src/run-grant-materialization.ts @@ -415,9 +415,8 @@ function snapshotHasMailTrigger(snapshot: GrantWalkSnapshot): boolean { } /** - * Project a materialized run grant row into the `run.grants` wire shape -- - * the same `WireGrantRule` encoding the `agent.deploy` frame's - * `config.grants` ships. A run grant is always principal-scoped and never + * Project a materialized run grant row into the `run.grants` wire shape. A + * run grant is always principal-scoped and never * role-scoped, so `roleId` is null and `principalId` is the run principal. */ export function runGrantToWire( diff --git a/packages/hub-api/src/timeline-reconstruction.test.ts b/packages/hub-api/src/timeline-reconstruction.test.ts deleted file mode 100644 index 359875a57..000000000 --- a/packages/hub-api/src/timeline-reconstruction.test.ts +++ /dev/null @@ -1,812 +0,0 @@ -import { describe, test, expect, afterAll } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import git from "isomorphic-git"; -import { - IsogitStore, - initAgentRepo, - createMailAuditStore, -} from "@intx/storage-isogit/node"; -import type { ConversationTurn } from "@intx/types/runtime"; -import type { ErrorRecord } from "@intx/types/audit"; -import { reconstructTimeline } from "./timeline-reconstruction"; - -const tempDirs: string[] = []; - -async function makeTempDir(): Promise { - const d = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "timeline-recon-"), - ); - tempDirs.push(d); - return d; -} - -afterAll(async () => { - for (const d of tempDirs) { - await fs.promises.rm(d, { recursive: true, force: true }); - } -}); - -function userMessage(text: string, timestamp = Date.now()): ConversationTurn { - return { role: "user", content: [{ type: "text", text }], timestamp }; -} - -function assistantMessage( - text: string, - timestamp = Date.now(), -): ConversationTurn { - return { - role: "assistant", - content: [{ type: "text", text }], - model: "test-model", - timestamp, - }; -} - -function toolCallMessage( - callId: string, - name: string, - args: Record, - timestamp = Date.now(), -): ConversationTurn { - return { - role: "assistant", - content: [{ type: "tool_call", id: callId, name, arguments: args }], - model: "test-model", - timestamp, - }; -} - -function toolResultMessage( - callId: string, - result: string, - timestamp = Date.now(), -): ConversationTurn { - return { - role: "user", - content: [ - { - type: "tool_result", - callId, - content: [{ type: "text", text: result }], - }, - ], - timestamp, - }; -} - -function buildRawMessage(opts: { - messageId: string; - from?: string; - to?: string; - inReplyTo?: string; - body?: string; -}): Uint8Array { - const lines: string[] = []; - lines.push(`Message-ID: ${opts.messageId}`); - lines.push(`From: ${opts.from ?? "sender@example.com"}`); - lines.push(`To: ${opts.to ?? "recipient@example.com"}`); - lines.push(`Date: ${new Date().toUTCString()}`); - if (opts.inReplyTo !== undefined) { - lines.push(`In-Reply-To: ${opts.inReplyTo}`); - } - lines.push(""); - lines.push(opts.body ?? "test body"); - return new TextEncoder().encode(lines.join("\r\n")); -} - -describe("reconstructTimeline", () => { - test("reconstructs a single-turn conversation", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - const t = 1700000000000; - const messages: ConversationTurn[] = [ - userMessage("Hello", t), - assistantMessage("Hi there", t + 1000), - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: inference-done" }); - - const result = await reconstructTimeline(dir); - - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns).toHaveLength(1); - expect(turns[0]?.content).toBe("Hi there"); - // Should use the per-message timestamp, not the git commit timestamp - expect(turns[0]?.timestamp).toBe(t + 1000); - // Status derived from checkpoint reason - expect(turns[0]?.kind === "turn" && turns[0].status).toBe("completed"); - }); - - test("surfaces refusal-only assistant turns in the timeline summary", async () => { - // A turn whose only content is a RefusalBlock (OpenAI strict- - // mode policy decline) must appear on the timeline with the - // refusal text as the turn content. Filtering to text-only - // blocks would render the turn invisible even though the model - // produced coherent output. - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - const t = 1700000000000; - const messages: ConversationTurn[] = [ - userMessage("Tell me how to do something policy-tripping.", t), - { - role: "assistant", - content: [{ type: "refusal", reason: "I cannot help with that." }], - model: "gpt-test", - timestamp: t + 1000, - }, - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: inference-done" }); - - const result = await reconstructTimeline(dir); - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns).toHaveLength(1); - expect(turns[0]?.content).toBe("I cannot help with that."); - expect(turns[0]?.timestamp).toBe(t + 1000); - }); - - test("surfaces safety-only assistant turns in the timeline summary", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - const t = 1700000000000; - const messages: ConversationTurn[] = [ - userMessage("Describe prohibited content.", t), - { - role: "assistant", - content: [{ type: "safety_rating", blockReason: "PROHIBITED_CONTENT" }], - model: "gemini-2.5-flash", - timestamp: t + 1000, - }, - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: inference-done" }); - - const result = await reconstructTimeline(dir); - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns).toHaveLength(1); - expect(turns[0]?.content).toBe("Request blocked: PROHIBITED_CONTENT"); - expect(turns[0]?.timestamp).toBe(t + 1000); - }); - - test("reconstructs multi-turn conversations across checkpoints", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - const t = 1700000000000; - - // First turn - const messages1: ConversationTurn[] = [ - userMessage("Hello", t), - assistantMessage("Hi there", t + 1000), - ]; - await store.writeTurns(messages1); - - await store.commit({ message: "checkpoint: inference-done" }); - - // Second turn (appends to the same message array) - const messages2: ConversationTurn[] = [ - ...messages1, - userMessage("How are you?", t + 5000), - assistantMessage("I'm doing well", t + 6000), - ]; - await store.writeTurns(messages2); - - await store.commit({ message: "checkpoint: inference-done" }); - - const result = await reconstructTimeline(dir); - - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns).toHaveLength(2); - expect(turns[0]?.content).toBe("Hi there"); - expect(turns[1]?.content).toBe("I'm doing well"); - - // Should use per-message timestamps - expect(turns[0]?.timestamp).toBe(t + 1000); - expect(turns[1]?.timestamp).toBe(t + 6000); - }); - - test("treats a tool-use loop as a single turn", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - const messages: ConversationTurn[] = [ - userMessage("What's the weather?"), - toolCallMessage("call-1", "get_weather", { city: "SF" }), - toolResultMessage("call-1", "72F and sunny"), - assistantMessage("The weather in SF is 72F and sunny."), - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: inference-done" }); - - const result = await reconstructTimeline(dir); - - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns).toHaveLength(1); - expect(turns[0]?.content).toBe("The weather in SF is 72F and sunny."); - }); - - test("reconstructs mail events", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - - const mailStore = await createMailAuditStore(dir); - - const inbound = buildRawMessage({ - messageId: "", - from: "alice@example.com", - to: "agent@example.com", - body: "Please help me", - }); - await mailStore.commitMail(inbound, "in"); - - const outbound = buildRawMessage({ - messageId: "", - from: "agent@example.com", - to: "alice@example.com", - inReplyTo: "", - body: "Sure, how can I help?", - }); - await mailStore.commitMail(outbound, "out"); - - const result = await reconstructTimeline(dir); - - const mailEvents = result.events.filter((e) => e.kind === "mail"); - expect(mailEvents).toHaveLength(2); - - const inboundEvent = mailEvents.find( - (e) => e.kind === "mail" && e.direction === "in", - ); - expect(inboundEvent).toBeDefined(); - if (inboundEvent !== undefined && inboundEvent.kind === "mail") { - expect(inboundEvent.messageId).toBe(""); - } - - const outboundEvent = mailEvents.find( - (e) => e.kind === "mail" && e.direction === "out", - ); - expect(outboundEvent).toBeDefined(); - if (outboundEvent !== undefined && outboundEvent.kind === "mail") { - expect(outboundEvent.messageId).toBe(""); - } - }); - - test("links outbound mail to checkpoint via Checkpoint trailer", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - const t = 1700000000000; - const messages: ConversationTurn[] = [ - userMessage("Hello", t), - assistantMessage("Hi there", t + 1000), - ]; - await store.writeTurns(messages); - - const commit = await store.commit({ - message: "checkpoint: inference-done", - }); - - const mailStore = await createMailAuditStore(dir); - const outbound = buildRawMessage({ - messageId: "", - from: "agent@example.com", - to: "alice@example.com", - body: "Hi there", - }); - await mailStore.commitMail(outbound, "out", { - checkpointHash: commit.hash, - }); - - const result = await reconstructTimeline(dir); - - const mailEvents = result.events.filter((e) => e.kind === "mail"); - expect(mailEvents).toHaveLength(1); - const mailEvent = mailEvents[0]; - expect(mailEvent).toBeDefined(); - if (mailEvent !== undefined && mailEvent.kind === "mail") { - expect(mailEvent.checkpointHash).toBe(commit.hash); - } - }); - - test("associates error records from git with the preceding turn", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - // Write a conversation first - const messages: ConversationTurn[] = [ - userMessage("Do something risky"), - assistantMessage("Attempting..."), - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: inference-done" }); - - // Write error records (committed to git by the store) - const errors: ErrorRecord[] = [ - { - source: "inference", - category: "rate_limit", - message: "Rate limit exceeded", - fatal: false, - timestamp: new Date().toISOString(), - sessionId: "test-session", - seq: 0, - }, - ]; - await store.commitErrors(errors); - - const result = await reconstructTimeline(dir); - - // Errors should be attached to the preceding turn, not a synthetic one - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns).toHaveLength(1); - const turn = turns[0]; - expect(turn?.kind === "turn" && turn.isError).toBe(true); - expect(turn?.kind === "turn" && turn.errors).toHaveLength(1); - if (turn?.kind === "turn") { - expect(turn.errors?.[0]?.category).toBe("rate_limit"); - } - }); - - test("handles message-count regression gracefully", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - // First checkpoint with 4 messages - const messages1: ConversationTurn[] = [ - userMessage("Hello"), - assistantMessage("Hi"), - userMessage("More"), - assistantMessage("Sure"), - ]; - await store.writeTurns(messages1); - - await store.commit({ message: "checkpoint: inference-done" }); - - // Second checkpoint with only 2 messages (regression) - const messages2: ConversationTurn[] = [ - userMessage("Fresh start"), - assistantMessage("OK"), - ]; - await store.writeTurns(messages2); - - await store.commit({ message: "checkpoint: inference-done" }); - - const result = await reconstructTimeline(dir); - - // Should not crash — should produce a gap record - const regressionGap = result.gaps.find( - (g) => g.kind === "message-count-regression", - ); - expect(regressionGap).toBeDefined(); - - // Should still produce turn events from what it can reconstruct - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns.length).toBeGreaterThan(0); - }); - - test("interleaves mail and turn events by timestamp", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - const mailStore = await createMailAuditStore(dir); - - // Inbound mail first - const inbound = buildRawMessage({ - messageId: "", - body: "Hello agent", - }); - await mailStore.commitMail(inbound, "in"); - - // Then a turn - const messages: ConversationTurn[] = [ - userMessage("Hello"), - assistantMessage("Hi there"), - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: inference-done" }); - - const result = await reconstructTimeline(dir); - - // Both kinds should be present - const mailEvents = result.events.filter((e) => e.kind === "mail"); - const turnEvents = result.events.filter((e) => e.kind === "turn"); - expect(mailEvents).toHaveLength(1); - expect(turnEvents).toHaveLength(1); - - // Mail should come before turn (committed first) - if (result.events[0] !== undefined && result.events[1] !== undefined) { - expect(result.events[0].timestamp).toBeLessThanOrEqual( - result.events[1].timestamp, - ); - } - }); - - test("produces no gaps for a well-formed checkpoint", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - const t = 1700000000000; - const messages: ConversationTurn[] = [ - userMessage("Hello", t), - assistantMessage("Hi", t + 1000), - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: inference-done" }); - - const result = await reconstructTimeline(dir); - - expect(result.gaps).toHaveLength(0); - }); - - test("marks turns as error when checkpoint reason is inference-error", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - const t = 1700000000000; - const messages: ConversationTurn[] = [ - userMessage("Hello", t), - assistantMessage("Something went wrong", t + 1000), - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: inference-error" }); - - const result = await reconstructTimeline(dir); - - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns).toHaveLength(1); - expect(turns[0]?.kind === "turn" && turns[0].status).toBe("error"); - expect(turns[0]?.kind === "turn" && turns[0].isError).toBe(true); - }); - - test("marks turns as in-progress for mid-turn checkpoints", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - const t = 1700000000000; - const messages: ConversationTurn[] = [ - userMessage("What's the weather?", t), - toolCallMessage("call-1", "get_weather", { city: "SF" }, t + 1000), - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: tool-execution" }); - - // Add tool result and final response - const messages2: ConversationTurn[] = [ - ...messages, - toolResultMessage("call-1", "72F", t + 2000), - assistantMessage("It's 72F in SF", t + 3000), - ]; - await store.writeTurns(messages2); - - await store.commit({ message: "checkpoint: inference-done" }); - - const result = await reconstructTimeline(dir); - - const turns = result.events.filter((e) => e.kind === "turn"); - // tool-execution checkpoint has no text content, so no turn emitted - // inference-done checkpoint has the final response - expect(turns).toHaveLength(1); - expect(turns[0]?.kind === "turn" && turns[0].status).toBe("completed"); - }); - - test("handles empty repo with no checkpoints", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - - const result = await reconstructTimeline(dir); - - expect(result.events).toHaveLength(0); - expect(result.gaps).toBeDefined(); - }); - - test("records a gap when a checkpoint commit has corrupt context", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - // Write a valid checkpoint first - const messages: ConversationTurn[] = [ - userMessage("Hello"), - assistantMessage("Hi"), - ]; - await store.writeTurns(messages); - - await store.commit({ message: "checkpoint: inference-done" }); - - // Write a corrupt checkpoint directly via git - const turnsPath = path.join(dir, "turns.jsonl"); - await fs.promises.writeFile(turnsPath, "NOT VALID JSON"); - await git.add({ fs, dir, filepath: "turns.jsonl" }); - await git.commit({ - fs, - dir, - message: "checkpoint: inference-done", - author: { name: "test", email: "test@test.dev" }, - }); - - const result = await reconstructTimeline(dir); - - // Should still have the valid turn from the first checkpoint - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns).toHaveLength(1); - - // Should record a gap for the corrupt checkpoint - const corruptGaps = result.gaps.filter( - (g) => g.kind === "corrupt-checkpoint", - ); - expect(corruptGaps).toHaveLength(1); - }); - - test("records gaps for corrupt error record files in git", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - await store.writeTurns([userMessage("Hello"), assistantMessage("Hi")]); - await store.commit({ message: "checkpoint: inference-done" }); - - // Commit a valid error record via the store - await store.commitErrors([ - { - source: "inference", - category: "rate_limit", - message: "Rate limited", - fatal: false, - timestamp: new Date().toISOString(), - sessionId: "test-session", - seq: 0, - }, - ]); - - // Manually commit a corrupt error file directly via git - const errorsDir = path.join(dir, "state/errors/test-session"); - await fs.promises.writeFile( - path.join(errorsDir, "00000001-corrupt.json"), - "NOT VALID JSON", - ); - await git.add({ - fs, - dir, - filepath: "state/errors/test-session/00000001-corrupt.json", - }); - await git.commit({ - fs, - dir, - message: "Record 1 error record", - author: { name: "test", email: "test@test.dev" }, - }); - - const result = await reconstructTimeline(dir); - - // Valid error should be associated with the preceding turn - const turns = result.events.filter((e) => e.kind === "turn"); - expect(turns).toHaveLength(1); - const turn = turns[0]; - expect(turn?.kind === "turn" && turn.isError).toBe(true); - expect(turn?.kind === "turn" && turn.errors?.length).toBeGreaterThanOrEqual( - 1, - ); - - // Corrupt file should produce a gap - const corruptGaps = result.gaps.filter( - (g) => g.kind === "corrupt-error-record", - ); - expect(corruptGaps).toHaveLength(1); - expect(corruptGaps[0]?.description).toContain("00000001-corrupt.json"); - }); - - test("reconstructs a full multi-step session from git", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - const mailStore = await createMailAuditStore(dir); - - const t = 1700000000000; - - // 1. Inbound mail arrives - const inbound = buildRawMessage({ - messageId: "", - from: "alice@example.com", - to: "agent@example.com", - body: "What is the weather in SF and NYC?", - }); - await mailStore.commitMail(inbound, "in"); - - // 2. First inference: agent decides to call a tool - const msgs1: ConversationTurn[] = [ - userMessage("What is the weather in SF and NYC?", t), - toolCallMessage("call-1", "get_weather", { city: "SF" }, t + 1000), - ]; - await store.writeTurns(msgs1); - - await store.commit({ message: "checkpoint: tool-execution" }); - - // 3. Tool result comes back - const msgs2: ConversationTurn[] = [ - ...msgs1, - toolResultMessage("call-1", "72F and sunny", t + 2000), - ]; - await store.writeTurns(msgs2); - - await store.commit({ message: "checkpoint: tool-done" }); - - // 4. Second inference: agent calls another tool - const msgs3: ConversationTurn[] = [ - ...msgs2, - toolCallMessage("call-2", "get_weather", { city: "NYC" }, t + 3000), - ]; - await store.writeTurns(msgs3); - - await store.commit({ message: "checkpoint: tool-execution" }); - - // 5. Second tool result - const msgs4: ConversationTurn[] = [ - ...msgs3, - toolResultMessage("call-2", "55F and rainy", t + 4000), - ]; - await store.writeTurns(msgs4); - - await store.commit({ message: "checkpoint: tool-done" }); - - // 6. Final inference: agent composes reply - const msgs5: ConversationTurn[] = [ - ...msgs4, - assistantMessage("SF is 72F and sunny. NYC is 55F and rainy.", t + 5000), - ]; - await store.writeTurns(msgs5); - - const finalCommit = await store.commit({ - message: "checkpoint: inference-done", - }); - - // 7. Outbound mail sent with checkpoint linkage - const outbound = buildRawMessage({ - messageId: "", - from: "agent@example.com", - to: "alice@example.com", - inReplyTo: "", - body: "SF is 72F and sunny. NYC is 55F and rainy.", - }); - await mailStore.commitMail(outbound, "out", { - checkpointHash: finalCommit.hash, - }); - - // 8. An error occurs on a follow-up inference - const msgs6: ConversationTurn[] = [ - ...msgs5, - userMessage("What about London?", t + 10000), - assistantMessage("Let me check London weather.", t + 11000), - ]; - await store.writeTurns(msgs6); - - await store.commit({ message: "checkpoint: inference-error" }); - - // 9. Error record committed - await store.commitErrors([ - { - source: "inference", - category: "rate_limit", - message: "Rate limited by provider", - fatal: false, - timestamp: new Date(t + 11000).toISOString(), - sessionId: "test-session", - seq: 0, - }, - ]); - - // --- Reconstruct and verify --- - const result = await reconstructTimeline(dir); - - // No gaps — all data is well-formed and linked - expect(result.gaps).toHaveLength(0); - - // Mail events - const mailEvents = result.events.filter((e) => e.kind === "mail"); - expect(mailEvents).toHaveLength(2); - - const inboundMail = mailEvents.find( - (e) => e.kind === "mail" && e.direction === "in", - ); - expect(inboundMail).toBeDefined(); - if (inboundMail !== undefined && inboundMail.kind === "mail") { - expect(inboundMail.messageId).toBe(""); - } - - const outboundMail = mailEvents.find( - (e) => e.kind === "mail" && e.direction === "out", - ); - expect(outboundMail).toBeDefined(); - if (outboundMail !== undefined && outboundMail.kind === "mail") { - expect(outboundMail.messageId).toBe(""); - expect(outboundMail.checkpointHash).toBe(finalCommit.hash); - } - - // Turn events - const turns = result.events.filter((e) => e.kind === "turn"); - - // Tool-only checkpoints (tool-execution, tool-done) produce no text - // content, so no turn events. Only inference-done and inference-error - // checkpoints that add assistant text produce turns. - expect(turns).toHaveLength(2); - - const completedTurn = turns.find( - (e) => e.kind === "turn" && e.status === "completed", - ); - expect(completedTurn).toBeDefined(); - if (completedTurn !== undefined && completedTurn.kind === "turn") { - expect(completedTurn.content).toBe( - "SF is 72F and sunny. NYC is 55F and rainy.", - ); - } - - const errorTurn = turns.find( - (e) => e.kind === "turn" && e.status === "error", - ); - expect(errorTurn).toBeDefined(); - if (errorTurn !== undefined && errorTurn.kind === "turn") { - expect(errorTurn.content).toBe("Let me check London weather."); - expect(errorTurn.isError).toBe(true); - expect(errorTurn.errors).toBeDefined(); - expect(errorTurn.errors?.length).toBe(1); - expect(errorTurn.errors?.[0]?.category).toBe("rate_limit"); - } - - // Events are sorted by timestamp — mail and turns interleaved correctly - for (let i = 1; i < result.events.length; i++) { - const prev = result.events[i - 1]; - const curr = result.events[i]; - if (prev !== undefined && curr !== undefined) { - expect(curr.timestamp).toBeGreaterThanOrEqual(prev.timestamp); - } - } - }); - - test("records multiple gaps for multiple corrupt checkpoints", async () => { - const dir = await makeTempDir(); - await initAgentRepo(dir); - - // Write two corrupt checkpoints - for (let i = 0; i < 2; i++) { - const turnsPath = path.join(dir, "turns.jsonl"); - await fs.promises.writeFile(turnsPath, `CORRUPT ${String(i)}`); - await git.add({ fs, dir, filepath: "turns.jsonl" }); - await git.commit({ - fs, - dir, - message: "checkpoint: inference-done", - author: { name: "test", email: "test@test.dev" }, - }); - } - - const result = await reconstructTimeline(dir); - - const corruptGaps = result.gaps.filter( - (g) => g.kind === "corrupt-checkpoint", - ); - expect(corruptGaps).toHaveLength(2); - }); -}); diff --git a/packages/hub-api/src/timeline-reconstruction.ts b/packages/hub-api/src/timeline-reconstruction.ts deleted file mode 100644 index fd82b3166..000000000 --- a/packages/hub-api/src/timeline-reconstruction.ts +++ /dev/null @@ -1,385 +0,0 @@ -import fs from "node:fs"; -import git from "isomorphic-git"; -import { type } from "arktype"; -import { - IsogitStore, - listMail, - type MailDirection, -} from "@intx/storage-isogit/node"; -import type { ConversationTurn } from "@intx/types/runtime"; -import { formatSafetyRatingText } from "@intx/types/format-safety-rating"; -import { - ErrorRecord, - type ErrorRecord as ErrorRecordType, -} from "@intx/types/audit"; - -export type ReconstructedEvent = - | { - kind: "mail"; - direction: MailDirection; - messageId: string; - timestamp: number; - raw: Uint8Array; - checkpointHash?: string; - } - | { - kind: "turn"; - content: string; - timestamp: number; - status: "completed" | "error" | "in-progress"; - isError?: boolean; - errors?: { category: string; message: string }[]; - }; - -export type GapKind = - | "message-count-regression" - | "corrupt-checkpoint" - | "corrupt-error-record" - | "orphan-mail"; - -export type ReconstructionGap = { - kind: GapKind; - description: string; -}; - -export type ReconstructionResult = { - events: ReconstructedEvent[]; - gaps: ReconstructionGap[]; -}; - -const ERRORS_DIR = "state/errors"; -const MAX_LOG_DEPTH = 10000; - -type CheckpointReason = - | "inference-done" - | "tool-execution" - | "tool-done" - | "inference-error" - | "gate-cleared"; - -function isCheckpointReason(value: string): value is CheckpointReason { - return ( - value === "inference-done" || - value === "tool-execution" || - value === "tool-done" || - value === "inference-error" || - value === "gate-cleared" - ); -} - -function parseCheckpointReason(commitMessage: string): CheckpointReason | null { - const match = /^checkpoint: (.+)$/.exec(commitMessage); - if (match?.[1] === undefined) return null; - if (!isCheckpointReason(match[1])) return null; - return match[1]; -} - -function reasonToStatus( - reason: CheckpointReason, -): "completed" | "error" | "in-progress" { - switch (reason) { - case "inference-done": - return "completed"; - case "inference-error": - return "error"; - case "tool-execution": - case "tool-done": - case "gate-cleared": - return "in-progress"; - } -} - -function isToolResultTurn(msg: ConversationTurn): boolean { - const first = msg.content[0]; - return ( - msg.role === "user" && first !== undefined && first.type === "tool_result" - ); -} - -function extractTextContent(msg: ConversationTurn): string { - // Treat refusal and safety_rating blocks as text for timeline- - // summary purposes. A refusal-only or safety-only assistant turn - // carries human-readable model/filter output; summarising it as - // the empty string would render the turn invisible on the timeline. - // Structural part kinds stay on the persisted turn-part for any - // consumer that wants to render policy declines differently. - return msg.content - .map((b) => { - if (b.type === "text") return b.text; - if (b.type === "refusal") return b.reason; - if (b.type === "safety_rating") { - return formatSafetyRatingText(b); - } - return ""; - }) - .join(""); -} - -type TurnAccumulator = { - texts: string[]; - timestamp: number; -}; - -function extractTurns( - newMessages: ConversationTurn[], - status: "completed" | "error" | "in-progress", -): ReconstructedEvent[] { - const events: ReconstructedEvent[] = []; - let current: TurnAccumulator | null = null; - - for (const msg of newMessages) { - if (msg.role === "user" && !isToolResultTurn(msg)) { - // New user message (not a tool result) starts a new turn - if (current !== null && current.texts.length > 0) { - events.push({ - kind: "turn", - content: current.texts.join(""), - timestamp: current.timestamp, - status, - }); - } - current = { texts: [], timestamp: msg.timestamp }; - } else if (msg.role === "assistant") { - if (current === null) { - current = { texts: [], timestamp: msg.timestamp }; - } else { - // Update timestamp to the latest assistant message in this turn - current.timestamp = msg.timestamp; - } - const text = extractTextContent(msg); - if (text.length > 0) { - current.texts.push(text); - } - } - // tool_result user messages and system messages are continuations, not new turns - } - - if (current !== null && current.texts.length > 0) { - events.push({ - kind: "turn", - content: current.texts.join(""), - timestamp: current.timestamp, - status, - ...(status === "error" ? { isError: true } : {}), - }); - } - - return events; -} - -type ErrorReadResult = { - errors: ErrorRecordType[]; - corruptFiles: string[]; -}; - -const ERROR_COMMIT_PATTERN = /^Record \d+ error records?$/; - -async function readErrorRecordsFromCommit( - dir: string, - oid: string, -): Promise { - const errors: ErrorRecordType[] = []; - const corruptFiles: string[] = []; - - // Walk state/errors/ in the commit tree - let sessionTree; - try { - sessionTree = await git.readTree({ fs, dir, oid, filepath: ERRORS_DIR }); - } catch { - return { errors: [], corruptFiles: [] }; - } - - for (const sessionEntry of sessionTree.tree) { - if (sessionEntry.type !== "tree") continue; - const sessionId = sessionEntry.path; - - let fileTree; - try { - fileTree = await git.readTree({ - fs, - dir, - oid, - filepath: `${ERRORS_DIR}/${sessionId}`, - }); - } catch { - continue; - } - - for (const fileEntry of fileTree.tree) { - if (fileEntry.type !== "blob" || !fileEntry.path.endsWith(".json")) - continue; - - let blob: Uint8Array; - try { - ({ blob } = await git.readBlob({ fs, dir, oid: fileEntry.oid })); - } catch { - corruptFiles.push(`${sessionId}/${fileEntry.path}`); - continue; - } - - const text = new TextDecoder().decode(blob); - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - corruptFiles.push(`${sessionId}/${fileEntry.path}`); - continue; - } - - const result = ErrorRecord(parsed); - if (result instanceof type.errors) { - corruptFiles.push(`${sessionId}/${fileEntry.path}`); - continue; - } - errors.push(result); - } - } - - return { errors, corruptFiles }; -} - -export async function reconstructTimeline( - dir: string, -): Promise { - const store = new IsogitStore(dir); - const events: ReconstructedEvent[] = []; - const gaps: ReconstructionGap[] = []; - const gapKindsAdded = new Set(); - - function addGap(kind: GapKind, description: string): void { - if (gapKindsAdded.has(kind)) return; - gapKindsAdded.add(kind); - gaps.push({ kind, description }); - } - - // Walk the full commit log, oldest first - const commits = await store.log(MAX_LOG_DEPTH); - commits.reverse(); - - // Process commits to extract turns and associate errors - let prevMessageCount = 0; - // Track the index of the last turn event so error commits can attach to it - let lastTurnEventIndex = -1; - - for (const commit of commits) { - // Handle error commits — associate with the most recent turn - if (ERROR_COMMIT_PATTERN.test(commit.message)) { - const { errors: errorRecords, corruptFiles } = - await readErrorRecordsFromCommit(dir, commit.hash); - - for (const file of corruptFiles) { - gaps.push({ - kind: "corrupt-error-record", - description: `Error record ${file} failed validation and was excluded from the timeline`, - }); - } - - if (errorRecords.length > 0 && lastTurnEventIndex >= 0) { - const turnEvent = events[lastTurnEventIndex]; - if (turnEvent !== undefined && turnEvent.kind === "turn") { - turnEvent.isError = true; - turnEvent.errors = [ - ...(turnEvent.errors ?? []), - ...errorRecords.map((e) => ({ - category: e.category, - message: e.message, - })), - ]; - } - } - continue; - } - - const reason = parseCheckpointReason(commit.message); - if (reason === null) continue; - - const status = reasonToStatus(reason); - - let messages: ConversationTurn[]; - try { - messages = await store.readAt(commit.hash); - } catch { - gaps.push({ - kind: "corrupt-checkpoint", - description: `Checkpoint commit ${commit.hash} could not be read; this checkpoint is missing from the reconstructed timeline`, - }); - continue; - } - - if (messages.length < prevMessageCount) { - addGap( - "message-count-regression", - `Message count dropped from ${prevMessageCount} to ${messages.length} at commit ${commit.hash}`, - ); - // Treat the entire message array as new for this checkpoint - const turnEvents = extractTurns(messages, status); - events.push(...turnEvents); - if (turnEvents.length > 0) { - lastTurnEventIndex = events.length - 1; - } - prevMessageCount = messages.length; - continue; - } - - const newMessages = messages.slice(prevMessageCount); - prevMessageCount = messages.length; - - if (newMessages.length === 0) continue; - - const turnEvents = extractTurns(newMessages, status); - events.push(...turnEvents); - if (turnEvents.length > 0) { - lastTurnEventIndex = events.length - 1; - } - } - - // Process mail entries - const mailEntries = await listMail(dir); - // Correlate mail timestamps and checkpoint linkage with git commits. - // The Checkpoint trailer regex uses $ to anchor at end-of-string. This - // relies on IsogitStore.log() calling trimEnd() on commit messages to - // strip the trailing newline that isomorphic-git appends. - const mailCommitMeta = new Map< - string, - { timestamp: number; checkpointHash?: string } - >(); - for (const commit of commits) { - const match = /^Record (?:inbound|outbound) mail (.+)$/m.exec( - commit.message, - ); - if (match?.[1] !== undefined) { - const checkpointMatch = /\nCheckpoint: ([0-9a-f]+)$/.exec(commit.message); - mailCommitMeta.set(match[1], { - timestamp: commit.timestamp, - ...(checkpointMatch?.[1] !== undefined - ? { checkpointHash: checkpointMatch[1] } - : {}), - }); - } - } - - for (const entry of mailEntries) { - const meta = mailCommitMeta.get(entry.messageId); - if (meta === undefined) { - gaps.push({ - kind: "orphan-mail", - description: `Mail entry ${entry.messageId} has no corresponding git commit; its timestamp is unreliable`, - }); - } - events.push({ - kind: "mail", - direction: entry.direction, - messageId: entry.messageId, - timestamp: meta?.timestamp ?? 0, - raw: entry.raw, - ...(meta?.checkpointHash !== undefined - ? { checkpointHash: meta.checkpointHash } - : {}), - }); - } - - // Sort all events by timestamp - events.sort((a, b) => a.timestamp - b.timestamp); - - return { events, gaps }; -} diff --git a/packages/hub-common/src/action-mapping.test.ts b/packages/hub-common/src/action-mapping.test.ts index 16c878576..e5d0cce3e 100644 --- a/packages/hub-common/src/action-mapping.test.ts +++ b/packages/hub-common/src/action-mapping.test.ts @@ -59,7 +59,7 @@ describe("httpToRepoAction", () => { expect( httpToRepoAction({ method: "POST", - path: "/tenants/t1/agents/agt_xyz/state/git-receive-pack", + path: "/tenants/t1/assets/skill/example.git/git-receive-pack", query: {}, }), ).toBe("receivePack"); diff --git a/packages/hub-common/src/action-mapping.ts b/packages/hub-common/src/action-mapping.ts index 6b1f2a511..3612dadcd 100644 --- a/packages/hub-common/src/action-mapping.ts +++ b/packages/hub-common/src/action-mapping.ts @@ -22,7 +22,7 @@ const RECEIVE_PACK_SERVICE = "git-receive-pack"; /** * Resolve a smart-HTTP request to the `RepoAction` it requires. The * match is on the trailing smart-HTTP suffix so the same logic works - * for every mount prefix (asset routes, agent-state routes, etc.). + * for every mount prefix. * * Throws when the request shape is not a recognised smart-HTTP * endpoint; callers are expected to gate this behind their route diff --git a/packages/hub-sessions/README.md b/packages/hub-sessions/README.md index 370039627..d12d6bcec 100644 --- a/packages/hub-sessions/README.md +++ b/packages/hub-sessions/README.md @@ -12,11 +12,11 @@ to provision it on the connected sidecar, and event collectors feed agent events back to the HTTP layer for observability. `createSessionService` takes a `SessionServiceDeps` of -`sidecarRouter`, `agentRepoStore`, and an optional -`assetService` paired with a `db` handle for the asset manifest -inserts. `createHubSessionOrchestrator` takes a -`HubSessionOrchestratorDeps` of `events`, `router`, `db`, -`eventCollectors`, and `agentRepoStore`. See the +`sidecarRouter`, `agentRepoStore`, `loadRunCredentials`, and optional +`sidecarAllocationRouter`, `db`, and `toolPackageRegistries`. +`createHubSessionOrchestrator` takes a +`HubSessionOrchestratorDeps` of `events`, `router`, `db`, and +`eventCollectors`. See the exported types in `src/session-service.ts` and `src/hub-session-orchestrator.ts` for the authoritative shapes; `@intx/hub-api` is the in-tree consumer that wires these @@ -65,8 +65,8 @@ for the authoritative type shapes. ### Agent repository store -- `createAgentRepoStore` / `AgentRepoStore`, `DeployContent` — the - agent-specific repository store. +- `createAgentRepoStore` / `AgentRepoStore` — the agent-specific + repository store. - `createRepoStore`, `UserPrincipal`, and the supporting types `AuthorizeFn`, `CreateRepoStoreConfig`, `InitRepoOpts`, `KindHandler`, `Principal`, `RefEntry`, `RepoAction`, `RepoId`, @@ -99,10 +99,8 @@ for the authoritative type shapes. `CreateAssetParams`, `PopulateAssetParams`, `AssetServiceErrorReason`, `ReadAssetBlobParams`, `ListAssetBlobsParams`. -### Skills stanza and credential push +### Credential push -- `buildAvailableSkillsStanza` / `AvailableSkillEntry` — renders the - available-skills stanza injected into agent context. - `pushCredentialRotation` — after a secret rotation, pushes the new secret to every live deployment that holds the credential, in the tenant and its descendants. diff --git a/packages/hub-sessions/src/agent-repo.test.ts b/packages/hub-sessions/src/agent-repo.test.ts index b407da946..b5e4ecafd 100644 --- a/packages/hub-sessions/src/agent-repo.test.ts +++ b/packages/hub-sessions/src/agent-repo.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import git from "isomorphic-git"; import { generateKeyPair } from "@intx/crypto"; import { createDeployPack } from "@intx/storage-isogit/node"; -import { createAgentRepoStore, DEFAULT_GC_KINDS } from "./agent-repo"; +import { createAgentRepoStore } from "./agent-repo"; import { applyWorkflowRunPack } from "./agent-repo.test-helpers"; import type { WorkflowRunSupervisorPrincipal } from "./workflow-run-kind"; import type { KeyPair } from "@intx/types/runtime"; @@ -32,280 +32,7 @@ afterAll(async () => { } }); -describe("AgentRepoStore GC scope", () => { - test("workflow-run is excluded from GC so expunged mailbox blobs survive in history", () => { - // The warm-agent mailbox physically expunges .eml blobs from the - // live tree; the raw bytes then persist only through the parent commit, - // and survive ONLY because a workflow-run repo's objects are never - // pruned. This tripwire fails if a future change makes workflow-run - // GC-eligible, which would silently destroy the mailbox audit trail. - const kinds: readonly string[] = DEFAULT_GC_KINDS; - expect(kinds).not.toContain("workflow-run"); - expect(kinds).toEqual(["agent-state"]); - }); -}); - describe("AgentRepoStore", () => { - test("writeDeployTree creates a commit with the system prompt", async () => { - const dataDir = await makeTempDir("agent-repo-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - const { commitSha } = await store.writeDeployTree("agent-1", { - systemPrompt: "You are a test agent.", - }); - - expect(commitSha).toMatch(/^[0-9a-f]{40}$/); - - const repoDir = path.join(dataDir, "agents", "agent-1"); - const prompt = await fs.promises.readFile( - path.join(repoDir, "deploy", "prompt.md"), - "utf-8", - ); - expect(prompt).toBe("You are a test agent."); - - const ref = await git.resolveRef({ - fs, - dir: repoDir, - ref: "refs/heads/deploy", - }); - expect(ref).toBe(commitSha); - }); - - test("writeDeployTree does not advance refs/heads/main", async () => { - const dataDir = await makeTempDir("agent-repo-ref-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - await store.writeDeployTree("agent-ref", { - systemPrompt: "Ref test.", - }); - - const repoDir = path.join(dataDir, "agents", "agent-ref"); - const mainLog = await git.log({ fs, dir: repoDir, ref: "refs/heads/main" }); - const deployLog = await git.log({ - fs, - dir: repoDir, - ref: "refs/heads/deploy", - }); - - // main should only have the init commit - expect(mainLog.length).toBe(1); - // deploy should have 2: init parent + deploy commit - expect(deployLog.length).toBe(2); - }); - - test("createDeployPack produces a valid packfile", async () => { - const dataDir = await makeTempDir("agent-repo-pack-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - await store.writeDeployTree("agent-2", { - systemPrompt: "Pack test.", - }); - - const { pack, commitSha, ref } = await store.createDeployPack("agent-2"); - - expect(pack).toBeInstanceOf(Uint8Array); - expect(pack.length).toBeGreaterThan(0); - expect(commitSha).toMatch(/^[0-9a-f]{40}$/); - expect(ref).toBe("refs/heads/deploy"); - }); - - test("receiveAgentStatePack indexes objects and updates ref", async () => { - const dataDir = await makeTempDir("agent-repo-state-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - await store.writeDeployTree("agent-3", { - systemPrompt: "State test.", - }); - - const sourceDir = await makeTempDir("state-source-"); - await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); - await fs.promises.mkdir(path.join(sourceDir, "state"), { recursive: true }); - await fs.promises.writeFile( - path.join(sourceDir, "state", "turns.jsonl"), - '{"messages":[]}', - ); - await git.add({ fs, dir: sourceDir, filepath: "state/turns.jsonl" }); - const stateCommit = await git.commit({ - fs, - dir: sourceDir, - message: "State snapshot", - author: { name: "test", email: "test@test" }, - }); - - const { pack } = await createDeployPack(sourceDir, "refs/heads/main"); - - const stateRef = "refs/instances/test-instance"; - await store.receiveAgentStatePack( - { kind: "agent-state", id: "agent-3" }, - pack, - stateRef, - stateCommit, - ); - - const repoDir = path.join(dataDir, "agents", "agent-3"); - const resolved = await git.resolveRef({ - fs, - dir: repoDir, - ref: stateRef, - }); - expect(resolved).toBe(stateCommit); - }); - - test("receiveAgentStatePack accepts packs with .gitignore alongside state", async () => { - const dataDir = await makeTempDir("agent-repo-gitignore-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - await store.writeDeployTree("agent-gi", { - systemPrompt: "Gitignore test.", - }); - - const sourceDir = await makeTempDir("gitignore-source-"); - await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); - await fs.promises.writeFile(path.join(sourceDir, ".gitignore"), "keys/\n"); - await fs.promises.mkdir(path.join(sourceDir, "state"), { recursive: true }); - await fs.promises.writeFile( - path.join(sourceDir, "state", "turns.jsonl"), - "{}", - ); - await git.add({ fs, dir: sourceDir, filepath: ".gitignore" }); - await git.add({ fs, dir: sourceDir, filepath: "state/turns.jsonl" }); - const stateCommit = await git.commit({ - fs, - dir: sourceDir, - message: "State with gitignore", - author: { name: "test", email: "test@test" }, - }); - - const { pack } = await createDeployPack(sourceDir, "refs/heads/main"); - const stateRef = "refs/instances/gi-test"; - await store.receiveAgentStatePack( - { kind: "agent-state", id: "agent-gi" }, - pack, - stateRef, - stateCommit, - ); - - const repoDir = path.join(dataDir, "agents", "agent-gi"); - const resolved = await git.resolveRef({ - fs, - dir: repoDir, - ref: stateRef, - }); - expect(resolved).toBe(stateCommit); - }); - - test("receiveAgentStatePack rejects packs with only .gitignore and no state/", async () => { - const dataDir = await makeTempDir("agent-repo-gitignore-only-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - await store.writeDeployTree("agent-gio", { - systemPrompt: "Gitignore-only test.", - }); - - const sourceDir = await makeTempDir("gitignore-only-source-"); - await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); - await fs.promises.writeFile(path.join(sourceDir, ".gitignore"), "keys/\n"); - await git.add({ fs, dir: sourceDir, filepath: ".gitignore" }); - const badCommit = await git.commit({ - fs, - dir: sourceDir, - message: "Only gitignore", - author: { name: "test", email: "test@test" }, - }); - - const { pack } = await createDeployPack(sourceDir, "refs/heads/main"); - - await expect( - store.receiveAgentStatePack( - { kind: "agent-state", id: "agent-gio" }, - pack, - "refs/instances/test", - badCommit, - ), - ).rejects.toThrow("path_violation"); - }); - - test("receiveAgentStatePack rejects packs with paths outside state/", async () => { - const dataDir = await makeTempDir("agent-repo-confined-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - await store.writeDeployTree("agent-confined", { - systemPrompt: "Confinement test.", - }); - - const sourceDir = await makeTempDir("confined-source-"); - await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); - await fs.promises.mkdir(path.join(sourceDir, "state"), { recursive: true }); - await fs.promises.mkdir(path.join(sourceDir, "deploy"), { - recursive: true, - }); - await fs.promises.writeFile( - path.join(sourceDir, "state", "turns.jsonl"), - "{}", - ); - await fs.promises.writeFile( - path.join(sourceDir, "deploy", "prompt.md"), - "evil", - ); - await git.add({ fs, dir: sourceDir, filepath: "state/turns.jsonl" }); - await git.add({ fs, dir: sourceDir, filepath: "deploy/prompt.md" }); - const badCommit = await git.commit({ - fs, - dir: sourceDir, - message: "Escaped confinement", - author: { name: "test", email: "test@test" }, - }); - - const { pack } = await createDeployPack(sourceDir, "refs/heads/main"); - - await expect( - store.receiveAgentStatePack( - { kind: "agent-state", id: "agent-confined" }, - pack, - "refs/instances/test", - badCommit, - ), - ).rejects.toThrow("path_violation"); - }); - - test("writeDeployTree produces a fresh commit when content changes", async () => { - const dataDir = await makeTempDir("agent-repo-idem-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - const first = await store.writeDeployTree("agent-4", { - systemPrompt: "Version 1", - }); - - const second = await store.writeDeployTree("agent-4", { - systemPrompt: "Version 2", - }); - - expect(second.commitSha).not.toBe(first.commitSha); - - const repoDir = path.join(dataDir, "agents", "agent-4"); - const prompt = await fs.promises.readFile( - path.join(repoDir, "deploy", "prompt.md"), - "utf-8", - ); - expect(prompt).toBe("Version 2"); - }); - - test("hub repo does not contain state/ scaffolding", async () => { - const dataDir = await makeTempDir("agent-repo-nostate-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - await store.writeDeployTree("agent-5", { - systemPrompt: "No state test.", - }); - - const repoDir = path.join(dataDir, "agents", "agent-5"); - const stateExists = await fs.promises - .stat(path.join(repoDir, "state")) - .then(() => true) - .catch(() => false); - expect(stateExists).toBe(false); - }); - test("prepareWorkflowRunPack and commit index a workflow-run genesis pack and update the ref", async () => { const dataDir = await makeTempDir("agent-repo-wfr-"); const store = createAgentRepoStore({ dataDir, signingKey }); @@ -641,7 +368,7 @@ describe("AgentRepoStore", () => { await expect( store.prepareWorkflowRunPack( - { kind: "agent-state", id: "agent-x" }, + { kind: "skill", id: "agent-x" }, pack, "refs/heads/events", commit, @@ -651,53 +378,16 @@ describe("AgentRepoStore", () => { ); }); - test("receiveAgentStatePack rejects packs whose repoId.kind is not agent-state", async () => { - const dataDir = await makeTempDir("agent-repo-state-wrong-kind-"); - const store = createAgentRepoStore({ dataDir, signingKey }); - - const sourceDir = await makeTempDir("state-wrong-kind-source-"); - await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); - await fs.promises.mkdir(path.join(sourceDir, "state"), { recursive: true }); - await fs.promises.writeFile( - path.join(sourceDir, "state", "turns.jsonl"), - "{}", - ); - await git.add({ fs, dir: sourceDir, filepath: "state/turns.jsonl" }); - const commit = await git.commit({ - fs, - dir: sourceDir, - message: "Misrouted", - author: { name: "test", email: "test@test" }, - }); - - const { pack } = await createDeployPack(sourceDir, "refs/heads/main"); - - await expect( - store.receiveAgentStatePack( - { kind: "workflow-run", id: "dep-misrouted" }, - pack, - "refs/heads/state", - commit, - ), - ).rejects.toThrow( - /receiveAgentStatePack requires repoId\.kind === "agent-state"/, - ); - }); - test("rejects agent IDs with path traversal characters", () => { const dataDir = "/tmp/never-created"; const store = createAgentRepoStore({ dataDir, signingKey }); expect(() => - store.writeDeployTree("../../evil", { - systemPrompt: "x", - }), + store.repoStore.getRepoDir({ kind: "workflow-run", id: "../../evil" }), ).toThrow("repo_id_invalid: ../../evil"); expect(() => - store.writeDeployTree("agent@domain", { - systemPrompt: "x", - }), + store.repoStore.getRepoDir({ kind: "workflow-run", id: "agent@domain" }), ).toThrow("repo_id_invalid: agent@domain"); }); }); diff --git a/packages/hub-sessions/src/agent-repo.ts b/packages/hub-sessions/src/agent-repo.ts index 866718a45..c143804c9 100644 --- a/packages/hub-sessions/src/agent-repo.ts +++ b/packages/hub-sessions/src/agent-repo.ts @@ -1,6 +1,4 @@ import { createSSHSignature } from "@intx/crypto"; -import type { GCPolicy } from "@intx/storage-isogit/node"; -import type { ToolPackageManifest } from "@intx/types/tool-packages"; import { createRepoStore } from "./repo-store"; import type { @@ -9,13 +7,6 @@ import type { PreparedReceivePack, RepoId, } from "./repo-store"; -import { - agentStateKindHandler, - agentStateAuthorize, - AGENT_STATE_DEPLOY_REF, - type AgentStateHubPrincipal, - type AgentStateSidecarPrincipal, -} from "./agent-state-kind"; import { skillKindHandler, skillAuthorize } from "./skill-kind"; import { packageRegistryKindHandler, @@ -28,62 +19,7 @@ import { type WorkflowRunSupervisorPrincipal, } from "./workflow-run-kind"; -export type DeployContent = { - systemPrompt: string; - /** - * Optional. When present, written to - * `deploy/tool-packages-manifest.json` so the sidecar's loader can - * materialize the pinned tool-package closure on apply. - */ - toolPackageManifest?: ToolPackageManifest; - /** - * Optional. `assetId` → workspace-relative mount path for every - * asset id referenced by a `kind: "asset"` entry in - * `toolPackageManifest`. When present, written to - * `deploy/asset-mounts.json`; the sidecar's loader reads it back via - * `readDeployTree` and resolves asset-sourced tarballs against it. - * Empty maps and absent values produce no file on disk — both shapes - * read back as an empty mount table. - */ - assetMounts?: ReadonlyMap; -}; - export type AgentRepoStore = { - /** - * Write deploy content into the agent's hub-side repo and commit on - * refs/heads/deploy. Creates the repo if it doesn't exist. - * - * The caller is responsible for serializing calls per agent. - */ - writeDeployTree( - agentId: string, - content: DeployContent, - ): Promise<{ commitSha: string }>; - - /** - * Produce a packfile from the agent's current deploy ref. - */ - createDeployPack( - agentId: string, - ): Promise<{ pack: Uint8Array; commitSha: string; ref: string }>; - - /** - * Receive and store an agent-state pack from a sidecar. Indexes the - * pack objects and updates the ref without materializing a working - * tree. - * - * `repoId.kind` must be `"agent-state"`. The `repoId.id` is used as - * the run address internally. Stamps an - * `AgentStateSidecarPrincipal` so the agent-state kind handler - * authorizes the receivePack as a per-agent sidecar write. - */ - receiveAgentStatePack( - repoId: RepoId, - pack: Uint8Array, - ref: string, - commitSha: string, - ): Promise; - /** * Stage and validate a workflow-run pack from a sidecar, and return the * prepared receive. The validation holds no repo lock, so a caller can @@ -118,49 +54,22 @@ export type AgentRepoStore = { commitSha: string, ): Promise; - /** Raw 32-byte Ed25519 public key used to sign deploy commits. */ - getSigningPublicKey(): Uint8Array; - /** - * Underlying kind-keyed substrate. Exposed so callers that need to - * operate on non-agent-state kinds (e.g. the asset service writing - * skill repos) can share the same on-disk root and signing key + * Underlying kind-keyed substrate. Exposed so callers (e.g. the asset + * service writing skill repos) can share the same on-disk root and signing key * without spinning up a parallel RepoStore. */ readonly repoStore: LocalRepoStore; }; -/** - * Repo kinds eligible for write-path object GC. Deliberately EXCLUDES - * "workflow-run". The warm-agent mailbox physically expunges `.eml` - * blobs from the live tree; the raw bytes then persist only through the - * parent commit in git history, and they survive a push ONLY because a - * `workflow-run` repo's objects are never GC'd. Adding "workflow-run" - * here -- above all with a retention other than "keep-history" -- would - * make an expunged message's bytes prunable and silently destroy the - * mailbox audit trail. The mailbox subtree contract in `workflow-run-kind` - * documents this dependency; `agent-repo.test.ts` pins it. Do not add - * "workflow-run" without replacing physical expunge with a tip-reachable - * retain-bytes scheme first. - */ -export const DEFAULT_GC_KINDS = ["agent-state"] as const; - export function createAgentRepoStore(config: { dataDir: string; signingKey: { privateKey: Uint8Array; publicKey: Uint8Array }; - /** - * Optional write-path GC policy for the agent-state repos this store - * owns. Scoped to the `agent-state` kind; the other kinds the - * underlying substrate services are left untouched. - */ - gc?: GCPolicy; }): AgentRepoStore { - const { dataDir, signingKey, gc } = config; + const { dataDir, signingKey } = config; const authorize: AuthorizeFn = (principal, incomingRepoId, ref, action) => { switch (incomingRepoId.kind) { - case "agent-state": - return agentStateAuthorize(principal, incomingRepoId, ref, action); case "skill": return skillAuthorize(principal, incomingRepoId, ref, action); case "package-registry": @@ -181,8 +90,8 @@ export function createAgentRepoStore(config: { // The substrate's signingCallback bridges the agent-repo store's // raw Ed25519 keypair to the storage layer's per-payload SSHSIG - // signer. Skill asset genesis commits and agent-state deploy - // commits both flow through this signer so that signed commits + // signer. Skill asset genesis commits flow through this signer so that + // signed commits // round-trip through the smart-HTTP layer and verify under // `git log --show-signature` and `git verify-commit`. const signer = async (payload: string) => @@ -192,7 +101,6 @@ export function createAgentRepoStore(config: { dataDir, signingKey, handlers: { - "agent-state": agentStateKindHandler, skill: skillKindHandler, "package-registry": packageRegistryKindHandler, workflow: workflowKindHandler, @@ -200,13 +108,8 @@ export function createAgentRepoStore(config: { }, authorize, signingCallback: () => signer, - ...(gc === undefined - ? {} - : { gc: { kinds: [...DEFAULT_GC_KINDS], ...gc } }), }); - const hub: AgentStateHubPrincipal = { kind: "hub" }; - async function prepareWorkflowRunPack( incomingRepoId: RepoId, pack: Uint8Array, @@ -231,70 +134,9 @@ export function createAgentRepoStore(config: { ); } - function repoId(agentId: string): RepoId { - return { kind: "agent-state", id: agentId }; - } - return { - async writeDeployTree(agentId, content) { - const id = repoId(agentId); - const files: Record = { - "deploy/prompt.md": content.systemPrompt, - }; - if (content.toolPackageManifest !== undefined) { - files["deploy/tool-packages-manifest.json"] = JSON.stringify( - content.toolPackageManifest, - null, - 2, - ); - } - if (content.assetMounts !== undefined && content.assetMounts.size > 0) { - files["deploy/asset-mounts.json"] = JSON.stringify( - { assetMounts: Object.fromEntries(content.assetMounts) }, - null, - 2, - ); - } - return store.writeTree(hub, id, AGENT_STATE_DEPLOY_REF, { - files, - clearPrefix: "deploy/", - message: "Update deploy tree", - }); - }, - - async createDeployPack(agentId) { - return store.createPack(hub, repoId(agentId), AGENT_STATE_DEPLOY_REF); - }, - - async receiveAgentStatePack(incomingRepoId, pack, ref, commitSha) { - if (incomingRepoId.kind !== "agent-state") { - throw new Error( - `AgentRepoStore.receiveAgentStatePack requires repoId.kind === "agent-state", got ${JSON.stringify(incomingRepoId.kind)}`, - ); - } - const agentId = incomingRepoId.id; - const id = repoId(agentId); - const principal: AgentStateSidecarPrincipal = { - kind: "sidecar", - agentId, - }; - const expectedOldSha = await store.resolveRef(principal, id, ref); - await store.receivePack( - principal, - id, - ref, - pack, - commitSha, - expectedOldSha, - ); - }, - prepareWorkflowRunPack, - getSigningPublicKey() { - return signingKey.publicKey; - }, - repoStore: store, }; } diff --git a/packages/hub-sessions/src/agent-state-kind.test.ts b/packages/hub-sessions/src/agent-state-kind.test.ts deleted file mode 100644 index 24c52a655..000000000 --- a/packages/hub-sessions/src/agent-state-kind.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -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 git from "isomorphic-git"; -import { generateKeyPair } from "@intx/crypto"; -import { collectReachableObjects } from "@intx/storage-isogit/node"; -import type { KeyPair } from "@intx/types/runtime"; -import { - agentStateAuthorize, - agentStateKindHandler, - AGENT_STATE_DEPLOY_REF, -} from "./agent-state-kind"; -import { createRepoStore } from "./repo-store"; -import type { - KindHandler, - Principal, - RepoId, - ValidatePushResult, -} from "./repo-store"; - -describe("agentStateKindHandler metadata", () => { - test("declares the agent-state kind and agents directory prefix", () => { - expect(agentStateKindHandler.kind).toBe("agent-state"); - expect(agentStateKindHandler.directoryPrefix).toBe("agents"); - }); -}); - -describe("agentStateAuthorize", () => { - const REPO: RepoId = { kind: "agent-state", id: "agent-1" }; - const OTHER_REPO: RepoId = { kind: "agent-state", id: "agent-2" }; - const STATE_REF = "refs/heads/state"; - - function farFuture(): number { - return Date.now() + 60_000; - } - - function userPrincipal( - overrides: { - effect?: "allow" | "deny"; - resource?: string; - grantVerb?: string; - refPattern?: string; - actions?: string[]; - expiresAt?: number; - } = {}, - ): Principal { - return { - kind: "user", - principalId: "user-1", - tenantId: "tenant-1", - authz: { - effect: overrides.effect ?? "allow", - resource: overrides.resource ?? "agent-state:agent-1", - grantVerb: overrides.grantVerb ?? "read", - }, - tokenClaims: { - refPattern: overrides.refPattern ?? "refs/heads/**", - actions: overrides.actions ?? ["createPack", "resolveRef"], - expiresAt: overrides.expiresAt ?? farFuture(), - }, - } as Principal; - } - - test("hub principal: allowed for any action (regression)", () => { - for (const action of [ - "init", - "writeTree", - "receivePack", - "createPack", - "resolveRef", - ] as const) { - const r = agentStateAuthorize( - { kind: "hub" } as Principal, - REPO, - STATE_REF, - action, - ); - expect(r.allowed).toBe(true); - } - }); - - test("sidecar principal: receivePack allowed on its own repo (regression)", () => { - const sidecar = { kind: "sidecar", agentId: "agent-1" } as Principal; - const r = agentStateAuthorize(sidecar, REPO, STATE_REF, "receivePack"); - expect(r.allowed).toBe(true); - }); - - test("sidecar principal: createPack / resolveRef on deploy ref allowed (regression)", () => { - const sidecar = { kind: "sidecar", agentId: "agent-1" } as Principal; - expect( - agentStateAuthorize(sidecar, REPO, AGENT_STATE_DEPLOY_REF, "createPack") - .allowed, - ).toBe(true); - expect( - agentStateAuthorize(sidecar, REPO, AGENT_STATE_DEPLOY_REF, "resolveRef") - .allowed, - ).toBe(true); - }); - - test("sidecar principal: createPack on non-deploy ref denied (regression)", () => { - const sidecar = { kind: "sidecar", agentId: "agent-1" } as Principal; - const r = agentStateAuthorize(sidecar, REPO, STATE_REF, "createPack"); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/sidecar may only fetch/); - }); - - test("sidecar principal: cannot access another sidecar's repo (regression)", () => { - const sidecar = { kind: "sidecar", agentId: "agent-1" } as Principal; - const r = agentStateAuthorize( - sidecar, - OTHER_REPO, - STATE_REF, - "receivePack", - ); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/cannot access/); - }); - - test("sidecar principal: writeTree denied (regression)", () => { - const sidecar = { kind: "sidecar", agentId: "agent-1" } as Principal; - const r = agentStateAuthorize(sidecar, REPO, STATE_REF, "writeTree"); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/hub-only/); - }); - - test("user principal: allowed when claims and verdict agree", () => { - const r = agentStateAuthorize( - userPrincipal(), - REPO, - STATE_REF, - "createPack", - ); - expect(r.allowed).toBe(true); - }); - - test("user principal: denied for non-agent-state repo", () => { - const r = agentStateAuthorize( - userPrincipal({ resource: "asset:asset-1" }), - { kind: "skill", id: "asset-1" }, - STATE_REF, - "createPack", - ); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/non-agent-state repo/); - }); - - test("user principal: malformed principal is denied with structural reason", () => { - const badPrincipal = { - kind: "user", - principalId: "user-1", - // missing tenantId, authz, tokenClaims - } as Principal; - const r = agentStateAuthorize(badPrincipal, REPO, STATE_REF, "createPack"); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/user principal is malformed/); - }); - - test("user principal: denied when tokenClaims.actions does not include the requested action", () => { - const r = agentStateAuthorize( - userPrincipal({ actions: ["resolveRef"] }), - REPO, - STATE_REF, - "createPack", - ); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/token does not grant action createPack/); - }); - - test("user principal: denied when refPattern does not match the requested ref", () => { - const r = agentStateAuthorize( - userPrincipal({ refPattern: "refs/heads/release-*" }), - REPO, - STATE_REF, - "createPack", - ); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/refPattern .* does not match/); - }); - - test("user principal: denied when the token is expired", () => { - const r = agentStateAuthorize( - userPrincipal({ expiresAt: Date.now() - 1 }), - REPO, - STATE_REF, - "createPack", - ); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/token expired/); - }); - - test("user principal: denied when verdict.resource does not target this repo (sanity drift)", () => { - const r = agentStateAuthorize( - userPrincipal({ resource: "agent-state:agent-other" }), - REPO, - STATE_REF, - "createPack", - ); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/authz verdict resource .* does not match/); - }); - - test("user principal: denied when verdict.resource has the wrong kind prefix (sanity drift)", () => { - const r = agentStateAuthorize( - userPrincipal({ resource: "asset:agent-1" }), - REPO, - STATE_REF, - "createPack", - ); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/authz verdict resource .* does not match/); - }); - - test("user principal: denied when verdict.grantVerb does not match the action's verb (sanity drift)", () => { - const r = agentStateAuthorize( - userPrincipal({ grantVerb: "write" }), - REPO, - STATE_REF, - "createPack", - ); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/authz verdict grantVerb .* does not match/); - }); - - test("user principal: denied when verdict effect is deny even though all sanity checks pass", () => { - const r = agentStateAuthorize( - userPrincipal({ effect: "deny" }), - REPO, - STATE_REF, - "createPack", - ); - expect(r.allowed).toBe(false); - if (r.allowed) throw new Error("unreachable"); - expect(r.reason).toMatch(/authz verdict denied/); - }); - - test("user principal: write action requires write grantVerb and matching claims", () => { - const r = agentStateAuthorize( - userPrincipal({ - actions: ["receivePack"], - grantVerb: "write", - }), - REPO, - STATE_REF, - "receivePack", - ); - expect(r.allowed).toBe(true); - }); -}); - -// The substrate's `receivePack` walks every new commit in the pack and -// invokes the kind handler's `validatePush` once per commit, so a tree -// that violates the kind's allowlist on an intermediate commit must -// reject the pack even when the tip's tree happens to be valid. The -// agent-state handler does not consult prior closures — every commit's -// tree is judged on its own top-level paths. This regression pins that -// behaviour: the per-commit walk catches an intermediate-state -// violation at the offending commit, not by accidentally being lenient -// at the tip. -describe("agent-state per-commit pack walk", () => { - const tempDirs: string[] = []; - let signingKey: KeyPair; - - beforeAll(async () => { - signingKey = await generateKeyPair(); - }); - - afterAll(async () => { - for (const d of tempDirs.splice(0)) { - await fs.promises - .rm(d, { recursive: true, force: true }) - .catch(() => undefined); - } - }); - - async function makeTempDir(prefix: string): Promise { - const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(d); - return d; - } - - const permissiveHandler: KindHandler = { - kind: "agent-state", - directoryPrefix: "agents", - validatePush(): ValidatePushResult { - return { ok: true }; - }, - onRefUpdated() { - /* no-op */ - }, - }; - - const PRINCIPAL: Principal = { kind: "hub" }; - const STATE_REF = "refs/heads/state"; - - test("rejects a multi-commit pack whose second commit adds a disallowed top-level path", async () => { - const sourceDataDir = await makeTempDir("agent-state-percommit-src-"); - const sourceStore = createRepoStore({ - dataDir: sourceDataDir, - signingKey, - handlers: { "agent-state": permissiveHandler }, - authorize: () => ({ allowed: true }), - }); - const repoId: RepoId = { - kind: "agent-state", - id: `agent-${Math.random().toString(36).slice(2, 10)}`, - }; - await sourceStore.initRepo(repoId); - - const { commitSha: firstSha } = await sourceStore.writeTree( - PRINCIPAL, - repoId, - STATE_REF, - { - files: { "manifest.jsonl": '{"turn":0}\n' }, - message: "valid state-bearing tree", - }, - ); - const { commitSha: secondSha } = await sourceStore.writeTree( - PRINCIPAL, - repoId, - STATE_REF, - { - files: { "forbidden.txt": "not in the agent-state allowlist" }, - message: "intermediate violation: stray top-level path", - }, - ); - - const sourceDir = sourceStore.getRepoDir(repoId); - const firstObjects = await collectReachableObjects(sourceDir, firstSha); - const secondObjects = await collectReachableObjects(sourceDir, secondSha); - const oids = Array.from(new Set([...firstObjects, ...secondObjects])); - const packResult = await git.packObjects({ - fs, - dir: sourceDir, - oids, - write: false, - }); - if (packResult.packfile === undefined) { - throw new Error("git.packObjects returned no packfile"); - } - const pack = packResult.packfile; - - const targetDataDir = await makeTempDir("agent-state-percommit-tgt-"); - const targetStore = createRepoStore({ - dataDir: targetDataDir, - signingKey, - handlers: { "agent-state": agentStateKindHandler }, - authorize: () => ({ allowed: true }), - }); - await targetStore.initRepo(repoId); - - await expect( - targetStore.receivePack( - PRINCIPAL, - repoId, - STATE_REF, - pack, - secondSha, - null, - ), - ).rejects.toThrow( - /path_violation:.*tree contains disallowed top-level path: forbidden\.txt/, - ); - - // The rejected pack must leave the ref unset; a future legitimate - // push must not observe a half-applied state. - const resolvedAfter = await targetStore.resolveRef( - PRINCIPAL, - repoId, - STATE_REF, - ); - expect(resolvedAfter).toBeNull(); - }); - - test("the same violation at the tip of a single-commit pack also rejects", async () => { - // Sanity check: the per-commit walk catches the same kind-handler - // verdict the tip-only walk would have caught when the violation - // lives at the tip. Without this the multi-commit test above - // could in principle pass via some pack-walk-specific path that - // never reached the kind handler at all. - const sourceDataDir = await makeTempDir("agent-state-tiponly-src-"); - const sourceStore = createRepoStore({ - dataDir: sourceDataDir, - signingKey, - handlers: { "agent-state": permissiveHandler }, - authorize: () => ({ allowed: true }), - }); - const repoId: RepoId = { - kind: "agent-state", - id: `agent-${Math.random().toString(36).slice(2, 10)}`, - }; - await sourceStore.initRepo(repoId); - const { commitSha } = await sourceStore.writeTree( - PRINCIPAL, - repoId, - STATE_REF, - { - files: { "forbidden.txt": "tip-only violation" }, - message: "tip violates the agent-state allowlist", - }, - ); - const { pack } = await sourceStore.createPack(PRINCIPAL, repoId, STATE_REF); - - const targetDataDir = await makeTempDir("agent-state-tiponly-tgt-"); - const targetStore = createRepoStore({ - dataDir: targetDataDir, - signingKey, - handlers: { "agent-state": agentStateKindHandler }, - authorize: () => ({ allowed: true }), - }); - await targetStore.initRepo(repoId); - await expect( - targetStore.receivePack( - PRINCIPAL, - repoId, - STATE_REF, - pack, - commitSha, - null, - ), - ).rejects.toThrow( - /path_violation:.*tree contains disallowed top-level path: forbidden\.txt/, - ); - }); -}); diff --git a/packages/hub-sessions/src/agent-state-kind.ts b/packages/hub-sessions/src/agent-state-kind.ts deleted file mode 100644 index d0a89c513..000000000 --- a/packages/hub-sessions/src/agent-state-kind.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { type } from "arktype"; -import { - authorizeUserPrincipal, - type AuthorizeFn, - type KindHandler, - type Principal, - type ValidatePushResult, -} from "./repo-store"; - -export type AgentStateHubPrincipal = { readonly kind: "hub" }; - -export type AgentStateSidecarPrincipal = { - readonly kind: "sidecar"; - readonly agentId: string; -}; - -export type AgentStatePrincipal = - | AgentStateHubPrincipal - | AgentStateSidecarPrincipal; - -const SidecarPrincipal = type({ - kind: "'sidecar'", - agentId: "string", -}); - -export const AGENT_STATE_DEPLOY_REF = "refs/heads/deploy"; - -// Mirror of the sidecar's agent-state write surface. The isogit -// ContextStore (`packages/storage-isogit/src/store.ts`) writes exactly -// these top-level entries: `turns.jsonl`, `prompt.jsonl`, -// `response.jsonl`, `manifest.jsonl`, `metadata.json`, the `tool-output/` -// blob directory, and the `state/` directory holding `state/audit/` and -// `state/errors/`. `.gitignore` is seeded once by `initSidecarRepo`. -// Adding a new top-level write on the sidecar side requires adding the -// entry here in the same change — receivePack will silently -// `path_violation` the push otherwise. The receivePack path -// (`pack-receive.ts:validateTree`) walks every top-level tree entry — -// files and directories alike — through this allowlist; widening it -// to anything unowned by the sidecar's writer would let a malicious -// pack smuggle non-state content into the repo. -const ALLOWED_STATE_TOP_LEVEL = new Set([ - "state", - ".gitignore", - "turns.jsonl", - "prompt.jsonl", - "response.jsonl", - "manifest.jsonl", - "metadata.json", - "tool-output", -]); - -const ALLOWED_DEPLOY_TOP_LEVEL = new Set(["deploy", ".gitignore"]); - -export const agentStateKindHandler: KindHandler = { - kind: "agent-state", - directoryPrefix: "agents", - validatePush: ({ ref, topLevelTreePaths }): ValidatePushResult => { - // The deploy ref carries hub-authored prompt content under - // `deploy/`; every other ref carries sidecar-pushed agent state - // and must stay confined to the state-bearing allowlist. - const allowed = - ref === AGENT_STATE_DEPLOY_REF - ? ALLOWED_DEPLOY_TOP_LEVEL - : ALLOWED_STATE_TOP_LEVEL; - const offender = topLevelTreePaths.find((p) => !allowed.has(p)); - if (offender !== undefined) { - return { - ok: false, - reason: `tree contains disallowed top-level path: ${offender}`, - }; - } - if (!topLevelTreePaths.some((p) => p !== ".gitignore")) { - return { - ok: false, - reason: "tree must include at least one state-bearing top-level entry", - }; - } - return { ok: true }; - }, - onRefUpdated: () => { - // No consumer at this kind today; the substrate's hook surface is - // uniform across kinds and future kinds will use it. - }, -}; - -export const agentStateAuthorize: AuthorizeFn = ( - principal: Principal, - repoId, - ref, - action, -) => { - if (principal.kind === "hub") { - // Full access at this kind. Hub-side reads (createDeployPack) and writes - // (writeDeployTree) all flow through here, so changing this branch tightens - // behavior in non-obvious places. - return { allowed: true }; - } - - if (principal.kind === "sidecar") { - const parsed = SidecarPrincipal(principal); - if (parsed instanceof type.errors) { - return { - allowed: false, - reason: `sidecar principal is malformed: ${parsed.summary}`, - }; - } - if (repoId.kind !== "agent-state" || repoId.id !== parsed.agentId) { - return { - allowed: false, - reason: `sidecar ${parsed.agentId} cannot access ${repoId.kind}/${repoId.id}`, - }; - } - switch (action) { - case "receivePack": - case "resolveRef": - return { allowed: true }; - case "createPack": - if (ref !== AGENT_STATE_DEPLOY_REF) { - return { - allowed: false, - reason: `sidecar may only fetch ${AGENT_STATE_DEPLOY_REF}, not ${ref}`, - }; - } - return { allowed: true }; - case "init": - return { - allowed: false, - reason: "init is not authorize-gated for agent-state", - }; - case "writeTree": - return { - allowed: false, - reason: `action ${action} is hub-only for agent-state`, - }; - default: { - const _exhaustive: never = action; - return { - allowed: false, - reason: `unhandled action: ${String(_exhaustive)}`, - }; - } - } - } - - if (principal.kind === "user") { - // `agentStateAuthorize` has no top-level repoId.kind gate (the - // other kind handlers reject a mismatched kind up front), so the - // kind check lives here inside the user branch. - if (repoId.kind !== "agent-state") { - return { - allowed: false, - reason: `user authorize received non-agent-state repo ${repoId.kind}/${repoId.id}`, - }; - } - return authorizeUserPrincipal({ - principal, - repoId, - ref, - action, - resourcePrefix: "agent-state", - }); - } - - // Fail closed on any kind not handled above. The tenant-level - // `workflow` principal kind (`@intx/types` principalKinds) is a - // grant owner, not an agent-state bearer, and never carries an - // agent-state repo push here -- so it is intentionally left denied. - return { - allowed: false, - reason: `unknown principal kind: ${principal.kind}`, - }; -}; diff --git a/packages/hub-sessions/src/asset-service.test.ts b/packages/hub-sessions/src/asset-service.test.ts index a730892bc..6cf23ebc5 100644 --- a/packages/hub-sessions/src/asset-service.test.ts +++ b/packages/hub-sessions/src/asset-service.test.ts @@ -246,23 +246,20 @@ describe("AssetService", () => { expect(out.commitSha).toMatch(/^[0-9a-f]{40}$/); }); - test.each([["agent-state"], ["workflow-run"]] as const)( - "rejects kind %s", - async (kind) => { - const err = await service - .createAsset({ - tenantId: "tnt_1", - kind, - name: "x", - }) - .catch((e: unknown) => e); + test.each([["workflow-run"]] as const)("rejects kind %s", async (kind) => { + const err = await service + .createAsset({ + tenantId: "tnt_1", + kind, + name: "x", + }) + .catch((e: unknown) => e); - expect(err).toBeInstanceOf(AssetServiceError); - if (!(err instanceof AssetServiceError)) throw new Error("unreachable"); - expect(err.reason).toBe("unsupported_kind"); - expect(dbFixture.assets).toHaveLength(0); - }, - ); + expect(err).toBeInstanceOf(AssetServiceError); + if (!(err instanceof AssetServiceError)) throw new Error("unreachable"); + expect(err.reason).toBe("unsupported_kind"); + expect(dbFixture.assets).toHaveLength(0); + }); test("creates a workflow asset and inits its repo", async () => { const asset = await service.createAsset({ @@ -327,56 +324,6 @@ describe("AssetService", () => { expect(dbFixture.assets).toHaveLength(0); }); - test("rejects a package-registry asset whose name shadows a configured HTTP registry", async () => { - // The session service builds the per-launch tool-package - // registry map by iterating package-registry assets first and - // HTTP registries second, with an asset-wins-on-name-collision - // rule. Without the reserved-name gate, a `package-registry` - // asset named `npmjs` on any tenant would silently shadow the - // public npm registry for every session resolving through that - // tenant — an opaque reroute the operator did not request. Pin - // the rejection at the asset-creation boundary so the collision - // surfaces at intent time, not as a debugging exercise later. - const reservedService = createAssetService({ - db: dbFixture.db, - repoStore, - reservedPackageRegistryNames: new Set(["npmjs"]), - }); - const err = await reservedService - .createAsset({ - tenantId: "tnt_1", - kind: "package-registry", - name: "npmjs", - }) - .catch((e: unknown) => e); - - expect(err).toBeInstanceOf(AssetServiceError); - if (!(err instanceof AssetServiceError)) throw new Error("unreachable"); - expect(err.reason).toBe("name_reserved"); - expect(err.message).toContain("npmjs"); - expect(dbFixture.assets).toHaveLength(0); - }); - - test("a reserved name only shadows the matching kind", async () => { - // The reserved-name gate scopes to `package-registry` — a skill - // asset named `npmjs` poses no shadowing risk against the - // registry map. Surface that asymmetry in the test so a future - // refactor that broadens the gate has to update this assertion - // along with the code. - const reservedService = createAssetService({ - db: dbFixture.db, - repoStore, - reservedPackageRegistryNames: new Set(["npmjs"]), - }); - const asset = await reservedService.createAsset({ - tenantId: "tnt_1", - kind: "skill", - name: "npmjs", - }); - expect(asset.kind).toBe("skill"); - expect(asset.name).toBe("npmjs"); - }); - test("surfaces duplicate (tenantId, kind, name) as duplicate_asset", async () => { await service.createAsset({ tenantId: "tnt_1", diff --git a/packages/hub-sessions/src/asset-service.ts b/packages/hub-sessions/src/asset-service.ts index f26488350..0171ff6a3 100644 --- a/packages/hub-sessions/src/asset-service.ts +++ b/packages/hub-sessions/src/asset-service.ts @@ -44,9 +44,7 @@ export type Asset = { export type CreateAssetParams = { tenantId: string; - /** Accepted kinds: "skill", "package-registry", "workflow". - * "agent-state" is rejected because those repos are managed by the - * agent lifecycle, not the asset service. */ + /** Accepted kinds: "skill", "package-registry", "workflow". */ kind: RepoKind; name: string; displayName?: string; @@ -119,23 +117,16 @@ export type AssetServiceErrorReason = | "duplicate_attachment" | "invalid_name" | "invalid_reference" - | "name_reserved" | "not_found" | "path_violation"; -// An asset name becomes a workspace mountpath segment for the asset -// kinds a session actually mounts: a tool-package registry mounts at -// `package-registries//`. A skill is an asset kind too, -// and its `SKILL.md` frontmatter is validated on push and indexed -// under the pushed ref, but nothing consumes that index and no +// A skill is an asset kind, and its `SKILL.md` frontmatter is validated on push +// and indexed under the pushed ref, but nothing consumes that index and no // session path mounts a skill asset into an agent workspace. A skill // asset is therefore recorded and validated, not materialized and not -// executed. The mountpath segment validator in applyAssetPack -// rejects anything outside a safe character set; validate at the -// createAsset boundary so a bad name fails at creation time rather -// than at materialization time. Names must be lowercase-kebab: -// lowercase letters, digits, hyphens, with no leading or trailing -// hyphen. The sole enforcement site is the createAsset check below, +// executed. An asset name is a segment of the asset's Git URL, so names must be +// lowercase-kebab: lowercase letters, digits, hyphens, with no leading or +// trailing hyphen. The sole enforcement site is the createAsset check below, // which rejects a bad name as `invalid_name`. export const ASSET_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -159,7 +150,7 @@ export class AssetServiceError extends Error { * "list the root" form; anything else must be a relative path with * no leading slash, no trailing slash, no `..` segment, and no empty * segments (no `//`). This mirrors the same rules that - * `validateClearPrefix` in the repo-store enforces on `clearPrefix` + * `validatePreservePrefix` in the repo-store enforces on `preservePrefix` * arguments, surfaced here as a path-violation error so the caller * sees a structured rejection instead of a confusing "no directory * at /tarballs/" miss when an absolute or `..`-bearing input slips @@ -202,9 +193,6 @@ function rowToAsset(row: typeof assetTable.$inferSelect): Asset { // loudly fails rather than silently mistypes the returned shape. let narrowed: RepoKind; switch (row.kind) { - case "agent-state": - narrowed = "agent-state"; - break; case "skill": narrowed = "skill"; break; @@ -236,24 +224,8 @@ export function createAssetService(deps: { // with createHubSessionLookups and its sibling services. db: DB["db"]; repoStore: RepoStore; - /** - * Names that the session service treats as configured HTTP registries - * when assembling the per-launch package-registry map. A - * `package-registry` asset whose name collides with one of these - * shadows the corresponding HTTP registry at session-launch time - * (asset wins on name collision). Creating such an asset is almost - * always an operator footgun — silently rerouting the public npm - * registry traffic to a tenant-owned asset — so reject the creation - * up front rather than letting the misroute surface later. The host - * threads in its `httpRegistries` keys; the asset service holds them - * statically because the registry config is loaded at hub boot and - * does not change at runtime. - */ - reservedPackageRegistryNames?: ReadonlySet; }): AssetService { const { db, repoStore } = deps; - const reservedPackageRegistryNames = - deps.reservedPackageRegistryNames ?? new Set(); async function createAsset(params: CreateAssetParams): Promise { if ( @@ -276,27 +248,6 @@ export function createAssetService(deps: { ); } - if ( - params.kind === "package-registry" && - reservedPackageRegistryNames.has(params.name) - ) { - // Session-launch builds the per-launch registry map by iterating - // package-registry assets first and HTTP registries second, with - // an asset-wins-on-collision rule. A `package-registry` asset - // named after a configured HTTP registry would silently shadow - // that registry for every session that resolves through this - // tenant — almost certainly an operator misconfig, not an - // intended override. Reject the creation so the operator sees - // the collision at intent time instead of debugging an - // unexpected reroute later. - throw new AssetServiceError( - "name_reserved", - `createAsset rejects name ${JSON.stringify( - params.name, - )}: it collides with a configured HTTP registry of the same name and would silently shadow it at session launch`, - ); - } - const id = generateId("asset"); const now = new Date(); const insertRow = { diff --git a/packages/hub-sessions/src/hub-session-lookups.ts b/packages/hub-sessions/src/hub-session-lookups.ts index 31a228261..50e78daa9 100644 --- a/packages/hub-sessions/src/hub-session-lookups.ts +++ b/packages/hub-sessions/src/hub-session-lookups.ts @@ -25,7 +25,6 @@ import { import { getLogger } from "@intx/log"; import type { PackRejectReason } from "@intx/types/sidecar"; import { signalName } from "@intx/types/signals"; -import { deriveWorkflowRunId } from "@intx/types/workflow-run-id"; import { deriveWorkflowRunRepoId } from "@intx/workflow-deploy"; import type { AgentRepoStore } from "./agent-repo"; @@ -84,7 +83,7 @@ function packFailureReason( logger.error`Workflow-run pack rejected for ${workflowRunRepoId}: ${msg}`; return "missing_objects"; } - // Mirror the agent-state branch's catch-all: any other failure + // Any other failure // (transaction failures, filesystem races, a staged pack whose bytes do // not match its tip, kind-handler diagnostics surfaced as Error messages, // etc.) becomes a structured `corrupt` rejection so the sender can @@ -127,13 +126,6 @@ export function createHubSessionLookups( }); return { - async lookupDeployRef() { - // A workflow run is a supervised workflow-process pinned forever like a - // native deployment: it keeps its deploy-time definition and never - // reconciles, so no address enrolls in the reconnect deploy-ref catch-up. - return null; - }, - readWorkflowRunRefTips(agentAddress) { return readWorkflowRunRefTips(agentRepoStore.repoStore, agentAddress); }, @@ -395,44 +387,6 @@ export function createHubSessionLookups( }); }, - async receiveAgentStatePack(repoId, pack, ref, commitSha) { - if (repoId.kind !== "agent-state") { - throw new Error( - `hub-session lookups receiveAgentStatePack received unsupported repo kind ${JSON.stringify(repoId.kind)}`, - ); - } - const agentAddress = repoId.id; - const agentId = deriveWorkflowRunId(agentAddress); - try { - await agentRepoStore.receiveAgentStatePack( - { kind: "agent-state", id: agentId }, - pack, - ref, - commitSha, - ); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.startsWith("path_violation")) { - logger.warn`State pack rejected for ${agentAddress}: ${msg}`; - return { accepted: false, reason: "path_violation" as const }; - } - // Any other failure from the repo subsystem reaches the - // WebSocket handler as an unhandled rejection unless we catch - // it here. Transient failures during receivePack (the agent - // directory being torn down concurrently with an in-flight - // pack write, filesystem errors mid-rename, etc.) are - // recoverable from the sender's perspective — the sender can - // re-push. Surface every such failure as a structured pack - // rejection (`corrupt` is the closest existing reason — from - // the sender's perspective the pack failed to index) and log - // the underlying error so the cause stays traceable on the - // hub side. - logger.error`State pack receive failed for ${agentAddress}: ${msg}`; - return { accepted: false, reason: "corrupt" as const }; - } - return { accepted: true }; - }, - async receiveWorkflowRunPack(repoId, pack, ref, commitSha, source) { if (repoId.kind !== "workflow-run") { throw new Error( diff --git a/packages/hub-sessions/src/hub-session-orchestrator.test.ts b/packages/hub-sessions/src/hub-session-orchestrator.test.ts index 18e4b97c7..1a7c28019 100644 --- a/packages/hub-sessions/src/hub-session-orchestrator.test.ts +++ b/packages/hub-sessions/src/hub-session-orchestrator.test.ts @@ -4,7 +4,7 @@ import { getTableName, is, Table, type SQL } from "drizzle-orm"; import type { DB } from "@intx/db"; import type { InferenceEvent } from "@intx/types/runtime"; -import type { AgentRepoStore, DeployContent } from "./agent-repo"; +import type { AgentRepoStore } from "./agent-repo"; import type { LocalRepoStore } from "./repo-store"; import type { EventCollectorRegistry } from "./event-collector-registry"; import { agentSession, workflowRun } from "@intx/db/schema"; @@ -195,36 +195,12 @@ function createCollectorRegistry( }; } -type RepoCall = { kind: "createDeployPack"; agentId: string }; - -function createRepoStoreStub(): { - store: AgentRepoStore; - calls: RepoCall[]; -} { - const calls: RepoCall[] = []; +function createRepoStoreStub(): { store: AgentRepoStore } { return { - calls, store: { - async writeDeployTree(_agentId: string, _content: DeployContent) { - throw new Error("mock: writeDeployTree not implemented"); - }, - async createDeployPack(agentId: string) { - calls.push({ kind: "createDeployPack", agentId }); - return { - pack: new Uint8Array([1, 2, 3]), - commitSha: "c".repeat(40), - ref: "refs/heads/deploy", - }; - }, - async receiveAgentStatePack() { - throw new Error("mock: receiveAgentStatePack not implemented"); - }, async prepareWorkflowRunPack() { throw new Error("mock: prepareWorkflowRunPack not implemented"); }, - getSigningPublicKey() { - return new Uint8Array(32); - }, repoStore: unusedRepoStore(), }, }; diff --git a/packages/hub-sessions/src/hub-session-orchestrator.ts b/packages/hub-sessions/src/hub-session-orchestrator.ts index 333dfa28c..d29fbf39f 100644 --- a/packages/hub-sessions/src/hub-session-orchestrator.ts +++ b/packages/hub-sessions/src/hub-session-orchestrator.ts @@ -92,16 +92,15 @@ export function createHubSessionOrchestrator( unsubscribers.push( events.on("agent.deploy.ack", async (event) => { const { agentAddress, publicKey, allocated } = event; - // Provisioned initialization publishes its key only after every deploy - // and asset pack succeeds under the allocation generation fence. + // Provisioned initialization publishes its key only after its deploy + // succeeds under the allocation generation fence. if (allocated !== undefined) return; // Every deploy address names a workflow run whose public key lives on its // single self-anchored workflow_run row, keyed by address. Persist it // there as the deployment's published identity. Reconnect routing is // allocation-authenticated, so this projection is not connection - // authority. Only the deployment-level address owns a row, so a stray - // per-step ack updates nothing. + // authority. await db .update(workflowRun) .set({ publicKey }) diff --git a/packages/hub-sessions/src/index.ts b/packages/hub-sessions/src/index.ts index def3b952b..55de54651 100644 --- a/packages/hub-sessions/src/index.ts +++ b/packages/hub-sessions/src/index.ts @@ -11,16 +11,11 @@ export { createWorkflowDispatchProjection, DEFAULT_WORKFLOW_PROJECTION_CONCURRENCY, } from "./workflow-dispatch-projection"; -export { - createAgentRepoStore, - type AgentRepoStore, - type DeployContent, -} from "./agent-repo"; +export { createAgentRepoStore, type AgentRepoStore } from "./agent-repo"; export { createSessionService, recoverSenderDeploy, SessionLaunchError, - bridgeOrchestratorDeployContent, deployCodeSourcedWorkflow, type SessionService, type DeployWorkflowDefinitionResult, diff --git a/packages/hub-sessions/src/package-registry-kind.ts b/packages/hub-sessions/src/package-registry-kind.ts index 2fa6386ee..aa650d3e8 100644 --- a/packages/hub-sessions/src/package-registry-kind.ts +++ b/packages/hub-sessions/src/package-registry-kind.ts @@ -249,8 +249,8 @@ export const packageRegistryKindHandler: KindHandler = { return { ok: true }; }, onRefUpdated() { - // No cached index today. The resolver reads the asset's contents - // through `readAssetBlob`/`listAssetBlobs` at session time. + // No cached index: the closure resolver reads the asset's contents when + // it resolves a workflow's closure. }, }; diff --git a/packages/hub-sessions/src/repo-store/delta-adversarial.test.ts b/packages/hub-sessions/src/repo-store/delta-adversarial.test.ts index 9b0e8edc4..03a0e6dad 100644 --- a/packages/hub-sessions/src/repo-store/delta-adversarial.test.ts +++ b/packages/hub-sessions/src/repo-store/delta-adversarial.test.ts @@ -32,15 +32,15 @@ afterAll(async () => { const allowAll: AuthorizeFn = () => ({ allowed: true }); const principal: Principal = { kind: "test" }; const REF = "refs/heads/events"; -const repoId: RepoId = { kind: "agent-state", id: "subject" }; +const repoId: RepoId = { kind: "skill", id: "subject" }; function makeStore(dataDir: string) { return createRepoStore({ dataDir, signingKey, handlers: { - "agent-state": { - kind: "agent-state", + skill: { + kind: "skill", directoryPrefix: "repos-under-test", validatePush: () => ({ ok: true }), onRefUpdated: () => undefined, diff --git a/packages/hub-sessions/src/repo-store/guard-probes.test.ts b/packages/hub-sessions/src/repo-store/guard-probes.test.ts index a845635b3..63f79b442 100644 --- a/packages/hub-sessions/src/repo-store/guard-probes.test.ts +++ b/packages/hub-sessions/src/repo-store/guard-probes.test.ts @@ -24,7 +24,7 @@ let signingKey: KeyPair; const allowAll: AuthorizeFn = () => ({ allowed: true }); const principal: Principal = { kind: "test" }; const REF = "refs/heads/events"; -const repoId: RepoId = { kind: "agent-state", id: "subject" }; +const repoId: RepoId = { kind: "skill", id: "subject" }; beforeAll(async () => { signingKey = await generateKeyPair(); @@ -42,8 +42,8 @@ function makeStore(dataDir: string) { dataDir, signingKey, handlers: { - "agent-state": { - kind: "agent-state", + skill: { + kind: "skill", directoryPrefix: "repos-under-test", validatePush: () => ({ ok: true }), onRefUpdated: () => undefined, diff --git a/packages/hub-sessions/src/repo-store/sealed-run-subscribe-replay.test.ts b/packages/hub-sessions/src/repo-store/sealed-run-subscribe-replay.test.ts index 06a7ae1e8..cebcd26b7 100644 --- a/packages/hub-sessions/src/repo-store/sealed-run-subscribe-replay.test.ts +++ b/packages/hub-sessions/src/repo-store/sealed-run-subscribe-replay.test.ts @@ -37,7 +37,7 @@ afterAll(async () => { function permissive(): KindHandler { return { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-sealed-replay", validatePush(): ValidatePushResult { return { ok: true }; @@ -50,7 +50,7 @@ function permissive(): KindHandler { const allowAll: AuthorizeFn = () => ({ allowed: true }); const principal: Principal = { kind: "test" }; -const repoId: RepoId = { kind: "agent-state", id: "subject" }; +const repoId: RepoId = { kind: "skill", id: "subject" }; const REF = "refs/heads/test"; const TimerFired = type({ @@ -67,7 +67,7 @@ describe("subscribeKind replay over a sealed run", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissive() }, + handlers: { skill: permissive() }, authorize: allowAll, }); @@ -102,10 +102,14 @@ describe("subscribeKind replay over a sealed run", () => { new TextEncoder().encode(blob1), new TextEncoder().encode(blob2), ]); - await store.writeTree(principal, repoId, REF, { - files: { "runs/r1/events.jsonl": combined }, - clearPrefix: "runs/r1/events/", + await store.writeTreeDelta(principal, repoId, REF, { + changedPathPrefixes: new Set(["runs/r1/"]), message: "compact r1", + computeDelta: () => + Promise.resolve({ + puts: { "runs/r1/events.jsonl": combined }, + deletes: ["runs/r1/events/"], + }), }); // Confirm the tip really is sealed (events/ gone, events.jsonl present). diff --git a/packages/hub-sessions/src/repo-store/store.test.ts b/packages/hub-sessions/src/repo-store/store.test.ts index d18ffe453..7964d5480 100644 --- a/packages/hub-sessions/src/repo-store/store.test.ts +++ b/packages/hub-sessions/src/repo-store/store.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import git from "isomorphic-git"; import { generateKeyPair } from "@intx/crypto"; -import { collectReachableObjects, runGC } from "@intx/storage-isogit/node"; +import { collectReachableObjects } from "@intx/storage-isogit/node"; import type { KeyPair } from "@intx/types/runtime"; import { waitUntil } from "@intx/types/testing"; import { createRepoStore, type LocalRepoStore } from "./store"; @@ -60,7 +60,7 @@ function createTestHandler(opts?: { const onRefUpdatedCalls: RefUpdateRecord[] = []; const allowFn = opts?.allowTopLevelPaths; return { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-under-test", validatePush({ topLevelTreePaths }): ValidatePushResult { // Ignore the readBlob argument: this fixture only ever needs path-level @@ -85,7 +85,7 @@ const allowAll: AuthorizeFn = () => ({ allowed: true }); const principal: Principal = { kind: "test" }; -const repoId: RepoId = { kind: "agent-state", id: "subject" }; +const repoId: RepoId = { kind: "skill", id: "subject" }; const REF = "refs/heads/test"; @@ -313,7 +313,7 @@ describe("RepoStore", () => { signingKey, handlers: { workflow: { ...createTestHandler(), kind: "workflow" }, - "agent-state": createTestHandler(), + skill: createTestHandler(), }, authorize: allowAll, }); @@ -440,12 +440,11 @@ describe("RepoStore", () => { expect( (await store.listRefs(hub, definitionRepo)).map((entry) => entry.name), ).toEqual(["refs/heads/main", REF]); - await runGC(store.getRepoDir(definitionRepo), { retention: "tip-only" }); - const afterGC = await store.createPack(hub, definitionRepo, REF, { + const retainedPack = await store.createPack(hub, definitionRepo, REF, { commitSha: first.commitSha, retain: true, }); - expect(afterGC.commitSha).toBe(first.commitSha); + expect(retainedPack.commitSha).toBe(first.commitSha); const receiverDir = await makeTempDir("repo-store-pinned-receiver-"); const receiver = createRepoStore({ dataDir: receiverDir, @@ -476,7 +475,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -499,7 +498,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -536,7 +535,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); const first = await store.writeTree(principal, repoId, REF, { @@ -567,7 +566,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.writeTree(principal, repoId, REF, { @@ -610,7 +609,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.writeTree(principal, repoId, REF, { @@ -661,7 +660,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); for (const message of ["first", "second"]) @@ -723,11 +722,11 @@ describe("RepoStore", () => { // The lock map is shared by every store in the process, so this // test's own repo id keeps a gate it leaves held from blocking // other tests. - const raceRepoId: RepoId = { kind: "agent-state", id: "seed-window" }; + const raceRepoId: RepoId = { kind: "skill", id: "seed-window" }; const before = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); for (const message of ["v1", "v2"]) @@ -743,7 +742,7 @@ describe("RepoStore", () => { dataDir, signingKey, handlers: { - "agent-state": { + skill: { ...createTestHandler(), async onRefUpdated() { if (holding) await hold.arrive(); @@ -811,11 +810,11 @@ describe("RepoStore", () => { // The lock map is shared by every store in the process, so this // test's own repo id keeps a gate it leaves held from blocking // other tests. - const raceRepoId: RepoId = { kind: "agent-state", id: "seed-race" }; + const raceRepoId: RepoId = { kind: "skill", id: "seed-race" }; const before = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); for (const message of ["v1", "v2"]) @@ -828,7 +827,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); const dir = store.getRepoDir(raceRepoId); @@ -883,7 +882,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -919,7 +918,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.initRepo(repoId); @@ -947,7 +946,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.initRepo(repoId); @@ -973,26 +972,31 @@ describe("RepoStore", () => { ).toBeNull(); }); - test("writeTree with clearPrefix clears stale tracked files under that prefix", async () => { + test("writeTreePreservingPrefix clears stale tracked files under that prefix", async () => { const dataDir = await makeTempDir("repo-store-clear-"); const handler = createTestHandler(); const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); - await store.writeTree(principal, repoId, REF, { - files: { "deploy/a.md": "A", "deploy/b.md": "B" }, - clearPrefix: "deploy/", + await store.writeTreePreservingPrefix(principal, repoId, REF, { + preservePrefix: "deploy/", + merge: () => Promise.resolve({ "deploy/a.md": "A", "deploy/b.md": "B" }), message: "first", }); - const second = await store.writeTree(principal, repoId, REF, { - files: { "deploy/a.md": "A2" }, - clearPrefix: "deploy/", - message: "second", - }); + const second = await store.writeTreePreservingPrefix( + principal, + repoId, + REF, + { + preservePrefix: "deploy/", + merge: () => Promise.resolve({ "deploy/a.md": "A2" }), + message: "second", + }, + ); const dir = path.join(dataDir, handler.directoryPrefix, repoId.id); const { commit } = await git.readCommit({ @@ -1015,25 +1019,26 @@ describe("RepoStore", () => { expect(deployPaths).not.toContain("b.md"); }); - test("writeTree with clearPrefix on a first write clears nothing and does not error", async () => { - // clearIndexPrefix narrows git.statusMatrix to the cleared prefix. - // A first write to a fresh repo has no tracked entries under the - // prefix, so the narrowed statusMatrix must return an empty matrix - // (clear nothing) rather than erroring on a non-existent subtree. + test("writeTreePreservingPrefix on a first write clears nothing and does not error", async () => { const dataDir = await makeTempDir("repo-store-clear-first-"); const handler = createTestHandler(); const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); - const first = await store.writeTree(principal, repoId, REF, { - files: { "deploy/a.md": "A" }, - clearPrefix: "deploy/", - message: "first write with clearPrefix", - }); + const first = await store.writeTreePreservingPrefix( + principal, + repoId, + REF, + { + preservePrefix: "deploy/", + merge: () => Promise.resolve({ "deploy/a.md": "A" }), + message: "first write", + }, + ); expect(first.commitSha).toMatch(/^[0-9a-f]{40}$/); const dir = path.join(dataDir, handler.directoryPrefix, repoId.id); @@ -1053,16 +1058,13 @@ describe("RepoStore", () => { expect(deployPaths).toEqual(["a.md"]); }); - test("writeTree clearPrefix targeting a never-written prefix is a no-op clear", async () => { - // The narrowed statusMatrix is asked for a prefix that exists in - // neither the index nor the tree (a sibling subtree was written, but - // not this one). It must return empty and leave the sibling intact. + test("writeTreePreservingPrefix on a never-written prefix leaves its sibling intact", async () => { const dataDir = await makeTempDir("repo-store-clear-absent-"); const handler = createTestHandler(); const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -1070,11 +1072,16 @@ describe("RepoStore", () => { files: { "keep/a.md": "A" }, message: "seed sibling subtree", }); - const second = await store.writeTree(principal, repoId, REF, { - files: { "fresh/b.md": "B" }, - clearPrefix: "fresh/", - message: "clear a never-written prefix", - }); + const second = await store.writeTreePreservingPrefix( + principal, + repoId, + REF, + { + preservePrefix: "fresh/", + merge: () => Promise.resolve({ "fresh/b.md": "B" }), + message: "clear a never-written prefix", + }, + ); const dir = path.join(dataDir, handler.directoryPrefix, repoId.id); const { commit } = await git.readCommit({ @@ -1093,7 +1100,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.initRepo(repoId); @@ -1115,17 +1122,14 @@ describe("RepoStore", () => { "deploy\\x.json", "deploy/x\0.json", ]) { - for (const clear of [{}, { clearPrefix: "deploy/" }]) { - await expect( - store.writeTree(principal, repoId, REF, { - files: { "deploy/b.md": "b", [bad]: "{}" }, - ...clear, - message: "bad path", - }), - ).rejects.toThrow(`tree_path_invalid: ${JSON.stringify(bad)}`); - } + await expect( + store.writeTree(principal, repoId, REF, { + files: { "deploy/b.md": "b", [bad]: "{}" }, + message: "bad path", + }), + ).rejects.toThrow(`tree_path_invalid: ${JSON.stringify(bad)}`); } - for (const clearPrefix of [ + for (const preservePrefix of [ "deploy/../", "deploy/./", "deploy//", @@ -1136,12 +1140,12 @@ describe("RepoStore", () => { "deploy\0/", ]) { await expect( - store.writeTree(principal, repoId, REF, { - files: { "deploy/b.md": "b" }, - clearPrefix, + store.writeTreePreservingPrefix(principal, repoId, REF, { + preservePrefix, + merge: () => Promise.resolve({ "deploy/b.md": "b" }), message: "bad prefix", }), - ).rejects.toThrow(`clear_prefix_invalid: ${clearPrefix}`); + ).rejects.toThrow(`preserve_prefix_invalid: ${preservePrefix}`); } for (const bad of ["deploy/./b.md", "deploy//b.md"]) { await expect( @@ -1160,13 +1164,13 @@ describe("RepoStore", () => { expect(fs.existsSync(path.join(dir, "deploy", "b.md"))).toBe(false); }); - test("writeTree without clearPrefix is purely additive", async () => { + test("writeTree is purely additive", async () => { const dataDir = await makeTempDir("repo-store-additive-"); const handler = createTestHandler(); const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -1201,7 +1205,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir: await makeTempDir("repo-store-preserve-scope-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); const prefix = "runs/r1/steps/s1/state/"; @@ -1242,13 +1246,13 @@ describe("RepoStore", () => { ); }); - test("a clearPrefix write scopes files outside its prefix to their own directories", async () => { + test("a prefix write scopes the handler to its prefix and a plain write to the whole tree", async () => { const seen: (string[] | undefined)[] = []; const store = createRepoStore({ dataDir: await makeTempDir("repo-store-prefix-scope-"), signingKey, handlers: { - "agent-state": { + skill: { ...createTestHandler(), validatePush({ changedPathPrefixes }) { seen.push( @@ -1264,31 +1268,17 @@ describe("RepoStore", () => { }); const prefix = "runs/r1/steps/s1/state/"; - await store.writeTree(principal, repoId, REF, { - files: { [`${prefix}checkpoint.json`]: "{}" }, - clearPrefix: prefix, - message: "in prefix", + await store.writeTreePreservingPrefix(principal, repoId, REF, { + preservePrefix: prefix, + merge: () => Promise.resolve({ [`${prefix}checkpoint.json`]: "{}" }), + message: "prefix write", }); await store.writeTree(principal, repoId, REF, { - files: { - [`${prefix}checkpoint.json`]: "{}", - "runs/r1/events/1.json": "{}", - "runs/r1/steps/s2/seed.json": "{}", - }, - clearPrefix: prefix, - message: "outside prefix", - }); - await store.writeTree(principal, repoId, REF, { - files: { [`${prefix}checkpoint.json`]: "{}", "top.json": "{}" }, - clearPrefix: prefix, - message: "outside any directory", + files: { "top.json": "{}" }, + message: "plain write", }); - expect(seen).toEqual([ - [prefix], - ["runs/r1/events/", "runs/r1/steps/s1/state/", "runs/r1/steps/s2/"], - undefined, - ]); + expect(seen).toEqual([[prefix], undefined]); }); test("interleaved writes to two refs of one repo never contaminate either ref's tree", async () => { @@ -1297,7 +1287,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); const dir = path.join(dataDir, handler.directoryPrefix, repoId.id); @@ -1312,11 +1302,12 @@ describe("RepoStore", () => { // below stage identical bytes through this, so equal content must // yield equal tree oids unless a ref switch contaminates the tree. const roundWrite = (topPrefix: string, round: number) => ({ - files: { - [`${topPrefix}/state/${round}.json`]: `${topPrefix}-${round}`, - [`${topPrefix}/head`]: String(round), - }, - clearPrefix: `${topPrefix}/`, + preservePrefix: `${topPrefix}/`, + merge: () => + Promise.resolve({ + [`${topPrefix}/state/${round}.json`]: `${topPrefix}-${round}`, + [`${topPrefix}/head`]: String(round), + }), message: `${topPrefix} ${round}`, }); @@ -1326,13 +1317,13 @@ describe("RepoStore", () => { const aTrees: string[] = []; const bTrees: string[] = []; for (let round = 0; round < ROUNDS; round++) { - const a = await store.writeTree( + const a = await store.writeTreePreservingPrefix( principal, repoId, REF_A, roundWrite("alpha", round), ); - const b = await store.writeTree( + const b = await store.writeTreePreservingPrefix( principal, repoId, REF_B, @@ -1363,7 +1354,7 @@ describe("RepoStore", () => { const baseStore = createRepoStore({ dataDir: baseDataDir, signingKey, - handlers: { "agent-state": baseHandler }, + handlers: { skill: baseHandler }, authorize: allowAll, }); const baseDir = path.join( @@ -1374,7 +1365,7 @@ describe("RepoStore", () => { const ref = `refs/heads/${topPrefix}`; const trees: string[] = []; for (let round = 0; round < ROUNDS; round++) { - const r = await baseStore.writeTree( + const r = await baseStore.writeTreePreservingPrefix( principal, repoId, ref, @@ -1398,7 +1389,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDataDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); await sourceStore.writeTree(principal, repoId, REF, { @@ -1418,7 +1409,7 @@ describe("RepoStore", () => { const acceptStore = createRepoStore({ dataDir: acceptDir, signingKey, - handlers: { "agent-state": acceptHandler }, + handlers: { skill: acceptHandler }, authorize: allowAll, }); await acceptStore.receivePack( @@ -1441,7 +1432,7 @@ describe("RepoStore", () => { const rejectStore = createRepoStore({ dataDir: rejectDir, signingKey, - handlers: { "agent-state": rejectHandler }, + handlers: { skill: rejectHandler }, authorize: allowAll, }); await expect( @@ -1454,7 +1445,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: await makeTempDir("repo-store-steps-source-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); const seen: { @@ -1477,7 +1468,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: await makeTempDir("repo-store-steps-target-"), signingKey, - handlers: { "agent-state": recordingHandler }, + handlers: { skill: recordingHandler }, authorize: allowAll, }); const push = async ( @@ -1537,7 +1528,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const seen: (string[] | undefined)[] = []; @@ -1555,7 +1546,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: await makeTempDir("repo-store-mode-target-"), signingKey, - handlers: { "agent-state": recordingHandler }, + handlers: { skill: recordingHandler }, authorize: allowAll, }); const dir = path.join(sourceDir, sourceHandler.directoryPrefix, repoId.id); @@ -1725,7 +1716,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir: storeDir, signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); return { @@ -1818,7 +1809,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir: storeDir, signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); // The recursion must build the full "deep/link" path, not just "link". @@ -1892,7 +1883,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir: storeDir, signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); await expect( @@ -1969,7 +1960,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir: storeDir, signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); // A regular file, an executable (100755), and a subtree must all pass. @@ -1983,7 +1974,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha } = await sourceStore.writeTree(principal, repoId, REF, { @@ -1999,7 +1990,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": targetHandler }, + handlers: { skill: targetHandler }, authorize: allowAll, }); @@ -2021,7 +2012,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.initRepo(repoId); @@ -2039,7 +2030,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha: existingSha, ...rest } = await sourceStore.writeTree( @@ -2068,7 +2059,7 @@ describe("RepoStore", () => { const denyStore = createRepoStore({ dataDir: denyDir, signingKey, - handlers: { "agent-state": denyHandler }, + handlers: { skill: denyHandler }, authorize: denyAuthorize, }); @@ -2116,7 +2107,7 @@ describe("RepoStore", () => { const partialStore = createRepoStore({ dataDir: partialDir, signingKey, - handlers: { "agent-state": partialHandler }, + handlers: { skill: partialHandler }, authorize: allowOnlyResolve, }); @@ -2158,7 +2149,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha } = await sourceStore.writeTree(principal, repoId, REF, { @@ -2174,7 +2165,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": targetHandler }, + handlers: { skill: targetHandler }, authorize: allowAll, }); @@ -2195,7 +2186,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -2215,7 +2206,7 @@ describe("RepoStore", () => { const dataDir = await makeTempDir("repo-store-writetree-rollback-"); let allowNext = false; const handler: TestHandler = { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-under-test", validatePush(): ValidatePushResult { if (allowNext) return { ok: true }; @@ -2229,7 +2220,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -2290,7 +2281,7 @@ describe("RepoStore", () => { const dataDir = await makeTempDir("repo-store-writetree-restore-ref-"); let allowNext = true; const handler: TestHandler = { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-under-test", validatePush(): ValidatePushResult { if (allowNext) return { ok: true }; @@ -2304,7 +2295,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -2367,7 +2358,7 @@ describe("RepoStore", () => { blob: null, }; const handler: TestHandler = { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-under-test", async validatePush({ topLevelTreePaths, readBlob }) { captured.paths = topLevelTreePaths; @@ -2382,7 +2373,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -2404,7 +2395,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha } = await sourceStore.writeTree(principal, repoId, REF, { @@ -2420,7 +2411,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": targetHandler }, + handlers: { skill: targetHandler }, authorize: allowAll, }); @@ -2435,7 +2426,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha } = await sourceStore.writeTree(principal, repoId, REF, { @@ -2451,7 +2442,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": targetHandler }, + handlers: { skill: targetHandler }, authorize: allowAll, }); @@ -2480,7 +2471,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha: firstSha } = await sourceStore.writeTree( @@ -2513,7 +2504,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": targetHandler }, + handlers: { skill: targetHandler }, authorize: allowAll, }); await targetStore.receivePack( @@ -2546,7 +2537,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha: firstSha } = await sourceStore.writeTree( @@ -2579,7 +2570,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": targetHandler }, + handlers: { skill: targetHandler }, authorize: allowAll, }); await targetStore.receivePack( @@ -2612,7 +2603,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha: firstSha } = await sourceStore.writeTree( @@ -2645,7 +2636,7 @@ describe("RepoStore", () => { // a fixed delay the second push has to arrive inside of. const parked: (() => void)[] = []; const slowHandler: TestHandler = { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-under-test", async validatePush() { events.push("validate"); @@ -2662,7 +2653,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": slowHandler }, + handlers: { skill: slowHandler }, authorize: allowAll, }); @@ -2710,11 +2701,11 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); - const repoA: RepoId = { kind: "agent-state", id: "alpha" }; - const repoB: RepoId = { kind: "agent-state", id: "beta" }; + const repoA: RepoId = { kind: "skill", id: "alpha" }; + const repoB: RepoId = { kind: "skill", id: "beta" }; const { commitSha: shaA } = await sourceStore.writeTree( principal, repoA, @@ -2738,7 +2729,7 @@ describe("RepoStore", () => { reportBothInFlight = resolve; }); const trackingHandler: TestHandler = { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-under-test", async validatePush() { activeConcurrent += 1; @@ -2764,7 +2755,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": trackingHandler }, + handlers: { skill: trackingHandler }, authorize: allowAll, }); @@ -2789,7 +2780,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -2803,13 +2794,13 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); - expect(() => - store.getRepoDir({ kind: "agent-state", id: "../escape" }), - ).toThrow(/^repo_id_invalid/); + expect(() => store.getRepoDir({ kind: "skill", id: "../escape" })).toThrow( + /^repo_id_invalid/, + ); }); test("listRefs returns the genesis branch on a freshly-initialised repo", async () => { @@ -2818,7 +2809,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -2845,7 +2836,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -2887,7 +2878,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -2906,7 +2897,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: denyAuthorize, }); @@ -2922,7 +2913,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -2943,7 +2934,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha } = await sourceStore.writeTree(principal, repoId, REF, { @@ -2955,7 +2946,7 @@ describe("RepoStore", () => { const targetDir = await makeTempDir("repo-store-release-target-"); let rejectOnce = true; const flakyHandler: TestHandler = { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-under-test", validatePush() { if (rejectOnce) { @@ -2972,7 +2963,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": flakyHandler }, + handlers: { skill: flakyHandler }, authorize: allowAll, }); @@ -3000,7 +2991,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -3047,7 +3038,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -3093,7 +3084,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -3128,7 +3119,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.initRepo(repoId); @@ -3158,7 +3149,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.initRepo(repoId); @@ -3217,7 +3208,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.initRepo(repoId); @@ -3267,7 +3258,7 @@ describe("RepoStore", () => { const source = createRepoStore({ dataDir: await makeTempDir("repo-store-dangling-src-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); await source.writeTree(principal, repoId, REF, { @@ -3284,7 +3275,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir: await makeTempDir("repo-store-dangling-tgt-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); await store.receivePack(principal, repoId, REF, pack, commitSha, null); @@ -3324,7 +3315,7 @@ describe("RepoStore", () => { const source = createRepoStore({ dataDir: await makeTempDir("repo-store-gap-src-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); const write = (message: string) => @@ -3337,7 +3328,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir: await makeTempDir("repo-store-gap-tgt-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); // Each pack carries its tip only, and the source writes two commits @@ -3387,7 +3378,7 @@ describe("RepoStore", () => { const source = createRepoStore({ dataDir: await makeTempDir("repo-store-dangling-replay-src-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); const { commitSha: missing } = await source.writeTree( @@ -3406,7 +3397,7 @@ describe("RepoStore", () => { createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); await target().receivePack(principal, repoId, REF, pack, commitSha, null); @@ -3464,70 +3455,12 @@ describe("RepoStore", () => { } }); - test("a subscriber on a kind that GC reclaims does not seed its seq with no lock", async () => { - const dataDir = await makeTempDir("repo-store-gc-seed-"); - const gcStore = () => - createRepoStore({ - dataDir, - signingKey, - handlers: { "agent-state": createTestHandler() }, - authorize: allowAll, - gc: { - kinds: ["agent-state"], - retention: "keep-history", - packThreshold: 1000, - looseThreshold: 1e6, - warnBytes: 1e12, - }, - }); - const before = gcStore(); - for (const message of ["v1", "v2", "v3"]) - await before.writeTree(principal, repoId, REF, { - files: { "a.md": message }, - message, - }); - // A new store over the same data, as after a restart, has no seq - // cached, so the subscriber below would seed one. - const store = gcStore(); - const dir = store.getRepoDir(repoId); - - // A seed would first resolve the ref, and that call starts within - // the microtasks after the first `next()`. - const resolveRef = spyOn(git, "resolveRef"); - const ac = new AbortController(); - try { - const stream = store.subscribe(principal, repoId, REF, { - from: "head", - signal: ac.signal, - }); - const next = stream.next(); - await drainMicrotasks(); - expect( - resolveRef.mock.calls.filter( - ([args]) => args.dir === dir && args.ref === REF, - ), - ).toHaveLength(0); - - await store.writeTree(principal, repoId, REF, { - files: { "a.md": "v4" }, - message: "v4", - }); - const entry = await next; - if (entry.done) throw new Error("Missing update"); - const history = await git.log({ fs, dir, ref: REF }); - expect(entry.value.seq).toBe(history.length - 1); - } finally { - ac.abort(); - resolveRef.mockRestore(); - } - }); - test("a subscriber after updates with no subscriber seeds its seq outside the lock", async () => { const dataDir = await makeTempDir("repo-store-forget-"); const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); const write = (message: string) => @@ -3606,7 +3539,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: () => ({ allowed: false, reason: "denied" }), }); @@ -3636,7 +3569,7 @@ describe("RepoStore", () => { const sourceStore = createRepoStore({ dataDir: sourceDir, signingKey, - handlers: { "agent-state": sourceHandler }, + handlers: { skill: sourceHandler }, authorize: allowAll, }); const { commitSha: parentA } = await sourceStore.writeTree( @@ -3702,7 +3635,7 @@ describe("RepoStore", () => { const targetStore = createRepoStore({ dataDir: targetDir, signingKey, - handlers: { "agent-state": targetHandler }, + handlers: { skill: targetHandler }, authorize: allowAll, }); await targetStore.initRepo(repoId); @@ -3748,11 +3681,9 @@ describe("RepoStore", () => { // lost a commit it acked. // // The substrate keeps the silent-degrade behavior for other kinds - // (e.g. agent-state) whose deploy-shape packs intentionally omit - // the parent chain and whose handlers do not read prior bytes; - // gating the rejection on `repoId.kind === "workflow-run"` is what - // keeps the agent-state state-push flow working through the same - // substrate primitive. + // whose deploy-shape packs intentionally omit the parent chain and whose + // handlers do not read prior bytes, so the rejection gates on + // `repoId.kind === "workflow-run"`. test("receivePack rejects a workflow-run pack with a dangling parent", async () => { const wfRepoId: RepoId = { kind: "workflow-run", id: "subject" }; // Permissive test handler stamped as the workflow-run kind so @@ -4563,20 +4494,17 @@ describe("RepoStore", () => { }, message: "v2", }); - const { commitSha: thirdSha } = await sourceStore.writeTree( - principal, - wfRepoId, - REF, - { - clearPrefix: "swap/", - files: { - "swap/a/z": "shared", - "swap/a/w": "only in x", - "swap/x/z": "shared", - }, + const { commitSha: thirdSha } = + await sourceStore.writeTreePreservingPrefix(principal, wfRepoId, REF, { + preservePrefix: "swap/", + merge: () => + Promise.resolve({ + "swap/a/z": "shared", + "swap/a/w": "only in x", + "swap/x/z": "shared", + }), message: "v3", - }, - ); + }); const { pack } = await sourceStore.createPack(principal, wfRepoId, REF); @@ -4754,7 +4682,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -4789,7 +4717,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -4823,7 +4751,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -4844,7 +4772,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.writeTree(principal, repoId, REF, { @@ -4890,7 +4818,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); await store.writeTree(principal, repoId, REF, { @@ -4928,7 +4856,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); const { commitSha } = await store.writeTree(principal, repoId, REF, { @@ -4965,7 +4893,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -4985,7 +4913,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -5010,7 +4938,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: denyResolveRef, }); @@ -5030,7 +4958,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -5061,7 +4989,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -5086,7 +5014,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: allowAll, }); @@ -5105,7 +5033,7 @@ describe("RepoStore", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": handler }, + handlers: { skill: handler }, authorize: denyResolveRef, }); @@ -5375,7 +5303,7 @@ describe("RepoStore pack receive reads", () => { const sourceStore = createRepoStore({ dataDir: await makeTempDir("repo-store-reads-rejected-source-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); const first = await sourceStore.writeTree(principal, repoId, REF, { @@ -5396,8 +5324,8 @@ describe("RepoStore pack receive reads", () => { dataDir, signingKey, handlers: { - "agent-state": { - kind: "agent-state", + skill: { + kind: "skill", directoryPrefix: "repos-under-test", async validatePush({ readBlob }): Promise { for (let i = 0; i < reads; i++) { @@ -5448,88 +5376,6 @@ describe("RepoStore pack receive reads", () => { expect([...loads.keys()].some((n) => n.endsWith(".idx"))).toBe(true); }); - test("reads stay correct across GC repacks between receives", async () => { - const RECEIVES = 6; - const sourceStore = createRepoStore({ - dataDir: await makeTempDir("repo-store-reads-gc-source-"), - signingKey, - handlers: { "agent-state": createTestHandler() }, - authorize: allowAll, - }); - - const priors: (string | null)[] = []; - const dataDir = await makeTempDir("repo-store-reads-gc-"); - const store = createRepoStore({ - dataDir, - signingKey, - handlers: { - "agent-state": { - kind: "agent-state", - directoryPrefix: "repos-under-test", - async validatePush({ - readBlob, - priorReadBlob, - }): Promise { - await readBlob("deploy/payload.txt"); - const prior = await priorReadBlob("deploy/payload.txt"); - priors.push( - prior === null ? null : new TextDecoder().decode(prior), - ); - return { ok: true }; - }, - onRefUpdated() { - return; - }, - }, - }, - authorize: allowAll, - // A low pack threshold makes the receives repack several times, - // so later receives read objects from a GC pack. - gc: { - kinds: ["agent-state"], - packThreshold: 2, - looseThreshold: 1e6, - warnBytes: 1e12, - retention: "keep-history", - }, - }); - await store.initRepo(repoId); - - let tip: string | null = null; - for (let i = 0; i < RECEIVES; i++) { - const { commitSha } = await sourceStore.writeTree( - principal, - repoId, - REF, - { - files: { "deploy/payload.txt": `payload ${String(i)}` }, - message: `payload ${String(i)}`, - }, - ); - const { pack } = await sourceStore.createPack(principal, repoId, REF); - await store.receivePack(principal, repoId, REF, pack, commitSha, tip); - tip = commitSha; - expect(await readPayload(store, repoId)).toBe(`payload ${String(i)}`); - } - - expect(priors).toEqual([ - null, - ...Array.from({ length: RECEIVES - 1 }, (_, i) => `payload ${String(i)}`), - ]); - const packNames = await fs.promises.readdir( - path.join( - dataDir, - "repos-under-test", - repoId.id, - ".git", - "objects", - "pack", - ), - ); - // A GC repack publishes its pack under the transfer id `gc-`. - expect(packNames.some((n) => n.startsWith("pack-recv-gc-"))).toBe(true); - }); - test("a multi-commit receive parses each commit and tree a bounded number of times", async () => { // Each commit adds one file to `deploy/`, and validation reads every // file on both sides. Without a memo, each path read parses the root @@ -5576,8 +5422,8 @@ describe("RepoStore pack receive reads", () => { dataDir: await makeTempDir("repo-store-reads-memo-tgt-"), signingKey, handlers: { - "agent-state": { - kind: "agent-state", + skill: { + kind: "skill", directoryPrefix: "repos-under-test", async validatePush({ listDir, @@ -5627,7 +5473,7 @@ describe("RepoStore pack receive reads", () => { const sourceStore = createRepoStore({ dataDir: await makeTempDir("repo-store-reads-wide-source-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); const { commitSha } = await sourceStore.writeTree(principal, repoId, REF, { @@ -5641,8 +5487,8 @@ describe("RepoStore pack receive reads", () => { dataDir: await makeTempDir("repo-store-reads-wide-target-"), signingKey, handlers: { - "agent-state": { - kind: "agent-state", + skill: { + kind: "skill", directoryPrefix: "repos-under-test", async validatePush({ listDir, @@ -5693,7 +5539,7 @@ describe("RepoStore pack receive reads", () => { function listingHandler(out: Map): KindHandler { return { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-under-test", async validatePush({ listDirOids }): Promise { for (const p of listedPaths) out.set(p, await listDirOids(p)); @@ -5709,7 +5555,7 @@ describe("RepoStore pack receive reads", () => { const sourceStore = createRepoStore({ dataDir: await makeTempDir("repo-store-listing-source-"), signingKey, - handlers: { "agent-state": listingHandler(written) }, + handlers: { skill: listingHandler(written) }, authorize: allowAll, }); const { commitSha } = await sourceStore.writeTree(principal, repoId, REF, { @@ -5726,7 +5572,7 @@ describe("RepoStore pack receive reads", () => { const targetStore = createRepoStore({ dataDir: await makeTempDir("repo-store-listing-target-"), signingKey, - handlers: { "agent-state": listingHandler(received) }, + handlers: { skill: listingHandler(received) }, authorize: allowAll, }); await targetStore.receivePack( @@ -6603,35 +6449,6 @@ describe("RepoStore prepared pack receive", () => { } }); - test("prepareReceivePack refuses a kind that GC reclaims", async () => { - const line = await sourceLine(["v1"]); - const [tip] = line.commits; - if (tip === undefined) throw new Error("line"); - const store = createRepoStore({ - dataDir: await makeTempDir("repo-store-prepared-gc-"), - signingKey, - handlers: { "workflow-run": runsHandler() }, - authorize: allowAll, - gc: { - kinds: ["workflow-run"], - retention: "keep-history", - packThreshold: 1000, - looseThreshold: 1000, - warnBytes: Number.MAX_SAFE_INTEGER, - }, - }); - - await expect( - store.prepareReceivePack( - principal, - repoId, - REF, - await line.packAt(tip), - tip, - ), - ).rejects.toThrow(/reclaimed by GC/); - }); - test("a receive with no subscriber reads no ref history under the lock", async () => { const line = await sourceLine( Array.from({ length: 12 }, (_, i) => `v${String(i + 1)}`), @@ -6812,11 +6629,11 @@ describe("RepoStore prepared pack receive", () => { if (tipA === undefined || tipB === undefined) throw new Error("line"); // A workflow-run history only grows, so the line is replaced on a // kind that allows it. - const stateId: RepoId = { kind: "agent-state", id: "subject" }; + const stateId: RepoId = { kind: "skill", id: "subject" }; const store = createRepoStore({ dataDir: await makeTempDir("repo-store-replaced-line-"), signingKey, - handlers: { "agent-state": createTestHandler() }, + handlers: { skill: createTestHandler() }, authorize: allowAll, }); await store.receivePack( @@ -6906,10 +6723,7 @@ describe("RepoStore prepared pack receive", () => { // A store over `dataDir` whose workflow-run handler counts the // commits it validates, by their `deploy/a.md`, and can hold a ref // update in onRefUpdated while it holds the repo lock. - function countingStore( - dataDir: string, - gc?: Parameters[0]["gc"], - ): { + function countingStore(dataDir: string): { store: ReturnType; validated: string[]; holdNextUpdate: () => ReturnType; @@ -6933,7 +6747,6 @@ describe("RepoStore prepared pack receive", () => { }, }, authorize: allowAll, - ...(gc === undefined ? {} : { gc }), }); return { store, @@ -7379,64 +7192,6 @@ describe("RepoStore prepared pack receive", () => { } }); - test("a receive into a kind that GC reclaims scans its existing commits only under the lock", async () => { - const contents = Array.from({ length: 6 }, (_, i) => `v${String(i + 1)}`); - // The test holds the repo lock, so it uses its own repo id. - const gcRepoId: RepoId = { kind: "workflow-run", id: "gc-kind" }; - const { line, dataDir, repoDir } = await restartedTarget( - contents, - 4, - gcRepoId, - ); - const received = line.commits[3]; - const tip = line.commits.at(-1); - if (received === undefined || tip === undefined) throw new Error("line"); - const pack = await line.packAt(tip); - const { store, validated, holdNextUpdate } = countingStore(dataDir, { - kinds: ["workflow-run"], - retention: "keep-history", - packThreshold: 1000, - looseThreshold: 1000, - warnBytes: Number.MAX_SAFE_INTEGER, - }); - - const held = holdNextUpdate(); - const write = store.writeTree(principal, gcRepoId, "refs/heads/other", { - files: { "deploy/a.md": "w1" }, - message: "w1", - }); - await held.reached; - - // A scan with no lock first checks that the repository exists, and - // that check starts within the microtasks after the receive starts. - const gitDir = path.join(repoDir, ".git"); - const stat = spyOn(fs.promises, "stat"); - let receiving: Promise | null = null; - try { - receiving = store.receivePack( - principal, - gcRepoId, - REF, - pack, - tip, - received, - ); - await drainMicrotasks(); - expect( - stat.mock.calls.filter(([target]) => target === gitDir), - ).toHaveLength(0); - held.release(); - await write; - validated.length = 0; - await receiving; - expect(validated).toEqual(["v5", "v6"]); - } finally { - stat.mockRestore(); - held.release(); - await Promise.allSettled([write, receiving]); - } - }); - test("a receive whose scan before the lock fails scans under the lock", async () => { const contents = Array.from({ length: 6 }, (_, i) => `v${String(i + 1)}`); const { line, dataDir, repoDir } = await restartedTarget(contents, 4); diff --git a/packages/hub-sessions/src/repo-store/store.ts b/packages/hub-sessions/src/repo-store/store.ts index 3df00c649..9e94c8504 100644 --- a/packages/hub-sessions/src/repo-store/store.ts +++ b/packages/hub-sessions/src/repo-store/store.ts @@ -11,14 +11,12 @@ import { createDeployPack, createObjectPresence, HUB_REF_NAMESPACE, - maybeGC, removeOrphanPackIndexes, removeStagedPacks, stagePack, syncDirectoryDurable, syncRemovedPathDurable, type CommitSigner, - type GCPolicy, type ObjectPresence, type StagedPack, } from "@intx/storage-isogit/node"; @@ -155,16 +153,6 @@ export type CreateRepoStoreConfig = { * back to the unsigned harness-authored genesis. */ signingCallback?: (repoId: RepoId) => CommitSigner | undefined; - /** - * Optional write-path garbage-collection policy. When supplied, after - * every successful write under the repo lock the substrate applies the - * shared reclaim policy to repos whose kind is in `kinds`. Omitted - * entirely, the substrate never reclaims and never warns. The `kinds` - * allowlist keeps the policy scoped to the kinds the caller intends - * (e.g. `agent-state`) rather than every kind the store happens to - * service. - */ - gc?: GCPolicy & { kinds: readonly RepoKind[] }; }; /** @@ -211,11 +199,10 @@ export type LocalRepoStore = RepoStore & { * `commit()`, when `removeRepo` removed the repo after the pack was * staged. * - * Only for the workflow-run kind, and only while GC does not reclaim - * that kind. The validation runs with no repo lock, so the kind's - * validatePush must hold no state for onRefUpdated, and no pack may - * be removed during the validation. Throws for any other kind, and - * for a kind that GC reclaims. + * Only for the workflow-run kind. The validation runs with no repo + * lock, so the kind's validatePush must hold no state for + * onRefUpdated, and no pack may be removed during the validation. + * Throws for any other kind. */ prepareReceivePack( principal: Principal, @@ -227,8 +214,7 @@ export type LocalRepoStore = RepoStore & { }; export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { - const { dataDir, signingKey, handlers, authorize, signingCallback, gc } = - config; + const { dataDir, signingKey, handlers, authorize, signingCallback } = config; // Per-repo-directory isomorphic-git memoization cache. The store // threads that dir's cache through every index-touching and @@ -260,12 +246,12 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // The cache keys a pack by its filename, so it depends on pack names // that never repeat with different bytes. @intx/storage-isogit names a // received pack `pack-recv-`, where the receive draws a - // random transferId, and a GC repack `pack-recv-gc-`. Thus a + // random transferId. Thus a // pack receive needs no invalidation after it writes objects and moves // a ref. A receive validates a pack through a view that shows the // staged pack under the name it gets when published, so an entry // loaded during the validation is the same entry after the publish. - // An entry for a rejected (discarded) or GC-replaced pack stays until + // An entry for a rejected (discarded) pack stays until // the next budget rebuild, but the directory listing does not name // that pack, so no read uses the entry again. These // properties also make it safe to drop a dir's cache at any instant: @@ -494,7 +480,7 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // advances from, so that history's seq starts again once the entry // goes. An update with no subscriber computes no seq and deletes the // entry, and `primeReplay` seeds it again, with no lock, for the next - // subscriber, except on a kind that GC reclaims (see `seedFromTip`). + // subscriber. // A kept entry for an older tip would make that seed skip, and the // next update would walk every commit since that tip under the lock. // An update that lands after a subscriber registers and before its @@ -574,10 +560,10 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // commits (writeTree commits one parent and a receive rejects a // commit with more), so this is the position `replayHistory` gives // the commit. The walk stops early at `known`, a commit whose seq it - // already has. A history with a missing parent, as a deploy pack or - // GC leaves it, has no replay entries to match. A walk that meets the - // missing parent counts the readable commits it walked: on from - // `known` when there is one, so a gap between two received tips + // already has. A history with a missing parent, as a pack that carries + // only its tip leaves it, has no replay entries to match. A walk that + // meets the missing parent counts the readable commits it walked: on + // from `known` when there is one, so a gap between two received tips // still advances the seq, and from 0 at the oldest readable commit // when there is none. Returns null only when `sha` itself is missing. async function commitDepth( @@ -731,27 +717,6 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { return path.join(dataDir, handler.directoryPrefix, repoId.id); } - // GC runs on the write path of a repo whose kind is in `gc.kinds`. It - // removes packs and loose objects, and under `tip-only` retention it - // drops history, so a read of that repo with no repo lock can find - // objects gone partway through. - function isReclaimedByGC(repoId: RepoId): boolean { - return gc !== undefined && gc.kinds.includes(repoId.kind); - } - - // Write-path reclaim. Called at the end of every successful write for an - // allowlisted kind, from inside the substrate's `withRepoLock`. The shared - // evaluator acquires the storage per-directory lock, applies the policy - // (reclaim over threshold, warn over byte budget), and logs rather than - // propagates a reclaim failure — the triggering write has already - // committed durably, so failing the caller would misreport it. - async function maybeRunGC(repoId: RepoId): Promise { - // Not `isReclaimedByGC`: the check is inline so `gc` narrows for the - // `maybeGC` call below. - if (gc === undefined || !gc.kinds.includes(repoId.kind)) return; - await maybeGC(repoDir(repoId), gc); - } - function gateAccess( principal: Principal, repoId: RepoId, @@ -769,8 +734,8 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { "authorize_denied: only the Hub may write non-ASCII ref names", ); } - // GC keeps these refs alive, and a loose ref at a namespace root would - // block all retained children. Compare case-insensitively: on a + // These refs are the Hub's, and a loose ref at a namespace root would + // block all its children. Compare case-insensitively: on a // case-insensitive filesystem a differently cased ref is the same file. const lowerRef = ref.toLowerCase(); if ( @@ -788,11 +753,12 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { } } - function validateClearPrefix(clearPrefix: string): void { + function validatePreservePrefix(preservePrefix: string): void { const malformed = - !clearPrefix.endsWith("/") || !isPlainRelativePath(clearPrefix, true); + !preservePrefix.endsWith("/") || + !isPlainRelativePath(preservePrefix, true); if (malformed) { - throw new Error(`clear_prefix_invalid: ${clearPrefix}`); + throw new Error(`preserve_prefix_invalid: ${preservePrefix}`); } } @@ -1374,9 +1340,8 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // "new" when it is readable from the object store, was not present // before the pack arrived (not in `existingCommits`), and does not // match `priorTip`, the ref's tip under the lock. When the parent's - // commit object is not in the store at all — the common case for the - // deploy-pack shape `createDeployPack` produces, which packs only the - // tip's tree and not its ancestor commits — that absence is the + // commit object is not in the store at all — a pack that carries only its + // tip, with no ancestor commits — that absence is the // boundary: history older than the readable commit is opaque from // this pack's perspective, so the walker stops without pushing the // unreadable parent. @@ -1462,7 +1427,7 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // and clears the blob there; deleting a path that is a directory in // the base is rejected with `delete_type_mismatch` (there is no // git-rm-style recursive drop). A trailing-slash entry names a - // subtree prefix and clears it (the clear-prefix shape); a + // subtree prefix and clears it; a // trailing-slash delete whose leading segment is a base blob is // rejected with `delete_type_mismatch`, unless a `put` drives the // same descent -- that is a legitimate file-to-directory replacement @@ -1806,8 +1771,7 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { }); if (!validation.ok) { // Nothing was staged and no ref advanced: the assembly wrote only - // unreferenced blob and tree objects, which the next GC reclaims. - // There is no half-applied index or working-tree state to roll + // unreferenced blob and tree objects. There is no half-applied index or working-tree state to roll // back, so the refusal just surfaces. throw new Error(`path_violation: ${validation.reason}`); } @@ -1862,64 +1826,9 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { await handler.onRefUpdated({ repoId, ref, oldSha, newSha: commitSha }); await emitRefUpdate(repoId, ref, oldSha, commitSha); - await maybeRunGC(repoId); - return { commitSha, newlyTerminalRuns: validation.newlyTerminalRuns ?? [] }; } - // A clearPrefix write clears and rewrites its prefix, but its files may - // land outside it. Each such file widens the change scope to its own - // directory, or to the whole tree for a top-level file, so no change - // escapes the handler's validation. - function prefixWriteScope( - prefix: string, - files: Record, - ): ReadonlySet | undefined { - const scope = new Set([prefix]); - for (const filepath of Object.keys(files)) { - if (filepath.startsWith(prefix)) continue; - const slash = filepath.lastIndexOf("/"); - if (slash < 0) return undefined; - scope.add(filepath.slice(0, slash + 1)); - } - return scope; - } - - // Normalize a `TreeContent` (files + optional clearPrefix) into the - // puts/deletes/scope shape writeTreeUnderLock consumes. A clearPrefix - // becomes a single subtree-delete and anchors the handler's change - // scope; its absence is a purely-additive write validated in full. - function normalizeTreeContent(content: TreeContent): { - files: Record; - deletes: ReadonlySet; - changedPathPrefixes: ReadonlySet | undefined; - message: string; - } { - for (const filepath of Object.keys(content.files)) { - if (!isPlainRelativePath(filepath, false)) { - throw new Error(`tree_path_invalid: ${JSON.stringify(filepath)}`); - } - } - if (content.clearPrefix !== undefined) { - validateClearPrefix(content.clearPrefix); - return { - files: content.files, - deletes: new Set([content.clearPrefix]), - changedPathPrefixes: prefixWriteScope( - content.clearPrefix, - content.files, - ), - message: content.message, - }; - } - return { - files: content.files, - deletes: new Set(), - changedPathPrefixes: undefined, - message: content.message, - }; - } - async function writeTree( principal: Principal, repoId: RepoId, @@ -1927,13 +1836,23 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { content: TreeContent, ): Promise { gateAccess(principal, repoId, ref, "writeTree"); + for (const filepath of Object.keys(content.files)) { + if (!isPlainRelativePath(filepath, false)) { + throw new Error(`tree_path_invalid: ${JSON.stringify(filepath)}`); + } + } // The lock spans the entire substrate body of writeTree: tree // assembly, validatePush, commit, and the onRefUpdated hook. Holding // the lock through onRefUpdated keeps post-update consumers // serialized against the same ref's next writer. return withRepoLock(repoId, () => - writeTreeUnderLock(principal, repoId, ref, normalizeTreeContent(content)), + writeTreeUnderLock(principal, repoId, ref, { + files: content.files, + deletes: new Set(), + changedPathPrefixes: undefined, + message: content.message, + }), ); } @@ -1977,7 +1896,7 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { args: WriteTreePreservingPrefixArgs, ): Promise { gateAccess(principal, repoId, ref, "writeTree"); - validateClearPrefix(args.preservePrefix); + validatePreservePrefix(args.preservePrefix); return withRepoLock(repoId, async () => { // Reading and merging both happen inside the lock so two // concurrent callers targeting the same prefix observe each @@ -2014,7 +1933,7 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // move that adds one entry and deletes another) does not re-hash or // re-materialize the untouched siblings. `changedPathPrefixes` is the // handler's scoping hint for the touched region; the caller supplies it - // because the delta has no single clear-prefix to derive it from. + // because the delta has no single prefix to derive it from. async function writeTreeDelta( principal: Principal, repoId: RepoId, @@ -2126,9 +2045,7 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // Scan a repo's history before a receive takes the repo lock, when // the repo has no existing-commit set yet. Answers null when the set - // exists, when GC reclaims the kind (a scan with no lock could lose a - // pack and leave commits out of the set), when the repo does not - // exist yet, and when the scan fails: the scan races ref writes and + // exists, when the repo does not exist yet, and when the scan fails: the scan races ref writes and // `removeRepo`, so a failure is not a fault of the receive, and the // locked scan in `stageReceive` reports a real one. A receive that // starts while a scan of the repo runs waits for that scan. @@ -2136,7 +2053,7 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { repoId: RepoId, ): Promise { const key = indexCacheKey(repoId); - if (existingCommitsCache.has(key) || isReclaimedByGC(repoId)) { + if (existingCommitsCache.has(key)) { return Promise.resolve(null); } const running = existingCommitsScans.get(key); @@ -2569,7 +2486,6 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { try { const runs = await validateStaged(s); const result = await commitStaged(s, runs, mode); - await maybeRunGC(repoId); // A receive that checks `expectedOldSha` always moves the ref. return result.kind === "applied" ? result.newlyTerminalRuns : []; } finally { @@ -2591,18 +2507,12 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // onRefUpdated (the skill kind does) needs one lock span over both, // so only the workflow-run kind, the one caller of this path, may // use it. The validation also depends on no pack being removed while - // it reads. Only GC removes packs, so the kind must not be a GC kind. - // For the same reason commit does not run GC. + // it reads, and nothing but `removeRepo` removes one. if (repoId.kind !== "workflow-run") { throw new Error( `prepareReceivePack: kind ${repoId.kind} is not workflow-run; a prepared receive validates with no repo lock, so it accepts only the workflow-run kind`, ); } - if (isReclaimedByGC(repoId)) { - throw new Error( - `prepareReceivePack: kind ${repoId.kind} is reclaimed by GC, which can remove a pack while a prepared receive reads it`, - ); - } // No `expectedOldSha`: the ref can move while this receive validates, // so commit decides from the live tip instead of a CAS. const mode = { kind: "prepared" } as const; @@ -2691,8 +2601,10 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { const dir = repoDir(repoId); await git.readCommit({ fs, dir, oid: pinned.commitSha }); if (pinned.retain) { - // This ref keeps approved source bytes reachable after a branch is - // replaced. Its name is its value, so retaining it is idempotent. + // Nothing prunes a Hub repo today, but this ref is all that keeps + // approved source bytes reachable after a branch is replaced, so a + // later prune or a manual `git gc` cannot lose them. Its name is its + // value, so retaining it is idempotent. const retainedRef = `${DEFINITION_REF_NAMESPACE}/${pinned.commitSha}`; await git.writeRef({ fs, @@ -2756,7 +2668,7 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // cursor stays put, so the next `createPack` re-includes the un-acked // commits and the receiver gets a self-consistent chain. Only the // `workflow-run` kind ships incremental packs; for every other kind - // `createPack` produces a self-contained deploy pack and the cursor + // `createPack` produces a self-contained pack and the cursor // is unused, so this is a no-op there. function commitPackedTip(repoId: RepoId, ref: string, commitSha: string) { if (repoId.kind !== "workflow-run") return; @@ -3001,12 +2913,9 @@ export function createRepoStore(config: CreateRepoStoreConfig): LocalRepoStore { // that lands after the seed walks under the repo lock only to that // tip, not the whole history. The seed runs with no repo lock, so an // update can cache its own entry first; the seed then leaves that - // entry, which a history with a missing parent advances from. A - // kind that GC reclaims gets no seed walk: GC can remove an object - // during a walk with no lock, and the walk would count a history it - // saw cut short. Its first update counts under the lock instead. + // entry, which a history with a missing parent advances from. async function seedFromTip(dir: string): Promise { - if (seqCache.has(key) || isReclaimedByGC(repoId)) return; + if (seqCache.has(key)) return; const tip = await resolveRefSha(dir, ref); if (tip === null) return; const depth = await withReceiveCache(dir, (cache) => diff --git a/packages/hub-sessions/src/repo-store/subscribe-kind.test.ts b/packages/hub-sessions/src/repo-store/subscribe-kind.test.ts index 67ac40069..7b13cdadb 100644 --- a/packages/hub-sessions/src/repo-store/subscribe-kind.test.ts +++ b/packages/hub-sessions/src/repo-store/subscribe-kind.test.ts @@ -39,7 +39,7 @@ afterAll(async () => { function createPermissiveHandler(): KindHandler { return { - kind: "agent-state", + kind: "skill", directoryPrefix: "repos-subscribe-kind", validatePush(): ValidatePushResult { return { ok: true }; @@ -52,7 +52,7 @@ function createPermissiveHandler(): KindHandler { const allowAll: AuthorizeFn = () => ({ allowed: true }); const principal: Principal = { kind: "test" }; -const repoId: RepoId = { kind: "agent-state", id: "subject" }; +const repoId: RepoId = { kind: "skill", id: "subject" }; const REF = "refs/heads/test"; const TimerFired = type({ @@ -76,7 +76,7 @@ describe("subscribeKind", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createPermissiveHandler() }, + handlers: { skill: createPermissiveHandler() }, authorize: allowAll, }); @@ -117,7 +117,7 @@ describe("subscribeKind", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createPermissiveHandler() }, + handlers: { skill: createPermissiveHandler() }, authorize: allowAll, }); @@ -150,7 +150,7 @@ describe("subscribeKind", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createPermissiveHandler() }, + handlers: { skill: createPermissiveHandler() }, authorize: allowAll, }); @@ -215,7 +215,7 @@ describe("subscribeKind added-blob handling", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createPermissiveHandler() }, + handlers: { skill: createPermissiveHandler() }, authorize: allowAll, }); // A single commit adds events across two runs out of natural order. @@ -257,7 +257,7 @@ describe("subscribeKind added-blob handling", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createPermissiveHandler() }, + handlers: { skill: createPermissiveHandler() }, authorize: allowAll, }); await store.writeTree(principal, repoId, REF, { @@ -284,7 +284,7 @@ describe("subscribeKind added-blob handling", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createPermissiveHandler() }, + handlers: { skill: createPermissiveHandler() }, authorize: allowAll, }); await store.writeTree(principal, repoId, REF, { @@ -306,7 +306,7 @@ describe("subscribeKind added-blob handling", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": createPermissiveHandler() }, + handlers: { skill: createPermissiveHandler() }, authorize: allowAll, }); await store.writeTree(principal, repoId, REF, { diff --git a/packages/hub-sessions/src/repo-store/subscribe-kind.ts b/packages/hub-sessions/src/repo-store/subscribe-kind.ts index 5676dc57a..0bd739fd0 100644 --- a/packages/hub-sessions/src/repo-store/subscribe-kind.ts +++ b/packages/hub-sessions/src/repo-store/subscribe-kind.ts @@ -119,8 +119,8 @@ export async function* subscribeKind( repoId, envelope.newSha, ); - // A commit a concurrent GC pruned between the ref-update event and - // this read yields nothing; the substrate surfaces that as `null`. + // A commit the repo no longer holds by the time of this read yields + // nothing; the substrate surfaces that as `null`. if (newReads === null) continue; const oldReads = envelope.oldSha === undefined || envelope.oldSha === null diff --git a/packages/hub-sessions/src/repo-store/types.ts b/packages/hub-sessions/src/repo-store/types.ts index d073df64f..e5704572a 100644 --- a/packages/hub-sessions/src/repo-store/types.ts +++ b/packages/hub-sessions/src/repo-store/types.ts @@ -158,21 +158,12 @@ export type RefEntry = { export type TreeContent = { /** - * Map of repo-relative path to file contents. Each entry is written - * to the working tree and staged before commit. A path with a segment - * that fails `isPlainPathSegment`, which includes a leading `/`, rejects - * the write. + * Map of repo-relative path to file contents. The write is purely + * additive: every other path in the parent tree carries forward. A path + * with a segment that fails `isPlainPathSegment`, which includes a + * leading `/`, rejects the write. */ files: Record; - /** - * Optional directory-subtree prefix to clear before staging. When - * set, every tracked path beginning with this prefix is removed - * from the git index and the corresponding directory on disk is - * deleted before `files` is written. Must end with `/`, and every - * segment must pass `isPlainPathSegment`. When unset, writeTree is - * purely additive. - */ - clearPrefix?: string; /** Commit message for the resulting commit. */ message: string; }; @@ -224,7 +215,7 @@ export type WriteTreePreservingPrefixArgs = { * * `changedPathPrefixes` is the validation scoping hint for the touched * region (e.g. `addresses//`), supplied by the caller because a - * delta has no single clear-prefix to derive it from; `undefined` means + * delta has no single prefix to derive it from; `undefined` means * validate the whole tree. * * The `prior` argument exposes cache-backed reads of that pinned parent @@ -254,7 +245,7 @@ export interface KindHandler { kind: RepoKind; /** * On-disk directory under `dataDir` for repos of this kind. Allows - * each kind to declare its own layout (e.g. "agents") so the + * each kind to declare its own layout (e.g. "assets/skill") so the * substrate does not hard-code a `/` path. */ directoryPrefix: string; @@ -301,16 +292,15 @@ export interface KindHandler { * * `changedPathPrefixes` is the set of repo-root-relative POSIX path * prefixes (each ending in `/`) under which this commit could have - * mutated tree entries -- for a `writeTree` carrying a `clearPrefix`, - * the cleared prefix plus the directory of each file written outside - * it; for a received pack, the entries whose mode or object differs - * from the parent commit. Every path the commit can have changed + * mutated tree entries -- for a `writeTreePreservingPrefix`, the + * replaced prefix; for a `writeTreeDelta`, the scope its caller + * declared; for a received pack, the entries whose mode or object + * differs from the parent commit. Every path the commit can have changed * relative to its parent is under one of these prefixes; any path * outside them is carried forward byte-identical by the substrate. It - * is `undefined` when the substrate cannot bound the change set (no - * parent to diff against and no `clearPrefix`, or a file written - * outside the `clearPrefix` whose directory cannot be named), in - * which case the handler must validate the whole prospective tree. A + * is `undefined` when the substrate cannot bound the change set (a + * plain `writeTree`, a delta declaring no scope, or a pack with no + * parent to diff against), in which case the handler must validate the whole prospective tree. A * handler with per-subtree invariants that cannot be affected by a * commit outside that subtree (workflow-run's per-run append-only * log) uses this to skip re-validating subtrees the commit provably @@ -444,8 +434,8 @@ export interface RepoStore { * commit's tip — there is no read-outside-the-lock window where one * caller could base its write on a stale view of the prefix. * - * The substrate handles `clearPrefix` and the commit internally; - * paths outside the prefix are untouched. + * The substrate clears the prefix and commits internally; paths + * outside the prefix are untouched. */ writeTreePreservingPrefix( principal: Principal, @@ -602,10 +592,9 @@ export interface RepoStore { * Gated under the same `resolveRef` action as `openCommittedReads`. * `commitSha` is validated at the boundary: a malformed SHA throws * `commit_sha_invalid`. Returns `null` when the repo does not yet exist - * or when `commitSha` names no commit in the object store (a commit a - * concurrent GC pruned between the caller learning of it and reading - * it), so a caller diffing a possibly-vanished commit gets an empty - * view rather than a mid-walk throw. + * or when `commitSha` names no commit in the object store, so a caller + * diffing a commit the repo does not hold gets an empty view rather than + * a mid-walk throw. */ openCommittedReadsAtCommit( principal: Principal, @@ -618,8 +607,7 @@ export interface RepoStore { * zero-indexed at the ref's root commit and counts ancestors * walking forward to HEAD, so the same commit always carries the * same `seq` across restarts. A history with a missing parent (a - * pack that carries only its tip, or history GC removed) replays no - * entries. Its live `seq` advances by the readable commits each + * pack that carries only its tip) replays no entries. Its live `seq` advances by the readable commits each * update adds while the ref has a subscriber, and starts again from * 0 at the oldest readable commit after a restart or after an update * with no subscriber. The emitted `event` is diff --git a/packages/hub-sessions/src/repo-store/user-principal-gate.ts b/packages/hub-sessions/src/repo-store/user-principal-gate.ts index 329f0650e..e7adddd09 100644 --- a/packages/hub-sessions/src/repo-store/user-principal-gate.ts +++ b/packages/hub-sessions/src/repo-store/user-principal-gate.ts @@ -1,6 +1,6 @@ // Shared authorization gate for the `user` principal variant, used by // every kind handler that accepts user-token-authenticated requests -// (workflow, skill, agent-state, workflow-run). +// (workflow, skill, workflow-run). // // The route layer has already pre-resolved the grant verdict and // attached it as `authz`; the kind handlers do NOT re-query the grant @@ -12,8 +12,7 @@ // Funnelling every kind through this one gate keeps the security- // critical claim/verdict cross-check from drifting between kinds. The // only per-kind input is the `resourcePrefix` the verdict's `resource` -// must carry (`asset:` for the codebase kinds, `agent-state:`, -// `workflow-run:`). +// must carry (`asset:` for the codebase kinds, `workflow-run:`). import { type } from "arktype"; import { glob, repoActionToGrantVerb } from "@intx/hub-common"; diff --git a/packages/hub-sessions/src/repo-store/write-tree-delta.test.ts b/packages/hub-sessions/src/repo-store/write-tree-delta.test.ts index cd26116a0..794396971 100644 --- a/packages/hub-sessions/src/repo-store/write-tree-delta.test.ts +++ b/packages/hub-sessions/src/repo-store/write-tree-delta.test.ts @@ -1,8 +1,8 @@ // OID-equivalence guard for the delta write path (writeTreeDelta) added // alongside the claim-check narrowing. A delta ({puts, deletes} carried // against the parent tree, reusing untouched entries by oid) MUST commit -// the byte-identical tree that a full-replace writeTree of the same -// logical final state produces. SHA-1 tree hashing is deterministic +// the byte-identical tree that a full-replace writeTreePreservingPrefix of +// the same logical final state produces. SHA-1 tree hashing is deterministic // given identical content and structure, so equal commit-tree oids prove // the delta path never diverges from canonical git across the move // shapes: enqueue-add, dequeue-move (delete+put), markConsumed-move, @@ -48,8 +48,8 @@ function makeStore(dataDir: string) { dataDir, signingKey, handlers: { - "agent-state": { - kind: "agent-state", + skill: { + kind: "skill", directoryPrefix: "repos-under-test", validatePush: () => ({ ok: true }), onRefUpdated: () => undefined, @@ -65,7 +65,7 @@ async function freshStore(label: string) { return makeStore(dataDir); } -const repoId: RepoId = { kind: "agent-state", id: "subject" }; +const repoId: RepoId = { kind: "skill", id: "subject" }; async function commitTreeOid( store: ReturnType, @@ -135,9 +135,9 @@ async function assertDeltaMatchesFullReplace( message: "seed", }); } - await fullStore.writeTree(principal, repoId, REF, { - files: finalScopeFiles, - clearPrefix: SCOPE, + await fullStore.writeTreePreservingPrefix(principal, repoId, REF, { + preservePrefix: SCOPE, + merge: () => Promise.resolve(finalScopeFiles), message: "full-replace", }); const fullTree = await commitTreeOid(fullStore); @@ -211,7 +211,7 @@ test("delta exact-file-delete matches full-replace", async () => { test("delta cascade-delete (empties dir and parent) matches full-replace", async () => { // The only entry under SCOPE is one inbox blob; deleting it empties // inbox/, then addresses/a/, then addresses/, so all three subtrees - // must vanish exactly as a full clearPrefix of an emptied scope does. + // must vanish exactly as a full replace of an emptied scope does. await assertDeltaMatchesFullReplace( "cascade", { [`${SCOPE}inbox/10-m1.json`]: `{"messageId":"m1"}` }, diff --git a/packages/hub-sessions/src/session-service.test.ts b/packages/hub-sessions/src/session-service.test.ts index 12346e654..d870c562e 100644 --- a/packages/hub-sessions/src/session-service.test.ts +++ b/packages/hub-sessions/src/session-service.test.ts @@ -1,11 +1,6 @@ import { describe, test, expect, beforeEach } from "bun:test"; import { type } from "arktype"; -import { promises as fs } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import * as tar from "tar"; import type { HarnessConfig } from "@intx/types/runtime"; -import { hexEncode } from "@intx/types"; import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; import { action, @@ -18,26 +13,17 @@ import { type AgentDeployFrame, WorkflowProjectionDefinition, } from "@intx/types/sidecar"; +import { sidecarAllocation } from "@intx/db/schema"; import type { ToolPackageManifest } from "@intx/types/tool-packages"; -import { - sessionAsset as sessionAssetTable, - sidecarAllocation, -} from "@intx/db/schema"; import type { CredentialDelivery } from "@intx/types/credential-delivery"; import type { DB, SidecarAllocation } from "@intx/db"; import { generateId } from "@intx/hub-common"; import { createApprovalSet, deriveRunAddress } from "@intx/workflow-deploy"; import { createNoopCredentialCipher } from "@intx/crypto"; import type { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; -import type { AgentRepoStore, DeployContent } from "./agent-repo"; -import type { AssetService } from "./asset-service"; +import type { AgentRepoStore } from "./agent-repo"; import type { RunCredentialsLoader } from "./run-credentials"; -import type { - LocalRepoStore, - Principal, - RepoId, - RepoStore, -} from "./repo-store"; +import type { LocalRepoStore } from "./repo-store"; import { createSessionService, recoverSenderDeploy, @@ -53,7 +39,6 @@ import { tick, } from "./ws/sidecar-handler.test-helpers"; import type { - SendPackOptions, SidecarAllocationRouter, SidecarRouter, } from "./ws/sidecar-handler"; @@ -95,19 +80,9 @@ function snapshotForProjection(projection: WorkflowProjectionDefinition) { type TestSidecarRouter = SidecarRouter & { sendAgentDeploy( agentAddress: string, - config: HarnessConfig, - workflow?: AgentDeployFrame["workflow"], + config: AgentDeployFrame["config"], + workflow: AgentDeployFrame["workflow"], ): Promise<{ publicKey: string }>; - sendPack( - agentAddress: string, - pack: Uint8Array, - ref: string, - commitSha: string, - options?: SendPackOptions, - ): Promise; - sendProvisionStep(agentAddress: string, config: HarnessConfig): Promise; - bindStepRoute(stepAddress: string): void; - unbindStepRoute(stepAddress: string): void; }; function createMockRouter(): TestSidecarRouter & { @@ -156,7 +131,7 @@ function createMockRouter(): TestSidecarRouter & { sendAgentDeploy: (( agentAddress: string, config: HarnessConfig, - workflow?: Parameters[2], + workflow: Parameters[2], ) => { calls.push({ method: "sendAgentDeploy", @@ -171,28 +146,6 @@ function createMockRouter(): TestSidecarRouter & { "sendCredentialsUpdate", ) as SidecarRouter["sendCredentialsUpdate"], sendRunGrants: async () => false, - sendPack: (( - agentAddress: string, - pack: Uint8Array, - ref: string, - commitSha: string, - options?: SendPackOptions, - ) => { - calls.push({ - method: "sendPack", - args: [agentAddress, pack, ref, commitSha, options], - }); - return Promise.resolve(); - }) as TestSidecarRouter["sendPack"], - sendProvisionStep: track( - "sendProvisionStep", - ) as TestSidecarRouter["sendProvisionStep"], - bindStepRoute(stepAddress: string) { - calls.push({ method: "bindStepRoute", args: [stepAddress] }); - }, - unbindStepRoute(stepAddress: string) { - calls.push({ method: "unbindStepRoute", args: [stepAddress] }); - }, sendSignalDeliver: async ( opts: Parameters[0], ) => { @@ -262,21 +215,9 @@ function createMockAllocationRouter( } return { publicKey: "allocated-public-key" }; }, - sendPackToAllocation: async (...args) => { - calls.push({ method: "sendPackToAllocation", args }); - }, sendWorkflowRunPackToAllocation: async (...args) => { calls.push({ method: "sendWorkflowRunPackToAllocation", args }); }, - bindAllocatedStepRoute: async (...args) => { - calls.push({ method: "bindAllocatedStepRoute", args }); - }, - unbindAllocatedStepRoute(...args) { - calls.push({ method: "unbindAllocatedStepRoute", args }); - }, - sendProvisionStepToAllocation: async (...args) => { - calls.push({ method: "sendProvisionStepToAllocation", args }); - }, sendWorkflowRunDispatchToAllocation: async (...args) => { calls.push({ method: "sendWorkflowRunDispatchToAllocation", args }); }, @@ -286,36 +227,11 @@ function createMockAllocationRouter( }; } -function createMockRepoStore(): AgentRepoStore & { calls: Call[] } { - const calls: Call[] = []; +function createMockRepoStore(): AgentRepoStore { return { - calls, - async writeDeployTree(agentId: string, content: DeployContent) { - calls.push({ method: "writeDeployTree", args: [agentId, content] }); - return { commitSha: "abc123" + "0".repeat(34) }; - }, - async createDeployPack(agentId: string) { - calls.push({ method: "createDeployPack", args: [agentId] }); - return { - pack: new Uint8Array([1, 2, 3]), - commitSha: "abc123" + "0".repeat(34), - ref: "refs/heads/deploy", - }; - }, - async receiveAgentStatePack( - repoId: { kind: "agent-state"; id: string }, - _pack: Uint8Array, - _ref: string, - _commitSha: string, - ) { - calls.push({ method: "receiveAgentStatePack", args: [repoId.id] }); - }, async prepareWorkflowRunPack() { throw new Error("mock: prepareWorkflowRunPack not implemented"); }, - getSigningPublicKey() { - return new Uint8Array(32); - }, repoStore: unusedRepoStore(), }; } @@ -356,95 +272,7 @@ function unusedRepoStore(): LocalRepoStore { }; } -type FakeAssetPackEntry = { - pack: Uint8Array; - commitSha: string; - ref: string; -}; - -function createFakeRepoStore( - packsByAssetId: Map, -): RepoStore & { - resolveRefCalls: { repoId: RepoId; ref: string }[]; - createPackCalls: { repoId: RepoId; ref: string }[]; -} { - const resolveRefCalls: { repoId: RepoId; ref: string }[] = []; - const createPackCalls: { repoId: RepoId; ref: string }[] = []; - const unused = () => - Promise.reject(new Error("repoStore method not wired in fake")); - return { - initRepo: unused, - writeTree: unused, - writeTreePreservingPrefix: unused, - writeTreeDelta: unused, - receivePack: unused, - commitPackedTip: () => { - throw new Error("repoStore method not wired in fake"); - }, - forgetPackedTip: () => { - throw new Error("repoStore method not wired in fake"); - }, - listRefs: unused, - resolveHead: unused, - getRepoDir: () => { - throw new Error("repoStore method not wired in fake"); - }, - openCommittedReads: unused, - openCommittedReadsAtCommit: unused, - subscribe: () => { - throw new Error("repoStore method not wired in fake"); - }, - async resolveRef(_principal: Principal, repoId: RepoId, ref: string) { - resolveRefCalls.push({ repoId, ref }); - const entry = packsByAssetId.get(repoId.id); - if (entry === undefined) return null; - return entry.commitSha; - }, - async createPack(_principal: Principal, repoId: RepoId, ref: string) { - createPackCalls.push({ repoId, ref }); - const entry = packsByAssetId.get(repoId.id); - if (entry === undefined) { - throw new Error(`no fake pack registered for ${repoId.id}`); - } - return { - pack: entry.pack, - commitSha: entry.commitSha, - ref: entry.ref, - }; - }, - resolveRefCalls, - createPackCalls, - }; -} - -type CapturedSessionAssetRow = { - runId: string; - mountPath: string; - assetPackSha: string; - sourceCommitSha: string; -}; - const AGENT_ADDRESS = "agent-1@test.local"; -const AGENT_ID = "agent-1"; -const INSTANCE_ID = "instance-1"; - -const MOCK_CONFIG: HarnessConfig = { - sessionId: "ses-1", - agentId: AGENT_ID, - tenantId: "tenant-1", - principalId: "prin-1", - agentAddress: AGENT_ADDRESS, - systemPrompt: "Test", - tools: [], - grants: [], - sources: [], - defaultSource: "", -}; - -const MOCK_CONTENT: DeployContent = { - systemPrompt: "Test", -}; - describe("SessionService", () => { let router: ReturnType; let repoStore: ReturnType; @@ -454,125 +282,6 @@ describe("SessionService", () => { repoStore = createMockRepoStore(); }); - test("stageWorkflowStep keeps every phase on its allocated worker", async () => { - const allocationRouter = createMockAllocationRouter(); - const service = createSessionService({ - loadRunCredentials: async () => null, - sidecarRouter: router, - sidecarAllocationRouter: allocationRouter, - agentRepoStore: repoStore, - }); - const target = { allocationId: "alloc-1", generation: 2 }; - - await service.stageWorkflowStep({ - agentAddress: AGENT_ADDRESS, - agentId: AGENT_ID, - runId: INSTANCE_ID, - config: MOCK_CONFIG, - deployContent: MOCK_CONTENT, - allocationTarget: target, - }); - - expect(allocationRouter.calls.map((call) => call.method)).toEqual([ - "bindAllocatedStepRoute", - "sendProvisionStepToAllocation", - "sendPackToAllocation", - "unbindAllocatedStepRoute", - ]); - expect(router.calls).toEqual([]); - }); - - test("stageWorkflowStep unbinds the route even when the pack fails", async () => { - const allocationRouter = createMockAllocationRouter(); - allocationRouter.sendPackToAllocation = () => - Promise.reject(new Error("pack failed")); - - const service = createSessionService({ - loadRunCredentials: async () => null, - sidecarRouter: router, - sidecarAllocationRouter: allocationRouter, - agentRepoStore: repoStore, - }); - - await service - .stageWorkflowStep({ - agentAddress: AGENT_ADDRESS, - agentId: AGENT_ID, - runId: INSTANCE_ID, - config: MOCK_CONFIG, - deployContent: MOCK_CONTENT, - allocationTarget: { allocationId: "alloc-1", generation: 1 }, - }) - .catch((e: unknown) => e); - - const routerMethods = allocationRouter.calls.map((c) => c.method); - // The transient route is dropped in the `finally`, even on failure, so no - // stale per-step route leaks. A stage-only step has no warm harness to - // tear down, so it never undeploys. - expect(routerMethods).toContain("unbindAllocatedStepRoute"); - }); - - test("launchSession does not provision on write failure", async () => { - repoStore.writeDeployTree = () => Promise.reject(new Error("write failed")); - - const allocationRouter = createMockAllocationRouter(); - const service = createSessionService({ - loadRunCredentials: async () => null, - sidecarRouter: router, - sidecarAllocationRouter: allocationRouter, - agentRepoStore: repoStore, - }); - - const err = await service - .stageWorkflowStep({ - agentAddress: AGENT_ADDRESS, - agentId: AGENT_ID, - runId: INSTANCE_ID, - config: MOCK_CONFIG, - deployContent: MOCK_CONTENT, - allocationTarget: { allocationId: "alloc-1", generation: 1 }, - }) - .catch((e: unknown) => e); - - expect(err).toBeInstanceOf(SessionLaunchError); - if (!(err instanceof SessionLaunchError)) throw new Error("unreachable"); - expect(err.phase).toBe("write"); - expect(err.leakedAgent).toBe(false); - expect(allocationRouter.calls.length).toBe(0); - }); - - test("launchSession does not send pack on provision failure", async () => { - const allocationRouter = createMockAllocationRouter(); - allocationRouter.sendProvisionStepToAllocation = () => - Promise.reject(new Error("provision failed")); - - const service = createSessionService({ - loadRunCredentials: async () => null, - sidecarRouter: router, - sidecarAllocationRouter: allocationRouter, - agentRepoStore: repoStore, - }); - - const err = await service - .stageWorkflowStep({ - agentAddress: AGENT_ADDRESS, - agentId: AGENT_ID, - runId: INSTANCE_ID, - config: MOCK_CONFIG, - deployContent: MOCK_CONTENT, - allocationTarget: { allocationId: "alloc-1", generation: 1 }, - }) - .catch((e: unknown) => e); - - expect(err).toBeInstanceOf(SessionLaunchError); - if (!(err instanceof SessionLaunchError)) throw new Error("unreachable"); - expect(err.phase).toBe("provision"); - expect(err.leakedAgent).toBe(false); - - const routerMethods = allocationRouter.calls.map((c) => c.method); - expect(routerMethods).not.toContain("sendPackToAllocation"); - }); - test("endSession awaits undeploy ack", async () => { const service = createSessionService({ loadRunCredentials: async () => null, @@ -588,589 +297,6 @@ describe("SessionService", () => { expect(call.method).toBe("sendAgentUndeploy"); expect(call.args).toEqual([AGENT_ADDRESS, "test_end"]); }); - - // --------------------------------------------------------------------- - // Attachment fan-out - // --------------------------------------------------------------------- - - async function createAllocatedAssetFixture() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "ss-allocated-asset-")); - const packageDir = path.join(dir, "package"); - await fs.mkdir(packageDir, { recursive: true }); - await fs.writeFile( - path.join(packageDir, "package.json"), - JSON.stringify({ name: "tools-allocated", version: "1.0.0" }), - ); - const tarballPath = path.join(dir, "tools-allocated-1.0.0.tgz"); - await tar.create({ cwd: dir, gzip: true, file: tarballPath }, ["package"]); - const tarballBytes = await fs.readFile(tarballPath); - - const assetId = "ast_allocated_registry"; - const assetName = "allocated-registry"; - const assetService: AssetService = { - createAsset: () => { - throw new Error("not used"); - }, - populateAsset: () => { - throw new Error("not used"); - }, - readAssetBlob: async ({ assetId: requestedAssetId, path: blobPath }) => { - if (requestedAssetId !== assetId) { - throw new Error(`unexpected assetId: ${requestedAssetId}`); - } - if (blobPath !== "tarballs/tools-allocated-1.0.0.tgz") { - throw new Error(`unexpected blob path: ${blobPath}`); - } - return tarballBytes; - }, - listAssetBlobs: async ({ assetId: requestedAssetId, dir: blobDir }) => { - if (requestedAssetId !== assetId) { - throw new Error(`unexpected assetId: ${requestedAssetId}`); - } - if (blobDir !== "tarballs") { - throw new Error(`unexpected list dir: ${blobDir}`); - } - return ["tools-allocated-1.0.0.tgz"]; - }, - }; - const assetRow = { - id: assetId, - tenantId: MOCK_CONFIG.tenantId, - kind: "package-registry" as const, - name: assetName, - displayName: null, - creatorPrincipalId: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - const state: { - row: CapturedSessionAssetRow | undefined; - insertAttempts: number; - deleteCalls: number; - } = { - row: undefined, - insertAttempts: 0, - deleteCalls: 0, - }; - const fakeDb = { - query: { - tenant: { - findFirst: async (_args: unknown) => ({ parentId: null }), - }, - asset: { - findMany: async (_args: unknown) => [assetRow], - }, - sessionAsset: { - findFirst: async (_args: unknown) => state.row, - }, - }, - insert(table: unknown) { - if (table !== sessionAssetTable) { - throw new Error("unexpected insert table"); - } - return { - values(row: CapturedSessionAssetRow) { - return { - onConflictDoNothing() { - return { - returning() { - state.insertAttempts += 1; - if (state.row !== undefined) return Promise.resolve([]); - state.row = row; - return Promise.resolve([{ runId: row.runId }]); - }, - }; - }, - }; - }, - }; - }, - delete(table: unknown) { - if (table !== sessionAssetTable) { - throw new Error("unexpected delete table"); - } - return { - where(_predicate: unknown) { - state.deleteCalls += 1; - state.row = undefined; - return Promise.resolve(); - }, - }; - }, - }; - const packsByAssetId = new Map([ - [ - assetId, - { - pack: new Uint8Array([42, 43, 44]), - commitSha: "e".repeat(40), - ref: "refs/heads/main", - }, - ], - ]); - const fakeRepoStore = createFakeRepoStore(packsByAssetId); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- replace the empty unusedRepoStore with the resolving fake for this fixture - (repoStore as unknown as { repoStore: RepoStore }).repoStore = - fakeRepoStore; - const allocationRouter = createMockAllocationRouter(); - const service = createSessionService({ - loadRunCredentials: async () => null, - sidecarRouter: router, - sidecarAllocationRouter: allocationRouter, - agentRepoStore: repoStore, - assetService, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- DB stub satisfies the narrow allocated-asset surface exercised here - db: fakeDb as unknown as NonNullable< - Parameters[0]["db"] - >, - toolPackageRegistries: { - httpRegistries: new Map(), - defaultRegistry: assetName, - }, - }); - const allocationTarget = { allocationId: "alloc-asset", generation: 2 }; - const launch = () => - service.stageWorkflowStep({ - agentAddress: AGENT_ADDRESS, - agentId: AGENT_ID, - runId: INSTANCE_ID, - config: MOCK_CONFIG, - deployContent: MOCK_CONTENT, - toolPackagePins: [{ name: "tools-allocated", version: "1.0.0" }], - allocationTarget, - }); - const assetPackCallCount = () => - allocationRouter.calls.filter( - (call) => - call.method === "sendPackToAllocation" && call.args[5] !== undefined, - ).length; - const failAssetPacks = () => { - const sendPack = - allocationRouter.sendPackToAllocation.bind(allocationRouter); - allocationRouter.sendPackToAllocation = async ( - target, - agentAddress, - pack, - ref, - commitSha, - options, - ) => { - if (options !== undefined) throw new Error("replacement pack failed"); - await sendPack(target, agentAddress, pack, ref, commitSha, options); - }; - return () => { - allocationRouter.sendPackToAllocation = sendPack; - }; - }; - - return { - allocationRouter, - assetId, - assetPackCallCount, - failAssetPacks, - launch, - packsByAssetId, - state, - }; - } - - test("allocated asset restoration reuses its materialization record", async () => { - const fixture = await createAllocatedAssetFixture(); - - await fixture.launch(); - const originalRow = fixture.state.row; - expect(originalRow).toBeDefined(); - - await fixture.launch(); - expect(fixture.state.row).toEqual(originalRow); - expect(fixture.state.insertAttempts).toBe(2); - expect(fixture.state.deleteCalls).toBe(0); - expect(fixture.assetPackCallCount()).toBe(2); - - fixture.failAssetPacks(); - - const err = await fixture.launch().catch((error: unknown) => error); - expect(err).toBeInstanceOf(SessionLaunchError); - expect(fixture.state.row).toEqual(originalRow); - expect(fixture.state.deleteCalls).toBe(0); - }); - - test("allocated asset restoration rejects a conflicting record", async () => { - const fixture = await createAllocatedAssetFixture(); - await fixture.launch(); - const originalRow = fixture.state.row; - expect(originalRow).toBeDefined(); - - fixture.packsByAssetId.set(fixture.assetId, { - pack: new Uint8Array([45, 46, 47]), - commitSha: "f".repeat(40), - ref: "refs/heads/main", - }); - - const err = await fixture.launch().catch((error: unknown) => error); - expect(err).toBeInstanceOf(SessionLaunchError); - if (!(err instanceof SessionLaunchError)) throw new Error("unreachable"); - expect(err.message).toContain("conflicts with the allocated workflow"); - expect(fixture.state.row).toEqual(originalRow); - expect(fixture.state.deleteCalls).toBe(0); - expect(fixture.assetPackCallCount()).toBe(1); - }); - - test("allocated asset failure preserves a newly created recovery record", async () => { - const fixture = await createAllocatedAssetFixture(); - const restoreAssetPacks = fixture.failAssetPacks(); - - const err = await fixture.launch().catch((error: unknown) => error); - expect(err).toBeInstanceOf(SessionLaunchError); - const recoveryRow = fixture.state.row; - expect(recoveryRow).toBeDefined(); - expect(fixture.state.deleteCalls).toBe(0); - - restoreAssetPacks(); - await fixture.launch(); - expect(fixture.state.row).toEqual(recoveryRow); - expect(fixture.state.insertAttempts).toBe(2); - expect(fixture.state.deleteCalls).toBe(0); - }); - - test("launchSession writes a resolved-source session_asset row for resolver-derived packs", async () => { - // Build a single-tarball asset registry, fake the DB query path - // the session service walks (`listAssetsForTenant` walks - // `tenant.findFirst` + `asset.findMany`), and assert the fan-out - // materializes a session_asset row for the resolver-derived pack at - // the expected mount path and source commit. - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "ss-resolved-")); - const stagingDir = path.join(dir, "tools-resolved-1.0.0"); - const pkgDir = path.join(stagingDir, "package"); - await fs.mkdir(pkgDir, { recursive: true }); - await fs.writeFile( - path.join(pkgDir, "package.json"), - JSON.stringify({ name: "tools-resolved", version: "1.0.0" }), - ); - const tarballPath = path.join(stagingDir, "out.tgz"); - await tar.create({ cwd: stagingDir, gzip: true, file: tarballPath }, [ - "package", - ]); - const tarballBytes = await fs.readFile(tarballPath); - const byPath = new Map([ - ["tarballs/tools-resolved-1.0.0.tgz", tarballBytes], - ]); - - const RESOLVED_ASSET_ID = "ast_workspace_builtins"; - const RESOLVED_ASSET_NAME = "workspace-builtins"; - const TENANT_ID = "tenant-1"; - - const assetService: AssetService = { - createAsset: () => { - throw new Error("not used"); - }, - populateAsset: () => { - throw new Error("not used"); - }, - readAssetBlob: async ({ assetId, path: p }) => { - if (assetId !== RESOLVED_ASSET_ID) { - throw new Error(`unexpected assetId: ${assetId}`); - } - const b = byPath.get(p); - if (b === undefined) throw new Error(`no blob at ${p}`); - return b; - }, - listAssetBlobs: async ({ assetId, dir: d }) => { - if (assetId !== RESOLVED_ASSET_ID) { - throw new Error(`unexpected assetId: ${assetId}`); - } - if (d !== "tarballs") { - throw new Error(`unexpected list dir: ${d}`); - } - return Array.from(byPath.keys()).map((p) => - p.slice("tarballs/".length), - ); - }, - }; - - const assetRow = { - id: RESOLVED_ASSET_ID, - tenantId: TENANT_ID, - kind: "package-registry" as const, - name: RESOLVED_ASSET_NAME, - displayName: null, - creatorPrincipalId: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const captured: CapturedSessionAssetRow[] = []; - const fakeDb = { - query: { - tenant: { - findFirst: async (_args: unknown) => - ({ parentId: null }) as { parentId: string | null }, - }, - asset: { - findMany: async (_args: unknown) => [assetRow], - }, - }, - insert(_table: unknown) { - return { - values(row: CapturedSessionAssetRow) { - captured.push(row); - return { - onConflictDoNothing: () => ({ - returning: () => Promise.resolve([{ runId: row.runId }]), - }), - }; - }, - }; - }, - delete(_table: unknown) { - return { - where(_predicate: unknown) { - return Promise.resolve(); - }, - }; - }, - }; - - const packsByAssetId = new Map([ - [ - RESOLVED_ASSET_ID, - { - pack: new Uint8Array([42, 43, 44]), - commitSha: "e".repeat(40), - ref: "refs/heads/main", - }, - ], - ]); - const fakeRepoStore = createFakeRepoStore(packsByAssetId); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- replace the empty unusedRepoStore with the resolving fake for this test - (repoStore as unknown as { repoStore: RepoStore }).repoStore = - fakeRepoStore; - - const allocationRouter = createMockAllocationRouter(); - const service = createSessionService({ - loadRunCredentials: async () => null, - sidecarRouter: router, - sidecarAllocationRouter: allocationRouter, - agentRepoStore: repoStore, - assetService, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- DB stub satisfies the narrow surface session-service actually calls (query.tenant.findFirst, query.asset.findMany, insert/delete) - db: fakeDb as unknown as NonNullable< - Parameters[0]["db"] - >, - toolPackageRegistries: { - httpRegistries: new Map(), - defaultRegistry: RESOLVED_ASSET_NAME, - }, - }); - - await service.stageWorkflowStep({ - agentAddress: AGENT_ADDRESS, - agentId: AGENT_ID, - runId: INSTANCE_ID, - config: MOCK_CONFIG, - deployContent: MOCK_CONTENT, - toolPackagePins: [{ name: "tools-resolved", version: "1.0.0" }], - allocationTarget: { allocationId: "alloc-1", generation: 1 }, - }); - - expect(captured).toHaveLength(1); - const row = captured[0]; - if (row === undefined) throw new Error("unreachable"); - expect(row.mountPath).toBe(`package-registries/${RESOLVED_ASSET_NAME}/`); - expect(row.sourceCommitSha).toBe("e".repeat(40)); - expect(row.runId).toBe(INSTANCE_ID); - expect(row.assetPackSha).toBe( - hexEncode( - new Uint8Array( - await crypto.subtle.digest("SHA-256", new Uint8Array([42, 43, 44])), - ), - ), - ); - }); - - test("allocated launch preserves recovery rows on a later fan-out failure", async () => { - // Two resolver-derived package-registry attachments, one per - // registry (routed by scope), so the fan-out has two items. The - // first attachment pack sends cleanly; the second fails. The - // second's own catch rolls back its row, and the outer sweep must - // additionally remove the first attachment's already-committed row. - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "ss-rollback-")); - const TENANT_ID = "tenant-1"; - - // The asset registry keys packuments by each tarball's package.json - // name, so the tarball filename is irrelevant — only the embedded - // name/version matters. - async function buildTarball(pkgName: string): Promise { - const stagingDir = path.join(dir, pkgName.replace(/[@/]/g, "_")); - const pkgDir = path.join(stagingDir, "package"); - await fs.mkdir(pkgDir, { recursive: true }); - await fs.writeFile( - path.join(pkgDir, "package.json"), - JSON.stringify({ name: pkgName, version: "1.0.0" }), - ); - const tarballPath = path.join(stagingDir, "out.tgz"); - await tar.create({ cwd: stagingDir, gzip: true, file: tarballPath }, [ - "package", - ]); - return fs.readFile(tarballPath); - } - - const registries = [ - { - assetId: "ast_reg_a", - name: "reg-a", - scope: "@rega", - pkg: "@rega/tools", - pack: new Uint8Array([1, 2, 3]), - }, - { - assetId: "ast_reg_b", - name: "reg-b", - scope: "@regb", - pkg: "@regb/tools", - pack: new Uint8Array([4, 5, 6]), - }, - ]; - const tarballByAsset = new Map(); - for (const r of registries) { - tarballByAsset.set(r.assetId, await buildTarball(r.pkg)); - } - - const assetService: AssetService = { - createAsset: () => { - throw new Error("not used"); - }, - populateAsset: () => { - throw new Error("not used"); - }, - readAssetBlob: async ({ assetId, path: p }) => { - const t = tarballByAsset.get(assetId); - if (t === undefined) throw new Error(`unexpected assetId: ${assetId}`); - if (!p.startsWith("tarballs/")) { - throw new Error(`unexpected blob path: ${p}`); - } - return t; - }, - listAssetBlobs: async ({ assetId, dir: d }) => { - if (!tarballByAsset.has(assetId)) { - throw new Error(`unexpected assetId: ${assetId}`); - } - if (d !== "tarballs") throw new Error(`unexpected list dir: ${d}`); - return ["pkg-1.0.0.tgz"]; - }, - }; - - const assetRows = registries.map((r) => ({ - id: r.assetId, - tenantId: TENANT_ID, - kind: "package-registry" as const, - name: r.name, - displayName: null, - creatorPrincipalId: null, - createdAt: new Date(), - updatedAt: new Date(), - })); - - const captured: CapturedSessionAssetRow[] = []; - let deleteCalls = 0; - const fakeDb = { - query: { - tenant: { - findFirst: async (_args: unknown) => - ({ parentId: null }) as { parentId: string | null }, - }, - asset: { - findMany: async (_args: unknown) => assetRows, - }, - }, - insert(_table: unknown) { - return { - values(row: CapturedSessionAssetRow) { - captured.push(row); - return { - onConflictDoNothing: () => ({ - returning: () => Promise.resolve([{ runId: row.runId }]), - }), - }; - }, - }; - }, - delete(_table: unknown) { - return { - where(_predicate: unknown) { - deleteCalls += 1; - return Promise.resolve(); - }, - }; - }, - }; - - const packsByAssetId = new Map( - registries.map((r) => [ - r.assetId, - { pack: r.pack, commitSha: "e".repeat(40), ref: "refs/heads/main" }, - ]), - ); - const fakeRepoStore = createFakeRepoStore(packsByAssetId); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- replace the empty unusedRepoStore with the resolving fake for this test - (repoStore as unknown as { repoStore: RepoStore }).repoStore = - fakeRepoStore; - - let attachmentPackCalls = 0; - const allocationRouter = createMockAllocationRouter(); - allocationRouter.sendPackToAllocation = async (...args) => { - const options = args[5]; - if (options !== undefined) { - attachmentPackCalls += 1; - if (attachmentPackCalls === 2) { - throw new Error("induced fan-out failure"); - } - } - }; - - const service = createSessionService({ - loadRunCredentials: async () => null, - sidecarRouter: router, - sidecarAllocationRouter: allocationRouter, - agentRepoStore: repoStore, - assetService, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- DB stub satisfies the narrow surface session-service actually calls - db: fakeDb as unknown as NonNullable< - Parameters[0]["db"] - >, - toolPackageRegistries: { - httpRegistries: new Map(), - defaultRegistry: "reg-a", - scopeRouting: registries.map((r) => ({ - scope: r.scope, - registry: r.name, - })), - }, - }); - - let err: unknown; - try { - await service.stageWorkflowStep({ - agentAddress: AGENT_ADDRESS, - agentId: AGENT_ID, - runId: INSTANCE_ID, - config: MOCK_CONFIG, - deployContent: MOCK_CONTENT, - toolPackagePins: registries.map((r) => ({ - name: r.pkg, - version: "1.0.0", - })), - allocationTarget: { allocationId: "alloc-1", generation: 1 }, - }); - } catch (e) { - err = e; - } - expect(err).toBeInstanceOf(SessionLaunchError); - // Allocated rows are durable recovery intent, so a later generation can - // retry the exact same materialization after this failed send. - expect(captured.length).toBeGreaterThanOrEqual(2); - expect(deleteCalls).toBe(0); - }); }); describe("sendMultiStepDeployFrame", () => { @@ -1180,8 +306,8 @@ describe("sendMultiStepDeployFrame", () => { []; mockRouter.sendAgentDeploy = (( _agentAddress: string, - _config: HarnessConfig, - workflow?: Parameters[2], + _config: AgentDeployFrame["config"], + workflow: Parameters[2], ) => { sentWorkflows.push(workflow); return Promise.resolve({ publicKey: "ed25519-supervisor-pubkey" }); @@ -1215,13 +341,7 @@ describe("sendMultiStepDeployFrame", () => { }; const config: HarnessConfig = { sessionId: "ses-sourced", - agentId: "ins_dep_src", - tenantId: "tenant-1", - principalId: "prin-src", agentAddress: "ins_dep_src@workflow.interchange", - systemPrompt: "deployment-level", - tools: [], - grants: [], sources: Object.values(sources).flat(), defaultSource: "src-only", }; @@ -1345,13 +465,7 @@ describe("deployCodeSourcedWorkflow", () => { }); const CONFIG: HarnessConfig = { sessionId: "ses-composed", - agentId: "ins_dep_composed", - tenantId: "tenant-1", - principalId: "prin-composed", agentAddress: DEPLOY_ADDRESS, - systemPrompt: "deployment-level", - tools: [], - grants: [], sources: Object.values(SOURCES).flat(), defaultSource: "src-only", }; @@ -2618,7 +1732,6 @@ describe("deployCodeSourcedWorkflow", () => { if (!(error instanceof SessionLaunchError)) { throw new Error("expected SessionLaunchError"); } - expect(error.phase).toBe("start"); expect(error.leakedAgent).toBe(frameSent); expect(error.cause).toBe(failure); }); @@ -2692,8 +1805,8 @@ describe("deployCodeSourcedWorkflow", () => { []; mockRouter.sendAgentDeploy = (( _agentAddress: string, - _config: HarnessConfig, - workflow?: Parameters[2], + _config: AgentDeployFrame["config"], + workflow: Parameters[2], ) => { sentWorkflows.push(workflow); return Promise.resolve({ publicKey: "ed25519-supervisor-pubkey" }); @@ -2893,8 +2006,8 @@ describe("deployCodeSourcedWorkflow", () => { []; mockRouter.sendAgentDeploy = (( _agentAddress: string, - _config: HarnessConfig, - workflow?: Parameters[2], + _config: AgentDeployFrame["config"], + workflow: Parameters[2], ) => { sentWorkflows.push(workflow); return Promise.resolve({ publicKey: "ed25519-supervisor-pubkey" }); @@ -3071,8 +2184,8 @@ describe("deployCodeSourcedWorkflow", () => { []; mockRouter.sendAgentDeploy = (( _agentAddress: string, - _config: HarnessConfig, - workflow?: Parameters[2], + _config: AgentDeployFrame["config"], + workflow: Parameters[2], ) => { sentWorkflows.push(workflow); return Promise.resolve({ publicKey: "ed25519-supervisor-pubkey" }); @@ -3399,8 +2512,8 @@ describe("deployCodeSourcedWorkflow", () => { []; mockRouter.sendAgentDeploy = (( _agentAddress: string, - _config: HarnessConfig, - workflow?: Parameters[2], + _config: AgentDeployFrame["config"], + workflow: Parameters[2], ) => { sentWorkflows.push(workflow); return Promise.resolve({ publicKey: "ed25519-supervisor-pubkey" }); @@ -3450,11 +2563,6 @@ describe("deployCodeSourcedWorkflow", () => { // The body agent's declared (anthropic, mock-model) resolved to the // operator-approved config source, pinned as a single-element chain. expect(body.sources["work"]).toEqual(SOURCES.only); - // Per-body hash is recomputed from the inert body verbatim, so a body - // child's re-verify over the re-evaluated closure clears the same barrier. - expect(body.approvedWireHash).toBe( - await computeWireDefinitionHash(body.definition), - ); }); test("fails closed when a body step's inference source is not operator-approved", async () => { @@ -3501,8 +2609,8 @@ describe("deployCodeSourcedWorkflow", () => { []; mockRouter.sendAgentDeploy = (( _agentAddress: string, - _config: HarnessConfig, - workflow?: Parameters[2], + _config: AgentDeployFrame["config"], + workflow: Parameters[2], ) => { sentWorkflows.push(workflow); return Promise.resolve({ publicKey: "ed25519-supervisor-pubkey" }); diff --git a/packages/hub-sessions/src/session-service.ts b/packages/hub-sessions/src/session-service.ts index 68d2a69fa..7429a1299 100644 --- a/packages/hub-sessions/src/session-service.ts +++ b/packages/hub-sessions/src/session-service.ts @@ -1,4 +1,3 @@ -import { type } from "arktype"; import { and, eq, isNull } from "drizzle-orm"; import { getLogger } from "@intx/log"; @@ -6,7 +5,6 @@ import { buildCredentialDelivery, createSidecarAllocationStore, createWorkflowDefinitionArtifactStore, - listAssetsForTenant, resolveInferenceMaterials, type DB, type SidecarAllocation, @@ -16,28 +14,14 @@ import { workflowRun as workflowRunTable, type WorkflowRunCredentialRefs, } from "@intx/db/schema"; -import { hexEncode } from "@intx/types"; import type { CredentialDelivery, CredentialMaterialEntry, } from "@intx/types/credential-delivery"; import { DeploymentRejectedError } from "@intx/types/sidecar"; import type { CredentialCipher } from "@intx/types"; -import { sessionAsset as sessionAssetTable } from "@intx/db/schema"; import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; -import { - type RegistryConfig, - type RegistrySource, - type ScopeRoute, - AssetRegistrySource, - HttpRegistrySource, - ManifestInvalidError, - createClosureResolver, -} from "@intx/tool-packaging"; -import { - ToolPackageManifest, - type ToolPackagePin, -} from "@intx/types/tool-packages"; +import { type RegistryConfig, type ScopeRoute } from "@intx/tool-packaging"; import type { SourceRefPin, WorkflowProjectionWithSources, @@ -54,16 +38,11 @@ import { collectHubConsumers, deriveRunAddress, WorkflowDefinitionInvalidError, - type DeployContent as OrchestratorDeployContent, } from "@intx/workflow-deploy"; -import type { AgentRepoStore, DeployContent } from "./agent-repo"; +import type { AgentRepoStore } from "./agent-repo"; import { recheckDeliveredCredentials } from "./credential-push"; -import { - DEFAULT_ASSET_REF, - type Asset, - type AssetService, -} from "./asset-service"; +import { DEFAULT_ASSET_REF } from "./asset-service"; import type { AllocatedSidecarTarget, SendProbeArgs, @@ -72,7 +51,7 @@ import type { WorkflowProbeResult, } from "./ws/sidecar-handler"; import { isDeployFrameFailure } from "./ws/sidecar-handler"; -import type { Principal, RepoId, RepoKind } from "./repo-store"; +import type { Principal, RepoKind } from "./repo-store"; import { buildSourceAssetMounts, type ResolveAssetAttachmentFn, @@ -97,41 +76,19 @@ import type { SidecarReconciliationContext } from "./sidecar-allocation/operatio const logger = getLogger(["interchange", "hub", "session-service"]); export class SessionLaunchError extends Error { - /** Which phase failed: "write", "provision", "pack", or "start". */ - readonly phase: string; /** True if the sidecar has a provisioned agent that could not be cleaned up. */ readonly leakedAgent: boolean; - constructor(phase: string, cause: unknown, leakedAgent: boolean) { + constructor(cause: unknown, leakedAgent: boolean) { const msg = cause instanceof Error ? cause.message : "Session launch failed"; super(msg, { cause }); this.name = "SessionLaunchError"; - this.phase = phase; this.leakedAgent = leakedAgent; } } export type SessionService = { - /** - * Stage one step of a multi-step workflow deploy: bind a transient route - * for the step address, fire a no-spawn provision frame (init the step's - * agent-state repo and record the hub key), deliver the deploy + asset - * packs, and unbind the route -- no warm harness. The multi-step branch - * stages every step this way before firing the deployment-level workflow - * frame that spawns the supervised child; the child reads each staged step - * tree from disk and runs the step itself. - */ - stageWorkflowStep(params: { - agentAddress: string; - agentId: string; - runId: string; - config: HarnessConfig; - deployContent: DeployContent; - toolPackagePins?: readonly ToolPackagePin[]; - allocationTarget: AllocatedSidecarTarget; - }): Promise; - /** * Undeploy an agent and wait for the sidecar to acknowledge. */ @@ -235,40 +192,13 @@ export type SessionServiceDeps = { /** Present when this Hub can route deploy phases to provisioned allocations. */ sidecarAllocationRouter?: SidecarAllocationRouter; agentRepoStore: AgentRepoStore; - /** - * Optional asset attachment integration. When set, the deploy flow - * fans out per-attachment packs after the deploy pack lands and - * inserts a `session_asset` row per attachment. When unset, only - * the deploy pack is sent — the single-pack path is preserved - * bit-for-bit. - */ - assetService?: AssetService; - /** DB handle used for `session_asset` manifest inserts. Required - * iff `assetService` is set. */ + /** DB handle a workflow deploy reads and updates its run records with. */ db?: DB["db"]; /** - * Tool-package registry configuration. Required iff any agent the - * service launches has non-empty `toolPackagePins`. When set, the - * service builds a per-agent `ClosureResolver` at launch time: the - * registry map combines (a) every `package-registry` asset visible - * to the agent's tenant via the INTR-178 walker — keyed by - * `asset.name` — and (b) the statically-configured HTTP registries - * in `httpRegistries`. - * - * **Name-collision policy.** When an asset and an HTTP registry - * both claim the same registry name, the asset wins. This mirrors - * the inner-shadows-outer rule the tenancy walker already applies - * to asset resolution and gives operators a single mental model: - * closer-scope shadows wider-scope. The rule is a contract this - * service guarantees, not an iteration-order accident — consumers - * may rely on it to override a wider-scope HTTP registry by - * publishing an asset at a closer tenancy. - * - * `defaultRegistry` names the entry the resolver consults for any - * package whose scope does not match `scopeRouting`. The name must - * resolve in the combined map for the given agent — if no asset and - * no HTTP entry carries that name, launch fails at the - * registry-resolution step. + * The registries a source deploy resolves through. Required for a source + * deploy. `httpRegistries` maps each registry name to its config. A registry + * source names its registry, and a source-format asset's external + * dependencies use `defaultRegistry`. No deploy path reads `scopeRouting`. */ toolPackageRegistries?: { /** @@ -288,85 +218,11 @@ export type SessionServiceDeps = { }; // Hub-side principal for reading asset repos. Assets are signed by the -// hub itself, and the launch fan-out reads them on the hub to assemble -// packs for delivery to a sidecar -- so the hub principal is correct. +// hub itself, and a source deploy reads them on the hub to assemble the +// packs it delivers to a sidecar -- so the hub principal is correct. const HUB_PRINCIPAL: Principal = { kind: "hub" }; -type ResolvedAttachment = { - mountPath: string; - sourceCommitSha: string; - repoId: RepoId; - pack: Uint8Array; - ref: string; -}; - -type SessionAssetRecord = { - runId: string; - mountPath: string; - assetPackSha: string; - sourceCommitSha: string; -}; - -async function createPackSha(pack: Uint8Array): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ArrayBuffer-backed at the call site; Web Crypto's BufferSource type rejects Uint8Array under TS 5.9 (microsoft/TypeScript#62240) - pack as Uint8Array, - ); - return hexEncode(new Uint8Array(digest)); -} - -/** - * Walk a resolved tool-package manifest and return every distinct - * `assetId` referenced by a `kind: "asset"` entry. Order is the - * resolver's BFS order so the fan-out below is deterministic for - * tests; a `Set` would be wrong here because tests assert specific - * orderings. - */ -function collectDistinctAssetIds(manifest: ToolPackageManifest): string[] { - const seen = new Set(); - const out: string[] = []; - for (const entry of manifest.entries) { - if (entry.source.kind !== "asset") continue; - if (seen.has(entry.source.assetId)) continue; - seen.add(entry.source.assetId); - out.push(entry.source.assetId); - } - return out; -} - -/** - * Translate the orchestrator's structural `DeployContent` (which types - * `toolPackageManifest` as `unknown`) back into the hub-sessions - * `DeployContent` shape. The orchestrator round-trips whatever the - * caller supplied, but the surface type widens `toolPackageManifest` to - * `unknown`; the validator narrows it back to the canonical shape - * `agentRepoStore.writeDeployTree` consumes. - * - * Exported so a test fixture that forwards orchestrator-shaped deploy - * content into `launchSession` narrows it the same validated way the - * production multi-step callback does, rather than casting `unknown`. - */ -export function bridgeOrchestratorDeployContent( - content: OrchestratorDeployContent, -): DeployContent { - const bridged: DeployContent = { systemPrompt: content.systemPrompt }; - if (content.toolPackageManifest !== undefined) { - const validated = ToolPackageManifest(content.toolPackageManifest); - if (validated instanceof type.errors) { - throw new Error( - `orchestrator deploy content carries an invalid toolPackageManifest: ${validated.summary}`, - ); - } - bridged.toolPackageManifest = validated; - } - if (content.assetMounts !== undefined) { - bridged.assetMounts = content.assetMounts; - } - return bridged; -} - -/** Fields the deploy frame carries onto `sendAgentDeploy`. */ +/** Fields the deploy frame carries onto `sendAgentDeployToAllocation`. */ type DeployFrameCommonArgs = { sidecarAllocationRouter: SidecarAllocationRouter; allocationTarget: AllocatedSidecarTarget; @@ -430,11 +286,9 @@ export type SourceRefDeployFrameArgs = DeployFrameCommonArgs & { export type SendMultiStepDeployFrameArgs = SourceRefDeployFrameArgs; /** - * Emit the source-ref deploy frame onto `SidecarRouter.sendAgentDeploy`. The - * router accepts an optional `workflow` projection on the deploy frame; the - * sidecar's deploy router uses field presence to route the frame to the - * workflow deploy path, and returns the supervisor public key on the - * `agent.deploy.ack`. + * Emit the source-ref deploy frame through + * `SidecarAllocationRouter.sendAgentDeployToAllocation`. The sidecar's deploy + * router returns the supervisor public key on the `agent.deploy.ack`. * * The gate/freeze layer already hashed the inert projection, so the frozen hash * and the inert projection ride the frame verbatim -- this never recomputes the @@ -442,8 +296,8 @@ export type SendMultiStepDeployFrameArgs = SourceRefDeployFrameArgs; * inert projection the child re-verifies against. * * Exported so the co-located caller-site test can assert that the constructed - * closure reaches the wire surface via `sendAgentDeploy` with a `workflow` - * field structurally matching the `AgentDeployFrame.workflow` schema. + * closure reaches the wire surface with a `workflow` field structurally + * matching the `AgentDeployFrame.workflow` schema. */ export async function sendMultiStepDeployFrame( args: SendMultiStepDeployFrameArgs, @@ -562,9 +416,7 @@ function isAssetDeployArgs( * tenant-ownership-scoped), never a shared authz check. * Credential GRANT enforcement is a SEPARATE layer: the `credential:{id}` / * `use` grant the runtime gate checks is minted per-run by run-grant - * materialization into `runs//grants.json`, not carried on this frame -- - * the deploy-time `config.grants` spawn-time snapshot is suppressed once the - * sidecar wires per-run grant pushes, so it is not the enforcement transport. + * materialization into `runs//grants.json`, not carried on this frame. * * A gate outcome that did not approve cannot deploy: an unapproved `approval` * fails closed here rather than shipping an unfrozen definition. @@ -676,13 +528,11 @@ async function prepareSourceRefDeploy( // These entries ride the `referencedDefinitions` wire field. Each entry's // `definition` is the approved inert body def straight from the frozen, // hash-covered projection (id set to the ref); the sidecar reads that id to - // key the per-body approved hash and to stage the body's `sources.json`, which - // the body child reads to pin its steps. The body child resolves the body - // DEFINITION itself in-memory from the re-verified closure and hard-fails - // rather than reading it off disk, so no body workflow.json is staged (see the - // staging loop in workflow-host-wiring.ts and the anti-fallback guard in - // workflow-host run-child.ts). - const referencedDefinitions = await buildReferencedWorkflowSourcePins({ + // key the body's sources in the run record, which the body child reads to pin + // its steps. The body child resolves the body DEFINITION itself in-memory from + // the re-verified closure and hard-fails rather than reading it off disk (see + // the anti-fallback guard in workflow-host run-child.ts). + const referencedDefinitions = buildReferencedWorkflowSourcePins({ projection, config: args.config, operatorApprovals: approval.approvedSurface, @@ -980,7 +830,7 @@ async function emitSourceRefDeployFrame( logger.warn`Could not clear unsent initialization for ${initialization.allocationId}: ${error instanceof Error ? error.message : String(error)}`; } } - throw new SessionLaunchError("start", cause, cause.frameSent); + throw new SessionLaunchError(cause, cause.frameSent); } } @@ -1044,7 +894,7 @@ export async function deployCodeSourcedWorkflow( ...credentialRefsField(sendArgs.credentials, hubConsumers), }); } catch (cause) { - throw new SessionLaunchError("start", cause, false); + throw new SessionLaunchError(cause, false); } let publicKey: string; @@ -1072,9 +922,9 @@ export async function deployCodeSourcedWorkflow( .returning({ id: workflowRunTable.id }); if (deleted.length === 0) { logger.error`anchor-before-frame rollback found no deployed/null-key row for ${args.anchorRunId} after a frameSent:false failure; the never-sent contract was violated and a child may be live`; - throw new SessionLaunchError("start", cause, true); + throw new SessionLaunchError(cause, true); } - throw new SessionLaunchError("start", cause, false); + throw new SessionLaunchError(cause, false); } // Preserve a rejected deployment's anchor as a recorded failure. Otherwise // a sent-but-unacked frame or unexpected error leaves it possibly live. Fence the @@ -1106,7 +956,7 @@ export async function deployCodeSourcedWorkflow( } else { logger.warn`anchor-before-frame: fenced anchor ${args.anchorRunId} deployed->failed on an unacked/failed emit; the agent may be leaked but the run is dead`; } - throw new SessionLaunchError("start", cause, true); + throw new SessionLaunchError(cause, true); } // Emit succeeded: stamp the acked key. No status guard -- the key is a fact @@ -1170,22 +1020,10 @@ export function createSessionService( sidecarRouter, sidecarAllocationRouter, agentRepoStore, - assetService, db, toolPackageRegistries, } = deps; - if (assetService !== undefined && db === undefined) { - throw new Error( - "createSessionService: db is required when assetService is set", - ); - } - if (toolPackageRegistries !== undefined && db === undefined) { - throw new Error( - "createSessionService: db is required when toolPackageRegistries is set", - ); - } - function requireAllocationRouter(): SidecarAllocationRouter { if (sidecarAllocationRouter === undefined) { throw new Error("Provisioned deployment routing is not configured"); @@ -1193,264 +1031,6 @@ export function createSessionService( return sidecarAllocationRouter; } - /** - * Stage one per-step deploy on the sidecar: resolve assets and tool - * packages, write the deploy tree, provision the step, and deliver the - * deploy + asset packs (Phases 0-2b). Phase 1 binds a transient route for - * the step address, fires a no-spawn provision frame (init repo + record - * hub key), and unbinds the route once the packs land -- no warm harness and - * no child. The deployment-level workflow frame, sent once after every step - * is staged, spawns the child. A call without `stageOnly` is rejected -- the - * legacy warm-harness and single-step-head paths are gone. - */ - async function executeLaunchPhases(params: { - agentAddress: string; - agentId: string; - runId: string; - config: HarnessConfig; - deployContent: DeployContent; - toolPackagePins?: readonly ToolPackagePin[]; - /** - * Per-step stage. When true, Phase 1 binds a transient route for the step - * address, fires a no-spawn provision frame (the sidecar inits the step's - * agent-state repo and records the hub key), delivers the deploy + asset - * packs, and unbinds the route -- no warm harness and no child. The - * deployment-level workflow frame, sent once after every step is staged, - * spawns the child. - */ - stageOnly?: boolean; - allocationTarget: AllocatedSidecarTarget; - }): Promise { - const { agentAddress, agentId, runId, config, deployContent } = params; - const toolPackagePins = params.toolPackagePins ?? []; - const stageOnly = params.stageOnly ?? false; - - let effectiveDeployContent: DeployContent = deployContent; - - // Phase 0a-bis: Resolve the agent's tool-package pins into a full - // closure manifest. Empty pins skip the resolver entirely. A - // ManifestInvalidError (e.g. unsatisfied peer dependency) is a - // launch-time failure — the deploy never ships and the sidecar - // is not touched. - // - // The resolver runs once per launch with no cross-launch caching; - // the packument cache scopes only within a single closure walk. - // Acceptable at the current N (handful of agents, small pin sets - // per agent) — a tenant-scoped packument cache or a per-pin set - // resolved-manifest cache would be the obvious scaling lever - // when launch latency becomes the bottleneck. - const manifestAssetAttachments: ResolvedAttachment[] = []; - if (toolPackagePins.length > 0) { - if (toolPackageRegistries === undefined) { - throw new SessionLaunchError( - "write", - new Error( - `agent ${agentId} has ${String(toolPackagePins.length)} pinned tool package(s) but the session service has no toolPackageRegistries configured`, - ), - false, - ); - } - if (assetService === undefined) { - throw new SessionLaunchError( - "write", - new Error( - `agent ${agentId} has pinned tool packages but the session service has no assetService configured for asset-backed registries`, - ), - false, - ); - } - let manifest: ToolPackageManifest; - let assetIndex: Map; - try { - const built = await buildAndResolve({ - agentId, - tenantId: config.tenantId, - pins: toolPackagePins, - registries: toolPackageRegistries, - assetService, - }); - manifest = built.manifest; - assetIndex = built.assetIndex; - } catch (err) { - if (err instanceof ManifestInvalidError) { - logger.warn`tool-package manifest validation failed for agent ${agentId}: ${err.message}`; - } - throw new SessionLaunchError("write", err, false); - } - - const assetMounts = new Map(); - try { - for (const assetId of collectDistinctAssetIds(manifest)) { - const asset = assetIndex.get(assetId); - if (asset === undefined) { - // The asset id appears in the manifest but is not in the - // tenant-visible asset set. This can only happen if the - // resolver's registry map and the asset index disagree — - // the same scan populated both, so reaching this branch - // would indicate an upstream invariant violation. - throw new Error( - `resolved tool-package manifest references asset ${assetId} which is not visible to tenant ${config.tenantId}`, - ); - } - const mountPath = `package-registries/${asset.name}/`; - assetMounts.set(assetId, mountPath); - manifestAssetAttachments.push( - await resolveAssetAttachment({ - asset, - mountPath, - }), - ); - } - } catch (err) { - throw new SessionLaunchError("write", err, false); - } - - effectiveDeployContent = { - ...effectiveDeployContent, - toolPackageManifest: manifest, - ...(assetMounts.size > 0 ? { assetMounts } : {}), - }; - } - - // Phase 0b: Write deploy tree and produce packfile (hub-local, no - // sidecar state to clean up if this fails). - let pack: Uint8Array; - let commitSha: string; - let ref: string; - try { - await agentRepoStore.writeDeployTree(agentId, effectiveDeployContent); - ({ pack, commitSha, ref } = - await agentRepoStore.createDeployPack(agentId)); - } catch (err) { - throw new SessionLaunchError("write", err, false); - } - - // A stage-only per-step deploy binds a transient route for the step - // address so the packs below route to the deployment's sidecar; the - // route is held only for the pack window and dropped in the `finally`. - if (stageOnly) { - try { - await requireAllocationRouter().bindAllocatedStepRoute( - params.allocationTarget, - agentAddress, - ); - } catch (err) { - throw new SessionLaunchError("provision", err, false); - } - } - try { - // Phase 1: Provision on sidecar. A stage-only per-step deploy sends a - // no-spawn provision frame: the sidecar inits the step's agent-state - // repo and records the hub key, but spawns nothing. Firing the frame - // before the Phase 2 pack is the ordering barrier -- the repo must - // exist before the pack applies. - try { - if (stageOnly) { - await requireAllocationRouter().sendProvisionStepToAllocation( - params.allocationTarget, - agentAddress, - config, - ); - } else { - // Every caller supplies `stageOnly`. A deploy without it has no - // provisioning shape -- the legacy warm-harness and single-step-head - // paths are gone -- so fail loud rather than ship a deploy pack the - // sidecar never provisioned a repo for. - throw new Error("executeLaunchPhases: a deploy requires stageOnly"); - } - } catch (err) { - throw new SessionLaunchError("provision", err, false); - } - - // Phase 2: Pack delivery. A stage-only step has no supervisor to - // undeploy, so on failure it only drops its transient route (in the - // `finally`). The step's inited agent-state repo is left on the sidecar: - // the deploy aborts before the deployment frame is sent, so there is - // nothing to undeploy, and a redeploy of the same deployment overwrites - // the orphaned repo. This is an acceptable minor leak on the exceptional - // staging-failure path, not a live-path cost. - try { - await requireAllocationRouter().sendPackToAllocation( - params.allocationTarget, - agentAddress, - pack, - ref, - commitSha, - ); - } catch (err) { - throw new SessionLaunchError("pack", err, !stageOnly); - } - - // Phase 2b: Asset-pack fan-out. For each attached asset, build a - // pack, reserve the manifest row, then send the pack. The manifest - // reservation MUST happen before the pack send: if the sidecar acks - // but the row is missing, the session has materialization without - // a recorded manifest. A retried first deploy may reuse the exact row an - // earlier attempt recorded. If reservation fails, no pack is sent. - // - // The fan-out materializes the package-registry assets the - // tool-package resolver picked. They live behind tenant - // inheritance rather than a per-agent attachment row, so the - // session service synthesizes the attachment view in - // `manifestAssetAttachments`. - const fanOut: ResolvedAttachment[] = manifestAssetAttachments; - if (assetService !== undefined && fanOut.length > 0) { - for (const att of fanOut) { - try { - await sendAttachmentPack( - runId, - agentAddress, - att, - params.allocationTarget, - ); - } catch (err) { - throw new SessionLaunchError("pack", err, !stageOnly); - } - } - } - } finally { - if (stageOnly) { - requireAllocationRouter().unbindAllocatedStepRoute( - params.allocationTarget, - agentAddress, - ); - } - } - } - - /** - * Stage one step of a multi-step workflow deploy: bind a transient route - * for the step address, fire a no-spawn provision frame (the sidecar inits - * the step's agent-state repo and records the hub key), deliver the deploy - * and asset packs, and unbind the route -- no warm harness. The multi-step - * branch stages every step this way, then fires ONE deployment-level - * workflow frame that writes the step grants and spawns the supervised - * workflow-process child; the child reads each step's staged deploy tree - * from disk and runs the step itself. - */ - async function stageWorkflowStep(params: { - agentAddress: string; - agentId: string; - runId: string; - config: HarnessConfig; - deployContent: DeployContent; - toolPackagePins?: readonly ToolPackagePin[]; - allocationTarget: AllocatedSidecarTarget; - }): Promise { - await executeLaunchPhases({ - agentAddress: params.agentAddress, - agentId: params.agentId, - runId: params.runId, - config: params.config, - deployContent: params.deployContent, - stageOnly: true, - ...(params.toolPackagePins !== undefined - ? { toolPackagePins: params.toolPackagePins } - : {}), - allocationTarget: params.allocationTarget, - }); - } - // Resolve the npm registry config a code-sourced install resolves external // deps against, by the registry name. A code-sourced deploy needs the // registry map configured; a hub that mounts the deploy surface without it is @@ -1727,7 +1307,7 @@ export function createSessionService( ); } } catch (error) { - throw new SessionLaunchError("start", error, true); + throw new SessionLaunchError(error, true); } } @@ -1898,209 +1478,6 @@ export function createSessionService( } } - async function sendAttachmentPack( - runId: string, - agentAddress: string, - attachment: ResolvedAttachment, - allocationTarget: AllocatedSidecarTarget, - ): Promise { - if (db === undefined) { - // Guarded at construction; reassert defensively so the - // narrowing is visible to readers and a future refactor cannot - // accidentally invoke this without a db. - throw new Error("sendAttachmentPack invoked without a db handle"); - } - - const { mountPath, sourceCommitSha, repoId, pack, ref } = attachment; - - const assetPackSha = await createPackSha(pack); - const record: SessionAssetRecord = { - runId, - mountPath, - assetPackSha, - sourceCommitSha, - }; - - // Reserve durable recovery intent before the pack send. A retried first - // deploy may reuse the exact row an earlier attempt recorded. - const inserted = await db - .insert(sessionAssetTable) - .values({ ...record, materializedAt: new Date() }) - .onConflictDoNothing({ - target: [sessionAssetTable.runId, sessionAssetTable.mountPath], - }) - .returning({ runId: sessionAssetTable.runId }); - if (inserted.length === 0) { - const existing = await db.query.sessionAsset.findFirst({ - where: and( - eq(sessionAssetTable.runId, runId), - eq(sessionAssetTable.mountPath, mountPath), - ), - columns: { - assetPackSha: true, - sourceCommitSha: true, - }, - }); - if (existing === undefined) { - throw new Error( - `session_asset ${runId}/${mountPath} disappeared after its insert conflicted`, - ); - } - if ( - existing.assetPackSha !== assetPackSha || - existing.sourceCommitSha !== sourceCommitSha - ) { - throw new Error( - `session_asset ${runId}/${mountPath} conflicts with the allocated workflow's restored asset`, - ); - } - } - - await requireAllocationRouter().sendPackToAllocation( - allocationTarget, - agentAddress, - pack, - ref, - sourceCommitSha, - { mountPath, repoId }, - ); - } - - /** - * Build a per-agent `ClosureResolver` from the tenant's visible - * package-registry assets plus the statically-configured HTTP - * registries, then run the closure resolution against `pins`. - * - * Returns the resolved manifest and an asset-id-keyed index of the - * package-registry assets the resolver knew about, so the caller can - * derive mount paths from the asset name without a second DB hit. - */ - async function buildAndResolve(args: { - agentId: string; - tenantId: string; - pins: readonly ToolPackagePin[]; - registries: NonNullable; - assetService: AssetService; - }): Promise<{ - manifest: ToolPackageManifest; - assetIndex: Map; - }> { - if (db === undefined) { - // Guarded at construction; restate for the narrowing. - throw new Error("buildAndResolve invoked without a db handle"); - } - const visibleAssets = await listAssetsForTenant( - db, - args.tenantId, - "package-registry", - ); - const registryMap = new Map(); - // `assetIndex` carries only the assets the resolver might have - // read from — i.e. one row per registry name, the one that won - // its `(kind, name)` slot. Shadowed assets that lost the - // collision are deliberately excluded: the resolver can never - // reach them, so the fan-out path must never see them in the - // index either. The walker walks leaf-to-root inside - // `listAssetsForTenant`, so the first occurrence of any - // `(kind, name)` wins — we replay the same shadowing here. - // Shadowed assets — those that lose the `(kind, name)` collision - // contest at a lower tenancy level — are dropped entirely from - // the per-launch registry map. They never appear in `assetIndex` - // either, so the fan-out that translates `kind: "asset"` manifest - // entries back to asset rows cannot reach them. This matches the - // resolver's view: a closure built from this map sees exactly - // the assets the resolver would have read from, and shadowed - // tarballs are invisible to both layers. - const assetIndex = new Map(); - for (const row of visibleAssets) { - if (registryMap.has(row.name)) continue; - const asset: Asset = { - id: row.id, - tenantId: row.tenantId, - kind: "package-registry", - name: row.name, - displayName: row.displayName, - creatorPrincipalId: row.creatorPrincipalId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; - assetIndex.set(asset.id, asset); - registryMap.set( - asset.name, - new AssetRegistrySource({ - name: asset.name, - assetId: asset.id, - readBlob: (path) => - args.assetService.readAssetBlob({ - assetId: asset.id, - path, - }), - listBlobs: (dir) => - args.assetService.listAssetBlobs({ - assetId: asset.id, - dir, - }), - }), - ); - } - for (const [name, cfg] of args.registries.httpRegistries) { - // Asset wins on collision with an HTTP registry of the same - // name; symmetric with the inner-shadows-outer rule that - // governs the tenant walker. - if (registryMap.has(name)) continue; - registryMap.set(name, new HttpRegistrySource({ name, config: cfg })); - } - if (!registryMap.has(args.registries.defaultRegistry)) { - throw new Error( - `agent ${args.agentId}: defaultRegistry "${args.registries.defaultRegistry}" is neither a tenant-visible package-registry asset nor a configured HTTP registry`, - ); - } - const resolver = createClosureResolver({ - registries: registryMap, - defaultRegistry: args.registries.defaultRegistry, - ...(args.registries.scopeRouting !== undefined - ? { scopeRouting: args.registries.scopeRouting } - : {}), - }); - const manifest = await resolver.resolveClosure(args.pins); - return { manifest, assetIndex }; - } - - /** - * Build a `ResolvedAttachment` for an asset the tool-package resolver - * picked from. The pack is read from the asset's main ref (the same - * ref the resolver consumed tarballs from). - */ - async function resolveAssetAttachment(args: { - asset: Asset; - mountPath: string; - }): Promise { - const repoId: RepoId = { kind: args.asset.kind, id: args.asset.id }; - const sourceCommitSha = await agentRepoStore.repoStore.resolveRef( - HUB_PRINCIPAL, - repoId, - DEFAULT_ASSET_REF, - ); - if (sourceCommitSha === null) { - throw new Error( - `tool-package asset ${args.asset.kind}/${args.asset.id} has no commit on ${DEFAULT_ASSET_REF}`, - ); - } - const { pack, ref: returnedRef } = - await agentRepoStore.repoStore.createPack( - HUB_PRINCIPAL, - repoId, - DEFAULT_ASSET_REF, - ); - return { - mountPath: args.mountPath, - sourceCommitSha, - repoId, - pack, - ref: returnedRef, - }; - } - async function endSession( agentAddress: string, reason: string, @@ -2109,7 +1486,6 @@ export function createSessionService( } return { - stageWorkflowStep, installAndApproveWorkflowSource, deployPreparedCodeSourcedWorkflow, endSession, diff --git a/packages/hub-sessions/src/sidecar-allocation/reconciler.test.ts b/packages/hub-sessions/src/sidecar-allocation/reconciler.test.ts index 6fe006258..62c97f6b9 100644 --- a/packages/hub-sessions/src/sidecar-allocation/reconciler.test.ts +++ b/packages/hub-sessions/src/sidecar-allocation/reconciler.test.ts @@ -24,6 +24,7 @@ import { createAllocatedRouter, deployReply, TEST_CONFIG, + TEST_WORKFLOW, TEST_IDENTITY, TEST_TARGET, tick, @@ -4357,11 +4358,7 @@ describe("createSidecarAllocationReconciler", () => { "capacity_full", "No active slot", ); - throw new SessionLaunchError( - "provision", - new Error("deploy pack failed"), - true, - ); + throw new SessionLaunchError(new Error("deploy failed"), true); }, }), ); @@ -4377,7 +4374,7 @@ describe("createSidecarAllocationReconciler", () => { ? "sidecar_deployment_rejected" : "sidecar_initialization_uncertain", failureMessage: - outcome === "rejected" ? "No active slot" : "deploy pack failed", + outcome === "rejected" ? "No active slot" : "deploy failed", onlyIfInitializationIncomplete: true, }); expect(fences).toEqual([ @@ -4577,11 +4574,7 @@ describe("createSidecarAllocationReconciler", () => { fences, ready: true, onReady: async () => { - throw new SessionLaunchError( - "provision", - new Error("deploy pack failed"), - true, - ); + throw new SessionLaunchError(new Error("deploy failed"), true); }, }), ); @@ -5497,7 +5490,7 @@ describe("durable initialization outcomes", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, - undefined, + TEST_WORKFLOW, signal, async () => { current = { ...current, initializationLeaseId: leaseId }; @@ -5507,7 +5500,7 @@ describe("durable initialization outcomes", () => { if (!isDeployFrameFailure(cause)) throw cause; expect(cause.frameSent).toBe(true); timedOut.resolve(true); - throw new SessionLaunchError("start", cause, true); + throw new SessionLaunchError(cause, true); } }, }); @@ -5639,7 +5632,6 @@ describe("durable initialization outcomes", () => { onReady: async () => { if (++initialized === 1) throw new SessionLaunchError( - "start", new Error("commit response lost"), true, ); diff --git a/packages/hub-sessions/src/skill-kind.test.ts b/packages/hub-sessions/src/skill-kind.test.ts index 21c35be31..f87c548e9 100644 --- a/packages/hub-sessions/src/skill-kind.test.ts +++ b/packages/hub-sessions/src/skill-kind.test.ts @@ -494,7 +494,7 @@ describe("skillKindHandler metadata", () => { describe("skillAuthorize", () => { const SKILL_REPO: RepoId = { kind: "skill", id: "asset-123" }; - const AGENT_STATE_REPO: RepoId = { kind: "agent-state", id: "asset-123" }; + const OTHER_KIND_REPO: RepoId = { kind: "workflow", id: "asset-123" }; const REF = "refs/heads/main"; function farFuture(): number { @@ -531,7 +531,7 @@ describe("skillAuthorize", () => { test("rejects calls when repoId.kind is not skill", () => { const r = skillAuthorize( { kind: "hub" } as Principal, - AGENT_STATE_REPO, + OTHER_KIND_REPO, REF, "createPack", ); @@ -645,7 +645,7 @@ describe("skillAuthorize", () => { test("user principal: denied when verdict.resource has the wrong kind prefix (sanity drift)", () => { const r = skillAuthorize( - userPrincipal({ resource: "agent-state:asset-123" }), + userPrincipal({ resource: "workflow-run:asset-123" }), SKILL_REPO, REF, "createPack", diff --git a/packages/hub-sessions/src/skill-kind.ts b/packages/hub-sessions/src/skill-kind.ts index 673358e2f..ef15c7f42 100644 --- a/packages/hub-sessions/src/skill-kind.ts +++ b/packages/hub-sessions/src/skill-kind.ts @@ -179,8 +179,8 @@ async function buildSkillIndex( const candidates = [...topLevelTreePaths].sort(); for (const candidate of candidates) { // Skill subdirectories are arbitrarily named (`/`), so - // there is no enumerable allowlist of names the way the workflow, - // package-registry, and agent-state handlers use. The structural + // there is no enumerable allowlist of names the way the workflow and + // package-registry handlers use. The structural // distinction is tree-vs-blob: a top-level tree entry is a skill // subdir, a top-level blob (e.g. `.gitignore` seeded by the // genesis init) is not. diff --git a/packages/hub-sessions/src/substrate.ts b/packages/hub-sessions/src/substrate.ts index c21d27ae9..2685dc737 100644 --- a/packages/hub-sessions/src/substrate.ts +++ b/packages/hub-sessions/src/substrate.ts @@ -10,7 +10,7 @@ // // This entry exposes only the substrate layer, so any consumer that needs // repo/substrate access without the control-plane services -- for example a -// process that reads and writes the agent-state and workflow-run repos but +// process that reads and writes the workflow-run repos but // runs nowhere near a database -- can import it without dragging `@intx/db` // into its boot graph. Every symbol re-exported here is defined in a db-free // module. The barrel still re-exports all of these for hub-side consumers. diff --git a/packages/hub-sessions/src/workflow-allocation-service.ts b/packages/hub-sessions/src/workflow-allocation-service.ts index aa0bb2c82..0a6ef7af6 100644 --- a/packages/hub-sessions/src/workflow-allocation-service.ts +++ b/packages/hub-sessions/src/workflow-allocation-service.ts @@ -41,10 +41,8 @@ import { approvalSetFromItems, buildInertProjectionStepSources, deriveRunAddress, - deriveRunAgentId, } from "@intx/workflow-deploy"; -import type { DeployContent } from "./agent-repo"; import type { LocalRepoStore } from "./repo-store"; import { adapterManifestDigest, @@ -118,7 +116,6 @@ export type PrepareProvisionedWorkflowDeploymentArgs = { readonly sourceAuthorityPrincipalId: string; readonly sourceOfferingIds: readonly string[]; readonly defaultSourceOfferingId: string; - readonly deployContent: DeployContent; readonly toolPackagePins?: readonly ToolPackagePin[]; /** * State to start agent steps from, keyed by step id. Each key must name a @@ -234,11 +231,9 @@ function parseDestroyResult(value: unknown): DestroySidecarResult { } function createProvisionedHarnessConfig(args: { - readonly tenantId: string; readonly anchorRunId: string; readonly deploymentDomain: string; readonly sessionId: string; - readonly sourceAuthorityPrincipalId: string; readonly sources: HarnessConfig["sources"]; readonly defaultSource: string; }): HarnessConfig { @@ -248,13 +243,7 @@ function createProvisionedHarnessConfig(args: { }); return { sessionId: args.sessionId, - agentId: deriveRunAgentId({ runId: args.anchorRunId }), - tenantId: args.tenantId, - principalId: args.sourceAuthorityPrincipalId, agentAddress: deploymentAddress, - systemPrompt: "", - tools: [], - grants: [], sources: args.sources, defaultSource: args.defaultSource, }; @@ -505,7 +494,6 @@ export function createWorkflowAllocationService({ definitionArtifactId: approved.artifactId ?? null, sourceOfferingIds: [...request.sourceOfferingIds], defaultSourceOfferingId: request.defaultSourceOfferingId, - deployContent: request.deployContent, ...(request.toolPackagePins !== undefined ? { toolPackagePins: [...request.toolPackagePins] } : {}), @@ -821,11 +809,9 @@ export function createWorkflowAllocationService({ approval: approved.approval, }; const config = createProvisionedHarnessConfig({ - tenantId: args.tenantId, anchorRunId: args.anchorRunId, deploymentDomain: args.deploymentDomain, sessionId: args.sessionId, - sourceAuthorityPrincipalId: args.sourceAuthorityPrincipalId, sources: sourceCheck.sources, defaultSource: defaultSource.id, }); @@ -850,7 +836,7 @@ export function createWorkflowAllocationService({ stepStateSeeds.reason, ); } - await buildReferencedWorkflowSourcePins({ + buildReferencedWorkflowSourcePins({ projection: approved.projection, config, operatorApprovals: approved.approval.approvedSurface, @@ -954,7 +940,6 @@ export function createWorkflowAllocationService({ if (await allocationRouter.isAllocatedWorkflowActive(allocationTarget)) { if (anchor.publicKey !== null) return null; throw new SessionLaunchError( - "provision", new Error( `Allocation ${allocation.id} has an active workflow without a completed initialization key`, ), @@ -1046,11 +1031,9 @@ export function createWorkflowAllocationService({ ); } const config = createProvisionedHarnessConfig({ - tenantId: allocation.tenantId, anchorRunId: allocation.anchorRunId, deploymentDomain: spec.deploymentDomain, sessionId: spec.sessionId, - sourceAuthorityPrincipalId: spec.sourceAuthorityPrincipalId, sources: resolved.sources, defaultSource: defaultSource.id, }); diff --git a/packages/hub-sessions/src/workflow-kind.test.ts b/packages/hub-sessions/src/workflow-kind.test.ts index b43e31ad6..2bae6bda4 100644 --- a/packages/hub-sessions/src/workflow-kind.test.ts +++ b/packages/hub-sessions/src/workflow-kind.test.ts @@ -95,7 +95,7 @@ describe("workflowKindHandler metadata", () => { describe("workflowAuthorize", () => { const WORKFLOW_REPO: RepoId = { kind: "workflow", id: "wf-123" }; - const AGENT_STATE_REPO: RepoId = { kind: "agent-state", id: "wf-123" }; + const OTHER_KIND_REPO: RepoId = { kind: "skill", id: "wf-123" }; function farFuture(): number { return Date.now() + 60_000; @@ -131,7 +131,7 @@ describe("workflowAuthorize", () => { test("rejects calls when repoId.kind is not workflow", () => { const r = workflowAuthorize( { kind: "hub" } as Principal, - AGENT_STATE_REPO, + OTHER_KIND_REPO, REF, "createPack", ); diff --git a/packages/hub-sessions/src/workflow-run-deletion-bypass.test.ts b/packages/hub-sessions/src/workflow-run-deletion-bypass.test.ts index 7e50a92cb..504017cba 100644 --- a/packages/hub-sessions/src/workflow-run-deletion-bypass.test.ts +++ b/packages/hub-sessions/src/workflow-run-deletion-bypass.test.ts @@ -80,13 +80,11 @@ describe("workflow-run validatePush: deletion-bypass surface", () => { address: ADDR, runId: "run-1", consumedAt: 200, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); const inboxBody = JSON.stringify({ messageId: "msg-1", receivedAt: 300, address: ADDR, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); const prior = { [`addresses/${ADDR_SEG}/consumed/msg-1.json`]: consumedBody, @@ -111,7 +109,6 @@ describe("workflow-run validatePush: deletion-bypass surface", () => { messageId: "msg-1", receivedAt: 100, address: ADDR, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); const prior = { [`addresses/${ADDR_SEG}/processing/100-msg-1.json`]: body, @@ -137,7 +134,6 @@ describe("workflow-run validatePush: deletion-bypass surface", () => { address: ADDR, runId: "run-1", consumedAt: 200, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); const prior = { [`addresses/${ADDR_SEG}/consumed/msg-1.json`]: consumedBody, diff --git a/packages/hub-sessions/src/workflow-run-kind.test.ts b/packages/hub-sessions/src/workflow-run-kind.test.ts index 62504f7c7..9158fb551 100644 --- a/packages/hub-sessions/src/workflow-run-kind.test.ts +++ b/packages/hub-sessions/src/workflow-run-kind.test.ts @@ -2331,7 +2331,7 @@ describe("workflowRunAuthorize — repoId guard", () => { test("rejects calls when repoId.kind is not workflow-run", () => { const r = workflowRunAuthorize( { kind: "hub" } as Principal, - { kind: "agent-state", id: "dep-1" } as RepoId, + { kind: "skill", id: "dep-1" } as RepoId, REF, "createPack", ); @@ -2717,7 +2717,6 @@ function inboxBody( messageId, receivedAt, address, - mailAuditRef: { store: "audit", path: `mail/${messageId}` }, }); } @@ -2734,11 +2733,23 @@ function consumedBody( address, runId, consumedAt, - mailAuditRef: { store: "audit", path: `mail/${messageId}` }, }); } describe("workflowRunKindHandler.validatePush — claim-check subtree shape", () => { + test("accepts an inbox entry carrying a since-removed envelope field", async () => { + const r = await validate({ + [WORKFLOW_RUN_GITIGNORE_PATH]: "", + [inboxPathFor(ADDRESS_SEG, 100, "msg-1")]: JSON.stringify({ + messageId: "msg-1", + receivedAt: 100, + address: ADDRESS, + mailAuditRef: { store: "audit", path: "mail/msg-1" }, + }), + }); + expect(r.ok).toBe(true); + }); + test("accepts a single inbox entry with a well-formed envelope", async () => { const r = await validate({ [WORKFLOW_RUN_GITIGNORE_PATH]: "", @@ -3045,7 +3056,6 @@ describe("claim-check API — enqueueInbox", () => { address: ADDRESS, messageId: "msg-1", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); expect(result.outcome).toBe("enqueued"); if (result.outcome !== "enqueued") throw new Error("expected enqueued"); @@ -3078,13 +3088,11 @@ describe("claim-check API — enqueueInbox", () => { address: ADDRESS, messageId: "msg-1", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); await enqueueInbox(store, principal, repoId, { address: ADDRESS, messageId: "msg-2", receivedAt: 200, - mailAuditRef: { store: "audit", path: "mail/msg-2" }, }); const repoDir = store.getRepoDir(repoId); const inboxDir = path.join( @@ -3102,14 +3110,12 @@ describe("claim-check API — enqueueInbox", () => { address: ADDRESS, messageId: "msg-1", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); const outcome = await enqueueInbox(store, principal, repoId, { address: ADDRESS, messageId: "msg-1", receivedAt: 300, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); expect(outcome).toEqual({ outcome: "already-present", @@ -3119,13 +3125,69 @@ describe("claim-check API — enqueueInbox", () => { }); describe("claim-check API — markConsumed", () => { + test("entries written with a since-removed envelope field keep moving", async () => { + // The real handler refuses a consumed entry with no prior processing + // entry, so seed through a permissive one. + const store = createRepoStore({ + dataDir: await makeClaimCheckTempDir("cc-old-entries-"), + signingKey: claimCheckSigningKey, + handlers: { + "workflow-run": { + kind: "workflow-run", + directoryPrefix: "workflow-runs", + validatePush: () => ({ ok: true }), + onRefUpdated: () => undefined, + }, + }, + authorize: () => ({ allowed: true }), + }); + const repoId: RepoId = { kind: "workflow-run", id: "dep-oldentries" }; + await store.initRepo(repoId); + const removedField = { mailAuditRef: { store: "audit", path: "mail/x" } }; + await store.writeTree(HUB_PRINCIPAL, repoId, "refs/heads/events", { + files: { + [WORKFLOW_RUN_GITIGNORE_PATH]: "", + [inboxPathFor(ADDRESS_SEG, 100, "msg-old")]: JSON.stringify({ + messageId: "msg-old", + receivedAt: 100, + address: ADDRESS, + ...removedField, + }), + [consumedPathFor(ADDRESS_SEG, "msg-older")]: JSON.stringify({ + messageId: "msg-older", + receivedAt: 50, + address: ADDRESS, + runId: "run-W", + consumedAt: 60, + ...removedField, + }), + }, + message: "seed entries from before the field was removed", + }); + + await dequeueToProcessing(store, HUB_PRINCIPAL, repoId, ADDRESS); + const consumed = await markConsumed(store, HUB_PRINCIPAL, repoId, { + address: ADDRESS, + messageId: "msg-old", + runId: "run-X", + consumedAt: 500, + }); + expect(consumed.envelope.receivedAt).toBe(100); + + const redelivered = await enqueueInbox(store, HUB_PRINCIPAL, repoId, { + address: ADDRESS, + messageId: "msg-older", + receivedAt: 50, + }); + expect(redelivered.outcome).toBe("already-present"); + }); + test("atomic move from processing to consumed preserves originating receivedAt", async () => { const { store, repoId, principal } = await makeClaimCheckStore("cc-mark-"); await enqueueInbox(store, principal, repoId, { address: ADDRESS, messageId: "msg-1", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); const result = await markConsumed(store, principal, repoId, { @@ -3184,13 +3246,11 @@ describe("claim-check substrate FIFO invariant", () => { address: ADDRESS, messageId: "msg-1", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); await enqueueInbox(store, principal, repoId, { address: ADDRESS, messageId: "msg-2", receivedAt: 200, - mailAuditRef: { store: "audit", path: "mail/msg-2" }, }); const first = await dequeueToProcessing(store, principal, repoId, ADDRESS); @@ -3257,7 +3317,6 @@ describe("claim-check substrate FIFO invariant", () => { address: ADDRESS, messageId: "msg-1", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); const replay = await replayProcessingToInbox( store, @@ -3287,13 +3346,11 @@ describe("claim-check substrate FIFO invariant", () => { address: ADDRESS, messageId: "msg-A", receivedAt: 99, - mailAuditRef: { store: "audit", path: "mail/A" }, }); await enqueueInbox(store, principal, repoId, { address: ADDRESS, messageId: "msg-B", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/B" }, }); const first = await dequeueToProcessing(store, principal, repoId, ADDRESS); expect(first).not.toBeNull(); @@ -3678,13 +3735,11 @@ describe("claim-check API — resume-owned processing entries survive replay", ( address: ADDRESS, messageId: "live", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/live" }, }); await enqueueInbox(store, principal, repoId, { address: ADDRESS, messageId: "orphan", receivedAt: 200, - mailAuditRef: { store: "audit", path: "mail/orphan" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); await dequeueToProcessing(store, principal, repoId, ADDRESS); @@ -3733,7 +3788,6 @@ describe("claim-check API — resume-owned processing entries survive replay", ( address: ADDRESS, messageId: "live", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/live" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); await store.writeTree(principal, repoId, "refs/heads/main", { @@ -3774,13 +3828,11 @@ describe("claim-check API — enqueueInbox per-messageId atomicity in inbox", () address: ADDRESS, messageId: "msg-X", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/X" }, }); const outcome = await enqueueInbox(store, principal, repoId, { address: ADDRESS, messageId: "msg-X", receivedAt: 200, - mailAuditRef: { store: "audit", path: "mail/X" }, }); expect(outcome).toEqual({ outcome: "already-present", @@ -3802,13 +3854,11 @@ describe("claim-check API — enqueueInbox per-messageId atomicity in inbox", () address: ADDRESS, messageId: "msg-D", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/D" }, }); const outcome = await enqueueInbox(store, principal, repoId, { address: ADDRESS, messageId: "msg-D", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/D" }, }); expect(outcome).toEqual({ outcome: "already-present", @@ -3939,7 +3989,6 @@ describe("workflow-run substrate — per-commit pack validation", () => { address: ADDRESS, messageId: "msg-1", receivedAt: 100, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }, ); if (enqueueResult.outcome !== "enqueued") { @@ -4359,22 +4408,22 @@ describe("workflowRunKindHandler — scoped vs validate-all byte-identity", () = const refDir = path.join(reference.dir, repoId.id); for (const c of commits) { - const content = { - files: c.files, - clearPrefix: `runs/${c.runId}/events/`, + const args = { + preservePrefix: `runs/${c.runId}/events/`, + merge: () => Promise.resolve(c.files), message: `bracket ${c.runId}`, }; - const s = await scoped.store.writeTree( + const s = await scoped.store.writeTreePreservingPrefix( HUB_PRINCIPAL, repoId, ref, - content, + args, ); - const r = await reference.store.writeTree( + const r = await reference.store.writeTreePreservingPrefix( HUB_PRINCIPAL, repoId, ref, - content, + args, ); const { commit: sCommit } = await git.readCommit({ fs, @@ -5007,7 +5056,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "msg-1", receivedAt: 1000, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); await markConsumed(store, principal, repoId, { @@ -5025,7 +5073,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "msg-1", receivedAt: 3000, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); expect(outcome).toEqual({ outcome: "already-present", reason: "consumed" }); }); @@ -5042,7 +5089,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "msg-driver", receivedAt: 100_000, - mailAuditRef: { store: "audit", path: "mail/msg-driver" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); const consumed = await markConsumed(store, principal, repoId, { @@ -5061,7 +5107,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "msg-stale", receivedAt: 50_000, - mailAuditRef: { store: "audit", path: "mail/msg-stale" }, }), ).rejects.toThrow(/claim_check_stale_enqueue/); }); @@ -5075,7 +5120,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "msg-driver", receivedAt: 100_000, - mailAuditRef: { store: "audit", path: "mail/msg-driver" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); await markConsumed(store, principal, repoId, { @@ -5090,7 +5134,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "msg-fresh", receivedAt: 100_500, - mailAuditRef: { store: "audit", path: "mail/msg-fresh" }, }); expect(r.outcome).toBe("enqueued"); if (r.outcome !== "enqueued") throw new Error("expected enqueued"); @@ -5121,7 +5164,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId, receivedAt: t, - mailAuditRef: { store: "audit", path: `mail/${messageId}` }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); await markConsumed(store, principal, repoId, { @@ -5155,7 +5197,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "a", receivedAt: 50_000, - mailAuditRef: { store: "audit", path: "mail/a" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); await markConsumed(store, principal, repoId, { @@ -5175,7 +5216,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "b", receivedAt: 49_500, - mailAuditRef: { store: "audit", path: "mail/b" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); const consumedB = await markConsumed(store, principal, repoId, { @@ -5212,7 +5252,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "inflight", receivedAt: 10_000, - mailAuditRef: { store: "audit", path: "mail/inflight" }, }); const inflightDequeue = await dequeueToProcessing( store, @@ -5230,7 +5269,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "newer", receivedAt: 100_000, - mailAuditRef: { store: "audit", path: "mail/newer" }, }); await dequeueToProcessing(store, principal, repoId, ADDRESS); const newerConsumed = await markConsumed(store, principal, repoId, { @@ -5303,7 +5341,6 @@ describe("claim-check API — retention watermark exactly-once + bounded", () => address: ADDRESS, messageId: "inflight", receivedAt: 102_000, - mailAuditRef: { store: "audit", path: "mail/inflight" }, }); expect(outcome).toEqual({ outcome: "already-present", reason: "consumed" }); }); @@ -5321,7 +5358,6 @@ describe("claim-check API — a receive whose prior watermark the receiver lost" address: ADDRESS, messageId: "msg-1", receivedAt: 1000, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); await dequeueToProcessing(hub.store, principal, repoId, ADDRESS); await markConsumed(hub.store, principal, repoId, { @@ -5369,7 +5405,6 @@ describe("claim-check API — a receive whose prior watermark the receiver lost" address: ADDRESS, messageId: "msg-2", receivedAt: 150_000, - mailAuditRef: { store: "audit", path: "mail/msg-2" }, }); await dequeueToProcessing(sender, principal, repoId, ADDRESS); const parentSha = await ship(ackedSha); diff --git a/packages/hub-sessions/src/workflow-run-kind.ts b/packages/hub-sessions/src/workflow-run-kind.ts index 5a9cb1463..4661a876f 100644 --- a/packages/hub-sessions/src/workflow-run-kind.ts +++ b/packages/hub-sessions/src/workflow-run-kind.ts @@ -371,11 +371,10 @@ export const DEFAULT_CONSUMED_RETENTION_MS = 24 * 60 * 60 * 1000; * like `runs//blobs/`. A prior `.eml` may be ABSENT from * the prospective tree — that is the warm agent expunging a message. * The raw bytes are not lost: they stay reachable through the parent - * commit, and a `workflow-run` repo's objects are never GC'd (its - * kind is off the GC allow-list), so an expunged message survives in - * history for the life of the run repo. The audit trail rests on - * "these objects are never pruned", not on the live tree being - * monotonic. + * commit, and a `workflow-run` repo's objects are never GC'd, so an + * expunged message survives in history for the life of the run repo. The + * audit trail rests on "these objects are never pruned", not on the live + * tree being monotonic. * * The only entries permitted under `mailbox/` are the `INBOX/` * directory; the only entries permitted under `mailbox/INBOX/` are @@ -532,18 +531,13 @@ const CONSUMED_FILENAME_RE = /^(.+)\.json$/; * - `receivedAt`: epoch-ms timestamp the reactor accepted the * message; sortable FIFO key prefix. * - `address`: decoded canonical address (not URL-encoded). - * - `mailAuditRef`: pointer to the raw mail bytes in the mail-audit - * store. For the in-process single-agent path a separate - * `MailAuditStore` holds the authoritative bytes and this ref joins - * onto it. * - `rawMessage`: base64 of the inbound mail's raw MIME bytes, * inlined so the workflow-process child can read its step input by * messageId at `trigger.fired` time. The supervisor is the sole * mail owner under the unified-execution host (§3a); it has no * separate durable byte store the child can read, so the bytes ride * the claim-check envelope itself. Present whenever the supervisor - * enqueued the entry; omitted by callers that only stamp the audit - * ref. The bytes survive the inbox→processing transition verbatim + * enqueued the entry. The bytes survive the inbox→processing transition verbatim * (the dequeue copies the entry bytes), so a `trigger.fired` for a * processing entry can always recover the input. */ @@ -551,10 +545,6 @@ const ClaimCheckEnvelope = type({ messageId: "string > 0", receivedAt: "number >= 0", address: "string > 0", - mailAuditRef: { - store: "string > 0", - path: "string > 0", - }, "rawMessage?": "string > 0", "+": "ignore", }); @@ -571,10 +561,6 @@ const ConsumedEnvelope = type({ address: "string > 0", runId: "string > 0", consumedAt: "number >= 0", - mailAuditRef: { - store: "string > 0", - path: "string > 0", - }, "rejection?": { code: "string > 0", message: "string > 0", @@ -2895,8 +2881,7 @@ async function enumerateMailboxInbox( * by its blob OID. A prior `.eml` absent from the prospective tree is * a legal expunge: the warm agent physically removes a message from the * live INBOX. The raw bytes are not lost -- they stay reachable through the parent commit, and - * a `workflow-run` repo's objects are never GC'd (its kind is excluded - * from the GC allow-list; see `DEFAULT_GC_KINDS` in `agent-repo`), so the + * a `workflow-run` repo's objects are never GC'd, so the * expunged message survives in history for the life of the run repo. The * audit trail therefore rests on "these objects are never pruned", not on * the live tree being monotonic. @@ -3749,13 +3734,12 @@ export type EnqueueInboxArgs = { address: string; messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; /** * Base64 of the inbound mail's raw MIME bytes. Inlined on the * claim-check envelope so the workflow-process child can recover its * step input by messageId at `trigger.fired` time (§3a -- the * supervisor is the sole mail owner and has no separate durable byte - * store the child reads). Omit to stamp only the audit ref. + * store the child reads). */ rawMessage?: string; }; @@ -3863,7 +3847,6 @@ export async function enqueueInbox( messageId: args.messageId, receivedAt: args.receivedAt, address: args.address, - mailAuditRef: args.mailAuditRef, ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}), }; const newInboxPath = inboxPath(addressSegment, inboxKey); @@ -4033,10 +4016,9 @@ export type ReadProcessingEntryResult = { /** * Read the processing-queue entry for `messageId` at `address` without - * mutating the tree. Returns the decoded claim-check envelope (carrying - * `mailAuditRef` and, when the enqueuer inlined them, the base64 - * `rawMessage` bytes) or `null` when no processing entry exists for the - * messageId. + * mutating the tree. Returns the decoded claim-check envelope (carrying, + * when the enqueuer inlined them, the base64 `rawMessage` bytes) or `null` + * when no processing entry exists for the messageId. * * This is the read half of mailbox ownership (§3a): the supervisor's * dispatch loop moves an inbox entry to processing and forwards a @@ -4138,9 +4120,8 @@ export type MarkConsumedResult = { * called `dequeueToProcessing` for this messageId; calling * `markConsumed` without a matching processing entry throws. * - * The consumed envelope preserves the original `receivedAt` and - * `mailAuditRef` from the processing entry so the dedup index doubles - * as an audit record. + * The consumed envelope preserves the original `receivedAt` from the + * processing entry so the dedup index doubles as an audit record. * * Retention (the bounded-`consumed/` contract): the watermark advances * to `max(priorWatermark, min(consumedAt - retentionHorizonMs, @@ -4197,7 +4178,6 @@ export async function markConsumed( address: args.address, runId: args.runId, consumedAt: args.consumedAt, - mailAuditRef: processingEnvelope.mailAuditRef, ...(args.rejection !== undefined ? { rejection: args.rejection } : {}), }; consumedEnvelope = envelope; diff --git a/packages/hub-sessions/src/workflow-run-reader.ts b/packages/hub-sessions/src/workflow-run-reader.ts index 74753e16d..fd3dddab4 100644 --- a/packages/hub-sessions/src/workflow-run-reader.ts +++ b/packages/hub-sessions/src/workflow-run-reader.ts @@ -30,8 +30,7 @@ export type WorkflowRunEvent = { * Reader for a workflow-run repo's committed event log. Projects the * `runs//events/.json` substrate the workflow-process child * writes (validated at push time by the workflow-run kind handler) into - * seq-ordered in-memory events, mirroring the read-model approach the - * per-session timeline reconstruction uses against an agent-state repo. + * seq-ordered in-memory events. * * The reader is read-only: it composes `RepoStore.getRepoDir` (a pure * path computation) with direct `isomorphic-git` tree/blob reads. It diff --git a/packages/hub-sessions/src/workflow-run-receive-cost.test.ts b/packages/hub-sessions/src/workflow-run-receive-cost.test.ts index b8b123dcc..7284dbba1 100644 --- a/packages/hub-sessions/src/workflow-run-receive-cost.test.ts +++ b/packages/hub-sessions/src/workflow-run-receive-cost.test.ts @@ -123,7 +123,6 @@ async function manyRunsFixture(commits: number): Promise { messageId, receivedAt: i, address: ADDRESS, - mailAuditRef: { store: "audit", path: `mail/${messageId}` }, }); inbox.push({ filename, body }); puts[`${ADDRESS_DIR}/${WORKFLOW_RUN_INBOX_DIR}/${filename}`] = body; diff --git a/packages/hub-sessions/src/workflow-run-restore.test.ts b/packages/hub-sessions/src/workflow-run-restore.test.ts index 0607c98d7..a5604b14c 100644 --- a/packages/hub-sessions/src/workflow-run-restore.test.ts +++ b/packages/hub-sessions/src/workflow-run-restore.test.ts @@ -39,7 +39,6 @@ test("sends the Hub's main and events refs sequentially to the exact allocation" address: agentAddress, messageId: "message-1", receivedAt: 1, - mailAuditRef: { store: "mail", path: "message-1" }, }); const sends: { diff --git a/packages/hub-sessions/src/workflow-run-verified-gaps.test.ts b/packages/hub-sessions/src/workflow-run-verified-gaps.test.ts index ae50c2d23..2180a2318 100644 --- a/packages/hub-sessions/src/workflow-run-verified-gaps.test.ts +++ b/packages/hub-sessions/src/workflow-run-verified-gaps.test.ts @@ -61,7 +61,6 @@ describe("workflow-run principal-vs-path scoping (regression)", () => { messageId: "msg-1", receivedAt: 100, address: ADDR, - mailAuditRef: { store: "audit", path: "x" }, }), }; const r = await validateRun(prospective, {}, principal); @@ -174,7 +173,6 @@ describe("workflow-run inbox deletion is rejected (regression)", () => { messageId: "msg-1", receivedAt: 100, address: ADDR_SEG, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); const prior = { [`addresses/${ADDR_SEG}/inbox/100-msg-1.json`]: inboxBody, @@ -195,7 +193,6 @@ describe("workflow-run inbox deletion is rejected (regression)", () => { messageId: "msg-1", receivedAt: 100, address: ADDR_SEG, - mailAuditRef: { store: "audit", path: "mail/msg-1" }, }); const prior = { [`addresses/${ADDR_SEG}/inbox/100-msg-1.json`]: body, diff --git a/packages/hub-sessions/src/workflow-source-pins.ts b/packages/hub-sessions/src/workflow-source-pins.ts index cf5e70732..9d2bb7910 100644 --- a/packages/hub-sessions/src/workflow-source-pins.ts +++ b/packages/hub-sessions/src/workflow-source-pins.ts @@ -1,27 +1,23 @@ import type { WorkflowProjectionWithSources } from "@intx/types/sidecar"; import type { HarnessConfig } from "@intx/types/runtime"; -import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; import { buildInertBodyStepSources, enumerateInertBodies, type ApprovalSet, } from "@intx/workflow-deploy"; -export async function buildReferencedWorkflowSourcePins(args: { +export function buildReferencedWorkflowSourcePins(args: { projection: WorkflowProjectionWithSources["definition"]; config: HarnessConfig; operatorApprovals: ApprovalSet; -}): Promise { - return Promise.all( - enumerateInertBodies(args.projection).map(async (body) => ({ +}): readonly WorkflowProjectionWithSources[] { + return enumerateInertBodies(args.projection).map((body) => ({ + definition: body.definition, + sources: buildInertBodyStepSources({ definition: body.definition, - sources: buildInertBodyStepSources({ - definition: body.definition, - workflowId: body.ref, - config: args.config, - operatorApprovals: args.operatorApprovals, - }), - approvedWireHash: await computeWireDefinitionHash(body.definition), - })), - ); + workflowId: body.ref, + config: args.config, + operatorApprovals: args.operatorApprovals, + }), + })); } diff --git a/packages/hub-sessions/src/ws/index.ts b/packages/hub-sessions/src/ws/index.ts index 7add5aede..da84e4416 100644 --- a/packages/hub-sessions/src/ws/index.ts +++ b/packages/hub-sessions/src/ws/index.ts @@ -18,7 +18,6 @@ export { type SidecarAuthenticator, type AllocatedSidecarTarget, type SidecarAllocationRouter, - type SendPackOptions, type WsHandle, } from "./sidecar-handler"; export { diff --git a/packages/hub-sessions/src/ws/sidecar-events.ts b/packages/hub-sessions/src/ws/sidecar-events.ts index 74231c4e9..6111c55bf 100644 --- a/packages/hub-sessions/src/ws/sidecar-events.ts +++ b/packages/hub-sessions/src/ws/sidecar-events.ts @@ -95,8 +95,7 @@ export type SidecarEventMap = { /** Notification. Emitted when a sidecar's connection closes, and when * one allocation leaves a connection that stays open for others, - * carrying every workflow address that lost its route: deployment - * addresses and the step addresses staged for them. */ + * carrying every workflow address that lost its route. */ "sidecar.disconnect": { ownedAddresses: string[]; /** Allocation generations whose current connection this was. */ @@ -264,12 +263,6 @@ export function createSidecarEmitter(): SidecarEventEmitter { } export type SidecarLookups = { - /** Returns the hub's current deploy ref for the address, or `null` if - * no deploy state is tracked. The wire layer compares this against - * the sidecar's reported ref during reconnect and emits - * `deploy.ref.stale` only on mismatch. */ - lookupDeployRef?: (agentAddress: string) => Promise; - /** Re-resolves a reconnecting deployment's current credential delivery and * reconciles the child, so a credential revoked, deleted, or rotated while * the sidecar was disconnected is applied on reconnect (closing the offline @@ -426,21 +419,6 @@ export type SidecarLookups = { recipientAddresses: string[]; }) => Promise; - /** Ingests a received agent-state pack and returns whether the wire - * layer should ack or reject the pack to the sidecar. `repoId.kind` - * is `"agent-state"` and `repoId.id` is the run address. The wire - * layer dispatches on `repoId.kind` against the receive lookups - * before calling either; this lookup must reject any pack whose - * `repoId.kind` is not `"agent-state"`. */ - receiveAgentStatePack?: ( - repoId: RepoId, - pack: Uint8Array, - ref: string, - commitSha: string, - ) => Promise< - { accepted: true } | { accepted: false; reason: PackRejectReason } - >; - /** Ingests a received workflow-run pack and returns whether the wire * layer should ack or reject the pack to the sidecar. `source` is derived * from the authenticated socket, never from the frame. The lookup must diff --git a/packages/hub-sessions/src/ws/sidecar-handler.control.test.ts b/packages/hub-sessions/src/ws/sidecar-handler.control.test.ts index f446a078c..12936260f 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.control.test.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.control.test.ts @@ -29,6 +29,7 @@ import { deployReply, parsedFrames, TEST_CONFIG, + TEST_WORKFLOW, TEST_IDENTITY, TEST_REF_TIPS, TEST_TARGET, @@ -292,6 +293,7 @@ describe("SidecarRouter allocation control protocols", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ), ).rejects.toThrow("timed out"); @@ -306,26 +308,6 @@ describe("SidecarRouter allocation control protocols", () => { ).rejects.toBeInstanceOf(WorkflowControlTimeoutError); }); - test("a step the worker is still provisioning does not defer the deployment's control", async () => { - const router = createAllocatedRouter({ requestTimeoutMs: 5 }); - await connectAllocated(router); - const stepAddress = "run_anchor-step-1@tenant.example"; - await router.bindAllocatedStepRoute(TEST_TARGET, stepAddress); - // The Hub stops waiting for the step before the worker answers it, but the - // worker handles the step's frames apart from the deployment's. - await expect( - router.sendProvisionStepToAllocation( - TEST_TARGET, - stepAddress, - TEST_CONFIG, - ), - ).rejects.toThrow("timed out"); - - await expect( - router.sendWorkflowControl(TEST_IDENTITY, stopCommand(), 1), - ).rejects.toBeInstanceOf(WorkflowControlTimeoutError); - }); - test("a deploy the worker refused no longer defers control", async () => { const router = createAllocatedRouter(); const ws = await connectAllocated(router); @@ -333,6 +315,7 @@ describe("SidecarRouter allocation control protocols", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ); await tick(); router.handleMessage(ws, deployReply(ws, { error: "Deploy failed" })); @@ -1328,7 +1311,6 @@ describe("SidecarRouter allocated outbound mail", () => { describe("SidecarRouter not-connected allocations", () => { const createdAt = new Date("2026-01-01T00:00:00.000Z"); const later = (ms: number) => new Date(createdAt.getTime() + ms); - const stepAddress = "run_anchor-step-1@tenant.example"; function clockedRouter(config: Partial = {}) { const clock = { current: createdAt }; @@ -1365,10 +1347,16 @@ describe("SidecarRouter not-connected allocations", () => { clock.current = later(20_000); const raw = await router - .bindAllocatedStepRoute(TEST_TARGET, stepAddress) + .sendWorkflowRunPackToAllocation( + TEST_TARGET, + TEST_IDENTITY.workflowRunAddress, + new Uint8Array(), + "refs/heads/main", + "0".repeat(40), + ) .then( () => { - throw new Error("The step route was bound"); + throw new Error("The pack was sent"); }, (error: unknown) => error, ); diff --git a/packages/hub-sessions/src/ws/sidecar-handler.mail.test.ts b/packages/hub-sessions/src/ws/sidecar-handler.mail.test.ts index 11dca6cd1..abc4a2810 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.mail.test.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.mail.test.ts @@ -463,7 +463,6 @@ describe("SidecarRouter allocation mail durability", () => { withExecutableWorkflowRun: async (_target, send) => send(), ...sidecarAuth(() => [current]), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, mailAckRetryIntervalMs: 10_000, mailHoldTTLMs: 60_000, @@ -2105,18 +2104,10 @@ describe("SidecarRouter workflow-trigger mail gating", () => { ]); }); - // A non-run address bound on the allocation is routed. One after the - // workflow address must still be routed on the attempt that parks, and an - // address with no route is reported then, not when the hold later settles. - const peer = "peer@example.test"; - const otherStep = "step@example.test"; + // An address with no route is reported on the attempt that parks the workflow + // address, not when the hold later settles. const later = "later@example.test"; - const mixedRecipients = [ - peer, - TEST_IDENTITY.workflowRunAddress, - otherStep, - later, - ]; + const mixedRecipients = [TEST_IDENTITY.workflowRunAddress, later]; function inboundAddresses(ws: { sent: string[] }, address: string): number { return framesOfType(ws, "mail.inbound").filter( @@ -2140,8 +2131,6 @@ describe("SidecarRouter workflow-trigger mail gating", () => { const ws = await connectAllocated(router, [ TEST_IDENTITY.workflowRunAddress, ]); - await router.bindAllocatedStepRoute(TEST_TARGET, peer); - await router.bindAllocatedStepRoute(TEST_TARGET, otherStep); router.handleMessage( ws, JSON.stringify({ @@ -2156,14 +2145,12 @@ describe("SidecarRouter workflow-trigger mail gating", () => { return { router, ws, undelivered }; } - test("a not-ready workflow recipient leaves its co-recipients on this attempt", async () => { + test("a not-ready workflow recipient reports an unrouted co-recipient on this attempt", async () => { let ready = false; const { router, ws, undelivered } = await sendMixedResolutionMail( () => ready, ); - expect(inboundAddresses(ws, peer)).toBe(1); - expect(inboundAddresses(ws, otherStep)).toBe(1); expect(inboundAddresses(ws, TEST_IDENTITY.workflowRunAddress)).toBe(0); expect(undelivered.map((event) => event.recipients)).toEqual([[later]]); @@ -2173,8 +2160,6 @@ describe("SidecarRouter workflow-trigger mail gating", () => { }); await tick(); - expect(inboundAddresses(ws, peer)).toBe(1); - expect(inboundAddresses(ws, otherStep)).toBe(1); expect(inboundAddresses(ws, TEST_IDENTITY.workflowRunAddress)).toBe(1); expect(undelivered.map((event) => event.recipients)).toEqual([[later]]); expect(JSON.stringify(framesOfType(ws, "run.grants"))).not.toContain( @@ -2188,8 +2173,6 @@ describe("SidecarRouter workflow-trigger mail gating", () => { () => ready, ); - expect(inboundAddresses(ws, peer)).toBe(1); - expect(inboundAddresses(ws, otherStep)).toBe(1); expect(inboundAddresses(ws, TEST_IDENTITY.workflowRunAddress)).toBe(0); expect(undelivered.map((event) => event.recipients)).toEqual([[later]]); @@ -2199,8 +2182,6 @@ describe("SidecarRouter workflow-trigger mail gating", () => { await tick(); expect(inboundAddresses(ws, TEST_IDENTITY.workflowRunAddress)).toBe(0); - expect(inboundAddresses(ws, peer)).toBe(1); - expect(inboundAddresses(ws, otherStep)).toBe(1); expect(undelivered.map((event) => event.recipients)).toEqual([[later]]); ready = true; @@ -2210,8 +2191,6 @@ describe("SidecarRouter workflow-trigger mail gating", () => { await tick(); expect(inboundAddresses(ws, TEST_IDENTITY.workflowRunAddress)).toBe(1); - expect(inboundAddresses(ws, peer)).toBe(1); - expect(inboundAddresses(ws, otherStep)).toBe(1); expect(undelivered.map((event) => event.recipients)).toEqual([[later]]); expect(JSON.stringify(framesOfType(ws, "run.grants"))).not.toContain( "recipient-key", diff --git a/packages/hub-sessions/src/ws/sidecar-handler.shared.test.ts b/packages/hub-sessions/src/ws/sidecar-handler.shared.test.ts index 068182838..b0dc1bce1 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.shared.test.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.shared.test.ts @@ -16,6 +16,7 @@ import { parsedFrames, sidecarAuth, TEST_CONFIG, + TEST_WORKFLOW, tick, undeployAck, } from "./sidecar-handler.test-helpers"; @@ -75,7 +76,6 @@ function createSharedRouter( sidecarId === SIDECAR ? hosted.bindings : [], ), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, ...config, lookups: { @@ -1533,6 +1533,7 @@ describe("SidecarRouter allocation cleanup", () => { target(cleanup), first.workflowRunAddress, configFor(first), + TEST_WORKFLOW, ), ).rejects.toThrow("does not permit"); @@ -1723,25 +1724,11 @@ describe("SidecarRouter shared sidecars", () => { "user@tenant.example", "message-1", ), - () => - router.sendPackToAllocation( - target(first), - address, - new Uint8Array(), - "refs/heads/main", - "0".repeat(40), - ), - () => - router.sendProvisionStepToAllocation( - target(first), - address, - TEST_CONFIG, - ), ]; for (const attempt of attempts) { await expect(attempt()).rejects.toThrow( - new RegExp(`${address} is not (routed on|bound to) allocation alloc-a`), + new RegExp(`${address} is not routed on allocation alloc-a`), ); } expect(ws.sent.length).toBe(sentBefore); @@ -1958,6 +1945,7 @@ describe("SidecarRouter shared sidecars", () => { target(adopted), adopted.workflowRunAddress, configFor(adopted), + TEST_WORKFLOW, ); await ws.awaitSent((sent) => sent.some((raw) => raw.includes('"agent.deploy"')), @@ -2212,12 +2200,14 @@ describe("SidecarRouter shared sidecars", () => { target(first), first.workflowRunAddress, configFor(first), + TEST_WORKFLOW, ) .catch((error: unknown) => error); const secondDeploy = router.sendAgentDeployToAllocation( target(second), second.workflowRunAddress, configFor(second), + TEST_WORKFLOW, ); await ws.awaitSent( (sent) => @@ -2321,6 +2311,7 @@ describe("SidecarRouter shared sidecars", () => { target(first), first.workflowRunAddress, configFor(first), + TEST_WORKFLOW, ) .catch((error: unknown) => error); expect(await deployed).toBeInstanceOf(Error); @@ -2751,6 +2742,7 @@ describe("SidecarRouter deploy replies on a shared sidecar", () => { target(first), first.workflowRunAddress, configFor(first), + TEST_WORKFLOW, ) .catch((error: unknown) => error); await ws.awaitSent(() => deployFrames(ws) === 1); @@ -2776,6 +2768,7 @@ describe("SidecarRouter deploy replies on a shared sidecar", () => { target(next), next.workflowRunAddress, configFor(next), + TEST_WORKFLOW, ) .finally(() => { settled = true; @@ -2806,6 +2799,7 @@ describe("SidecarRouter deploy replies on a shared sidecar", () => { target(first), first.workflowRunAddress, configFor(first), + TEST_WORKFLOW, ) .catch((error: unknown) => error); expect(unsent).toMatchObject({ frameSent: false }); @@ -2815,6 +2809,7 @@ describe("SidecarRouter deploy replies on a shared sidecar", () => { target(first), first.workflowRunAddress, configFor(first), + TEST_WORKFLOW, ); await ws.awaitSent(() => deployFrames(ws) === 1); router.handleMessage( @@ -2899,6 +2894,7 @@ describe("SidecarRouter replacing a generation on the same sidecar", () => { target(first), first.workflowRunAddress, configFor(first), + TEST_WORKFLOW, ); await tick(); expect(framesOfType(ws, "agent.deploy")).toEqual([]); @@ -2932,6 +2928,7 @@ describe("SidecarRouter replacing a generation on the same sidecar", () => { target(first), first.workflowRunAddress, configFor(first), + TEST_WORKFLOW, ) .catch((error: unknown) => error); @@ -2959,6 +2956,7 @@ describe("SidecarRouter replacing a generation on the same sidecar", () => { target(first), first.workflowRunAddress, configFor(first), + TEST_WORKFLOW, ) .catch((error: unknown) => error); await ws.awaitSent((sent) => @@ -2983,6 +2981,7 @@ describe("SidecarRouter replacing a generation on the same sidecar", () => { target(next), next.workflowRunAddress, configFor(next), + TEST_WORKFLOW, ) .finally(() => { settled = true; @@ -3030,6 +3029,7 @@ describe("SidecarRouter replacing a generation on the same sidecar", () => { target(next), next.workflowRunAddress, configFor(next), + TEST_WORKFLOW, ); router.handleMessage(ws, undeployAck(ws, first.workflowRunAddress)); await ws.awaitSent((sent) => diff --git a/packages/hub-sessions/src/ws/sidecar-handler.test-helpers.ts b/packages/hub-sessions/src/ws/sidecar-handler.test-helpers.ts index d807ef32e..9f187425a 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.test-helpers.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.test-helpers.ts @@ -1,5 +1,5 @@ import type { HarnessConfig } from "@intx/types/runtime"; -import type { HostedIncarnation } from "@intx/types/sidecar"; +import type { AgentDeployFrame, HostedIncarnation } from "@intx/types/sidecar"; import type { SidecarCredentials } from "../sidecar-allocation/contracts"; @@ -50,13 +50,7 @@ export const TEST_TARGET: AllocatedSidecarTarget = { export const TEST_CONFIG: HarnessConfig = { sessionId: "ses-router-test", - agentId: "workflow", - tenantId: TEST_IDENTITY.tenantId, - principalId: "principal-1", agentAddress: TEST_IDENTITY.workflowRunAddress, - systemPrompt: "test", - tools: [], - grants: [], sources: [ { id: "test-source", @@ -69,6 +63,14 @@ export const TEST_CONFIG: HarnessConfig = { defaultSource: "test-source", }; +export const TEST_WORKFLOW: AgentDeployFrame["workflow"] = { + sources: {}, + sourceRef: { + source: { kind: "registry", registry: "npmjs" }, + closure: { schemaVersion: "1", topLevel: [], entries: [] }, + }, +}; + export function createMockWs(): WsHandle & { sent: string[]; closed: boolean; @@ -123,7 +125,6 @@ export function createAllocatedRouter( authenticateSidecar: async () => TEST_CREDENTIALS, validateSidecarIdentity: async () => true, resolveSidecarBindings: async () => [TEST_IDENTITY], - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, ...config, lookups: { diff --git a/packages/hub-sessions/src/ws/sidecar-handler.test.ts b/packages/hub-sessions/src/ws/sidecar-handler.test.ts index 6cc01941b..1859c7764 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.test.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.test.ts @@ -27,6 +27,7 @@ import { helloFrame, liveIncarnations, sidecarAuth, + TEST_WORKFLOW, } from "./sidecar-handler.test-helpers"; const identity: Extract = { @@ -42,13 +43,7 @@ const identity: Extract = { const target = { allocationId: identity.allocationId, generation: 1 }; const config = { sessionId: "ses-exclusive", - agentId: "workflow", - tenantId: identity.tenantId, - principalId: "principal-1", agentAddress: identity.workflowRunAddress, - systemPrompt: "test", - tools: [], - grants: [], sources: [ { id: "anthropic:test", @@ -94,7 +89,6 @@ function createAllocatedRouter( withExecutableWorkflowRun: async (_target, send) => send(), ...sidecarAuth(() => [resolved]), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, mailAckRetryIntervalMs: 10_000, }); @@ -109,7 +103,6 @@ function createSenderKeyRouter( withExecutableWorkflowRun: async (_target, send) => send(), ...sidecarAuth(() => [identity]), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, lookups: { resolveSenderKeyStrict }, }); @@ -249,7 +242,6 @@ describe("SidecarRouter allocation routing", () => { withExecutableWorkflowRun: async (_target, send) => send(), ...sidecarAuth(() => [{ ...identity, workflowRunAddress: runAddress }]), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, lookups: { resyncCredentials: (addr) => resynced.push(addr), @@ -268,7 +260,6 @@ describe("SidecarRouter allocation routing", () => { withExecutableWorkflowRun: async (_target, send) => send(), ...sidecarAuth(() => [{ ...identity, workflowRunAddress: runAddress }]), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, lookups: { resyncCredentials: () => { @@ -497,6 +488,7 @@ describe("SidecarRouter allocation routing", () => { target, identity.workflowRunAddress, config, + TEST_WORKFLOW, ); await tick(); expect(lastFrame(ws).type).toBe("agent.deploy"); @@ -509,6 +501,7 @@ describe("SidecarRouter allocation routing", () => { { ...target, generation: 2 }, identity.workflowRunAddress, config, + TEST_WORKFLOW, ), ).rejects.toThrow("is not current"); }); @@ -590,6 +583,7 @@ describe("SidecarRouter allocation routing", () => { target, identity.workflowRunAddress, config, + TEST_WORKFLOW, ); await tick(); router.handleMessage(ws, deployReply(ws, { publicKey: "b".repeat(64) })); @@ -709,7 +703,6 @@ describe("SidecarRouter current grants on signals and triggers", () => { withExecutableWorkflowRun: async (_target, send) => send(), ...sidecarAuth(() => [runIdentity]), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, lookups, }); @@ -1137,7 +1130,6 @@ describe("SidecarRouter current grants on signals and triggers", () => { withExecutableWorkflowRun: async (_target, send) => send(), ...sidecarAuth(() => [routed]), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, lookups: { loadRunGrants: async () => { @@ -1217,7 +1209,6 @@ describe("SidecarRouter current grants on signals and triggers", () => { }, ...sidecarAuth(() => [runIdentity]), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, lookups: { loadRunGrants: async () => stepGrants }, }); @@ -1413,7 +1404,6 @@ describe("SidecarRouter pre-ack sender-key interlock", () => { return binding === undefined ? [] : [binding]; }), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, mailAckRetryIntervalMs: 10_000, // A wide TTL: any delivery the test observes came from a settle, not from diff --git a/packages/hub-sessions/src/ws/sidecar-handler.transport.test.ts b/packages/hub-sessions/src/ws/sidecar-handler.transport.test.ts index cff4ee8c3..b236c5469 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.transport.test.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.transport.test.ts @@ -15,6 +15,7 @@ import { parsedFrames, sidecarAuth, TEST_CONFIG, + TEST_WORKFLOW, TEST_IDENTITY, TEST_TARGET, tick, @@ -64,7 +65,7 @@ describe("SidecarRouter allocation initialization cancellation", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, - undefined, + TEST_WORKFLOW, controller.signal, ) : router.sendWorkflowRunPackToAllocation( @@ -121,6 +122,7 @@ describe("SidecarRouter allocation initialization cancellation", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ) .catch((cause: unknown) => cause); expect(error).toMatchObject({ frameSent: true, cause: failure }); @@ -148,6 +150,7 @@ describe("SidecarRouter allocation initialization cancellation", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ) .catch((cause: unknown) => cause); try { @@ -183,7 +186,7 @@ describe("SidecarRouter allocation initialization cancellation", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, - undefined, + TEST_WORKFLOW, controller.signal, async () => { entered.resolve(true); @@ -225,7 +228,7 @@ describe("SidecarRouter allocation initialization cancellation", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, - undefined, + TEST_WORKFLOW, controller.signal, ) : router.sendWorkflowRunPackToAllocation( @@ -340,30 +343,6 @@ describe("SidecarRouter dispatch cancellation", () => { }); describe("SidecarRouter allocation deploy transport", () => { - test("rejects deploys without a Hub signing key before mutating routing", async () => { - const router = createSidecarRouter({ - withExecutableWorkflowRun: async (_target, send) => send(), - ...sidecarAuth(() => [TEST_IDENTITY]), - validateSidecarIdentity: async () => true, - }); - router.fenceAllocation(TEST_TARGET.allocationId, TEST_TARGET.generation); - await connectAllocated(router); - - const error = await router - .sendAgentDeployToAllocation( - TEST_TARGET, - TEST_IDENTITY.workflowRunAddress, - TEST_CONFIG, - ) - .catch((cause: unknown) => cause); - - expect(isDeployFrameFailure(error)).toBe(true); - if (!isDeployFrameFailure(error)) - throw new Error("Expected deploy failure"); - expect(error.frameSent).toBe(false); - expect(router.getRoutableAddresses()).toEqual([]); - }); - test("times out an unacknowledged deploy and removes its route", async () => { const router = createAllocatedRouter({ requestTimeoutMs: 20 }); await connectAllocated(router); @@ -373,6 +352,7 @@ describe("SidecarRouter allocation deploy transport", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ) .catch((cause: unknown) => cause); @@ -396,6 +376,7 @@ describe("SidecarRouter allocation deploy transport", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ) .catch((cause: unknown) => cause); @@ -413,6 +394,7 @@ describe("SidecarRouter allocation deploy transport", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ); await tick(); @@ -430,6 +412,7 @@ describe("SidecarRouter allocation deploy transport", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ) .catch(() => undefined); await tick(); @@ -449,6 +432,7 @@ describe("SidecarRouter allocation deploy transport", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ); let settled = false; const result = deploy.catch((error: unknown) => { @@ -502,6 +486,7 @@ describe("SidecarRouter allocation deploy transport", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ); let settled = false; void deploy @@ -541,6 +526,7 @@ describe("SidecarRouter allocation deploy transport", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ); await tick(); @@ -557,6 +543,7 @@ describe("SidecarRouter allocation deploy transport", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ); await tick(); @@ -579,7 +566,6 @@ describe("SidecarRouter allocation deploy transport", () => { sidecarId === secondary.sidecarId ? secondary : TEST_IDENTITY, ]), validateSidecarIdentity: async () => true, - hubPublicKey: "a".repeat(64), requestTimeoutMs: 500, }); router.fenceAllocation(TEST_TARGET.allocationId, TEST_TARGET.generation); @@ -603,6 +589,7 @@ describe("SidecarRouter allocation deploy transport", () => { TEST_TARGET, TEST_IDENTITY.workflowRunAddress, TEST_CONFIG, + TEST_WORKFLOW, ); let settled = false; void deploy.finally(() => { diff --git a/packages/hub-sessions/src/ws/sidecar-handler.ts b/packages/hub-sessions/src/ws/sidecar-handler.ts index 6f3710828..dfbc83d16 100644 --- a/packages/hub-sessions/src/ws/sidecar-handler.ts +++ b/packages/hub-sessions/src/ws/sidecar-handler.ts @@ -52,7 +52,7 @@ import { } from "@intx/types/sidecar"; import { RepoId } from "@intx/types/repo"; import type { CredentialDelivery } from "@intx/types/credential-delivery"; -import type { ConnectorThreadState, HarnessConfig } from "@intx/types/runtime"; +import type { ConnectorThreadState } from "@intx/types/runtime"; import type { SidecarCredentialIdentity, SidecarCredentials, @@ -218,10 +218,10 @@ export type SidecarConnection = { * keeps its own generation fence. */ bindings: Map; - // Allocated deployment routes (including first deploy and reconnect) and - // transient step routes, each mapped to the allocation that owns it, so one - // allocation's routes can leave a connection that stays open for the - // others. `handleClose` cleans them out of `addressIndex`. + // Allocated deployment routes (including first deploy and reconnect), each + // mapped to the allocation that owns it, so one allocation's routes can + // leave a connection that stays open for the others. `handleClose` cleans + // them out of `addressIndex`. workflowAddresses: Map; // Deploy frames the worker has not answered, by request id to their // address, kept past the Hub's own deploy timeout. The worker handles an @@ -286,8 +286,7 @@ function namesRoutedIncarnation( /** * Bind pack writes to the repository implied by the authenticated * incarnation: an allocation may only write its own deployment's workflow-run - * repository, never a standalone agent-state repository, and only as the - * generation this connection hosts. It authorizes the write even when the + * repository, and only as the generation this connection hosts. It authorizes the write even when the * address is not routed: a stopped worker is not routed, but its stop is * confirmed only once its remaining history reaches the Hub. */ @@ -311,25 +310,6 @@ function allocationBindings( ); } -export type SendPackOptions = { - /** - * Repo-relative mount path under the sidecar's per-agent workspace. - * When set, the receiving sidecar materializes the pack as plain - * files at `//` and does NOT apply it to - * the agent's deploy git tree. Absent for agent-state deploy/state - * packs, which continue to apply to the deploy tree. - */ - mountPath?: string; - /** - * Override the `repoId` emitted on the wire. The agent-state flow - * defaults to `{ kind: "agent-state", id: agentAddress }`; asset - * packs must pass the SOURCE asset's id so audit can correlate the - * pack back to its hub-side origin. Workflow-run restoration uses a - * dedicated allocation-bound sender that supplies its derived repo id. - */ - repoId?: RepoId; -}; - /** * Everything a `sendProbe` caller supplies to populate the outbound * `workflow.probe.request` frame: where the definition's bytes come from, the @@ -641,19 +621,11 @@ export type SidecarAllocationRouter = { sendAgentDeployToAllocation( target: AllocatedSidecarTarget, agentAddress: string, - config: HarnessConfig, - workflow?: AgentDeployFrame["workflow"], + config: AgentDeployFrame["config"], + workflow: AgentDeployFrame["workflow"], signal?: AbortSignal, beforeSend?: () => Promise, ): Promise<{ publicKey: string }>; - sendPackToAllocation( - target: AllocatedSidecarTarget, - agentAddress: string, - pack: Uint8Array, - ref: string, - commitSha: string, - options?: SendPackOptions, - ): Promise; /** * Restore one Hub-authoritative workflow-run ref onto the exact allocation * generation before its deployment address is routed or supervisor spawned. @@ -666,19 +638,6 @@ export type SidecarAllocationRouter = { commitSha: string, signal?: AbortSignal, ): Promise; - bindAllocatedStepRoute( - target: AllocatedSidecarTarget, - stepAddress: string, - ): Promise; - unbindAllocatedStepRoute( - target: AllocatedSidecarTarget, - stepAddress: string, - ): void; - sendProvisionStepToAllocation( - target: AllocatedSidecarTarget, - agentAddress: string, - config: HarnessConfig, - ): Promise; /** * Deliver one durable workflow trigger to the exact allocation generation. * Grants and mail are written to the same websocket in FIFO order. The @@ -729,9 +688,6 @@ export type SidecarAuthenticator = (claim: { export type SidecarRouterConfig = { requestTimeoutMs?: number; - /** Hex-encoded 32-byte Ed25519 public key for signing deploy commits. - * Included in agent.deploy frames so sidecars can verify pack signatures. */ - hubPublicKey?: string; /** Resolves each hello handshake to a verified sidecar * identity. Required: without it a connection could route on an * unverified frame claim. Return null to reject the handshake. */ @@ -841,7 +797,6 @@ export function createSidecarRouter( const { requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS, - hubPublicKey: hubPublicKeyHex, authenticateSidecar, validateSidecarIdentity, withExecutableWorkflowRun, @@ -1184,14 +1139,7 @@ export function createSidecarRouter( string >(); - // Receives agent-state packs pushed from sidecars. The wire frames - // (`repo.pack.push` / `repo.pack.done`) are shared with the - // workflow-run flow; dispatch on `repoId.kind` picks which receiver - // observes the chunks. The two receivers maintain independent - // in-flight pack state and independent cancel-by-agent semantics so a - // pending workflow-run transfer cannot disturb a concurrent agent- - // state transfer for the same agent and vice versa. - const agentStatePackReceiver = createPackReceiver(); + // Receives workflow-run packs pushed from sidecars. const workflowRunPackReceiver = createPackReceiver(); let requestCounter = 0; @@ -2328,7 +2276,6 @@ export function createSidecarRouter( connectorStates.delete(address); retainPendingMailForAddress(address); } - agentStatePackReceiver.cancelByAgent(address); workflowRunPackReceiver.cancelByAgent(address); } const deploymentAddress = @@ -3937,14 +3884,9 @@ export function createSidecarRouter( `Sidecar ${conn.sidecarId} disconnected`, ); - // Cancel any in-flight inbound pack transfers from this sidecar - // across both receivers. The two receivers track their own in- - // flight transferIds, so a pending workflow-run transfer for an - // agent that just disconnected won't outlive the connection just - // because the agent-state receiver has nothing to cancel. + // Cancel any in-flight inbound pack transfers from this sidecar. const owned = new Set(conn.workflowAddresses.keys()); for (const addr of owned) { - agentStatePackReceiver.cancelByAgent(addr); workflowRunPackReceiver.cancelByAgent(addr); } // An own-repository transfer needs no route, so the owned addresses above @@ -4126,41 +4068,6 @@ export function createSidecarRouter( pendingProbes.reject(requestId, error); } - // Routing rule: pick the receiver dedicated to the repoId.kind the - // frame carries. The receivers' in-flight state is independent, so a - // workflow-run transferId can never collide with or evict an - // agent-state transferId for the same agentAddress. - function pickPackReceiver( - repoId: RepoId, - ): { receiver: ReturnType } | null { - switch (repoId.kind) { - case "agent-state": - return { receiver: agentStatePackReceiver }; - case "workflow-run": - return { receiver: workflowRunPackReceiver }; - // The remaining kinds in `RepoKind` (`skill`, `package-registry`, - // `workflow`) have no sidecar->hub pack flow today. A frame - // arriving with those kinds is malformed at this layer. - default: - return null; - } - } - - function pickReceivePackLookup( - repoId: RepoId, - ): SidecarLookups["receiveWorkflowRunPack"] | undefined { - switch (repoId.kind) { - case "agent-state": - // The agent-state lookup ignores the `source` argument the workflow-run - // lookup takes; the two are otherwise the same contract. - return lookups.receiveAgentStatePack; - case "workflow-run": - return lookups.receiveWorkflowRunPack; - default: - return undefined; - } - } - // The generation whose workflow stop the Hub confirmed, when the pack is // for its workflow-run repository. That history is final. function stoppedPackSender( @@ -4183,7 +4090,9 @@ export function createSidecarRouter( ): void { const conn = connections.get(ws); if (conn === undefined) return; - pickPackReceiver(done.repoId)?.receiver.cancel(done.transferId); + if (done.repoId.kind === "workflow-run") { + workflowRunPackReceiver.cancel(done.transferId); + } conn.send({ type: "repo.pack.reject", agentAddress: done.agentAddress, @@ -4204,20 +4113,7 @@ export function createSidecarRouter( ) return; - const picked = pickPackReceiver(frame.repoId); - if (picked === null) { - logger.warn`Received repo.pack.push with unsupported repoId.kind ${frame.repoId.kind}`; - conn.send({ - type: "repo.pack.reject", - agentAddress: frame.agentAddress, - repoId: frame.repoId, - transferId: frame.transferId, - reason: "corrupt", - }); - return; - } - - const reason = picked.receiver.handlePush(frame); + const reason = workflowRunPackReceiver.handlePush(frame); if (reason !== null) { conn.send({ type: "repo.pack.reject", @@ -4262,20 +4158,7 @@ export function createSidecarRouter( return; } - const picked = pickPackReceiver(frame.repoId); - if (picked === null) { - logger.warn`Received repo.pack.done with unsupported repoId.kind ${frame.repoId.kind}`; - conn.send({ - type: "repo.pack.reject", - agentAddress: frame.agentAddress, - repoId: frame.repoId, - transferId: frame.transferId, - reason: "corrupt", - }); - return; - } - - const result = picked.receiver.handleDone(frame); + const result = workflowRunPackReceiver.handleDone(frame); if (result === null) { conn.send({ type: "repo.pack.reject", @@ -4287,7 +4170,7 @@ export function createSidecarRouter( return; } - const receivePackLookup = pickReceivePackLookup(frame.repoId); + const receivePackLookup = lookups.receiveWorkflowRunPack; if (receivePackLookup === undefined) { conn.send({ type: "repo.pack.ack", @@ -4343,19 +4226,6 @@ export function createSidecarRouter( } } - /** - * Bind a per-step workflow-substrate address to a sidecar for the staging - * window of a multi-step deploy, so `sendPack` can route the step's deploy - * and asset packs before the deployment-level frame spawns the child. - * - * The address is Hub-minted and workflow-derived, so it enters the - * `workflowAddresses` set and is - * torn down by `unbindStepRoute` once the - * step's packs land. `handleClose` reclaims it if the sidecar drops - * mid-stage. Per-step addresses are not runtime-routed (mail, signals, and - * drains use the deployment address), so the binding is transient: no - * `hello` reports it and no reconnect restores it. - */ function fenceAllocation( allocationId: string, generation: number, @@ -4858,46 +4728,6 @@ export function createSidecarRouter( }); } - async function bindAllocatedStepRoute( - target: AllocatedSidecarTarget, - stepAddress: string, - ): Promise { - const { ws, conn } = await getAllocatedConnection(target, "routing"); - const existing = addressIndex.get(stepAddress); - if (existing !== undefined && existing !== ws) { - throw new Error( - `Workflow step ${stepAddress} is already routed to another sidecar`, - ); - } - const owner = conn.workflowAddresses.get(stepAddress); - if (owner !== undefined && owner !== target.allocationId) { - throw new Error( - `Workflow step ${stepAddress} is already routed to allocation ${owner}`, - ); - } - conn.workflowAddresses.set(stepAddress, target.allocationId); - addressIndex.set(stepAddress, ws); - } - - function unbindAllocatedStepRoute( - target: AllocatedSidecarTarget, - stepAddress: string, - ): void { - const current = allocatedConnections.get(target.allocationId); - if ( - current === undefined || - current.identity.generation !== target.generation - ) { - return; - } - if (addressIndex.get(stepAddress) !== current.ws) return; - const conn = connections.get(current.ws); - if (conn?.workflowAddresses.get(stepAddress) !== target.allocationId) - return; - conn.workflowAddresses.delete(stepAddress); - addressIndex.delete(stepAddress); - } - // Pack transfers may take longer than session requests due to data volume. const PACK_TIMEOUT_MS = requestTimeoutMs * 4; @@ -4909,18 +4739,9 @@ export function createSidecarRouter( pack: Uint8Array, ref: string, commitSha: string, - options?: SendPackOptions, + repoId: RepoId, ): Promise { const transferId = `pack-${crypto.randomUUID()}`; - // For the agent-state flow the destination agent and the source repo - // are the same entity, so `repoId.id === agentAddress`. Asset packs - // override this with the SOURCE asset's id so audit can correlate - // the pack back to its hub-side origin. - const repoId: RepoId = options?.repoId ?? { - kind: "agent-state", - id: agentAddress, - }; - const mountPath = options?.mountPath; // Register pending entry before sending frames so that a synchronous // repo.pack.ack (e.g. in tests or loopback transports) resolves correctly. @@ -4961,48 +4782,10 @@ export function createSidecarRouter( transferId, ref, commitSha, - ...(mountPath !== undefined ? { mountPath } : {}), }); }); } - async function sendPackToAllocation( - target: AllocatedSidecarTarget, - agentAddress: string, - pack: Uint8Array, - ref: string, - commitSha: string, - options?: SendPackOptions, - ): Promise { - const { ws, conn } = await getAllocatedConnection(target, "routing"); - if ( - addressIndex.get(agentAddress) !== ws || - conn.workflowAddresses.get(agentAddress) !== target.allocationId - ) { - throw new Error( - `Address ${agentAddress} is not routed on allocation ${target.allocationId}`, - ); - } - let transfer: Promise | undefined; - await withWorkflowWorkAdmission(ws, conn, agentAddress, () => { - transfer = sendPackOnConnection( - ws, - conn, - agentAddress, - target.generation, - pack, - ref, - commitSha, - options, - ); - // The acknowledgement can reject before admission's transaction ends. - void transfer.catch(() => undefined); - return true; - }); - if (transfer === undefined) throw new Error("Workflow pack was not sent"); - return transfer; - } - async function sendWorkflowRunPackToAllocation( target: AllocatedSidecarTarget, agentAddress: string, @@ -5040,7 +4823,7 @@ export function createSidecarRouter( pack, ref, commitSha, - { repoId: workflowRunRepoIdForAddress(agentAddress) }, + workflowRunRepoIdForAddress(agentAddress), ); // The acknowledgement can reject before admission's transaction ends. void transfer.catch(() => undefined); @@ -5341,16 +5124,9 @@ export function createSidecarRouter( conn: SidecarConnection, target: AllocatedSidecarTarget, agentAddress: string, - harnessConfig: HarnessConfig, - workflow?: AgentDeployFrame["workflow"], + config: AgentDeployFrame["config"], + workflow: AgentDeployFrame["workflow"], ): Promise<{ publicKey: string }> { - if (hubPublicKeyHex === undefined) { - throw deployFrameFailure( - "Hub signing key is required for agent deployment", - false, - ); - } - if (pendingDeploys.has(agentAddress)) { throw deployFrameFailure( `Deploy already in progress for agent "${agentAddress}"`, @@ -5395,10 +5171,8 @@ export function createSidecarRouter( requestId, agentAddress, generation: target.generation, - agentId: harnessConfig.agentId, - config: harnessConfig, - hubPublicKey: hubPublicKeyHex, - ...(workflow !== undefined ? { workflow } : {}), + config: { sessionId: config.sessionId }, + workflow, }); conn.unansweredDeploys.set(requestId, agentAddress); } catch (cause) { @@ -5421,8 +5195,8 @@ export function createSidecarRouter( async function sendAgentDeployToAllocation( target: AllocatedSidecarTarget, agentAddress: string, - harnessConfig: HarnessConfig, - workflow?: AgentDeployFrame["workflow"], + config: AgentDeployFrame["config"], + workflow: AgentDeployFrame["workflow"], signal?: AbortSignal, beforeSend?: () => Promise, ): Promise<{ publicKey: string }> { @@ -5445,8 +5219,6 @@ export function createSidecarRouter( `Deployment ${agentAddress} is already routed to another sidecar`, ); } - if (hubPublicKeyHex === undefined) - throw new Error("Hub signing key is required for agent deployment"); if (pendingDeploys.has(agentAddress)) throw new Error( `Deploy already in progress for agent "${agentAddress}"`, @@ -5466,7 +5238,7 @@ export function createSidecarRouter( conn, target, agentAddress, - harnessConfig, + config, workflow, ); // Own an early ack rejection while the admission transaction ends. @@ -5489,103 +5261,6 @@ export function createSidecarRouter( return response; } - /** - * Provision one step of a multi-step deploy on the sidecar WITHOUT - * spawning: the sidecar initializes the step's agent-state repo and - * records the hub key, so the follow-up deploy pack applies into a repo - * and verifies against the recorded key -- but no supervisor or child is - * constructed. The deployment-level workflow frame, sent once after every - * step is provisioned, spawns the child. - * - * The step address must already be bound via `bindStepRoute`, which - * resolves and records the sidecar; this reuses that route. Waits for the - * sidecar's `agent.deploy.ack` - * so the caller can safely deliver the deploy pack afterward. On failure - * the caller owns tearing the route down via `unbindStepRoute`. - */ - function sendProvisionStepOnConnection( - ws: WsHandle, - conn: SidecarConnection, - generation: number, - agentAddress: string, - harnessConfig: HarnessConfig, - ): Promise { - if (hubPublicKeyHex === undefined) { - throw new Error("Hub signing key is required for step provisioning"); - } - if (pendingDeploys.has(agentAddress)) { - throw new Error(`Deploy already in progress for agent "${agentAddress}"`); - } - - const hubKey = hubPublicKeyHex; - const requestId = nextRequestId(); - return new Promise((resolve, reject) => { - // The sidecar's `agent.deploy.ack` resolves this through - // `pendingDeploys.resolve`. The per-step address is workflow-derived - // and records no hub-side key, so the ack's public key is not needed - // and this resolves void. - pendingDeploys.register( - agentAddress, - ws, - { - timeoutMs: requestTimeoutMs, - timeoutMessage: `Step provision of "${agentAddress}" timed out after ${requestTimeoutMs}ms`, - resolve(_publicKey) { - resolve(); - }, - reject(error) { - reject(typeof error === "string" ? new Error(error) : error); - }, - }, - { requestId, generation }, - ); - - conn.send({ - type: "agent.deploy", - requestId, - agentAddress, - generation, - agentId: harnessConfig.agentId, - config: harnessConfig, - hubPublicKey: hubKey, - provisionStep: true, - }); - conn.unansweredDeploys.set(requestId, agentAddress); - }); - } - - async function sendProvisionStepToAllocation( - target: AllocatedSidecarTarget, - agentAddress: string, - harnessConfig: HarnessConfig, - ): Promise { - const { ws, conn } = await getAllocatedConnection(target, "routing"); - if ( - addressIndex.get(agentAddress) !== ws || - conn.workflowAddresses.get(agentAddress) !== target.allocationId - ) { - throw new Error( - `Step route ${agentAddress} is not bound to allocation ${target.allocationId}`, - ); - } - let provisioned: Promise | undefined; - await withWorkflowWorkAdmission(ws, conn, agentAddress, () => { - provisioned = sendProvisionStepOnConnection( - ws, - conn, - target.generation, - agentAddress, - harnessConfig, - ); - // The acknowledgement can reject before admission's transaction ends. - void provisioned.catch(() => undefined); - return true; - }); - if (provisioned === undefined) - throw new Error("Workflow step was not provisioned"); - return provisioned; - } - function sendProbeOnConnection( ws: WsHandle, conn: SidecarConnection, @@ -6386,7 +6061,6 @@ export function createSidecarRouter( waitForSidecarInventory, sendCredentialsUpdate, sendRunGrants, - sendPackToAllocation, sendWorkflowRunPackToAllocation, fenceAllocation, retireAllocation, @@ -6397,9 +6071,6 @@ export function createSidecarRouter( reportedDeploymentFailure, stoppedDeploymentHistory, sendAgentDeployToAllocation, - bindAllocatedStepRoute, - unbindAllocatedStepRoute, - sendProvisionStepToAllocation, sendWorkflowRunDispatchToAllocation, sendSignalDeliver, sendSignalDeliverToAllocation, diff --git a/packages/inference/README.md b/packages/inference/README.md index 6cd0e8f8b..fab521f06 100644 --- a/packages/inference/README.md +++ b/packages/inference/README.md @@ -46,5 +46,5 @@ The package is the lower half of the agent stack: it knows how to talk to model providers, how to drive a multi-turn exchange to completion, and how to compose extensions (gates, audit, correlation, authz) into the reactor pipeline. The higher-level -agent surface — config validation, tool runners, deploy trees, +agent surface — config validation, tool runners, runtime capabilities — lives in `@intx/harness`. diff --git a/packages/mime/README.md b/packages/mime/README.md index a8124247f..8ad69c839 100644 --- a/packages/mime/README.md +++ b/packages/mime/README.md @@ -5,8 +5,7 @@ messages with PGP detached signatures, parses inbound wire bytes back into structured parts, and owns the JMAP-shaped envelope the rest of the mail pipeline depends on. -Consumed by `@intx/mail-memory` (in-process transport), -`@intx/storage-isogit` (mail audit log), and `@intx/harness` +Consumed by `@intx/mail-memory` (in-process transport) and `@intx/harness` (sidecar mail-tool plumbing). ```ts diff --git a/packages/pack-transport/README.md b/packages/pack-transport/README.md index 93972cda7..372dfc4f1 100644 --- a/packages/pack-transport/README.md +++ b/packages/pack-transport/README.md @@ -6,7 +6,7 @@ reassembles the chunks on the receiver while validating sequence continuity and rejecting concurrent transfers for the same agent. Consumed by `@intx/hub-sessions` and `@intx/hub-agent` to ship -agent repository state across the WebSocket link. +workflow-run repository history across the WebSocket link. ```ts import { chunkPack, createPackReceiver } from "@intx/pack-transport"; diff --git a/packages/pack-transport/src/receiver.test.ts b/packages/pack-transport/src/receiver.test.ts index 05c57a6a4..d50223104 100644 --- a/packages/pack-transport/src/receiver.test.ts +++ b/packages/pack-transport/src/receiver.test.ts @@ -9,7 +9,7 @@ function makePush(overrides: Partial = {}): PackPushFrame { type: "repo.pack.push", agentAddress, generation: 1, - repoId: { kind: "agent-state", id: agentAddress }, + repoId: { kind: "skill", id: agentAddress }, transferId: "t1", seq: 0, data: base64Encode(new TextEncoder().encode("chunk-data")), @@ -23,7 +23,7 @@ function makeDone(overrides: Partial = {}): PackDoneFrame { type: "repo.pack.done", agentAddress, generation: 1, - repoId: { kind: "agent-state", id: agentAddress }, + repoId: { kind: "skill", id: agentAddress }, transferId: "t1", ref: "refs/heads/deploy", commitSha: "abc123", diff --git a/packages/pack-transport/src/sender.ts b/packages/pack-transport/src/sender.ts index c9888dfba..c275f7ef0 100644 --- a/packages/pack-transport/src/sender.ts +++ b/packages/pack-transport/src/sender.ts @@ -28,7 +28,7 @@ export type PackSendOpts = { */ transferId: string; pack: Uint8Array; - /** Workflow-run ref or deploy ref the receiver should advance. */ + /** Workflow-run ref the receiver should advance. */ ref: string; /** Commit SHA the receiver should pin the ref to after apply. */ commitSha: string; diff --git a/packages/storage-isogit/README.md b/packages/storage-isogit/README.md index 68161b21b..36ca15cd3 100644 --- a/packages/storage-isogit/README.md +++ b/packages/storage-isogit/README.md @@ -2,14 +2,12 @@ Isomorphic-git backed implementation of `ContextStore` and `AuditStore`. Each agent gets its own git repository in the host filesystem; -inference state lives on a working branch, the tool-authorization -audit log lives on its own branch, and mail history lives in a -dedicated audit store that commits each inbound and outbound -message. +inference state lives on a working branch, and tool-authorization audit +records live under `state/audit/`. -Consumed by `@intx/agent` for in-process persistence, and by -`@intx/hub-sessions` and `@intx/hub-agent` for the agent -repositories that move between the hub and the sidecar as packs. +Consumed by `@intx/agent` and `@intx/workflow-host` for a step's +persistence, and by `@intx/hub-sessions` and `@intx/hub-agent` for the pack +helpers that move repositories between the hub and the sidecar. ```ts import { createIsogitStore } from "@intx/storage-isogit/node"; @@ -48,9 +46,7 @@ completed mutations durable before the API call resolves. Audit and error record paths are append-only. Repeating a byte-identical record is an idempotent retry, including after an uncertain persistence -failure; reusing the same path for different content is rejected. Mail commits -reconcile an uncertain ref publication before the same store accepts another -message, so a committed message cannot have its ordinal reused. +failure; reusing the same path for different content is rejected. Conversation checkpoints stage the context files and tool-output directory in one Git index update. Tool-output files are processed sequentially; every @@ -85,11 +81,10 @@ guarantee. Use absolute repository paths; relative browser paths resolve from runtime through the package root and own that runtime's recovery guarantees. The pack-send and pack-receive helpers (`createDeployPack`, -`createNegotiatedPack`, `applyPack`, `stagePack`) produce +`createNegotiatedPack`, `stagePack`) produce and consume the wire bytes that `@intx/pack-transport` chunks -across the WebSocket. A `CommitSigner` is optional but required -when the consumer needs every commit to carry a verifiable -signature. +across the WebSocket. A `CommitSigner` is optional; when a consumer passes +one, every commit is signed. Nothing verifies those signatures at runtime. `stagePack` writes and indexes a received pack outside `objects/pack/` and returns a handle. The handle's `fs` reads the staged objects, so a receiver @@ -98,9 +93,3 @@ never enters `objects/pack/`. A crash can leave a staged pack behind, which no reader scans; it stays until a caller runs `removeStagedPacks`. The hub's repo store does this before the first receive for a repository in each process. - -Pack receivers flush an accepted pack before ref promotion. If a -later ref or persistence operation fails, the call rejects but retains the -pack because the ref update may already be observable. Callers must treat that -error as an uncertain promotion outcome rather than assuming the ref is -unchanged; whichever ref value is visible remains backed by durable objects. diff --git a/packages/storage-isogit/package.json b/packages/storage-isogit/package.json index 4c8abcf3b..88430b9f8 100644 --- a/packages/storage-isogit/package.json +++ b/packages/storage-isogit/package.json @@ -22,8 +22,6 @@ } }, "dependencies": { - "@intx/log": "workspace:*", - "@intx/mime": "workspace:*", "@intx/types": "workspace:*", "@isomorphic-git/lightning-fs": "4.7.0", "arktype": "catalog:", diff --git a/packages/storage-isogit/src/gc-cache-transparency.test.ts b/packages/storage-isogit/src/gc-cache-transparency.test.ts deleted file mode 100644 index d1c9c5f63..000000000 --- a/packages/storage-isogit/src/gc-cache-transparency.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -// The store threads one long-lived isomorphic-git `cache` object per repo -// through every git.* call. GC repacks the object store — consolidating -// loose objects into a fresh, differently-named pack and pruning the old -// one — behind that cache's back. These tests pin that the cache stays a -// pure accelerator across a repack: an object that migrates from a pruned -// pack into the new one still reads correctly under the SAME warm cache -// (because reads enumerate .idx files from disk, so a stranded parse of a -// pruned pack is never consulted), and an object GC genuinely drops -// surfaces NotFound rather than stale bytes. - -import { describe, test, expect, afterEach } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import git from "isomorphic-git"; -import { initAgentRepo } from "./node"; -import { runGC } from "./node"; - -const author = { name: "Test", email: "test@test.dev" }; -const tempDirs: string[] = []; - -async function tempDir(): Promise { - const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gc-cache-")); - 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 })), - ); -}); - -function listIdx(dir: string): string[] { - const p = path.join(dir, ".git", "objects", "pack"); - if (!fs.existsSync(p)) return []; - return fs.readdirSync(p).filter((x) => x.endsWith(".idx")); -} - -function countLoose(dir: string): number { - const base = path.join(dir, ".git", "objects"); - if (!fs.existsSync(base)) return 0; - let n = 0; - for (const d of fs.readdirSync(base)) { - if (!/^[0-9a-f]{2}$/.test(d)) continue; - n += fs.readdirSync(path.join(base, d)).length; - } - return n; -} - -async function commitFile( - dir: string, - name: string, - content: string, -): Promise { - const full = path.join(dir, name); - await fs.promises.mkdir(path.dirname(full), { recursive: true }); - await fs.promises.writeFile(full, content); - await git.add({ fs, dir, filepath: name }); - return git.commit({ fs, dir, message: `add ${name}`, author }); -} - -async function commitFileOnRef( - dir: string, - branch: string, - name: string, - content: string, -): Promise { - await git.checkout({ fs, dir, ref: branch }); - const oid = await commitFile(dir, name, content); - await git.checkout({ fs, dir, ref: "main" }); - return oid; -} - -function packfileCacheKeys(cache: object): string[] { - const packSym = Object.getOwnPropertySymbols(cache).find( - (s) => s.toString() === "Symbol(PackfileCache)", - ); - if (packSym === undefined) return []; - const map: unknown = Reflect.get(cache, packSym); - if (!(map instanceof Map)) return []; - const keys: string[] = []; - for (const k of map.keys()) { - if (typeof k === "string") keys.push(k); - } - return keys; -} - -describe("shared-cache repack transparency", () => { - test("object in a pre-GC pack reads correctly after GC prunes that pack, under the SAME warm cache", async () => { - const dir = await tempDir(); - await initAgentRepo(dir); - - const targetContent = "the-target-blob-payload-v1"; - let tip = await commitFile(dir, "state/target.txt", targetContent); - const targetOid = await git.writeBlob({ - fs, - dir, - blob: Buffer.from(targetContent), - }); - for (let i = 0; i < 5; i++) { - tip = await commitFile(dir, `state/f${i}.txt`, `payload ${i}`); - } - - // First GC consolidates all loose objects into pack #1 and prunes the - // loose copies. The target now lives only in pack #1. - await runGC(dir, { retention: "keep-history" }); - const idxBefore = listIdx(dir); - const firstIdx = idxBefore[0]; - if (firstIdx === undefined) throw new Error("expected a pack .idx"); - expect(countLoose(dir)).toBe(0); - - // One shared, long-lived cache object — the exact shape the store - // threads through every git.* call. - const cache: object = {}; - - // Warm the cache: read the target from the pre-GC pack, populating - // isomorphic-git's PackfileCache keyed by the pre-GC .idx filename. - const warm = await git.readBlob({ fs, dir, oid: targetOid, cache }); - expect(Buffer.from(warm.blob).toString()).toBe(targetContent); - expect(packfileCacheKeys(cache).some((k) => k.includes(firstIdx))).toBe( - true, - ); - - // More commits, then a second GC: repacks into a new pack #2 that - // supersedes and prunes pack #1. The target migrates to pack #2. - for (let i = 0; i < 4; i++) { - tip = await commitFile(dir, `state/g${i}.txt`, `more ${i}`); - } - await runGC(dir, { retention: "keep-history" }); - - const idxAfter = listIdx(dir); - // The pre-GC pack is gone from disk, but its parsed index is still - // stranded in the warm cache map. - expect(idxAfter).not.toContain(firstIdx); - expect(packfileCacheKeys(cache).some((k) => k.includes(firstIdx))).toBe( - true, - ); - - // The test: read the target again under the SAME warm cache, after the - // pack the cache indexed was pruned from disk. - const reread = await git.readBlob({ fs, dir, oid: targetOid, cache }); - expect(Buffer.from(reread.blob).toString()).toBe(targetContent); - - // Exercise the readTree/readCommit path through the same cache too. - const co = await git.readCommit({ fs, dir, oid: tip, cache }); - const tr = await git.readTree({ fs, dir, oid: co.commit.tree, cache }); - expect(tr.tree.length).toBeGreaterThan(0); - }); - - test("GC that drops a now-unreachable object surfaces NotFound, not stale bytes, under a warm cache", async () => { - const dir = await tempDir(); - await initAgentRepo(dir); - - for (let i = 0; i < 3; i++) { - await commitFile(dir, `state/f${i}.txt`, `keep ${i}`); - } - - // A blob reachable only from a secondary ref. - const orphanContent = "orphan-only-in-old-pack"; - const orphanOid = await git.writeBlob({ - fs, - dir, - blob: Buffer.from(orphanContent), - }); - await git.branch({ fs, dir, ref: "scratch" }); - await commitFileOnRef(dir, "scratch", "state/orphan.txt", orphanContent); - - // Pack everything reachable into pack #1. - await runGC(dir, { retention: "keep-history" }); - expect(countLoose(dir)).toBe(0); - - const cache: object = {}; - const warm = await git.readBlob({ fs, dir, oid: orphanOid, cache }); - expect(Buffer.from(warm.blob).toString()).toBe(orphanContent); - - // Drop the scratch ref so the orphan is unreachable, then GC. - await git.deleteBranch({ fs, dir, ref: "scratch" }); - await commitFile(dir, "state/f9.txt", "keep 9"); - await runGC(dir, { retention: "keep-history" }); - - // The object is genuinely gone — a NotFound throw, never stale bytes. - let threw = false; - try { - const r = await git.readBlob({ fs, dir, oid: orphanOid, cache }); - expect(Buffer.from(r.blob).toString()).not.toBe(orphanContent); - } catch { - threw = true; - } - expect(threw).toBe(true); - }); -}); diff --git a/packages/storage-isogit/src/gc.test.ts b/packages/storage-isogit/src/gc.test.ts deleted file mode 100644 index 90524213c..000000000 --- a/packages/storage-isogit/src/gc.test.ts +++ /dev/null @@ -1,381 +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 git from "isomorphic-git"; -import { initAgentRepo } from "./node"; -import { applyPack, type CommitVerifier } from "./node"; -import { collectReachableObjects } from "./node"; -import { - createIsogitStorage, - createNodeIsogitRuntime, - type IsogitRuntime, -} from "./node"; -import { repoDiskUsage } from "./node"; -import { runGC } from "./node"; -import { IsogitStore } from "./node"; - -const author = { name: "Test", email: "test@test.dev" }; - -const tempDirs: string[] = []; - -async function tempDir(): Promise { - const d = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "interchange-gc-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 })), - ); -}); - -async function packFor(sourceDir: string, oids: string[]): Promise { - const result = await git.packObjects({ - fs, - dir: sourceDir, - oids, - write: false, - }); - if (result.packfile === undefined) { - throw new Error("packObjects returned no packfile"); - } - return result.packfile; -} - -/** - * A single-commit source repo. `tipOids` is the tip-tree reachable set, the - * same shape a deploy pack carries (commit + tree + blobs, no ancestors). - */ -async function sourceRepo(files: Record): Promise<{ - dir: string; - tip: string; - tipOids: string[]; -}> { - const dir = await tempDir(); - await git.init({ fs, dir, defaultBranch: "main" }); - for (const [filepath, content] of Object.entries(files)) { - const full = path.join(dir, filepath); - await fs.promises.mkdir(path.dirname(full), { recursive: true }); - await fs.promises.writeFile(full, content); - await git.add({ fs, dir, filepath }); - } - const tip = await git.commit({ fs, dir, message: "deploy", author }); - return { dir, tip, tipOids: await collectReachableObjects(dir, tip) }; -} - -/** - * Commit a file onto `refs/heads/main` in a target repo, producing loose - * objects and extending the main ancestry chain. - */ -async function commitOnMain(dir: string, name: string): Promise { - const full = path.join(dir, "state", name); - await fs.promises.mkdir(path.dirname(full), { recursive: true }); - await fs.promises.writeFile(full, `content of ${name}`); - await git.add({ fs, dir, filepath: `state/${name}` }); - return git.commit({ fs, dir, message: `add ${name}`, author }); -} - -async function deployOnto( - dir: string, - files: Record, - transferId: string, -): Promise { - const source = await sourceRepo(files); - const pack = await packFor(source.dir, source.tipOids); - await applyPack(dir, pack, "refs/heads/deploy", source.tip, transferId); - return source.tip; -} - -function runtimeFailingOnFlush(failOn: number): IsogitRuntime { - const runtime = createNodeIsogitRuntime(); - let flushCount = 0; - return { - ...runtime, - flush: () => { - flushCount += 1; - if (flushCount === failOn) { - return Promise.reject( - new Error(`injected flush ${flushCount.toString()} failure`), - ); - } - return Promise.resolve(); - }, - }; -} - -describe("runGC", () => { - test("reclaims disk from superseded packs and loose objects across both refs", async () => { - const dir = await tempDir(); - await initAgentRepo(dir); - - let mainTip = ""; - for (const name of ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]) { - mainTip = await commitOnMain(dir, name); - } - - await deployOnto(dir, { "deploy/one.txt": "first deploy" }, "deploy-1"); - await deployOnto(dir, { "deploy/two.txt": "second deploy" }, "deploy-2"); - const deployTip = await deployOnto( - dir, - { "deploy/three.txt": "third deploy" }, - "deploy-3", - ); - - const unsignedSource = await sourceRepo({ "deploy/x.txt": "unsigned" }); - const unsignedPack = await packFor( - unsignedSource.dir, - unsignedSource.tipOids, - ); - const verifier: CommitVerifier = async () => true; - await expect( - applyPack( - dir, - unsignedPack, - "refs/heads/deploy", - unsignedSource.tip, - "reject-2", - verifier, - ), - ).rejects.toThrow(/^signature_unsigned/); - - const before = repoDiskUsage(dir); - expect(before.packCount).toBeGreaterThan(1); - expect(before.looseObjectCount).toBeGreaterThan(0); - - const result = await runGC(dir, { retention: "keep-history" }); - - expect(result.reclaimedBytes).toBeGreaterThan(0); - expect(result.after.packCount).toBe(1); - expect(result.after.looseObjectCount).toBe(0); - - // Both diverging heads remain readable after the consolidation. - expect((await git.readCommit({ fs, dir, oid: mainTip })).oid).toBe(mainTip); - expect((await git.readCommit({ fs, dir, oid: deployTip })).oid).toBe( - deployTip, - ); - }); - - test("preserves every object reachable from both diverging refs", async () => { - const dir = await tempDir(); - await initAgentRepo(dir); - - const mainTip = await commitOnMain(dir, "main-state.txt"); - const deployTip = await deployOnto( - dir, - { "deploy/prompt.txt": "deploy content" }, - "deploy-1", - ); - - const mainBefore = new Set(await collectReachableObjects(dir, mainTip)); - const deployBefore = new Set(await collectReachableObjects(dir, deployTip)); - - await runGC(dir, { retention: "tip-only" }); - - // The full tree-reachable closure of both diverging heads survives the - // consolidation; walking it re-reads every commit and tree, and the - // enumerated object set is unchanged. - expect(new Set(await collectReachableObjects(dir, mainTip))).toEqual( - mainBefore, - ); - expect(new Set(await collectReachableObjects(dir, deployTip))).toEqual( - deployBefore, - ); - }); - - test("tip-only drops history while keep-history retains it", async () => { - async function buildChain(): Promise<{ - dir: string; - ancestor: string; - tip: string; - }> { - const dir = await tempDir(); - await initAgentRepo(dir); - const ancestor = await commitOnMain(dir, "old.txt"); - const tip = await commitOnMain(dir, "new.txt"); - return { dir, ancestor, tip }; - } - - const shallow = await buildChain(); - await runGC(shallow.dir, { retention: "tip-only" }); - expect( - (await git.readCommit({ fs, dir: shallow.dir, oid: shallow.tip })).oid, - ).toBe(shallow.tip); - await expect( - git.readCommit({ fs, dir: shallow.dir, oid: shallow.ancestor }), - ).rejects.toThrow(shallow.ancestor); - - const deep = await buildChain(); - await runGC(deep.dir, { retention: "keep-history" }); - expect( - (await git.readCommit({ fs, dir: deep.dir, oid: deep.ancestor })).oid, - ).toBe(deep.ancestor); - }); - - test("keep-history tolerates dangling parents on the deploy ref", async () => { - const dir = await tempDir(); - await initAgentRepo(dir); - - // A two-commit source whose tip carries a parent pointer; the pack only - // contains the tip's tree-reachable objects, so the parent is absent on - // disk once applied — the steady state of a real deploy ref. - const source = await tempDir(); - await git.init({ fs, dir: source, defaultBranch: "main" }); - await fs.promises.writeFile(path.join(source, "a.txt"), "a"); - await git.add({ fs, dir: source, filepath: "a.txt" }); - await git.commit({ fs, dir: source, message: "first", author }); - await fs.promises.writeFile(path.join(source, "b.txt"), "b"); - await git.add({ fs, dir: source, filepath: "b.txt" }); - const deployTip = await git.commit({ - fs, - dir: source, - message: "second", - author, - }); - const tipOnly = await collectReachableObjects(source, deployTip); - const pack = await packFor(source, tipOnly); - await applyPack(dir, pack, "refs/heads/deploy", deployTip, "deploy-1"); - - // The tip's parent is unresolvable; keep-history must not abort on it. - const result = await runGC(dir, { retention: "keep-history" }); - expect(result.after.packCount).toBe(1); - expect((await git.readCommit({ fs, dir, oid: deployTip })).oid).toBe( - deployTip, - ); - }); - - test("serializes concurrent commits against GC without corruption", async () => { - const dir = await tempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - // Fire context commits and GC passes against the same repo concurrently. - // Every commit and every GC acquires the per-directory lock, so they run - // one-at-a-time and the object store is never observed mid-mutation. - const ops: Promise[] = []; - for (let i = 0; i < 12; i += 1) { - await fs.promises.writeFile( - path.join(dir, "turns.jsonl"), - `${JSON.stringify({ role: "user", content: [], timestamp: i })}\n`, - ); - ops.push(store.commit({ message: `cycle ${i.toString()}` })); - ops.push(runGC(dir, { retention: "tip-only" })); - } - await Promise.all(ops); - - // The repo is intact: HEAD resolves and walking its entire tree-reachable - // closure re-reads every commit and tree without a missing object. - const head = await git.resolveRef({ fs, dir, ref: "refs/heads/main" }); - const reachable = await collectReachableObjects(dir, head); - expect(reachable.length).toBeGreaterThan(0); - }); - - test("history reads degrade to the surviving slice after a tip-only GC", async () => { - const dir = await tempDir(); - await initAgentRepo(dir); - const store = new IsogitStore(dir); - - for (let i = 0; i < 4; i += 1) { - await fs.promises.writeFile( - path.join(dir, "turns.jsonl"), - `${JSON.stringify({ role: "user", content: [], timestamp: i })}\n`, - ); - await fs.promises.writeFile(path.join(dir, "manifest.jsonl"), ""); - await store.commit({ message: `cycle ${i.toString()}` }); - } - const before = await store.log(10); - expect(before.length).toBeGreaterThan(1); - - await runGC(dir, { retention: "tip-only" }); - - // tip-only prunes the older commits. The reads must not throw into the - // caller (the agent's checkpoints tool) — they return the surviving - // slice from the tip, which is itself intact. - const after = await store.log(10); - expect(after.length).toBeGreaterThanOrEqual(1); - expect(after[0]?.hash).toBe(before[0]?.hash); - await store.readManifestHistory(10); - }); - - test("keeps superseded object bodies quarantined when the commit flush fails", async () => { - const dir = await tempDir(); - await initAgentRepo(dir); - const mainTip = await commitOnMain(dir, "main-state.txt"); - const deployTip = await deployOnto( - dir, - { "deploy/prompt.txt": "deploy content" }, - "deploy-1", - ); - const before = repoDiskUsage(dir); - expect(before.packCount).toBeGreaterThan(0); - expect(before.looseObjectCount).toBeGreaterThan(0); - - const failingStorage = createIsogitStorage(runtimeFailingOnFlush(1)); - await expect( - failingStorage.runGC(dir, { retention: "keep-history" }), - ).rejects.toThrow("injected flush 1 failure"); - - const trashRoot = path.join(dir, ".git", "objects", "gc-trash"); - const trashRuns = await fs.promises.readdir(trashRoot); - expect(trashRuns).toHaveLength(1); - const trashRun = trashRuns[0]; - if (trashRun === undefined) throw new Error("missing GC quarantine"); - const retiredPacks = await fs.promises.readdir( - path.join(trashRoot, trashRun, "pack"), - ); - const retiredLooseDirectories = await fs.promises.readdir( - path.join(trashRoot, trashRun, "loose"), - ); - expect(retiredPacks.some((name) => name.endsWith(".pack"))).toBe(true); - expect(retiredPacks.some((name) => name.endsWith(".idx"))).toBe(true); - expect(retiredLooseDirectories.length).toBeGreaterThan(0); - - expect((await git.readCommit({ fs, dir, oid: mainTip })).oid).toBe(mainTip); - expect((await git.readCommit({ fs, dir, oid: deployTip })).oid).toBe( - deployTip, - ); - - // A later GC first removes the abandoned quarantine, durably flushes - // that cleanup, and then performs its own consolidation. - await runGC(dir, { retention: "keep-history" }); - expect(await fs.promises.readdir(trashRoot)).toEqual([]); - expect((await git.readCommit({ fs, dir, oid: mainTip })).oid).toBe(mainTip); - expect((await git.readCommit({ fs, dir, oid: deployTip })).oid).toBe( - deployTip, - ); - }); - - test("keeps refs readable when the quarantine cleanup flush fails", async () => { - const dir = await tempDir(); - await initAgentRepo(dir); - const mainTip = await commitOnMain(dir, "main-state.txt"); - const deployTip = await deployOnto( - dir, - { "deploy/prompt.txt": "deploy content" }, - "deploy-1", - ); - - const failingStorage = createIsogitStorage(runtimeFailingOnFlush(2)); - await expect( - failingStorage.runGC(dir, { retention: "keep-history" }), - ).rejects.toThrow("injected flush 2 failure"); - - expect((await git.readCommit({ fs, dir, oid: mainTip })).oid).toBe(mainTip); - expect((await git.readCommit({ fs, dir, oid: deployTip })).oid).toBe( - deployTip, - ); - expect( - await fs.promises.readdir(path.join(dir, ".git", "objects", "gc-trash")), - ).toEqual([]); - - await runGC(dir, { retention: "keep-history" }); - expect((await git.readCommit({ fs, dir, oid: mainTip })).oid).toBe(mainTip); - expect((await git.readCommit({ fs, dir, oid: deployTip })).oid).toBe( - deployTip, - ); - }); -}); diff --git a/packages/storage-isogit/src/gc.ts b/packages/storage-isogit/src/gc.ts deleted file mode 100644 index f4d85f30a..000000000 --- a/packages/storage-isogit/src/gc.ts +++ /dev/null @@ -1,420 +0,0 @@ -import git from "isomorphic-git"; -import { getLogger } from "@intx/log"; -import { hasCode } from "@intx/types/has-code"; -import { collectReachableObjects } from "./object-walk"; -import { stagePack } from "./pack-receive"; -import { - gitBytes, - listRepoRefs, - repoDiskUsage, - repoObjectCounts, - type RepoDiskUsage, -} from "./repo-disk"; -import { withRepoDirLock } from "./repo-lock"; -import { flushRuntime, type StorageRuntime } from "./runtime"; - -const logger = getLogger(["interchange", "storage-isogit", "gc"]); -const GC_TRASH_DIR = "gc-trash"; - -type ObjectStoreEntry = { - name: string; - filepath: string; -}; - -/** - * How much commit history a GC pass preserves. - * - * - `tip-only`: keep only the objects reachable from each ref tip's tree. - * Prior commits are dropped, leaving the tip commit with dangling parent - * pointers (the same shape the deploy ref already has). Smallest repo; - * suited to environments that treat the repo as current-state cache. - * - `keep-history`: also keep every object reachable through the commit - * ancestry. Preserves the audit trail; suited to environments that treat - * the repo as a long-term archive. - */ -export type RetentionPolicy = "tip-only" | "keep-history"; - -export type GCResult = { - before: RepoDiskUsage; - after: RepoDiskUsage; - reclaimedBytes: number; - keptObjects: number; -}; - -/** - * Write-path reclaim policy. A writer holding the per-directory lock samples - * the repo's object counts after its mutation and repacks under `retention` - * once the pack count reaches `packThreshold` OR the loose-object count - * reaches `looseThreshold`. Both triggers matter: a hub repo accumulates - * packs as it receives state, while a sidecar repo accumulates loose objects - * as the reactor commits. When a reclaim runs, the `.git` byte size is - * checked against `warnBytes` and a disk-pressure warning is emitted if it - * is reached — surfacing runaway accumulation that survives a reclaim — so - * the byte check rides the reclaim rather than every write. - */ -export type GCPolicy = { - packThreshold: number; - looseThreshold: number; - warnBytes: number; - retention: RetentionPolicy; -}; - -/** - * Collect every object reachable through a commit's ancestry, tolerating - * commits whose parents are not present on disk. - * - * The deploy ref is applied tip-only, so its commit carries parent pointers - * to objects that were never transferred. Reading an absent parent throws - * `NotFoundError`; we stop descending that branch rather than aborting the - * whole GC, because a missing parent is the expected steady state for these - * repos. Only that specific absence is tolerated — any other read failure - * (corruption, a non-commit oid, I/O) surfaces, since swallowing it here - * would drop a present, reachable subtree from the keep set and the caller - * would then delete it. - */ -async function collectHistoryObjects( - runtime: StorageRuntime, - dir: string, - tipOid: string, -): Promise> { - const objects = new Set(); - const seenCommits = new Set(); - const queue: string[] = [tipOid]; - - while (queue.length > 0) { - const commitOid = queue.shift(); - if (commitOid === undefined) break; - if (seenCommits.has(commitOid)) continue; - seenCommits.add(commitOid); - - let parents: string[]; - try { - const { commit } = await git.readCommit({ - fs: runtime.fs.git, - dir, - oid: commitOid, - }); - parents = commit.parent; - } catch (err) { - if (err instanceof Error && "code" in err && err.code === "NotFoundError") - continue; - throw err; - } - - for (const oid of await collectReachableObjects(runtime, dir, commitOid)) { - objects.add(oid); - } - for (const parent of parents) { - if (!seenCommits.has(parent)) queue.push(parent); - } - } - - return objects; -} - -/** - * Every `.pack` and `.idx` file currently in the repo's pack directory. - * Snapshotted before the consolidated pack is published so the freshly - * published pair is never in the retirement set. - */ -async function listPackFiles( - runtime: StorageRuntime, - dir: string, -): Promise { - const packDir = runtime.path.join(dir, ".git", "objects", "pack"); - let entries: string[]; - try { - entries = await runtime.fs.readdir(packDir); - } catch (cause) { - if (hasCode(cause) && cause.code === "ENOENT") { - return []; - } - throw cause; - } - return entries - .filter((name) => name.endsWith(".pack") || name.endsWith(".idx")) - .map((name) => ({ - name, - filepath: runtime.path.join(packDir, name), - })); -} - -/** - * Snapshot every loose-object fan-out directory under `.git/objects/`. - * Non-object children such as `pack`, `info`, and `gc-trash` are excluded by - * the two-lowercase-hex directory-name constraint. - */ -async function listLooseObjectDirectories( - runtime: StorageRuntime, - dir: string, -): Promise { - const objectsDir = runtime.path.join(dir, ".git", "objects"); - let entries: string[]; - try { - entries = await runtime.fs.readdir(objectsDir); - } catch (cause) { - if (hasCode(cause) && cause.code === "ENOENT") { - return []; - } - throw cause; - } - return entries - .filter((name) => /^[0-9a-f]{2}$/.test(name)) - .map((name) => ({ - name, - filepath: runtime.path.join(objectsDir, name), - })); -} - -function gcTrashRoot(runtime: StorageRuntime, dir: string): string { - return runtime.path.join(dir, ".git", "objects", GC_TRASH_DIR); -} - -/** - * Delete quarantine left by a prior GC that reached its durability commit - * point but did not finish cleanup. Quarantine is outside every path Git - * scans for packs or loose objects. If it is present in a durable namespace, - * the replacement pack was part of that same flushed namespace; a crash - * before the flush restores the prior namespace without quarantine instead. - */ -async function removeStaleGCTrash( - runtime: StorageRuntime, - dir: string, -): Promise { - const trashRoot = gcTrashRoot(runtime, dir); - let entries: string[]; - try { - entries = await runtime.fs.readdir(trashRoot); - } catch (cause) { - if (hasCode(cause) && cause.code === "ENOENT") return false; - throw cause; - } - if (entries.length === 0) return false; - - await runtime.fs.remove(trashRoot, { recursive: true, force: true }); - return true; -} - -/** - * Retire the old object-store namespace without deleting object bodies. - * LightningFS persists file bodies eagerly but its directory namespace only - * at flush time. Renaming into an unscanned quarantine therefore leaves both - * crash outcomes valid: a pre-flush reopen sees the old namespace and a - * post-flush reopen sees the consolidated pack plus quarantined old bodies. - */ -async function quarantineSupersededObjects( - runtime: StorageRuntime, - dir: string, - transferId: string, - supersededPacks: readonly ObjectStoreEntry[], - looseObjectDirectories: readonly ObjectStoreEntry[], -): Promise { - const trashDir = runtime.path.join(gcTrashRoot(runtime, dir), transferId); - const packTrashDir = runtime.path.join(trashDir, "pack"); - const looseTrashDir = runtime.path.join(trashDir, "loose"); - await runtime.fs.mkdir(packTrashDir, { recursive: true }); - await runtime.fs.mkdir(looseTrashDir, { recursive: true }); - - const orderedPacks = [...supersededPacks].sort((left, right) => { - const leftRank = left.name.endsWith(".idx") ? 0 : 1; - const rightRank = right.name.endsWith(".idx") ? 0 : 1; - if (leftRank !== rightRank) return leftRank - rightRank; - if (left.name < right.name) return -1; - if (left.name > right.name) return 1; - return 0; - }); - for (const entry of orderedPacks) { - await runtime.fs.rename( - entry.filepath, - runtime.path.join(packTrashDir, entry.name), - ); - } - for (const entry of looseObjectDirectories) { - await runtime.fs.rename( - entry.filepath, - runtime.path.join(looseTrashDir, entry.name), - ); - } - - return trashDir; -} - -/** - * Reclaim disk in an agent git repo by repacking everything reachable from - * its refs into a single pack and dropping the superseded packs and loose - * objects. - * - * Compute the keep set as the union of reachability over every head ref - * (agent repos carry two diverging heads, `main` and `deploy`, so unioning - * is mandatory — repacking one ref's reachability alone would discard the - * other's live objects). Pack the keep set into one self-contained pack via - * `git.packObjects` and publish it through the same atomic staging dance - * receives use, so a concurrent unlocked reader never observes a torn pack. - * Only then rename the packs that predated this pass and every loose-object - * fan-out directory into an unscanned quarantine. A flush makes the new pack - * and retired namespace durable together; object bodies are deleted only - * after that commit point, followed by a second cleanup flush. - * - * # Concurrency - * - * The caller MUST already hold the repo's per-directory lock - * (`withRepoDirLock`). This is the lock-free core: writers trigger reclaim - * inline after a commit/apply while still holding that lock, and external - * callers not already under it use {@link runGC}, which acquires it. The - * lock excludes concurrent writers, so the keep set computed from the refs - * cannot be invalidated by a commit landing mid-pass. Retirement of a - * superseded pack or loose object races only with unlocked readers; a - * reader that already loaded a retired pack finishes against those bytes, - * and its next listing no longer finds it. Indexes are retired before their - * packs, and every retired-but-reachable object is also in the freshly - * published consolidated pack. - * - * Returns the disk usage before and after plus the reclaimed byte delta. A - * repo with no resolvable refs is not repacked, though stale quarantine is - * still reclaimed. Exported for use within the storage package only — it is - * intentionally absent from the package's public barrel. - */ -export async function gcUnderLock( - runtime: StorageRuntime, - dir: string, - opts: { retention: RetentionPolicy }, -): Promise { - if (await removeStaleGCTrash(runtime, dir)) { - await flushRuntime(runtime); - } - - const before = await repoDiskUsage(runtime, dir); - const refs = await listRepoRefs(runtime, dir); - - if (refs.length === 0) { - return { before, after: before, reclaimedBytes: 0, keptObjects: 0 }; - } - - const keep = new Set(); - for (const { oid } of refs) { - const reachable = - opts.retention === "tip-only" - ? await collectReachableObjects(runtime, dir, oid) - : await collectHistoryObjects(runtime, dir, oid); - for (const objectOid of reachable) keep.add(objectOid); - } - - const supersededPacks = await listPackFiles(runtime, dir); - const looseObjectDirectories = await listLooseObjectDirectories(runtime, dir); - - const result = await git.packObjects({ - fs: runtime.fs.git, - dir, - oids: [...keep], - write: false, - }); - if (result.packfile === undefined) { - throw new Error( - `packObjects returned no packfile while consolidating ${keep.size.toString()} objects in ${dir}`, - ); - } - const transferId = `gc-${crypto.randomUUID().replace(/-/g, "")}`; - await (await stagePack(runtime, dir, result.packfile, transferId)).publish(); - - const trashDir = await quarantineSupersededObjects( - runtime, - dir, - transferId, - supersededPacks, - looseObjectDirectories, - ); - - // Durability commit point: deletion cannot begin until the replacement - // pack is published and every retired body is reachable through quarantine. - await flushRuntime(runtime); - await runtime.fs.remove(trashDir, { recursive: true, force: true }); - await flushRuntime(runtime); - - const after = await repoDiskUsage(runtime, dir); - return { - before, - after, - reclaimedBytes: before.gitBytes - after.gitBytes, - keptObjects: keep.size, - }; -} - -/** - * Garbage-collect the agent repo at `dir`, acquiring the repo's - * per-directory lock for the duration. Use this from callers that are not - * already holding the lock (e.g. the hub's substrate, which holds its own - * higher-level lock but not the storage lock). Writers that trigger reclaim - * while already under the lock call {@link gcUnderLock} directly. - */ -export async function runGC( - runtime: StorageRuntime, - dir: string, - opts: { retention: RetentionPolicy }, -): Promise { - return withRepoDirLock(runtime, dir, () => gcUnderLock(runtime, dir, opts)); -} - -function warnIfOverBudget(dir: string, bytes: number, warnBytes: number): void { - if (bytes >= warnBytes) { - logger.warn`disk pressure on ${dir}: .git is ${String(bytes)} bytes, at or above the ${String(warnBytes)} byte threshold`; - } -} - -/** - * Apply a write-path reclaim policy to the repo at `dir`. Reclaims when the - * pack count or the loose-object count has reached its threshold. Intended - * to be called by a writer that has just mutated the repo and is still - * holding the per-directory lock, so the reclaim itself runs without - * re-entering the lock. - * - * The trigger samples only the object counts — two directory reads — on - * every write; the full `.git` byte walk that feeds the disk-pressure - * warning runs only when a reclaim does (the collector computes it for its - * before/after delta anyway, and the failure path walks it once). So the - * warning is evaluated at reclaim time, not on every write, and the common - * below-threshold write pays no byte walk. - * - * A reclaim failure is logged, not propagated: the write that triggered this - * has already committed, so failing the caller would falsely report the - * write as failed. The disk-pressure warning still fires on a failed reclaim - * — the case where accumulation is most likely runaway. - */ -export async function maybeGCUnderLock( - runtime: StorageRuntime, - dir: string, - policy: GCPolicy, -): Promise { - const counts = await repoObjectCounts(runtime, dir); - if ( - counts.packCount < policy.packThreshold && - counts.looseObjectCount < policy.looseThreshold - ) { - return; - } - try { - const result = await gcUnderLock(runtime, dir, { - retention: policy.retention, - }); - logger.info`reclaimed ${String(result.reclaimedBytes)} bytes from ${dir}: packs ${String(result.before.packCount)} to ${String(result.after.packCount)}, loose ${String(result.before.looseObjectCount)} to ${String(result.after.looseObjectCount)}`; - warnIfOverBudget(dir, result.after.gitBytes, policy.warnBytes); - } catch (err) { - logger.warn`GC of ${dir} failed; active refs remain intact, but garbage may remain quarantined — ${err instanceof Error ? err.message : String(err)}`; - warnIfOverBudget(dir, await gitBytes(runtime, dir), policy.warnBytes); - } -} - -/** - * {@link maybeGCUnderLock} for callers that do not already hold the repo's - * per-directory lock — it acquires the lock for the duration. The hub's - * substrate uses this from inside its own higher-level lock; sidecar writers - * that already hold the per-directory lock call `maybeGCUnderLock` directly. - */ -export async function maybeGC( - runtime: StorageRuntime, - dir: string, - policy: GCPolicy, -): Promise { - return withRepoDirLock(runtime, dir, () => - maybeGCUnderLock(runtime, dir, policy), - ); -} diff --git a/packages/storage-isogit/src/hub-refs.ts b/packages/storage-isogit/src/hub-refs.ts new file mode 100644 index 000000000..4e3984885 --- /dev/null +++ b/packages/storage-isogit/src/hub-refs.ts @@ -0,0 +1,5 @@ +/** + * Hub-owned refs that keep commits reachable without showing up as branches, so + * a prune or a manual `git gc` cannot lose them. + */ +export const HUB_REF_NAMESPACE = "refs/interchange"; diff --git a/packages/storage-isogit/src/index.ts b/packages/storage-isogit/src/index.ts index 00accc391..184191414 100644 --- a/packages/storage-isogit/src/index.ts +++ b/packages/storage-isogit/src/index.ts @@ -8,7 +8,6 @@ import { } from "./history"; import { initAgentRepo, initRepo } from "./init"; import { - applyPack, removeOrphanPackIndexes, removeStagedPacks, stagePack, @@ -18,17 +17,8 @@ import { collectReachableObjects } from "./object-walk"; import { createObjectPresence } from "./object-presence"; import { writeTreeToDisk } from "./write-tree"; import { indexPackIntoGitDir } from "./index-pack"; -import { - countLooseObjects, - countPackFiles, - gitBytes, - listRepoRefs, - repoDiskUsage, -} from "./repo-disk"; import { IsogitStore, type DurableMirrorReads } from "./store"; import type { CommitSigner } from "./signer"; -import { maybeGC, runGC, type GCPolicy } from "./gc"; -import { createMailAuditStore, listMail } from "./mail-store"; import { normalizeRuntime, type IsogitRuntime, @@ -36,24 +26,15 @@ import { } from "./runtime"; export type { ContextStore, AuditStore, CommitSigner }; -export type { CommitVerifier, StagedPack } from "./pack-receive"; +export type { StagedPack } from "./pack-receive"; export type { IncludeShaPredicate } from "./pack-send"; export type { ObjectPresence } from "./object-presence"; export type { DurableMirrorReads }; export type { InitRepoOpts } from "./init"; -export type { RetentionPolicy, GCResult, GCPolicy } from "./gc"; -export type { RepoDiskUsage } from "./repo-disk"; -export type { - MailAuditStore, - MailCommitOptions, - MailCommitResult, - MailDirection, - MailEntry, -} from "./mail-store"; export type { IsogitPath, IsogitRuntime } from "./runtime"; export { writeTreeToDisk } from "./write-tree"; export { indexPackIntoGitDir } from "./index-pack"; -export { HUB_REF_NAMESPACE } from "./repo-disk"; +export { HUB_REF_NAMESPACE } from "./hub-refs"; export { DEFAULT_PACK_MATERIALIZATION_LIMITS, type PackMaterializationLimits, @@ -75,11 +56,10 @@ export function createIsogitStorage(runtime: IsogitRuntime) { createIsogitStore: async ( dir: string, signer?: CommitSigner, - gcPolicy?: GCPolicy, ): Promise => { const resolvedDir = storageRuntime.path.resolve(dir); await initAgentRepo(storageRuntime, resolvedDir); - return new IsogitStore(storageRuntime, resolvedDir, signer, gcPolicy); + return new IsogitStore(storageRuntime, resolvedDir, signer); }, initRepo: bindRepoDir(storageRuntime, initRepo), initAgentRepo: bindRepoDir(storageRuntime, initAgentRepo), @@ -88,7 +68,6 @@ export function createIsogitStorage(runtime: IsogitRuntime) { currentBranch: bindRepoDir(storageRuntime, currentBranch), listBranches: bindRepoDir(storageRuntime, listBranches), logHistory: bindRepoDir(storageRuntime, logHistory), - applyPack: bindRepoDir(storageRuntime, applyPack), stagePack: bindRepoDir(storageRuntime, stagePack), removeStagedPacks: bindRepoDir(storageRuntime, removeStagedPacks), removeOrphanPackIndexes: bindRepoDir( @@ -102,15 +81,6 @@ export function createIsogitStorage(runtime: IsogitRuntime) { collectReachableObjects, ), createObjectPresence: bindRepoDir(storageRuntime, createObjectPresence), - repoDiskUsage: bindRepoDir(storageRuntime, repoDiskUsage), - countLooseObjects: bindRepoDir(storageRuntime, countLooseObjects), - countPackFiles: bindRepoDir(storageRuntime, countPackFiles), - gitBytes: bindRepoDir(storageRuntime, gitBytes), - listRepoRefs: bindRepoDir(storageRuntime, listRepoRefs), - runGC: bindRepoDir(storageRuntime, runGC), - maybeGC: bindRepoDir(storageRuntime, maybeGC), - createMailAuditStore: bindRepoDir(storageRuntime, createMailAuditStore), - listMail: bindRepoDir(storageRuntime, listMail), writeTreeToDisk: bindRepoDir(storageRuntime, writeTreeToDisk), indexPackIntoGitDir: bindRepoDir(storageRuntime, indexPackIntoGitDir), }; diff --git a/packages/storage-isogit/src/isogit-helpers.ts b/packages/storage-isogit/src/isogit-helpers.ts index 640d538a8..7c27df5b0 100644 --- a/packages/storage-isogit/src/isogit-helpers.ts +++ b/packages/storage-isogit/src/isogit-helpers.ts @@ -1,10 +1,9 @@ -import git, { type FsClient } from "isomorphic-git"; +import git from "isomorphic-git"; import { type } from "arktype"; import type { StorageRuntime } from "./runtime"; const CommitObject = type({ tree: "string" }); const TreeEntry = type({ oid: "string", type: "string" }); -const RawObject = type({ object: type.instanceOf(Uint8Array) }); export async function readCommitObject( runtime: StorageRuntime, @@ -33,13 +32,3 @@ export async function readTreeEntries( }); return TreeEntry.array().assert(object); } - -export async function readRawObject(gitFs: FsClient, dir: string, oid: string) { - const { object } = await git.readObject({ - fs: gitFs, - dir, - oid, - format: "content", - }); - return RawObject.assert({ object }); -} diff --git a/packages/storage-isogit/src/mail-store.test.ts b/packages/storage-isogit/src/mail-store.test.ts deleted file mode 100644 index 93cab1493..000000000 --- a/packages/storage-isogit/src/mail-store.test.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { describe, test, expect, beforeEach } from "bun:test"; -import fs from "node:fs"; -import path from "node:path"; -import os from "node:os"; -import git, { type PromiseFsClient } from "isomorphic-git"; -import { initAgentRepo } from "./node"; -import { createMailAuditStore, listMail } from "./node"; -import { createIsogitStorage } from "./index"; -import { createNodeIsogitRuntime } from "./node-runtime"; - -function createRefWriteFailureRuntime( - refPath: string, - effect: "before" | "after", -) { - const runtime = createNodeIsogitRuntime(); - const nodePromises = fs.promises; - let armed = false; - const promiseFs: PromiseFsClient = { - promises: { - readFile: nodePromises.readFile.bind(nodePromises), - writeFile: async (...args: unknown[]) => { - const filepath = args[0]; - if (!armed || filepath !== refPath) { - return Reflect.apply(nodePromises.writeFile, nodePromises, args); - } - if (effect === "after") { - await Reflect.apply(nodePromises.writeFile, nodePromises, args); - } - throw new Error(`injected ${effect}-effect ref write failure`); - }, - unlink: nodePromises.unlink.bind(nodePromises), - readdir: nodePromises.readdir.bind(nodePromises), - mkdir: nodePromises.mkdir.bind(nodePromises), - rmdir: nodePromises.rmdir.bind(nodePromises), - stat: nodePromises.stat.bind(nodePromises), - lstat: nodePromises.lstat.bind(nodePromises), - readlink: nodePromises.readlink.bind(nodePromises), - symlink: nodePromises.symlink.bind(nodePromises), - chmod: nodePromises.chmod.bind(nodePromises), - }, - }; - return { - runtime: { ...runtime, fs: promiseFs }, - arm: () => { - armed = true; - }, - disarm: () => { - armed = false; - }, - }; -} - -function buildRawMessage(opts: { - messageId: string; - from?: string; - to?: string; - inReplyTo?: string; - references?: string[]; - body?: string; -}): Uint8Array { - const lines: string[] = []; - lines.push(`Message-ID: ${opts.messageId}`); - lines.push(`From: ${opts.from ?? "sender@example.com"}`); - lines.push(`To: ${opts.to ?? "recipient@example.com"}`); - lines.push(`Date: ${new Date().toUTCString()}`); - if (opts.inReplyTo !== undefined) { - lines.push(`In-Reply-To: ${opts.inReplyTo}`); - } - if (opts.references !== undefined && opts.references.length > 0) { - lines.push(`References: ${opts.references.join(" ")}`); - } - lines.push(""); - lines.push(opts.body ?? "test body"); - return new TextEncoder().encode(lines.join("\r\n")); -} - -let testDir: string; - -beforeEach(async () => { - testDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mail-store-")); - await initAgentRepo(testDir); -}); - -describe("createMailAuditStore", () => { - test("first message creates a new thread", async () => { - const store = await createMailAuditStore(testDir); - const raw = buildRawMessage({ messageId: "" }); - const result = await store.commitMail(raw, "in"); - if (result === null) throw new Error("expected non-null result"); - - expect(result.messageId).toBe(""); - expect(result.threadId).toMatch(/^[0-9a-f]{8}$/); - expect(result.filepath).toBe(`state/mail/${result.threadId}/0001-in.eml`); - - const filePath = path.join(testDir, result.filepath); - const stored = await fs.promises.readFile(filePath, "utf-8"); - expect(stored).toBe(new TextDecoder().decode(raw)); - }); - - test("reply via In-Reply-To joins existing thread", async () => { - const store = await createMailAuditStore(testDir); - - const msg1 = buildRawMessage({ messageId: "" }); - const r1 = await store.commitMail(msg1, "in"); - if (r1 === null) throw new Error("expected non-null r1"); - - const msg2 = buildRawMessage({ - messageId: "", - inReplyTo: "", - }); - const r2 = await store.commitMail(msg2, "out"); - if (r2 === null) throw new Error("expected non-null r2"); - - expect(r2.threadId).toBe(r1.threadId); - expect(r2.filepath).toBe(`state/mail/${r1.threadId}/0002-out.eml`); - }); - - test("reply via References joins correct thread", async () => { - const store = await createMailAuditStore(testDir); - - const msg1 = buildRawMessage({ messageId: "" }); - const r1 = await store.commitMail(msg1, "in"); - if (r1 === null) throw new Error("expected non-null r1"); - - const msg2 = buildRawMessage({ - messageId: "", - references: [""], - }); - const r2 = await store.commitMail(msg2, "out"); - if (r2 === null) throw new Error("expected non-null r2"); - - expect(r2.threadId).toBe(r1.threadId); - }); - - test("unrelated message creates a separate thread", async () => { - const store = await createMailAuditStore(testDir); - - const msg1 = buildRawMessage({ messageId: "" }); - const r1 = await store.commitMail(msg1, "in"); - if (r1 === null) throw new Error("expected non-null r1"); - - const msg2 = buildRawMessage({ messageId: "" }); - const r2 = await store.commitMail(msg2, "in"); - if (r2 === null) throw new Error("expected non-null r2"); - - expect(r2.threadId).not.toBe(r1.threadId); - }); - - test("duplicate Message-ID throws", async () => { - const store = await createMailAuditStore(testDir); - - const msg = buildRawMessage({ messageId: "" }); - await store.commitMail(msg, "in"); - - expect(store.commitMail(msg, "in")).rejects.toThrow( - "Duplicate mail: Message-ID already stored", - ); - }); - - test("duplicate Message-ID returns null with ignoreDuplicate", async () => { - const store = await createMailAuditStore(testDir); - - const msg = buildRawMessage({ messageId: "" }); - await store.commitMail(msg, "in"); - - const result = await store.commitMail(msg, "in", { - ignoreDuplicate: true, - }); - expect(result).toBeNull(); - }); - - test("missing Message-ID throws", async () => { - const store = await createMailAuditStore(testDir); - - const raw = new TextEncoder().encode( - "From: sender@example.com\r\nDate: Mon, 01 Jan 2024 00:00:00 GMT\r\n\r\nbody", - ); - - expect(store.commitMail(raw, "in")).rejects.toThrow( - "Message-ID header is missing or empty", - ); - }); - - test("index rebuilds correctly from disk on init", async () => { - const store1 = await createMailAuditStore(testDir); - - const msg1 = buildRawMessage({ messageId: "" }); - const r1 = await store1.commitMail(msg1, "in"); - if (r1 === null) throw new Error("expected non-null r1"); - - const msg2 = buildRawMessage({ - messageId: "", - inReplyTo: "", - }); - await store1.commitMail(msg2, "out"); - - // Create a fresh store from the same directory - const store2 = await createMailAuditStore(testDir); - - const msg3 = buildRawMessage({ - messageId: "", - inReplyTo: "", - }); - const r3 = await store2.commitMail(msg3, "in"); - if (r3 === null) throw new Error("expected non-null r3"); - - expect(r3.threadId).toBe(r1.threadId); - expect(r3.filepath).toBe(`state/mail/${r1.threadId}/0003-in.eml`); - }); - - test("References list walks to find first matching thread", async () => { - const store = await createMailAuditStore(testDir); - - const msg1 = buildRawMessage({ messageId: "" }); - const r1 = await store.commitMail(msg1, "in"); - if (r1 === null) throw new Error("expected non-null r1"); - - // Reference an unknown ID first, then the known one - const msg2 = buildRawMessage({ - messageId: "", - references: ["", ""], - }); - const r2 = await store.commitMail(msg2, "out"); - if (r2 === null) throw new Error("expected non-null r2"); - - expect(r2.threadId).toBe(r1.threadId); - }); -}); - -describe("listMail", () => { - test("returns empty array when no mail exists", async () => { - const entries = await listMail(testDir); - expect(entries).toEqual([]); - }); - - test("returns single entry after one commit", async () => { - const store = await createMailAuditStore(testDir); - const raw = buildRawMessage({ messageId: "" }); - await store.commitMail(raw, "in"); - - const entries = await listMail(testDir); - expect(entries).toHaveLength(1); - - const entry = entries[0]; - if (entry === undefined) throw new Error("expected entry"); - expect(entry.messageId).toBe(""); - expect(entry.direction).toBe("in"); - expect(entry.ordinal).toBe(1); - expect(entry.raw).toEqual(raw); - }); - - test("returns entries sorted by threadId then ordinal", async () => { - const store = await createMailAuditStore(testDir); - - const msg1 = buildRawMessage({ messageId: "" }); - const r1 = await store.commitMail(msg1, "in"); - if (r1 === null) throw new Error("expected non-null r1"); - - const msg2 = buildRawMessage({ - messageId: "", - inReplyTo: "", - }); - await store.commitMail(msg2, "out"); - - const msg3 = buildRawMessage({ messageId: "" }); - const r3 = await store.commitMail(msg3, "in"); - if (r3 === null) throw new Error("expected non-null r3"); - - const entries = await listMail(testDir); - expect(entries).toHaveLength(3); - - // Entries are sorted by threadId then ordinal — verify cross-thread - // ordering is lexicographic and entries within a thread are contiguous - const threadIds = entries.map((e) => e.threadId); - const uniqueThreadIds = [...new Set(threadIds)]; - const sortedThreadIds = [...uniqueThreadIds].sort(); - expect(uniqueThreadIds).toEqual(sortedThreadIds); - - // Thread 1 entries should be grouped and ordered - const thread1 = entries.filter((e) => e.threadId === r1.threadId); - expect(thread1).toHaveLength(2); - expect(thread1[0]?.ordinal).toBe(1); - expect(thread1[0]?.direction).toBe("in"); - expect(thread1[1]?.ordinal).toBe(2); - expect(thread1[1]?.direction).toBe("out"); - - // Thread 2 - const thread2 = entries.filter((e) => e.threadId === r3.threadId); - expect(thread2).toHaveLength(1); - expect(thread2[0]?.ordinal).toBe(1); - }); - - test("reads correctly from a fresh directory without store", async () => { - // Write some mail via store, then read via standalone listMail - const store = await createMailAuditStore(testDir); - const raw = buildRawMessage({ - messageId: "", - from: "alice@example.com", - to: "bob@example.com", - body: "hello from alice", - }); - await store.commitMail(raw, "in"); - - // listMail should work without a store instance - const entries = await listMail(testDir); - expect(entries).toHaveLength(1); - const entry = entries[0]; - if (entry === undefined) throw new Error("expected entry"); - expect(new TextDecoder().decode(entry.raw)).toContain("hello from alice"); - }); - - test("does not reuse an ordinal after uncertain ref publication", async () => { - let flushes = 0; - const storage = createIsogitStorage({ - ...createNodeIsogitRuntime(), - flush: () => { - flushes += 1; - if (flushes === 4) { - return Promise.reject(new Error("injected final flush failure")); - } - return Promise.resolve(); - }, - }); - const store = await storage.createMailAuditStore(testDir); - - const first = await store.commitMail( - buildRawMessage({ messageId: "" }), - "in", - ); - if (first === null) throw new Error("expected first mail commit"); - - await expect( - store.commitMail( - buildRawMessage({ - messageId: "", - inReplyTo: "", - }), - "in", - ), - ).rejects.toThrow("injected final flush failure"); - - const third = await store.commitMail( - buildRawMessage({ - messageId: "", - inReplyTo: "", - }), - "in", - ); - if (third === null) throw new Error("expected third mail commit"); - - expect(third.filepath).toBe(`state/mail/${first.threadId}/0003-in.eml`); - expect( - (await storage.listMail(testDir)).map((entry) => entry.messageId), - ).toEqual([ - "", - "", - "", - ]); - }); - - test("reconciles a ref write that takes effect before rejecting", async () => { - const refPath = path.join(testDir, ".git", "refs", "heads", "main"); - const failure = createRefWriteFailureRuntime(refPath, "after"); - const storage = createIsogitStorage(failure.runtime); - const store = await storage.createMailAuditStore(testDir); - - const first = await store.commitMail( - buildRawMessage({ messageId: "" }), - "in", - ); - if (first === null) throw new Error("expected first mail commit"); - - failure.arm(); - await expect( - store.commitMail( - buildRawMessage({ - messageId: "", - inReplyTo: "", - }), - "out", - ), - ).rejects.toThrow("Ref publication"); - failure.disarm(); - - const third = await store.commitMail( - buildRawMessage({ - messageId: "", - inReplyTo: "", - }), - "in", - ); - if (third === null) throw new Error("expected third mail commit"); - - expect(third.threadId).toBe(first.threadId); - expect(third.filepath).toBe(`state/mail/${first.threadId}/0003-in.eml`); - expect( - (await storage.listMail(testDir)).map((entry) => entry.messageId), - ).toEqual([ - "", - "", - "", - ]); - }); - - test("does not advance mail state when a ref write has no effect", async () => { - const refPath = path.join(testDir, ".git", "refs", "heads", "main"); - const failure = createRefWriteFailureRuntime(refPath, "before"); - const storage = createIsogitStorage(failure.runtime); - const store = await storage.createMailAuditStore(testDir); - - const first = await store.commitMail( - buildRawMessage({ messageId: "" }), - "in", - ); - if (first === null) throw new Error("expected first mail commit"); - const previousOid = await git.resolveRef({ fs, dir: testDir, ref: "HEAD" }); - - failure.arm(); - await expect( - store.commitMail( - buildRawMessage({ - messageId: "", - inReplyTo: "", - }), - "out", - ), - ).rejects.toThrow("Ref publication"); - failure.disarm(); - expect(await git.resolveRef({ fs, dir: testDir, ref: "HEAD" })).toBe( - previousOid, - ); - - const third = await store.commitMail( - buildRawMessage({ - messageId: "", - inReplyTo: "", - }), - "out", - ); - if (third === null) throw new Error("expected third mail commit"); - - expect(third.threadId).toBe(first.threadId); - expect(third.filepath).toBe(`state/mail/${first.threadId}/0002-out.eml`); - expect( - (await storage.listMail(testDir)).map((entry) => entry.messageId), - ).toEqual(["", ""]); - }); - - test("a failed mail commit cannot leak into a cycle commit", async () => { - let failed = false; - const storage = createIsogitStorage({ - ...createNodeIsogitRuntime(), - flush: () => { - if (!failed) { - failed = true; - return Promise.reject(new Error("injected object flush failure")); - } - return Promise.resolve(); - }, - }); - const mailStore = await storage.createMailAuditStore(testDir); - - await expect( - mailStore.commitMail( - buildRawMessage({ messageId: "" }), - "in", - ), - ).rejects.toThrow("injected object flush failure"); - - const [threadId] = await fs.promises.readdir( - path.join(testDir, "state", "mail"), - ); - if (threadId === undefined) throw new Error("expected mail thread"); - const [filename] = await fs.promises.readdir( - path.join(testDir, "state", "mail", threadId), - ); - if (filename === undefined) throw new Error("expected mail file"); - - const contextStore = await storage.createIsogitStore(testDir); - await contextStore.writeTurns([]); - const cycle = await contextStore.commit({ message: "Cycle: inference" }); - - await expect( - git.readBlob({ - fs, - dir: testDir, - oid: cycle.hash, - filepath: `state/mail/${threadId}/${filename}`, - }), - ).rejects.toMatchObject({ code: "NotFoundError" }); - }); -}); diff --git a/packages/storage-isogit/src/mail-store.ts b/packages/storage-isogit/src/mail-store.ts deleted file mode 100644 index ea5b71592..000000000 --- a/packages/storage-isogit/src/mail-store.ts +++ /dev/null @@ -1,306 +0,0 @@ -import git from "isomorphic-git"; -import { parseHeaderSection } from "@intx/mime"; -import { hasCode } from "@intx/types/has-code"; -import { hexEncode } from "@intx/types/hex"; -import { AUTHOR } from "./init"; -import type { CommitSigner } from "./signer"; -import { - buildSigningArgs, - commitDurably, - restoreIndexAfterFailedCommit, - UncertainRefPublicationError, -} from "./commit-helpers"; -import { withRepoDirLock } from "./repo-lock"; -import type { StorageRuntime } from "./runtime"; - -const MAIL_DIR = "state/mail"; - -export type MailDirection = "in" | "out"; - -export type MailCommitResult = { - threadId: string; - messageId: string; - filepath: string; -}; - -export type MailCommitOptions = { - ignoreDuplicate?: boolean; - checkpointHash?: string; -}; - -export type MailEntry = { - threadId: string; - ordinal: number; - direction: MailDirection; - messageId: string; - raw: Uint8Array; -}; - -export type MailAuditStore = { - commitMail( - rawMessage: Uint8Array, - direction: MailDirection, - options?: MailCommitOptions, - ): Promise; -}; - -type ThreadState = { - nextOrdinal: number; -}; - -function generateThreadId(): string { - return hexEncode(crypto.getRandomValues(new Uint8Array(4))); -} - -function formatOrdinal(n: number): string { - return String(n).padStart(4, "0"); -} - -function parseThreadingHeaders(raw: Uint8Array): { - messageId: string; - inReplyTo: string | undefined; - references: string[]; -} { - const { headers } = parseHeaderSection(raw); - const messageId = headers.get("message-id"); - if (messageId === undefined || messageId.trim() === "") { - throw new Error("Message-ID header is missing or empty"); - } - const inReplyTo = headers.get("in-reply-to"); - const refsRaw = headers.get("references"); - const references = refsRaw ? refsRaw.split(/\s+/).filter(Boolean) : []; - return { messageId, inReplyTo, references }; -} - -export async function createMailAuditStore( - runtime: StorageRuntime, - dir: string, - signer?: CommitSigner, -): Promise { - const signingArgs = buildSigningArgs(signer); - - // Message-ID -> thread-id - const messageIndex = new Map(); - // thread-id -> thread state - const threads = new Map(); - let reconciliationFailure: Error | undefined; - - await rebuildIndex(runtime, dir, messageIndex, threads); - - function resolveThread( - inReplyTo: string | undefined, - references: string[], - ): string { - if (inReplyTo !== undefined) { - const threadId = messageIndex.get(inReplyTo); - if (threadId !== undefined) return threadId; - } - for (const ref of references) { - const threadId = messageIndex.get(ref); - if (threadId !== undefined) return threadId; - } - return generateThreadId(); - } - - function peekNextOrdinal(threadId: string): number { - const state = threads.get(threadId); - if (state === undefined) return 1; - return state.nextOrdinal; - } - - function advanceOrdinal(threadId: string): void { - let state = threads.get(threadId); - if (state === undefined) { - state = { nextOrdinal: 2 }; - threads.set(threadId, state); - } else { - state.nextOrdinal++; - } - } - - async function commitMail( - rawMessage: Uint8Array, - direction: MailDirection, - options?: MailCommitOptions, - ): Promise { - // The whole body runs under the per-directory lock: the ordinal peek, - // the commit, and the in-memory index/ordinal advance must be atomic - // against a concurrent reactor commit or GC pass sharing this repo. - return withRepoDirLock(runtime, dir, async () => { - if (reconciliationFailure !== undefined) { - throw new Error( - "Mail audit store cannot accept writes after failed ref reconciliation", - { cause: reconciliationFailure }, - ); - } - - const { messageId, inReplyTo, references } = - parseThreadingHeaders(rawMessage); - - if (messageIndex.has(messageId)) { - if (options?.ignoreDuplicate === true) return null; - throw new Error( - `Duplicate mail: Message-ID ${messageId} already stored`, - ); - } - - const threadId = resolveThread(inReplyTo, references); - const ordinal = peekNextOrdinal(threadId); - const filename = `${formatOrdinal(ordinal)}-${direction}.eml`; - const filepath = runtime.path.join(MAIL_DIR, threadId, filename); - - const fullDir = runtime.path.join(dir, MAIL_DIR, threadId); - await runtime.fs.mkdir(fullDir, { recursive: true }); - - const fullPath = runtime.path.join(dir, filepath); - await runtime.fs.writeFile(fullPath, rawMessage); - - const label = direction === "in" ? "inbound" : "outbound"; - const subject = `Record ${label} mail ${messageId}`; - const message = - options?.checkpointHash !== undefined - ? `${subject}\n\nCheckpoint: ${options.checkpointHash}` - : subject; - try { - await git.add({ fs: runtime.fs.git, dir, filepath }); - await commitDurably(runtime, dir, { - message, - author: AUTHOR, - ...signingArgs, - }); - } catch (cause) { - try { - await restoreIndexAfterFailedCommit(runtime, dir, [filepath]); - } catch (reconciliationCause) { - reconciliationFailure = new Error( - "Could not restore the mail index after commit failure", - { - cause: new AggregateError([cause, reconciliationCause]), - }, - ); - throw reconciliationFailure; - } - - if (!(cause instanceof UncertainRefPublicationError)) throw cause; - - try { - const actualOid = await git.resolveRef({ - fs: runtime.fs.git, - dir, - ref: cause.ref, - }); - if (actualOid === cause.oid) { - advanceOrdinal(threadId); - messageIndex.set(messageId, threadId); - } - } catch (reconciliationCause) { - reconciliationFailure = new Error( - `Could not reconcile uncertain mail commit ${cause.oid}`, - { cause: reconciliationCause }, - ); - } - throw cause; - } - - advanceOrdinal(threadId); - messageIndex.set(messageId, threadId); - return { threadId, messageId, filepath }; - }); - } - - return { commitMail }; -} - -function parseFilename(filename: string): { - ordinal: number; - direction: MailDirection; -} { - const stem = filename.replace(/\.eml$/, ""); - const dashIndex = stem.indexOf("-"); - if (dashIndex === -1) { - throw new Error(`Malformed mail filename: ${filename}`); - } - - const ordinalStr = stem.slice(0, dashIndex); - const ordinal = parseInt(ordinalStr, 10); - if (isNaN(ordinal)) { - throw new Error(`Malformed mail filename: ${filename}`); - } - - const directionStr = stem.slice(dashIndex + 1); - if (directionStr !== "in" && directionStr !== "out") { - throw new Error( - `Invalid mail direction '${directionStr}' in filename: ${filename}`, - ); - } - - return { ordinal, direction: directionStr }; -} - -async function scanMail( - runtime: StorageRuntime, - dir: string, -): Promise { - const mailDir = runtime.path.join(dir, MAIL_DIR); - - let threadDirs: string[]; - try { - threadDirs = await runtime.fs.readdir(mailDir); - } catch (e: unknown) { - if (hasCode(e) && e.code === "ENOENT") { - return []; - } - throw e; - } - - threadDirs.sort(); - - const entries: MailEntry[] = []; - - for (const threadId of threadDirs) { - const threadPath = runtime.path.join(mailDir, threadId); - const stat = await runtime.fs.stat(threadPath); - if (!stat.isDirectory()) continue; - - const files = await runtime.fs.readdir(threadPath); - const emlFiles = files.filter((f) => f.endsWith(".eml")).sort(); - - for (const file of emlFiles) { - const { ordinal, direction } = parseFilename(file); - const fullPath = runtime.path.join(threadPath, file); - const raw = await runtime.fs.readFile(fullPath); - const { messageId } = parseThreadingHeaders(raw); - - entries.push({ threadId, ordinal, direction, messageId, raw }); - } - } - - return entries; -} - -export async function listMail( - runtime: StorageRuntime, - dir: string, -): Promise { - return scanMail(runtime, dir); -} - -async function rebuildIndex( - runtime: StorageRuntime, - dir: string, - messageIndex: Map, - threads: Map, -): Promise { - const entries = await scanMail(runtime, dir); - - for (const entry of entries) { - messageIndex.set(entry.messageId, entry.threadId); - - const state = threads.get(entry.threadId); - if (state === undefined) { - threads.set(entry.threadId, { nextOrdinal: entry.ordinal + 1 }); - } else if (entry.ordinal >= state.nextOrdinal) { - state.nextOrdinal = entry.ordinal + 1; - } - } -} diff --git a/packages/storage-isogit/src/node-metrics.ts b/packages/storage-isogit/src/node-metrics.ts index 9281c13a4..4ffffd33c 100644 --- a/packages/storage-isogit/src/node-metrics.ts +++ b/packages/storage-isogit/src/node-metrics.ts @@ -2,10 +2,6 @@ import fs from "node:fs"; import path from "node:path"; import { hasCode } from "@intx/types/has-code"; -import type { RepoDiskUsage, RepoObjectCounts } from "./repo-disk"; - -// Synchronous Node counterparts to the runtime-backed helpers in repo-disk.ts. -// Keep their counting and absence semantics aligned. function countDirEntries(dir: string): number { try { @@ -34,17 +30,6 @@ export function countLooseObjects(repoDir: string): number { return total; } -export function countPackFiles(repoDir: string): number { - const packDir = path.join(repoDir, ".git", "objects", "pack"); - try { - return fs.readdirSync(packDir).filter((name) => name.endsWith(".pack")) - .length; - } catch (cause) { - if (hasCode(cause) && cause.code === "ENOENT") return 0; - throw cause; - } -} - export function gitBytes(repoDir: string): number { let total = 0; const stack = [path.join(repoDir, ".git")]; @@ -68,17 +53,3 @@ export function gitBytes(repoDir: string): number { } return total; } - -export function repoObjectCounts(dir: string): RepoObjectCounts { - return { - packCount: countPackFiles(dir), - looseObjectCount: countLooseObjects(dir), - }; -} - -export function repoDiskUsage(dir: string): RepoDiskUsage { - return { - gitBytes: gitBytes(dir), - ...repoObjectCounts(dir), - }; -} diff --git a/packages/storage-isogit/src/node.ts b/packages/storage-isogit/src/node.ts index e807e6a0f..7fc948d53 100644 --- a/packages/storage-isogit/src/node.ts +++ b/packages/storage-isogit/src/node.ts @@ -4,7 +4,6 @@ import { createIsogitStorage, type CommitSigner, type DurableMirrorReads, - type GCPolicy, } from "./index"; import { createNodeIsogitRuntime } from "./node-runtime"; import { IsogitStore as RuntimeIsogitStore } from "./store"; @@ -15,33 +14,18 @@ export { syncDirectoryDurable, syncRemovedPathDurable, } from "./node-durability"; -export { - countLooseObjects, - countPackFiles, - gitBytes, - repoDiskUsage, -} from "./node-metrics"; +export { countLooseObjects, gitBytes } from "./node-metrics"; export { createIsogitStorage, type CommitSigner, type DurableMirrorReads, - type GCPolicy, type ContextStore, type AuditStore, - type CommitVerifier, type StagedPack, type IncludeShaPredicate, type ObjectPresence, type InitRepoOpts, - type RetentionPolicy, - type GCResult, - type RepoDiskUsage, - type MailAuditStore, - type MailCommitOptions, - type MailCommitResult, - type MailDirection, - type MailEntry, type IsogitPath, type IsogitRuntime, DEFAULT_PACK_MATERIALIZATION_LIMITS, @@ -54,24 +38,18 @@ const storage = createIsogitStorage(runtime); const { storageRuntime } = storage; export const { - applyPack, collectReachableObjects, createAndSwitchBranch, createDeployPack, - createMailAuditStore, createNegotiatedPack, createObjectPresence, currentBranch, initAgentRepo, initRepo, listBranches, - listMail, - listRepoRefs, logHistory, - maybeGC, removeOrphanPackIndexes, removeStagedPacks, - runGC, stagePack, switchBranch, writeTreeToDisk, @@ -96,15 +74,14 @@ export async function indexPackIntoGitDir( } export class IsogitStore extends RuntimeIsogitStore { - constructor(dir: string, signer?: CommitSigner, gcPolicy?: GCPolicy) { - super(storageRuntime, storageRuntime.path.resolve(dir), signer, gcPolicy); + constructor(dir: string, signer?: CommitSigner) { + super(storageRuntime, storageRuntime.path.resolve(dir), signer); } } export async function createIsogitStore( dir: string, signer?: CommitSigner, - gcPolicy?: GCPolicy, ): Promise { - return storage.createIsogitStore(dir, signer, gcPolicy); + return storage.createIsogitStore(dir, signer); } diff --git a/packages/storage-isogit/src/object-presence.ts b/packages/storage-isogit/src/object-presence.ts index 77d6db975..f3c799566 100644 --- a/packages/storage-isogit/src/object-presence.ts +++ b/packages/storage-isogit/src/object-presence.ts @@ -78,7 +78,7 @@ export function createObjectPresence( } } - // A pack can go only by a GC or by a loss, and a set that kept its + // A pack can go by a loss or a forced repack, and a set that kept its // names would answer for objects the repository no longer holds. The // rebuilt set replaces the old one only once it is complete, so a // query that runs alongside never sees a partial set. diff --git a/packages/storage-isogit/src/pack-receive.test.ts b/packages/storage-isogit/src/pack-receive.test.ts index 44fc895c5..3d74967f0 100644 --- a/packages/storage-isogit/src/pack-receive.test.ts +++ b/packages/storage-isogit/src/pack-receive.test.ts @@ -3,23 +3,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import git from "isomorphic-git"; -import { - generateKeyPair, - createSSHSignature, - verifySSHSignature, -} from "@intx/crypto"; import { initAgentRepo } from "./node"; -import { createIsogitStorage } from "./index"; import { - applyPack, removeOrphanPackIndexes, removeStagedPacks, stagePack, - type CommitVerifier, type StagedPack, } from "./node"; import { collectReachableObjects } from "./node"; -import { createNodeIsogitRuntime } from "./node-runtime"; const tempDirs: string[] = []; @@ -82,336 +73,6 @@ async function makeSourceRepo(): Promise<{ return { dir, commitSha, oids }; } -describe("applyPack", () => { - test("applies a packfile and updates the ref", async () => { - const source = await makeSourceRepo(); - const pack = await createPackFromRepo(source.dir, source.oids); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await applyPack( - targetDir, - pack, - "refs/heads/deploy", - source.commitSha, - "test-transfer-1", - ); - - const resolved = await git.resolveRef({ - fs, - dir: targetDir, - ref: "refs/heads/deploy", - }); - expect(resolved).toBe(source.commitSha); - }); - - test("retains pack and index in objects/pack after success", async () => { - const source = await makeSourceRepo(); - const pack = await createPackFromRepo(source.dir, source.oids); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await applyPack( - targetDir, - pack, - "refs/heads/deploy", - source.commitSha, - "cleanup-test", - ); - - const packPath = path.join( - targetDir, - ".git", - "objects", - "pack", - "pack-recv-cleanup-test.pack", - ); - const idxPath = path.join( - targetDir, - ".git", - "objects", - "pack", - "pack-recv-cleanup-test.idx", - ); - - // Pack and index are retained so git can read objects from them. - await fs.promises.access(packPath); - await fs.promises.access(idxPath); - }); - - test("checks out files to the working tree", async () => { - const source = await makeSourceRepo(); - const pack = await createPackFromRepo(source.dir, source.oids); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await applyPack( - targetDir, - pack, - "refs/heads/deploy", - source.commitSha, - "checkout-test", - ); - - const content = await fs.promises.readFile( - path.join(targetDir, "deploy", "prompt.txt"), - "utf-8", - ); - expect(content).toBe("You are a helpful agent."); - }); - - test("preserves executable mode on checked-out files", async () => { - const sourceDir = await tempDir(); - await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); - - const scriptPath = path.join(sourceDir, "deploy", "run.sh"); - await fs.promises.mkdir(path.join(sourceDir, "deploy"), { - recursive: true, - }); - await fs.promises.writeFile(scriptPath, "#!/bin/sh\necho hello\n", { - mode: 0o755, - }); - await git.add({ fs, dir: sourceDir, filepath: "deploy/run.sh" }); - - const commitSha = await git.commit({ - fs, - dir: sourceDir, - message: "Add executable script", - author: { name: "Test", email: "test@test.dev" }, - }); - - const oids = await collectReachableObjects(sourceDir, commitSha); - const pack = await createPackFromRepo(sourceDir, oids); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await applyPack( - targetDir, - pack, - "refs/heads/deploy", - commitSha, - "exec-test", - ); - - const stat = await fs.promises.stat( - path.join(targetDir, "deploy", "run.sh"), - ); - const isExecutable = (stat.mode & 0o111) !== 0; - expect(isExecutable).toBe(true); - }); - - test("removes stale files when a second deploy drops content", async () => { - // First commit: two state subtrees (turns + responses). - const sourceDir = await tempDir(); - await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); - - const turnsDir = path.join(sourceDir, "state", "turns"); - const responsesDir = path.join(sourceDir, "state", "responses"); - await fs.promises.mkdir(turnsDir, { recursive: true }); - await fs.promises.mkdir(responsesDir, { recursive: true }); - await fs.promises.writeFile( - path.join(turnsDir, "turn-1.json"), - '{"id":"turn-1"}', - ); - await fs.promises.writeFile( - path.join(responsesDir, "response-1.json"), - '{"id":"response-1"}', - ); - await git.add({ - fs, - dir: sourceDir, - filepath: "state/turns/turn-1.json", - }); - await git.add({ - fs, - dir: sourceDir, - filepath: "state/responses/response-1.json", - }); - const sha1 = await git.commit({ - fs, - dir: sourceDir, - message: "Two state subtrees", - author: { name: "Test", email: "test@test.dev" }, - }); - - const oids1 = await collectReachableObjects(sourceDir, sha1); - const pack1 = await createPackFromRepo(sourceDir, oids1); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await applyPack(targetDir, pack1, "refs/heads/deploy", sha1, "deploy-v1"); - - // Verify both subtrees exist after first deploy. - await fs.promises.access( - path.join(targetDir, "state", "responses", "response-1.json"), - ); - - // Second commit: remove responses, keep turns. - await fs.promises.rm(responsesDir, { recursive: true }); - await git.remove({ - fs, - dir: sourceDir, - filepath: "state/responses/response-1.json", - }); - const sha2 = await git.commit({ - fs, - dir: sourceDir, - message: "Remove responses", - author: { name: "Test", email: "test@test.dev" }, - }); - - const oids2 = await collectReachableObjects(sourceDir, sha2); - const pack2 = await createPackFromRepo(sourceDir, oids2); - - await applyPack(targetDir, pack2, "refs/heads/deploy", sha2, "deploy-v2"); - - // Turns should still exist. - const turnContent = await fs.promises.readFile( - path.join(targetDir, "state", "turns", "turn-1.json"), - "utf-8", - ); - expect(turnContent).toBe('{"id":"turn-1"}'); - - // Responses should be gone — stale files must not linger. - const responsesExist = await fs.promises - .access(path.join(targetDir, "state", "responses", "response-1.json")) - .then(() => true) - .catch(() => false); - expect(responsesExist).toBe(false); - }); - - test("removes stale top-level directories absent from new tree", async () => { - // First commit: deploy/ and config/ at the top level. - const sourceDir = await tempDir(); - await git.init({ fs, dir: sourceDir, defaultBranch: "main" }); - - await fs.promises.mkdir(path.join(sourceDir, "deploy"), { - recursive: true, - }); - await fs.promises.mkdir(path.join(sourceDir, "config"), { - recursive: true, - }); - await fs.promises.writeFile( - path.join(sourceDir, "deploy", "prompt.md"), - "hello", - ); - await fs.promises.writeFile( - path.join(sourceDir, "config", "settings.json"), - "{}", - ); - await git.add({ fs, dir: sourceDir, filepath: "deploy/prompt.md" }); - await git.add({ fs, dir: sourceDir, filepath: "config/settings.json" }); - const sha1 = await git.commit({ - fs, - dir: sourceDir, - message: "Deploy and config", - author: { name: "Test", email: "test@test.dev" }, - }); - - const oids1 = await collectReachableObjects(sourceDir, sha1); - const pack1 = await createPackFromRepo(sourceDir, oids1); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await applyPack(targetDir, pack1, "refs/heads/deploy", sha1, "tld-v1"); - - // Verify both top-level dirs exist. - await fs.promises.access(path.join(targetDir, "config", "settings.json")); - - // Second commit: remove config/ entirely. - await fs.promises.rm(path.join(sourceDir, "config"), { recursive: true }); - await git.remove({ - fs, - dir: sourceDir, - filepath: "config/settings.json", - }); - const sha2 = await git.commit({ - fs, - dir: sourceDir, - message: "Remove config", - author: { name: "Test", email: "test@test.dev" }, - }); - - const oids2 = await collectReachableObjects(sourceDir, sha2); - const pack2 = await createPackFromRepo(sourceDir, oids2); - - await applyPack(targetDir, pack2, "refs/heads/deploy", sha2, "tld-v2"); - - // deploy/ should still exist. - const promptContent = await fs.promises.readFile( - path.join(targetDir, "deploy", "prompt.md"), - "utf-8", - ); - expect(promptContent).toBe("hello"); - - // config/ should be gone. - const configExists = await fs.promises - .access(path.join(targetDir, "config")) - .then(() => true) - .catch(() => false); - expect(configExists).toBe(false); - }); - - test("throws on sha mismatch", async () => { - const source = await makeSourceRepo(); - const pack = await createPackFromRepo(source.dir, source.oids); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await expect( - applyPack( - targetDir, - pack, - "refs/heads/deploy", - "0000000000000000000000000000000000000000", - "mismatch-test", - ), - ).rejects.toThrow("sha_mismatch"); - }); - - test("cleans up pack files after failure", async () => { - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - // Write garbage as a pack — indexPack will fail - const garbagePack = new Uint8Array([1, 2, 3, 4]); - - await expect( - applyPack( - targetDir, - garbagePack, - "refs/heads/deploy", - "abc123", - "fail-cleanup", - ), - ).rejects.toThrow(); - - const packPath = path.join( - targetDir, - ".git", - "objects", - "pack", - "pack-recv-fail-cleanup.pack", - ); - const idxPath = path.join( - targetDir, - ".git", - "objects", - "pack", - "pack-recv-fail-cleanup.idx", - ); - await expect(fs.promises.access(packPath)).rejects.toThrow(); - await expect(fs.promises.access(idxPath)).rejects.toThrow(); - }); -}); - describe("stagePack", () => { function stagingDirOf(targetDir: string, transferId: string): string { return path.join(targetDir, ".git", "objects", "pack-staging", transferId); @@ -529,6 +190,20 @@ describe("stagePack", () => { } }); + test("unindexable bytes reject and leave no staging directory or pack", async () => { + const targetDir = await tempDir(); + await initAgentRepo(targetDir); + + await expect( + stagePack(targetDir, new Uint8Array([1, 2, 3, 4]), "unindexable"), + ).rejects.toThrow(); + + await expect( + fs.promises.access(stagingDirOf(targetDir, "unindexable")), + ).rejects.toThrow(); + expect(await packDirEntries(targetDir)).toEqual([]); + }); + test("discard leaves no staging directory and nothing in objects/pack", async () => { const source = await makeSourceRepo(); const pack = await createPackFromRepo(source.dir, source.oids); @@ -765,278 +440,3 @@ describe("stagePack", () => { ).rejects.toThrow(); }); }); - -async function makeSignedSourceRepo(keyPair: { - privateKey: Uint8Array; - publicKey: Uint8Array; -}): Promise<{ - dir: string; - commitSha: string; - oids: string[]; -}> { - const dir = await tempDir(); - await git.init({ fs, dir, defaultBranch: "main" }); - - const filePath = path.join(dir, "deploy", "prompt.txt"); - await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); - await fs.promises.writeFile(filePath, "You are a helpful agent."); - await git.add({ fs, dir, filepath: "deploy/prompt.txt" }); - - const commitSha = await git.commit({ - fs, - dir, - message: "Signed deploy", - author: { name: "Test", email: "test@test.dev" }, - signingKey: "sshsig", - onSign: async ({ payload }) => ({ - signature: await createSSHSignature( - payload, - keyPair.privateKey, - keyPair.publicKey, - ), - }), - }); - - const oids = await collectReachableObjects(dir, commitSha); - return { dir, commitSha, oids }; -} - -describe("applyPack signature verification", () => { - test("accepts a correctly signed pack", async () => { - const keyPair = await generateKeyPair(); - const source = await makeSignedSourceRepo(keyPair); - const pack = await createPackFromRepo(source.dir, source.oids); - - const verifier: CommitVerifier = (payload, signature) => - verifySSHSignature(payload, signature, keyPair.publicKey); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await applyPack( - targetDir, - pack, - "refs/heads/deploy", - source.commitSha, - "signed-ok", - verifier, - ); - - const resolved = await git.resolveRef({ - fs, - dir: targetDir, - ref: "refs/heads/deploy", - }); - expect(resolved).toBe(source.commitSha); - }); - - test("rejects a pack signed with the wrong key", async () => { - const signerKey = await generateKeyPair(); - const verifierKey = await generateKeyPair(); - const source = await makeSignedSourceRepo(signerKey); - const pack = await createPackFromRepo(source.dir, source.oids); - - const verifier: CommitVerifier = (payload, signature) => - verifySSHSignature(payload, signature, verifierKey.publicKey); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await expect( - applyPack( - targetDir, - pack, - "refs/heads/deploy", - source.commitSha, - "wrong-key", - verifier, - ), - ).rejects.toThrow("signature_invalid"); - }); - - test("rejects an unsigned commit when verifier is provided", async () => { - const source = await makeSourceRepo(); - const pack = await createPackFromRepo(source.dir, source.oids); - - const keyPair = await generateKeyPair(); - const verifier: CommitVerifier = (payload, signature) => - verifySSHSignature(payload, signature, keyPair.publicKey); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await expect( - applyPack( - targetDir, - pack, - "refs/heads/deploy", - source.commitSha, - "unsigned", - verifier, - ), - ).rejects.toThrow("signature_unsigned"); - }); - - test("skips verification when no verifier is provided", async () => { - const source = await makeSourceRepo(); - const pack = await createPackFromRepo(source.dir, source.oids); - - const targetDir = await tempDir(); - await initAgentRepo(targetDir); - - await applyPack( - targetDir, - pack, - "refs/heads/deploy", - source.commitSha, - "no-verify", - ); - - const resolved = await git.resolveRef({ - fs, - dir: targetDir, - ref: "refs/heads/deploy", - }); - expect(resolved).toBe(source.commitSha); - }); -}); - -// Helpers used by the rejection and flush tests below. The pack -// filename layout is derived from `transferId` per -// `publishedPackPaths` in pack-receive.ts; replicate the derivation -// here so the assertions stay decoupled from that helper's accessors -// (no internals exported). -function publishedPackFile(targetDir: string, transferId: string): string { - return path.join( - targetDir, - ".git", - "objects", - "pack", - `pack-recv-${transferId}.pack`, - ); -} - -function publishedIdxFile(targetDir: string, transferId: string): string { - return path.join( - targetDir, - ".git", - "objects", - "pack", - `pack-recv-${transferId}.idx`, - ); -} - -async function fileExists(p: string): Promise { - try { - await fs.promises.access(p); - return true; - } catch { - return false; - } -} - -function createStorageFailingPromotionFlush( - targetDir: string, - ref: string, - expectedSha: string, -) { - let failureInjected = false; - return createIsogitStorage({ - ...createNodeIsogitRuntime(), - flush: async () => { - const currentSha = await git - .resolveRef({ fs, dir: targetDir, ref }) - .catch(() => null); - if (!failureInjected && currentSha === expectedSha) { - failureInjected = true; - throw new Error("injected post-promotion flush failure"); - } - }, - }); -} - -describe("post-promotion flush failures", () => { - test("apply retains the durable pack and materialized tree for the promoted ref", async () => { - const source = await makeSourceRepo(); - const pack = await createPackFromRepo(source.dir, source.oids); - const targetDir = await tempDir(); - const ref = "refs/heads/deploy"; - const transferId = "apply-flush-failure"; - const storage = createStorageFailingPromotionFlush( - targetDir, - ref, - source.commitSha, - ); - await storage.initAgentRepo(targetDir); - - await expect( - storage.applyPack(targetDir, pack, ref, source.commitSha, transferId), - ).rejects.toThrow("injected post-promotion flush failure"); - - expect(await fileExists(publishedPackFile(targetDir, transferId))).toBe( - true, - ); - expect(await fileExists(publishedIdxFile(targetDir, transferId))).toBe( - true, - ); - expect(await git.resolveRef({ fs, dir: targetDir, ref })).toBe( - source.commitSha, - ); - expect( - await fs.promises.readFile( - path.join(targetDir, "deploy", "prompt.txt"), - "utf8", - ), - ).toBe("You are a helpful agent."); - }); -}); - -describe("applyPack keeps a rejected pack out of objects/pack", () => { - test("sha_mismatch publishes nothing and leaves no staged pack", async () => { - const source = await makeSourceRepo(); - const pack = await createPackFromRepo(source.dir, source.oids); - - const targetDir = await tempDir(); - let flushes = 0; - const storage = createIsogitStorage({ - ...createNodeIsogitRuntime(), - flush: () => { - flushes += 1; - return Promise.resolve(); - }, - }); - await storage.initAgentRepo(targetDir); - const flushesBefore = flushes; - - const bogusExpected = "0".repeat(40); - await expect( - storage.applyPack( - targetDir, - pack, - "refs/heads/deploy", - bogusExpected, - "apply-rejected-sha", - ), - ).rejects.toThrow("sha_mismatch"); - - expect( - await fileExists(publishedPackFile(targetDir, "apply-rejected-sha")), - ).toBe(false); - expect( - await fileExists(publishedIdxFile(targetDir, "apply-rejected-sha")), - ).toBe(false); - expect( - await fileExists( - path.join( - targetDir, - ".git", - "objects", - "pack-staging", - "apply-rejected-sha", - ), - ), - ).toBe(false); - // Nothing reached objects/pack, so there is nothing to make durable. - expect(flushes).toBe(flushesBefore); - }); -}); diff --git a/packages/storage-isogit/src/pack-receive.ts b/packages/storage-isogit/src/pack-receive.ts index 0bdf11e99..139546c70 100644 --- a/packages/storage-isogit/src/pack-receive.ts +++ b/packages/storage-isogit/src/pack-receive.ts @@ -1,56 +1,7 @@ import git, { type FsClient } from "isomorphic-git"; -import { readRawObject } from "./isogit-helpers"; -import { withRepoDirLock } from "./repo-lock"; -import { - decodeUTF8, - flushRuntime, - promiseFsMethods, - type StorageRuntime, -} from "./runtime"; +import { promiseFsMethods, type StorageRuntime } from "./runtime"; import { hasCode } from "@intx/types/has-code"; -/** - * Verifies the signature embedded in a git commit object. - * - * Callers bind this to their verification implementation (e.g. - * verifySSHSignature with the hub's public key). The storage layer does - * not own key material. - * - * Returns true when the signature is valid. Should throw on malformed - * input and return false on cryptographic failure. - */ -export type CommitVerifier = ( - payload: string, - signature: string, -) => Promise; - -/** - * Strip the gpgsig header from a raw git commit object, producing the - * payload that was originally signed. - * - * isomorphic-git's readCommit().payload uses withoutSignature() which is - * hardcoded to look for PGP armor markers. SSH signatures use different - * markers, so the built-in reconstruction is wrong. This function works - * with any signature format by parsing the header structure directly. - */ -function stripGpgsig(raw: string): string { - const gpgsigIdx = raw.indexOf("\ngpgsig "); - if (gpgsigIdx === -1) return raw; - - // The gpgsig header spans from "\ngpgsig " to the next header line that - // does not start with a space. Continuation lines in git headers are - // indented with a single leading space. - let endIdx = gpgsigIdx + 1; - while (endIdx < raw.length) { - const nlIdx = raw.indexOf("\n", endIdx); - if (nlIdx === -1) break; - endIdx = nlIdx + 1; - if (endIdx < raw.length && raw[endIdx] !== " ") break; - } - - return raw.substring(0, gpgsigIdx) + "\n" + raw.substring(endIdx); -} - const SAFE_PATH_SEGMENT = /^[a-zA-Z0-9_-]+$/; // Sibling of objects/pack/. Both directories share the storage namespace in @@ -97,116 +48,6 @@ async function pathExists( } } -type TreeEntry = { - type: "blob" | "tree" | "commit"; - mode: string; - path: string; - oid: string; -}; - -async function topLevelNames( - gitFs: FsClient, - dir: string, - oid: string, -): Promise> { - const { tree } = await git.readTree({ fs: gitFs, dir, oid }); - return new Set(tree.map((e) => e.path)); -} - -/** - * Remove stale working-tree content and write the commit's tree to disk. - * - * Derives the set of deploy-managed top-level entries from the union of the - * old and new commit trees, removes those entries, then writes the new tree. - * Paths that never appear in any commit tree (e.g. .git, state, keys) are - * never touched. - * - * NOTE: The rm-then-write sequence is not atomic. If writeTree fails after rm - * succeeds (e.g. disk full), the working tree will be missing the cleared - * paths. The ref is not updated in that case (caller writes ref after this - * function returns), so a restart will re-read from the prior commit, but the - * working tree will be stale until the next successful applyPack. - * - * Objects are read through `gitFs`, so a caller can check out a pack - * that is still staged. - */ -async function checkoutTree( - runtime: StorageRuntime, - gitFs: FsClient, - dir: string, - commitSha: string, - ref: string, -): Promise { - const { commit } = await git.readCommit({ - fs: gitFs, - dir, - oid: commitSha, - }); - const { tree } = await git.readTree({ - fs: gitFs, - dir, - oid: commit.tree, - }); - - // Collect top-level names managed by deploy trees (new + previous). - const managed = new Set(tree.map((e) => e.path)); - - const prevSha = await git - .resolveRef({ fs: gitFs, dir, ref }) - .catch(() => null); - if (prevSha !== null) { - const { commit: prev } = await git.readCommit({ - fs: gitFs, - dir, - oid: prevSha, - }); - for (const name of await topLevelNames(gitFs, dir, prev.tree)) { - managed.add(name); - } - } - - const existing = await runtime.fs.readdir(dir); - for (const name of existing) { - if (!managed.has(name)) continue; - await runtime.fs.remove(runtime.path.join(dir, name), { - recursive: true, - force: true, - }); - } - - await writeTreeEntries(runtime, gitFs, dir, dir, tree); -} - -async function writeTreeEntries( - runtime: StorageRuntime, - gitFs: FsClient, - repoDir: string, - targetDir: string, - entries: TreeEntry[], -): Promise { - for (const entry of entries) { - const entryPath = runtime.path.join(targetDir, entry.path); - if (entry.type === "tree") { - await runtime.fs.mkdir(entryPath, { recursive: true }); - const { tree } = await git.readTree({ - fs: gitFs, - dir: repoDir, - oid: entry.oid, - }); - await writeTreeEntries(runtime, gitFs, repoDir, entryPath, tree); - } else if (entry.type === "blob") { - const { blob } = await git.readBlob({ - fs: gitFs, - dir: repoDir, - oid: entry.oid, - }); - await runtime.fs.writeFile(entryPath, blob, { - mode: entry.mode === "100755" ? 0o755 : 0o644, - }); - } - } -} - /** * A received pack written and indexed outside `objects/pack/`, which * readers that use the repo's own filesystem do not find. `fs` is an @@ -302,9 +143,8 @@ export type StagedPack = { * in 3a. * * c. Remove the now-empty staging directory. `publish` does not - * flush: a caller that writes a ref to the pack flushes first, - * and one that publishes as part of a larger change (GC) - * flushes at its own commit point. + * flush: on a runtime that defines a flush, a caller that writes + * a ref to the pack flushes first. * * 4. On any throw in steps 1-2, and on `discard`, recursively remove * the staging directory. Forced recursive removal tolerates @@ -538,9 +378,8 @@ export async function removeStagedPacks( /** * Remove each pack index under `dir` whose pack is gone, and return the - * paths removed. A publish renames the pack before its index and GC - * moves the index out first, so no write leaves an index without its - * pack; a lost rename or a lost file does. A read of an object that + * paths removed. A publish renames the pack before its index, so no write + * leaves an index without its pack; a lost rename or a lost file does. A read of an object that * such an index names fails as an internal error instead of finding no * object, so a caller removes the index before it reads the repository. * The objects it names went with the pack. @@ -568,109 +407,3 @@ export async function removeOrphanPackIndexes( } return removed; } - -/** - * Apply a git packfile to a repository, check out the working tree, and - * update the ref. - * - * Stages the pack and creates its .idx index outside .git/objects/pack/, - * checks out the tree to the working directory through the staged view, - * publishes the pack into .git/objects/pack/ (the standard location for - * git packfiles), then updates `ref` to point at `expectedSha`. The ref - * is written last so it never points at a commit whose working tree has - * not been materialized. - * - * When `verifyCommit` is provided, the commit's embedded signature is - * verified before the working tree is materialized. Throws with a message - * prefixed by `"signature_unsigned"` if the commit has no signature, or - * `"signature_invalid"` if verification fails. Only omit `verifyCommit` - * for state packs that follow their own signing model. - * - * Throws if the expected commit is not found in the pack. A pack that - * fails a check never enters .git/objects/pack/. - * - * The caller is responsible for ensuring `dir` is an initialized git repo. - */ -export async function applyPack( - runtime: StorageRuntime, - dir: string, - pack: Uint8Array, - ref: string, - expectedSha: string, - transferId: string, - verifyCommit?: CommitVerifier, -): Promise { - // The deploy apply shares the agent repo's object store with the - // reactor's context commits, the mail-audit commits, and GC. Hold the - // per-directory lock across the stage, validation, checkout, publish, - // and ref write so none of them interleave with this apply. - await withRepoDirLock(runtime, dir, async () => { - const staged = await stagePack(runtime, dir, pack, transferId); - - // Sidecar `applyPack` is the last line of defence against a - // compromised hub or transport. Every check and the checkout read - // the pack through the staged view, so a rejected pack is discarded - // without ever entering objects/pack/, and a flood of - // rejected-signature packs cannot accumulate there. - try { - if (!staged.oids.includes(expectedSha)) { - throw new Error( - `sha_mismatch: expected commit ${expectedSha} not found in pack`, - ); - } - - if (verifyCommit !== undefined) { - const { commit } = await git.readCommit({ - fs: staged.fs, - dir, - oid: expectedSha, - }); - if (commit.gpgsig === undefined) { - throw new Error( - `signature_unsigned: commit ${expectedSha} has no signature`, - ); - } - - // Reconstruct the signing payload from the raw object bytes. - // readCommit().payload is unreliable for SSH signatures because - // isogit's withoutSignature() only handles PGP armor markers. - const { object: rawBytes } = await readRawObject( - staged.fs, - dir, - expectedSha, - ); - const payload = stripGpgsig(decodeUTF8(rawBytes)); - - if (!(await verifyCommit(payload, commit.gpgsig))) { - throw new Error( - `signature_invalid: commit ${expectedSha} signature verification failed`, - ); - } - } - - // Ref is written last so it never references a commit whose working - // tree has not been materialized. - await checkoutTree(runtime, staged.fs, dir, expectedSha, ref); - } catch (err) { - await staged.discard().catch(() => undefined); - throw err; - } - - // Make the accepted pack durable before ref promotion. A later - // failure may leave the promotion outcome uncertain, but every - // possible ref value will resolve to a durable object store. - await staged.publish(); - await flushRuntime(runtime); - - // Do not remove the pack after promotion begins: writeRef or the - // final flush can fail after making the new ref observable. - await git.writeRef({ - fs: runtime.fs.git, - dir, - ref, - value: expectedSha, - force: true, - }); - await flushRuntime(runtime); - }); -} diff --git a/packages/storage-isogit/src/pack-send.test.ts b/packages/storage-isogit/src/pack-send.test.ts index 4d01b00c2..a420f9cb5 100644 --- a/packages/storage-isogit/src/pack-send.test.ts +++ b/packages/storage-isogit/src/pack-send.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import git from "isomorphic-git"; import { createDeployPack, createNegotiatedPack } from "./node"; import { collectReachableObjects } from "./node"; -import { applyPack } from "./node"; +import { stagePack } from "./node"; import { initAgentRepo } from "./node"; const tempDirs: string[] = []; @@ -25,25 +25,25 @@ afterEach(async () => { ); }); -async function makeDeployRepo(): Promise { +async function makeSourceRepo(): Promise { const dir = await tempDir(); await git.init({ fs, dir, defaultBranch: "main" }); - await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true }); + await fs.promises.mkdir(path.join(dir, "src"), { recursive: true }); await fs.promises.writeFile( - path.join(dir, "deploy", "prompt.txt"), + path.join(dir, "src", "prompt.txt"), "You are a helpful agent.", ); await fs.promises.writeFile( - path.join(dir, "deploy", "metadata.json"), + path.join(dir, "src", "metadata.json"), JSON.stringify({ version: "1" }), ); - await git.add({ fs, dir, filepath: "deploy/prompt.txt" }); - await git.add({ fs, dir, filepath: "deploy/metadata.json" }); + await git.add({ fs, dir, filepath: "src/prompt.txt" }); + await git.add({ fs, dir, filepath: "src/metadata.json" }); await git.commit({ fs, dir, - message: "Initial deploy tree", + message: "Initial tree", author: { name: "Hub", email: "hub@interchange.dev" }, }); @@ -51,8 +51,8 @@ async function makeDeployRepo(): Promise { } describe("createDeployPack", () => { - test("produces a packfile from a repo with a deploy tree", async () => { - const sourceDir = await makeDeployRepo(); + test("produces a packfile from a repo", async () => { + const sourceDir = await makeSourceRepo(); const { pack, commitSha } = await createDeployPack( sourceDir, "refs/heads/main", @@ -62,8 +62,8 @@ describe("createDeployPack", () => { expect(commitSha.length).toBe(40); }); - test("produced pack can be applied to a fresh agent repo", async () => { - const sourceDir = await makeDeployRepo(); + test("produced pack can be staged into a fresh agent repo", async () => { + const sourceDir = await makeSourceRepo(); const { pack, commitSha } = await createDeployPack( sourceDir, "refs/heads/main", @@ -72,18 +72,19 @@ describe("createDeployPack", () => { const targetDir = await tempDir(); await initAgentRepo(targetDir); - await applyPack(targetDir, pack, "refs/heads/deploy", commitSha, "t1"); + const staged = await stagePack(targetDir, pack, "t1"); + await staged.publish(); - const resolved = await git.resolveRef({ + const { oid } = await git.readCommit({ fs, dir: targetDir, - ref: "refs/heads/deploy", + oid: commitSha, }); - expect(resolved).toBe(commitSha); + expect(oid).toBe(commitSha); }); test("throws for a nonexistent ref", async () => { - const dir = await makeDeployRepo(); + const dir = await makeSourceRepo(); await expect( createDeployPack(dir, "refs/heads/nonexistent"), ).rejects.toThrow(); diff --git a/packages/storage-isogit/src/pack-send.ts b/packages/storage-isogit/src/pack-send.ts index f18456d82..18b1438d2 100644 --- a/packages/storage-isogit/src/pack-send.ts +++ b/packages/storage-isogit/src/pack-send.ts @@ -1,39 +1,33 @@ import git from "isomorphic-git"; import { collectReachableObjects } from "./object-walk"; -import { withRepoDirLock } from "./repo-lock"; import type { StorageRuntime } from "./runtime"; /** - * Create a git packfile containing all objects reachable from a ref. + * Create a git packfile containing a ref's tip commit and its tree. * - * Used by the hub to produce deploy packs for transfer to sidecars. The - * caller sends the resulting bytes as chunked repo.pack.push frames. + * Used by the hub to produce the packs a deploy frame carries. */ export async function createDeployPack( runtime: StorageRuntime, dir: string, ref: string, ): Promise<{ pack: Uint8Array; commitSha: string }> { - // Read under the per-directory lock so a concurrent GC pass cannot prune - // a loose object out from under the reachability walk or the pack write. - return withRepoDirLock(runtime, dir, async () => { - const commitSha = await git.resolveRef({ fs: runtime.fs.git, dir, ref }); - const oids = await collectReachableObjects(runtime, dir, commitSha); - - const result = await git.packObjects({ - fs: runtime.fs.git, - dir, - oids, - write: false, - }); - if (result.packfile === undefined) { - throw new Error( - `packObjects returned no packfile for ref "${ref}" (${commitSha})`, - ); - } + const commitSha = await git.resolveRef({ fs: runtime.fs.git, dir, ref }); + const oids = await collectReachableObjects(runtime, dir, commitSha); - return { pack: result.packfile, commitSha }; + const result = await git.packObjects({ + fs: runtime.fs.git, + dir, + oids, + write: false, }); + if (result.packfile === undefined) { + throw new Error( + `packObjects returned no packfile for ref "${ref}" (${commitSha})`, + ); + } + + return { pack: result.packfile, commitSha }; } /** diff --git a/packages/storage-isogit/src/repo-disk.ts b/packages/storage-isogit/src/repo-disk.ts deleted file mode 100644 index e29166218..000000000 --- a/packages/storage-isogit/src/repo-disk.ts +++ /dev/null @@ -1,203 +0,0 @@ -import git from "isomorphic-git"; -import { hasCode } from "@intx/types/has-code"; -import type { StorageRuntime } from "./runtime"; - -/** - * Disk-occupancy snapshot of an agent repo's `.git` directory. Drives the - * write-path GC trigger (pack count crossing a threshold) and the - * disk-pressure observability warning (byte size crossing a threshold). - */ -export type RepoDiskUsage = { - gitBytes: number; - packCount: number; - looseObjectCount: number; -}; - -/** - * Count the immediate child entries of `dir`. Returns 0 when the directory - * does not exist (a repo subtree that has not been created yet) -- absence - * is a real zero, not an error to surface. - */ -async function countDirEntries( - runtime: StorageRuntime, - dir: string, -): Promise { - try { - return (await runtime.fs.readdir(dir)).length; - } catch (cause) { - if (hasCode(cause) && cause.code === "ENOENT") { - return 0; - } - throw cause; - } -} - -/** - * Count loose git objects under `.git/objects//`. Loose objects are the - * un-packed per-commit objects isomorphic-git writes on each commit; their - * count rising and collapsing after a repack is the pack-growth signature a - * GC pass reclaims. The two-hex-char fan-out dirs plus `pack`/`info` are the - * only children of `objects/`; the latter two are skipped. Keep this filter in - * step with the synchronous Node counterpart in `node-metrics.ts`. - */ -export async function countLooseObjects( - runtime: StorageRuntime, - repoDir: string, -): Promise { - const objectsDir = runtime.path.join(repoDir, ".git", "objects"); - let fanoutDirs: string[]; - try { - fanoutDirs = await runtime.fs.readdir(objectsDir); - } catch (cause) { - if (hasCode(cause) && cause.code === "ENOENT") { - return 0; - } - throw cause; - } - let total = 0; - for (const name of fanoutDirs) { - if (name === "pack" || name === "info") continue; - // Loose-object fan-out dirs are exactly two lowercase hex chars. - if (!/^[0-9a-f]{2}$/.test(name)) continue; - total += await countDirEntries( - runtime, - runtime.path.join(objectsDir, name), - ); - } - return total; -} - -/** - * Count `.pack` files under `.git/objects/pack/`. Each accepted receive - * publishes one pack and the prior tip's pack is never reclaimed without a - * GC pass, so the pack count is the monotonic accumulation a write-path GC - * trigger watches. - */ -export async function countPackFiles( - runtime: StorageRuntime, - repoDir: string, -): Promise { - const packDir = runtime.path.join(repoDir, ".git", "objects", "pack"); - let entries: string[]; - try { - entries = await runtime.fs.readdir(packDir); - } catch (cause) { - if (hasCode(cause) && cause.code === "ENOENT") { - return 0; - } - throw cause; - } - return entries.filter((name) => name.endsWith(".pack")).length; -} - -/** - * Total byte size of the repo's `.git` directory (loose + pack + refs + - * logs). A coarse repo-size proxy for the disk-pressure warning. Walks the - * tree through the configured filesystem; bounded by the repo size, which is - * the thing being measured. - */ -export async function gitBytes( - runtime: StorageRuntime, - repoDir: string, -): Promise { - const gitDir = runtime.path.join(repoDir, ".git"); - let total = 0; - const stack: string[] = [gitDir]; - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined) break; - let stat; - try { - stat = await runtime.fs.lstat(current); - } catch (cause) { - if (hasCode(cause) && cause.code === "ENOENT") { - continue; - } - throw cause; - } - if (stat.isDirectory()) { - for (const child of await runtime.fs.readdir(current)) { - stack.push(runtime.path.join(current, child)); - } - } else if (stat.isFile()) { - total += stat.size; - } - } - return total; -} - -/** - * The object counts alone, without the full `.git` byte walk. The - * write-path GC trigger samples these on every write, so they must stay - * cheap — two directory reads — and leave the recursive byte walk - * (`gitBytes`) for the infrequent reclaim path. - */ -export type RepoObjectCounts = { - packCount: number; - looseObjectCount: number; -}; - -export async function repoObjectCounts( - runtime: StorageRuntime, - dir: string, -): Promise { - const [packCount, looseObjectCount] = await Promise.all([ - countPackFiles(runtime, dir), - countLooseObjects(runtime, dir), - ]); - return { - packCount, - looseObjectCount, - }; -} - -/** - * Snapshot the GC-relevant disk counters for the agent repo at `dir`, - * including the full `.git` byte walk. - */ -export async function repoDiskUsage( - runtime: StorageRuntime, - dir: string, -): Promise { - const [bytes, counts] = await Promise.all([ - gitBytes(runtime, dir), - repoObjectCounts(runtime, dir), - ]); - return { - gitBytes: bytes, - ...counts, - }; -} - -/** Hub-owned refs that keep commits alive without showing up as branches. */ -export const HUB_REF_NAMESPACE = "refs/interchange"; - -/** - * Resolve every local branch ref and Hub-owned ref of the repo at `dir` to its - * tip SHA. - * - * Agent repos carry two diverging heads (`refs/heads/main` and - * `refs/heads/deploy`); GC must union reachability across all of them, so it - * enumerates them here rather than assuming a single ref. Besides the - * Hub-owned refs under `refs/interchange`, no tags or other ref namespaces are - * created in these repos, so this listing is complete. - */ -export async function listRepoRefs( - runtime: StorageRuntime, - dir: string, -): Promise<{ ref: string; oid: string }[]> { - const [branches, hubRefs] = await Promise.all([ - git.listBranches({ fs: runtime.fs.git, dir }), - git.listRefs({ fs: runtime.fs.git, dir, filepath: HUB_REF_NAMESPACE }), - ]); - const names = [ - ...branches.map((branch) => `refs/heads/${branch}`), - ...hubRefs.map((name) => `${HUB_REF_NAMESPACE}/${name}`), - ]; - const refs: { ref: string; oid: string }[] = []; - for (const ref of names) { - const oid = await git.resolveRef({ fs: runtime.fs.git, dir, ref }); - refs.push({ ref, oid }); - } - return refs; -} diff --git a/packages/storage-isogit/src/repo-lock.ts b/packages/storage-isogit/src/repo-lock.ts index 916f59281..bc163d926 100644 --- a/packages/storage-isogit/src/repo-lock.ts +++ b/packages/storage-isogit/src/repo-lock.ts @@ -1,26 +1,19 @@ import type { StorageRuntime } from "./runtime"; /** - * Process-wide per-directory serialization for agent-repo object-store + * Process-wide per-directory serialization for a store's object-store * mutations. Keyed by the lexically-resolved absolute path of the repo * working directory. * - * On the sidecar a single agent repo is written by several independent - * drivers — the reactor's context commits, the mail-audit commits, deploy - * pack applies — none of which share a higher-level lock. Garbage - * collection prunes loose objects and packs, so - * it cannot run concurrently with any of them without risking the deletion - * of an object a writer just produced. This lock makes the storage layer - * the single owner of that constraint: every mutator and the collector - * acquire it, so they run one-at-a-time per directory and GC observes a - * quiescent object store. + * On the sidecar a single store is written by several independent + * drivers — the reactor's context commits and the audit commits — none + * of which share a higher-level lock. This lock makes the storage layer the + * single owner of their serialization: every mutator acquires it, so they run + * one-at-a-time per directory. * * Single-process only — it serializes operations issued from one process, * not across a second process or an external git client touching the same - * directory. The hub's higher-level `withRepoLock` already serializes its - * own write paths; this lock nests harmlessly underneath it (the hub never - * holds this lock while acquiring its own, so the acquisition order is - * always outer-to-inner and cannot deadlock). + * directory. * * Each entry holds the tail of the chain of in-flight critical sections for * that directory; the next acquirer awaits the current tail and replaces it diff --git a/packages/storage-isogit/src/runtime.ts b/packages/storage-isogit/src/runtime.ts index 3326d36e7..9a228c291 100644 --- a/packages/storage-isogit/src/runtime.ts +++ b/packages/storage-isogit/src/runtime.ts @@ -24,9 +24,7 @@ export interface IsogitRuntime { } export interface FileSystemStat { - readonly size: number; isDirectory(): boolean; - isFile(): boolean; } type WriteFileOptions = { @@ -139,20 +137,12 @@ function fileStat(value: unknown, method: FsMethod): FileSystemStat { if (typeof value !== "object" || value === null) { throw new TypeError(`Filesystem ${method} returned an invalid stat`); } - const size: unknown = Reflect.get(value, "size"); const isDirectory: unknown = Reflect.get(value, "isDirectory"); - const isFile: unknown = Reflect.get(value, "isFile"); - if ( - typeof size !== "number" || - typeof isDirectory !== "function" || - typeof isFile !== "function" - ) { + if (typeof isDirectory !== "function") { throw new TypeError(`Filesystem ${method} returned an invalid stat`); } return { - size, isDirectory: () => Boolean(Reflect.apply(isDirectory, value, [])), - isFile: () => Boolean(Reflect.apply(isFile, value, [])), }; } diff --git a/packages/storage-isogit/src/store.test.ts b/packages/storage-isogit/src/store.test.ts index 04e77ec31..ff2267f3c 100644 --- a/packages/storage-isogit/src/store.test.ts +++ b/packages/storage-isogit/src/store.test.ts @@ -15,7 +15,7 @@ import { import { createNodeIsogitRuntime } from "./node-runtime"; import { base64Encode } from "@intx/types"; import { IsogitStore } from "./node"; -import { initAgentRepo } from "./node"; +import { collectReachableObjects, initAgentRepo } from "./node"; import type { AssistantTurn, ContextStore, @@ -2609,3 +2609,48 @@ describe("readManifestHistory", () => { expect(thrown?.message).toMatch(/Invalid manifest record/); }); }); + +describe("per-directory commit lock", () => { + test("overlapping commits leave a walkable history", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + const store = new IsogitStore(dir); + + const commits: Promise[] = []; + for (let i = 0; i < 12; i += 1) { + await fs.promises.writeFile( + path.join(dir, "turns.jsonl"), + `${JSON.stringify({ role: "user", content: [], timestamp: i })}\n`, + ); + commits.push(store.commit({ message: `cycle ${i.toString()}` })); + } + await Promise.all(commits); + + const head = await git.resolveRef({ fs, dir, ref: "refs/heads/main" }); + const reachable = await collectReachableObjects(dir, head); + expect(reachable.length).toBeGreaterThan(0); + // The init commit plus one per cycle; without the lock, overlapping + // commits overwrite each other's ref update and lose history. + expect(await store.log(20)).toHaveLength(13); + }); +}); + +describe("log", () => { + test("throws when a commit's parent is missing", async () => { + const dir = await tempDir(); + await initAgentRepo(dir); + const store = new IsogitStore(dir); + await fs.promises.writeFile(path.join(dir, "turns.jsonl"), "{}\n"); + await store.commit({ message: "first" }); + + const head = await git.resolveRef({ fs, dir, ref: "refs/heads/main" }); + const { commit } = await git.readCommit({ fs, dir, oid: head }); + const parent = commit.parent[0]; + if (parent === undefined) throw new Error("expected a parent commit"); + await fs.promises.rm( + path.join(dir, ".git", "objects", parent.slice(0, 2), parent.slice(2)), + ); + + await expect(store.log(20)).rejects.toThrow(); + }); +}); diff --git a/packages/storage-isogit/src/store.ts b/packages/storage-isogit/src/store.ts index bb989e3d1..5380ee51c 100644 --- a/packages/storage-isogit/src/store.ts +++ b/packages/storage-isogit/src/store.ts @@ -28,7 +28,6 @@ import { restoreIndexAfterFailedCommit, } from "./commit-helpers"; import { withRepoDirLock } from "./repo-lock"; -import { maybeGCUnderLock, type GCPolicy } from "./gc"; import { decodeUTF8, flushRuntime, @@ -92,17 +91,10 @@ function parseMetadata(raw: unknown): MetadataData { /** * Walk the first-parent commit chain from HEAD, newest-first, stopping at - * `limit` entries or at the first parent that is not present on disk. - * - * `git.log` throws `NotFoundError` the moment it reaches a missing commit, - * which is the steady state under `tip-only` GC: the collector prunes - * ancestry, leaving the tip's older parents absent. The durable conversation - * lives in the working-tree files at the tip, not in this history, so the - * commit log is a best-effort time-travel surface — degrade to the surviving - * slice rather than throwing into a caller (e.g. the agent's `checkpoints` - * tool). Any non-absence read error still surfaces. + * `limit` entries. A repo with no HEAD yet has no history. Nothing prunes these + * repos, so a missing parent is lost data, and the read throws. */ -async function tolerantLog( +async function readCommitChain( runtime: StorageRuntime, dir: string, limit: number, @@ -115,14 +107,7 @@ async function tolerantLog( return out; } while (oid !== undefined && out.length < limit) { - let entry: Awaited>; - try { - entry = await git.readCommit({ fs: runtime.fs.git, dir, oid }); - } catch (err) { - if (err instanceof Error && "code" in err && err.code === "NotFoundError") - break; - throw err; - } + const entry = await git.readCommit({ fs: runtime.fs.git, dir, oid }); out.push(entry); oid = entry.commit.parent[0]; } @@ -134,7 +119,7 @@ async function readCommitLog( dir: string, limit: number, ): Promise { - const entries = await tolerantLog(runtime, dir, limit); + const entries = await readCommitChain(runtime, dir, limit); return entries.map((e) => { const base = { hash: e.oid, @@ -320,36 +305,22 @@ export class IsogitStore private readonly runtime: StorageRuntime; private readonly dir: string; private readonly signer: CommitSigner | undefined; - private readonly gcPolicy: GCPolicy | undefined; private pendingConnectorState: ConnectorThreadState | null = null; private lastTurns: ConversationTurn[] = []; // `retrying` is a checkpoint opened after a failed one: until it completes, // the files can still hold the failed checkpoint's partial state. private checkpoint: "open" | "retrying" | "complete" | "failed" = "complete"; - constructor( - runtime: StorageRuntime, - dir: string, - signer?: CommitSigner, - gcPolicy?: GCPolicy, - ) { + constructor(runtime: StorageRuntime, dir: string, signer?: CommitSigner) { this.runtime = runtime; this.dir = dir; this.signer = signer; - this.gcPolicy = gcPolicy; } private signingArgs() { return buildSigningArgs(this.signer); } - // Reclaim after a write while the per-directory lock is still held, per - // the configured policy. A no-op when no policy was supplied. - private async maybeGC(): Promise { - if (this.gcPolicy === undefined) return; - await maybeGCUnderLock(this.runtime, this.dir, this.gcPolicy); - } - /** Persist append-only record files with retry-safe publication semantics. */ private async commitRecordFiles( files: readonly DurableRecordFile[], @@ -439,7 +410,6 @@ export class IsogitStore } throw cause; } - await this.maybeGC(); await flushRuntime(this.runtime); } @@ -547,7 +517,6 @@ export class IsogitStore } const described = await this.describeHead(oid, options.message); - await this.maybeGC(); return described; }); const commit = await this.checkpointStep(committed); @@ -769,7 +738,7 @@ export class IsogitStore _signal?: AbortSignal, ): Promise { if (limit <= 0) return []; - const entries = await tolerantLog(this.runtime, this.dir, limit); + const entries = await readCommitChain(this.runtime, this.dir, limit); const collected: TransformRecordType[] = []; for (const entry of entries) { let blob: Uint8Array; diff --git a/packages/tool-packaging/ASSET-LAYOUT.md b/packages/tool-packaging/ASSET-LAYOUT.md index 5a943b8f9..7a2b03b56 100644 --- a/packages/tool-packaging/ASSET-LAYOUT.md +++ b/packages/tool-packaging/ASSET-LAYOUT.md @@ -28,19 +28,17 @@ collide remain distinguishable on disk. handler validates at push time. Each tarball's `package.json` is parsed and validated by the kind handler against the workspace's `PackageJSON` schema before the push is accepted. -- **Mount path on the sidecar**: `package-registries//`, - resolved by the `package-registry` kind handler. Asset packs land at - `//`, so the sidecar's loader finds the - tarballs at `/package-registries//tarballs/`. +- **Mount path on the sidecar**: `source-assets//` + (`workflowSourceAssetMountPath` in `@intx/types/workflow-sources`). Asset + packs land at `//`, so the sidecar's loader finds the + tarballs at `/source-assets//tarballs/`. ## Where the manifest lives -The pinned closure is the hub's responsibility: the closure resolver -walks every agent-pinned tool package, resolves transitive dependencies, -and writes a `ToolPackageManifest` (validated by -`@intx/types/tool-packages`) into the deploy pack at -`deploy/tool-packages-manifest.json`. The sidecar reads it from there -at apply time. +The closure resolver walks a list of top-level packages, resolves +transitive dependencies, and produces a `ToolPackageManifest` (validated +by `@intx/types/tool-packages`). A deploy from source resolves its +workflow's closure this way and hands the manifest to the loader. Manifest entries whose `source.kind` is `"asset"` carry the asset's id plus a `package` envelope. The envelope's `format` discriminates where @@ -69,22 +67,10 @@ tarball entry looks like: The loader joins `source.package.path` against the materialized asset root on the sidecar to locate the bytes. -## Mount table in the deploy pack +## Mount table -The hub-side session service writes a sidecar-readable mapping from -asset id to mount path into the deploy pack at -`deploy/asset-mounts.json`: - -```json -{ - "assetMounts": { - "ast_workspace_builtins": "package-registries/workspace-builtins/" - } -} -``` - -The sidecar's `readDeployTree` parses this file into a -`Map` and hands it to the loader. The loader uses +The loader takes a `Map`, for example +`ast_workspace_builtins` to `source-assets/ast_workspace_builtins/`. It uses the map to resolve every `kind: "asset"` entry — looking up the mount path by `source.assetId`, then joining it with `source.package.path` against the workspace's asset root. @@ -137,9 +123,9 @@ ladder owned by the sidecar's materialization layer. See - Bytes are exactly what an npm registry would serve: the package tree rooted under `package/`, gzipped tar. - Integrity is verified by the sidecar loader against the entry's - `source.package.integrity` field before extraction. The asset substrate's git-commit - signature provides the trust root; the SRI hash provides byte-level - integrity at materialization time. + `source.package.integrity` field before extraction. That SRI hash, in the + closure the hub froze, is what the sidecar trusts; the asset repo's commit + signatures are not verified at runtime. - The workspace's bundled tarballs (`bin/build-builtins.ts`) emit a packed `package.json` with `dependencies`, `devDependencies`, `optionalDependencies`, and `exports` deleted: every workspace and @@ -169,7 +155,7 @@ surface. | Concern | Asset-sourced | Registry-sourced | | --------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- | | Distribution | Operator commits the tarball to the asset repo. | Sidecar fetches at apply time from a configured registry. | -| Trust | Git-commit signature on the asset. | Registry trust + manifest integrity pin. | +| Trust | Manifest integrity pin; commit signatures are not verified. | Registry trust + manifest integrity pin. | | Reproducibility | Strong — the bytes are vendored. | Strong as long as the registry honors immutability of `name@version`. | | Use case | Operator-curated tools, anything the operator wants to vendor. | Public packages, third-party tools, anything not vendored. | diff --git a/packages/tool-packaging/README.md b/packages/tool-packaging/README.md index 2fe756ece..2deeb4669 100644 --- a/packages/tool-packaging/README.md +++ b/packages/tool-packaging/README.md @@ -79,26 +79,9 @@ outside any optional subtree abort the walk and surface as a ## Asset modification race -The hub-side `AssetRegistrySource` reads tarball packuments from a -`package-registry` asset at resolution time. The sidecar's -`createPack` then resolves the asset's main ref and serves the tarball -bytes the resolver pinned. A concurrent PUT or DELETE against the -asset's `tarballs/` prefix in the window between the resolver's read -and the sidecar's fetch can cause the apply to fail with -`tarball.missing` or `integrity.mismatch`. - -The substrate's `writeTreePreservingPrefix` primitive makes PUTs and -DELETEs against the asset atomic with respect to one another, but it -does not couple them to the sidecar's deploy-apply read. The -intentional behavior is: the apply fails loudly as a rejected -deploy-apply. There is no silent corruption — -the integrity check on the sidecar side guarantees that mismatched -bytes are rejected — but the operator does see a failed apply if the -asset is mutated concurrently with a launch. - -A follow-up that snapshots the asset's commit SHA at resolution time -and replays against it at apply time is in scope if this shows up in -operator practice; it is not implemented today. +The Hub reads and packs a source asset at one commit, so a concurrent PUT or +DELETE against the asset's `tarballs/` prefix cannot change the bytes of a +deploy. The sidecar still verifies each tarball's SRI before it extracts it. ## Call-time imports and `import.meta.url` diff --git a/packages/tool-packaging/src/loader.ts b/packages/tool-packaging/src/loader.ts index 844684404..96b5dd2a2 100644 --- a/packages/tool-packaging/src/loader.ts +++ b/packages/tool-packaging/src/loader.ts @@ -222,16 +222,14 @@ export interface LoadManifestArgs { readonly instanceScratchDir: string; /** * Filesystem root that `assetMounts` paths are joined against. Mount - * paths from the deploy pack are workspace-relative; the loader + * paths are workspace-relative; the loader * resolves them against `assetRoot` to get the absolute tarball * location for `kind: "asset"` entries. */ readonly assetRoot: string; /** * Maps a `source.assetId` from a manifest entry to a - * workspace-relative mount path. The session service emits this map - * into the deploy pack as `deploy/asset-mounts.json`; the sidecar - * threads it through to here. Empty map is valid when no entry + * workspace-relative mount path. Empty map is valid when no entry * sources from an asset. */ readonly assetMounts: ReadonlyMap; diff --git a/packages/tool-packaging/src/store-layout.ts b/packages/tool-packaging/src/store-layout.ts index 2b5e1c57d..6ebfc084c 100644 --- a/packages/tool-packaging/src/store-layout.ts +++ b/packages/tool-packaging/src/store-layout.ts @@ -83,7 +83,7 @@ export async function materializeClosure( if (gitDir === undefined) { throw new ToolLoaderError({ category: "git.materialization.failed", - message: `manifest entry references git assetId "${source.assetId}" which is not in the deploy pack's git-dirs map`, + message: `manifest entry references git assetId "${source.assetId}" which is not in the git-dirs map`, package: { name: entry.name, version: entry.version }, }); } @@ -98,7 +98,7 @@ export async function materializeClosure( if (!args.assetMounts.has(source.assetId)) { throw new ToolLoaderError({ category: "asset.mount.missing", - message: `manifest entry references assetId "${source.assetId}" which is not in the deploy pack's asset-mounts map`, + message: `manifest entry references assetId "${source.assetId}" which is not in the asset-mounts map`, package: { name: entry.name, version: entry.version }, }); } diff --git a/packages/tool-packaging/src/tool-materialization.ts b/packages/tool-packaging/src/tool-materialization.ts index 3c23b5113..032f0370c 100644 --- a/packages/tool-packaging/src/tool-materialization.ts +++ b/packages/tool-packaging/src/tool-materialization.ts @@ -53,9 +53,8 @@ interface MaterializedToolPackages { } // Exported for direct unit testing of the manifest-invalid gate -// (JSON.parse failure + arktype schema failure). The workflow-process -// child reaches it through the substrate factory's per-step agent -// builder. +// (JSON.parse failure + arktype schema failure). Nothing in production +// calls it. export async function materializeToolPackages(args: { rawManifestBytes: string | undefined; /** @@ -478,8 +477,8 @@ export async function persistActiveDeployIdWithFallback( * The files live in the agent's storeDir so a future * git-commit-of-audit-entries pass can pick them up without rerouting * the data. v1 writes them directly to disk; the formal git-commit - * step (turning these into a signed audit-trail commit on the - * agent-state repo) is a separate plumbing piece. + * step (turning these into a signed audit-trail commit) is a separate + * plumbing piece. */ async function writeRejectedApplyAudit(args: { storeDir: string; diff --git a/packages/types/README.md b/packages/types/README.md index 07823c3e8..a60fca385 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -69,8 +69,8 @@ pull in the shapes they need: - `@intx/types/grant-snapshot` — `GrantWalkSnapshot`, the per-step grant declarations and workflow grant requirements serialized for deployment, and `serializeGrantWalk`, which builds it from a capability walk. -- `@intx/types/grant-wire` — grant-update wire frames pushed from - the hub to the sidecar. +- `@intx/types/grant-wire` — the `WireGrantRule` validator for grants + that round-trip through JSON. - `@intx/types/tool-packages` — schemas for the tool-package distribution path: pin shapes (`ToolPackagePin`, `ToolPackagePinArray`, `ToolPackagePinName`), source variants @@ -78,7 +78,7 @@ pull in the shapes they need: `package` arms `ToolPackageAssetTarball` and `ToolPackageAssetSourceTree`, unioned as `ToolPackageSource`), the `getToolPackageSourceContentIdentity` helper that reads an entry's - content identity (tarball SRI or source tree oid), and the deploy-pack + content identity (tarball SRI or source tree oid), and the tool-package manifest (`ToolPackageManifestEntry`, `ToolPackageManifest`). - `@intx/types/package-json` — the `PackageJSON` validator for the subset of `package.json` fields the asset substrate and tool-package diff --git a/packages/types/src/repo.ts b/packages/types/src/repo.ts index cffd8e4b3..d5baa0767 100644 --- a/packages/types/src/repo.ts +++ b/packages/types/src/repo.ts @@ -6,7 +6,6 @@ import { type } from "arktype"; * the substrate package re-exports it for handler authors. */ export const RepoKind = type.enumerated( - "agent-state", "skill", "package-registry", "workflow", diff --git a/packages/types/src/runtime-core.ts b/packages/types/src/runtime-core.ts index 373b1eab9..9229079d0 100644 --- a/packages/types/src/runtime-core.ts +++ b/packages/types/src/runtime-core.ts @@ -8,7 +8,6 @@ import { type } from "arktype"; import type { AuditRecord, ErrorRecord } from "./audit"; -import { WireGrantRule } from "./grant-wire"; import type { SignalKind } from "./signals"; // --------------------------------------------------------------------------- @@ -2394,30 +2393,13 @@ export const ToolDefinition = type({ export type ToolDefinition = typeof ToolDefinition.infer; /** - * Agent harness configuration. Assembled from the agent definition package - * and capability grants during harness initialization. - * - * `principalId` is the agent's principal in the hub's authorization model. - * The sidecar needs it to reconstruct the in-memory grant store on restart - * (the store's `collectGrants` filters by principal). - * - * `grants` uses `WireGrantRule` because this type arrives over JSON where - * `GrantRule.expiresAt` is serialized as a string. The wire validator - * coerces strings back to Date instances. - * - * (ARCHITECTURE.md § Agent Harness) + * What the Hub assembles for a deploy. Of this, only the session id reaches + * the sidecar, on the `agent.deploy` frame. */ export const HarnessConfig = type({ sessionId: "string", - agentId: "string", - tenantId: "string", - principalId: "string", agentAddress: "string", - systemPrompt: "string", - tools: ToolDefinition.array(), - grants: WireGrantRule.array(), sources: InferenceSource.array(), defaultSource: "string", - "sessionChannelEnabled?": "boolean", }); export type HarnessConfig = typeof HarnessConfig.infer; diff --git a/packages/types/src/sidecar.test.ts b/packages/types/src/sidecar.test.ts index 74eb0be5e..232011bce 100644 --- a/packages/types/src/sidecar.test.ts +++ b/packages/types/src/sidecar.test.ts @@ -112,7 +112,7 @@ describe("PackRejectFrame reason forward-compat", () => { type: "repo.pack.reject", agentAddress: "agt_1@example.test", repoId: { kind: "workflow-run", id: "dep-1" }, - reason: "timeout", + reason: "corrupt", }); expect(result instanceof type.errors).toBe(true); }); @@ -132,13 +132,7 @@ describe("PackRejectFrame reason forward-compat", () => { describe("AgentDeployFrame", () => { const baseConfig = { sessionId: "ses_1", - agentId: "agt_1", - tenantId: "ten_1", - principalId: "pri_1", agentAddress: "agt_1@example.test", - systemPrompt: "system prompt", - tools: [], - grants: [], sources: [ { id: "src_default", @@ -151,14 +145,12 @@ describe("AgentDeployFrame", () => { defaultSource: "src_default", }; - const trivialFrame = { + const frameWithoutWorkflow = { type: "agent.deploy" as const, requestId: "req_1", agentAddress: "agt_1@example.test", generation: 1, - agentId: "agt_1", config: baseConfig, - hubPublicKey: "hub_pubkey_hex", }; const stepSource = { @@ -177,14 +169,14 @@ describe("AgentDeployFrame", () => { closure: { schemaVersion: "1", topLevel: [], entries: [] }, }; - test("accepts the existing trivial-shape frame (no workflow field)", () => { - const result = AgentDeployFrame(trivialFrame); - expect(result instanceof type.errors).toBe(false); + test("rejects a frame with no workflow", () => { + const result = AgentDeployFrame(frameWithoutWorkflow); + expect(result instanceof type.errors).toBe(true); }); test("accepts a workflow frame with per-step sources and a source-ref pin", () => { const result = AgentDeployFrame({ - ...trivialFrame, + ...frameWithoutWorkflow, workflow: { sources: { plan: [stepSource], act: [stepSource] }, sourceRef: validSourceRef, @@ -197,7 +189,7 @@ describe("AgentDeployFrame", () => { // Source-ref is the only deploy lineage; without the pin the sidecar has // no closure to evaluate the definition from, so the frame is rejected. const result = AgentDeployFrame({ - ...trivialFrame, + ...frameWithoutWorkflow, workflow: { sources: { plan: [stepSource] }, }, @@ -207,7 +199,7 @@ describe("AgentDeployFrame", () => { test("rejects a workflow frame with no per-step sources", () => { const result = AgentDeployFrame({ - ...trivialFrame, + ...frameWithoutWorkflow, workflow: { sourceRef: validSourceRef, }, @@ -218,7 +210,7 @@ describe("AgentDeployFrame", () => { test("rejects a workflow frame whose step source chain is empty", () => { // Every step's failover chain must carry at least one source. const result = AgentDeployFrame({ - ...trivialFrame, + ...frameWithoutWorkflow, workflow: { sources: { plan: [] }, sourceRef: validSourceRef, diff --git a/packages/types/src/sidecar.ts b/packages/types/src/sidecar.ts index 29a530c2b..670f9cb83 100644 --- a/packages/types/src/sidecar.ts +++ b/packages/types/src/sidecar.ts @@ -16,7 +16,6 @@ import { WireGrantRule } from "./grant-wire"; import { BoundedApprovalSnapshot, ConnectorThreadState, - HarnessConfig, InferenceEvent, InferenceSource, } from "./runtime"; @@ -562,10 +561,7 @@ export type SenderIdentity = typeof SenderIdentity.infer; * inside the deployment's `workflow-run` repo -- sibling to the run's * `runs//events/` subtree. * - * `stepGrants` carries the same `WireGrantRule` shape the `agent.deploy` - * frame's `config.grants` ships, so the run's grants ride the same - * validated grant encoding as the deploy-time step grants rather than a - * new one. + * `stepGrants` carries the run's grants as validated `WireGrantRule`s. * * `senderIdentities` carries the resolved public keys of the run's * authorized senders, co-delivered on the same `run.grants` barrier as the @@ -756,9 +752,8 @@ export type WorkflowSourceAssetMount = typeof WorkflowSourceAssetMount.infer; * (`validateWorkflowProjection`), since the frame holds no definition to cover. * * This is deliberately NOT built on `WorkflowProjectionWithSources`: that shape - * (definition + sources + approved hash) is the approval/probe projection and - * stays intact for the probe surface and for each `referencedDefinitions` body, - * which still carry their own inert definition. + * (definition + sources) is a `referencedDefinitions` body, which still carries + * its own inert definition. */ export const AgentDeployWorkflow = type({ // Per-step inference-source failover chains, one per step in the closure's @@ -773,8 +768,8 @@ export const AgentDeployWorkflow = type({ // closed if it is absent. "approvedWireHash?": "string > 0", // Extracted trigger bodies -- onTrigger sections and childWorkflow children, - // lifted transitively. Each entry carries the body's inert definition, its own - // per-step inference-source pins, and its approved wire hash. The sidecar seals + // lifted transitively. Each entry carries the body's inert definition and its + // own per-step inference-source pins. The sidecar seals // each body's sources into the per-run record and delivers the plaintext to the // run child through the spawn env, so a body child -- in-process, its env lost // across a restart -- resolves inference durably without holding the cipher @@ -806,17 +801,8 @@ export type AgentDeployWorkflow = typeof AgentDeployWorkflow.infer; * Deploy an agent to this sidecar. The sidecar spawns a supervised * workflow-process child to host the deployment. * - * The deploy router discriminates two shapes by field presence without - * consulting `config`: - * - `workflow` set: a workflow deployment (single-step head or multi-step) - * that spawns the supervised workflow-process child. - * - `provisionStep` true: a no-spawn per-step provision of a multi-step - * deploy -- the sidecar initializes the step's agent-state repo and - * records the hub key so the follow-up deploy pack applies and verifies, - * but spawns nothing. The deployment-level `workflow` frame (sent once - * after every step is provisioned) spawns the child. - * A frame carrying neither is rejected -- there is no in-process - * fall-through. `workflow` and `provisionStep` are mutually exclusive. + * A frame with no `workflow` is rejected -- there is no in-process + * fall-through. * * The sidecar answers with `agent.deploy.ack` or `agent.deploy.error` * carrying the same `requestId`. A deploy of an address the sidecar already @@ -827,11 +813,8 @@ export const AgentDeployFrame = type({ requestId: "string", agentAddress: "string", generation: Generation, - agentId: "string", - config: HarnessConfig, - hubPublicKey: "string", - "workflow?": AgentDeployWorkflow, - "provisionStep?": "boolean", + config: { sessionId: "string" }, + workflow: AgentDeployWorkflow, }); export type AgentDeployFrame = typeof AgentDeployFrame.infer; @@ -995,27 +978,17 @@ export type CredentialsUpdateFrame = typeof CredentialsUpdateFrame.infer; // // Each pack frame carries two complementary addressing fields: // -// - `agentAddress` identifies the destination agent on the receiving -// sidecar. The sidecar manages per-agent state and uses this field to -// route the pack to the correct workspace. For agent-state packs the -// sidecar applies the pack onto the agent's deploy/state tree. +// - `agentAddress` identifies the deployment on the receiving sidecar. // -// - `repoId` identifies the source repo at the hub. The hub maps `repoId` -// to the originating entry in its kind-keyed RepoStore. For -// `repoId.kind === "agent-state"`, `repoId.id` is the run address -// (the deploy/state repo and the destination agent are the same), so -// the two fields carry the same value. Future kinds (e.g. assets) use -// `repoId` to name a non-agent source while `agentAddress` continues -// to address the destination agent. +// - `repoId` identifies the workflow-run repo the pack belongs to. // // `repo.pack.push` and `repo.pack.done` also carry the generation of the // incarnation the transfer belongs to. The ack and reject answer one transfer // on the connection that carried it, and transfer ids are never reused, so // they need no generation. // -// Flow control: deferred. Agent deploy trees are small enough that the sender -// can push all chunks without windowing. If this becomes a problem, a credit- -// based mechanism can be added later. +// Flow control: deferred. A sender pushes all chunks without windowing. If this +// becomes a problem, a credit-based mechanism can be added later. /** * A chunk of git pack data. The sender splits the packfile into chunks of at @@ -1039,11 +1012,6 @@ export type PackPushFrame = typeof PackPushFrame.infer; * Signals the end of a pack transfer. The receiver applies the pack and * updates `ref` to point at `commitSha`. If the post-apply HEAD does not * match `commitSha`, the receiver must reject with reason "sha_mismatch". - * - * When `mountPath` is set, the receiver materializes the pack at - * `workspace//` instead of the hardcoded agent deploy tree. - * Absent for agent-state deploy/state flows and workflow-run restoration. - * The receiver distinguishes those paths by `repoId.kind`. */ export const PackDoneFrame = type({ type: "'repo.pack.done'", @@ -1053,7 +1021,6 @@ export const PackDoneFrame = type({ transferId: "string", ref: "string", commitSha: "string", - "mountPath?": "string", }); export type PackDoneFrame = typeof PackDoneFrame.infer; @@ -1069,12 +1036,10 @@ export const PackAckFrame = type({ export type PackAckFrame = typeof PackAckFrame.infer; export const PackRejectReason = type.enumerated( - "signature_invalid", "path_violation", "conflict", "corrupt", "sha_mismatch", - "timeout", "missing_objects", ); export type PackRejectReason = typeof PackRejectReason.infer; @@ -1111,9 +1076,8 @@ export type PackRejectFrame = typeof PackRejectFrame.infer; * tarball.missing — a manifest entry's asset-sourced tarball * is not present at the recorded path. * asset.mount.missing — a `kind: "asset"` manifest entry names - * an `assetId` that the deploy pack's - * `deploy/asset-mounts.json` does not - * cover. Indicates a mismatch between the + * an `assetId` that the asset mount map + * does not cover. Indicates a mismatch between the * resolver's view of attached assets and * the materialization fan-out, not a * missing file on disk. diff --git a/packages/types/src/tool-packages.ts b/packages/types/src/tool-packages.ts index fad721d3a..78cd0a792 100644 --- a/packages/types/src/tool-packages.ts +++ b/packages/types/src/tool-packages.ts @@ -206,7 +206,7 @@ export type ToolPackageAssetSourceTree = /** * A pinned entry's bytes come from a hub `asset` -- a checked-out git * repo attached to the agent at session time. `assetId` is the hub-side - * asset row id; the sidecar resolves it against the deploy pack's mount + * asset row id; the loader resolves it against its mount * map to reach the asset's checkout. The package lives at a location * within the checkout, either a prepackaged `tarball` or a `source` * subtree, discriminated by `package.format`. @@ -273,8 +273,7 @@ export const ToolPackageManifestEntry = type({ export type ToolPackageManifestEntry = typeof ToolPackageManifestEntry.infer; /** - * The manifest written into the deploy pack at - * `deploy/tool-packages-manifest.json`. + * The manifest of a resolved tool-package closure. * * `schemaVersion` is a literal "1" for now. Future schema changes bump * this and the loader refuses unknown versions with `manifest.invalid`. diff --git a/packages/types/src/wire-workflow.ts b/packages/types/src/wire-workflow.ts index 4dd86408a..3c1c18496 100644 --- a/packages/types/src/wire-workflow.ts +++ b/packages/types/src/wire-workflow.ts @@ -53,10 +53,9 @@ export type WorkflowStep = typeof WorkflowStep.infer; * against the closed `WorkflowStep` union. The runtime constraint runs * through a `.narrow` over a `Record` rather than a typed * `{ "[string]": WorkflowStep }` on purpose: the inferred type stays - * `Record` so the existing live-deploy producer - * (`toWireWorkflowDefinition`, which hands a `Record` - * steps map to `sendAgentDeploy`) still typechecks, while the runtime - * validation is fully closed over the primitive-kind set. + * `Record` so a producer holding a plain steps map still + * typechecks, while the runtime validation is fully closed over the + * primitive-kind set. */ const WorkflowSteps = type({ "[string]": "unknown" }).narrow((steps, ctx) => { for (const [stepId, step] of Object.entries(steps)) { @@ -72,10 +71,8 @@ const WorkflowSteps = type({ "[string]": "unknown" }).narrow((steps, ctx) => { }); /** - * Workflow projection carried on an `agent.deploy` frame. Its presence - * at the deploy router routes the frame to the workflow deploy path -- - * single- or multi-step, both of which spawn the workflow-process child - * -- as opposed to a per-step provision frame. + * Workflow projection carried on an `agent.deploy` frame. The deploy router + * spawns the workflow-process child from it. * * `definition` is the wire projection of `WorkflowDefinition` from * `@intx/workflow`. The arktype validator enforces the structural @@ -153,27 +150,14 @@ export type WorkflowProjectionDefinition = typeof WorkflowProjectionDefinition.infer; /** - * A workflow projection paired with its per-step inference-source pins and the - * hub-approved wire hash, with the invariant that every `stepOrder` entry has a - * `sources` failover chain. This is the shared base for BOTH the top-level - * deploy frame (`AgentDeployWorkflow`, which intersects its extras onto this) - * AND each extracted trigger body (onTrigger section or childWorkflow child) - * under `referencedDefinitions` -- so the field set and the coverage narrow are - * defined once and a body's sources cover the body's stepOrder just as the - * top-level's cover the top-level's. + * A workflow projection paired with its per-step inference-source pins, with + * the invariant that every `stepOrder` entry has a `sources` failover chain. + * This is the shape of each extracted trigger body (onTrigger section or + * childWorkflow child) under `referencedDefinitions`. */ export const WorkflowProjectionWithSources = type({ definition: WorkflowProjectionDefinition, sources: { "[string]": InferenceSource.array().atLeastLength(1) }, - // The hub-approved wire hash of `definition`'s projection -- the freeze anchor - // the hub gate wrote (`computeWireDefinitionHash`). The sidecar feeds it to - // the child as the `DEFINITION_HASH` it re-verifies its own recompute - // against, rather than trusting a sidecar-computed hash. At the top level it - // pins the deployment's content handle; per body it pins the body's - // projection, which is re-verified in-memory as part of the parent's - // already-re-verified closure. Optional on the wire because the frame schema - // does not force it; the production hub builder always stamps it. - "approvedWireHash?": "string > 0", }).narrow((value, ctx) => { for (const stepId of value.definition.stepOrder) { if (!Object.prototype.hasOwnProperty.call(value.sources, stepId)) { diff --git a/packages/workflow-deploy/README.md b/packages/workflow-deploy/README.md index 29fc3bab0..928e178a5 100644 --- a/packages/workflow-deploy/README.md +++ b/packages/workflow-deploy/README.md @@ -4,7 +4,7 @@ Use `@intx/workflow-deploy/capabilities` to import `walkCapabilities` and its grant projections without loading deployment orchestration or package materialization. -Use `@intx/workflow-deploy/addresses` for run and step address derivation, +Use `@intx/workflow-deploy/addresses` for run address derivation, and `@intx/workflow-deploy/child-grants` for collecting a child workflow's declared resources and credential consumers and filtering its inherited grants. @@ -17,16 +17,10 @@ takes a `WorkflowDefinition`, computes the per-step grant declarations the workflow will require, gates them against an operator-supplied `ApprovalSet`, and derives the deployment addresses the run occupies. -Address derivation is a pure function of `(runId, stepId, domain)`: - -- **Single-step workflow**: the lone step has no distinct address -- - it IS the deployment head (`deriveRunAddress`, `@`). -- **Multi-step workflow**: each step derives a per-step run address of - the form `-@` (`deriveStepAddress`). - -`resolveStepAddress` owns the head/step collapse decision. Because the -derivation carries no per-deploy state, the supervisor reconstructs the -same addresses at spawn time from the host-sourced step count alone. +Address derivation is a pure function of `(runId, domain)`: every step of a +deployment runs under the deployment address (`deriveRunAddress`, +`@`). Because the derivation carries no per-deploy state, the +supervisor reconstructs the same address at spawn time. Public surface: @@ -108,7 +102,7 @@ Public surface: the pinned map. - `enumerateInertBodies(...)` — lift each inline trigger body (onTrigger section or childWorkflow child), transitively, out of a frozen inert - projection so the hub can stage it and pin its per-step sources. The + projection so the hub can pin its per-step sources. The enumeration is purely structural; each body step's `(provider, model)` preference is read at pin time by `buildInertBodyStepSources`. - `checkCredentialBindings(projection)` / @@ -136,8 +130,7 @@ Public surface: that closed set throws, as does a container kind carrying neither an inline body nor a `{ ref }`; a `{ ref }` body yields nothing, because the referenced asset is deployed and walked on its own. -- `deriveRunAddress` / `deriveStepAddress` / `resolveStepAddress` / - `deriveRunAgentId` / `deriveStepAgentId` / `deriveWorkflowRunRepoId` +- `deriveRunAddress` / `deriveWorkflowRunRepoId` — the pure address and id derivation helpers. - `extractFoldedBody(definition)` — read the launch-relevant fields back out of a folded single-step definition. diff --git a/packages/workflow-deploy/src/addresses.test.ts b/packages/workflow-deploy/src/addresses.test.ts index 9f21c9ede..3056aa48f 100644 --- a/packages/workflow-deploy/src/addresses.test.ts +++ b/packages/workflow-deploy/src/addresses.test.ts @@ -2,46 +2,10 @@ import { describe, test, expect } from "bun:test"; import { deriveWorkflowRunId } from "@intx/types"; -import { - deriveRunAddress, - deriveRunAgentId, - deriveStepAddress, - deriveStepAgentId, - deriveWorkflowRunRepoId, -} from "./addresses"; +import { deriveRunAddress, deriveWorkflowRunRepoId } from "./addresses"; -describe("per-step address derivation", () => { - test("deriveStepAddress concatenates the run id, step, and deployment domain", () => { - expect( - deriveStepAddress({ - runId: "run_abc", - stepId: "step1", - domain: "workflow.interchange", - }), - ).toBe("run_abc-step1@workflow.interchange"); - }); - - test("deriveStepAgentId concatenates the run id and step", () => { - expect(deriveStepAgentId({ runId: "run_abc", stepId: "x" })).toBe( - "run_abc-x", - ); - }); - - test("derivation is deterministic across calls", () => { - const a = deriveStepAddress({ - runId: "run_a", - stepId: "s", - domain: "d", - }); - const b = deriveStepAddress({ - runId: "run_a", - stepId: "s", - domain: "d", - }); - expect(a).toBe(b); - }); - - test("deriveRunAddress drops the per-step suffix", () => { +describe("run address derivation", () => { + test("deriveRunAddress joins the run id and the domain", () => { expect( deriveRunAddress({ runId: "run_abc", @@ -50,10 +14,6 @@ describe("per-step address derivation", () => { ).toBe("run_abc@workflow.interchange"); }); - test("deriveRunAgentId drops the per-step suffix", () => { - expect(deriveRunAgentId({ runId: "run_abc" })).toBe("run_abc"); - }); - test("deriveWorkflowRunRepoId sanitizes the deployment address into a SAFE_REPO_ID slug", () => { const address = deriveRunAddress({ runId: "run_abc", @@ -82,15 +42,6 @@ describe("deriveRunAddress / deriveWorkflowRunId round-trip", () => { ).toBe("run_abc"); }); - test("round-trips a per-step address to its step-suffixed run id", () => { - const stepAddress = deriveStepAddress({ - runId: "run_abc", - stepId: "plan", - domain, - }); - expect(deriveWorkflowRunId(stepAddress)).toBe("run_abc-plan"); - }); - test("throws on an address whose local part is not a run id", () => { expect(() => deriveWorkflowRunId("usr_abc@workflow.interchange")).toThrow( "Invalid run address", diff --git a/packages/workflow-deploy/src/addresses.ts b/packages/workflow-deploy/src/addresses.ts index cd4dc99cc..57a77cd21 100644 --- a/packages/workflow-deploy/src/addresses.ts +++ b/packages/workflow-deploy/src/addresses.ts @@ -1,53 +1,15 @@ -// The deployment address model is a pure function of `(runId, stepId, -// domain)`: a one-step workflow has no distinct step address -- the lone -// step IS the deployment head (`deriveRunAddress`) -- while a workflow with -// more than one step derives per-step addresses of the form -// `-@`. Because the derivation is pure, the -// supervisor reconstructs the same addresses at spawn time without any -// per-deploy state, and `resolveStepAddress` is the single owner of the -// head/step collapse decision for a consumer that must choose an address -// from the host-sourced step count alone. +// The deployment address model is a pure function of `(runId, domain)`, so the +// supervisor reconstructs the same address at spawn time without any per-deploy +// state. import { formatRunAddress } from "@intx/types/agent-address"; -/** - * Pure function: derive a step's run address from - * `(runId, stepId, domain)`. Exported so the supervisor can reconstruct - * the same addresses at spawn time without sharing storage with the - * deploy flow. - * - * The local part IS the run id with the step suffix appended; the runId is - * already a minted `run_` carrying the `run_` marker `parseRunAddress` - * requires at the substrate boundary. The per-step local-part is concat-only - * because `stepId` is already constrained to `[a-zA-Z0-9_-]+` by the workflow - * definition validator. - */ -export function deriveStepAddress(args: { - runId: string; - stepId: string; - domain: string; -}): string { - return formatRunAddress(`${args.runId}-${args.stepId}`, args.domain); -} - -/** - * Derive the per-step agent id (the `agent-state` repo's id and the - * `HarnessConfig.agentId`). Pure function of `(runId, stepId)`. - */ -export function deriveStepAgentId(args: { - runId: string; - stepId: string; -}): string { - return `${args.runId}-${args.stepId}`; -} - /** * Derive the deployment-level mail address the supervisor registers on * the bus. It is the run id `@` the domain; pure function of `(runId, domain)`. * * The supervisor uses this address as the inbound mail address for the - * deployment as a whole; per-step bindings carry their own - * derived-step addresses. + * deployment as a whole. */ export function deriveRunAddress(args: { runId: string; @@ -56,45 +18,6 @@ export function deriveRunAddress(args: { return formatRunAddress(args.runId, args.domain); } -/** - * Resolve where a step's deploy tree lives, given the deployment's step - * count. This is the single owner of the head/step collapse DECISION for - * a consumer that must choose the address without knowing the deploy - * shape: a one-step workflow has no distinct steps, so its lone step IS - * the head (`deriveRunAddress`); a multi-step deployment keeps the - * head distinct from its per-step addresses (`deriveStepAddress`). The - * sidecar child reads its deploy tree from the address this returns, - * keyed only off the deployment mailbox and the host-sourced `stepCount`. - * - * The producers do not route through here -- each handles one shape - * unconditionally: the single-step deploy stages the tree at the head, - * the multi-step deploy at each per-step address. Because `stepCount` is - * the deployed definition's `stepOrder.length`, sourced from the host, - * the consumer's collapse always agrees with whichever producer staged - * the tree; the two processes never derive divergent addresses. - */ -export function resolveStepAddress(args: { - runId: string; - stepId: string; - domain: string; - stepCount: number; -}): string { - return args.stepCount === 1 - ? deriveRunAddress({ - runId: args.runId, - domain: args.domain, - }) - : deriveStepAddress(args); -} - -/** - * Derive the deployment-level agent id used on the `agent.deploy` - * frame's `agentId` field. Pure function of `(runId)`. - */ -export function deriveRunAgentId(args: { runId: string }): string { - return args.runId; -} - /** * Project a workflow-deployment run address into the substrate-safe * id of its workflow-run repo (`{ kind: "workflow-run", id }`). Pure diff --git a/packages/workflow-deploy/src/capability-walk.ts b/packages/workflow-deploy/src/capability-walk.ts index e7dc17562..d459179ce 100644 --- a/packages/workflow-deploy/src/capability-walk.ts +++ b/packages/workflow-deploy/src/capability-walk.ts @@ -215,8 +215,7 @@ const EFFECT_GRANT_PREFIX = "effect:"; * * The `tool:` rules carry the bare tool names the walk reads from inline * `agent.toolFactories`. They authorize a source-ref tool, which runs under - * that name; a pinned tool runs as `:` and takes its - * authority from the sidecar tool-mark floor instead. + * that name. */ export function runtimeGrantRules( snapshot: Pick, diff --git a/packages/workflow-deploy/src/index.ts b/packages/workflow-deploy/src/index.ts index 284331186..8373e878b 100644 --- a/packages/workflow-deploy/src/index.ts +++ b/packages/workflow-deploy/src/index.ts @@ -10,7 +10,7 @@ // - approval gate: consumes the walk's output plus an operator- // supplied `ApprovalSet` and yields a per-step pending delta. // - deploy derivation + source pinning: pure address derivation -// (`deriveRunAddress`, `deriveStepAddress`, `resolveStepAddress`, ...) +// (`deriveRunAddress`, ...) // and per-step inference-source resolution against the operator- // approved grant set (`pickStepInferenceSource`, `pinInertStepSources`, // `buildInertProjectionStepSources`, `buildInertBodyStepSources`), plus @@ -64,13 +64,5 @@ export { buildInertBodyStepSources, buildSingleStepAgentDefinition, WorkflowDefinitionInvalidError, - type DeployContent, } from "./orchestrator"; -export { - deriveRunAddress, - deriveRunAgentId, - deriveStepAddress, - resolveStepAddress, - deriveStepAgentId, - deriveWorkflowRunRepoId, -} from "./addresses"; +export { deriveRunAddress, deriveWorkflowRunRepoId } from "./addresses"; diff --git a/packages/workflow-deploy/src/inert-ontrigger-bodies.ts b/packages/workflow-deploy/src/inert-ontrigger-bodies.ts index b33a1be0f..1ded32893 100644 --- a/packages/workflow-deploy/src/inert-ontrigger-bodies.ts +++ b/packages/workflow-deploy/src/inert-ontrigger-bodies.ts @@ -4,23 +4,23 @@ // On the source-ref (code-sourced) deploy the hub never holds a live // `WorkflowDefinition` -- it has only the inert `WorkflowProjectionDefinition` // the gate froze and hashed. This module walks that frozen projection and lifts -// each inline body so the hub can stage it and pin its per-step inference +// each inline body so the hub can pin its per-step inference // sources. Enumeration itself is purely structural: the deploy pin reads each // body step's declared `(provider, model)` preference through // `readInertStepInference` when it resolves sources. // // The walk mirrors the runtime's per-rung rewrite exactly, so the ref each body -// is staged under equals the ref the runtime reads it back by: +// is pinned under equals the ref the runtime reads it back by: // - childWorkflow bodies are lifted at EVERY rung (the runtime re-runs // `rewriteInlineChildWorkflowBodies` per child run), so the enumeration // recurses into every lifted body; // - onTrigger bodies are lifted only at the TOP level (the runtime lifts them // once at child boot, never inside a spawned body), so an onTrigger section // nested inside a spawned body would reach the runtime inline and hard-fail -// -- this module rejects it at deploy rather than staging an asset the +// -- this module rejects it at deploy rather than pinning sources the // runtime will never read; -// - a loop body is NOT lifted as an asset -- it runs in-process sharing the -// parent env, so it needs no sources.json of its own -- but the enumeration +// - a loop body is NOT lifted -- it runs in-process sharing the +// parent env, so it needs no sources of its own -- but the enumeration // recurses INTO it to lift any childWorkflow grandchild, keyed under // `inlineBodyRef(loopBodyRef, childStepId)` to match the runtime's rewrite // of the loop body copy. @@ -589,8 +589,8 @@ function enumerateInertBodiesAtDepth( } // A loop body may carry a `childWorkflow` grandchild. Unlike the two arms // above, the loop body itself is NOT pushed to `bodies`: it runs in-process - // sharing the parent env, so it is never a staged asset and needs no - // sources.json of its own (its agent steps pin into the flat top-level map). + // sharing the parent env, so it needs no sources of its own (its agent + // steps pin into the flat top-level map). // Recurse into it (no longer top level) ONLY to lift its childWorkflow // grandchildren, keying each under its ref so the deploy stages the same ref // the runtime re-derives -- `inlineBodyRef(loopBodyRef, childStepId)`. diff --git a/packages/workflow-deploy/src/orchestrator-fallback-source.test.ts b/packages/workflow-deploy/src/orchestrator-fallback-source.test.ts index 06f907010..5105139eb 100644 --- a/packages/workflow-deploy/src/orchestrator-fallback-source.test.ts +++ b/packages/workflow-deploy/src/orchestrator-fallback-source.test.ts @@ -31,13 +31,7 @@ function makeConfig(args: { }): HarnessConfig { return { sessionId: "ses-fallback", - agentId: "ag_fallback", - tenantId: "tenant-1", - principalId: "prin-1", agentAddress: "run_fallback@workflow.interchange", - systemPrompt: "shared-prompt", - tools: [], - grants: [], sources: args.sources, defaultSource: args.defaultSource, }; diff --git a/packages/workflow-deploy/src/orchestrator-inert-body-source.test.ts b/packages/workflow-deploy/src/orchestrator-inert-body-source.test.ts index e54ff809a..8830a2187 100644 --- a/packages/workflow-deploy/src/orchestrator-inert-body-source.test.ts +++ b/packages/workflow-deploy/src/orchestrator-inert-body-source.test.ts @@ -47,13 +47,7 @@ const DEFAULT_SOURCE = { const CONFIG: HarnessConfig = { sessionId: "ses-inert-body", - agentId: "ag_inert_body", - tenantId: "tenant-1", - principalId: "prin-1", agentAddress: "run_inertbody@workflow.interchange", - systemPrompt: "shared", - tools: [], - grants: [], sources: [DEFAULT_SOURCE], defaultSource: DEFAULT_SOURCE.id, }; diff --git a/packages/workflow-deploy/src/orchestrator-loop-source.test.ts b/packages/workflow-deploy/src/orchestrator-loop-source.test.ts index 6231358dd..71202b943 100644 --- a/packages/workflow-deploy/src/orchestrator-loop-source.test.ts +++ b/packages/workflow-deploy/src/orchestrator-loop-source.test.ts @@ -43,13 +43,7 @@ function projectionOf(def: Parameters[0]) { const CONFIG: HarnessConfig = { sessionId: "ses-loop-src", - agentId: "ag_loop_src", - tenantId: "tenant-1", - principalId: "prin-1", agentAddress: "run_loopsrc@workflow.interchange", - systemPrompt: "shared", - tools: [], - grants: [], sources: [ { id: "src-worker", diff --git a/packages/workflow-deploy/src/orchestrator-nonagent-toplevel-source.test.ts b/packages/workflow-deploy/src/orchestrator-nonagent-toplevel-source.test.ts index 5ab112524..f3727cba2 100644 --- a/packages/workflow-deploy/src/orchestrator-nonagent-toplevel-source.test.ts +++ b/packages/workflow-deploy/src/orchestrator-nonagent-toplevel-source.test.ts @@ -51,13 +51,7 @@ const DEFAULT_SOURCE = { const CONFIG: HarnessConfig = { sessionId: "ses-nonagent-top", - agentId: "ag_nonagent_top", - tenantId: "tenant-1", - principalId: "prin-1", agentAddress: "run_nonagenttop@workflow.interchange", - systemPrompt: "shared", - tools: [], - grants: [], sources: [DEFAULT_SOURCE], defaultSource: DEFAULT_SOURCE.id, }; diff --git a/packages/workflow-deploy/src/orchestrator-nopreference-source.test.ts b/packages/workflow-deploy/src/orchestrator-nopreference-source.test.ts index 6f8f24279..50a18bb26 100644 --- a/packages/workflow-deploy/src/orchestrator-nopreference-source.test.ts +++ b/packages/workflow-deploy/src/orchestrator-nopreference-source.test.ts @@ -30,13 +30,7 @@ function makeConfig(args: { }): HarnessConfig { return { sessionId: "ses-nonagent", - agentId: "ag_nonagent", - tenantId: "tenant-1", - principalId: "prin-1", agentAddress: "run_nonagent@workflow.interchange", - systemPrompt: "shared-prompt", - tools: [], - grants: [], sources: args.sources, defaultSource: args.defaultSource, }; diff --git a/packages/workflow-deploy/src/orchestrator.ts b/packages/workflow-deploy/src/orchestrator.ts index 732c9bae8..c52ed0036 100644 --- a/packages/workflow-deploy/src/orchestrator.ts +++ b/packages/workflow-deploy/src/orchestrator.ts @@ -26,20 +26,6 @@ import { type InertBodyStepPreference, } from "./inert-ontrigger-bodies"; -/** - * Minimal structural `DeployContent` shape. Carried as a structural type so - * this package does not need a runtime dependency on `@intx/hub-sessions` to - * name the type. Mirrors the public fields of - * `packages/hub-sessions/src/agent-repo.ts`'s `DeployContent`; the hub's - * `bridgeOrchestratorDeployContent` narrows this widened shape back to the - * canonical one at the deploy boundary. - */ -export interface DeployContent { - readonly systemPrompt: string; - readonly toolPackageManifest?: unknown; - readonly assetMounts?: ReadonlyMap; -} - /** * Error thrown when a workflow definition fails deploy-time validation -- * an inverted or unapproved inference chain, or a step whose source the diff --git a/packages/workflow-host/README.md b/packages/workflow-host/README.md index 9ba2eaaff..58707f605 100644 --- a/packages/workflow-host/README.md +++ b/packages/workflow-host/README.md @@ -72,8 +72,6 @@ Public surface (the package barrel re-exports these): adapter. - `createWorkflowSupervisor` — the per-deployment supervisor factory. See "Supervisor" below for the bindings shape. -- `assembleCredentialsSnapshot` — per-step grant assembly the - supervisor uses at spawn time and on every recycle. - `commitCancelRequested` — low-level Q3 `CancelRequested` commit primitive the supervisor invokes for every cancellation origin. @@ -88,8 +86,7 @@ Plus the relevant options and callback types (`StepEnvBase`, with `runLocal` and the real step invoker, with no hub and no sidecar: - `createWorkflowStepInvoker`, `createToolBearingAgentFactory`, - `attachStepTools`, `attachStepCredentialWiring` and - `deriveToolMarkFloorGrants` — the step invoker and the step tool builders + `attachStepTools` and `attachStepCredentialWiring` — the step invoker and the step tool builders the deployed host uses. - `createCredentialsBackedAuthorize`, `createGrantEvaluator` and `createSnapshotGrantsResolver` (with its `StepGrantsResolver` type) — the @@ -140,12 +137,14 @@ The constructor argument shape: spawner invokes (resolved by the host via `require.resolve` / `import.meta.resolve` against the host's own package, `@intx/`). -- `layoutSteps` — derives the deployment's step ids, step addresses, - and step repo ids from the definition the first child reported, +- `layoutSteps` — derives the deployment's step ids + from the definition the first child reported, once the supervisor has checked it against the approved hash. The - supervisor keeps that layout for every later respawn. + supervisor keeps those ids for every later respawn. +- `onRunStart` — reads a run's credentials snapshot, which the + supervisor sends in each `authority.set`. - `substrateEnv`, `workflowRunRepoId`, `workflowRunRef`, - `anchorRunId`, `deploymentMailAddress`, `readPrincipal`, + `anchorRunId`, `deploymentMailAddress`, `ipcKeyPairFactory?` — per-deployment configuration the supervisor needs in its closure state. @@ -155,33 +154,21 @@ The sidecar's deploy router is the single ingress for inbound `agent.deploy` frames; its production wiring lives at `src/deploy/workflow-host-wiring.ts` in `createSidecarDeployRouter`. Every deploy stages through the -workflow-run substrate, and the router decides between two frame -shapes: - -- **Provision-step frame (`provisionStep: true`).** The router - primes the frame's per-step `agent-state` repo and records the - hub key, without constructing a supervisor or spawning a child. - The follow-up full-closure deploy pack then applies into the - primed repo and verifies against the recorded key. -- **Workflow frame (carries a `WorkflowDefinition`).** The router - constructs a fresh per-deployment supervisor and drives its - `spawn(opts)` lifecycle: per-step `agent-state` repo - provisioning, key minting, child spawn via `subprocessSpawner`, - mail-bus registration, IPC handshake, and `credentialsSnapshot` - assembly. - -A frame carrying neither shape is rejected -- there is no -in-process deploy path. +workflow-run substrate. The router constructs a fresh per-deployment +supervisor and drives its `spawn(opts)` lifecycle: key minting, child spawn via +`subprocessSpawner`, mail-bus registration, and IPC handshake. + +A frame without a workflow is rejected -- there is no in-process +deploy path. ### Lifecycle `spawn(opts)` performs the IPC handshake (mint channelId, mint HMAC key, mint IPC Ed25519 keypair, build spawn-time env, invoke the spawner, wait for the child's `definition` frame and check its -projection against the approved hash), derives the step layout from -that projection through the host's `layoutSteps`, assembles the -`credentialsSnapshot` from each step's `agent-state` repo, sends -`authority.set`, registers the deployment's mail address, waits for +projection against the approved hash), derives the step ids from +that projection through the host's `layoutSteps`, sends +`authority.set` with the snapshot `onRunStart` returns, registers the deployment's mail address, waits for the child's `ready` frame, and begins forwarding inbound mail as `trigger.fire` control frames. The IPC private key never leaves the supervisor's closure — only the public key (as @@ -214,9 +201,9 @@ runs to drain per each step's `drainBehavior`; on the drain-timeout it escalates to a signed `CancelRequested{origin: "supervisor-drain"}`. `recycle(opts)` tears the current child down and stands a fresh one up -against the SAME deploy tree (same materialized source closure, same -per-step credential repos). A deployment keeps its deploy tree for its -life; a different tree means a new deployment. Operator, +against the SAME definition (same materialized source closure). A +deployment keeps its definition for its life; a different definition +means a new deployment. Operator, supervisor-policy (max-uptime / max-rss / grants-staleness), and workflow-process-self-initiated origins all funnel through the same path. @@ -239,7 +226,7 @@ On an unexpected exit the supervisor, with no external intervention: keys. Those keys sort ahead of any mail that arrived during the kill/respawn gap, so the stranded entry is re-dispatched first and FIFO ordering holds across the respawn boundary. -2. Spawns a fresh workflow-process child against the same deploy tree +2. Spawns a fresh workflow-process child against the same definition (reusing the recycle path's respawn machinery) and resumes dispatch. The respawn is bounded so a persistently-broken child cannot saturate @@ -323,8 +310,7 @@ forking a process. 6. Emit `ready` and then `resumed.runs` on the control channel. Every other upstream control frame waits until both are out. 7. Loop on `trigger.fire`, `authority.set`, `grants-updated`, - `credentials-updated`, `drain`, `recycle`, `shutdown`, and - `signal.deliver` frames. + `credentials-updated`, `drain`, `shutdown`, and `signal.deliver` frames. ### Authorize Closure diff --git a/packages/workflow-host/src/adapters/blob-substrate.test.ts b/packages/workflow-host/src/adapters/blob-substrate.test.ts index 36a0e42e9..3dc2a9d1b 100644 --- a/packages/workflow-host/src/adapters/blob-substrate.test.ts +++ b/packages/workflow-host/src/adapters/blob-substrate.test.ts @@ -45,11 +45,11 @@ const allowAll: AuthorizeFn = () => ({ allowed: true }); // the workflow-run kind handler's strict `runs//` schema // rejects (only `events/` is permitted alongside event blobs). The // adapter tests target the adapter's behavior, not the kind handler's -// schema, so a permissive `agent-state`-shaped handler stands in for +// schema, so a permissive `skill`-shaped handler stands in for // the production wiring -- the same pattern the sibling repo-store // adapter test uses for its permissive smoke case. const permissiveHandler: KindHandler = { - kind: "agent-state", + kind: "skill", directoryPrefix: "blob-substrate-test", validatePush(): ValidatePushResult { return { ok: true }; @@ -67,11 +67,11 @@ async function makeAdapter( inlineMaxBytes?: number, ) { const dataDir = await makeTempDir("blob-substrate-adapter-"); - const repoId: RepoId = { kind: "agent-state", id: anchorRunId }; + const repoId: RepoId = { kind: "skill", id: anchorRunId }; const substrate = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler }, + handlers: { skill: permissiveHandler }, authorize: allowAll, }); const adapter = createWorkflowRunBlobSubstrate({ diff --git a/packages/workflow-host/src/adapters/repo-store.test.ts b/packages/workflow-host/src/adapters/repo-store.test.ts index 666c0b7df..a9ada7474 100644 --- a/packages/workflow-host/src/adapters/repo-store.test.ts +++ b/packages/workflow-host/src/adapters/repo-store.test.ts @@ -293,7 +293,7 @@ describe("workflow-host RepoStore adapter — append-result error translation", test("validate_failed: kind handler rejection surfaces with the handler's reason", async () => { const dataDir = await makeTempDir("repo-store-adapter-validate-failed-"); const repoId: RepoId = { - kind: "agent-state", + kind: "skill", id: "deployment-validate-failed", }; const principal: Principal = { kind: "test" }; @@ -305,7 +305,7 @@ describe("workflow-host RepoStore adapter — append-result error translation", // adapter must translate the substrate's `path_violation:` // surface into a thrown Error carrying the handler's reason. const failingHandler: KindHandler = { - kind: "agent-state", + kind: "skill", directoryPrefix: "validate-failed", async validatePush({ topLevelTreePaths, listDir, readBlob }) { if (!topLevelTreePaths.includes("runs")) return { ok: true }; @@ -337,7 +337,7 @@ describe("workflow-host RepoStore adapter — append-result error translation", const substrate = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": failingHandler }, + handlers: { skill: failingHandler }, authorize: allowAll, }); const adapter = createWorkflowRunRepoStore({ @@ -432,10 +432,10 @@ describe("workflow-host RepoStore adapter — first-seq contract against runtime describe("workflow-host RepoStore adapter — permissive handler smoke", () => { test("write+read round-trip via permissive handler preserves event fields", async () => { const dataDir = await makeTempDir("repo-store-adapter-smoke-"); - const repoId: RepoId = { kind: "agent-state", id: "deployment-smoke" }; + const repoId: RepoId = { kind: "skill", id: "deployment-smoke" }; const principal: Principal = { kind: "test" }; const permissive: KindHandler = { - kind: "agent-state", + kind: "skill", directoryPrefix: "smoke", validatePush(): ValidatePushResult { return { ok: true }; @@ -447,7 +447,7 @@ describe("workflow-host RepoStore adapter — permissive handler smoke", () => { const substrate = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissive }, + handlers: { skill: permissive }, authorize: allowAll, }); const adapter = createWorkflowRunRepoStore({ diff --git a/packages/workflow-host/src/child/action-credentials.test.ts b/packages/workflow-host/src/child/action-credentials.test.ts index aea6a5366..989092259 100644 --- a/packages/workflow-host/src/child/action-credentials.test.ts +++ b/packages/workflow-host/src/child/action-credentials.test.ts @@ -142,9 +142,7 @@ describe("createSidecarActionCredentialsFactory", () => { resolveStepGrants: createSnapshotGrantsResolver( { current: { - steps: [ - { stepId: "push", address: "push", grants, contentHash: "h" }, - ], + steps: [{ stepId: "push", grants, contentHash: "h" }], }, }, "lbl", diff --git a/packages/workflow-host/src/child/credentials-backed-authorize.test.ts b/packages/workflow-host/src/child/credentials-backed-authorize.test.ts index 247ee6b9b..586e24827 100644 --- a/packages/workflow-host/src/child/credentials-backed-authorize.test.ts +++ b/packages/workflow-host/src/child/credentials-backed-authorize.test.ts @@ -51,7 +51,6 @@ describe("createCredentialsBackedAuthorize base-step grant resolution", () => { steps: [ { stepId: "summarize", - address: "run_dep-summarize@example.com", grants: [summarizeGrant], contentHash: "hash", }, @@ -135,7 +134,7 @@ describe("createCredentialsBackedAuthorize base-step grant resolution", () => { describe("createSnapshotGrantsResolver", () => { function entry(stepId: string, grants: unknown[]) { - return { stepId, address: stepId, grants, contentHash: "h" }; + return { stepId, grants, contentHash: "h" }; } const oldGrant = rule("tool:old", "allow"); const newGrant = rule("tool:new", "allow"); @@ -208,7 +207,7 @@ describe("createSnapshotGrantsResolver", () => { describe("createSnapshotGrantsReader", () => { function entry(stepId: string, grants: unknown[]) { - return { stepId, address: stepId, grants, contentHash: "h" }; + return { stepId, grants, contentHash: "h" }; } test("returns one entry per grant id and keeps a row without an id once", () => { @@ -257,8 +256,8 @@ describe("createGrantEvaluator", () => { grants, }); - test("evaluates the snapshot grants when the step has no floor", async () => { - const evaluate = createGrantEvaluator({ floorFor: () => [] }); + test("evaluates the snapshot grants", async () => { + const evaluate = createGrantEvaluator(); await expect( evaluate(input("work", [rule("tool:fetch", "allow")])), ).resolves.toEqual({ @@ -273,23 +272,6 @@ describe("createGrantEvaluator", () => { }); }); - test("adds the floor of the base step, and a declared deny still wins", async () => { - const asked: string[] = []; - const evaluate = createGrantEvaluator({ - floorFor: (stepId) => { - asked.push(stepId); - return [rule("tool:fetch", "ask")]; - }, - }); - await expect(evaluate(input("fan[2]", []))).resolves.toMatchObject({ - effect: "ask", - }); - await expect( - evaluate(input("fan[0]", [rule("tool:fetch", "deny")])), - ).resolves.toMatchObject({ effect: "deny" }); - expect(asked).toEqual(["fan", "fan"]); - }); - test("evaluates a time window against the clock, so an approved ask allows inside it", async () => { // Windows relative to now, in UTC, an hour clear of either edge. const hhmm = (offsetHours: number) => @@ -303,7 +285,7 @@ describe("createGrantEvaluator", () => { timezone: "UTC", }, }); - const evaluate = createGrantEvaluator({ floorFor: () => [] }); + const evaluate = createGrantEvaluator(); const approved = (conditions: GrantRule["conditions"]) => evaluate(input("work", [{ ...rule("tool:fetch", "allow"), conditions }])); @@ -324,7 +306,7 @@ describe("createGrantEvaluator", () => { }); test("fails closed on a condition the child cannot evaluate", async () => { - const evaluate = createGrantEvaluator({ floorFor: () => [] }); + const evaluate = createGrantEvaluator(); await expect( evaluate( input("work", [ @@ -344,7 +326,6 @@ describe("a run child's authorize closure over grants that crossed JSON", () => steps: [ { stepId: "work", - address: "work", grants: overJSON(grants), contentHash: "h", }, @@ -353,7 +334,7 @@ describe("a run child's authorize closure over grants that crossed JSON", () => }, "lbl", ), - createGrantEvaluator({ floorFor: () => [] }), + createGrantEvaluator(), ); } const ctx = { stepId: "work", attempt: 1, runId: "run-1" }; diff --git a/packages/workflow-host/src/child/credentials-backed-authorize.ts b/packages/workflow-host/src/child/credentials-backed-authorize.ts index 036720983..e46f648f4 100644 --- a/packages/workflow-host/src/child/credentials-backed-authorize.ts +++ b/packages/workflow-host/src/child/credentials-backed-authorize.ts @@ -2,9 +2,9 @@ // grant evaluator it delegates to. // // The closure resolves a step's grants through a grants resolver, and the -// evaluator ranks those grants (plus any pinned-tool floor) with -// `evaluateGrants`. Nothing here imports a Node API, so a host -// that cannot load the run child can still build the same pair. +// evaluator ranks those grants with `evaluateGrants`. Nothing here imports a +// Node API, so a host that cannot load the run child can still build the same +// pair. import { evaluateGrants, @@ -216,16 +216,7 @@ export function createCredentialsBackedAuthorize( /** * The grant evaluator a run child's authorize closure delegates to. It ranks - * the step's snapshot grants together with the step's pinned-tool floor - * (`floorFor`, keyed by base step id) through `evaluateGrants`. - * - * The floor supplies the `tool:` authority a pinned tool never got from - * the hub's capability walk. It is ADDITIVE: `evaluateGrants` ranks by - * specificity then effect, so a declared `deny` (priority 2) still beats the - * derived `ask`/`allow` and an explicit denial is honored -- the floor only - * raises the minimum authority to the tool's static mark. A step with no floor - * contributes no rows, which can only fail more closed. A source-ref step has - * no floor: its `tool:` grant is already in the snapshot. + * the step's snapshot grants through `evaluateGrants`. * * A snapshot grant's conditions come from the run's declared grant * requirements, or are the `{ tool }` condition of a credential use row. Only @@ -237,17 +228,11 @@ export function createCredentialsBackedAuthorize( * other condition key needs request context the child does not have, so it * throws and the authorization fails closed. */ -export function createGrantEvaluator(opts: { - readonly floorFor: (baseStepId: string) => readonly GrantRule[]; -}): GrantEvaluator { - return async ({ resource, action, stepId, grants }) => { - const floor = opts.floorFor(baseStepId(stepId)); - const result = await evaluateGrants( - [...grants, ...floor], - resource, - action, - { conditions: RUN_CHILD_CONDITION_REGISTRY }, - ); +export function createGrantEvaluator(): GrantEvaluator { + return async ({ resource, action, grants }) => { + const result = await evaluateGrants([...grants], resource, action, { + conditions: RUN_CHILD_CONDITION_REGISTRY, + }); return { effect: result.effect, matchingGrants: [], diff --git a/packages/workflow-host/src/child/from-process-env.ts b/packages/workflow-host/src/child/from-process-env.ts index fb2420b7a..02a539e41 100644 --- a/packages/workflow-host/src/child/from-process-env.ts +++ b/packages/workflow-host/src/child/from-process-env.ts @@ -243,7 +243,7 @@ export async function runWorkflowChildFromProcessEnv( ); // The one evaluation of the workflow code in this process. The factory // and the runtime body both run this verified definition. The supervisor - // derives the deployment's step layout from the reported projection, and + // derives the deployment's step ids from the reported projection, and // learns from the report why a closure that does not load failed. const reportDefinition = (result: DefinitionResult) => upstreamSender.sendNow({ diff --git a/packages/workflow-host/src/child/program.ts b/packages/workflow-host/src/child/program.ts index 64145874c..32ca1cc00 100644 --- a/packages/workflow-host/src/child/program.ts +++ b/packages/workflow-host/src/child/program.ts @@ -8,9 +8,6 @@ export { SIDECAR_SUBSTRATE_CONFIG_KEYS, createSidecarSubstrateFactory, } from "./substrate-factory"; -export type { - StepToolCacheConfig, - StepToolMaterialization, -} from "./step-tools"; +export type { StepToolMaterialization } from "./step-tools"; export { runWorkflowChildFromProcessEnv } from "./from-process-env"; diff --git a/packages/workflow-host/src/child/run-child-source-ref.test.ts b/packages/workflow-host/src/child/run-child-source-ref.test.ts index 7e2f29b0d..939b3f36b 100644 --- a/packages/workflow-host/src/child/run-child-source-ref.test.ts +++ b/packages/workflow-host/src/child/run-child-source-ref.test.ts @@ -322,7 +322,6 @@ async function seedProcessingEntry( messageId: opts.messageId, receivedAt: opts.receivedAt, address: opts.address, - mailAuditRef: { store: "test", path: opts.messageId }, rawMessage: base64Encode( assembleConversationMessage(opts.address, opts.text), ), @@ -421,7 +420,6 @@ function credentialedActionAuthority(consumer: string) { steps: [ { stepId: "fetch", - address: "deployment-x-fetch@example.com", grants: [ { id: "grant-use", @@ -491,7 +489,6 @@ const AUTHORITY_SNAPSHOT = { steps: [ { stepId: "main", - address: "deployment-x-main@example.com", grants: [], contentHash: "deadbeef", }, diff --git a/packages/workflow-host/src/child/run-child.test.ts b/packages/workflow-host/src/child/run-child.test.ts index c09c9f81d..f50a2dd9b 100644 --- a/packages/workflow-host/src/child/run-child.test.ts +++ b/packages/workflow-host/src/child/run-child.test.ts @@ -341,7 +341,6 @@ async function seedProcessingEntry( messageId: opts.messageId, receivedAt: opts.receivedAt, address: opts.address, - mailAuditRef: { store: "test", path: opts.messageId }, rawMessage: base64Encode(rawMessage), }; await fs.writeFile( @@ -386,7 +385,6 @@ const AUTHORITY_SNAPSHOT = { steps: [ { stepId: "step-1", - address: "deployment-x-step-1@example.com", grants: [], contentHash: "deadbeef", }, @@ -1414,7 +1412,6 @@ describe("runWorkflowChild", () => { steps: [ { stepId: "step-1", - address: "deployment-x-step-1@example.com", grants: [{ resource: "thing", action: "read" }], contentHash: "freshhash", }, @@ -1577,7 +1574,6 @@ describe("runWorkflowChild", () => { steps: [ { stepId: "step-1", - address: "deployment-x-step-1@example.com", grants: [{ resource: "thing", action: "read" }], contentHash: "replaced", }, @@ -2064,7 +2060,6 @@ async function seedProcessingEntryInDir( messageId: opts.messageId, receivedAt: opts.receivedAt, address: opts.address, - mailAuditRef: { store: "test", path: opts.messageId }, rawMessage: base64Encode(rawMessage), }; await fs.writeFile( @@ -2219,7 +2214,6 @@ describe("warm-agent round-trip (Phase 4.4)", () => { steps: [ { stepId, - address: "deployment-x@example.com", grants: [], contentHash: "deadbeef", }, @@ -2411,7 +2405,6 @@ describe("warm-agent round-trip (Phase 4.4)", () => { steps: [ { stepId, - address: "deployment-x@example.com", grants: [], contentHash: "deadbeef", }, diff --git a/packages/workflow-host/src/child/step-tools.test.ts b/packages/workflow-host/src/child/step-tools.test.ts index b2075d2b1..bceb5a0d7 100644 --- a/packages/workflow-host/src/child/step-tools.test.ts +++ b/packages/workflow-host/src/child/step-tools.test.ts @@ -33,14 +33,11 @@ import { defineTool, type BaseEnv, type ToolBundle, - type ToolDeclaration, } from "@intx/agent"; import { createDefaultDirectorRegistry } from "@intx/agent"; -import { evaluateGrants } from "@intx/authz"; import { createIsogitStore } from "@intx/storage-isogit/node"; import { noopAuditStore } from "@intx/agent/testing"; import type { HostCredentialCapability } from "@intx/types"; -import type { GrantRule } from "@intx/types/authz"; import type { InferenceSource } from "@intx/types/runtime"; import { createRuntimeCapabilities, @@ -51,7 +48,6 @@ import { waitUntil } from "@intx/types/testing"; import { attachStepTools, createToolBearingAgentFactory, - deriveToolMarkFloorGrants, rewrapStepToolFactory, sourceStepToolFactories, type StepToolMaterialization, @@ -560,134 +556,6 @@ describe("rewrapStepToolFactory", () => { }); }); -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( - packageName: string, - definitions: readonly ToolDeclaration[], - ): { packageName: string; definitions: readonly ToolDeclaration[] } { - return { packageName, definitions }; - } - - // Mirror the factory's grant evaluator merge: the credentials snapshot's - // grants plus the derived floor, resolved via `evaluateGrants`. - async function resolveWithFloor( - toolName: string, - factories: readonly { - packageName: string; - definitions: readonly ToolDeclaration[]; - }[], - snapshotGrants: readonly GrantRule[], - ): Promise<"allow" | "deny" | "ask" | null> { - const floor = deriveToolMarkFloorGrants(factories); - const result = await evaluateGrants( - [...snapshotGrants, ...floor], - `tool:${toolName}`, - "invoke", - { conditions: "pessimistic" }, - ); - 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", - ); - }); -}); - describe("sourceStepToolFactories", () => { test("keys each factory by its id and carries its credential declaration", () => { const declaring = defineTool({ diff --git a/packages/workflow-host/src/child/step-tools.ts b/packages/workflow-host/src/child/step-tools.ts index 6e8dceafe..6b63ddb0e 100644 --- a/packages/workflow-host/src/child/step-tools.ts +++ b/packages/workflow-host/src/child/step-tools.ts @@ -1,16 +1,12 @@ // Per-step tool attachment and agent construction for the workflow child. // -// The process that boots the child materializes pinned tool packages and -// passes the result in. This module attaches that result to the step env, -// derives the tool-mark floor from each factory's static declarations, and -// builds the agent whose `close()` tears the plugin chain and tool bundles -// down. It does not read a deploy tree or load a tool package. +// This module attaches a step's tool factories to the step env and builds the +// agent whose `close()` tears the plugin chain and tool bundles down. import { createAgent, defineAgent, defineTool, - toolApprovalEffect, toolFactoryMeta, type Agent, type AgentDefinition, @@ -18,9 +14,7 @@ import { type AnnotatedToolFactory, type BaseEnv, type ToolBundle, - type ToolDeclaration, } from "@intx/agent"; -import type { GrantRule } from "@intx/authz"; import { buildCredentialCapabilities, type StepCredentialWiring, @@ -35,18 +29,6 @@ import { const logger = getLogger(["sidecar", "workflow-child", "step-tools"]); -/** - * Cache and registry caps the per-step tool loader needs. Resolved at - * the sidecar boot edge from the existing `SIDECAR_CACHE_*` / - * `SIDECAR_REGISTRY_*` config keys and threaded into the child through - * the substrate config, so the child's per-step materialization is - * bounded by those boot-edge-resolved caps. - */ -export interface StepToolCacheConfig { - readonly cacheMaxBytes: number; - readonly registryMaxTarballBytes: number; -} - /** * Materialized tool runtime for one step's agent. Carried from * `buildEnv` (which knows the step's identity) to the `agentFactory` @@ -85,57 +67,6 @@ export function sourceStepToolFactories( })); } -/** - * Derive the per-step tool-mark floor grants from a step's materialized - * tool factories. Each loaded factory carries its static - * `definitions` (name + optional `approval` mark) verbatim from the tool - * package; every declared tool contributes a `tool:` / `invoke` - * grant whose effect is the tool's floor (`ask` for an approval-gated - * tool, `allow` otherwise), computed through the same - * `toolApprovalEffect` mapping the deploy-time capability walk uses so a - * pinned tool floors identically hub-side and sidecar-side. - * - * The hub's capability walk reads only INLINE `agent.toolFactories`, so a - * tool that ships as a pinned package loads in the child and never - * produces a `tool:` grant on the run principal. These derived rows - * supply that missing floor: they join the per-step grants the authz path - * evaluates as ADDITIONAL rows, so `evaluateGrants` precedence still - * resolves an explicit `deny` (priority 2) over the derived `ask`/`allow` - * -- the floor only raises the minimum authority to the tool's mark, it - * never overrides a declared denial. - * - * The grant `id` is deterministic (`floor:tool:`) rather than - * random: `evaluateGrants` never dedupes or joins on `id` (it ranks by - * specificity then effect), so a stable id keeps the rows reproducible - * without a generator, and a floor row that coincides with a - * hub-supplied `tool:` row resolves by effect precedence regardless - * of the ids. - */ -export function deriveToolMarkFloorGrants( - factories: readonly { - readonly packageName: string; - readonly definitions: readonly ToolDeclaration[]; - }[], -): GrantRule[] { - const rows: GrantRule[] = []; - for (const factory of factories) { - for (const definition of factory.definitions) { - rows.push({ - id: `floor:tool:${definition.name}`, - resource: `tool:${definition.name}`, - action: "invoke", - effect: toolApprovalEffect(definition), - origin: "creator", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }); - } - } - return rows; -} - /** * Symbol-keyed slot the `buildEnv` callback sets on the env it returns * and the `agentFactory` reads. Object spread (`{ ...envBase, @@ -264,7 +195,7 @@ function requireCapabilitiesBag(env: BaseEnv): RuntimeCapabilities { /** * Attach a step's materialized tool runtime to the per-step env so the * tool-bearing `agentFactory` can consume it. Called by `buildEnv` - * after `materializeStepTools` resolves. + * once it has assembled the step's factories. * * The parameter is `Omit` because `buildEnv` * yields exactly that shape (the step-invoker adapter adds `authorize` diff --git a/packages/workflow-host/src/child/substrate-factory.ts b/packages/workflow-host/src/child/substrate-factory.ts index 6a1842e47..d5f259e9b 100644 --- a/packages/workflow-host/src/child/substrate-factory.ts +++ b/packages/workflow-host/src/child/substrate-factory.ts @@ -44,7 +44,6 @@ import { type AdapterManifest, parseAdapterManifestEnv, } from "@intx/types/adapter-manifest"; -import type { GrantRule } from "@intx/authz"; import { createDependencies, type AdapterRegistry } from "@intx/inference"; import { loadAdapterRegistry } from "@intx/inference/providers"; import type { @@ -168,9 +167,7 @@ import { attachStepCredentialWiring, attachStepTools, createToolBearingAgentFactory, - deriveToolMarkFloorGrants, sourceStepToolFactories, - type StepToolCacheConfig, type StepToolMaterialization, } from "./step-tools"; import { @@ -201,8 +198,6 @@ export const SIDECAR_SUBSTRATE_CONFIG_KEYS = [ "SIDECAR_SIGNING_PRIVATE_KEY", "STEP_INFERENCE_SOURCES", "WORKFLOW_BODY_SOURCES", - "SIDECAR_CACHE_MAX_BYTES", - "SIDECAR_REGISTRY_MAX_TARBALL_BYTES", "SIDECAR_ADAPTER_MANIFEST", ] as const; @@ -219,41 +214,15 @@ const SubstrateConfig = type({ // deployment spawns no bodies), so a missing key child-side is a serialization // bug and must fail loud here. WORKFLOW_BODY_SOURCES: "string > 0", - // Per-step tool-loader caps. The supervisor threads the boot edge's - // resolved `SIDECAR_CACHE_MAX_BYTES` / `SIDECAR_REGISTRY_MAX_TARBALL_BYTES` - // through `substrateEnv` so the child's per-step tool materialization is - // bounded by the sidecar's boot-edge-resolved caps. Validated as - // positive-finite-number strings at this boundary. - SIDECAR_CACHE_MAX_BYTES: "string > 0", - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: "string > 0", // JSON-encoded custom inference adapter manifest. Required: the boot // edge always serializes it into `substrateEnv` (defaulting to "[]" // when no custom adapters are configured), so a missing key child-side - // is a serialization bug and must fail loud here, exactly like the - // byte-cap fields. Validated as a non-empty string at this boundary; + // is a serialization bug and must fail loud here. Validated as a non-empty string at this boundary; // its JSON shape is re-validated against `AdapterManifest` in // `parseAdapterManifest` before any module is imported. SIDECAR_ADAPTER_MANIFEST: "string > 0", }).onUndeclaredKey("ignore"); -/** - * Parse a substrate-config cap entry (`SIDECAR_CACHE_MAX_BYTES` / - * `SIDECAR_REGISTRY_MAX_TARBALL_BYTES`) into a positive finite number. - * The boot edge already validated these via the `config.ts` readers - * before serializing them into `substrateEnv`; this re-parse at the - * child boundary keeps the typed-config contract honest rather than - * trusting the wire blindly. - */ -function parseByteCap(raw: string, name: string): number { - const n = Number(raw); - if (!Number.isFinite(n) || n <= 0) { - throw new Error( - `sidecar workflow-child substrate config: ${name} must be a positive finite number; got ${JSON.stringify(raw)}`, - ); - } - return n; -} - /** * Per-step inference-source table parsed from the spawn-time * `STEP_INFERENCE_SOURCES` env entry. The deploy router serializes @@ -426,26 +395,12 @@ function hexDecode(hex: string, name: string): Uint8Array { } /** - * Pinned tool-package materialization. The sidecar process owns the - * deploy-tree read; the factory calls this only on the pinned arm. - */ -type MaterializeStepTools = (args: { - dataDir: string; - mailboxAddress: string; - stepId: string; - stepCount: number; - storeDir: string; - cache: StepToolCacheConfig; -}) => Promise; - -/** - * Dependencies `createSidecarSubstrateFactory` closes over. Tool - * materialization and the child-grant cap are required: this package does - * not import the modules that implement them. `createBareRepoStore` stays + * Dependencies `createSidecarSubstrateFactory` closes over. The child-grant + * cap is required: this package does not import the modules that implement + * it. `createBareRepoStore` stays * optional so tests can inject an in-memory store. */ interface SidecarSubstrateFactoryDeps { - materializeStepTools: MaterializeStepTools; collectDeclaredResources: ( definition: WorkflowDefinition, directors: DirectorRegistry, @@ -482,7 +437,7 @@ interface SidecarSubstrateFactoryDeps { * `CommitSigner` the per-step isogit stores use to sign every commit. * The factory's Ed25519 signing keypair (the same key the child's bare * `RepoStore` carries) is bound into an `sshsig`-shaped signer so the - * per-step agent-state commits are attributable to the sidecar's + * per-step step-state commits are attributable to the sidecar's * substrate identity, matching the signing surface the production * `RepoStore` uses for workflow-run writes. */ @@ -497,7 +452,7 @@ function createStepStorageSigner(signingKey: { } /** - * Root directory for a single step invocation's agent-state storage and + * Root directory for a single step invocation's storage and * workspace, derived from the sidecar data dir and the run/step/attempt * coordinates the workflow runtime owns. * @@ -570,7 +525,7 @@ function runStepStorageRoot(args: { /** * Stable per-agent scratch root for the WARM single-step agent's - * workspace + tool materialization (tarball-cache + apply-state). Keyed + * workspace. Keyed * by the step identity exactly like the durable conversation store's * `agent-conversation-state///` (conversation-state.ts), * NOT by the arbitrary first-message runId. Keying it stably is what @@ -834,24 +789,14 @@ export interface SidecarStepBuildEnvDeps { signer: (payload: string) => Promise; /** * Deployment mailbox address the supervisor threaded into the child - * (`MAILBOX_ADDRESS`). Used to locate each step's on-disk deploy tree - * for tool materialization (see `stepDeployTreeDir`) AND as the step - * agent's outbound mail `address`: the supervisor signs the agent's + * (`MAILBOX_ADDRESS`). Used as the step agent's outbound mail `address`: + * the supervisor signs the agent's * outbound mail as this address through the host transport (OUTBOUND * half of mailbox ownership, §3a). For the single-agent deploy this is * the run address the host registered the agent's `CryptoProvider` * against. */ mailboxAddress: string; - /** - * Step count of the deployed `WorkflowDefinition` (`stepOrder.length`), - * threaded from the host through the spawn-time env. Selects the - * head/step collapse when locating a step's deploy tree - * (`stepDeployTreeDir` -> `resolveStepAddress`): a single-step - * deployment reads at the head, a multi-step deployment at the per-step - * address, matching the host's producer push. - */ - stepCount: number; /** * Child-side outbound-mail bridge over the upstream control channel * (OUTBOUND half of mailbox ownership, §3a). The per-step env builder @@ -873,8 +818,6 @@ export interface SidecarStepBuildEnvDeps { * transport inbound stays inert. */ inbound?: SupervisorBackedTransportInbound; - /** Per-step tool-loader caps (cache + registry tarball size). */ - cache: StepToolCacheConfig; /** * Adapter registry the step agent resolves inference adapters through. * The child builds this eagerly at boot from the validated @@ -898,57 +841,18 @@ export interface SidecarStepBuildEnvDeps { * scoped to the attempt (see `flushColdStepState`). */ durableConversation?: DurableConversationRegistry; - /** - * Record the tool-mark floor grants derived from the step's - * materialized tool factories, keyed by the step's base id. The env - * builder is the only place the child holds the loaded factories' - * static `definitions` (name + approval mark), so it derives the floor - * here; the grant evaluator (a sibling closure in the substrate - * factory) reads the recorded floor by base step id and merges it under - * the credentials snapshot's grants at authorization time. Keyed by - * base id (matching the credentials snapshot and inference-source - * table) so a `map` iteration's scoped id shares its base step's floor. - * The recording persists across a warm agent's messages: the env - * builder runs once (first build) but the floor it records must be - * available for every later tool call the warm agent makes. - */ - recordToolMarkFloor: (baseStepId: string, grants: GrantRule[]) => void; - /** - * Feed the step agent's OWN evaluated tool factories into the - * materialization slot, instead of reading a pinned tool-package manifest - * off the deploy tree. Set for the source-ref lineage, whose child holds - * the live `WorkflowDefinition` (re-verified against the approved wire hash - * at run start) and threads each step's live `agent.toolFactories` in as - * `req.agent.toolFactories`. Those are the same `AnnotatedToolFactory` - * callables `materializeStepTools` would produce, bare-named (no - * namespacing loader runs), so the existing tool-bearing agent factory - * consumes them unchanged. - * - * The source-ref lineage stages no `tool-packages-manifest.json`, so - * `materializeStepTools` would find nothing and return empty tools; this - * arm is what actually runs a source workflow's tools. No tool-mark floor - * is recorded on this lineage: a source tool's runtime name is the bare - * `definition.name` the probe's capability walk already emitted a - * `tool:` grant for into the frozen credentials snapshot, so the - * snapshot authorizes it directly (unlike a pinned tool, whose namespaced - * name the walk never saw and which the floor exists to compensate for). - */ - sourceTools: boolean; /** * Sidecar-local directory of the materialized workflow-definition closure - * (`env.spawn.closurePackageDir`), present only on the source-ref lineage. - * The source arm materializes each step agent's declared plugin packages - * (`req.agent.plugins`) from this already-laid-out closure -- resolving them + * (`env.spawn.closurePackageDir`). The invoker materializes each step + * agent's declared plugin packages (`req.agent.plugins`) from this + * already-laid-out closure -- resolving them * through the workflow package's `node_modules/` graph, no re-download -- and * feeds the resulting plugin factories into the per-step plugin chain. A * plugin package contributes no agent-visible tool factory, so this closure * load is the only channel that reaches an `env.plugins` a source workflow's - * posix bundle consumes. Undefined on a build that materializes pinned tool - * packages instead, which reads no closure. + * posix bundle consumes. */ - closurePackageDir?: string; - /** Pinned tool-package materialization. Unused when `sourceTools` is set. */ - materializeStepTools: MaterializeStepTools; + closurePackageDir: string; /** * Director registry each step env carries. Required: the source-ref lineage * supplies the closure-composed registry so a step agent's `director` ref @@ -963,11 +867,7 @@ export interface SidecarStepBuildEnvDeps { /** * Materialize the plugin factories a source-ref step agent declares * (`req.agent.plugins`) from the workflow's frozen closure. Returns an - * empty list when the agent declares no plugins. Fails closed when plugins - * are declared but the source-ref closure dir is absent -- that pairing is a - * wiring bug (the source arm always carries `closurePackageDir`), and - * silently dropping the plugins would run the workflow without the tools it - * declared and leave the reactor to fail closed on the un-loaded tool. + * empty list when the agent declares no plugins. * * The closure is already laid out on disk from the deploy-side apply, so * this is a load-only pass -- no re-download -- resolving each plugin @@ -982,11 +882,6 @@ async function materializeSourcePluginFactories( if (plugins.length === 0) { return []; } - if (deps.closurePackageDir === undefined) { - throw new Error( - `sidecar workflow-child step invoker: step agent ${JSON.stringify(req.agent.id)} declares plugins ${JSON.stringify(plugins)} but the source-ref closure package dir is absent; a source workflow's plugin factories can only be materialized from its frozen closure`, - ); - } return loadWorkflowPluginFactoriesFromClosure({ packageDir: deps.closurePackageDir, plugins, @@ -1056,8 +951,8 @@ export function createSidecarStepBuildEnv( ); } - // Root the per-step scratch (workspace + tool tarball-cache + - // apply-state). The cold (multi-step) path keys it per + // Root the per-step scratch (the workspace). The cold (multi-step) path + // keys it per // run/step/attempt: each run rebuilds the agent and its scratch, and // the run's whole `runs//` subtree is reclaimed on run // completion. The warm single-step path (`durableConversation` @@ -1158,76 +1053,21 @@ export function createSidecarStepBuildEnv( const workdir = path.join(storeDir, "workspace"); await fs.promises.mkdir(workdir, { recursive: true }); - // Assemble the step's tool runtime. Two arms: - // - // - Source-ref lineage (`sourceTools`): run the step agent's OWN - // evaluated tool factories. The child holds the live re-verified - // `WorkflowDefinition`, and the runtime threads each step's live agent - // in as `req.agent`, so `req.agent.toolFactories` are the real callable - // `AnnotatedToolFactory`s -- the same shape `materializeStepTools` - // produces (`LoadedToolFactory === AnnotatedToolFactory`), - // bare-named because no namespacing loader runs. The source deploy - // stages no `tool-packages-manifest.json`, so `materializeStepTools` - // would find nothing here; feeding the evaluated factories straight - // into the slot is what runs a source workflow's tools. `packageName` - // is the factory's bundle `id` (the credential Gate-2 key), and its - // `defineTool` declaration feeds the declared-versus-bound check; a - // mail/plain tool declares no credentials, so it stays inert. Plugin factories -- - // which have no agent slot, so Option A cannot carry them -- are - // materialized separately from the frozen closure the child already - // holds (`materializeSourcePluginFactories`) and fed into the SAME - // `pluginFactories` slot `materializeStepTools` fills for pinned - // packages, so the existing per-step plugin chain wires them onto - // `env.plugins`. - // - // - Pinned tool packages (`materializeStepTools`): materialize the pinned - // tool-package closure (posix, LSP, mail, ...) from its on-disk deploy - // tree, rooted per step under `storeDir` so concurrent steps in one - // child never collide on the tarball cache or the apply-state tree. A - // step with no manifest yields empty tools (the legitimate - // `rawManifestBytes === undefined` case); a present-but-broken manifest - // surfaces loudly through `materializeStepTools` rather than degrading to - // empty tools. - const materialization: StepToolMaterialization = - deps.sourceTools === true - ? { - factories: sourceStepToolFactories(req.agent.toolFactories), - pluginFactories: await materializeSourcePluginFactories(deps, req), - } - : await deps.materializeStepTools({ - dataDir: deps.dataDir, - mailboxAddress: deps.mailboxAddress, - stepId, - stepCount: deps.stepCount, - storeDir, - cache: deps.cache, - }); - - // Derive and record the step's tool-mark floor from the just-loaded - // factories' static definitions. A pinned tool loads here in the - // child and never reached the hub's capability walk, so its - // `tool:` grant is absent from the credentials snapshot; the - // recorded floor is what lets the grant evaluator authorize a pinned - // tool against its own static mark. Keyed by base step id so the - // evaluator's `baseStepId(stepId)` lookup resolves for both a plain - // step and a `map` iteration's scoped id. - // - // Skipped on the source-ref lineage: a source tool's runtime name is the - // bare `definition.name` the capability walk already emitted a `tool:` - // grant for, so the credentials snapshot authorizes it directly with the - // correct effect -- no floor is needed, and recording one would blur that - // invariant. - if (deps.sourceTools !== true) { - deps.recordToolMarkFloor( - baseStepId(stepId), - deriveToolMarkFloorGrants( - materialization.factories.map((f) => ({ - packageName: f.packageName, - definitions: f.factory.definitions, - })), - ), - ); - } + // Assemble the step's tool runtime from the step agent's OWN evaluated tool + // factories. The child holds the live re-verified `WorkflowDefinition`, and + // the runtime threads each step's live agent in as `req.agent`, so + // `req.agent.toolFactories` are the real callable `AnnotatedToolFactory`s, + // bare-named because no namespacing loader runs. `packageName` is the + // factory's bundle `id` (the credential Gate-2 key), and its `defineTool` + // declaration feeds the declared-versus-bound check; a mail/plain tool + // declares no credentials, so it stays inert. Plugin factories -- which have + // no agent slot -- are materialized separately from the frozen closure the + // child already holds (`materializeSourcePluginFactories`), and the per-step + // plugin chain wires them onto `env.plugins`. + const materialization: StepToolMaterialization = { + factories: sourceStepToolFactories(req.agent.toolFactories), + pluginFactories: await materializeSourcePluginFactories(deps, req), + }; // Supervisor-backed transport for the step agent's mail tools (both // halves of mailbox ownership, §3a OUTBOUND and §3b INBOUND). Outbound @@ -1466,10 +1306,9 @@ interface SidecarRunChildDeps { * from it so a plugin-contributed `tool:` the child loads is declared * when capping the child's inherited grants, and loads `interchange.actions` * when this body can reach an action. A body that declares neither a plugin - * nor an action needs no closure read; absent on a lineage that stages no - * closure. A body that reaches an action and has no directory fails at spawn. + * nor an action needs no closure read. */ - closurePackageDir?: string; + closurePackageDir: string; /** Clock for timestamp generation; defaults to `() => new Date()`. */ clock?: () => Date; /** @@ -1862,13 +1701,10 @@ async function collectChildCapInputs( deps: SidecarRunChildDeps, definition: WorkflowDefinition, ): Promise { - const pluginDefs: ReadonlyMap = - deps.closurePackageDir === undefined - ? new Map() - : await loadWorkflowPluginToolDefinitionsFromClosure({ - packageDir: deps.closurePackageDir, - plugins: collectDeclaredPluginNames(definition), - }); + const pluginDefs = await loadWorkflowPluginToolDefinitionsFromClosure({ + packageDir: deps.closurePackageDir, + plugins: collectDeclaredPluginNames(definition), + }); return { declaredResources: deps.collectDeclaredResources( definition, @@ -2051,16 +1887,9 @@ async function buildChildRunEnv(args: { } } // Resolve the loop while/carry fns from the closure so a loop in this body can - // run. Only load when the body contains a loop. If it declares a loop but no - // closure is wired, that is a wiring defect -- fail loud, mirroring the - // establish-time posture rather than deferring to a mid-run resolve failure. + // run. Only load when the body contains a loop. let loopFns: LoopFnRegistry | undefined; if (loopBodies.length > 0) { - if (deps.closurePackageDir === undefined) { - throw new Error( - "sidecar child: a loop is nested in this spawned body but deps.closurePackageDir is missing; the loop while/carry fns cannot be resolved", - ); - } loopFns = await loadWorkflowLoopFnsFromClosure({ packageDir: deps.closurePackageDir, }); @@ -2092,10 +1921,8 @@ async function buildChildRunEnv(args: { }); // The child's credentials snapshot applies the capped grant set // uniformly across every step the child definition declares, keyed on - // each step's id (the same shape the deploy-time and per-run snapshot - // assemblies produce). The in-process child has no per-step mail - // address, so the snapshot's `address` mirrors the step id -- - // `createCredentialsBackedAuthorize` reads only `grants`. + // each step's id (the same shape the per-run snapshot assembly + // produces). // // "Every step the child definition declares" reaches past `stepOrder` into // this body's `loop` bodies: a loop iteration runs under the inherited env, @@ -2118,7 +1945,6 @@ async function buildChildRunEnv(args: { const credentialsSnapshot: CredentialsSnapshot = { steps: [...credentialStepIds].map((stepId) => ({ stepId, - address: stepId, grants: childGrants, contentHash, })), @@ -2195,11 +2021,6 @@ async function buildChildRunEnv(args: { }); let actionResolver: ((ref: string) => ActionHandler) | undefined; if (actionReachable) { - if (deps.closurePackageDir === undefined) { - throw new Error( - "sidecar child: an action is reachable in this spawned body but deps.closurePackageDir is missing; the action handlers cannot be resolved", - ); - } actionResolver = await loadWorkflowActionHandlersFromClosure({ packageDir: deps.closurePackageDir, }); @@ -2445,7 +2266,7 @@ function defaultNewId(prefix: string): string { /** * Build a `SubstrateFactory` closed over the supplied dependencies. - * The sidecar binding passes tool materialization and the grant cap. + * The sidecar binding passes the grant cap. * * Construction order: * 1. Narrow the `substrateConfig` record against the typed schema. @@ -2574,27 +2395,13 @@ export function createSidecarSubstrateFactory( // step-invoker instantiates the step's agent via `createAgent`, // delivers the resolved input as a synthesized inbound message, and // captures the agent's reply as the step output. - const stepToolCache: StepToolCacheConfig = { - cacheMaxBytes: parseByteCap( - validated.SIDECAR_CACHE_MAX_BYTES, - "SIDECAR_CACHE_MAX_BYTES", - ), - registryMaxTarballBytes: parseByteCap( - validated.SIDECAR_REGISTRY_MAX_TARBALL_BYTES, - "SIDECAR_REGISTRY_MAX_TARBALL_BYTES", - ), - }; - // The single-step / top-level path runs a real agent with REAL - // tools materialized in-child. The per-step env builder stands up + // tools built in-child. The per-step env builder stands up // real per-step storage/workdir/audit/directors rooted under the - // run (see `createSidecarStepBuildEnv`), resolves the per-step - // `InferenceSource`, and materializes the step's pinned - // tool-package closure (posix, LSP, mail, ...) from its on-disk - // deploy tree -- rooted per step so concurrent steps in one child - // never collide on the tarball cache or apply-state. The - // tool-bearing `agentFactory` below attaches those factories to the - // step's `AgentDefinition` and builds the plugin chain. + // run (see `createSidecarStepBuildEnv`) and resolves the per-step + // `InferenceSource`. The tool-bearing `agentFactory` below attaches + // the agent's evaluated factories to the step's `AgentDefinition` + // and builds the plugin chain. // Durable-conversation registry for the warm single-step agent // (design §3c). Built only when the deployment is warm-kept: the // sole long-lived agent's conversation must survive child respawn, @@ -2625,16 +2432,6 @@ export function createSidecarSubstrateFactory( }) : undefined; - // Per-step tool-mark floor grants, keyed by base step id. The step - // env builder derives and records each step's floor from its - // materialized (pinned) tool factories; the grant evaluator reads it - // by base step id and merges it under the credentials snapshot's - // grants so a pinned tool authorizes against its own static mark. The - // map lives for the factory's (child's) lifetime, so a warm agent's - // floor -- recorded on its single first build -- remains available - // for every later tool call it makes. - const toolMarkFloorByStep = new Map(); - // The step env's director registry is the closure-composed one, built // from the SAME frozen closure the run-child runs, so a step agent's // `director` ref resolves to a custom director shipped by the workflow @@ -2653,21 +2450,10 @@ export function createSidecarSubstrateFactory( principal, signer: conversationSigner, mailboxAddress: env.spawn.mailboxAddress, - stepCount: env.definition.stepOrder.length, outboundMailBridge: env.outboundMailBridge, - cache: stepToolCache, adapters: childAdapterRegistry, - recordToolMarkFloor: (stepId, grants) => { - toolMarkFloorByStep.set(stepId, grants); - }, - materializeStepTools: deps.materializeStepTools, - // Source-ref is the only deploy lineage: the child runs each step agent's - // own evaluated tool factories (fed from `req.agent.toolFactories`) from - // the materialized closure, never a pinned tool-package manifest off a - // deploy tree. - sourceTools: true, - // The materialized closure dir the source arm materializes each step - // agent's declared plugin packages from. Always present (source-ref only). + // The materialized closure dir the invoker materializes each step + // agent's declared plugin packages from. closurePackageDir: env.spawn.closurePackageDir, directors: stepDirectors, // Activate the warm agent's inbound mail surface. Mail tools ask the @@ -2719,16 +2505,11 @@ export function createSidecarSubstrateFactory( // Built COLD per invocation -- no `durableConversation`/warm hooks (a // fan-out branch and a section body are each a fresh run per spawn) and no // `inbound` (a spawned child owns no warm inbound mailbox; inbound-reading - // tools stay inert). The source arm records no tool-mark floor (a source - // tool's bare `tool:` grant is already in the credentials snapshot), - // so the floor recorder throw-asserts that invariant. + // tools stay inert). A source tool's bare `tool:` grant is already + // in the credentials snapshot. // - // The source arm also keeps the body's tools scoped correctly by - // construction: a body child runs under the PARENT deployment's - // `mailboxAddress`/`stepCount`, so a body step whose id collides with a - // parent step id would read the PARENT step's tools if tools were resolved - // off the deploy tree. Feeding each agent its own evaluated - // `req.agent.toolFactories` never consults that tree. + // Each agent gets its own evaluated `req.agent.toolFactories`, so a body + // step whose id collides with a parent step id still gets its own tools. const coldChildBuildStepEnv = createSidecarStepBuildEnv({ dataDir: validated.SIDECAR_DATA_DIR, workflowRunRepoId, @@ -2737,17 +2518,8 @@ export function createSidecarSubstrateFactory( principal, signer: conversationSigner, mailboxAddress: env.spawn.mailboxAddress, - stepCount: env.definition.stepOrder.length, outboundMailBridge: env.outboundMailBridge, - cache: stepToolCache, adapters: childAdapterRegistry, - recordToolMarkFloor: () => { - throw new Error( - "source-tools child build-env must not record a tool-mark floor", - ); - }, - materializeStepTools: deps.materializeStepTools, - sourceTools: true, closurePackageDir: env.spawn.closurePackageDir, directors: stepDirectors, }); @@ -2794,8 +2566,8 @@ export function createSidecarSubstrateFactory( // `resource = tool:`, `action = "invoke"` (the inference // layer's authz before-tool extension); using the credentials-backed // authorize here means each tool call resolves against the per-step - // grant snapshot the supervisor assembled from the agent's - // `state/grants.json` and pushed over the control IPC. A tool the + // grant snapshot of the run's grants the supervisor pushed over the + // control IPC. A tool the // agent's grants do not allow is blocked; a granted tool runs. The // operator gate at deploy time (the capability walk's `tool:` // approval) and this runtime grant check are complementary: the walk @@ -2914,9 +2686,7 @@ export function createSidecarSubstrateFactory( : {}), })(req); - const evaluateGrantsAdapter: GrantEvaluator = createGrantEvaluator({ - floorFor: (stepId) => toolMarkFloorByStep.get(stepId) ?? [], - }); + const evaluateGrantsAdapter: GrantEvaluator = createGrantEvaluator(); const childRunDeps: SidecarRunChildDeps = { substrate, diff --git a/packages/workflow-host/src/child/workflow-substrate-factory-abort.test.ts b/packages/workflow-host/src/child/workflow-substrate-factory-abort.test.ts index 4f4599eb6..2ceecb0f4 100644 --- a/packages/workflow-host/src/child/workflow-substrate-factory-abort.test.ts +++ b/packages/workflow-host/src/child/workflow-substrate-factory-abort.test.ts @@ -238,6 +238,7 @@ function sharedDeps( collectDeclaredCredentialConsumers: () => new Set(), filterGrantsToDeclaredResources: (parentGrants: readonly unknown[]) => parentGrants, + closurePackageDir: "/unused-closure", directors: createDefaultDirectorRegistry(), }; } diff --git a/packages/workflow-host/src/child/workflow-substrate-factory-child-action.test.ts b/packages/workflow-host/src/child/workflow-substrate-factory-child-action.test.ts index 155c0ac4d..d8a99e865 100644 --- a/packages/workflow-host/src/child/workflow-substrate-factory-child-action.test.ts +++ b/packages/workflow-host/src/child/workflow-substrate-factory-child-action.test.ts @@ -273,7 +273,7 @@ const refuseStep: SidecarChildStepInvoker = () => { function makeDeps( substrate: ReturnType, - packageDir: string | undefined, + packageDir: string, ): Parameters[0] { return { substrate, @@ -299,7 +299,7 @@ function makeDeps( credentialProviders: createCredentialProviderRegistry( builtinCredentialProviders(), ), - ...(packageDir !== undefined ? { closurePackageDir: packageDir } : {}), + closurePackageDir: packageDir, collectDeclaredResources: () => new Set([SHIP_RESOURCE]), collectDeclaredCredentialConsumers: () => new Set(), filterGrantsToDeclaredResources: (parentGrants, declared) => @@ -431,32 +431,6 @@ describe("spawned body action steps", () => { expect(event.terminalStatus).toBe("completed"); expect(await fs.promises.readFile(marker, "utf8")).toBe("shipped"); }); - - test("rejects an action body when the closure directory is missing", async () => { - const substrate = await makeSubstrate("child-action-nodir-"); - const parentRunId = "run-parent-nodir"; - await seedRunGrants(substrate, parentRunId, [grant(SHIP_RESOURCE)]); - const marker = await markerPath(); - const runChild = createSidecarRunChild(makeDeps(substrate, undefined)); - - await expect( - runChild( - { - definition: actionBody(ALLOW_BODY_ID, [SHIP_CAPABILITY]), - definitionRef: REF, - childRunId: "run-body-nodir", - input: { marker, capability: SHIP_CAPABILITY }, - parentRunId, - parentStepId: "spawn", - signal: new AbortController().signal, - depth: 1, - maxChildSpawnDepth: 32, - }, - () => undefined, - ), - ).rejects.toThrow(/deps\.closurePackageDir is missing/); - await expect(fs.promises.access(marker)).rejects.toThrow(); - }); }); // A body action that declares the `api-token` credential and resolves it. @@ -552,7 +526,7 @@ function credentialUseGrant(consumer: string): GrantRule { // The grants of the top-level run, which a spawned body caps on each read. function rootSnapshot(grants: readonly GrantRule[]) { return { - steps: [{ stepId: "root", address: "root", grants, contentHash: "h" }], + steps: [{ stepId: "root", grants, contentHash: "h" }], }; } diff --git a/packages/workflow-host/src/child/workflow-substrate-factory-child-depth.test.ts b/packages/workflow-host/src/child/workflow-substrate-factory-child-depth.test.ts index 7bf906734..6aa17cde9 100644 --- a/packages/workflow-host/src/child/workflow-substrate-factory-child-depth.test.ts +++ b/packages/workflow-host/src/child/workflow-substrate-factory-child-depth.test.ts @@ -271,6 +271,7 @@ describe("createSidecarRunChild spawn-depth ceiling", () => { collectDeclaredCredentialConsumers: () => new Set(), filterGrantsToDeclaredResources: (parentGrants: readonly unknown[]) => parentGrants, + closurePackageDir: "/unused-closure", directors: createDefaultDirectorRegistry(), }); @@ -325,7 +326,7 @@ describe("createSidecarRunChild root grants at depth", () => { function rootSnapshot(grants: readonly GrantRule[]) { return { - steps: [{ stepId: "top", address: "top", grants, contentHash: "h" }], + steps: [{ stepId: "top", grants, contentHash: "h" }], }; } @@ -355,6 +356,7 @@ describe("createSidecarRunChild root grants at depth", () => { collectDeclaredCredentialConsumers: () => new Set(), filterGrantsToDeclaredResources: (parentGrants: readonly unknown[]) => parentGrants, + closurePackageDir: "/unused-closure", directors: createDefaultDirectorRegistry(), }); } @@ -495,13 +497,12 @@ describe("createSidecarRunChild per-rung cap", () => { typeof g.resource === "string" && declared.has(g.resource), ), + closurePackageDir: "/unused-closure", directors: createDefaultDirectorRegistry(), }); const rootGrants = { current: { - steps: [ - { stepId: "top", address: "top", grants: [allow], contentHash: "h" }, - ], + steps: [{ stepId: "top", grants: [allow], contentHash: "h" }], }, }; const handle = await runChild( diff --git a/packages/workflow-host/src/child/workflow-substrate-factory-child-grants.test.ts b/packages/workflow-host/src/child/workflow-substrate-factory-child-grants.test.ts index bd7cf963f..b1bd8bb52 100644 --- a/packages/workflow-host/src/child/workflow-substrate-factory-child-grants.test.ts +++ b/packages/workflow-host/src/child/workflow-substrate-factory-child-grants.test.ts @@ -162,9 +162,8 @@ function childDefinition(id: string): WorkflowDefinition { } // 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. +// credentials snapshot's grants alone, so a decision here reflects only the +// capped grant set the child inherited. const evaluateGrantsAdapter: SidecarRunChildDepsEvaluator = async ({ resource, action, @@ -270,6 +269,7 @@ function makeRunChild( collectDeclaredCredentialConsumers: grantCap.collectDeclaredCredentialConsumers, filterGrantsToDeclaredResources: grantCap.filterGrantsToDeclaredResources, + closurePackageDir: "/unused-closure", directors: createDefaultDirectorRegistry(), }); } @@ -478,7 +478,7 @@ describe("createSidecarRunChild root grants", () => { function rootSnapshot(grants: readonly GrantRule[]) { return { - steps: [{ stepId: "top", address: "top", grants, contentHash: "h" }], + steps: [{ stepId: "top", grants, contentHash: "h" }], }; } diff --git a/packages/workflow-host/src/child/workflow-substrate-factory-step-storage.test.ts b/packages/workflow-host/src/child/workflow-substrate-factory-step-storage.test.ts index 074aec48a..977856884 100644 --- a/packages/workflow-host/src/child/workflow-substrate-factory-step-storage.test.ts +++ b/packages/workflow-host/src/child/workflow-substrate-factory-step-storage.test.ts @@ -150,14 +150,9 @@ function buildDeps(opts: { principal: PRINCIPAL, signer, 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, - materializeStepTools: () => - Promise.resolve({ factories: [], pluginFactories: [] }), + closurePackageDir: "/unused-closure", directors: createDefaultDirectorRegistry(), ...(opts.durableConversation !== undefined ? { durableConversation: opts.durableConversation } @@ -174,7 +169,7 @@ function sourcesRefFor( function requestForRun(runId: string, attempt = 1): 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"], + agent: { toolFactories: [] } as unknown as StepInvokeRequest["agent"], input: null, authzContext: { stepId: STEP_ID, runId, attempt }, signal: new AbortController().signal, @@ -345,7 +340,7 @@ describe("createSidecarStepBuildEnv per-step scratch keying", () => { 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"], + agent: { toolFactories: [] } as unknown as StepInvokeRequest["agent"], input: null, authzContext: { stepId: scopedId, runId: "run-1", attempt: 1 }, signal: new AbortController().signal, @@ -422,7 +417,7 @@ function approvalResumeRequest( ): 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"], + agent: { toolFactories: [] } as unknown as StepInvokeRequest["agent"], input: null, authzContext: { stepId: STEP_ID, runId, attempt: 1 }, resume: { diff --git a/packages/workflow-host/src/child/workflow-substrate-factory-suspendable-child.test.ts b/packages/workflow-host/src/child/workflow-substrate-factory-suspendable-child.test.ts index f8ea16293..0c7f80bef 100644 --- a/packages/workflow-host/src/child/workflow-substrate-factory-suspendable-child.test.ts +++ b/packages/workflow-host/src/child/workflow-substrate-factory-suspendable-child.test.ts @@ -240,6 +240,7 @@ function makeSpawner( collectDeclaredCredentialConsumers: () => new Set(), filterGrantsToDeclaredResources: (parentGrants: readonly unknown[]) => parentGrants, + closurePackageDir: "/unused-closure", directors: createDefaultDirectorRegistry(), }); } diff --git a/packages/workflow-host/src/conversation-state.ts b/packages/workflow-host/src/conversation-state.ts index a345df15a..09f74347a 100644 --- a/packages/workflow-host/src/conversation-state.ts +++ b/packages/workflow-host/src/conversation-state.ts @@ -62,10 +62,9 @@ // // Substrate-merge constraint (load-bearing, design §4 "Substrate-merge // note"). `writeTreePreservingPrefix`'s `merge` callback receives only -// the DIRECT CHILDREN of `preservePrefix`, and the substrate's -// `clearPrefix` step recursively removes the whole `preservePrefix` -// subtree before writing the merge's returned set (paths outside the -// prefix pass through untouched). A WAL blob is two levels below the +// the DIRECT CHILDREN of `preservePrefix`, and the substrate recursively +// removes the whole `preservePrefix` subtree before writing the merge's +// returned set (paths outside the prefix pass through untouched). A WAL blob is two levels below the // state directory, so: // // - WAL append uses `preservePrefix = /wal//`. @@ -76,7 +75,7 @@ // - Checkpoint write + WAL truncate uses `preservePrefix = /`. // The top-level checkpoint files are direct children; the merge // returns ONLY those files and NO `wal/...` paths, so the recursive -// `clearPrefix` at the state directory drops the entire WAL subtree in +// clear at the state directory drops the entire WAL subtree in // the same atomic commit. The truncate needs no nested read: omitting // the WAL paths from the returned set IS the truncate. // @@ -340,9 +339,8 @@ export async function createDurableConversationStore( // `onStateChanged` synchronously, which enqueues a mirror. A single // per-instance tail runs every such op one-at-a-time regardless of entry // point, so two overlapping runs cannot read the same boundary seq and - // emit two WAL entries at it. Modeled on `runRepoOp` in the hub-agent - // session manager: the stored tail swallows rejections so one failed op - // does not poison the chain, while each caller still observes its own op's + // emit two WAL entries at it. The stored tail swallows rejections so one + // failed op does not poison the chain, while each caller still observes its own op's // result (or rejection) through the returned promise. // // This serializes mirror-vs-mirror and mirror-vs-restore only. It does NOT @@ -561,7 +559,7 @@ export async function createDurableConversationStore( * Fold the full conversation into a fresh checkpoint and truncate the * WAL in one atomic commit with the state directory as `preservePrefix`. * The merge returns ONLY the two checkpoint files and NO `wal/...` paths; - * because the substrate's `clearPrefix` recursively removes the whole + * because the substrate recursively removes the whole * state directory before writing the returned set, omitting the WAL * paths IS the truncate. */ diff --git a/packages/workflow-host/src/deploy/agent-key-registration-lifecycle.test.ts b/packages/workflow-host/src/deploy/agent-key-registration-lifecycle.test.ts index 95d6c1f70..5df9d8fec 100644 --- a/packages/workflow-host/src/deploy/agent-key-registration-lifecycle.test.ts +++ b/packages/workflow-host/src/deploy/agent-key-registration-lifecycle.test.ts @@ -80,6 +80,8 @@ function deployHostBindings() { resolveInboundMailPolicy: () => resolvedInboundMailPolicy, materializeWorkflowAssets: () => Promise.resolve(undefined), maxInlineAssetPayloadBytes: 32 * 1024 * 1024, + closureCacheMaxBytes: 1_000_000, + registryMaxTarballBytes: 1_000_000, multistepBinaryPath: "/fake/bin/workflow-child", multistepSubprocessSpawner: (): never => { throw new Error("workflow child spawner was not provided"); @@ -219,29 +221,13 @@ describe("agent signing-key registration lifecycle on the host transport", () => const router = createSidecarDeployRouter({ ...DEFAULT_SIDECAR_CAPACITY, ...deployHostBindings(), - // 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, - deleteAgentDir: async () => undefined, - } as unknown as Parameters< - typeof createSidecarDeployRouter - >[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 + sessions: { deleteAgentDir: async () => undefined }, keyStore: { - recordHubKey: () => undefined, loadOrGenerateKey: async () => ({ keyPair: await generateKeyPair(), isNew: false, }), - } as unknown as Parameters< - typeof createSidecarDeployRouter - >[0]["keyStore"], + }, transport, repoStore, signingKeySeed: keyPair.privateKey, @@ -255,15 +241,11 @@ describe("agent signing-key registration lifecycle on the host transport", () => recursive: true, force: true, }), - removeAgentStateRepository: async () => undefined, unregisterDeployment: () => undefined, reportDeploymentRefTips: async () => ({}), 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 materializes the // pin's closure through this dependency for the child, which reports the @@ -283,10 +265,9 @@ describe("agent signing-key registration lifecycle on the host transport", () => requestId: "deploy-test", agentAddress: AGENT_ADDRESS, generation: 1, - 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"], + config: { + sessionId: "s", + }, workflow: { approvedWireHash, // Source-ref is the only deploy lineage: the frame carries no inline diff --git a/packages/workflow-host/src/deploy/workflow-host-wiring-deploy-failure.test.ts b/packages/workflow-host/src/deploy/workflow-host-wiring-deploy-failure.test.ts index 2fe095667..ae4df39d5 100644 --- a/packages/workflow-host/src/deploy/workflow-host-wiring-deploy-failure.test.ts +++ b/packages/workflow-host/src/deploy/workflow-host-wiring-deploy-failure.test.ts @@ -7,10 +7,6 @@ // 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"; @@ -79,6 +75,8 @@ function deployHostBindings() { resolveInboundMailPolicy: () => resolvedInboundMailPolicy, materializeWorkflowAssets: () => Promise.resolve(undefined), maxInlineAssetPayloadBytes: 32 * 1024 * 1024, + closureCacheMaxBytes: 1_000_000, + registryMaxTarballBytes: 1_000_000, multistepBinaryPath: "/fake/bin/workflow-child", multistepSubprocessSpawner: (): never => { throw new Error("workflow child spawner was not provided"); @@ -103,29 +101,9 @@ function stubKeyStore(): Parameters< // 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< - typeof createSidecarDeployRouter ->[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 @@ -233,74 +211,10 @@ function makeRouterDeps() { } 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({ - ...DEFAULT_SIDECAR_CAPACITY, - ...deployHostBindings(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- an unsupported frame throws before sessions is touched - sessions: {} as Parameters< - typeof createSidecarDeployRouter - >[0]["sessions"], - keyStore: stubKeyStore(), - transport, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- an unsupported frame throws before any repoStore usage - repoStore: {} as Parameters< - typeof createSidecarDeployRouter - >[0]["repoStore"], - signingKeySeed: new Uint8Array(32), - credentialCipher: createNoopCredentialCipher(), - createAgentCrypto: createEd25519Crypto, - assertSourceBuildable: () => undefined, - registerDeployment: ({ runId, agentAddress, generation }) => { - registry.record(runId, { agentAddress, generation }); - }, - removeRunRepository: async () => undefined, - removeAgentStateRepository: async () => undefined, - unregisterDeployment: ({ runId }) => { - registry.unregister(runId); - }, - reportDeploymentRefTips: async () => ({}), - 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", - requestId: "deploy-test", - agentAddress: "agent-unsupported@x.example", - generation: 1, - 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 sessions = { deleteAgentDir: async () => undefined }; const keyStore = stubKeyStore(); const failingSpawner: SubprocessSpawner = () => { @@ -345,7 +259,6 @@ describe("deploy-failure registry leak", () => { registry.record(runId, { agentAddress, generation }); }, removeRunRepository: async () => undefined, - removeAgentStateRepository: async () => undefined, unregisterDeployment: ({ runId }) => { registry.unregister(runId); }, @@ -358,9 +271,6 @@ describe("deploy-failure registry leak", () => { SIDECAR_SIGNING_PUBLIC_KEY: "00".repeat(32), SIDECAR_SIGNING_PRIVATE_KEY: "00".repeat(32), 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(), @@ -369,22 +279,13 @@ describe("deploy-failure registry leak", () => { const frame: AgentDeployFrame = { type: "agent.deploy", requestId: "deploy-test", - // 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. + // Single-step projection: the address carries the canonical + // `run_@` shape. agentAddress: "run_mstep@x.example", generation: 1, - 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 @@ -422,25 +323,16 @@ describe("deploy-failure registry leak", () => { expect(registry.resolve(slug)).toBeNull(); }); - test("single-step spawn failure reverses the recorded hub key but preserves the head repo", async () => { + test("single-step spawn failure preserves the head directory", 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 once the child reports its definition, - // and then fail when the child exits before it readies) 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 + + // A sessions stub whose `deleteAgentDir` is spied: the unwind must NOT call + // it, because the head directory holds the durable identity keypair. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub: 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); }, @@ -451,15 +343,6 @@ describe("deploy-failure registry leak", () => { 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 tmpDir = mkdtempSync(pathJoin(tmpdir(), "single-step-unwind-")); @@ -498,7 +381,6 @@ describe("deploy-failure registry leak", () => { registry.record(runId, { agentAddress, generation }); }, removeRunRepository: async () => undefined, - removeAgentStateRepository: async () => undefined, unregisterDeployment: ({ runId }) => { registry.unregister(runId); }, @@ -511,9 +393,6 @@ describe("deploy-failure registry leak", () => { SIDECAR_SIGNING_PUBLIC_KEY: "00".repeat(32), SIDECAR_SIGNING_PRIVATE_KEY: "00".repeat(32), 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: reportThenExitSpawner(SINGLE_STEP_PROJECTION), applyFrozenWorkflowClosure: stubApplyFrozenWorkflowClosure(), @@ -524,17 +403,9 @@ describe("deploy-failure registry leak", () => { requestId: "deploy-test", agentAddress: "run_single@x.example", generation: 1, - 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: { approvedWireHash: SINGLE_STEP_HASH, // Source-ref is the only deploy lineage: the frame carries no inline @@ -562,14 +433,7 @@ describe("deploy-failure registry leak", () => { /control channel ended before child emitted ready/, ); - // The single-step head initialized its repo and recorded the hub key - // once the child reported its definition, 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). + // A failed spawn keeps the single-step head's directory. expect(removedRepos).toEqual([]); // Registry untouched. expect(registry.resolve("run_single-x-example")).toBeNull(); @@ -607,7 +471,6 @@ describe("deploy-failure registry leak", () => { registry.record(runId, { agentAddress, generation }); }, removeRunRepository: async () => undefined, - removeAgentStateRepository: async () => undefined, unregisterDeployment: ({ runId }) => { registry.unregister(runId); }, @@ -617,8 +480,6 @@ describe("deploy-failure registry leak", () => { multistepDrainRouter: drainRouter, multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir, - SIDECAR_CACHE_MAX_BYTES: "1000000", - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: "1000000", }, multistepSubprocessSpawner: () => { throw new Error("undeploy never spawns"); diff --git a/packages/workflow-host/src/deploy/workflow-host-wiring-undeploy-supervisor.test.ts b/packages/workflow-host/src/deploy/workflow-host-wiring-undeploy-supervisor.test.ts index df52263fd..3124bf213 100644 --- a/packages/workflow-host/src/deploy/workflow-host-wiring-undeploy-supervisor.test.ts +++ b/packages/workflow-host/src/deploy/workflow-host-wiring-undeploy-supervisor.test.ts @@ -104,6 +104,8 @@ function deployHostBindings() { resolveInboundMailPolicy: () => resolvedInboundMailPolicy, materializeWorkflowAssets: () => Promise.resolve(undefined), maxInlineAssetPayloadBytes: 32 * 1024 * 1024, + closureCacheMaxBytes: 1_000_000, + registryMaxTarballBytes: 1_000_000, multistepBinaryPath: "/fake/bin/workflow-child", multistepSubprocessSpawner: (): never => { throw new Error("workflow child spawner was not provided"); @@ -124,10 +126,8 @@ function createSpawnTestRepoStore(tempBase: string): RepoStore { 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. + // Mirror the `getRepoDir` layout so a write lands where a 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)) { @@ -234,33 +234,13 @@ describe("createSidecarDeployRouter multi-step undeploy shuts the supervisor dow const router = createSidecarDeployRouter({ ...DEFAULT_SIDECAR_CAPACITY, ...deployHostBindings(), - // 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, - deleteAgentDir: async () => undefined, - } as unknown as Parameters< - typeof createSidecarDeployRouter - >[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 + sessions: { deleteAgentDir: async () => undefined }, keyStore: { - recordHubKey: () => undefined, loadOrGenerateKey: async () => ({ keyPair: await generateKeyPair(), isNew: false, }), - } as unknown as Parameters< - typeof createSidecarDeployRouter - >[0]["keyStore"], + }, transport, repoStore, signingKeySeed: keyPair.privateKey, @@ -271,7 +251,6 @@ describe("createSidecarDeployRouter multi-step undeploy shuts the supervisor dow /* no-op */ }, removeRunRepository: async () => undefined, - removeAgentStateRepository: async () => undefined, unregisterDeployment: () => { /* no-op */ }, @@ -285,9 +264,6 @@ describe("createSidecarDeployRouter multi-step undeploy shuts the supervisor dow 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 materializes the // pin's closure through this dependency for the child, which reports the @@ -305,15 +281,13 @@ describe("createSidecarDeployRouter multi-step undeploy shuts the supervisor dow const frame: AgentDeployFrame = { type: "agent.deploy", requestId: "deploy-test", - // Single-step projection: the deploy router derives the sole - // step's agent-state repo from `deriveWorkflowRunId(agentAddress)`, which - // requires the canonical `run_@` instance shape. + // Single-step projection: the address carries the canonical + // `run_@` shape. agentAddress: "run_undeploy-supervisor@example.com", generation: 1, - 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"], + config: { + sessionId: "s", + }, workflow: { approvedWireHash: APPROVED_WIRE_HASH, // Source-ref is the only deploy lineage: the frame carries no inline diff --git a/packages/workflow-host/src/deploy/workflow-host-wiring.test.ts b/packages/workflow-host/src/deploy/workflow-host-wiring.test.ts index 8c3b4e217..32a65013a 100644 --- a/packages/workflow-host/src/deploy/workflow-host-wiring.test.ts +++ b/packages/workflow-host/src/deploy/workflow-host-wiring.test.ts @@ -109,6 +109,8 @@ function deployHostBindings() { resolveInboundMailPolicy: () => resolvedInboundMailPolicy, materializeWorkflowAssets: () => Promise.resolve(undefined), maxInlineAssetPayloadBytes: 32 * 1024 * 1024, + closureCacheMaxBytes: 1_000_000, + registryMaxTarballBytes: 1_000_000, multistepBinaryPath: "/fake/bin/workflow-child", multistepSubprocessSpawner: (): never => { throw new Error("workflow child spawner was not provided"); @@ -177,12 +179,7 @@ describe("createSidecarWorkflowSupervisor", () => { workflowRunRef: "refs/heads/main", runId: "wire-test", deploymentMailAddress: "wire-test@example.com", - layoutSteps: () => - Promise.resolve({ - stepIds: ["step1"], - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }), + layoutSteps: () => Promise.resolve(["step1"]), substrateEnv: { DATA_DIR: "/tmp/wire" }, dynamicSpawnEnv: () => ({}), binaryPath: "/fake/bin/workflow-child", @@ -190,7 +187,6 @@ describe("createSidecarWorkflowSupervisor", () => { }); expect(typeof wired.supervisor.spawn).toBe("function"); - expect(wired.getCredentialsSnapshot()).toBeNull(); const result = await wired.supervisor.requestCancel({ runId: "r-wire-1", @@ -217,12 +213,7 @@ describe("createSidecarWorkflowSupervisor", () => { workflowRunRef: "refs/heads/main", runId: "inbound", deploymentMailAddress: "inbound@example.com", - layoutSteps: () => - Promise.resolve({ - stepIds: ["step1"], - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }), + layoutSteps: () => Promise.resolve(["step1"]), substrateEnv: {}, dynamicSpawnEnv: () => ({}), binaryPath: "/fake/bin/workflow-child", @@ -241,7 +232,7 @@ describe("createSidecarWorkflowSupervisor", () => { 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 + // barrier rather than started without its grants. An // unpoisoned run with a per-run grants file on disk resolves normally. const tempBase = await createTempBaseDir("sidecar-poison-barrier-"); const anchorRunId = "dep-poison"; @@ -286,12 +277,7 @@ describe("createSidecarWorkflowSupervisor", () => { workflowRunRef: "refs/heads/main", runId: anchorRunId, deploymentMailAddress: `${anchorRunId}@example.com`, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: ({ runId: dep, stepId }) => - `${dep}-${stepId}@example.com`, - }), + layoutSteps: () => Promise.resolve(["step-1"]), substrateEnv: {}, dynamicSpawnEnv: () => ({}), binaryPath: "/fake/bin/workflow-child", @@ -301,112 +287,21 @@ describe("createSidecarWorkflowSupervisor", () => { isRunPoisoned: (runId) => runId === "run-poisoned", }); - const layout = { - stepIds: ["step-1"], - deriveStepAddress: ({ - runId: dep, - stepId, - }: { - runId: string; - stepId: string; - }) => `${dep}-${stepId}@example.com`, - }; + const stepIds = ["step-1"]; await expect( - wired.onRunStart({ runId: "run-poisoned", anchorRunId, layout }), + wired.onRunStart({ runId: "run-poisoned", anchorRunId, stepIds }), ).rejects.toThrow(/grants write failed/); const snapshot = await wired.onRunStart({ runId: cleanRunId, anchorRunId, - layout, + stepIds, }); 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({ - ...DEFAULT_SIDECAR_CAPACITY, - ...deployHostBindings(), - // 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< - typeof createSidecarDeployRouter - >[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< - typeof createSidecarDeployRouter - >[0]["keyStore"], - transport, - repoStore, - signingKeySeed: keyPair.privateKey, - credentialCipher: createNoopCredentialCipher(), - createAgentCrypto: createEd25519Crypto, - assertSourceBuildable: () => undefined, - registerDeployment: () => undefined, - removeRunRepository: async () => undefined, - removeAgentStateRepository: async () => undefined, - unregisterDeployment: () => undefined, - reportDeploymentRefTips: async () => ({}), - }); - - const STEP_ADDR = "run_abc-step1@example.com"; - const HUB_KEY = "aa".repeat(32); - const result = await router.deploy({ - type: "agent.deploy", - requestId: "deploy-test", - agentAddress: STEP_ADDR, - generation: 1, - 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(heldAddresses(router)).toEqual([]); - }); -}); - // -------------------------------------------------------------------- // Multi-step branch tests // -------------------------------------------------------------------- @@ -521,15 +416,9 @@ function downstreamPayloadType(line: string): string | undefined { /** * 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. + * tempBase. */ -function createSpawnTestRepoStore( - tempBase: string, - beforeWriteTree?: (repoId: RepoId) => Promise, -): RepoStore { +function createSpawnTestRepoStore(tempBase: string): RepoStore { const stub: Partial = { getRepoDir(repoId: RepoId): string { return path.join(tempBase, repoId.kind, repoId.id); @@ -538,13 +427,9 @@ function createSpawnTestRepoStore( 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 once the child reports its definition. - // Mirror the `getRepoDir` layout so the write lands where the - // subsequent `assembleCredentialsSnapshot` working-tree read looks for - // it. + // Mirror the `getRepoDir` layout so a write lands where a working-tree + // read looks for it. async writeTree(_p, repoId, _ref, content) { - await beforeWriteTree?.(repoId); const dir = path.join(tempBase, repoId.kind, repoId.id); for (const [relPath, contents] of Object.entries(content.files)) { const full = path.join(dir, relPath); @@ -568,7 +453,7 @@ function createSpawnTestRepoStore( }); } -type WorkflowProjection = NonNullable; +type WorkflowProjection = AgentDeployFrame["workflow"]; // 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]; @@ -582,13 +467,9 @@ type MultistepDeployArgs = { 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 `deriveWorkflowRunId(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). + * Override the deploy frame's `agentAddress`. Tests that drive a single-step + * projection supply a canonical `run_@` address here; the default + * keeps the historical multi-step address for the multi-step tests. */ agentAddress?: string; /** @@ -716,15 +597,7 @@ function makeMultistepFrame(args: MultistepDeployArgs): AgentDeployFrame { requestId: "deploy-test", agentAddress, generation: 1, - 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"], + config: { sessionId: "s" }, workflow: { sources: args.sources, // Placeholder source-ref pin. The deploy/restore path re-materializes the @@ -943,12 +816,11 @@ describe("createSidecarDeployRouter multi-step branch", () => { typeof createSidecarDeployRouter >[0]["applyFrozenWorkflowClosure"]; /** Runs before the repo store writes a tree, such as a step's grants. */ - beforeWriteTree?: (repoId: RepoId) => Promise; }) { const transport = opts.transport ?? createInMemoryTransport(); const keyPair = await generateKeyPair(); const tempBase = await createTempBaseDir("sidecar-multistep-"); - const repoStore = createSpawnTestRepoStore(tempBase, opts.beforeWriteTree); + 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 @@ -957,13 +829,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { // 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, @@ -987,35 +852,15 @@ describe("createSidecarDeployRouter multi-step branch", () => { const router = createSidecarDeployRouter({ ...DEFAULT_SIDECAR_CAPACITY, ...deployHostBindings(), - // 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, deleteAgentDir: opts.deleteAgentDir ?? (async () => undefined), - } as unknown as Parameters< - typeof createSidecarDeployRouter - >[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< - typeof createSidecarDeployRouter - >[0]["keyStore"], + }, transport, repoStore, signingKeySeed: keyPair.privateKey, @@ -1026,11 +871,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { removeRunRepository: opts.removeRunRepository ?? removeRunDirectory(mergedSubstrateEnv["SIDECAR_DATA_DIR"] ?? tempBase), - removeAgentStateRepository: (id) => - fs.rm(repoStore.getRepoDir({ kind: "agent-state", id }), { - recursive: true, - force: true, - }), unregisterDeployment: opts.unregisterDeployment ?? (() => { @@ -2840,7 +2680,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { generation: 1, definitionId: "wf-missing-step", sources: { "step-1": [makeInferenceSource("step-1")] }, - hubPublicKey: "hub-pk", approvedWireHash: "a".repeat(64), lineage: "source-ref", sourceRef: { @@ -2909,7 +2748,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { agentAddress: head, definitionId: "wf-srcref", sources: { "step-1": [makeInferenceSource("step-1")] }, - hubPublicKey: "hub-pk", lineage: "source-ref", }), "utf8", @@ -3167,27 +3005,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { expect(heldAddresses(router)).toEqual([]); }); - test("refuses to provision a step over an address it holds", async () => { - const dataDir = await createTempBaseDir("sidecar-provision-held-"); - const head = "run_provision_held@example.com"; - const spawner = makeReadyDrivingSpawner(9670); - const { router } = await buildMultistepFixture({ - spawner: spawner.spawner, - multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - }); - const deployed = router.deploy(singleStepFrame(head, "wf-provision-held")); - await spawner.driveReadyFor(0); - await deployed; - - await expect( - router.deploy({ - ...singleStepFrame(head, "wf-provision-held"), - provisionStep: true, - }), - ).rejects.toThrow(/a step cannot be provisioned over it/); - expect(heldAddresses(router)).toEqual([head]); - }); - test("an undeploy reclaims a step scratch a tool left without write permission", async () => { const dataDir = await createTempBaseDir("sidecar-undeploy-read-only-"); const head = "run_undeploy_read_only@example.com"; @@ -3234,39 +3051,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { } }); - test("an undeploy removes a single-step deployment's grants repository", async () => { - const head = "run_undeploy_grants@example.com"; - const spawner = makeReadyDrivingSpawner(9680); - const { router, tempBase } = await buildMultistepFixture({ - spawner: spawner.spawner, - }); - const deployPromise = router.deploy(singleStepFrame(head, "wf-grants")); - await spawner.driveReadyFor(0); - await deployPromise; - const grants = path.join(tempBase, "agent-state", "run_undeploy_grants"); - const exists = () => - fs.access(grants).then( - () => true, - () => false, - ); - expect(await exists()).toBe(true); - const undeploy = router.undeploy; - if (undeploy === undefined) { - throw new Error("router.undeploy is undefined"); - } - - await undeploy({ - type: "agent.undeploy", - requestId: "undeploy-grants", - agentAddress: head, - generation: 1, - reason: "test", - }); - - expect(await exists()).toBe(false); - expect(heldAddresses(router)).toEqual([]); - }); - 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"; @@ -3302,7 +3086,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { generation: 1, definitionId: "wf-srcref", sources: { "step-1": [makeInferenceSource("step-1")] }, - hubPublicKey: "hub-pk", approvedWireHash: registerProjection( WorkflowProjectionDefinition.assert( projectLiveToInert(closureDefinition), @@ -3365,8 +3148,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { transport: freshTransport, multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir, - SIDECAR_CACHE_MAX_BYTES: "1000000", - SIDECAR_REGISTRY_MAX_TARBALL_BYTES: "1000000", }, applyFrozenWorkflowClosure: applyStub, }); @@ -3974,7 +3755,7 @@ describe("createSidecarDeployRouter multi-step branch", () => { const agentDir = path.join(dataDir, "agent-directory"); const spawner = makeReadyDrivingSpawner(15410); let stopped = false; - const { router, tempBase, transport } = await buildMultistepFixture({ + const { router, transport } = await buildMultistepFixture({ spawner: (args) => { const child = spawner.spawner(args); return { @@ -4002,7 +3783,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { path.join(dataDir, "workflow-definition-sources", runId), path.join(dataDir, "workflow-definition-source-gits", runId), conversationStateRoot(dataDir, runId), - path.join(tempBase, "agent-state", deriveWorkflowRunId(head)), recordDir, ]; for (const dir of stateDirs) { @@ -4388,49 +4168,29 @@ describe("createSidecarDeployRouter multi-step branch", () => { }, ); - test("a deploy writes its run record only once its child reported and its grants are on disk", async () => { - const dataDir = await createTempBaseDir("sidecar-record-after-grants-"); - const head = "run_record_after_grants@example.com"; + test("a deploy writes its run record only once its child reported", async () => { + const dataDir = await createTempBaseDir("sidecar-record-after-report-"); + const head = "run_record_after_report@example.com"; const spawner = makeReadyDrivingSpawner(15800); - const recordAtGrantsWrite: boolean[] = []; - const { router, tempBase } = await buildMultistepFixture({ + const { router } = await buildMultistepFixture({ spawner: spawner.spawner, multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, - beforeWriteTree: async () => { - recordAtGrantsWrite.push( - await recordExists(dataDir, deriveWorkflowRunRepoId(head)), - ); - }, }); - const deploying = router.deploy( - singleStepFrame(head, "wf-record-after-grants"), + singleStepFrame(head, "wf-record-after-report"), ); await spawner.awaitSpawnCount(1); // Until the child reports its definition, a restart finds nothing to - // re-drive: the record would otherwise come back without its grants. + // re-drive. expect(await recordExists(dataDir, deriveWorkflowRunRepoId(head))).toBe( false, ); await spawner.driveReadyFor(0); await deploying; - // The grants went to disk before the record existed. - expect(recordAtGrantsWrite).toEqual([false]); expect(await recordExists(dataDir, deriveWorkflowRunRepoId(head))).toBe( true, ); - const grants = await fs.readFile( - path.join( - tempBase, - "agent-state", - deriveWorkflowRunId(head), - "state", - "grants.json", - ), - "utf8", - ); - expect(JSON.parse(grants)).toEqual({ grants: [] }); }); test("restore holds a deployment its record marks stopped or tearing down without spawning it", async () => { @@ -5224,7 +4984,6 @@ describe("createSidecarDeployRouter multi-step branch", () => { : {}), definitionId: "wf-mismatch", sources: { "step-1": [makeInferenceSource("step-1")] }, - hubPublicKey: "hub-pk", approvedWireHash: "a".repeat(64), lineage: "source-ref", sourceRef: { @@ -6511,8 +6270,7 @@ 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`. + // the sink's working-tree read looks. function createReadStubRepoStore(tempBase: string): RepoStore { const stub: Partial = { getRepoDir(repoId: RepoId): string { @@ -6536,13 +6294,6 @@ describe("assembleRunCredentialsSnapshot", () => { 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, @@ -6553,22 +6304,7 @@ describe("assembleRunCredentialsSnapshot", () => { 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 () => { + test("a run with a per-run grants file reads those grants", async () => { const tempBase = await createTempBaseDir("sidecar-run-grants-"); const repoStore = createReadStubRepoStore(tempBase); @@ -6576,29 +6312,20 @@ describe("assembleRunCredentialsSnapshot", () => { { 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, }); if (snapshot === null) throw new Error("expected a snapshot"); - // Every step carries the single flat per-run grant set, keyed on the - // deploy-time per-step address, with a shared content hash. + // Every step carries the single flat per-run grant set, 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"); @@ -6611,14 +6338,8 @@ describe("assembleRunCredentialsSnapshot", () => { 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; the run must have none, so the - // barrier fails it closed and a new child starts with no snapshot. - await writeDeployTimeStepGrants( - tempBase, - "step-1", - JSON.stringify({ grants: [{ id: "deploy-grant-1" }] }), - ); + // No per-run file: the run has no grants, so the barrier fails it closed + // and a new child starts with no snapshot. expect( await assembleRunCredentialsSnapshot({ @@ -6626,7 +6347,6 @@ describe("assembleRunCredentialsSnapshot", () => { anchorRunId, runId, stepOrder, - deriveStepAddress, }), ).toBeNull(); }); @@ -6635,14 +6355,8 @@ describe("assembleRunCredentialsSnapshot", () => { 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. + // The file exists but is not valid JSON. await writeRunGrantsFile(tempBase, "{ not valid json"); - await writeDeployTimeStepGrants( - tempBase, - "step-1", - JSON.stringify({ grants: [{ id: "deploy-grant" }] }), - ); await expect( assembleRunCredentialsSnapshot({ @@ -6650,7 +6364,6 @@ describe("assembleRunCredentialsSnapshot", () => { anchorRunId, runId, stepOrder, - deriveStepAddress, }), ).rejects.toThrow(/is not valid JSON/); @@ -6664,20 +6377,17 @@ describe("assembleRunCredentialsSnapshot", () => { 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 () => { + test("a re-dispatched run re-reads the durable per-run grants", 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. + // resolution must still read `runs//grants.json`. const tempBase = await createTempBaseDir("sidecar-run-grants-"); const repoStore = createReadStubRepoStore(tempBase); @@ -6685,21 +6395,12 @@ describe("assembleRunCredentialsSnapshot", () => { { 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 @@ -6709,7 +6410,6 @@ describe("assembleRunCredentialsSnapshot", () => { anchorRunId, runId, stepOrder, - deriveStepAddress, }); if (first === null || second === null) { throw new Error("expected both snapshots"); diff --git a/packages/workflow-host/src/deploy/workflow-host-wiring.ts b/packages/workflow-host/src/deploy/workflow-host-wiring.ts index 4b8470d94..0637dcbbc 100644 --- a/packages/workflow-host/src/deploy/workflow-host-wiring.ts +++ b/packages/workflow-host/src/deploy/workflow-host-wiring.ts @@ -10,7 +10,7 @@ import { chmod, lstat, readdir, rm, stat } from "node:fs/promises"; import { join as pathJoin } from "node:path"; -import { derivePublicKeyBytes, signEd25519 } from "@intx/crypto"; +import { signEd25519 } from "@intx/crypto"; import { getLogger } from "@intx/log"; import { syncRemovedPathDurable } from "@intx/storage-isogit/node"; import type { HubTransport } from "@intx/mail-memory"; @@ -18,10 +18,8 @@ import type { Principal, RepoId, RepoStore, - WorkflowRunSupervisorPrincipal, } from "@intx/hub-sessions/substrate"; import { hexEncode } from "@intx/types/hex"; -import { parseRunAddress } from "@intx/types/agent-address"; import { deriveWorkflowRunId } from "@intx/types/workflow-run-id"; import { workflowSourceAssetMountPath } from "@intx/types/workflow-sources"; import type { CredentialCipher } from "@intx/types/credential-cipher"; @@ -64,13 +62,8 @@ import { import { conversationStateRoot } from "../conversation-state-root"; import { hashGrants, - STEP_GRANTS_PATH, - STEP_GRANTS_REF, type CredentialsSnapshot, type CredentialsSnapshotStep, - type DeriveStepAddress, - type DeriveStepRepoId, - type StepLayout, } from "../supervisor/credentials"; import { createWorkflowSupervisor, @@ -268,183 +261,42 @@ export async function resolveDeploymentAssetMounts( } /** - * Read a substrate-config byte cap (`SIDECAR_CACHE_MAX_BYTES` / - * `SIDECAR_REGISTRY_MAX_TARBALL_BYTES`) from the multi-step substrate env and - * parse it to a positive finite number. The boot edge resolves these once and - * threads them through the substrate env; a source-ref deploy needs them to - * size the tarball cache and per-fetch cap when it materializes the frozen - * workflow closure. A missing or non-numeric value is a boot-edge wiring bug, - * so it fails loud rather than defaulting. - */ -function requireSubstrateByteCap( - env: Record, - key: string, -): number { - const raw = env[key]; - if (raw === undefined) { - throw new Error( - `sidecar deploy router: ${key} must be present in the multi-step substrate env to materialize a frozen workflow closure`, - ); - } - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error( - `sidecar deploy router: ${key} must be a positive finite number, got ${JSON.stringify(raw)}`, - ); - } - return parsed; -} - -/** - * Hub principal the deploy router presents when it writes a step's - * grants into the agent-state repo on the sidecar's substrate. The - * agent-state kind handler gates `writeTree` as hub-only; the deploy - * router is the local stand-in for the hub on the sidecar's disk, so it - * claims the hub principal for this single bookkeeping write. The child - * reads the same repo via the working-tree path (`getRepoDir`), which is - * not authorize-gated. + * Hub principal the deploy router presents when it writes a run's grants. The + * deploy router is the local stand-in for the hub on the sidecar's disk, so it + * claims the hub principal for this single bookkeeping write. */ const GRANTS_WRITE_PRINCIPAL: Principal = { kind: "hub" }; -/** - * Per-deploy address/repo strategy. The single-step launched-agent - * deploy and the derived multi-step deploy disagree on how the per-step - * mail address and agent-state repo id are computed; both - * `deriveStepAddress` (consumed by the supervisor's credentialsSnapshot - * assembly for the step's mail address) and `deriveStepRepoId` (consumed - * by the same assembly to locate each step's grants) must agree on the - * choice, so they are minted together. - */ -type StepStrategy = { - deriveStepAddress: DeriveStepAddress; - deriveStepRepoId: DeriveStepRepoId; -}; +/** The workflow-run repo pins each run's subtree under this moving ref. */ +const RUN_GRANTS_REF = "refs/heads/main"; /** - * Decide the per-step address/repo strategy from the projection's step - * count. - * - * `stepOrder.length === 1` is the single-agent deploy: the sole - * step keeps the deploy's own mail address, so its grants live in the - * agent-state repo keyed by `deriveWorkflowRunId(address)`. The spawned - * child reads the agent's grants from where the run's identity already - * lives, and the deploy frame's run address is preserved (the - * workflow-run repo stays keyed by `deriveWorkflowRunRepoId(address)`). - * - * Any other step count is a derived multi-step deploy: each step gets a - * derived `-` mail address (via the router's - * `multistepDeriveStepAddress`) and a derived agent-state repo under the - * default `-` convention. - * - * NOTE: the supervisor's `deriveStepAddress` feeds the credentials - * snapshot's per-step mail `address` and the grants-repo derivation. It - * does NOT feed the child's on-disk tool read (`stepDeployTreeDir` in - * `apps/sidecar/src/step-tool-materialization.ts`), which re-derives the - * step address from the deployment mailbox address independently. The - * deploy tree must therefore be staged at the address `stepDeployTreeDir` - * computes, regardless of this strategy's address choice. - */ -function createStepStrategy(args: { - legacyAddress: string; - stepOrder: readonly string[]; - multistepDeriveStepAddress: DeriveStepAddress; -}): StepStrategy { - if (args.stepOrder.length === 1) { - return { - deriveStepAddress: () => args.legacyAddress, - // `deriveWorkflowRunId` is deferred into the closure rather than - // computed eagerly: the supervisor only invokes `deriveStepRepoId` - // while assembling the credentialsSnapshot inside `spawn()`, so a - // malformed address surfaces at the same point the rest of the - // spawn path would fault rather than ahead of the deploy router's - // other boundary checks. - deriveStepRepoId: () => ({ - kind: "agent-state", - id: deriveWorkflowRunId(args.legacyAddress), - }), - }; - } - return { - deriveStepAddress: args.multistepDeriveStepAddress, - deriveStepRepoId: ({ runId, stepId }) => ({ - kind: "agent-state", - id: `${runId}-${stepId}`, - }), - }; -} - -/** - * Write a grant set into the sidecar's substrate as the canonical - * `{ grants: WireGrantRule[] }` envelope -- the shape - * `assembleCredentialsSnapshot` validates (`{ grants: unknown[] }`) and + * Write a run's grant set into the deployment's `workflow-run` repo at + * `runs//grants.json`, sibling to that run's `runs//events/` + * subtree, as the canonical `{ grants: WireGrantRule[] }` envelope -- the shape * the child's grants resolver parses as `GrantRule[]`. - * - * Two destinations share this write machinery, selected by `runId`: - * - * - `runId` absent (deploy time): write every step's grants into its - * own `agent-state` repo at `STEP_GRANTS_PATH`, keyed by the same - * `deriveStepRepoId` the supervisor reads with, so read and write - * address the same repo. `stepOrder` here is the deployment's whole - * flat step-id namespace, not the bare top-level order, because the - * supervisor assembles a snapshot entry for every loop-body step too. - * The write is on the spawn critical path: a failure rejects the - * deploy (the caller's `finally` unwinds the partial state) rather - * than spawning a child that would fail every authorize closed - * against an empty grant set. - * - * - `runId` present (per-run delivery): write a single - * `runs//grants.json` into the deployment's `workflow-run` - * repo, sibling to that run's `runs//events/` subtree. The - * `stepOrder` / `deriveStepRepoId` fields are unused in this mode -- - * the destination is the one workflow-run repo keyed by - * `runId`, not a per-step fan-out. - * - * Both destinations use the same hub principal and `refs/heads/main` - * ref: the agent-state kind handler gates `writeTree` as hub-only, and - * the workflow-run repo pins its run subtree under the same moving ref. */ -async function writeStepGrants(args: { +async function writeRunGrants(args: { repoStore: RepoStore; anchorRunId: string; - stepOrder: readonly string[]; - deriveStepRepoId: DeriveStepRepoId; - grants: readonly unknown[] | undefined; - runId?: string; + runId: string; + grants: readonly unknown[]; }): Promise { - // The deploy frame's validated HarnessConfig always carries a `grants` - // array (possibly empty); an absent array means "no grants", which - // serializes to the same fail-closed empty file the snapshot expects. - // Coerce here so the on-disk envelope is always a valid `{ grants: [] }` - // rather than `{}` (which the snapshot's validator rejects). - const grants = args.grants ?? []; - const serialized = JSON.stringify({ grants }, null, 2); - if (args.runId !== undefined) { - await args.repoStore.writeTree( - GRANTS_WRITE_PRINCIPAL, - { kind: "workflow-run", id: args.anchorRunId }, - STEP_GRANTS_REF, - { - files: { [runGrantsPath(args.runId)]: serialized }, - message: `Write run grants for ${args.runId}`, - }, - ); - return; - } - for (const stepId of args.stepOrder) { - const repoId = args.deriveStepRepoId({ - runId: args.anchorRunId, - stepId, - }); - await args.repoStore.writeTree( - GRANTS_WRITE_PRINCIPAL, - repoId, - STEP_GRANTS_REF, - { - files: { [STEP_GRANTS_PATH]: serialized }, - message: `Write step grants for ${stepId}`, + await args.repoStore.writeTree( + GRANTS_WRITE_PRINCIPAL, + { kind: "workflow-run", id: args.anchorRunId }, + RUN_GRANTS_REF, + { + files: { + [runGrantsPath(args.runId)]: JSON.stringify( + { grants: args.grants }, + null, + 2, + ), }, - ); - } + message: `Write run grants for ${args.runId}`, + }, + ); } export type AssembleRunCredentialsSnapshotOpts = { @@ -460,8 +312,6 @@ export type AssembleRunCredentialsSnapshotOpts = { * uniformly across them. */ stepOrder: readonly string[]; - /** Per-step mail-address derivation. */ - deriveStepAddress: DeriveStepAddress; }; /** @@ -475,11 +325,10 @@ export type AssembleRunCredentialsSnapshotOpts = { * `run.grants` frame the sidecar writes, and a spawned child inherits its * parent's grants directly at spawn without reaching this barrier. The * per-run file IS the run's snapshot: the run's single flat grant set is - * applied uniformly across every step, keyed on each step's address. + * applied uniformly across every step. * - * A missing file resolves to null: the run has no grants yet. It is not - * an internal run inheriting deploy-time grants, so the barrier fails the - * run closed and a new child starts with no snapshot. A file that exists + * A missing file resolves to null: the run has no grants yet, so the barrier + * fails the run closed and a new child starts with no snapshot. A file that exists * but is malformed throws (via `readRunGrants`): the file's presence * implies a grants frame was delivered, so a structural failure is a * boundary bug, not a default. @@ -496,10 +345,6 @@ export async function assembleRunCredentialsSnapshot( const contentHash = await hashGrants(runGrants); const steps: CredentialsSnapshotStep[] = opts.stepOrder.map((stepId) => ({ stepId, - address: opts.deriveStepAddress({ - runId: opts.anchorRunId, - stepId, - }), grants: runGrants, contentHash, })); @@ -529,14 +374,14 @@ export type CreateSidecarWorkflowSupervisorOpts = { /** Deployment's mail address. */ deploymentMailAddress: string; /** - * Derive the deployment's step layout from the definition its first + * Derive the deployment's step ids from the definition its first * child verified (the supervisor's `layoutSteps` binding). The * `onRunStart` grants sink assembles each per-run credentialsSnapshot - * from the same layout. + * over the same ids. */ layoutSteps: ( definition: WorkflowProjectionDefinition, - ) => Promise; + ) => Promise; /** Substrate-config keys propagated to the child via spawn-time env. */ substrateEnv: Record; /** @@ -625,8 +470,6 @@ export type SidecarWorkflowSupervisor = { * `mail.inbound.ack` only on resolution (resolve = ack, reject = withhold). */ routeInbound(message: Uint8Array): Promise; - /** Snapshot accessor that proxies the supervisor's credentials view. */ - getCredentialsSnapshot(): CredentialsSnapshot | null; /** * The per-run grants source the supervisor reads before firing a run's * trigger and for each new child's `authority.set` frame. Rejects for a @@ -638,7 +481,7 @@ export type SidecarWorkflowSupervisor = { onRunStart(args: { runId: string; anchorRunId: string; - layout: StepLayout; + stepIds: readonly string[]; }): Promise; /** * Refuse further outbound mail and wait for the sends already started. The @@ -689,9 +532,7 @@ const WORKFLOW_BODY_SOURCES_MAX_BYTES = 96 * 1024; * references no bodies, so the record omits the field. */ function buildBodySourcesMap( - referenced: NonNullable< - AgentDeployFrame["workflow"] - >["referencedDefinitions"], + referenced: AgentDeployFrame["workflow"]["referencedDefinitions"], ): WorkflowRunRecord["bodySources"] { if (referenced === undefined || referenced.length === 0) { return undefined; @@ -718,9 +559,8 @@ function buildBodySourcesMap( * other invariants this function owns. * - `definition.stepOrder` is non-empty. The wire shape admits * `[]`; a zero-step workflow has no semantics here. - * - Every `stepOrder` entry matches `STEP_ID_PATTERN` so per-step - * mail-address derivation never needs escaping at the substrate - * boundary. + * - Every `stepOrder` entry matches `STEP_ID_PATTERN`, so it is safe as + * a path segment in the step's durable storage. * - Every `stepOrder` entry has a corresponding `steps[id]` entry. * The wire shape lets `steps[id]` be `unknown` and lets the * entry be absent; presence is required so the workflow-process @@ -796,18 +636,6 @@ export function validateWorkflowProjection(projection: { } } -/** - * Derive the supervisor's principal public key from the sidecar's - * Ed25519 signing seed. The supervisor signs every workflow-run event - * with this key; the multi-step branch surfaces it to the link so the - * hub records the verifying key for the deployment's signed events. - */ -async function derivePrincipalPublicKeyHex( - signingKeySeed: Uint8Array, -): Promise { - return hexEncode(await derivePublicKeyBytes(signingKeySeed)); -} - /** Values the link folds into the outbound deploy ack. */ type DeployRouterResult = { publicKey: string; @@ -877,7 +705,6 @@ export function createSidecarDeployRouter< TCache extends object, >(deps: { sessions: { - initRepo(address: string): Promise; /** Remove the directory durably before resolving; reject sync failures. */ deleteAgentDir(address: string): Promise; }; @@ -885,8 +712,6 @@ export function createSidecarDeployRouter< loadOrGenerateKey( address: string, ): Promise<{ keyPair: KeyPair; isNew: boolean }>; - recordHubKey(address: string, hexHubPublicKey: string): void; - forgetAgent(address: string): void; }; transport: HubTransport; repoStore: RepoStore; @@ -992,10 +817,9 @@ export function createSidecarDeployRouter< * each event to the hub timeline keyed to the right session. The * `InferenceEvent` itself * is sessionless; the session id rides alongside it, sourced from the - * deploy frame's `HarnessConfig.sessionId` per deployment. It is - * optional because a deploy frame need not carry a session id (a - * headless deployment with no hub-side session); the sink decides what - * an absent session id means. Defaults to a no-op; production wiring + * deploy frame's `config.sessionId` per deployment. It is + * optional because a run record need not carry a session id; the sink + * decides what an absent session id means. Defaults to a no-op; production wiring * supplies the event publisher. */ publishWorkflowInferenceEvent?: ( @@ -1022,17 +846,9 @@ export function createSidecarDeployRouter< kind: SignalKind; approvalSnapshot?: ApprovalSnapshot; }) => void; - /** - * Optional override for the multi-step branch's per-step mail-address - * derivation. Defaults to `${runId}-${stepId}@` - * derived from the frame's run address. Tests inject a deterministic - * factory. - */ - multistepDeriveStepAddress?: DeriveStepAddress; /** * Per-deployment-address mail handler registry the hub-link's - * `mail.inbound` path consults before falling back to the legacy - * session-routed delivery. The multi-step branch registers + * `mail.inbound` path consults. The multi-step branch registers * `wired.routeInbound` against the deployment's mail address once * `supervisor.spawn` succeeds so inbound mail aimed at the * deployment address flows into the supervisor's mail-bus @@ -1206,6 +1022,10 @@ export function createSidecarDeployRouter< readonly packageDir: string; readonly deployDir: string; }>; + /** On-disk size cap of the tarball cache closure applies share. */ + closureCacheMaxBytes: number; + /** Size cap of one registry tarball a closure apply downloads. */ + registryMaxTarballBytes: number; /** Registry table the closure apply selects optional dependencies against. */ readRegistries: () => TRegistries; /** Platform pair the closure apply selects optional dependencies with. */ @@ -1252,12 +1072,6 @@ export function createSidecarDeployRouter< * tree removal before resolving. */ removeRunRepository: (runId: string) => Promise; - /** - * Remove an agent-state repository from the substrate. Undeploy removes the - * one a single-step deployment keeps its grants in. Resolve only after the - * directory removal is durable; propagate sync failures. - */ - removeAgentStateRepository: (id: string) => Promise; /** * Send the Hub a report of a deployment that stopped though the Hub did not * stop it, with the tips of its history branches. Tests that do not @@ -1268,9 +1082,8 @@ export function createSidecarDeployRouter< ) => void; }): SidecarDeployRouter { // Validate the signing seed at construction so a malformed key fails - // sidecar boot rather than the first multi-step deploy, where the - // public key is derived from it (`derivePrincipalPublicKeyHex`). The - // seed also signs every workflow-run event via the supervisor. + // sidecar boot rather than the first deploy. The seed signs every + // workflow-run event via the supervisor. if (deps.signingKeySeed.length !== 32) { throw new Error( `sidecar deploy router: Ed25519 signing seed must be 32 bytes, got ${deps.signingKeySeed.length}`, @@ -1323,9 +1136,6 @@ export function createSidecarDeployRouter< const applyClosure = deps.applyFrozenWorkflowClosure; const multistepSpawner = deps.multistepSubprocessSpawner; const { maxActiveDeployments, maxRetainedDeployments } = deps; - const multistepDeriveStepAddress: DeriveStepAddress = - deps.multistepDeriveStepAddress ?? - (({ runId, stepId }) => `${runId}-${stepId}`); // Every incarnation this sidecar holds, by deployment address: what the // link reports to the Hub and checks every Hub frame against. An entry @@ -1642,7 +1452,7 @@ export function createSidecarDeployRouter< agentAddress: string; /** The allocation generation of the incarnation being stood up. */ generation: number; - sources: NonNullable["sources"]; + sources: AgentDeployFrame["workflow"]["sources"]; /** * Per spawned-body inference-source pins, keyed by the body's definition id * (the flat set of onTrigger sections and childWorkflow children). Persisted @@ -1671,13 +1481,6 @@ export function createSidecarDeployRouter< approvedWireHash: string | undefined; /** Correlates the child's inference events to the deploy's session. */ sessionId: string | undefined; - /** - * Hub public key recorded at the head for deploy-pack verification and - * inbound hub-frame verification. A single-step deployment (whose head - * IS the agent identity) requires it; a genuine multi-step deployment - * derives per-step addresses and records no head key. - */ - hubPublicKey: string | undefined; /** * Sidecar-local directory of the materialized workflow-definition closure, * set after the frozen closure is applied. The spawn core threads it into @@ -1694,34 +1497,17 @@ export function createSidecarDeployRouter< * registry token is resolved from env at apply time); its `closure` is * frozen versions + SRIs. */ - sourceRef: NonNullable["sourceRef"]; + sourceRef: AgentDeployFrame["workflow"]["sourceRef"]; /** - * What only a deploy persists, once the child has reported its + * The sidecar data dir the run record roots under. Only a deploy sets it: + * the spawn core writes the record once the child has reported its * definition and before the supervisor reads the grants for the child's - * first `authority.set`: the operator-approved step grants, then the run - * record. A restore carries none; it re-drives the record its deploy - * wrote, together with that deploy's grants. Plain data, not a callback - * over the deploy frame: the supervisor keeps this spec for the - * deployment's life, and the frame's source asset packs must not live - * that long. + * first `authority.set`. A restore re-drives the record its deploy wrote. + * Plain data, not a callback over the deploy frame: the supervisor keeps + * this spec for the deployment's life, and the frame's source asset packs + * must not live that long. */ - deployPersistence?: DeployPersistence; - } - - interface DeployPersistence { - readonly grants: AgentDeployFrame["config"]["grants"]; - /** The sidecar data dir the run record roots under. */ - readonly dataDir: string; - } - - /** - * The definition a deployment's first child verified, and the step - * strategy derived from it. Everything the spawn core does by step count - * or by the authored policy reads it. - */ - interface EstablishedDefinition { - definition: WorkflowProjectionDefinition; - strategy: StepStrategy; + recordDataDir?: string; } /** @@ -1733,7 +1519,7 @@ export function createSidecarDeployRouter< * included. */ function assertSourcesBuildable( - sources: NonNullable["sources"], + sources: AgentDeployFrame["workflow"]["sources"], bodySources: WorkflowRunRecord["bodySources"], ): void { for (const table of [sources, ...Object.values(bodySources ?? {})]) { @@ -1772,10 +1558,6 @@ export function createSidecarDeployRouter< ? { credentials: spec.credentials } : {}), ...(spec.sessionId !== undefined ? { sessionId: spec.sessionId } : {}), - // Only a single-step deployment records the hub key at its head. - ...(spec.hubPublicKey !== undefined && definition.stepOrder.length === 1 - ? { hubPublicKey: spec.hubPublicKey } - : {}), lineage: "source-ref", // Feeds the restored child's DEFINITION_HASH so it re-verifies the // evaluated closure against the hub-approved pin, and the source-ref pin a @@ -1793,10 +1575,7 @@ export function createSidecarDeployRouter< function closureCache(dataDir: string): TCache { sharedClosureCache ??= deps.createWorkflowCache({ rootDir: pathJoin(dataDir, "workflow-definition-closure-cache"), - maxBytes: requireSubstrateByteCap( - multistepSubstrateEnv, - "SIDECAR_CACHE_MAX_BYTES", - ), + maxBytes: deps.closureCacheMaxBytes, }); return sharedClosureCache; } @@ -1806,7 +1585,7 @@ export function createSidecarDeployRouter< * instance dir and return the applied result. Owns the plumbing both the * deploy path and the boot-time restore path share: the deterministic * instance dir under `/workflow-definition-closures/`, - * the content-addressed cache root, the two substrate byte caps, and the + * the content-addressed cache root, the two byte caps, and the * registry table. Both callers take `packageDir`, which the workflow child * evaluates; restore also re-carries the pin on its spec. * @@ -1847,10 +1626,7 @@ export function createSidecarDeployRouter< closure: pin.closure, instanceDir, cache: closureCache(dataDir), - registryMaxTarballBytes: requireSubstrateByteCap( - multistepSubstrateEnv, - "SIDECAR_REGISTRY_MAX_TARBALL_BYTES", - ), + registryMaxTarballBytes: deps.registryMaxTarballBytes, registries: deps.readRegistries(), host: deps.resolveHostPlatform(), assetRoot, @@ -1863,14 +1639,13 @@ export function createSidecarDeployRouter< * The single owner of the workflow-deployment spawn sequence: construct * the supervisor, register the agent's outbound key, spawn the * workflow-process child (whose reported definition drives the step - * layout, the step grants, the run record and the single-step head repo + - * hub key), then register the live deployment (supervisor, mail/signal/drain + * layout and the run record), then register the live deployment (supervisor, mail/signal/drain * routers, address mapping). Its `try/finally` unwinds every piece of * partial state if any step throws, so a failed spawn leaks nothing. Both * the live deploy path and the boot-time restore path route through here * so the two can never diverge on how a deployment is stood up. Callers * materialize the source closure before calling; a deploy supplies - * `deployPersistence` for the grants and the run record only it writes. + * `recordDataDir` for the run record only it writes. */ async function spawnWorkflowRun( spec: WorkflowDeploySpec, @@ -1910,11 +1685,10 @@ export function createSidecarDeployRouter< let supervisorRegistered = false; let routersRegistered = false; let agentTransportRegistered = false; - let hubKeyRecorded = false; let deploymentRegistered = false; // Set by `layoutSteps` while the supervisor starts the child. A holder // rather than a `let`, since the assignment happens in that callback. - const established: { current?: EstablishedDefinition } = {}; + const established: { definition?: WorkflowProjectionDefinition } = {}; try { // The child's `DEFINITION_HASH` is the HUB-APPROVED wire hash the deploy // frame carried (`spec.approvedWireHash`) -- the hub is the authority, so @@ -1974,9 +1748,8 @@ export function createSidecarDeployRouter< // RunIds whose `run.grants` write was attempted and failed. The grants // handler below records a runId here on a write failure; the grants // barrier reads it through `isRunPoisoned` and fails the run rather than - // starting it under the empty deploy-time grant set. Scoped to this - // deployment's supervisor; a run that never had a `run.grants` frame is - // never added, so internal runs inherit the deployment's grants normally. + // starting it without its grants. Scoped to this deployment's supervisor; + // a run that never had a `run.grants` frame is never added. const poisonedRunIds = new Set(); const wired = createSidecarWorkflowSupervisor({ @@ -2002,92 +1775,32 @@ export function createSidecarDeployRouter< // `sources` entry); the frame carries no definition to cover. validateWorkflowProjection({ definition, sources: spec.sources }); - // Single-step launched-agent deploy vs. derived multi-step deploy. - // A one-step deployment keeps the deployment's own (legacy) mail - // address and its grants in the legacy agent-state repo keyed by - // the legacy instance id. A multi-step deployment derives - // `-` per step for both the mail address and the - // agent-state repo id. - const strategy = createStepStrategy({ - legacyAddress: spec.agentAddress, - stepOrder: definition.stepOrder, - multistepDeriveStepAddress, - }); - // The step ids the credentials snapshot covers are NOT // `stepOrder`: a `loop` body runs in-process as a child run // inheriting the parent's env, so a body step authorizes against // the SAME snapshot the top-level steps do, keyed by its own plain // step id, and a snapshot built from `stepOrder` alone would make - // the child's authorize throw on the body's first tool call. The - // address/repo strategy stays on `stepOrder`, because the - // head/step collapse is a property of the deployment's own step - // count, not of what a body can run. + // the child's authorize throw on the body's first tool call. const stepIds = deps.inertFlatNamespaceStepIds({ definition, context: "sidecar deploy router credentials snapshot: ", }); - const persistence = spec.deployPersistence; - if (persistence !== undefined) { - // Grants bridge: the spawned child does not see the frame; it - // reads each step's grants out of `state/grants.json` in the - // step's agent-state repo while the supervisor assembles the - // credentialsSnapshot, so the write lands before that read. It - // covers the whole flat step-id namespace, loop body steps - // included: an entry read out of a repo that was never written - // carries an empty grant set, which denies the body every - // resource the deploy approved. - await writeStepGrants({ - repoStore: deps.repoStore, - anchorRunId: runId, - stepOrder: stepIds, - deriveStepRepoId: strategy.deriveStepRepoId, - grants: persistence.grants, - }); - // Persist the run record after the grants and before the child - // resumes or starts any run, so a record the boot scan re-drives - // after a crash always has its grants on disk, and any run that - // may have started has a record (an idempotent re-spawn; the - // child's in-flight-run discovery resumes the run). A soft-failed - // deploy removes it; failed removal keeps the record and its - // capacity reservation. + if (spec.recordDataDir !== undefined) { + // Persist the run record before the child resumes or starts any + // run, so any run that may have started has a record (an idempotent + // re-spawn; the child's in-flight-run discovery resumes the run). A + // soft-failed deploy removes it; + // failed removal keeps the record and its capacity reservation. await writeWorkflowRunRecord( - persistence.dataDir, + spec.recordDataDir, runId, buildWorkflowRunRecord(spec, definition), deps.credentialCipher, ); } - if (definition.stepOrder.length === 1) { - // A single-step workflow stages its deploy tree at the head (the - // lone step IS the head). Initialize the head's on-disk - // deploy-tree repo (idempotent) so the hub's deploy-pack push has - // a repo to apply into. The narrow `initRepo` (not - // `provisionAgent`) is deliberate: the supervised child mints its - // own keypair and persists no hub-agent config. - await deps.sessions.initRepo(spec.agentAddress); - - // Record the hub's public key at the head so the deploy-pack - // apply (and any inbound hub-signed frame) verifies against it. - // The verifier resolves the key from the in-memory key store's - // `recordHubKey` map, so a single-step deployment cannot stand up - // without it. - if (spec.hubPublicKey === undefined) { - throw new Error( - "sidecar deploy router: a single-step workflow deployment requires a hubPublicKey to record at the head; none was supplied", - ); - } - deps.keyStore.recordHubKey(spec.agentAddress, spec.hubPublicKey); - hubKeyRecorded = true; - } - - established.current = { definition, strategy }; - return { - stepIds, - deriveStepAddress: strategy.deriveStepAddress, - deriveStepRepoId: strategy.deriveStepRepoId, - }; + established.definition = definition; + return stepIds; }, isRunPoisoned: (runId) => poisonedRunIds.has(runId), // The supervisor stamps `runId` + `agentAddress` before @@ -2196,8 +1909,8 @@ export function createSidecarDeployRouter< wiredForUnwind = wired; hosted.wired = wired; supervisorRegistered = true; - const deployment = established.current; - if (deployment === undefined) { + const establishedDefinition = established.definition; + if (establishedDefinition === undefined) { throw new Error( `sidecar deploy router: ${spec.agentAddress}'s child started without its steps being laid out`, ); @@ -2206,7 +1919,7 @@ export function createSidecarDeployRouter< // IS the long-lived agent, so the child warm-keeps it across // messages and its sources can rotate in place. A multi-step deploy // keeps instantiate-send-teardown per step. - const warmKeep = deployment.definition.stepOrder.length === 1; + const warmKeep = establishedDefinition.stepOrder.length === 1; // Bind the deployment's mail address to this supervisor's // `routeInbound` so the hub-link dispatches inbound mail into the @@ -2222,7 +1935,7 @@ export function createSidecarDeployRouter< // for this address can route. deps.inboundMailPolicyRegistry?.register( spec.agentAddress, - deps.resolveInboundMailPolicy(deployment.definition.inboundMailPolicy), + deps.resolveInboundMailPolicy(establishedDefinition.inboundMailPolicy), ); // Register the signal-delivery handler so a hub `signal.deliver` frame // dispatches through the supervisor's `deliverSignal`. @@ -2244,9 +1957,7 @@ export function createSidecarDeployRouter< }); // Register the grants handler so a hub `run.grants` frame writes the // run's grants to `runs//grants.json` in the deployment's - // workflow-run repo. The `runId` selects the per-run destination; the - // step-fan-out fields are inert in that mode but the shared write - // machinery still takes them. + // workflow-run repo. The `runId` selects the per-run destination. deps.multistepGrantsRouter?.register(spec.agentAddress, async (args) => { try { // "Grant durable" implies "key durable": a run whose sender keys did @@ -2257,18 +1968,16 @@ export function createSidecarDeployRouter< `a sender key the grants of run ${args.runId} carry did not land in the sender-key cache`, ); } - await writeStepGrants({ + await writeRunGrants({ repoStore: deps.repoStore, anchorRunId: runId, - stepOrder: deployment.definition.stepOrder, - deriveStepRepoId: deployment.strategy.deriveStepRepoId, - grants: args.stepGrants, runId: args.runId, + grants: args.stepGrants, }); } catch (cause) { // A sender-key or per-run grants write did not land. Poison the runId - // so the grants barrier fails the run instead of starting it under - // the deploy-time grant set, then re-throw so the hub-link logs the + // so the grants barrier fails the run instead of starting it without + // its grants, then re-throw so the hub-link logs the // durable-write failure loudly. poisonedRunIds.add(args.runId); throw cause; @@ -2292,7 +2001,7 @@ export function createSidecarDeployRouter< // keyed by the head step. Derive that key once here (the layer that // owns the single-key invariant); `deliverSources` stays flat and // stepId-agnostic. - const rotationStepId = deployment.definition.stepOrder[0]; + const rotationStepId = establishedDefinition.stepOrder[0]; if (rotationStepId === undefined) { throw new Error( "single-step deploy has no step id for sources rotation", @@ -2445,17 +2154,6 @@ export function createSidecarDeployRouter< // not leave the address live with a dangling `CryptoProvider`. deps.transport.unregister(spec.agentAddress); } - if (hubKeyRecorded) { - // Reverse the single-step head's `recordHubKey` so a failed deploy - // leaves no in-memory hub key behind. `forgetAgent` also drops the - // agent keypair cache `loadOrGenerateKey` populated, which is safe: - // the transport registration is already unwound above and nothing - // reads that cache after unwind. The on-disk deploy-tree repo - // `initRepo` created, with the keypair under it in `keys/`, is left - // in place: the Hub releases a deployment whose deploy failed, and - // the release's undeploy deletes the agent directory. - deps.keyStore.forgetAgent(spec.agentAddress); - } if (deploymentRegistered) { // Reverse the pre-spawn `registerDeployment`: drop the address // mapping so a failed spawn leaves the boot-edge registry as it @@ -2472,45 +2170,13 @@ export function createSidecarDeployRouter< } } - /** - * Provision one step of a multi-step deploy WITHOUT spawning. The hub - * stages each step's deploy tree before firing the deployment-level - * workflow frame; a full-closure deploy pack still needs an initialized - * agent-state repo to apply into and the hub key recorded to verify the - * pack commit signature. This does exactly those two things -- the same - * harness-free `initRepo` + `recordHubKey` seam the single-step head uses - * -- and constructs no supervisor or child. The deployment-level workflow - * frame (fired once after every step is provisioned) spawns the child, - * which reads each step's staged deploy tree from disk. - * - * Returns the sidecar's principal public key so the link's - * `agent.deploy.ack` carries a key, matching the multi-step ack. A - * per-step address is workflow-derived and records no `agent_instance` - * key, so the hub discards this value. - */ - async function provisionStep( - frame: AgentDeployFrame, - ): Promise { - const held = deployments.get(frame.agentAddress); - if (held !== undefined) { - throw new Error( - `sidecar deploy router: ${frame.agentAddress} generation ${String(held.generation)} is ${held.state} here; a step cannot be provisioned over it`, - ); - } - await deps.sessions.initRepo(frame.agentAddress); - deps.keyStore.recordHubKey(frame.agentAddress, frame.hubPublicKey); - return { - publicKey: await derivePrincipalPublicKeyHex(deps.signingKeySeed), - }; - } - async function deployMultiStep( frame: AgentDeployFrame, - projection: NonNullable, ): Promise { + const projection = frame.workflow; // Refuse a deploy of an address already held BEFORE touching any durable // state. The durable writes below (the run record, the materialized - // closure, step grants) are destructive overwrites of state owned by + // closure) are destructive overwrites of state owned by // whatever deployment holds the address, and an address is deployed once. const held = deployments.get(frame.agentAddress); if (held !== undefined) { @@ -2539,7 +2205,7 @@ export function createSidecarDeployRouter< // Claim the deployment slug BEFORE any durable write so a colliding runId // (two distinct addresses projecting to the same slug) is rejected before - // the closure, the step grants, or the supervisor touch disk -- the + // the closure or the supervisor touch disk -- the // router's "no repo state touched before rejection" guarantee. The claim is // released after failed-deploy record cleanup succeeds; otherwise it stays // held until undeploy completes. The spawn core owns unwinding the @@ -2600,8 +2266,8 @@ export function createSidecarDeployRouter< // The spec the shared spawn core consumes, and the durable record that // lets a boot-time restore rebuild the SAME spec (the closure - // re-materialized from the pin, grants from the step repos, and the - // record's frame/in-memory-only inputs: sources, session id, hub key). + // re-materialized from the pin, and the record's frame/in-memory-only + // inputs: sources, session id). const spec: WorkflowDeploySpec = { agentAddress: frame.agentAddress, generation: frame.generation, @@ -2613,16 +2279,15 @@ export function createSidecarDeployRouter< credentials: projection.credentials, approvedWireHash: projection.approvedWireHash, sessionId: frame.config.sessionId, - hubPublicKey: frame.hubPublicKey, // The sidecar-local dir of the just-materialized closure the spawn core // threads into the child's env so it evaluates the pinned code. closurePackageDir: applied.packageDir, // The source-ref pin the record persists so a restore can // re-materialize the closure. sourceRef: projection.sourceRef, - // The operator-approved grants and the run record the spawn core - // writes once the child has reported its definition. - deployPersistence: { grants: frame.config.grants, dataDir }, + // The run record the spawn core writes once the child has reported + // its definition. + recordDataDir: dataDir, }; // Sweep any legacy plaintext body-source file this deployment's bodies @@ -2787,19 +2452,7 @@ export function createSidecarDeployRouter< if (workflowStopTasks.has(frame.agentAddress)) { throw new Error("Workflow deployment is still stopping"); } - if (frame.provisionStep === true) { - return await provisionStep(frame); - } - if (frame.workflow !== undefined) { - return await deployMultiStep(frame, frame.workflow); - } - // Every deploy stages through the workflow-run substrate: a - // provision-step frame primes the per-step repo, and a workflow - // frame spawns the supervised child. A frame carrying neither is - // an unsupported shape -- there is no in-process fall-through. - throw new Error( - `sidecar deploy router: unsupported deploy frame for ${frame.agentAddress}; a deploy must carry provisionStep or a workflow definition`, - ); + return await deployMultiStep(frame); }, control, async undeploy(frame): Promise { @@ -2887,12 +2540,11 @@ export function createSidecarDeployRouter< ); // Reclaim the deployment's local state: its per-step scratch (the warm // agent's workspace and any cold run subtrees), its materialized - // closure, its source stores, its local conversation copy and a - // single-step deployment's grants, which only that deployment's - // respawns and restarts read. Cleanup also runs without a live - // supervisor, so state an interrupted deploy or a failed restore - // left is reclaimed too. No child holds any of it: a live one was - // shut down above. + // closure, its source stores and its local conversation copy, which + // only that deployment's respawns and restarts read. Cleanup also + // runs without a live supervisor, so state an interrupted deploy or a + // failed restore left is reclaimed too. No child holds any of it: a + // live one was shut down above. if (dataDir !== undefined) { const localState: [string, string][] = [ [ @@ -2919,14 +2571,6 @@ export function createSidecarDeployRouter< for (const [step, dir] of localState) { await attempt(step, () => removeTree(dir)); } - // A single-step deployment's grants live in the agent-state - // repository of its own run address. - const run = parseRunAddress(frame.agentAddress); - if (run !== null) { - await attempt("removing its grants repository", () => - deps.removeAgentStateRepository(run.runId), - ); - } // A teardown that did not finish keeps them, so a restart still // reports the incarnation and the next undeploy finishes it. if (failures.length === 0) { @@ -3016,8 +2660,8 @@ export function createSidecarDeployRouter< continue; } // The slug is the caller's, matching `deployMultiStep`. Restore does NOT - // re-materialize the step grants or the onTrigger body sources -- both - // are already on disk from the original deploy. + // re-materialize the onTrigger body sources -- they are already on disk + // from the original deploy. claimSlug(runId, record.agentAddress); // A deployment that no longer ran is held as it was, unspawned, until // the Hub undeploys it. @@ -3088,7 +2732,6 @@ export function createSidecarDeployRouter< // it. approvedWireHash: record.approvedWireHash, sessionId: record.sessionId, - hubPublicKey: record.hubPublicKey, // The sidecar-local dir of the just-materialized closure the // spawn core threads into the child's env so it re-evaluates the // pinned code. @@ -3200,28 +2843,6 @@ export function createSidecarDeployRouter< }; } -/** - * Logical mail-audit reference the supervisor stamps onto every - * inbox/processing/consumed envelope for sidecar-hosted deployments. - * The substrate does not dereference the value; it is a host-side - * pointer the audit consumer joins on. The mail audit is keyed by the - * deployment id plus the parsed messageId, which is unique per inbound - * message and stable across the FIFO pipeline's - * enqueue/dequeue/markConsumed transitions. - */ -export function deriveSidecarMailAuditRef(runId: string): ( - messageId: string, - rawMessage: Uint8Array, -) => { - store: string; - path: string; -} { - return (messageId, _rawMessage) => ({ - store: "sidecar-mail-audit", - path: `${runId}/${messageId}`, - }); -} - /** * Construct a per-deployment supervisor with the sidecar's bindings * pre-wired. The router calls this once per multi-step `agent.deploy` @@ -3256,10 +2877,6 @@ export function createSidecarWorkflowSupervisor( return send; }, }; - const supervisorPrincipal: WorkflowRunSupervisorPrincipal = { - kind: "supervisor", - anchorRunId: opts.runId, - }; // Per-run grants source. The supervisor pushes its snapshot to the // child before the run's `trigger.fire`, where a null or a throw fails // the run rather than firing the trigger against absent grants, and in @@ -3269,7 +2886,7 @@ export function createSidecarWorkflowSupervisor( const onRunStart = (args: { runId: string; anchorRunId: string; - layout: StepLayout; + stepIds: readonly string[]; }): Promise => { // Fail closed on a run whose `run.grants` write was recorded as failed: // its per-run grants file never landed. A run with no file at all @@ -3287,8 +2904,7 @@ export function createSidecarWorkflowSupervisor( repoStore: opts.repoStore, anchorRunId: args.anchorRunId, runId: args.runId, - stepOrder: args.layout.stepIds, - deriveStepAddress: args.layout.deriveStepAddress, + stepOrder: args.stepIds, }); }; const supervisor = createWorkflowSupervisor({ @@ -3306,7 +2922,6 @@ export function createSidecarWorkflowSupervisor( workflowRunRef: opts.workflowRunRef, anchorRunId: opts.runId, deploymentMailAddress: opts.deploymentMailAddress, - readPrincipal: supervisorPrincipal, layoutSteps: opts.layoutSteps, onRunStart, ...(opts.credentialDelivery !== undefined @@ -3318,7 +2933,6 @@ export function createSidecarWorkflowSupervisor( ...(opts.onSelfTerminate !== undefined ? { onSelfTerminate: opts.onSelfTerminate } : {}), - deriveMailAuditRef: deriveSidecarMailAuditRef(opts.runId), ...(opts.onDispatchTiming !== undefined ? { onDispatchTiming: opts.onDispatchTiming } : {}), @@ -3337,7 +2951,6 @@ export function createSidecarWorkflowSupervisor( routeInbound(message) { return mailBus.routeInbound(opts.deploymentMailAddress, message); }, - getCredentialsSnapshot: () => supervisor.getCredentialsSnapshot(), onRunStart, async closeOutbound() { outboundClosed = true; diff --git a/packages/workflow-host/src/deploy/workflow-run-pack-client.test.ts b/packages/workflow-host/src/deploy/workflow-run-pack-client.test.ts index 22ad4237e..e22613819 100644 --- a/packages/workflow-host/src/deploy/workflow-run-pack-client.test.ts +++ b/packages/workflow-host/src/deploy/workflow-run-pack-client.test.ts @@ -362,7 +362,7 @@ describe("createWorkflowRunPackClient", () => { client.push({ agentAddress: "a@example.com", generation: 1, - repoId: { kind: "agent-state", id: "a@example.com" }, + repoId: { kind: "skill", id: "a@example.com" }, ref: "refs/heads/deploy", }), ).rejects.toThrow(/workflow-run/); @@ -720,7 +720,7 @@ describe("createWorkflowRunPackPushingRepoStore", () => { await facade.writeTreePreservingPrefix( { kind: "hub" }, - { kind: "agent-state", id: "a-1" }, + { kind: "skill", id: "a-1" }, "refs/heads/deploy", { preservePrefix: "deploy/", diff --git a/packages/workflow-host/src/deploy/workflow-run-pack-client.ts b/packages/workflow-host/src/deploy/workflow-run-pack-client.ts index 085485015..d11ab5b41 100644 --- a/packages/workflow-host/src/deploy/workflow-run-pack-client.ts +++ b/packages/workflow-host/src/deploy/workflow-run-pack-client.ts @@ -634,8 +634,7 @@ export function createMultistepCredentialsRouter(): MultistepCredentialsRouter { * every method to the underlying store; intercepts the * `writeTreePreservingPrefix` return path so a successful write * against a `workflow-run` repo schedules a workflow-run pack push. - * Writes against any other `repoId.kind` (today, only `agent-state` - * via the deploy-applier path) flow through unchanged. + * Writes against any other `repoId.kind` flow through unchanged. * * Pack-push coalescing: the facade returns from * `writeTreePreservingPrefix` as soon as the LOCAL commit lands and diff --git a/packages/workflow-host/src/deploy/workflow-run-pack-restore.test.ts b/packages/workflow-host/src/deploy/workflow-run-pack-restore.test.ts index 5370c1a38..7327d4cf4 100644 --- a/packages/workflow-host/src/deploy/workflow-run-pack-restore.test.ts +++ b/packages/workflow-host/src/deploy/workflow-run-pack-restore.test.ts @@ -60,7 +60,6 @@ test("a first deploy keeps its seeded refs and the next sidecar commit fast-forw address: agentAddress, messageId: "message-seeded", receivedAt: 1, - mailAuditRef: { store: "mail", path: "before" }, }); const pushed: string[] = []; @@ -137,7 +136,6 @@ test("a first deploy keeps its seeded refs and the next sidecar commit fast-forw address: agentAddress, messageId: "message-after-deploy", receivedAt: 2, - mailAuditRef: { store: "mail", path: "after" }, }); await packClient.push({ agentAddress, diff --git a/packages/workflow-host/src/deploy/workflow-run-record.test.ts b/packages/workflow-host/src/deploy/workflow-run-record.test.ts index 73ad72a72..b02f4d40f 100644 --- a/packages/workflow-host/src/deploy/workflow-run-record.test.ts +++ b/packages/workflow-host/src/deploy/workflow-run-record.test.ts @@ -114,7 +114,6 @@ const SINGLE_STEP: WorkflowRunRecord = { }, credentials: deliveryOf({ "cred-x": "sk-x" }), sessionId: "ses_1", - hubPublicKey: "deadbeef", // Source-ref is the only lineage: every record carries the pin + approved // hash the restore re-verifies the re-materialized closure against. approvedWireHash: "d".repeat(64), @@ -129,8 +128,7 @@ const SINGLE_STEP: WorkflowRunRecord = { }, }; -// A multi-step deployment records no head hub key and may carry no session -// id -- both optional fields absent. +// A multi-step deployment may carry no session id. const MULTI_STEP: WorkflowRunRecord = { version: 2, agentAddress: "run_xyz@tenant.example", @@ -447,7 +445,6 @@ describe("workflow run record store", () => { const scanned = await scanWorkflowRunRecords(dataDir, CIPHER); const restored = scanned[0]?.record; expect(restored).toEqual(MULTI_STEP); - expect(restored !== undefined && "hubPublicKey" in restored).toBe(false); expect(restored !== undefined && "sessionId" in restored).toBe(false); await fs.rm(dataDir, { recursive: true, force: true }); diff --git a/packages/workflow-host/src/deploy/workflow-run-record.ts b/packages/workflow-host/src/deploy/workflow-run-record.ts index 6afe9cac4..9fe78087c 100644 --- a/packages/workflow-host/src/deploy/workflow-run-record.ts +++ b/packages/workflow-host/src/deploy/workflow-run-record.ts @@ -10,16 +10,14 @@ // the sidecar reports to the Hub and stamps on what the deployment sends), // `sources` (each step's ordered inference-source failover chain, threaded to // the child via the spawn env and durable nowhere else), `sessionId` (inference-event -// correlation), `hubPublicKey` (the head's deploy-pack / inbound verification -// key), `approvedWireHash` (the hub-approved wire hash the deploy frame +// correlation), `approvedWireHash` (the hub-approved wire hash the deploy frame // carried, so a restore re-spawn feeds the child the same re-verify anchor // without recomputing it), `lineage`, and -- for a source-ref deployment -- // the `sourceRef` pin (source + frozen closure) a restore needs to // re-materialize and re-evaluate the pinned code. It also carries the run's // credential cell: the latest delivery the deployment received, sealed. The // definition itself is re-materialized from that `sourceRef` closure on -// restore, and each step's grants live in its agent-state repo, so neither is -// duplicated here. +// restore, so it is not duplicated here. import { mkdir, readdir, readFile } from "node:fs/promises"; import { dirname, join as pathJoin, resolve } from "node:path"; @@ -100,7 +98,6 @@ const workflowRunRecordBase = { // no update, carries none. A restore seeds the supervisor with it. "credentials?": CredentialDelivery, "sessionId?": "string > 0", - "hubPublicKey?": "string > 0", // A deployment that no longer runs keeps its record until the Hub undeploys // it, marked so a restart reports it instead of spawning it: `stopped`, with // the error that ended it unless the Hub stopped it, or `tearing-down` when @@ -218,10 +215,8 @@ async function transformDeliveryMaterials( } /** - * Persist the initial run record once the deploy's step grants are on disk - * and before the child starts or resumes any run, so a record the boot scan - * re-drives after a crash always has its grants. Idempotent: it overwrites - * any existing record for the same run. + * Persist the initial run record before the child starts or resumes any run. + * Idempotent: it overwrites any existing record for the same run. * * Every credential secret in the run's `credentials` cell -- inference and * binding alike -- is sealed under the sidecar `cipher` before it touches disk, diff --git a/packages/workflow-host/src/index.ts b/packages/workflow-host/src/index.ts index 2270fdeb7..0a0a19920 100644 --- a/packages/workflow-host/src/index.ts +++ b/packages/workflow-host/src/index.ts @@ -42,22 +42,17 @@ export { } from "./adapters/spawn-child"; export { createWorkflowSupervisor, - assembleCredentialsSnapshot, isErrnoNotFound, commitCancelRequested, createDrainTimeoutAccumulator, createRecyclePolicy, - defaultStepRepoId, hashGrants, triggerRecycle, DEFAULT_DRAIN_TIMEOUT_MS, DEFAULT_KILL_TIMEOUT_MS, DEFAULT_POLICY_INTERVAL_MS, MAX_BUFFERED_MAIL, - STEP_GRANTS_PATH, - STEP_GRANTS_REF, SUPERVISOR_PRINCIPAL_KIND, - type AssembleCredentialsSnapshotOpts, type CancelCommitInfo, type CancelRequestOpts, type CommitCancelRequestedOpts, @@ -66,15 +61,11 @@ export { type CredentialsSnapshotStep, type DeliverSignalOpts, type DeliverSourcesOpts, - type DeriveMailAuditRef, - type DeriveStepAddress, - type DeriveStepRepoId, type DrainOpts, type DrainTimeoutAccumulator, type DrainTimeoutAccumulatorFactory, type DrainTimeoutOpts, type InboxPrimitives, - type MailAuditRef, type MailBusBindings, type PrincipalSigner, type RecycleAttempt, diff --git a/packages/workflow-host/src/ipc/control-payloads.ts b/packages/workflow-host/src/ipc/control-payloads.ts index 2aa317161..ae16f5fbd 100644 --- a/packages/workflow-host/src/ipc/control-payloads.ts +++ b/packages/workflow-host/src/ipc/control-payloads.ts @@ -21,7 +21,6 @@ import { CredentialDelivery } from "@intx/types/credential-delivery"; */ export const CredentialsSnapshotStepPayload = type({ stepId: "string", - address: "string", grants: "unknown[]", contentHash: "string", }); diff --git a/packages/workflow-host/src/local-credentials.test.ts b/packages/workflow-host/src/local-credentials.test.ts index a95f68ec6..fabf8db4d 100644 --- a/packages/workflow-host/src/local-credentials.test.ts +++ b/packages/workflow-host/src/local-credentials.test.ts @@ -126,7 +126,6 @@ describe("createLocalCredentialsSnapshot", () => { ]); for (const entry of snapshot.steps) { expect(entry.grants).toBe(first.grants); - expect(entry.address).toBe(entry.stepId); } }); diff --git a/packages/workflow-host/src/local-credentials.ts b/packages/workflow-host/src/local-credentials.ts index 64c079b27..4ebe5d48d 100644 --- a/packages/workflow-host/src/local-credentials.ts +++ b/packages/workflow-host/src/local-credentials.ts @@ -80,7 +80,6 @@ export function createLocalCredentialsSnapshot( return { steps: executableStepIds(opts.definition).map((stepId) => ({ stepId, - address: stepId, grants, // A local snapshot is never pushed over a control channel, so it carries // no content hash to cross-check. diff --git a/packages/workflow-host/src/local.ts b/packages/workflow-host/src/local.ts index 583d17453..4a62818e7 100644 --- a/packages/workflow-host/src/local.ts +++ b/packages/workflow-host/src/local.ts @@ -26,7 +26,6 @@ export { attachStepCredentialWiring, attachStepTools, createToolBearingAgentFactory, - deriveToolMarkFloorGrants, type StepToolMaterialization, } from "./child/step-tools"; export { diff --git a/packages/workflow-host/src/run-grants.ts b/packages/workflow-host/src/run-grants.ts index ce114bf75..1599c38c7 100644 --- a/packages/workflow-host/src/run-grants.ts +++ b/packages/workflow-host/src/run-grants.ts @@ -33,10 +33,8 @@ export function runGrantsPath(runId: string): string { /** * Envelope the per-run grants file carries: the canonical `{ grants: [] }` - * shape the deploy-time snapshot writes and validates. The inner entries - * stay `unknown` -- the child's authorize layer narrows each against its - * own grant-rule validator, exactly as the deploy-time snapshot's grants - * are surfaced untyped. + * shape. The inner entries stay `unknown` -- the child's authorize layer + * narrows each against its own grant-rule validator. */ export const RunGrantsFile = type({ grants: "unknown[]", @@ -45,8 +43,7 @@ export const RunGrantsFile = type({ /** * Read a single run's grants from `runs//grants.json` inside the * deployment's `workflow-run` repo. The read is a working-tree read via - * `getRepoDir` (the same substrate access pattern the deploy-time - * per-step read uses), so it observes the tip the grants handler wrote. + * `getRepoDir`, so it observes the tip the grants handler wrote. * * Returns the run's grants when the file exists. Returns `undefined` * -- distinct from an empty grants array -- when the file is ABSENT diff --git a/packages/workflow-host/src/seams/child-workflow-signal-deliver.test.ts b/packages/workflow-host/src/seams/child-workflow-signal-deliver.test.ts index dafd11c31..94315c79e 100644 --- a/packages/workflow-host/src/seams/child-workflow-signal-deliver.test.ts +++ b/packages/workflow-host/src/seams/child-workflow-signal-deliver.test.ts @@ -100,7 +100,7 @@ const principal: Principal = { kind: "test" }; function permissiveHandler(directoryPrefix: string): KindHandler { return { - kind: "agent-state", + kind: "skill", directoryPrefix, validatePush(): ValidatePushResult { return { ok: true }; @@ -305,10 +305,10 @@ async function resumeOnHost(args: { const substrate = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler(args.prefix) }, + handlers: { skill: permissiveHandler(args.prefix) }, authorize: authorizeSubstrate, }); - const repoId: RepoId = { kind: "agent-state", id: args.prefix }; + const repoId: RepoId = { kind: "skill", id: args.prefix }; const runtimeStore = createWorkflowRunRepoStore({ substrate, repoId, diff --git a/packages/workflow-host/src/seams/loop-signal-delivery.test.ts b/packages/workflow-host/src/seams/loop-signal-delivery.test.ts index f9365753b..60c880340 100644 --- a/packages/workflow-host/src/seams/loop-signal-delivery.test.ts +++ b/packages/workflow-host/src/seams/loop-signal-delivery.test.ts @@ -85,7 +85,7 @@ const principal: Principal = { kind: "test" }; function permissiveHandler(directoryPrefix: string): KindHandler { return { - kind: "agent-state", + kind: "skill", directoryPrefix, validatePush(): ValidatePushResult { return { ok: true }; @@ -186,10 +186,10 @@ async function runLoopDelivery(args: { const substrate = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler(args.prefix) }, + handlers: { skill: permissiveHandler(args.prefix) }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: args.prefix }; + const repoId: RepoId = { kind: "skill", id: args.prefix }; const runtimeStore = createWorkflowRunRepoStore({ substrate, repoId, diff --git a/packages/workflow-host/src/seams/scheduler.test.ts b/packages/workflow-host/src/seams/scheduler.test.ts index 543736b83..70a96ac44 100644 --- a/packages/workflow-host/src/seams/scheduler.test.ts +++ b/packages/workflow-host/src/seams/scheduler.test.ts @@ -43,11 +43,10 @@ afterAll(async () => { function permissiveHandler(directoryPrefix: string): KindHandler { return { - // The scheduler test uses an `agent-state`-shaped repo because - // the workflow-run kind handler is not yet registered. The - // scheduler does not care about the kind discriminator -- only + // The scheduler test uses a `skill`-shaped repo with a permissive handler + // to skip event schema checks. The scheduler does not care about the kind discriminator -- only // that the substrate accepts writes under `runs//events/`. - kind: "agent-state", + kind: "skill", directoryPrefix, validatePush(): ValidatePushResult { return { ok: true }; @@ -227,11 +226,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-oneshot"), + skill: permissiveHandler("workflow-runs-oneshot"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-a" }; + const repoId: RepoId = { kind: "skill", id: "deployment-a" }; const runId = "r1"; const fireAtMs = Date.now() + 40; @@ -288,11 +287,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-recovery"), + skill: permissiveHandler("workflow-runs-recovery"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-b" }; + const repoId: RepoId = { kind: "skill", id: "deployment-b" }; const runId = "r1"; const farFuture = Date.now() + 60_000; @@ -346,11 +345,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-committed"), + skill: permissiveHandler("workflow-runs-committed"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-d" }; + const repoId: RepoId = { kind: "skill", id: "deployment-d" }; const runId = "r1"; const farFuture = Date.now() + 60_000; @@ -408,11 +407,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-compacted"), + skill: permissiveHandler("workflow-runs-compacted"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-e" }; + const repoId: RepoId = { kind: "skill", id: "deployment-e" }; const farFuture = Date.now() + 60_000; await seedTimerSet(store, repoId, "r1", 0, "t-live", farFuture); @@ -458,11 +457,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-badjson"), + skill: permissiveHandler("workflow-runs-badjson"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-f" }; + const repoId: RepoId = { kind: "skill", id: "deployment-f" }; await store.writeTree(principal, repoId, REF, { files: { "runs/r1/events/0.json": "{ not json" }, @@ -494,11 +493,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-badname"), + skill: permissiveHandler("workflow-runs-badname"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-g" }; + const repoId: RepoId = { kind: "skill", id: "deployment-g" }; // A foreign name under events/ is corruption; recovery surfaces it // rather than silently dropping the blob. @@ -540,11 +539,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-cron"), + skill: permissiveHandler("workflow-runs-cron"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-c" }; + const repoId: RepoId = { kind: "skill", id: "deployment-c" }; const runId = "r1"; const inThePast = Date.now() - 60_000; @@ -592,11 +591,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-stop"), + skill: permissiveHandler("workflow-runs-stop"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-d" }; + const repoId: RepoId = { kind: "skill", id: "deployment-d" }; const runId = "r1"; const fireAtMs = Date.now() + 1_000; @@ -645,11 +644,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-live"), + skill: permissiveHandler("workflow-runs-live"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-live" }; + const repoId: RepoId = { kind: "skill", id: "deployment-live" }; const runId = "r-live"; // Seed the run's events tree so it exists at start-time. The @@ -827,11 +826,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-buffered"), + skill: permissiveHandler("workflow-runs-buffered"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-buffered" }; + const repoId: RepoId = { kind: "skill", id: "deployment-buffered" }; const runId = "r-timer-buffered"; const runtimeStore = runtimeStoreFor(store, repoId); const at = "2026-01-01T00:00:00.000Z"; @@ -891,11 +890,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-empty"), + skill: permissiveHandler("workflow-runs-empty"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-empty" }; + const repoId: RepoId = { kind: "skill", id: "deployment-empty" }; const runId = "r-timer-empty"; const runtimeStore = runtimeStoreFor(store, repoId); const at = "2026-01-01T00:00:00.000Z"; @@ -945,11 +944,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-fired"), + skill: permissiveHandler("workflow-runs-fired"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-fired" }; + const repoId: RepoId = { kind: "skill", id: "deployment-fired" }; const runId = "r-timer-fired"; const runtimeStore = runtimeStoreFor(store, repoId); const at = "2026-01-01T00:00:00.000Z"; @@ -999,11 +998,11 @@ describe("workflow-host scheduler", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-terminal"), + skill: permissiveHandler("workflow-runs-terminal"), }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-terminal" }; + const repoId: RepoId = { kind: "skill", id: "deployment-terminal" }; const runId = "r-timer-terminal"; const runtimeStore = runtimeStoreFor(store, repoId); const at = "2026-01-01T00:00:00.000Z"; diff --git a/packages/workflow-host/src/seams/signal-channel.test.ts b/packages/workflow-host/src/seams/signal-channel.test.ts index 5e35a1a6f..2fbfec103 100644 --- a/packages/workflow-host/src/seams/signal-channel.test.ts +++ b/packages/workflow-host/src/seams/signal-channel.test.ts @@ -44,12 +44,11 @@ afterAll(async () => { function permissiveHandler(directoryPrefix: string): KindHandler { return { - // The signal-channel test uses an `agent-state`-shaped repo for - // the same reason the scheduler test does: the workflow-run kind - // handler is not yet registered. The signal channel does not care + // The signal-channel test uses a `skill`-shaped repo with a permissive + // handler to skip event schema checks. The signal channel does not care // about the kind discriminator -- only that the substrate accepts // writes under `runs//events/`. - kind: "agent-state", + kind: "skill", directoryPrefix, validatePush(): ValidatePushResult { return { ok: true }; @@ -98,10 +97,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-live") }, + handlers: { skill: permissiveHandler("workflow-runs-live") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-live" }; + const repoId: RepoId = { kind: "skill", id: "deployment-live" }; const runId = "r-live"; const box = makeStateBox(runId); @@ -142,10 +141,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-resume") }, + handlers: { skill: permissiveHandler("workflow-runs-resume") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-resume" }; + const repoId: RepoId = { kind: "skill", id: "deployment-resume" }; const runId = "r-resume"; const box = makeStateBox(runId); // Seed unconsumedSignals as if a log replay observed a @@ -187,10 +186,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-abort") }, + handlers: { skill: permissiveHandler("workflow-runs-abort") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-abort" }; + const repoId: RepoId = { kind: "skill", id: "deployment-abort" }; const runId = "r-abort"; const box = makeStateBox(runId); @@ -239,12 +238,12 @@ describe("workflow-host signal channel", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-isolated"), + skill: permissiveHandler("workflow-runs-isolated"), }, authorize: allowAll, }); const repoId: RepoId = { - kind: "agent-state", + kind: "skill", id: "deployment-isolated", }; const runId = "r-isolated"; @@ -290,10 +289,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-relive") }, + handlers: { skill: permissiveHandler("workflow-runs-relive") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-relive" }; + const repoId: RepoId = { kind: "skill", id: "deployment-relive" }; const runId = "r-relive"; const box = makeStateBox(runId); @@ -366,10 +365,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-fifo") }, + handlers: { skill: permissiveHandler("workflow-runs-fifo") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-fifo" }; + const repoId: RepoId = { kind: "skill", id: "deployment-fifo" }; const runId = "r-fifo"; const box = makeStateBox(runId); @@ -432,10 +431,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-dedup") }, + handlers: { skill: permissiveHandler("workflow-runs-dedup") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-dedup" }; + const repoId: RepoId = { kind: "skill", id: "deployment-dedup" }; const runId = "r-dedup"; const box = makeStateBox(runId); @@ -474,10 +473,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-buffered") }, + handlers: { skill: permissiveHandler("workflow-runs-buffered") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-buffered" }; + const repoId: RepoId = { kind: "skill", id: "deployment-buffered" }; const runId = "r-buffered"; const box = makeStateBox(runId); const runtimeStore = runtimeStoreFor(store, repoId); @@ -525,12 +524,12 @@ describe("workflow-host signal channel", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-empty-buffer"), + skill: permissiveHandler("workflow-runs-empty-buffer"), }, authorize: allowAll, }); const repoId: RepoId = { - kind: "agent-state", + kind: "skill", id: "deployment-empty-buffer", }; const runId = "r-empty-buffer"; @@ -569,10 +568,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-terminal") }, + handlers: { skill: permissiveHandler("workflow-runs-terminal") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-terminal" }; + const repoId: RepoId = { kind: "skill", id: "deployment-terminal" }; const runId = "r-terminal"; const box = makeStateBox(runId); const runtimeStore = runtimeStoreFor(store, repoId); @@ -611,10 +610,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-peek") }, + handlers: { skill: permissiveHandler("workflow-runs-peek") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-peek" }; + const repoId: RepoId = { kind: "skill", id: "deployment-peek" }; const runId = "r-peek"; const state: RunState = { ...emptyState(runId), @@ -716,10 +715,10 @@ describe("workflow-host signal channel", () => { const store = createRepoStore({ dataDir, signingKey, - handlers: { "agent-state": permissiveHandler("workflow-runs-slots") }, + handlers: { skill: permissiveHandler("workflow-runs-slots") }, authorize: allowAll, }); - const repoId: RepoId = { kind: "agent-state", id: "deployment-slots" }; + const repoId: RepoId = { kind: "skill", id: "deployment-slots" }; const runId = "r-slots"; const channel = createWorkflowHostSignalChannel({ repoStore: store, @@ -762,12 +761,12 @@ describe("workflow-host signal channel", () => { dataDir, signingKey, handlers: { - "agent-state": permissiveHandler("workflow-runs-name-skip"), + skill: permissiveHandler("workflow-runs-name-skip"), }, authorize: allowAll, }); const repoId: RepoId = { - kind: "agent-state", + kind: "skill", id: "deployment-name-skip", }; const runId = "r-name-skip"; diff --git a/packages/workflow-host/src/supervisor/crash-respawn.test.ts b/packages/workflow-host/src/supervisor/crash-respawn.test.ts index 64b027c0c..60736fb51 100644 --- a/packages/workflow-host/src/supervisor/crash-respawn.test.ts +++ b/packages/workflow-host/src/supervisor/crash-respawn.test.ts @@ -49,8 +49,8 @@ import { type SubprocessSpawner, type WorkflowSupervisorBindings, } from "./index"; -import { defaultStepRepoId, STEP_GRANTS_PATH } from "./credentials"; import { createControlChannelSender } from "../ipc/index"; +import { seedStepGrants, seededOnRunStart } from "./step-grants.test-helpers"; async function makeTempDir(prefix: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -114,19 +114,6 @@ function createStubRepoStore( }); } -async function seedStepGrants( - baseDir: string, - repoId: RepoId, - grants: unknown[], -): Promise { - const dir = path.join(baseDir, repoId.kind, repoId.id); - await fs.mkdir(path.join(dir, "state"), { recursive: true }); - await fs.writeFile( - path.join(dir, STEP_GRANTS_PATH), - JSON.stringify({ grants }), - ); -} - type FakeChild = { pid: number; channelId: string | undefined; @@ -241,7 +228,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives & { type Entry = { messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; rawMessage?: string; }; const state = new Map< @@ -266,7 +252,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives & { s.inbox.set(k, { messageId: args.messageId, receivedAt: args.receivedAt, - mailAuditRef: args.mailAuditRef, ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}), @@ -279,7 +264,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives & { messageId: args.messageId, receivedAt: args.receivedAt, address: args.address, - mailAuditRef: args.mailAuditRef, ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}), @@ -307,7 +291,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives & { messageId: envelope.messageId, receivedAt: envelope.receivedAt, address, - mailAuditRef: envelope.mailAuditRef, ...(envelope.rawMessage !== undefined ? { rawMessage: envelope.rawMessage } : {}), @@ -337,7 +320,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives & { address: args.address, runId: args.runId, consumedAt: args.consumedAt, - mailAuditRef: envelope.mailAuditRef, }, watermark: 0, prunedMessageIds: [], @@ -401,13 +383,8 @@ async function buildBindings(opts: { workflowRunRef: "refs/heads/main", anchorRunId: "run_deployment-x", deploymentMailAddress: "run_deployment-x@example.com", - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }), + onRunStart: seededOnRunStart(opts.baseDir), + layoutSteps: () => Promise.resolve(["step-1"]), ipcKeyPairFactory: () => Promise.resolve(opts.ipcKeypair), inboxPrimitives: opts.inboxPrimitives, ...(opts.crashLoopMaxCount !== undefined @@ -448,11 +425,9 @@ async function spawnSupervisor(opts: { reason: string; }) => void; }) { - await seedStepGrants( - opts.baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(opts.baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir: opts.baseDir, spawner: opts.tracker.spawner, diff --git a/packages/workflow-host/src/supervisor/credentials.ts b/packages/workflow-host/src/supervisor/credentials.ts index 13dcb2a63..ac88a1626 100644 --- a/packages/workflow-host/src/supervisor/credentials.ts +++ b/packages/workflow-host/src/supervisor/credentials.ts @@ -1,68 +1,19 @@ -// Per-step `credentialsSnapshot` assembly. -// -// At spawn time, the supervisor reads each workflow step's grants out -// of its `agent-state` repo and forwards the resulting per-step -// snapshot to the workflow-process over the control IPC. The snapshot -// never lands on disk in the child's view and is never placed in -// spawn-time env -- the env carries only the IPC trust anchors. The -// child receives credentials over the authenticated control channel -// once both halves are connected. +// The per-step `credentialsSnapshot` the supervisor forwards to the +// workflow-process over the control IPC. The snapshot never lands on disk in +// the child's view and is never placed in spawn-time env -- the env carries +// only the IPC trust anchors. The child receives credentials over the +// authenticated control channel once both halves are connected. // // Each step's snapshot is pinned to a content hash so the child can // detect a stale snapshot (e.g. one that arrived after a `grants- // updated` push the child already processed). The hash is the // sha256 of the canonicalized JSON serialization of the snapshot's -// per-step `grants` array -- not the git tree SHA of the underlying -// `agent-state` repo's `grants` ref. The substrate's tree SHA is -// implementation-coupled to the path of the file inside the repo; -// hashing the canonical JSON gives a stable identifier the child can -// compare across pushes without taking a dependency on the -// substrate's git layout. -// -// Per-step address derivation (Q6.4 discovery decision): -// - Multi-step deployments use `-@`. -// - Trivial (single-step) deployments use the deployment's own -// mail address as the sole step's address. -// The derivation is supplied by the caller as a `deriveStepAddress` -// callback so the supervisor doesn't have to encode the deployment- -// domain into the workflow-host package. - -import { type } from "arktype"; +// per-step `grants` array. import { hexEncode } from "@intx/types/hex"; -import type { - Principal, - RepoId, - RepoStore, -} from "@intx/hub-sessions/substrate"; import type { CredentialsSnapshotPayload } from "../ipc/control-payloads"; -/** - * Path inside each step's `agent-state` repo that carries the step's - * grants. The agent-state kind handler accepts state writes under the - * `state/` subtree; the grants snapshot rides at - * `state/grants.json` as a single canonical JSON document. - */ -export const STEP_GRANTS_PATH = "state/grants.json"; - -/** - * Ref the supervisor reads each step's grants from. The agent-state - * kind handler defines the deploy ref under `refs/heads/deploy`; the - * state-bearing refs the sidecar writes to use the default - * `refs/heads/main` convention. - */ -export const STEP_GRANTS_REF = "refs/heads/main"; - -/** - * Shape of the per-step grants file. The supervisor surfaces this - * untyped to the child -- the child's authorize layer narrows the - * inner `grants` entries against its own grant-rule validator. - */ -const StepGrantsFile = type({ - grants: "unknown[]", -}).onUndeclaredKey("ignore"); - export type CredentialsSnapshotStep = { /** * Workflow step id: a `WorkflowDefinition.stepOrder` entry, or a step id @@ -70,9 +21,7 @@ export type CredentialsSnapshotStep = { * enclosing definition's flat step-id namespace). */ stepId: string; - /** Mail address the step's agent presents to the bus. */ - address: string; - /** Opaque `grants` array as committed in the step's repo. */ + /** Opaque `grants` array the run holds. */ grants: readonly unknown[]; /** sha256 hex over the canonical JSON of `grants`. */ contentHash: string; @@ -83,160 +32,6 @@ export type CredentialsSnapshot = { steps: readonly CredentialsSnapshotStep[]; }; -/** - * Caller-supplied derivation of the per-step mail address from the - * run id and step id. The supervisor cannot encode the - * deployment-domain inside library code; the wiring module supplies - * the strategy the host owns. - */ -export type DeriveStepAddress = (args: { - runId: string; - stepId: string; -}) => string; - -/** - * Caller-supplied override of the per-step `agent-state` repo identity - * the supervisor reads grants from. Defaults to the - * `-` convention (`defaultStepRepoId`); the - * single-step launched-agent deploy supplies a derivation that returns - * the legacy agent-state repo so the child reads grants from the same - * repo the legacy agent identity already keys. - */ -export type DeriveStepRepoId = (args: { - runId: string; - stepId: string; -}) => RepoId; - -/** - * Where a deployment's steps live: every step id the credentials snapshot - * carries an entry for, and how each one maps to its mail address and its - * `agent-state` repo. The host derives it from the definition the - * deployment's first child verified; it is fixed for the deployment's - * lifetime. - */ -export type StepLayout = { - /** - * The deployment's flat step-id namespace: the definition's own - * `stepOrder` plus the step ids of every `loop` body it carries. - */ - readonly stepIds: readonly string[]; - readonly deriveStepAddress: DeriveStepAddress; - /** Defaults to `-` (`defaultStepRepoId`) when omitted. */ - readonly deriveStepRepoId?: DeriveStepRepoId; -}; - -/** The fields of `AssembleCredentialsSnapshotOpts` a step layout supplies. */ -export function stepLayoutSnapshotOpts( - layout: StepLayout, -): Pick< - AssembleCredentialsSnapshotOpts, - "stepOrder" | "deriveStepAddress" | "deriveStepRepoId" -> { - return { - stepOrder: layout.stepIds, - deriveStepAddress: layout.deriveStepAddress, - ...(layout.deriveStepRepoId !== undefined - ? { deriveStepRepoId: layout.deriveStepRepoId } - : {}), - }; -} - -export type AssembleCredentialsSnapshotOpts = { - /** Substrate handle the supervisor reads from. */ - repoStore: RepoStore; - /** Principal presented for each step's read. */ - principal: Principal; - /** - * Every step id the snapshot must carry an entry for. The trivial - * workflow passes a single entry; multi-step deployments pass every - * step in the order the workflow asset declared. - * - * This is the deployment's flat step-id namespace, which is WIDER than - * the definition's own `stepOrder`: a `loop` body runs in-process as a - * child run inheriting the parent's env, so a body step authorizes - * against this same snapshot under its own plain step id. The caller - * owns that widening -- it is the layer that holds the definition -- - * and the snapshot is total over whatever it passes, because the - * child's authorize treats a missing entry as unrecoverable. - */ - stepOrder: readonly string[]; - /** Anchor run id used in agent-state repo identity and address derivation. */ - anchorRunId: string; - /** Per-step mail-address derivation callback. */ - deriveStepAddress: DeriveStepAddress; - /** - * Optional override for the `agent-state` repo's id. Callers that - * follow the documented convention (`-`) can - * omit this; tests and bespoke layouts can supply their own. - */ - deriveStepRepoId?: DeriveStepRepoId; -}; - -/** - * Default mapping from `(runId, stepId)` to the agent-state - * repo id: `-`, isolating each step's grants in - * its own repo. Applied to a one-step `stepOrder` it yields a single - * such repo. The single-step launched-agent deploy overrides this - * default (see `DeriveStepRepoId`) to reuse the legacy agent-state - * repo. - */ -export function defaultStepRepoId(args: { - runId: string; - stepId: string; -}): RepoId { - return { - kind: "agent-state", - id: `${args.runId}-${args.stepId}`, - }; -} - -/** - * Read one step's grants file from disk via the substrate's working- - * tree directory. The substrate documents `getRepoDir` as a pure path - * computation -- the sibling production adapters (`repo-store.ts`, - * `spawn-child.ts`) use the same working-tree-read pattern when they - * need synchronous access to a ref's tip without round-tripping - * through the git object database. - * - * A missing grants file is treated as "no grants" (empty array) so - * a step whose repo carries no grants file does not crash; a - * malformed file does crash, because the file's presence implies - * the deploy orchestrator intended a snapshot and a structural - * failure is a programming bug at the boundary. - */ -async function readStepGrants( - opts: AssembleCredentialsSnapshotOpts, - repoId: RepoId, -): Promise { - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - const dir = opts.repoStore.getRepoDir(repoId); - const filePath = path.join(dir, STEP_GRANTS_PATH); - let raw: string; - try { - raw = await fs.readFile(filePath, "utf8"); - } catch (cause) { - if (isErrnoNotFound(cause)) return []; - throw cause; - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (cause) { - throw new Error( - `supervisor credentialsSnapshot: ${repoId.kind}/${repoId.id}:${STEP_GRANTS_PATH} is not valid JSON`, - { cause }, - ); - } - const validated = StepGrantsFile(parsed); - if (validated instanceof type.errors) { - throw new Error( - `supervisor credentialsSnapshot: ${repoId.kind}/${repoId.id}:${STEP_GRANTS_PATH} failed validation: ${validated.summary}`, - ); - } - return validated.grants; -} - /** * Compute the per-step content hash used to pin a credentialsSnapshot * push to a specific grants payload. Stable across processes because @@ -251,39 +46,6 @@ export async function hashGrants(grants: readonly unknown[]): Promise { return hexEncode(new Uint8Array(digest)); } -/** - * Assemble the per-deployment `credentialsSnapshot` from each step's - * `agent-state` repo. The supervisor invokes this at spawn time and - * again on every `grants-updated` mail. The result rides the - * control IPC as a single payload; the child pins each step's grants - * to the supplied hash so it can ignore an out-of-order push that - * arrives after a fresher one. - */ -export async function assembleCredentialsSnapshot( - opts: AssembleCredentialsSnapshotOpts, -): Promise { - const deriveRepoId = opts.deriveStepRepoId ?? defaultStepRepoId; - const steps: CredentialsSnapshotStep[] = []; - for (const stepId of opts.stepOrder) { - const repoId = deriveRepoId({ - runId: opts.anchorRunId, - stepId, - }); - const grants = await readStepGrants(opts, repoId); - const address = opts.deriveStepAddress({ - runId: opts.anchorRunId, - stepId, - }); - steps.push({ - stepId, - address, - grants, - contentHash: await hashGrants(grants), - }); - } - return { steps }; -} - /** The wire form of a snapshot, as a control frame carries it. */ export function toCredentialsSnapshotPayload( snapshot: CredentialsSnapshot, @@ -291,7 +53,6 @@ export function toCredentialsSnapshotPayload( return { steps: snapshot.steps.map((s) => ({ stepId: s.stepId, - address: s.address, grants: [...s.grants], contentHash: s.contentHash, })), @@ -305,7 +66,6 @@ export function fromCredentialsSnapshotPayload( return { steps: payload.steps.map((s) => ({ stepId: s.stepId, - address: s.address, grants: s.grants, contentHash: s.contentHash, })), diff --git a/packages/workflow-host/src/supervisor/index.ts b/packages/workflow-host/src/supervisor/index.ts index 5088bbfd5..790105dda 100644 --- a/packages/workflow-host/src/supervisor/index.ts +++ b/packages/workflow-host/src/supervisor/index.ts @@ -13,17 +13,10 @@ export { } from "./supervisor"; export { - assembleCredentialsSnapshot, isErrnoNotFound, - defaultStepRepoId, hashGrants, - STEP_GRANTS_PATH, - STEP_GRANTS_REF, - type AssembleCredentialsSnapshotOpts, type CredentialsSnapshot, type CredentialsSnapshotStep, - type DeriveStepAddress, - type DeriveStepRepoId, } from "./credentials"; export { @@ -59,12 +52,10 @@ export { } from "./recycle"; export type { - DeriveMailAuditRef, DispatchStructuralCounters, DispatchSubstrateLeg, DispatchTimingMark, InboxPrimitives, - MailAuditRef, MailBusBindings, PrincipalSigner, SignedPayload, diff --git a/packages/workflow-host/src/supervisor/lifecycle-races.test.ts b/packages/workflow-host/src/supervisor/lifecycle-races.test.ts index 6f12f6c2e..d830c1aac 100644 --- a/packages/workflow-host/src/supervisor/lifecycle-races.test.ts +++ b/packages/workflow-host/src/supervisor/lifecycle-races.test.ts @@ -20,7 +20,6 @@ import path from "node:path"; import { generateKeyPair } from "@intx/crypto"; import { hexEncode } from "@intx/types"; -import type { RepoId } from "@intx/hub-sessions"; import { createMemoryFrameStream, createMemoryNdjsonStream, @@ -40,32 +39,18 @@ import { type SubprocessSpawner, type WorkflowSupervisorBindings, } from "./index"; -import { defaultStepRepoId, STEP_GRANTS_PATH } from "./credentials"; import { createControlChannelSender } from "../ipc/index"; import { waitUntil } from "@intx/types/testing"; +import { seedStepGrants, seededOnRunStart } from "./step-grants.test-helpers"; async function makeTempDir(prefix: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), prefix)); } -async function seedStepGrants( - baseDir: string, - repoId: RepoId, - grants: unknown[], -): Promise { - const dir = path.join(baseDir, repoId.kind, repoId.id); - await fs.mkdir(path.join(dir, "state"), { recursive: true }); - await fs.writeFile( - path.join(dir, STEP_GRANTS_PATH), - JSON.stringify({ grants }), - ); -} - function createMemoryInboxPrimitives(): InboxPrimitives { type Entry = { messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; }; const state = new Map< string, @@ -93,7 +78,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives { const envelope: Entry = { messageId: args.messageId, receivedAt: args.receivedAt, - mailAuditRef: args.mailAuditRef, }; s.inbox.set(k, envelope); return { @@ -104,7 +88,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives { messageId: args.messageId, receivedAt: args.receivedAt, address: args.address, - mailAuditRef: args.mailAuditRef, }, }; }, @@ -129,7 +112,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives { messageId: envelope.messageId, receivedAt: envelope.receivedAt, address, - mailAuditRef: envelope.mailAuditRef, }, }; }, @@ -157,7 +139,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives { address: args.address, runId: args.runId, consumedAt: args.consumedAt, - mailAuditRef: envelope.mailAuditRef, }, watermark: 0, prunedMessageIds: [], @@ -197,13 +178,8 @@ async function buildBindings(opts: { workflowRunRef: "refs/heads/main", anchorRunId: "deployment-x", deploymentMailAddress: "deployment-x@example.com", - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }), + onRunStart: seededOnRunStart(opts.baseDir), + layoutSteps: () => Promise.resolve(["step-1"]), ipcKeyPairFactory: () => Promise.resolve(opts.ipcKeypair), inboxPrimitives: createMemoryInboxPrimitives(), }; @@ -223,11 +199,9 @@ describe("waitForReady -> pumpUpstreamControl iterator handoff (Gap A)", () => { // its recycle path, which spawns a fresh workflow-process child // under a new channelId. const baseDir = await makeTempDir("lifecycle-races-gap-a-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const ipcKeypair = await generateKeyPair(); const childIpcKeypair = await generateKeyPair(); const mailBus = createMockMailBus(); @@ -366,11 +340,6 @@ describe("waitForReady -> pumpUpstreamControl iterator handoff (Gap A)", () => { describe("shutdownInternal vs spawn-time crash (Gap B)", () => { test("kills the child before waiting for a blocked dispatch loop", async () => { const baseDir = await makeTempDir("shutdown-blocked-dispatch-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "deployment-x", stepId: "step-1" }), - [], - ); const ipcKeypair = await generateKeyPair(); const childKeypair = await generateKeyPair(); const controlIn = createMemoryNdjsonStream(); @@ -445,11 +414,6 @@ describe("shutdownInternal vs spawn-time crash (Gap B)", () => { test("escalates to SIGKILL when the child ignores SIGTERM", async () => { const baseDir = await makeTempDir("shutdown-sigterm-trap-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "deployment-x", stepId: "step-1" }), - [], - ); const ipcKeypair = await generateKeyPair(); const childKeypair = await generateKeyPair(); const controlIn = createMemoryNdjsonStream(); @@ -527,11 +491,9 @@ describe("shutdownInternal vs spawn-time crash (Gap B)", () => { // error MUST surface as a `spawn()` rejection -- not be swallowed // by the shutdown teardown's catches. const baseDir = await makeTempDir("lifecycle-races-gap-b-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const ipcKeypair = await generateKeyPair(); const mailBus = createMockMailBus(); diff --git a/packages/workflow-host/src/supervisor/outbound-signed-send.test.ts b/packages/workflow-host/src/supervisor/outbound-signed-send.test.ts index 3c57aca60..61c529340 100644 --- a/packages/workflow-host/src/supervisor/outbound-signed-send.test.ts +++ b/packages/workflow-host/src/supervisor/outbound-signed-send.test.ts @@ -46,6 +46,7 @@ import { createControlChannelSender, type ControlChannelSender, } from "../ipc/index"; +import { noGrantsOnRunStart } from "./step-grants.test-helpers"; const AGENT_ADDRESS = "run_outbound-agent@integration.example"; const RECIPIENT_ADDRESS = "recipient@integration.example"; @@ -149,17 +150,9 @@ async function startOutboundHarness(): Promise { workflowRunRef: "refs/heads/main", anchorRunId: "outbound-dep", deploymentMailAddress: AGENT_ADDRESS, - readPrincipal: { kind: "supervisor" }, - // Single-step launched agent: its grants live in the legacy - // agent-state repo keyed by the agent id. The stub repo's - // getRepoDir resolves the dir; no grants file is needed because the - // outbound path never reads grants. - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: () => AGENT_ADDRESS, - deriveStepRepoId: () => ({ kind: "agent-state", id: "outbound-dep" }), - }), + // Single-step launched agent. The outbound path never reads grants. + onRunStart: noGrantsOnRunStart, + layoutSteps: () => Promise.resolve(["step-1"]), inboxPrimitives: createNoopInboxPrimitives(), ipcKeyPairFactory: () => Promise.resolve(supervisorIpcKeyPair), }); diff --git a/packages/workflow-host/src/supervisor/park-notify.test.ts b/packages/workflow-host/src/supervisor/park-notify.test.ts index 891003213..c04319a19 100644 --- a/packages/workflow-host/src/supervisor/park-notify.test.ts +++ b/packages/workflow-host/src/supervisor/park-notify.test.ts @@ -36,6 +36,7 @@ import { createControlChannelSender, type ControlChannelSender, } from "../ipc/index"; +import { noGrantsOnRunStart } from "./step-grants.test-helpers"; const AGENT_ADDRESS = "run_park-agent@integration.example"; const DEPLOYMENT_ID = "park-dep"; @@ -122,13 +123,8 @@ describe("supervisor park.notify arm", () => { workflowRunRef: "refs/heads/main", anchorRunId: DEPLOYMENT_ID, deploymentMailAddress: AGENT_ADDRESS, - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: () => AGENT_ADDRESS, - deriveStepRepoId: () => ({ kind: "agent-state", id: DEPLOYMENT_ID }), - }), + onRunStart: noGrantsOnRunStart, + layoutSteps: () => Promise.resolve(["step-1"]), inboxPrimitives: createNoopInboxPrimitives(), ipcKeyPairFactory: () => Promise.resolve(supervisorIpcKeyPair), }); diff --git a/packages/workflow-host/src/supervisor/re-emit-parked-correlations.test.ts b/packages/workflow-host/src/supervisor/re-emit-parked-correlations.test.ts index 6b503b382..1fc1c6f67 100644 --- a/packages/workflow-host/src/supervisor/re-emit-parked-correlations.test.ts +++ b/packages/workflow-host/src/supervisor/re-emit-parked-correlations.test.ts @@ -41,6 +41,7 @@ import { type ControlPayload, } from "../ipc/index"; import { waitUntil } from "@intx/types/testing"; +import { noGrantsOnRunStart } from "./step-grants.test-helpers"; const AGENT_ADDRESS = "run_reemit-agent@integration.example"; const DEPLOYMENT_ID = "reemit-dep"; @@ -258,13 +259,8 @@ async function setup(opts: { workflowRunRef: "refs/heads/main", anchorRunId: DEPLOYMENT_ID, deploymentMailAddress: AGENT_ADDRESS, - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: () => AGENT_ADDRESS, - deriveStepRepoId: () => ({ kind: "agent-state", id: DEPLOYMENT_ID }), - }), + onRunStart: noGrantsOnRunStart, + layoutSteps: () => Promise.resolve(["step-1"]), inboxPrimitives: createNoopInboxPrimitives(), ipcKeyPairFactory: () => Promise.resolve(ipcKp), drainTimeoutMs: 200, @@ -529,13 +525,8 @@ describe("supervisor reEmitParkedCorrelations", () => { workflowRunRef: "refs/heads/main", anchorRunId: DEPLOYMENT_ID, deploymentMailAddress: AGENT_ADDRESS, - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: () => AGENT_ADDRESS, - deriveStepRepoId: () => ({ kind: "agent-state", id: DEPLOYMENT_ID }), - }), + onRunStart: noGrantsOnRunStart, + layoutSteps: () => Promise.resolve(["step-1"]), inboxPrimitives: createNoopInboxPrimitives(), }); diff --git a/packages/workflow-host/src/supervisor/recycle.test.ts b/packages/workflow-host/src/supervisor/recycle.test.ts index 58b7eb790..3c945a6e6 100644 --- a/packages/workflow-host/src/supervisor/recycle.test.ts +++ b/packages/workflow-host/src/supervisor/recycle.test.ts @@ -6,7 +6,7 @@ // into `createRecyclePolicy` for the policy-origin coverage. // // Strictly orthogonal to redeploy: every test below operates against -// the same fake deploy tree across the recycle. None of these tests +// the same fake definition across the recycle. None of these tests // re-seeds the workflow definition or mutates step credentials in a // way that would conflate the recycle path with a redeploy. @@ -21,7 +21,6 @@ import { generateKeyPair } from "@intx/crypto"; import { hexEncode } from "@intx/types"; import type { CredentialDelivery } from "@intx/types/credential-delivery"; import type { WorkflowProjectionDefinition } from "@intx/types/sidecar"; -import type { RepoId } from "@intx/hub-sessions"; import { createMemoryFrameStream, createMemoryNdjsonStream, @@ -50,12 +49,12 @@ import { createRecyclePolicy, type RecyclePolicyBounds } from "./recycle"; import { waitUntil } from "@intx/types/testing"; import { mergeCredentialDelivery } from "../child/credential-cell"; import { parseSpawnTimeEnv } from "../child/env-bootstrap"; -import { defaultStepRepoId, STEP_GRANTS_PATH } from "./credentials"; import { createControlChannelSender, ControlPayload, SignedEnvelope, } from "../ipc/index"; +import { seedStepGrants, seededOnRunStart } from "./step-grants.test-helpers"; function parseSignalDeliverRunIds(lines: readonly string[]): string[] { const ids: string[] = []; @@ -94,27 +93,6 @@ async function makeTempDir(prefix: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), prefix)); } -async function seedStepGrants( - baseDir: string, - repoId: RepoId, - grants: unknown[], -): Promise { - const dir = path.join(baseDir, repoId.kind, repoId.id); - await fs.mkdir(path.join(dir, "state"), { recursive: true }); - await fs.writeFile( - path.join(dir, STEP_GRANTS_PATH), - JSON.stringify({ grants }), - ); -} - -async function corruptStepGrants( - baseDir: string, - repoId: RepoId, -): Promise { - const dir = path.join(baseDir, repoId.kind, repoId.id); - await fs.writeFile(path.join(dir, STEP_GRANTS_PATH), "{ this is not json"); -} - type FakeChild = { pid: number; channelId: string | undefined; @@ -253,7 +231,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives { type Entry = { messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; rawMessage?: string; }; const state = new Map< @@ -282,7 +259,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives { const envelope: Entry = { messageId: args.messageId, receivedAt: args.receivedAt, - mailAuditRef: args.mailAuditRef, ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}), @@ -296,7 +272,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives { messageId: args.messageId, receivedAt: args.receivedAt, address: args.address, - mailAuditRef: args.mailAuditRef, ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}), @@ -324,7 +299,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives { messageId: envelope.messageId, receivedAt: envelope.receivedAt, address, - mailAuditRef: envelope.mailAuditRef, ...(envelope.rawMessage !== undefined ? { rawMessage: envelope.rawMessage } : {}), @@ -355,7 +329,6 @@ function createMemoryInboxPrimitives(): InboxPrimitives { address: args.address, runId: args.runId, consumedAt: args.consumedAt, - mailAuditRef: envelope.mailAuditRef, }, watermark: 0, prunedMessageIds: [], @@ -408,13 +381,8 @@ async function buildBindings(opts: { workflowRunRef: "refs/heads/main", anchorRunId: "run_deployment-x", deploymentMailAddress: "run_deployment-x@example.com", - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }), + onRunStart: seededOnRunStart(opts.baseDir), + layoutSteps: () => Promise.resolve(["step-1"]), ipcKeyPairFactory: () => Promise.resolve(opts.ipcKeypair), inboxPrimitives: opts.inboxPrimitives ?? createMemoryInboxPrimitives(), ...(opts.recyclePolicy !== undefined @@ -436,11 +404,9 @@ async function spawnSupervisor(opts: { onRunStart?: WorkflowSupervisorBindings["onRunStart"]; credentialDelivery?: WorkflowSupervisorBindings["credentialDelivery"]; }) { - await seedStepGrants( - opts.baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(opts.baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir: opts.baseDir, spawner: opts.tracker.spawner, @@ -518,11 +484,9 @@ describe("supervisor spawn: failure cleanup", () => { const mailBus = createMockMailBus(); // sigtermExits so the timeout path's kill settles the fake child. const tracker = createSpawnTracker({ sigtermExits: true }); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir, spawner: tracker.spawner, @@ -585,11 +549,9 @@ describe("supervisor spawn: failure cleanup", () => { }; // sigtermExits so the ready-timeout reap settles the fake child. const tracker = createSpawnTracker({ sigtermExits: true }); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir, spawner: tracker.spawner, @@ -633,11 +595,9 @@ describe("supervisor spawn: dynamic env", () => { const ipcKeypair = await generateKeyPair(); const mailBus = createMockMailBus(); const tracker = createSpawnTracker({}); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); let hostRevised = "before-rotation"; const bindings = await buildBindings({ baseDir, @@ -850,11 +810,9 @@ describe("supervisor recycle: respawn handshake bound", () => { // respawn is exactly the child that ignores SIGTERM, and the reap must // still guarantee it dies. const tracker = createSpawnTracker({ sigtermExits: false }); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir, spawner: tracker.spawner, @@ -923,11 +881,9 @@ describe("supervisor recycle: respawn handshake bound", () => { // path proves the child is killed even though the control channel end // does not itself terminate the process. const tracker = createSpawnTracker({ sigtermExits: true }); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir, spawner: tracker.spawner, @@ -976,11 +932,9 @@ describe("supervisor recycle: respawn handshake bound", () => { const baseDir = await makeTempDir("recycle-definition-mismatch-"); const ipcKeypair = await generateKeyPair(); const tracker = createSpawnTracker({ sigtermExits: true }); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir, spawner: tracker.spawner, @@ -1186,11 +1140,9 @@ describe("supervisor recycle: policy-initiated (max-uptime trip)", () => { const ipcKeypair = await generateKeyPair(); const mailBus = createMockMailBus(); const tracker = createSpawnTracker({}); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); let nowMs = 1_700_000_000_000; let trigger: ((reason: string) => Promise) | null = null; @@ -1324,11 +1276,9 @@ describe("supervisor recycle: terminal-event broadcaster cohort", () => { const mailBus = createMockMailBus(); const tracker = createSpawnTracker({}); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const baseBindings = await buildBindings({ baseDir, spawner: tracker.spawner, @@ -1417,11 +1367,9 @@ describe("supervisor recycle: drain-side processing replay", () => { return { commitSha: "memory", replayedKeys: [] }; }, }; - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir, spawner: tracker.spawner, @@ -1627,11 +1575,9 @@ describe("supervisor recycle: shutdown owns the retiring dispatch loop", () => { const consuming = Promise.withResolvers(); const releaseConsumption = Promise.withResolvers(); let consumed = false; - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir, spawner: tracker.spawner, @@ -1782,53 +1728,6 @@ describe("supervisor recycle: external drain phase guard", () => { }); }); -describe("supervisor recycle: respawn credentials-read failure", () => { - test("a respawn whose credentials re-read throws reaps the freshly-spawned child", async () => { - const baseDir = await makeTempDir("recycle-credleak-"); - const ipcKeypair = await generateKeyPair(); - const mailBus = createMockMailBus(); - // sigtermExits so the reap's SIGTERM settles the fake child. - const tracker = createSpawnTracker({ sigtermExits: true }); - const { supervisor } = await spawnSupervisor({ - baseDir, - tracker, - mailBus, - ipcKeypair, - }); - expect(tracker.totalSpawns).toBe(1); - - // Corrupt the step grants so the recycle's pre-handshake - // assembleCredentialsSnapshot re-read throws -- the recycle doubles - // as the grant-refresh path, so a malformed grants file is exactly - // the read that fails here. - await corruptStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - ); - - let caught: unknown; - try { - await supervisor.recycle({ reason: "credentials-leak-regression" }); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(Error); - expect(caught instanceof Error && caught.message).toMatch(/not valid JSON/); - - // The respawn spawned the new child before the credentials read - // threw. Its catch must reap that child rather than leak it: the - // supervisor's recycle-failure teardown reaps only the PRIOR cohort - // (state.handle during `recycling`), so a child that failed before - // installation is invisible to it. - expect(tracker.totalSpawns).toBe(2); - const newChild = tracker.children[1]; - if (newChild === undefined) { - throw new Error("tracker.children[1] missing"); - } - expect(newChild.killSignals.length).toBeGreaterThan(0); - }); -}); - describe("supervisor shutdown: teardown robustness", () => { test("shutdown still kills the child and reaches stopped when mail unsubscribe throws", async () => { const baseDir = await makeTempDir("shutdown-unsub-throw-"); @@ -1910,7 +1809,6 @@ describe("supervisor recycle: the new child's authority", () => { steps: [ { stepId: "step-1", - address: "run_deployment-x-step-1@example.com", grants: [{ id: "run-grant" }], contentHash: "run-hash", }, @@ -1927,7 +1825,6 @@ describe("supervisor recycle: the new child's authority", () => { steps: [ { stepId: "step-1", - address: "run_deployment-x-step-1@example.com", grants: [{ id: `grant-${contentHash}` }], contentHash, }, @@ -1963,11 +1860,9 @@ describe("supervisor recycle: the new child's authority", () => { ipcKeypair: { privateKey: Uint8Array; publicKey: Uint8Array }; onRunStart: NonNullable; }) { - await seedStepGrants( - opts.baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(opts.baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir: opts.baseDir, spawner: opts.tracker.spawner, @@ -2448,11 +2343,9 @@ describe("supervisor recycle: the new child's authority", () => { const baseDir = await makeTempDir("recycle-definition-wait-shutdown-"); const ipcKeypair = await generateKeyPair(); const tracker = createSpawnTracker({ sigtermExits: true }); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const bindings = await buildBindings({ baseDir, spawner: tracker.spawner, diff --git a/packages/workflow-host/src/supervisor/recycle.ts b/packages/workflow-host/src/supervisor/recycle.ts index a729dbd61..78d2a07e5 100644 --- a/packages/workflow-host/src/supervisor/recycle.ts +++ b/packages/workflow-host/src/supervisor/recycle.ts @@ -2,15 +2,15 @@ // RECYCLE -- workflow-process supervisor library code // ============================================================= // -// Recycle is the supervisor's "same deploy tree, fresh process" path. +// Recycle is the supervisor's "same definition, fresh process" path. // It tears the existing workflow-process child down and stands a new -// one up against the SAME deploy tree (same materialized source -// closure, same per-step credential repos). A deployment keeps its -// deploy tree for its life; a different tree means a new deployment. +// one up against the SAME definition (same materialized source +// closure). A deployment keeps its definition for its life; a different +// definition means a new deployment. // -// Recycle does not refetch the deploy tree, does not consult an +// Recycle does not refetch the source closure, does not consult an // updated workflow definition, does not re-resolve agents. The recycle -// module must never grow a "maybe also refetch the deploy tree" mode -- +// module must never grow a "maybe also refetch the definition" mode -- // that would let a recycle silently change what a deployment runs. // // Six-step sequence (locked): @@ -36,8 +36,7 @@ // here; the recycle path does not reach into Node primitives. // 3. `respawn` -- mint a new 16-byte hex channelId (the same shape // the initial spawn uses, per `generateChannelId`), generate a -// fresh 32-byte HMAC key, re-read per-step credentials via the -// injected `RepoStore`, and spawn a new Bun child via the same +// fresh 32-byte HMAC key, and spawn a new Bun child via the same // subprocess-spawner binding. The new IPC anchors flow through // spawn-time env exactly as the initial spawn's anchors did. // 4. `self-discover` -- the new child runs its existing self- @@ -97,12 +96,6 @@ import { receiveControlChannel } from "../ipc/control-receiver"; import { receiveEventChannel, type EventPayload } from "../ipc/event-channel"; import type { AuthoritySetData, ControlPayload } from "../ipc/control-payloads"; -import { - assembleCredentialsSnapshot, - type CredentialsSnapshot, - stepLayoutSnapshotOpts, - type StepLayout, -} from "./credentials"; import { waitForDefinition, waitForReady } from "./child-handshake"; import type { SubprocessHandle, WorkflowSupervisorBindings } from "./types"; import { buildChildSpawnEnv } from "./spawn-env"; @@ -175,13 +168,8 @@ export interface ChildWiring { * not re-register). */ export interface RecycleContext { - /** The supervisor's full bindings, reused on respawn for credentials and spawn. */ + /** The supervisor's full bindings, reused on respawn. */ readonly bindings: WorkflowSupervisorBindings; - /** - * The deployment's step layout, fixed when its first child reported its - * definition; the credentials snapshot is re-assembled from it. - */ - readonly layout: StepLayout; /** * Hub-approved definition hash, carried on the respawn env and checked * against the new child's reported definition (unchanged across recycle). @@ -212,23 +200,21 @@ export interface RecycleContext { */ readonly abortPriorCohort: () => void; /** - * The new child's `authority.set` frame data: the run's grants snapshot - * (`fallback` when the supervisor has no per-run grants source) and the - * supervisor's credential mirror, with the authority revision it was read + * The new child's `authority.set` frame data: the run's grants snapshot and + * the supervisor's credential mirror, with the authority revision it was read * at. Read from the supervisor at the call, never copied when the context is * built, so the frame carries the state at respawn time. */ - readonly buildAuthority: ( - fallback: CredentialsSnapshot, - ) => Promise<{ data: AuthoritySetData; revision: number }>; + readonly buildAuthority: () => Promise<{ + data: AuthoritySetData; + revision: number; + }>; /** * Onward sink the supervisor uses to install the new child wiring - * once the freshly-spawned child has emitted `ready` and the - * credentialsSnapshot has been re-assembled. + * once the freshly-spawned child has emitted `ready`. */ readonly installNewChild: (next: { wiring: ChildWiring; - credentialsSnapshot: CredentialsSnapshot; /** * The authority revision of the `authority.set` frame the new child got. * The supervisor sends the frame again when its revision moved on while @@ -345,8 +331,8 @@ export async function triggerRecycle( // Step 3: respawn. Fresh channelId, fresh HMAC key, fresh Ed25519 // IPC keypair. The new child gets the run's current grants and // credential material in its first frame, before it resumes any run. - // The deploy tree (the materialized source closure, the - // workflow-asset repo, the agent-state repos) is UNCHANGED. + // The materialized source closure and the workflow-asset repo are + // UNCHANGED. const channelId = generateChannelId(); const hmacKey = generateHmacKey(); const ipcKeypair = await ( @@ -391,7 +377,7 @@ export async function triggerRecycle( ); // Attach a benign handler at creation, before the fold below consumes // the rejection: `definitionPromise` is created here but not raced until - // after `assembleCredentialsSnapshot` awaits. A child that exits during + // after the authority read awaits. A child that exits during // that window rejects `definitionPromise` with no handler yet attached -- // an unhandled rejection across the await boundary. Mirrors the spawn // path's identical guard. Attaching `.catch` here and `.then` at the @@ -407,41 +393,13 @@ export async function triggerRecycle( const eventPump = pumpEvents(eventIter, ctx.onInferenceEvent); // The child handshake below is deadline-bounded and needs these timer - // bindings; the pre-handshake credentials-read reap needs them too, so - // derive them before that read. + // bindings. const setTimer = ctx.setTimer ?? defaultSetTimer; const clearTimer = ctx.clearTimer ?? defaultClearTimer; - // Re-read the spawn-time per-step credentials from the `agent-state` - // repos, and read the run's authority for the new child's first frame. - // - // The credentials read can reject; the authority read cannot, because a - // failed grants read gives the child a null snapshot. The new child is - // already spawned and wired but not yet installed on `state`, so the - // supervisor's recycle-failure teardown (which reaps the PRIOR cohort) - // cannot see it; reap it here on failure or it leaks. The spawn path - // kills its child on the same failure. The try wraps only the awaited - // reads: the handle and pumps it reaps are all constructed above, so - // the reap always has live handles. - let credentialsSnapshot: CredentialsSnapshot; - let authority: { data: AuthoritySetData; revision: number }; - try { - credentialsSnapshot = await assembleCredentialsSnapshot({ - repoStore: ctx.bindings.repoStore, - principal: ctx.bindings.readPrincipal, - anchorRunId: ctx.bindings.anchorRunId, - ...stepLayoutSnapshotOpts(ctx.layout), - }); - authority = await ctx.buildAuthority(credentialsSnapshot); - } catch (cause) { - await reapUnreadyChild(handle, eventPump, controlIncoming, { - killTimeoutMs, - setTimer, - clearTimer, - phase: "credentials read failure", - }); - throw cause; - } + // Read the run's authority for the new child's first frame. The read cannot + // reject: a failed grants read gives the child a null snapshot. + const authority = await ctx.buildAuthority(); // Steps 4 + 5: self-discover + resume. These run inside the child // before it emits `ready`; the supervisor waits, but bounded -- a child @@ -452,8 +410,8 @@ export async function triggerRecycle( // its first spawn did: fold the outcome into a value, race a // resolve-only deadline, clear the timer on every path. // - // NOTE: the credentials and authority reads above are substrate reads - // that sit OUTSIDE these deadlines; a wedged substrate is a + // NOTE: the authority read above is a substrate read that sits OUTSIDE + // these deadlines; a wedged substrate is a // supervisor-side fault bounded at its own layer, not by overloading the // handshake timer. The respawn is deadline-bounded on the child // handshake, not on that read. @@ -521,7 +479,6 @@ export async function triggerRecycle( }; ctx.installNewChild({ wiring: newWiring, - credentialsSnapshot, controlIncoming, authorityRevision: authority.revision, }); diff --git a/packages/workflow-host/src/supervisor/spawn-replay-fifo.test.ts b/packages/workflow-host/src/supervisor/spawn-replay-fifo.test.ts index 007fcfb26..b3599bb86 100644 --- a/packages/workflow-host/src/supervisor/spawn-replay-fifo.test.ts +++ b/packages/workflow-host/src/supervisor/spawn-replay-fifo.test.ts @@ -54,31 +54,17 @@ import { type SubprocessSpawner, type WorkflowSupervisorBindings, } from "./index"; -import { defaultStepRepoId, STEP_GRANTS_PATH } from "./credentials"; import { createControlChannelSender } from "../ipc/index"; import { waitUntil } from "@intx/types/testing"; +import { seedStepGrants, seededOnRunStart } from "./step-grants.test-helpers"; async function makeTempDir(prefix: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), prefix)); } -async function seedStepGrants( - baseDir: string, - repoId: RepoId, - grants: unknown[], -): Promise { - const dir = path.join(baseDir, repoId.kind, repoId.id); - await fs.mkdir(path.join(dir, "state"), { recursive: true }); - await fs.writeFile( - path.join(dir, STEP_GRANTS_PATH), - JSON.stringify({ grants }), - ); -} - type MemoryEntry = { messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; rawMessage?: string; }; @@ -135,7 +121,6 @@ function createGatedInboxPrimitives(): { const envelope: MemoryEntry = { messageId: args.messageId, receivedAt: args.receivedAt, - mailAuditRef: args.mailAuditRef, ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}), @@ -149,7 +134,6 @@ function createGatedInboxPrimitives(): { messageId: args.messageId, receivedAt: args.receivedAt, address: args.address, - mailAuditRef: args.mailAuditRef, ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}), @@ -177,7 +161,6 @@ function createGatedInboxPrimitives(): { messageId: envelope.messageId, receivedAt: envelope.receivedAt, address, - mailAuditRef: envelope.mailAuditRef, ...(envelope.rawMessage !== undefined ? { rawMessage: envelope.rawMessage } : {}), @@ -208,7 +191,6 @@ function createGatedInboxPrimitives(): { address: args.address, runId: args.runId, consumedAt: args.consumedAt, - mailAuditRef: envelope.mailAuditRef, }, watermark: 0, prunedMessageIds: [], @@ -274,13 +256,10 @@ async function seedOrphanedProcessing( messageId: string, receivedAt: number, ): Promise { - // The seed only needs the messageId; the supervisor's primitives - // do not validate the auditRef beyond passing it through. await primitives.enqueueInbox(store, { kind: "supervisor" }, repoId, { address, messageId, receivedAt, - mailAuditRef: { store: "memory", path: `audit/${messageId}` }, // Decodable mail bytes: the dispatch loop decodes and commits them // before forwarding the trigger.fire for the recovered orphan. rawMessage: base64Encode( @@ -333,11 +312,9 @@ async function boot(opts: { prefix: string }): Promise< } > { const baseDir = await makeTempDir(opts.prefix); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); @@ -394,13 +371,8 @@ async function boot(opts: { prefix: string }): Promise< workflowRunRef: "refs/heads/main", anchorRunId: "run_deployment-x", deploymentMailAddress, - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }), + onRunStart: seededOnRunStart(baseDir), + layoutSteps: () => Promise.resolve(["step-1"]), ipcKeyPairFactory: () => Promise.resolve(supervisorIpcKeyPair), inboxPrimitives: gatedInbox.primitives, }; diff --git a/packages/workflow-host/src/supervisor/stale-cohort-routing.test.ts b/packages/workflow-host/src/supervisor/stale-cohort-routing.test.ts index 3fb6e47e3..2491d7601 100644 --- a/packages/workflow-host/src/supervisor/stale-cohort-routing.test.ts +++ b/packages/workflow-host/src/supervisor/stale-cohort-routing.test.ts @@ -54,8 +54,8 @@ import { type SubprocessSpawner, type WorkflowSupervisorBindings, } from "./index"; -import { defaultStepRepoId, STEP_GRANTS_PATH } from "./credentials"; import { createControlChannelSender } from "../ipc/index"; +import { seedStepGrants, seededOnRunStart } from "./step-grants.test-helpers"; async function makeTempDir(prefix: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -82,19 +82,6 @@ function createStubRepoStore(baseDir: string): RepoStore { }); } -async function seedStepGrants( - baseDir: string, - repoId: RepoId, - grants: unknown[], -): Promise { - const dir = path.join(baseDir, repoId.kind, repoId.id); - await fs.mkdir(path.join(dir, "state"), { recursive: true }); - await fs.writeFile( - path.join(dir, STEP_GRANTS_PATH), - JSON.stringify({ grants }), - ); -} - function createMemoryInbox(): InboxPrimitives & { seedProcessing: ( address: string, @@ -105,7 +92,6 @@ function createMemoryInbox(): InboxPrimitives & { type E = { messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; rawMessage?: string; }; const st = new Map< @@ -134,7 +120,6 @@ function createMemoryInbox(): InboxPrimitives & { const env: E = { messageId: args.messageId, receivedAt: args.receivedAt, - mailAuditRef: args.mailAuditRef, ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}), @@ -148,7 +133,6 @@ function createMemoryInbox(): InboxPrimitives & { messageId: args.messageId, receivedAt: args.receivedAt, address: args.address, - mailAuditRef: args.mailAuditRef, }, }; }, @@ -169,7 +153,6 @@ function createMemoryInbox(): InboxPrimitives & { messageId: env.messageId, receivedAt: env.receivedAt, address, - mailAuditRef: env.mailAuditRef, ...(env.rawMessage !== undefined ? { rawMessage: env.rawMessage } : {}), @@ -190,7 +173,6 @@ function createMemoryInbox(): InboxPrimitives & { address: args.address, runId: args.runId, consumedAt: args.consumedAt, - mailAuditRef: value.mailAuditRef, }, watermark: 0, prunedMessageIds: [], @@ -213,7 +195,6 @@ function createMemoryInbox(): InboxPrimitives & { s.processing.set(key, { messageId, receivedAt, - mailAuditRef: { store: "memory", path: messageId }, rawMessage: base64Encode( new TextEncoder().encode(`Message-ID: ${messageId}\r\n\r\nbody`), ), @@ -316,11 +297,9 @@ describe("H-S2 stale-cohort routing pinch-point", () => { const tracker = createSpawnTracker(); const inbox = createMemoryInbox(); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const consumedRecord: string[] = []; // The markConsumed callback is the event; the poll it replaces re-read @@ -350,13 +329,8 @@ describe("H-S2 stale-cohort routing pinch-point", () => { workflowRunRef: "refs/heads/main", anchorRunId: "run_deployment-x", deploymentMailAddress: "run_deployment-x@example.com", - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }), + onRunStart: seededOnRunStart(baseDir), + layoutSteps: () => Promise.resolve(["step-1"]), ipcKeyPairFactory: () => Promise.resolve(ipcKp), inboxPrimitives: wrappedInbox, }; diff --git a/packages/workflow-host/src/supervisor/step-grants.test-helpers.ts b/packages/workflow-host/src/supervisor/step-grants.test-helpers.ts new file mode 100644 index 000000000..e355e380a --- /dev/null +++ b/packages/workflow-host/src/supervisor/step-grants.test-helpers.ts @@ -0,0 +1,81 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + hashGrants, + isErrnoNotFound, + type CredentialsSnapshot, +} from "./credentials"; +import type { WorkflowSupervisorBindings } from "./types"; + +function stepGrantsPath(baseDir: string, stepId: string): string { + return path.join(baseDir, "seeded-step-grants", `${stepId}.json`); +} + +/** Give a step the grants `seededSnapshot` then reads for it. */ +export async function seedStepGrants( + baseDir: string, + stepId: string, + grants: unknown[], +): Promise { + const file = stepGrantsPath(baseDir, stepId); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, JSON.stringify(grants)); +} + +async function readSeededGrants( + baseDir: string, + stepId: string, +): Promise { + let raw: string; + try { + raw = await fs.readFile(stepGrantsPath(baseDir, stepId), "utf8"); + } catch (cause) { + if (isErrnoNotFound(cause)) return []; + throw cause; + } + const grants: unknown = JSON.parse(raw); + if (!Array.isArray(grants)) { + throw new Error(`seeded grants of ${stepId} are not an array`); + } + return grants; +} + +/** The snapshot of the grants a test seeded, none for an unseeded step. */ +export async function seededSnapshot( + baseDir: string, + opts: { + stepOrder: readonly string[]; + }, +): Promise { + const steps = []; + for (const stepId of opts.stepOrder) { + const grants = await readSeededGrants(baseDir, stepId); + steps.push({ + stepId, + grants, + contentHash: await hashGrants(grants), + }); + } + return { steps }; +} + +/** A per-run grants source over the grants a test seeded. */ +export function seededOnRunStart( + baseDir: string, +): WorkflowSupervisorBindings["onRunStart"] { + return ({ stepIds }) => seededSnapshot(baseDir, { stepOrder: stepIds }); +} + +/** A per-run grants source that gives every step no grants. */ +export const noGrantsOnRunStart: WorkflowSupervisorBindings["onRunStart"] = + async ({ stepIds }) => { + const contentHash = await hashGrants([]); + return { + steps: stepIds.map((stepId) => ({ + stepId, + grants: [], + contentHash, + })), + }; + }; diff --git a/packages/workflow-host/src/supervisor/substrate-write.test.ts b/packages/workflow-host/src/supervisor/substrate-write.test.ts index b8c6006c0..3408cebe7 100644 --- a/packages/workflow-host/src/supervisor/substrate-write.test.ts +++ b/packages/workflow-host/src/supervisor/substrate-write.test.ts @@ -69,8 +69,8 @@ import { type SubprocessSpawner, type WorkflowSupervisorBindings, } from "./index"; -import { defaultStepRepoId, STEP_GRANTS_PATH } from "./credentials"; import { createControlChannelSender } from "../ipc/index"; +import { seedStepGrants, seededOnRunStart } from "./step-grants.test-helpers"; async function makeTempDir(prefix: string): Promise { return fs.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -186,7 +186,6 @@ function createStubRepoStore(opts: { type MemoryEntry = { messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; }; function createMemoryInboxPrimitives(opts: { @@ -233,7 +232,6 @@ function createMemoryInboxPrimitives(opts: { const envelope: MemoryEntry = { messageId: args.messageId, receivedAt: args.receivedAt, - mailAuditRef: args.mailAuditRef, }; s.inbox.set(k, envelope); return { @@ -244,7 +242,6 @@ function createMemoryInboxPrimitives(opts: { messageId: args.messageId, receivedAt: args.receivedAt, address: args.address, - mailAuditRef: args.mailAuditRef, }, }; }, @@ -269,7 +266,6 @@ function createMemoryInboxPrimitives(opts: { messageId: envelope.messageId, receivedAt: envelope.receivedAt, address, - mailAuditRef: envelope.mailAuditRef, }, }; }, @@ -300,7 +296,6 @@ function createMemoryInboxPrimitives(opts: { address: args.address, runId: args.runId, consumedAt: args.consumedAt, - mailAuditRef: envelope.mailAuditRef, }, watermark: 0, prunedMessageIds: [], @@ -319,19 +314,6 @@ function createMemoryInboxPrimitives(opts: { }; } -async function seedStepGrants( - baseDir: string, - repoId: RepoId, - grants: unknown[], -): Promise { - const dir = path.join(baseDir, repoId.kind, repoId.id); - await fs.mkdir(path.join(dir, "state"), { recursive: true }); - await fs.writeFile( - path.join(dir, STEP_GRANTS_PATH), - JSON.stringify({ grants }), - ); -} - type SupervisorHarness = { supervisor: ReturnType; channelId: string; @@ -378,11 +360,9 @@ async function bootSupervisorToReady( opts: BootSupervisorOpts, ): Promise { const baseDir = await makeTempDir(opts.prefix); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); @@ -443,13 +423,8 @@ async function bootSupervisorToReady( workflowRunRef: "refs/heads/main", anchorRunId: "deployment-x", deploymentMailAddress: "deployment-x@example.com", - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }), + onRunStart: seededOnRunStart(baseDir), + layoutSteps: () => Promise.resolve(["step-1"]), ipcKeyPairFactory: () => Promise.resolve(supervisorIpcKeyPair), inboxPrimitives, ...(opts.onSelfTerminate !== undefined @@ -592,7 +567,7 @@ describe("substrate-write authz: supervisor overrides the child's claim", () => // The supervisor's handler explicitly rejects any write whose // repoId.kind is not `workflow-run` -- the only kind the child's // proxy is supposed to forward through this IPC. A child that - // smuggled a `kind: "agent-state"` request through would be + // smuggled a `kind: "skill"` request through would be // attempting to write outside the supervisor's audit boundary; // the handler responds with `{ ok: false }` and never reaches the // underlying substrate. @@ -608,7 +583,7 @@ describe("substrate-write authz: supervisor overrides the child's claim", () => type: "substrate.write.request", data: { requestId, - repoId: { kind: "agent-state", id: "deployment-x" }, + repoId: { kind: "skill", id: "deployment-x" }, ref: "refs/heads/main", preservePrefix: "state/x/", message: "wrong-kind write", diff --git a/packages/workflow-host/src/supervisor/supervisor.test.ts b/packages/workflow-host/src/supervisor/supervisor.test.ts index 97e4fc4fb..e7b003b59 100644 --- a/packages/workflow-host/src/supervisor/supervisor.test.ts +++ b/packages/workflow-host/src/supervisor/supervisor.test.ts @@ -57,12 +57,6 @@ import { type SignedPayload, type WorkflowSupervisorBindings, } from "./index"; -import { - assembleCredentialsSnapshot, - defaultStepRepoId, - hashGrants, - STEP_GRANTS_PATH, -} from "./credentials"; import { commitCancelRequested } from "./cancel-signing"; import { createControlChannelSender, @@ -73,6 +67,11 @@ import { generateHmacKey, generateChannelId, } from "../ipc/index"; +import { + seedStepGrants, + seededOnRunStart, + seededSnapshot, +} from "./step-grants.test-helpers"; /** * The frame `type`s the supervisor writes to the child control stream, in @@ -527,7 +526,6 @@ function enqueuedOutcome(args: { address: string; messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; }): EnqueueInboxOutcome { return { outcome: "enqueued", @@ -537,7 +535,6 @@ function enqueuedOutcome(args: { messageId: args.messageId, receivedAt: args.receivedAt, address: args.address, - mailAuditRef: args.mailAuditRef, }, }; } @@ -778,7 +775,6 @@ function createStubRepoStore(opts: { type MemoryInboxEntry = { messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; rawMessage?: string; rejection?: { code: string; message: string }; }; @@ -843,7 +839,6 @@ function createMemoryInboxPrimitives(): MemoryInboxPrimitives { const envelope: MemoryInboxEntry = { messageId: args.messageId, receivedAt: args.receivedAt, - mailAuditRef: args.mailAuditRef, ...(args.rawMessage !== undefined ? { rawMessage: args.rawMessage } : {}), @@ -858,7 +853,6 @@ function createMemoryInboxPrimitives(): MemoryInboxPrimitives { messageId: args.messageId, receivedAt: args.receivedAt, address: args.address, - mailAuditRef: args.mailAuditRef, }, }; }, @@ -885,7 +879,6 @@ function createMemoryInboxPrimitives(): MemoryInboxPrimitives { messageId: envelope.messageId, receivedAt: envelope.receivedAt, address, - mailAuditRef: envelope.mailAuditRef, ...(envelope.rawMessage !== undefined ? { rawMessage: envelope.rawMessage } : {}), @@ -924,7 +917,6 @@ function createMemoryInboxPrimitives(): MemoryInboxPrimitives { address: args.address, runId: args.runId, consumedAt: args.consumedAt, - mailAuditRef: envelope.mailAuditRef, ...(args.rejection !== undefined ? { rejection: args.rejection } : {}), @@ -999,30 +991,12 @@ async function buildBindings(opts: { workflowRunRef: "refs/heads/main", anchorRunId: "run_deployment-x", deploymentMailAddress: "run_deployment-x@example.com", - readPrincipal: { kind: "supervisor" }, - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }), + onRunStart: seededOnRunStart(opts.baseDir), + layoutSteps: () => Promise.resolve(["step-1"]), inboxPrimitives: opts.inboxPrimitives ?? createMemoryInboxPrimitives(), }; } -async function seedStepGrants( - baseDir: string, - repoId: RepoId, - grants: unknown[], -): Promise { - const dir = path.join(baseDir, repoId.kind, repoId.id); - await fs.mkdir(path.join(dir, "state"), { recursive: true }); - await fs.writeFile( - path.join(dir, STEP_GRANTS_PATH), - JSON.stringify({ grants }), - ); -} - const supervisors = createSupervisorReaper(); afterEach(supervisors.reap); @@ -1047,16 +1021,13 @@ describe("createWorkflowSupervisor", () => { expect(typeof supervisor.shutdown).toBe("function"); expect(typeof supervisor.drain).toBe("function"); expect(typeof supervisor.recycle).toBe("function"); - expect(supervisor.getCredentialsSnapshot()).toBeNull(); }); - test("spawn completes the IPC handshake, registers mail, and pushes credentials", async () => { + test("spawn completes the IPC handshake and registers mail", async () => { const baseDir = await makeTempDir("supervisor-spawn-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); // Deterministic IPC keypairs so the test's "child" side can sign // a `ready` frame the supervisor accepts. Two keypairs ride per @@ -1180,12 +1151,7 @@ describe("createWorkflowSupervisor", () => { expect(observedEnv).not.toHaveProperty("HOST_PRIVATE_KEY"); expect(result.pid).toBe(4321); expect(result.channelId).toBe(channelId); - expect(result.credentialsSnapshot.steps).toHaveLength(1); - expect(result.credentialsSnapshot.steps[0]?.address).toBe( - "run_deployment-x-step-1@example.com", - ); expect(mailBus.registered()).toContain("run_deployment-x@example.com"); - expect(supervisor.getCredentialsSnapshot()).not.toBeNull(); // The first buffered mail fires the stable top-level run. The FIFO // claim-check pipeline holds the second until that run terminates, then @@ -2408,11 +2374,9 @@ describe("createWorkflowSupervisor", () => { test("dispatch pushes a per-run grants-updated before the run's trigger.fire", async () => { const baseDir = await makeTempDir("supervisor-barrier-ok-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); // The per-run sink reads the step's grants exactly as `spawn` does, but // per run rather than once per spawn. A new child gets its grants in the @@ -2426,13 +2390,8 @@ describe("createWorkflowSupervisor", () => { args, ) => { runStartCalls.push(args); - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }; @@ -2474,7 +2433,7 @@ describe("createWorkflowSupervisor", () => { const call = { runId: firedRunId, anchorRunId: "run_deployment-x", - layout: expect.objectContaining({ stepIds: ["step-1"] }), + stepIds: ["step-1"], }; expect(runStartCalls).toEqual([call, call]); @@ -2491,7 +2450,6 @@ describe("createWorkflowSupervisor", () => { steps: [ { stepId: "step-1", - address: "run_deployment-x-step-1@example.com", grants: [{ id: "run-grant" }], contentHash: "run-hash", }, @@ -2536,7 +2494,7 @@ describe("createWorkflowSupervisor", () => { { runId: "run_deployment-x", anchorRunId: "run_deployment-x", - layout: expect.objectContaining({ stepIds: ["step-1"] }), + stepIds: ["step-1"], }, ]); // A new child gets no separate grants or credentials push. @@ -2574,19 +2532,12 @@ describe("createWorkflowSupervisor", () => { test("deliverSignal refreshes grants immediately before the signal frame", async () => { const baseDir = await makeTempDir("supervisor-signal-grants-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const onRunStart: WorkflowSupervisorBindings["onRunStart"] = async () => - assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); const wired = await spawnWithRunStart({ baseDir, onRunStart }); @@ -2613,19 +2564,12 @@ describe("createWorkflowSupervisor", () => { test("deliverGrants skips without throwing when the child is not live", async () => { const baseDir = await makeTempDir("supervisor-deliver-grants-skip-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const onRunStart: WorkflowSupervisorBindings["onRunStart"] = async () => - assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); const wired = await spawnWithRunStart({ baseDir, onRunStart }); await wired.supervisor.shutdown(); @@ -2639,19 +2583,12 @@ describe("createWorkflowSupervisor", () => { test("the barrier pushes credentials-updated before trigger.fire when the deployment has credentials", async () => { const baseDir = await makeTempDir("supervisor-barrier-creds-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const onRunStart: WorkflowSupervisorBindings["onRunStart"] = async () => - assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); const delivery = { bindings: [ @@ -2707,19 +2644,12 @@ describe("createWorkflowSupervisor", () => { // supervisor mirrors the live delivery, so the barrier re-asserts the // post-revoke set, not the deploy-time one. const baseDir = await makeTempDir("supervisor-revoke-durable-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const onRunStart: WorkflowSupervisorBindings["onRunStart"] = async () => - assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); // The deploy delivery carries two credentials; cred_a also has a binding. const delivery = { @@ -2816,11 +2746,9 @@ describe("createWorkflowSupervisor", () => { // every enqueue disposition onto that boundary. test("durable receipt resolves for a fresh enqueue and for an already-present message", async () => { const baseDir = await makeTempDir("supervisor-ack-present-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); let mode: "enqueued" | "already-present" = "enqueued"; const inboxPrimitives = inboxPrimitivesWithEnqueue( async (_store, _principal, _repoId, args) => @@ -2853,16 +2781,13 @@ describe("createWorkflowSupervisor", () => { test("durable receipt rejects for a transient failure and a stale refusal, and self-heals on redelivery", async () => { const baseDir = await makeTempDir("supervisor-withhold-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); let onEnqueue: (args: { address: string; messageId: string; receivedAt: number; - mailAuditRef: { store: string; path: string }; }) => EnqueueInboxOutcome = () => { throw new Error("disk exploded"); }; @@ -2908,11 +2833,9 @@ describe("createWorkflowSupervisor", () => { test("durable receipt rejects when the supervisor is not accepting mail (phase-drop)", async () => { const baseDir = await makeTempDir("supervisor-phase-drop-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); // enqueue would resolve if it were reached; the phase gate must reject // first, without touching the inbox. const inboxPrimitives = inboxPrimitivesWithEnqueue( @@ -3141,15 +3064,12 @@ describe("createWorkflowSupervisor", () => { async function makePreRegistrationFailureHarness(opts: { failSubscribe?: boolean; failLayoutSteps?: boolean; - failDeriveStepAddress?: boolean; definition?: DefinitionResult; }) { const baseDir = await makeTempDir("supervisor-spawn-leak-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const supervisorToChild = createMemoryNdjsonStream(); const childToSupervisor = createMemoryNdjsonStream(); @@ -3208,17 +3128,6 @@ describe("createWorkflowSupervisor", () => { Promise.reject(new Error("injected layoutSteps failure")), } : {}), - ...(opts.failDeriveStepAddress === true - ? { - layoutSteps: () => - Promise.resolve({ - stepIds: ["step-1"], - deriveStepAddress: () => { - throw new Error("injected deriveStepAddress failure"); - }, - }), - } - : {}), }; const supervisor = supervisors.track(createWorkflowSupervisor(bindings)); return { @@ -3267,17 +3176,6 @@ describe("createWorkflowSupervisor", () => { expect(h.killSignals.length).toBeGreaterThan(0); }); - test("a spawn whose credentials assembly throws kills the child", async () => { - const h = await makePreRegistrationFailureHarness({ - failDeriveStepAddress: true, - }); - await expect(h.spawn()).rejects.toThrow( - "injected deriveStepAddress failure", - ); - expect(h.registered()).toHaveLength(0); - expect(h.killSignals.length).toBeGreaterThan(0); - }); - // The child runs author code, so the supervisor checks its report itself: // a bad report fails the spawn before the host lays out any step or the // child receives its authority. @@ -3314,11 +3212,9 @@ describe("createWorkflowSupervisor", () => { test("drain() forwards the `drain` control frame and arms a drainTimeout accumulator per in-flight run", async () => { const baseDir = await makeTempDir("supervisor-drain-arm-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); @@ -3543,11 +3439,9 @@ describe("createWorkflowSupervisor", () => { // the supervisor-equivalent of the in-process round-trip the // 13c integration test exercises. const baseDir = await makeTempDir("supervisor-drain-escalate-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); const supervisorToChild = createMemoryNdjsonStream(); @@ -3838,11 +3732,9 @@ describe("createWorkflowSupervisor", () => { test("drain() threads the per-cohort terminal broadcaster into each accumulator's opts", async () => { const baseDir = await makeTempDir("supervisor-drain-terminal-source-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); const supervisorToChild = createMemoryNdjsonStream(); @@ -3987,11 +3879,9 @@ describe("createWorkflowSupervisor", () => { test("drain() arms the broadcaster-backed accumulator source on the active cohort", async () => { const baseDir = await makeTempDir("supervisor-drain-no-term-source-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); const supervisorToChild = createMemoryNdjsonStream(); @@ -4252,11 +4142,9 @@ describe("createWorkflowSupervisor", () => { test("deliverSources() sends a sources-updated frame when running", async () => { const baseDir = await makeTempDir("supervisor-deliver-sources-running-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); @@ -4354,11 +4242,9 @@ describe("createWorkflowSupervisor", () => { // the credential stays evicted when the child (re)spawns -- otherwise the // frozen deploy delivery would resurrect it. const baseDir = await makeTempDir("supervisor-deliver-credentials-idle-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); @@ -4473,11 +4359,9 @@ describe("createWorkflowSupervisor", () => { const baseDir = await makeTempDir( "supervisor-deliver-credentials-running-", ); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); @@ -4581,23 +4465,16 @@ describe("createWorkflowSupervisor", () => { test("long-lived: first message fires trigger.fire with stable runId", async () => { const baseDir = await makeTempDir("supervisor-long-lived-first-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -4636,23 +4513,16 @@ describe("createWorkflowSupervisor", () => { test("long-lived: a trigger.fire run parking on approval releases the dispatch wait", async () => { const baseDir = await makeTempDir("supervisor-long-lived-approval-park-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -4696,23 +4566,16 @@ describe("createWorkflowSupervisor", () => { test("long-lived: subsequent messages fire signal.deliver after park.notify", async () => { const baseDir = await makeTempDir("supervisor-long-lived-signal-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -4794,23 +4657,16 @@ describe("createWorkflowSupervisor", () => { test("long-lived: messages before park.notify are queued and flushed", async () => { const baseDir = await makeTempDir("supervisor-long-lived-queue-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -4903,11 +4759,9 @@ describe("createWorkflowSupervisor", () => { test("long-lived: drain() does not arm accumulators", async () => { const baseDir = await makeTempDir("supervisor-long-lived-drain-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const armedStubs: { runId: string }[] = []; const factory: DrainTimeoutAccumulatorFactory = (opts) => { @@ -4919,13 +4773,8 @@ describe("createWorkflowSupervisor", () => { baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, drainTimeoutAccumulatorFactory: factory, @@ -5045,23 +4894,16 @@ describe("createWorkflowSupervisor", () => { test("long-lived: mail after terminal is rejected without another trigger.fire", async () => { const baseDir = await makeTempDir("supervisor-long-lived-terminal-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5121,21 +4963,14 @@ describe("createWorkflowSupervisor", () => { test("a recovery-window mail is rejected when the live run terminates before parking", async () => { const baseDir = await makeTempDir("supervisor-recovery-terminal-race-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => - assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }), }); const address = "run_deployment-x@example.com"; @@ -5185,22 +5020,15 @@ describe("createWorkflowSupervisor", () => { test("a clean deployment with grants but no events fires its top-level run", async () => { const baseDir = await makeTempDir("supervisor-clean-run-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5222,11 +5050,9 @@ describe("createWorkflowSupervisor", () => { test("a terminal durable log is never cleared or fired after supervisor restart", async () => { const baseDir = await makeTempDir("supervisor-terminal-restart-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const runEventsDir = path.join( baseDir, @@ -5249,13 +5075,8 @@ describe("createWorkflowSupervisor", () => { const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5280,22 +5101,15 @@ describe("createWorkflowSupervisor", () => { test("an instant park during the pre-wait window still advances the dispatch loop", async () => { const baseDir = await makeTempDir("supervisor-instant-park-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5328,22 +5142,15 @@ describe("createWorkflowSupervisor", () => { test("two mails to a parked run each deliver on the run's fresh correlation", async () => { const baseDir = await makeTempDir("supervisor-fresh-corr-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5422,22 +5229,15 @@ describe("createWorkflowSupervisor", () => { test("a park-registered run keeps its channel: a mail routes as signal, not a fresh trigger", async () => { const baseDir = await makeTempDir("supervisor-resumed-order-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5483,21 +5283,14 @@ describe("createWorkflowSupervisor", () => { test("a mail resumes a parked run with the decoded Mail, not raw MIME", async () => { const baseDir = await makeTempDir("supervisor-signal-text-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5545,21 +5338,14 @@ describe("createWorkflowSupervisor", () => { test("deliverSignal ships its structured payload through unchanged", async () => { const baseDir = await makeTempDir("supervisor-deliversignal-passthrough-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5584,21 +5370,14 @@ describe("createWorkflowSupervisor", () => { test("a malformed turn-2 mail is dropped and consumed, not poison-looped", async () => { const baseDir = await makeTempDir("supervisor-poison-mail-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5659,11 +5438,9 @@ describe("createWorkflowSupervisor", () => { test("a transient commit failure leaves the mail reclaimable and the loop alive", async () => { const baseDir = await makeTempDir("supervisor-transient-commit-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); // The injected fault is the event this test waits on: the dispatch // iteration it fails is the one the assertions describe. const partsFaults = createChangeNotifier(); @@ -5680,13 +5457,8 @@ describe("createWorkflowSupervisor", () => { } }, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5729,11 +5501,9 @@ describe("createWorkflowSupervisor", () => { test("a markConsumed failure leaves the mail reclaimable and the loop alive", async () => { const baseDir = await makeTempDir("supervisor-markconsumed-fatal-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const memoryInbox = createMemoryInboxPrimitives(); let failMarkConsumed = true; // The injected failure is the event this test waits on: the refusal is @@ -5755,13 +5525,8 @@ describe("createWorkflowSupervisor", () => { baseDir, inboxPrimitives: failingInbox, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5808,11 +5573,9 @@ describe("createWorkflowSupervisor", () => { test("a mail enqueued during a dispatch iteration is picked up, not stranded", async () => { const baseDir = await makeTempDir("supervisor-lost-wake-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const address = "run_deployment-x@example.com"; // The settleable bus hands back the arrival handler's own promise, which // is what makes the deliver below land INSIDE this dispatch iteration: @@ -5843,13 +5606,8 @@ describe("createWorkflowSupervisor", () => { mailBus, inboxPrimitives: racingInbox, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5877,21 +5635,14 @@ describe("createWorkflowSupervisor", () => { test("a terminal that lands during the trigger's forward window releases the wait", async () => { const baseDir = await makeTempDir("supervisor-subscribe-before-fire-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const wired = await spawnWithRunStart({ baseDir, onRunStart: async () => { - return assembleCredentialsSnapshot({ - repoStore: createStubRepoStore({ baseDir }), - principal: { kind: "supervisor" }, + return seededSnapshot(baseDir, { stepOrder: ["step-1"], - anchorRunId: "run_deployment-x", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, }); }, }); @@ -5922,81 +5673,6 @@ describe("createWorkflowSupervisor", () => { }); }); -describe("assembleCredentialsSnapshot", () => { - test("enumerates each step's agent-state repo and pins per-step grants by hash", async () => { - const baseDir = await makeTempDir("supervisor-creds-"); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "d1", stepId: "alpha" }), - [{ resource: "alpha-thing", action: "read" }], - ); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "d1", stepId: "beta" }), - [ - { resource: "beta-thing", action: "read" }, - { resource: "beta-thing", action: "write" }, - ], - ); - const repoStore = createStubRepoStore({ baseDir }); - const snapshot = await assembleCredentialsSnapshot({ - repoStore, - principal: { kind: "supervisor" }, - stepOrder: ["alpha", "beta"], - anchorRunId: "d1", - deriveStepAddress: ({ runId, stepId }) => - `${runId}-${stepId}@example.com`, - }); - expect(snapshot.steps).toHaveLength(2); - expect(snapshot.steps[0]?.stepId).toBe("alpha"); - expect(snapshot.steps[0]?.address).toBe("d1-alpha@example.com"); - expect(snapshot.steps[0]?.grants).toEqual([ - { resource: "alpha-thing", action: "read" }, - ]); - expect(snapshot.steps[0]?.contentHash).toBe( - await hashGrants([{ resource: "alpha-thing", action: "read" }]), - ); - expect(snapshot.steps[1]?.stepId).toBe("beta"); - expect(snapshot.steps[1]?.grants).toHaveLength(2); - expect(snapshot.steps[0]?.contentHash).not.toBe( - snapshot.steps[1]?.contentHash, - ); - }); - - test("treats a missing per-step grants file as an empty grant array", async () => { - const baseDir = await makeTempDir("supervisor-creds-empty-"); - const repoStore = createStubRepoStore({ baseDir }); - const snapshot = await assembleCredentialsSnapshot({ - repoStore, - principal: { kind: "supervisor" }, - stepOrder: ["solo"], - anchorRunId: "d2", - deriveStepAddress: ({ runId }) => `${runId}@example.com`, - }); - expect(snapshot.steps).toHaveLength(1); - expect(snapshot.steps[0]?.grants).toEqual([]); - expect(snapshot.steps[0]?.contentHash).toBe(await hashGrants([])); - }); - - test("a malformed grants file fails loudly rather than silently treating it as empty", async () => { - const baseDir = await makeTempDir("supervisor-creds-bad-"); - const repoId = defaultStepRepoId({ runId: "d3", stepId: "s" }); - const dir = path.join(baseDir, repoId.kind, repoId.id); - await fs.mkdir(path.join(dir, "state"), { recursive: true }); - await fs.writeFile(path.join(dir, STEP_GRANTS_PATH), "not json"); - const repoStore = createStubRepoStore({ baseDir }); - await expect( - assembleCredentialsSnapshot({ - repoStore, - principal: { kind: "supervisor" }, - stepOrder: ["s"], - anchorRunId: "d3", - deriveStepAddress: () => "d3-s@example.com", - }), - ).rejects.toThrow(/is not valid JSON/); - }); -}); - describe("commitCancelRequested (low-level)", () => { test("attaches the signed payload to the on-disk CancelRequested blob", async () => { const baseDir = await makeTempDir("cancel-signing-"); @@ -6128,17 +5804,11 @@ describe("supervisor inbox FIFO dispatch loop", () => { async function buildFifoTestFixture(opts: { label: string; inbox: InboxPrimitives; - deriveMailAuditRef?: ( - messageId: string, - rawMessage: Uint8Array, - ) => { store: string; path: string }; }) { const baseDir = await makeTempDir(opts.label); - await seedStepGrants( - baseDir, - defaultStepRepoId({ runId: "run_deployment-x", stepId: "step-1" }), - [{ resource: "thing", action: "read" }], - ); + await seedStepGrants(baseDir, "step-1", [ + { resource: "thing", action: "read" }, + ]); const supervisorIpcKeyPair = await generateKeyPair(); const childIpcKeyPair = await generateKeyPair(); const supervisorToChild = createMemoryNdjsonStream(); @@ -6180,9 +5850,6 @@ describe("supervisor inbox FIFO dispatch loop", () => { const bindings: WorkflowSupervisorBindings = { ...baseBindings, ipcKeyPairFactory: () => Promise.resolve(supervisorIpcKeyPair), - ...(opts.deriveMailAuditRef !== undefined - ? { deriveMailAuditRef: opts.deriveMailAuditRef } - : {}), }; const supervisor = supervisors.track(createWorkflowSupervisor(bindings)); const spawnPromise = supervisor.spawn({ @@ -6219,86 +5886,6 @@ describe("supervisor inbox FIFO dispatch loop", () => { }; } - test("default deriveMailAuditRef stamps `in-process` store on enqueued envelopes", async () => { - const inbox = createMemoryInboxPrimitives(); - const { supervisor, mailBus } = await buildFifoTestFixture({ - label: "fifo-default-audit-", - inbox, - }); - mailBus.deliver( - "run_deployment-x@example.com", - new TextEncoder().encode("audit-default-1"), - ); - // Wait for the enqueue to land in the in-memory inbox, which reports - // every mutation it makes. The dispatch loop may pull the entry into - // `processing` before the assertion fires (the loop dequeues immediately - // once the supervisor's spawn handshake completes), so the wait covers - // every claim-check substate -- and the entry never leaves all three, so - // a later move cannot take the state back out from under it. - await inbox.awaitState(() => { - const snap = inbox.snapshot("run_deployment-x@example.com"); - return ( - snap.inbox.size > 0 || - snap.processing.size > 0 || - snap.consumed.size > 0 - ); - }); - const snapshot = inbox.snapshot("run_deployment-x@example.com"); - const all = [ - ...snapshot.consumed.values(), - ...snapshot.processing.values(), - ...snapshot.inbox.values(), - ]; - expect(all.length).toBeGreaterThanOrEqual(1); - const first = all[0]; - if (first === undefined) throw new Error("unreachable"); - expect(first.mailAuditRef.store).toBe("in-process"); - expect(first.mailAuditRef.path.length).toBeGreaterThan(0); - await supervisor.shutdown(); - }); - - test("deriveMailAuditRef override is invoked with messageId and stamps the envelope", async () => { - const inbox = createMemoryInboxPrimitives(); - const observed: { messageId: string; len: number }[] = []; - // The override being invoked is the event; the poll it replaces re-read - // the array on a timer until something landed in it. - const observedChanges = createChangeNotifier(); - const { supervisor, mailBus } = await buildFifoTestFixture({ - label: "fifo-override-audit-", - inbox, - deriveMailAuditRef: (messageId, rawMessage) => { - observed.push({ messageId, len: rawMessage.byteLength }); - observedChanges.notify(); - return { - store: "test-audit", - path: `deployment-x/${messageId}`, - }; - }, - }); - const payload = new TextEncoder().encode("audit-override-1"); - mailBus.deliver("run_deployment-x@example.com", payload); - await observedChanges.until(() => observed.length > 0); - expect(observed.length).toBe(1); - const observedEntry = observed[0]; - if (observedEntry === undefined) throw new Error("unreachable"); - expect(observedEntry.len).toBe(payload.byteLength); - expect(observedEntry.messageId.length).toBeGreaterThan(0); - const overrideSnapshot = inbox.snapshot("run_deployment-x@example.com"); - const allEntries = [ - ...overrideSnapshot.inbox.values(), - ...overrideSnapshot.processing.values(), - ...overrideSnapshot.consumed.values(), - ]; - expect(allEntries.length).toBeGreaterThanOrEqual(1); - const first = allEntries[0]; - if (first === undefined) throw new Error("unreachable"); - expect(first.mailAuditRef.store).toBe("test-audit"); - expect(first.mailAuditRef.path).toBe( - `deployment-x/${observedEntry.messageId}`, - ); - await supervisor.shutdown(); - }); - test("two queued messages fire once, then reject the post-terminal message", async () => { const inbox = createMemoryInboxPrimitives(); // The supervisor's per-cohort terminal broadcaster gates each @@ -6366,7 +5953,6 @@ describe("supervisor inbox FIFO dispatch loop", () => { state.processing.set("1000-msg-orphan", { messageId: "msg-orphan", receivedAt: 1000, - mailAuditRef: { store: "test", path: "test/orphan" }, // The recovered entry must carry decodable mail bytes: the dispatch loop // decodes and commits them before forwarding the trigger.fire. rawMessage: base64Encode( diff --git a/packages/workflow-host/src/supervisor/supervisor.ts b/packages/workflow-host/src/supervisor/supervisor.ts index d5b52a973..ba77bdc9c 100644 --- a/packages/workflow-host/src/supervisor/supervisor.ts +++ b/packages/workflow-host/src/supervisor/supervisor.ts @@ -108,10 +108,7 @@ import type { } from "../ipc/control-payloads"; import { - assembleCredentialsSnapshot, type CredentialsSnapshot, - stepLayoutSnapshotOpts, - type StepLayout, toCredentialsSnapshotPayload, } from "./credentials"; import { @@ -151,11 +148,9 @@ import { type RecyclePolicy, } from "./recycle"; import type { - DeriveMailAuditRef, DispatchStructuralCounters, DispatchSubstrateLeg, InboxPrimitives, - MailAuditRef, SubprocessHandle, TerminalEventSource, TerminalRunEvent, @@ -257,7 +252,7 @@ export const TERMINAL_OR_PARK_BACKSTOP_MS = 300_000; export interface WorkflowSupervisor { /** * Spawn the workflow-process child, complete the IPC handshake, - * assemble the credentialsSnapshot, register the deployment's mail + * read the run's authority, register the deployment's mail * address, and begin forwarding inbound mail. Resolves once the * child's `ready` frame has been received and credentials have * been pushed. @@ -356,12 +351,6 @@ export interface WorkflowSupervisor { * re-establishment re-drives it. The caller need not guard the call site. */ reEmitParkedCorrelations(): Promise; - /** - * Current snapshot of the credentials pushed to the child. Surfaced - * so the host can audit the per-step contentHash without - * round-tripping the substrate. Returns `null` before spawn. - */ - getCredentialsSnapshot(): CredentialsSnapshot | null; /** * The supervisor's merged mirror of every credential delivery: the deploy * delivery plus each `deliverCredentials` update, revokes applied. It is @@ -391,8 +380,6 @@ export type SpawnResult = { pid: number; /** IPC channelId minted for this spawn. */ channelId: string; - /** Initial credentials snapshot pushed to the child. */ - credentialsSnapshot: CredentialsSnapshot; }; export type CancelRequestOpts = { @@ -1020,8 +1007,6 @@ export function createWorkflowSupervisor( markConsumed: defaultMarkConsumed, replayProcessingToInbox: defaultReplayProcessingToInbox, }; - const deriveMailAuditRef: DeriveMailAuditRef = - bindings.deriveMailAuditRef ?? defaultInProcessMailAuditRef; const defaultInboxWritePrincipal: WorkflowRunSupervisorPrincipal = { kind: "supervisor", anchorRunId: bindings.anchorRunId, @@ -1084,26 +1069,26 @@ export function createWorkflowSupervisor( } /** * Cached per-spawn context the recycle path needs to respawn the - * child against the same deploy tree. Populated on `spawn(opts)`; + * child against the same definition. Populated on `spawn(opts)`; * cleared on `shutdown`. The recycle path never mutates the * `definitionHash`: a deployment keeps it for its life, and a - * different deploy tree means a new deployment with its own supervisor. + * different definition means a new deployment with its own supervisor. */ let spawnContext: SpawnContext | null = null; /** - * The step layout the host derived from the first child's verified + * The step ids the host derived from the first child's verified * definition. Set once, while `spawn` is still starting that child, and * kept for the deployment's life: every later child reports a definition * with the same approved hash. */ - let stepLayout: StepLayout | null = null; - function requireStepLayout(): StepLayout { - if (stepLayout === null) { + let stepIds: readonly string[] | null = null; + function requireStepIds(): readonly string[] { + if (stepIds === null) { throw new Error( - "workflow-host supervisor: no step layout before the first child reported its definition", + "workflow-host supervisor: no step ids before the first child reported its definition", ); } - return stepLayout; + return stepIds; } let recyclePolicy: RecyclePolicy | null = null; // Mutual-exclusion latch shared by every respawn path (operator/policy/ @@ -1676,10 +1661,6 @@ export function createWorkflowSupervisor( async function enqueueInboundMail(rawMessage: Uint8Array): Promise { const messageId = await deriveMessageId(rawMessage); - const mailAuditRef: MailAuditRef = deriveMailAuditRef( - messageId, - rawMessage, - ); const receivedAt = Date.now(); // Inline the raw mail bytes on the claim-check envelope so the // workflow-process child can recover its step input by messageId at @@ -1702,7 +1683,6 @@ export function createWorkflowSupervisor( address: bindings.deploymentMailAddress, messageId, receivedAt, - mailAuditRef, rawMessage: rawMessageBase64, }, ); @@ -3008,7 +2988,6 @@ export function createWorkflowSupervisor( logger, }); let childPid: number; - let credentialsSnapshot: CredentialsSnapshot; let authority: ReadAuthority; try { try { @@ -3040,16 +3019,8 @@ export function createWorkflowSupervisor( } if (definitionRace.kind === "failed") throw definitionRace.err; childPid = definitionRace.report.childPid; - stepLayout = await bindings.layoutSteps( - definitionRace.report.projection, - ); - credentialsSnapshot = await assembleCredentialsSnapshot({ - repoStore: bindings.repoStore, - principal: bindings.readPrincipal, - anchorRunId: bindings.anchorRunId, - ...stepLayoutSnapshotOpts(stepLayout), - }); - authority = await readAuthority(credentialsSnapshot); + stepIds = await bindings.layoutSteps(definitionRace.report.projection); + authority = await readAuthority(); } catch (cause) { await killUnpublishedChild(); throw cause; @@ -3081,7 +3052,6 @@ export function createWorkflowSupervisor( eventPump: wired.wiring.eventPump, onInferenceEvent: opts.onInferenceEvent, mailUnsubscribe: null, - credentialsSnapshot, terminalCohortAbort: new AbortController(), terminalBroadcaster: createTerminalBroadcaster(), dispatchLoop: null, @@ -3293,7 +3263,6 @@ export function createWorkflowSupervisor( eventPump: wired.wiring.eventPump, onInferenceEvent: opts.onInferenceEvent, mailUnsubscribe, - credentialsSnapshot, terminalCohortAbort: startingPhaseCohortAbort, terminalBroadcaster: startingPhaseBroadcaster, dispatchLoop, @@ -3379,7 +3348,6 @@ export function createWorkflowSupervisor( return { pid: childPid, channelId, - credentialsSnapshot, }; } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); @@ -3582,8 +3550,7 @@ export function createWorkflowSupervisor( * Push the run's grants snapshot to the child ahead of its * `trigger.fire`. Returns `true` if the barrier FAILED (the caller must * skip the fire; the run has already been settled as `RunFailed`) and - * `false` if the barrier passed or is not armed (`onRunStart` unwired, - * where `spawn` supplied the snapshot instead). + * `false` if the barrier passed. * * The sink is a request/response contract: the supervisor awaits the * returned snapshot and awaits the `grants-updated` send so both land on @@ -3598,12 +3565,11 @@ export function createWorkflowSupervisor( runId: string, broadcaster: TerminalBroadcaster, ): Promise { - if (bindings.onRunStart === undefined) return false; try { const snapshot = await bindings.onRunStart({ runId, anchorRunId: bindings.anchorRunId, - layout: requireStepLayout(), + stepIds: requireStepIds(), }); if (snapshot === null) { throw new Error( @@ -3733,7 +3699,7 @@ export function createWorkflowSupervisor( // Subscribe to the terminal broadcaster BEFORE the grants barrier so // a synthetic `RunFailed` from a barrier failure can be captured. const preIter = broadcaster.source(runId)[Symbol.asyncIterator](); - // Per-run grants barrier. When `onRunStart` is wired, push this run's + // Per-run grants barrier. Push this run's // grants snapshot BEFORE the trigger/signal so the child's authorize // closure binds to it rather than throwing on a null snapshot. The push // and the fire share the child's control channel, so a `grants-updated` @@ -4721,7 +4687,6 @@ export function createWorkflowSupervisor( eventPump: prior.eventPump, onInferenceEvent: prior.onInferenceEvent, mailUnsubscribe: prior.mailUnsubscribe, - credentialsSnapshot: prior.credentialsSnapshot, terminalCohortAbort: prior.terminalCohortAbort, terminalBroadcaster: prior.terminalBroadcaster, dispatchLoop: null, @@ -4748,7 +4713,6 @@ export function createWorkflowSupervisor( return handle; }, }, - layout: requireStepLayout(), definitionHash: priorContext.definitionHash, onInferenceEvent: priorContext.onInferenceEvent, current: { @@ -4780,8 +4744,8 @@ export function createWorkflowSupervisor( // A standalone helper would take all of it as parameters for zero // reuse -- the crash-respawn path reaches this callback transitively // through `runRespawn`, so it needs no separate extraction. - buildAuthority: async (fallback) => { - const read = await readAuthority(fallback); + buildAuthority: async () => { + const read = await readAuthority(); return { data: authorityData(read.snapshot), revision: read.revision, @@ -4789,7 +4753,6 @@ export function createWorkflowSupervisor( }, installNewChild: ({ wiring, - credentialsSnapshot, controlIncoming, authorityRevision: sentRevision, }) => { @@ -4862,7 +4825,6 @@ export function createWorkflowSupervisor( eventPump: wiring.eventPump, onInferenceEvent: priorContext.onInferenceEvent, mailUnsubscribe: prior.mailUnsubscribe, - credentialsSnapshot, terminalCohortAbort: newCohortAbort, terminalBroadcaster: newBroadcaster, dispatchLoop: newDispatchLoop, @@ -5111,24 +5073,17 @@ export function createWorkflowSupervisor( } } - // The grants snapshot for a child's `authority.set`. With `onRunStart` wired - // it is the run's own grants file: none before the run's first trigger, and - // none when the read fails, so a new child's step that needs grants fails - // closed. Without it (the supervisor's own tests) it is the spawn-time - // snapshot. The revision is read before the grants, so an update during the - // read counts as newer. - async function readAuthority( - fallback: CredentialsSnapshot | null, - ): Promise { + // The grants snapshot for a child's `authority.set`: the run's own grants + // file, which is none before the run's first trigger, and none when the read + // fails, so a new child's step that needs grants fails closed. The revision + // is read before the grants, so an update during the read counts as newer. + async function readAuthority(): Promise { const revision = authorityRevision; - if (bindings.onRunStart === undefined) { - return { snapshot: fallback, revision, failed: false }; - } try { const snapshot = await bindings.onRunStart({ runId: deriveWorkflowRunId(bindings.deploymentMailAddress), anchorRunId: bindings.anchorRunId, - layout: requireStepLayout(), + stepIds: requireStepIds(), }); return { snapshot, revision, failed: false }; } catch (cause) { @@ -5168,9 +5123,7 @@ export function createWorkflowSupervisor( async function resendAuthority(): Promise { for (;;) { if (state.phase !== "starting" && state.phase !== "running") return; - const { snapshot, revision, failed } = await readAuthority( - state.credentialsSnapshot, - ); + const { snapshot, revision, failed } = await readAuthority(); if (revision !== authorityRevision) continue; if (state.phase !== "starting" && state.phase !== "running") return; await sendAuthority(state.controlSender, failed ? "keep" : snapshot); @@ -5198,7 +5151,6 @@ export function createWorkflowSupervisor( * floor, never inject one a deploy did not approve. */ async function deliverGrants(runId: string): Promise<"pushed" | "skipped"> { - if (bindings.onRunStart === undefined) return "skipped"; authorityRevision += 1; if (state.phase !== "running" && state.phase !== "starting") { return "skipped"; @@ -5207,7 +5159,7 @@ export function createWorkflowSupervisor( const snapshot = await bindings.onRunStart({ runId, anchorRunId: bindings.anchorRunId, - layout: requireStepLayout(), + stepIds: requireStepIds(), }); if (snapshot === null) { logger.warn`deliverGrants: run ${runId} has no grants file; nothing to refresh`; @@ -5225,13 +5177,6 @@ export function createWorkflowSupervisor( } } - function getCredentialsSnapshot(): CredentialsSnapshot | null { - if (state.phase === "starting" || state.phase === "running") { - return state.credentialsSnapshot; - } - return null; - } - return { spawn, requestCancel, @@ -5243,7 +5188,6 @@ export function createWorkflowSupervisor( deliverCredentials, deliverGrants, reEmitParkedCorrelations, - getCredentialsSnapshot, credentialDelivery: () => currentCredentialDelivery, }; } @@ -5269,7 +5213,6 @@ type ActiveState = { eventPump: Promise; onInferenceEvent: (event: EventPayload) => void; mailUnsubscribe: (() => void) | null; - credentialsSnapshot: CredentialsSnapshot | null; /** * Per-spawn cohort abort controller for terminal-event watchers * and the dispatch loop. Each watcher the supervisor mints inside @@ -5360,20 +5303,6 @@ async function pumpEvents( } } -/** - * Default `deriveMailAuditRef` derivation used when no host binding - * is configured. The reference points at an "in-process" store with - * the messageId as the path, which keeps the supervisor's library - * tests independent of any audit-store wiring. Production hosts - * supply a derivation coherent with their own mail-audit surface. - */ -function defaultInProcessMailAuditRef( - messageId: string, - _rawMessage: Uint8Array, -): MailAuditRef { - return { store: "in-process", path: messageId }; -} - /** * Project the wire shape of a `terminal.event` upstream control frame * into the workflow-vocabulary `TerminalRunEvent` discriminated union diff --git a/packages/workflow-host/src/supervisor/types.ts b/packages/workflow-host/src/supervisor/types.ts index c98ec7b40..b4c7a3c5e 100644 --- a/packages/workflow-host/src/supervisor/types.ts +++ b/packages/workflow-host/src/supervisor/types.ts @@ -196,32 +196,6 @@ export type SubprocessSpawner = (args: { env: Record; }) => SubprocessHandle; -/** - * Logical pointer to the raw mail bytes the inbox claim-check - * envelope stamps. The substrate stores this as the `mailAuditRef` - * on every inbox/processing/consumed envelope; the substrate itself - * never dereferences it. The boot edge supplies a derivation - * coherent with wherever the deployment's mail audit actually lives - * (a sidecar's session-mail audit, an alternative host's audit - * store, etc.). - */ -export type MailAuditRef = { store: string; path: string }; - -/** - * Host-supplied derivation that maps a parsed `messageId` plus the - * raw mail bytes into the logical audit reference the inbox claim- - * check envelope carries. Pure -- the supervisor invokes it on the - * mail-arrival hot path and expects no I/O. Absent binding falls - * back to a deterministic in-process derivation that synthesizes - * `{ store: "in-process", path: }` so the supervisor's - * library tests that do not stand up an audit store still work; the - * production sidecar boot edge supplies a real derivation. - */ -export type DeriveMailAuditRef = ( - messageId: string, - rawMessage: Uint8Array, -) => MailAuditRef; - /** * Inbox claim-check primitives the supervisor's mail-arrival path * and dispatch loop reach into. Production wires this against the @@ -295,7 +269,7 @@ export interface SuspensionRegistration { * holds a private key in plaintext. */ export interface WorkflowSupervisorBindings { - /** Substrate handle the supervisor reads grants from and commits events to. */ + /** Substrate handle the supervisor commits the run's mail and events through. */ repoStore: SubstrateRepoStore; /** Per-principal signing callback. See `PrincipalSigner`. */ signAsPrincipal: PrincipalSigner; @@ -358,15 +332,11 @@ export interface WorkflowSupervisorBindings { * synthesized `RunFailed`) rather than firing the trigger against absent * grants. For a new child, a null or a throw gives the child no grants * snapshot, so a step that needs grants fails closed. - * - * A caller that injects no per-run source -- today, the supervisor's own - * tests -- gives a new child the spawn-time snapshot instead. Production - * (the sidecar) wires it to the run's grants file. */ - onRunStart?: (args: { + onRunStart: (args: { runId: string; anchorRunId: string; - layout: import("./credentials").StepLayout; + stepIds: readonly string[]; }) => Promise; /** * Decrypted credential material for the deployment's binding consumers and @@ -420,23 +390,18 @@ export interface WorkflowSupervisorBindings { */ deploymentMailAddress: string; /** - * The supervisor's substrate principal for read-only operations - * the supervisor performs in its own right (e.g. enumerating - * step grants). Cancel-signing constructs its own principal at - * commit time; this binding is for non-write operations. - */ - readPrincipal: Principal; - /** - * Derive the deployment's step layout from the definition its first child - * verified. The supervisor calls it once, after checking the child's + * Derive the deployment's step ids from the definition its first child + * verified: every step id the credentials snapshot carries an entry for, the + * definition's own `stepOrder` plus the step ids of every `loop` body. The + * supervisor calls it once, after checking the child's * reported projection against the approved hash and before it reads the - * run's authority for that child, and keeps the layout for every later + * run's authority for that child, and keeps the ids for every later * respawn: the same hash check pins each later child to the same * definition. A throw fails the spawn and kills the child. */ layoutSteps: ( definition: import("@intx/types/sidecar").WorkflowProjectionDefinition, - ) => Promise; + ) => Promise; /** * Optional override for the per-spawn IPC keypair factory. Each * spawn mints a fresh control-channel Ed25519 keypair; this hook @@ -520,17 +485,6 @@ export interface WorkflowSupervisorBindings { */ recyclePolicySetTimer?: (cb: () => void, ms: number) => unknown; recyclePolicyClearTimer?: (handle: unknown) => void; - /** - * Compute the logical audit reference for an inbox entry. The - * substrate stamps this onto the envelope; the substrate itself - * does not dereference it. The boot edge supplies a derivation - * coherent with wherever the deployment's mail audit lives. - * - * Absent binding falls back to `{ store: "in-process", - * path: }`, a deterministic derivation that lets the - * supervisor's library tests run without a host-side audit store. - */ - deriveMailAuditRef?: DeriveMailAuditRef; /** * Inbox claim-check primitives the dispatch loop reaches into. * Production wires this against `@intx/hub-sessions`'s concrete diff --git a/packages/workflow/src/definition/body-id-grammar.test.ts b/packages/workflow/src/definition/body-id-grammar.test.ts index fdd70ba03..fbbd96045 100644 --- a/packages/workflow/src/definition/body-id-grammar.test.ts +++ b/packages/workflow/src/definition/body-id-grammar.test.ts @@ -162,8 +162,7 @@ describe("step-id grammar in a nested body", () => { }); test("rejects a body step whose embedded id names a different step", () => { // The record key is what every id-derived table is built on -- the - // credentials snapshot, the deploy-time grants write, the staged body ref - // -- while the runtime authorizes under the primitive's own `id`. The root + // credentials snapshot and a body's pinned sources -- while the runtime authorizes under the primitive's own `id`. The root // cannot diverge, because normalize assigns the key over the embedded id. // A hand-assembled body never passes through that assignment, so it can // key a step under one name and carry another, aiming the two halves at diff --git a/packages/workflow/src/definition/workflow.ts b/packages/workflow/src/definition/workflow.ts index a839e4687..038bb07ad 100644 --- a/packages/workflow/src/definition/workflow.ts +++ b/packages/workflow/src/definition/workflow.ts @@ -135,21 +135,17 @@ export function defineWorkflow( /** * `stepId` must be a non-empty sequence of ASCII letters, digits, - * underscores, and hyphens. The constraint exists so the workflow-deploy - * orchestrator can derive per-step mail addresses by string concat - * without escaping. Exported for downstream consumers (notably the - * orchestrator's per-step address derivation) that need to assert the - * same shape. + * underscores, and hyphens, so it is safe as a path segment in a step's + * durable storage. Exported for downstream consumers that need to assert + * the same shape. */ export const STEP_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; /** * A run id must be a non-empty sequence of ASCII letters, digits, underscores, * and hyphens -- the same shape as a step id. A run id is used verbatim as a - * durable-store path segment (`runs//...`) and as the local part of a - * derived per-step mail address (`-@`), so an - * unconstrained caller-supplied id is a path-escape and an unroutable-address - * hazard. `newId("run")` and the internally-derived child run ids (loop body, + * durable-store path segment (`runs//...`), so an unconstrained + * caller-supplied id is a path-escape hazard. `newId("run")` and the internally-derived child run ids (loop body, * onTrigger section) already satisfy this shape; the pattern is enforced at * the `runtimeRun` boundary where a run id enters the system. */ @@ -455,8 +451,7 @@ function validateSteps( function validateStepIds(steps: Record): void { for (const [stepId, primitive] of Object.entries(steps)) { // The record key is the id every id-derived table is built on -- the - // credentials snapshot, the deploy-time grants write, and the staged body - // ref -- while the runtime authorizes under the primitive's own `id`. At + // credentials snapshot and a body's pinned sources -- while the runtime authorizes under the primitive's own `id`. At // the root those cannot disagree, because `normalize` assigns the key over // the embedded id before this pass runs. A nested body never passes // through that assignment, so a hand-assembled one can key a step under @@ -471,13 +466,9 @@ function validateStepIds(steps: Record): void { if (stepId === "") { throw new Error("step ids cannot be empty"); } - // The workflow-deploy orchestrator derives per-step mail addresses - // of the form `-@` for - // multi-step deployments. Constraining `stepId` to - // `[a-zA-Z0-9_-]+` at definition time means the derived local-part - // never needs escaping and the address parser at the substrate - // boundary never sees a step-id-shaped local-part it cannot - // round-trip. + // A step id becomes a path segment in the step's durable storage. + // Constraining it to `[a-zA-Z0-9_-]+` at definition time keeps it from + // escaping that path. if (!STEP_ID_PATTERN.test(stepId)) { throw new Error( `step id ${JSON.stringify(stepId)} must match ${STEP_ID_PATTERN.source}`, diff --git a/tests/admin-ui-e2e/storage-isogit/harness.ts b/tests/admin-ui-e2e/storage-isogit/harness.ts index ea5a420c0..37e2e1107 100644 --- a/tests/admin-ui-e2e/storage-isogit/harness.ts +++ b/tests/admin-ui-e2e/storage-isogit/harness.ts @@ -24,8 +24,6 @@ const auditRecord: AuditRecord = { type WriteResult = { sourceCommitSha: string; deployCommitSha: string; - packCount: number; - looseObjectCount: number; }; type ReadResult = { @@ -63,22 +61,23 @@ async function writeAndFlush(databaseName: string): Promise { "refs/heads/main", ); await storage.initAgentRepo("/target"); - await storage.applyPack( + const staged = await storage.stagePack( "/target", pack, - "refs/heads/deploy", - commitSha, "browser-smoke-transfer", ); + await staged.publish(); + // This package does not depend on isomorphic-git, so it writes the loose ref. + await storage.storageRuntime.fs.writeFile( + "/target/.git/refs/heads/deploy", + `${commitSha}\n`, + ); - const gc = await storage.runGC("/target", { retention: "keep-history" }); await storage.runtime.flush(); return { sourceCommitSha: committed.hash, deployCommitSha: commitSha, - packCount: gc.after.packCount, - looseObjectCount: gc.after.looseObjectCount, }; } diff --git a/tests/admin-ui-e2e/storage-isogit/storage-isogit.spec.ts b/tests/admin-ui-e2e/storage-isogit/storage-isogit.spec.ts index 196fdcd19..d24b5034e 100644 --- a/tests/admin-ui-e2e/storage-isogit/storage-isogit.spec.ts +++ b/tests/admin-ui-e2e/storage-isogit/storage-isogit.spec.ts @@ -16,9 +16,6 @@ test("keeps single-owner storage within a page and resets it on reload", async ( return globalThis.storageIsogitSmoke.writeAndFlush(name); }, filesystemName); - expect(written.packCount).toBe(1); - expect(written.looseObjectCount).toBe(0); - const inSession = await page.evaluate((name) => { return globalThis.storageIsogitSmoke.readInSession(name); }, filesystemName); diff --git a/tests/db/anchor-before-frame.test.ts b/tests/db/anchor-before-frame.test.ts index 48349ef92..e7dab4635 100644 --- a/tests/db/anchor-before-frame.test.ts +++ b/tests/db/anchor-before-frame.test.ts @@ -102,13 +102,7 @@ const SOURCES = { only: [INFERENCE_SOURCE] }; const MANIFEST_DIGEST = "1".repeat(64); const CONFIG: HarnessConfig = { sessionId: "ses-anchor-before-frame", - agentId: ANCHOR_RUN_ID, - tenantId: TENANT_ID, - principalId: "prin-anchor-before-frame", agentAddress: DEPLOY_ADDRESS, - systemPrompt: "deployment-level", - tools: [], - grants: [], sources: Object.values(SOURCES).flat(), defaultSource: "src-only", }; diff --git a/tests/db/bytea-round-trip.test.ts b/tests/db/bytea-round-trip.test.ts index f855ddd3a..891de25df 100644 --- a/tests/db/bytea-round-trip.test.ts +++ b/tests/db/bytea-round-trip.test.ts @@ -71,7 +71,7 @@ describe.skipIf(!harnessDbEnvAvailable())("bytea round-trip (real DB)", () => { name: "laptop", kind: "pat", tokenHashSha256: c.bytes, - resource: "agent-state:run_1", + resource: "asset:ast_1", refPattern: "*", actions: ["resolveRef"], expiresAt: new Date(Date.now() + 3_600_000), diff --git a/tests/db/persisted-approval-compat.test.ts b/tests/db/persisted-approval-compat.test.ts index 4ca97bfe9..338c44fc8 100644 --- a/tests/db/persisted-approval-compat.test.ts +++ b/tests/db/persisted-approval-compat.test.ts @@ -53,7 +53,6 @@ function legacyRow(approvedGrants: unknown): LaunchSpecRow { }, sourceOfferingIds: ["off_1"], defaultSourceOfferingId: "off_1", - deployContent: {}, toolPackagePins: null, createdAt: new Date("2026-01-01T00:00:00Z"), }; diff --git a/tests/db/pinned-asset-deploy.test.ts b/tests/db/pinned-asset-deploy.test.ts index 067bc6166..fa8f693a2 100644 --- a/tests/db/pinned-asset-deploy.test.ts +++ b/tests/db/pinned-asset-deploy.test.ts @@ -22,7 +22,6 @@ import { DEFAULT_ASSET_REF, type InstallAndApproveResult, } from "@intx/hub-sessions"; -import { runGC } from "@intx/storage-isogit/node"; import type { HarnessConfig } from "@intx/types/runtime"; import type { AgentDeployFrame } from "@intx/types/sidecar"; import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; @@ -54,13 +53,7 @@ const ALLOC_GENERATION = 1; const PROJECTION = { id: "pinned", triggers: [], stepOrder: [], steps: {} }; const CONFIG: HarnessConfig = { sessionId: "ses-pinned-asset-deploy", - agentId: ANCHOR_RUN_ID, - tenantId: TENANT_ID, - principalId: "prin-pinned-asset-deploy", agentAddress: DEPLOY_ADDRESS, - systemPrompt: "", - tools: [], - grants: [], sources: [], defaultSource: "", }; @@ -234,7 +227,7 @@ describe.skipIf(!harnessDbEnvAvailable())( generation: 1, }); - test("a branch replaced and collected between probe and deploy still ships the probed commit", async () => { + test("a branch replaced between probe and deploy still ships the probed commit", async () => { await seedAsset(h.db, { id: ASSET_ID, tenantId: TENANT_ID, @@ -270,7 +263,6 @@ describe.skipIf(!harnessDbEnvAvailable())( DEFAULT_ASSET_REF, sourceTree("replacement", "replacement"), ); - await runGC(repoStore.getRepoDir(repoId), { retention: "tip-only" }); await deployPrepared(service, source, approved); expect(await repoStore.resolveRef(HUB, repoId, DEFAULT_ASSET_REF)).toBe( @@ -281,7 +273,7 @@ describe.skipIf(!harnessDbEnvAvailable())( ).toEqual([probed.commitSha]); }); - test("a tarball registry branch replaced and collected between probe and deploy still ships the probed commit", async () => { + test("a tarball registry branch replaced between probe and deploy still ships the probed commit", async () => { await seedAsset(h.db, { id: ASSET_ID, tenantId: TENANT_ID, @@ -319,7 +311,6 @@ describe.skipIf(!harnessDbEnvAvailable())( DEFAULT_ASSET_REF, await registryTree('export const v = "replacement";', "replacement"), ); - await runGC(repoStore.getRepoDir(repoId), { retention: "tip-only" }); await deployPrepared(service, source, approved); expect(await repoStore.resolveRef(HUB, repoId, DEFAULT_ASSET_REF)).toBe( diff --git a/tests/db/run-delegated-grants.test.ts b/tests/db/run-delegated-grants.test.ts index 363cab6d5..ed2e814bc 100644 --- a/tests/db/run-delegated-grants.test.ts +++ b/tests/db/run-delegated-grants.test.ts @@ -1162,7 +1162,6 @@ describe.skipIf(!harnessDbEnvAvailable())( steps: [ { stepId: "work", - address: "work", grants, contentHash: "", }, @@ -1171,7 +1170,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }, "test child", ), - createGrantEvaluator({ floorFor: () => [] }), + createGrantEvaluator(), ); const result = await childAuthorize("secret:vault", "use", { stepId: "work", diff --git a/tests/db/run-tool-grant-mutation.test.ts b/tests/db/run-tool-grant-mutation.test.ts index 96901d075..8efc0bf78 100644 --- a/tests/db/run-tool-grant-mutation.test.ts +++ b/tests/db/run-tool-grant-mutation.test.ts @@ -107,7 +107,7 @@ describe.skipIf(!harnessDbEnvAvailable())( timezone: "UTC", }, }); - const evaluate = createGrantEvaluator({ floorFor: () => [] }); + const evaluate = createGrantEvaluator(); const approvedEffect = async (conditions: Record) => { await h.db.delete(grantTable); await seedGrant(h.db, { diff --git a/tests/db/sidecar-allocation-store.test.ts b/tests/db/sidecar-allocation-store.test.ts index 408823dc5..560e72277 100644 --- a/tests/db/sidecar-allocation-store.test.ts +++ b/tests/db/sidecar-allocation-store.test.ts @@ -117,7 +117,6 @@ describe.skipIf(!harnessDbEnvAvailable())( }, sourceOfferingIds: ["offering-primary"], defaultSourceOfferingId: "offering-primary", - deployContent: { systemPrompt: "" }, }); }); diff --git a/tests/db/sidecar-lifecycle-integration.test.ts b/tests/db/sidecar-lifecycle-integration.test.ts index 4fbaa7c99..4026fb1c1 100644 --- a/tests/db/sidecar-lifecycle-integration.test.ts +++ b/tests/db/sidecar-lifecycle-integration.test.ts @@ -156,7 +156,6 @@ describe.skipIf(!harnessDbEnvAvailable())( historyReceives, }); const router = createSidecarRouter({ - hubPublicKey: "a".repeat(64), authenticateSidecar: async ({ token }) => credentials.resolve(token), resolveSidecarBindings: credentials.resolveBindings, validateSidecarIdentity: credentials.isCurrent, diff --git a/tests/db/workflow-allocation-service.test.ts b/tests/db/workflow-allocation-service.test.ts index 80a70873c..fd0a055da 100644 --- a/tests/db/workflow-allocation-service.test.ts +++ b/tests/db/workflow-allocation-service.test.ts @@ -261,7 +261,6 @@ describe.skipIf(!harnessDbEnvAvailable())( sourceAuthorityPrincipalId: PRINCIPAL_ID, sourceOfferingIds: [OFFERING_ID], defaultSourceOfferingId: OFFERING_ID, - deployContent: { systemPrompt: "" }, } as const; } @@ -1089,7 +1088,6 @@ describe.skipIf(!harnessDbEnvAvailable())( message: "catalog temporarily unavailable", }); failure = new SessionLaunchError( - "start", new Error("deploy acknowledgement timed out"), true, ); diff --git a/tests/db/workflow-execution-admission.test.ts b/tests/db/workflow-execution-admission.test.ts index 593ce6cdb..691514e7b 100644 --- a/tests/db/workflow-execution-admission.test.ts +++ b/tests/db/workflow-execution-admission.test.ts @@ -32,7 +32,11 @@ import { type SidecarRouterConfig, } from "@intx/hub-sessions"; import type { HarnessConfig } from "@intx/types/runtime"; -import { DEFAULT_SIDECAR_CAPACITY, HubFrame } from "@intx/types/sidecar"; +import { + type AgentDeployFrame, + DEFAULT_SIDECAR_CAPACITY, + HubFrame, +} from "@intx/types/sidecar"; import { createMockWs } from "./sidecar-test-helpers"; const MANIFEST_DIGEST = "1".repeat(64); @@ -51,15 +55,17 @@ const sidecarAuth = { resolveSidecarBindings: async () => [binding], }; +const TEST_WORKFLOW: AgentDeployFrame["workflow"] = { + sources: {}, + sourceRef: { + source: { kind: "registry", registry: "npmjs" }, + closure: { schemaVersion: "1", topLevel: [], entries: [] }, + }, +}; + const deployConfig: HarnessConfig = { sessionId: "session_admission", - agentId: "workflow", - tenantId: target.tenantId, - principalId: "principal_admission", agentAddress: target.workflowRunAddress, - systemPrompt: "test", - tools: [], - grants: [], sources: [], defaultSource: "test", }; @@ -119,7 +125,6 @@ describe.skipIf(!harnessDbEnvAvailable())( ) { const registered = Promise.withResolvers(); const router = createSidecarRouter({ - hubPublicKey: "a".repeat(64), ...sidecarAuth, validateSidecarIdentity: async () => true, withExecutableWorkflowRun: (identity, send, signal) => @@ -171,7 +176,7 @@ describe.skipIf(!harnessDbEnvAvailable())( target, target.workflowRunAddress, deployConfig, - undefined, + TEST_WORKFLOW, initialization.signal, async () => { const reserved = @@ -283,107 +288,6 @@ describe.skipIf(!harnessDbEnvAvailable())( } }); - test.each(["cancel", "expire"] as const)( - "refuses staging frames after %s", - async (change) => { - const { router, ws } = await connectForInitialization(); - const stepAddress = "step_admission@example.test"; - await router.bindAllocatedStepRoute(target, stepAddress); - try { - await h.db - .update(workflowRun) - .set( - change === "expire" - ? { expiresAt: new Date(0) } - : { - cancellationRequestedAt: new Date(), - cancellationDeadline: new Date(), - cancellationReason: "Stop", - }, - ) - .where(eq(workflowRun.id, target.anchorRunId)); - // A frame reaching the socket means admission let it through. - const sent = Promise.withResolvers<"sent">(); - const send = ws.send.bind(ws); - ws.send = (raw) => { - send(raw); - sent.resolve("sent"); - }; - const outcome = (sending: Promise) => - Promise.race([ - sending.then( - () => "delivered", - (cause: unknown) => cause, - ), - sent.promise, - ]); - expect( - await outcome( - router.sendProvisionStepToAllocation( - target, - stepAddress, - deployConfig, - ), - ), - ).toBeInstanceOf(WorkflowRunNotExecutableError); - expect( - await outcome( - router.sendPackToAllocation( - target, - stepAddress, - new Uint8Array([1]), - "refs/heads/main", - "a".repeat(40), - ), - ), - ).toBeInstanceOf(WorkflowRunNotExecutableError); - } finally { - router.handleClose(ws); - } - }, - ); - - test("sends staging frames while the run can execute", async () => { - const { router, ws } = await connectForInitialization(); - const stepAddress = "step_admission@example.test"; - await router.bindAllocatedStepRoute(target, stepAddress); - const types = new Set(); - const sent = Promise.withResolvers(); - const send = ws.send.bind(ws); - ws.send = (raw) => { - send(raw); - types.add(HubFrame.assert(JSON.parse(raw)).type); - if (types.has("agent.deploy") && types.has("repo.pack.done")) - sent.resolve(undefined); - }; - const provisioned = router - .sendProvisionStepToAllocation(target, stepAddress, deployConfig) - .catch((cause: unknown) => cause); - const packed = router - .sendPackToAllocation( - target, - stepAddress, - new Uint8Array([1]), - "refs/heads/main", - "a".repeat(40), - ) - .catch((cause: unknown) => cause); - try { - await Promise.race([sent.promise, Promise.all([provisioned, packed])]); - expect([...types]).toEqual( - expect.arrayContaining([ - "agent.deploy", - "repo.pack.push", - "repo.pack.done", - ]), - ); - } finally { - router.handleClose(ws); - await provisioned; - await packed; - } - }); - test("cancellation can commit after the deploy send without waiting for its acknowledgement", async () => { const { router, ws } = await connectForInitialization(); const sent = Promise.withResolvers(); @@ -401,6 +305,7 @@ describe.skipIf(!harnessDbEnvAvailable())( target, target.workflowRunAddress, deployConfig, + TEST_WORKFLOW, ); const result = deployment.catch((cause: unknown) => cause); try { diff --git a/tests/db/workflow-probe-cache.test.ts b/tests/db/workflow-probe-cache.test.ts index 62e096b90..d7ae17492 100644 --- a/tests/db/workflow-probe-cache.test.ts +++ b/tests/db/workflow-probe-cache.test.ts @@ -591,7 +591,6 @@ describe.skipIf(!harnessDbEnvAvailable())( }, sourceOfferingIds: ["offering"], defaultSourceOfferingId: "offering", - deployContent: {}, }); const changed = { perStep: [ diff --git a/tests/db/workflow-run-launch-spec-store.test.ts b/tests/db/workflow-run-launch-spec-store.test.ts index b900d3f87..11fe6833e 100644 --- a/tests/db/workflow-run-launch-spec-store.test.ts +++ b/tests/db/workflow-run-launch-spec-store.test.ts @@ -92,7 +92,6 @@ describe.skipIf(!harnessDbEnvAvailable())( frozenApprovalBundle: FROZEN_APPROVAL_BUNDLE, sourceOfferingIds: ["offering-primary", "offering-fallback"], defaultSourceOfferingId: "offering-primary", - deployContent: { systemPrompt: "" }, toolPackagePins: [{ name: "@intx/search", version: "^1.2.0" }], }); @@ -126,7 +125,6 @@ describe.skipIf(!harnessDbEnvAvailable())( frozenApprovalBundle: FROZEN_APPROVAL_BUNDLE, sourceOfferingIds: ["offering-primary"], defaultSourceOfferingId: "offering-missing", - deployContent: { systemPrompt: "" }, }), ).rejects.toThrow(/default source.*is not in/); expect(await store.get(ANCHOR_RUN_ID)).toBeNull(); diff --git a/tests/db/workflow-run-terminal-flip.test.ts b/tests/db/workflow-run-terminal-flip.test.ts index 83f56abd2..96cebc50a 100644 --- a/tests/db/workflow-run-terminal-flip.test.ts +++ b/tests/db/workflow-run-terminal-flip.test.ts @@ -256,7 +256,6 @@ describe.skipIf(!harnessDbEnvAvailable())( address: DEPLOYMENT_ADDRESS, messageId, receivedAt: now, - mailAuditRef: { store: "audit", path: `mail/${messageId}` }, }); await dequeueToProcessing(store, writer, repoId, DEPLOYMENT_ADDRESS); await markConsumed(store, writer, repoId, { diff --git a/tests/hub-agent/lib/deploy-flow-env.ts b/tests/hub-agent/lib/deploy-flow-env.ts index 8666ad96d..ca5e3c58c 100644 --- a/tests/hub-agent/lib/deploy-flow-env.ts +++ b/tests/hub-agent/lib/deploy-flow-env.ts @@ -19,7 +19,7 @@ // // The fixture exposes the hub handle, the inference request capture, the // sidecar process handle, and a `sidecarDiagnostics()` callback that -// surfaces sidecar stderr and hub state-pack receive failures. A wait helper +// surfaces sidecar stderr and hub pack receive failures. A wait helper // given a `timeoutMs` renders it when that bound lapses; `teardown()` renders // it when a wait is still in flight, which is what a wedged test leaves // behind, and then stops that wait so it does not poll on into the env @@ -536,7 +536,7 @@ function countToolResults(req: InferenceRequest): number { // // Returns a canned Anthropic-style SSE assistant response that includes the // tool names it was given in the request. This lets tests assert that the -// harness passed the deploy-tree tools through to inference. +// harness passed the step's tools through to inference. // // With `opts.toolCall`, the first request that exposes the named tool // instead returns a `tool_use` turn so the agent executes the tool and @@ -770,8 +770,7 @@ export type HubEnv = { agentRepoStore: AgentRepoStore; agentEvents: { addr: string; sid: string; event: unknown }[]; deployAcks: Map; - statePacks: { agentAddress: string; ref: string; commitSha: string }[]; - statePackReceiveFailures: { agentAddress: string; error: string }[]; + workflowRunPackReceiveFailures: { agentAddress: string; error: string }[]; /** * Every delivered `mail.outbound` frame the sidecar forwarded to the hub * for persistence, keyed by the signing sender. A frame reaches here only @@ -845,8 +844,8 @@ export async function startHub( ): Promise { const agentEvents: HubEnv["agentEvents"] = []; const deployAcks = new Map(); - const statePacks: HubEnv["statePacks"] = []; - const statePackReceiveFailures: HubEnv["statePackReceiveFailures"] = []; + const workflowRunPackReceiveFailures: HubEnv["workflowRunPackReceiveFailures"] = + []; const outboundMail: HubEnv["outboundMail"] = []; // Every live server-side WsHandle, so the reconnect helpers can force-close // the sidecar's hub link. Populated by the upgrade callback's onOpen/onClose. @@ -909,7 +908,6 @@ export async function startHub( const router = createSidecarRouter({ withExecutableWorkflowRun: async (_target, send) => send(), requestTimeoutMs: 10_000, - hubPublicKey: hexEncode(hubSigningKey.publicKey), // The spawned sidecar presents TOKEN on its handshake; verify it and // resolve to the fixed integration sidecar id, exercising the real // token-authenticated handshake rather than accepting any token. @@ -925,43 +923,6 @@ export async function startHub( }, validateSidecarIdentity: async () => true, lookups: { - async receiveAgentStatePack(repoId, pack, ref, commitSha) { - if (repoId.kind !== "agent-state") { - throw new Error( - `deploy-flow test mock received unsupported repo kind ${JSON.stringify(repoId.kind)}`, - ); - } - const agentAddress = repoId.id; - const agentId = deriveWorkflowRunId(agentAddress); - // Mirror createHubSessionLookups' fallback branch only: catch - // every receive failure and surface it as a structured "corrupt" - // rejection, so a transient (e.g. the agent directory being torn - // down concurrently with an in-flight pack write) does not - // propagate as an unhandled rejection through the WebSocket - // message handler. The production lookups distinguish a - // path_violation prefix and report that as a separate reason; - // this mock does not, because this fixture never exercises - // tree-validator rejection. - try { - await agentRepoStore.receiveAgentStatePack( - { kind: "agent-state", id: agentId }, - pack, - ref, - commitSha, - ); - } catch (err) { - // Capture the underlying error into the hub's diagnostic - // buffer so a regression does not hide behind the catch. - // sidecarDiagnostics surfaces this on waitFor timeouts, and - // tests that care can inspect hub.statePackReceiveFailures - // directly. - const message = err instanceof Error ? err.message : String(err); - statePackReceiveFailures.push({ agentAddress, error: message }); - return { accepted: false, reason: "corrupt" as const }; - } - statePacks.push({ agentAddress, ref, commitSha }); - return { accepted: true }; - }, async receiveWorkflowRunPack(repoId, pack, ref, commitSha) { if (repoId.kind !== "workflow-run") { throw new Error( @@ -1000,7 +961,7 @@ export async function startHub( ); } catch (err) { const message = err instanceof Error ? err.message : String(err); - statePackReceiveFailures.push({ + workflowRunPackReceiveFailures.push({ agentAddress: repoId.id, error: `workflow-run pack: ${message}`, }); @@ -1106,46 +1067,11 @@ export async function startHub( deployAcks.set(agentAddress, publicKey); }); - // The DB stub satisfies the narrow surface the session-service paths the - // deploy tests exercise actually consult: a tenant lookup and the - // session_asset insert/delete audit writes. Its other members throw on - // access so the test fails loudly if production code drifts into a - // dependency the stub does not cover. No package-registry asset or - // tool-package registry is wired: deploy tests carry their tools inline in - // the workflow source closure, so the session-service tool-package resolver - // path is never entered. - const fakeDb = { - query: { - tenant: { - findFirst: async (_args: unknown) => - ({ parentId: null }) as { parentId: string | null }, - }, - }, - insert(_table: unknown) { - return { - values(_row: unknown) { - return Promise.resolve(); - }, - }; - }, - delete(_table: unknown) { - return { - where(_predicate: unknown) { - return Promise.resolve(); - }, - }; - }, - }; - const sessionService = createSessionService({ loadRunCredentials: opts.loadRunCredentials ?? (async () => null), sidecarRouter: router, sidecarAllocationRouter: router, agentRepoStore, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the stub satisfies the narrow surface the session-service paths the deploy tests exercise actually call (query.tenant.findFirst, insert/delete), but cannot structurally satisfy the full drizzle PgDatabase type - db: fakeDb as unknown as NonNullable< - Parameters[0]["db"] - >, }); const app = new Hono(); @@ -1236,8 +1162,7 @@ export async function startHub( agentRepoStore, agentEvents, deployAcks, - statePacks, - statePackReceiveFailures, + workflowRunPackReceiveFailures, outboundMail, hubDataDir, liveHandles, @@ -1529,7 +1454,7 @@ export type DeployFlowEnv = { inference: MockInference; sidecar: SidecarHandle; /** - * Sidecar stderr plus hub state-pack receive failures, rendered for a + * Sidecar stderr plus hub pack receive failures, rendered for a * failing wait. Reports every sidecar registered on the env, so a restart * test's replacement process is covered by every wait that already passes * this function -- including `waitForReconnect`, which wires it itself. @@ -1757,10 +1682,10 @@ export async function startDeployFlowEnv( const label = index === 0 ? "sidecar" : `sidecar #${String(index)}`; parts.push(`${label} stderr:\n${handle.stderr.slice(-300).join("")}`); } - const failures = hub.statePackReceiveFailures; + const failures = hub.workflowRunPackReceiveFailures; if (failures.length > 0) { parts.push( - `state-pack receive failures (last ${String(Math.min(failures.length, 10))}):\n` + + `pack receive failures (last ${String(Math.min(failures.length, 10))}):\n` + failures .slice(-10) .map((f) => ` ${f.agentAddress}: ${f.error}`) @@ -2322,8 +2247,7 @@ function reapTerminalDeployments(env: DeployFlowEnv): void { * Read every event under `runs//events/` from the deployment's * workflow-run repo and return them in ascending `seq` order. Returns * an empty array when the run has not yet committed any events or the - * repo has not yet been created (e.g. the deployment hasn't taken the - * multi-step branch yet). Delegates to the shared hub-side + * repo has not yet been created. Delegates to the shared hub-side * `WorkflowRunReader` so the fixture and the REST route project the * substrate through one reader. */ @@ -2736,10 +2660,6 @@ export async function simulateProcessingCrash( address, messageId, receivedAt, - mailAuditRef: { - store: "deploy-flow-env-simulated-crash", - path: `${address}/${messageId}`, - }, }, ); const dequeued = await dequeueToProcessing( diff --git a/tests/hub-api/asset-routes.test.ts b/tests/hub-api/asset-routes.test.ts index c7aa0c06e..2c342d0ec 100644 --- a/tests/hub-api/asset-routes.test.ts +++ b/tests/hub-api/asset-routes.test.ts @@ -158,7 +158,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: notImplemented("sessionService.stageWorkflowStep"), endSession: notImplemented("sessionService.endSession"), }; } diff --git a/tests/hub-api/blob-route-folded-run.test.ts b/tests/hub-api/blob-route-folded-run.test.ts index 85b889312..d57c77f4b 100644 --- a/tests/hub-api/blob-route-folded-run.test.ts +++ b/tests/hub-api/blob-route-folded-run.test.ts @@ -123,7 +123,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/credential-routes.test.ts b/tests/hub-api/credential-routes.test.ts index 5c5c5ea79..b21779502 100644 --- a/tests/hub-api/credential-routes.test.ts +++ b/tests/hub-api/credential-routes.test.ts @@ -130,7 +130,6 @@ function capturingCredentialsRouter(): { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/delegator-grant-routes.test.ts b/tests/hub-api/delegator-grant-routes.test.ts index de3d83892..616b0af01 100644 --- a/tests/hub-api/delegator-grant-routes.test.ts +++ b/tests/hub-api/delegator-grant-routes.test.ts @@ -81,7 +81,6 @@ function mockSessionService(): SessionService { throw new Error(`mock: sessionService.${name} not implemented`); }; return { - stageWorkflowStep: notImpl("stageWorkflowStep"), endSession: notImpl("endSession"), }; } diff --git a/tests/hub-api/deployer-invoker-grants.test.ts b/tests/hub-api/deployer-invoker-grants.test.ts index 7c9d846a1..c16c0297d 100644 --- a/tests/hub-api/deployer-invoker-grants.test.ts +++ b/tests/hub-api/deployer-invoker-grants.test.ts @@ -86,7 +86,6 @@ describe.skipIf(!harnessDbEnvAvailable())( frozenApprovalBundle: {}, sourceOfferingIds: [], defaultSourceOfferingId: "ofr_deployer", - deployContent: {}, }); } diff --git a/tests/hub-api/federation-routes.test.ts b/tests/hub-api/federation-routes.test.ts index ace83f9ef..31ed0c7cc 100644 --- a/tests/hub-api/federation-routes.test.ts +++ b/tests/hub-api/federation-routes.test.ts @@ -85,7 +85,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/git-agent-state-clone-instance.test.ts b/tests/hub-api/git-agent-state-clone-instance.test.ts deleted file mode 100644 index 1b3bfd6a0..000000000 --- a/tests/hub-api/git-agent-state-clone-instance.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -// End-to-end clone of an agent-state per-run repo against the -// real `/usr/bin/git`. -// -// The per-run repo is materialised lazily by the sidecar's -// first state pack. These tests cover the read path BEFORE any pack -// has landed: the route layer resolves the folded run by id, the -// advertise layer emits the `capabilities^{}` empty-repo record, -// and `git clone` succeeds with an empty working tree. -// -// We bypass the API-driven launch flow (which requires a connected -// sidecar + a resolvable credential requirement) and insert the folded -// `workflow_run` row + the creator-seed agent-state grant directly -// against the hub's schema. The schema is the same one the spawned -// hub runs against; both processes share the postgres database, so -// the inserts are visible to the running hub immediately. -// -// Note on the wire-format gaps documented at the dispatch level: -// - Shallow clone is not advertised; `git clone --depth=1` returns -// empty. Not exercised here. - -import { describe, test, expect, afterEach } from "bun:test"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import postgres from "postgres"; - -import { generateId } from "@intx/hub-common"; - -import { - harnessHubEnvAvailable, - runGit, - startHub, - type HubHandle, -} from "./lib/git-harness"; -import { loadHarnessDbConfig } from "@intx/test-harness/db-harness"; -import { - createTenant, - mintTenantGitToken, - seedInstanceDefinition, - signUpUser, - tokenEnv, - type CreatedTenant, - type SignedUpUser, -} from "./lib/git-asset-fixtures"; - -const stops: (() => Promise)[] = []; -const tempDirs: string[] = []; - -afterEach(async () => { - for (const stop of stops.splice(0)) { - await stop(); - } - await Promise.all( - tempDirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })), - ); -}); - -async function mkTemp(prefix: string): Promise { - const d = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(d); - return d; -} - -async function startHubTracked(): Promise { - const hub = await startHub(); - stops.push(hub.stop); - return hub; -} - -/** - * Direct insert of a folded `workflow_run` row (instance-shaped: a routing - * address and no deployment) + the `agent-state:` creator-read grant. - * Bypasses the launch endpoint which depends on a connected sidecar + a - * resolvable credential requirement. - * - * Returns the synthetic run id; the caller uses this id in the smart-HTTP URL. - */ -async function seedRunRow( - schema: string, - user: SignedUpUser, - tenant: CreatedTenant, - definitionId: string, -): Promise<{ runId: string; creatorPrincipalId: string }> { - const dbConfig = loadHarnessDbConfig(); - const sql = postgres({ - host: dbConfig.host, - port: dbConfig.port, - user: dbConfig.user, - password: dbConfig.password, - database: dbConfig.database, - max: 1, - connection: { search_path: `"${schema.replace(/"/g, '""')}"` }, - }); - try { - // Look up the creator's tenant principal (created when the - // tenant was provisioned). - const principalRows = await sql< - { id: string }[] - >`select id from principal where tenant_id = ${tenant.tenantId} and kind = 'user' and ref_id = ${user.userId}`; - const creatorPrincipal = principalRows[0]; - if (creatorPrincipal === undefined) { - throw new Error( - `seedRunRow: no principal for user ${user.userId} in tenant ${tenant.tenantId}`, - ); - } - const creatorPrincipalId = creatorPrincipal.id; - - const runId = generateId("workflowRun"); - - // Tenant domain controls the run address; lookup the row. - const tenantRows = await sql< - { domain: string }[] - >`select domain from tenant where id = ${tenant.tenantId}`; - const tenantDomainRow = tenantRows[0]; - if (tenantDomainRow === undefined) { - throw new Error(`seedRunRow: no tenant row for ${tenant.tenantId}`); - } - const address = `${runId}@${tenantDomainRow.domain}`; - - // A folded launch run: `deployment_id` NULL and a routing address, so the - // route's instance-shape gate resolves it. `principal_id` is the invoker; - // the route does not consult it, verifying only tenant binding and the - // bearer-token principal's grant. - await sql`insert into workflow_run (id, tenant_id, definition_id, principal_id, address, status) - values (${runId}, ${tenant.tenantId}, ${definitionId}, ${creatorPrincipalId}, ${address}, 'running')`; - - // Mirror the seed grant that the launch path writes: creator reads the - // per-run agent-state repo. `grant` is a reserved SQL keyword so the table - // identifier is quoted. - const grantId = generateId("grant"); - await sql`insert into "grant" (id, tenant_id, principal_id, resource, action, effect, origin) - values (${grantId}, ${tenant.tenantId}, ${creatorPrincipalId}, ${`agent-state:${runId}`}, 'read', 'allow', 'creator')`; - - return { runId, creatorPrincipalId }; - } finally { - await sql.end(); - } -} - -function runStateGitUrl( - hubUrl: string, - tenantId: string, - runId: string, -): string { - return `${hubUrl}/api/tenants/${tenantId}/workflows/runs/${runId}/state.git`; -} - -describe.skipIf(!harnessHubEnvAvailable())("agent-state per-run clone", () => { - test("creator clones an empty per-run repo", async () => { - const hub = await startHubTracked(); - const user = await signUpUser(hub.url); - const tenant = await createTenant(hub.url, user); - const def = await seedInstanceDefinition( - hub.schema, - user, - tenant, - "clone-creator-agent", - ); - const run = await seedRunRow(hub.schema, user, tenant, def.definitionId); - - const token = await mintTenantGitToken(hub.url, user, tenant, { - resource: `agent-state:${run.runId}`, - refPattern: "**", - actions: ["can_read"], - }); - - const env = await tokenEnv(token.secret); - const cwd = await mkTemp("agent-state-creator-clone-"); - const cloneTarget = path.join(cwd, "repo"); - const remote = runStateGitUrl(hub.url, tenant.tenantId, run.runId); - const result = await runGit( - ["-c", "credential.helper=", "clone", remote, cloneTarget], - { cwd, env }, - ); - if (result.status !== 0) { - throw new Error( - `git clone exited ${result.status}: ${result.stderr}\nstdout: ${result.stdout}`, - ); - } - // Empty repo: clone succeeds, working tree exists, no refs. - const verify = await runGit(["rev-parse", "--is-inside-work-tree"], { - cwd: cloneTarget, - }); - if (verify.status !== 0) { - throw new Error(`rev-parse failed: ${verify.stderr}`); - } - expect(verify.stdout.trim()).toBe("true"); - const refs = await runGit(["for-each-ref"], { cwd: cloneTarget }); - expect(refs.status).toBe(0); - expect(refs.stdout.trim()).toBe(""); - }, 90_000); - - test("admin (tenant owner *:*) clones the per-run repo", async () => { - // The tenant owner role is granted `*:*`; the route layer - // authz check passes on `agent-state:` `read` via that - // catch-all. This scenario exercises the admin grant path - // distinctly from the creator seed grant by minting a token - // whose only authority is the owner's `*:*` grant — but in - // this fixture the creator IS the owner, so the two paths - // overlap. A separate admin-only flow without owner status - // would require additional tenant member orchestration; here we - // confirm the admin-grant code path returns 200 by exercising - // the same `*:*` chain. - const hub = await startHubTracked(); - const user = await signUpUser(hub.url); - const tenant = await createTenant(hub.url, user); - const def = await seedInstanceDefinition( - hub.schema, - user, - tenant, - "clone-admin-agent", - ); - const run = await seedRunRow(hub.schema, user, tenant, def.definitionId); - const token = await mintTenantGitToken(hub.url, user, tenant, { - resource: `agent-state:${run.runId}`, - refPattern: "**", - actions: ["can_read"], - }); - const env = await tokenEnv(token.secret); - const cwd = await mkTemp("agent-state-admin-clone-"); - const remote = runStateGitUrl(hub.url, tenant.tenantId, run.runId); - const result = await runGit( - ["-c", "credential.helper=", "clone", remote, path.join(cwd, "repo")], - { cwd, env }, - ); - if (result.status !== 0) { - throw new Error( - `git clone exited ${result.status}: ${result.stderr}\nstdout: ${result.stdout}`, - ); - } - }, 90_000); - - test("non-tenant token is denied at advertise", async () => { - // A second user signs up and creates their own tenant; the - // bearer middleware binds the token to that other tenant. Used - // against the original tenant's instance URL, the middleware - // rejects with 403 tenant_mismatch — the cleanest "this - // principal has no business reading that repo" surface this - // test file can produce without orchestrating fine-grained - // member roles. Stock `git clone` surfaces 403 as - // `http 403`/`forbidden` in stderr. - const hub = await startHubTracked(); - const userA = await signUpUser(hub.url); - const tenantA = await createTenant(hub.url, userA); - const def = await seedInstanceDefinition( - hub.schema, - userA, - tenantA, - "clone-denied-agent", - ); - const run = await seedRunRow(hub.schema, userA, tenantA, def.definitionId); - - const userB = await signUpUser(hub.url); - const tenantB = await createTenant(hub.url, userB); - const tokenB = await mintTenantGitToken(hub.url, userB, tenantB, { - resource: `agent-state:${run.runId}`, - refPattern: "**", - actions: ["can_read"], - }); - - const advertiseUrl = `${runStateGitUrl( - hub.url, - tenantA.tenantId, - run.runId, - )}/info/refs?service=git-upload-pack`; - const res = await fetch(advertiseUrl, { - headers: { - Authorization: `Bearer ${tokenB.secret}`, - }, - }); - expect(res.status).toBe(403); - }, 90_000); -}); diff --git a/tests/hub-api/git-agent-state-push-denied.test.ts b/tests/hub-api/git-agent-state-push-denied.test.ts deleted file mode 100644 index 2b361690a..000000000 --- a/tests/hub-api/git-agent-state-push-denied.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -// `git push -v` against the per-run agent-state URL returns 403 -// at the advertise step. The denial message names "read-only" so -// support can recognise it. -// -// The receive-pack denial routes are registered BEFORE the bearer -// middleware, so the test does not need to mint a token: an -// unauthenticated `git push -v` against the per-run URL still -// parses the pkt-line ERR record cleanly. -// -// We also verify the raw advertise body shape (the locked -// `ERR agent-state is read-only over HTTP\n` payload) via a direct -// fetch alongside the `git push -v` inspection. - -import { describe, test, expect, afterEach } from "bun:test"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - -import { - harnessHubEnvAvailable, - runGit, - startHub, - type HubHandle, -} from "./lib/git-harness"; -import { createTenant, signUpUser } from "./lib/git-asset-fixtures"; - -const stops: (() => Promise)[] = []; -const tempDirs: string[] = []; - -afterEach(async () => { - for (const stop of stops.splice(0)) { - await stop(); - } - await Promise.all( - tempDirs.splice(0).map((d) => fs.rm(d, { recursive: true, force: true })), - ); -}); - -async function mkTemp(prefix: string): Promise { - const d = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(d); - return d; -} - -async function startHubTracked(): Promise { - const hub = await startHub(); - stops.push(hub.stop); - return hub; -} - -/** - * Prepare a local non-bare repo with a single commit on - * `refs/heads/deploy` so `git push` has a ref + content to send. We - * never expect the push to succeed — the deny middleware rejects - * advertise before any pack is uploaded — but git needs a real - * source branch to dispatch a push at all. - */ -async function prepareLocalPushSource(workDir: string): Promise { - await runGit(["init", "--initial-branch=deploy", workDir], { cwd: workDir }); - await runGit(["config", "user.name", "push-tester"], { cwd: workDir }); - await runGit(["config", "user.email", "push@example.invalid"], { - cwd: workDir, - }); - await runGit(["config", "commit.gpgsign", "false"], { cwd: workDir }); - await fs.mkdir(path.join(workDir, "deploy"), { recursive: true }); - await fs.writeFile( - path.join(workDir, "deploy", "prompt.md"), - "# local\n", - "utf-8", - ); - await runGit(["add", "deploy/prompt.md"], { cwd: workDir }); - const r = await runGit(["commit", "-m", "Local commit for push test"], { - cwd: workDir, - }); - if (r.status !== 0) { - throw new Error(`local commit failed: ${r.stderr}`); - } -} - -function runStateGitUrl( - hubUrl: string, - tenantId: string, - runId: string, -): string { - return `${hubUrl}/api/tenants/${tenantId}/workflows/runs/${runId}/state.git`; -} - -describe.skipIf(!harnessHubEnvAvailable())("agent-state push denied", () => { - test("advertise deny body names read-only", async () => { - const hub = await startHubTracked(); - const user = await signUpUser(hub.url); - const tenant = await createTenant(hub.url, user); - - // The receive-pack advertise deny middleware runs ahead of the - // bearer middleware and the resolver, so an unauthenticated probe - // on a bogus instance id still yields the locked 403 body verbatim. - const res = await fetch( - `${runStateGitUrl(hub.url, tenant.tenantId, "run_doesnotexist")}/info/refs?service=git-receive-pack`, - ); - expect(res.status).toBe(403); - const body = await res.text(); - expect(body).toContain("agent-state is read-only over HTTP"); - }, 90_000); - - test("git push -v against per-run URL returns 403 with read-only message", async () => { - const hub = await startHubTracked(); - const user = await signUpUser(hub.url); - const tenant = await createTenant(hub.url, user); - - // No instance row required: the receive-pack deny runs before - // the bearer middleware AND before the resolver, so it 403s - // even on a bogus instance id. This is the desired property — - // even unauthenticated push attempts get a clean protocol-level - // rejection before any DB lookup. - const workDir = await mkTemp("agent-state-push-ins-"); - await prepareLocalPushSource(workDir); - - const remote = runStateGitUrl(hub.url, tenant.tenantId, "run_fakefakefake"); - const push = await runGit( - [ - "-c", - "credential.helper=", - "push", - "-v", - remote, - "refs/heads/deploy:refs/heads/deploy", - ], - { cwd: workDir }, - ); - expect(push.status).not.toBe(0); - const combined = `${push.stdout}\n${push.stderr}`.toLowerCase(); - // Stock git surfaces an HTTP 403 advertise denial as - // `the requested url returned error: 403` in stderr. The - // `read-only`/`agent-state` substring is best-effort: git - // strips most of the body unless the server emits a properly - // pkt-line-framed `ERR` record on the wire. The advertise body - // is verified separately via the direct-fetch test above. - expect(combined).toMatch(/error: 403|http 403|forbidden/); - }, 90_000); -}); diff --git a/tests/hub-api/git-asset-create-roundtrip.test.ts b/tests/hub-api/git-asset-create-roundtrip.test.ts index c028f15d4..2940ec034 100644 --- a/tests/hub-api/git-asset-create-roundtrip.test.ts +++ b/tests/hub-api/git-asset-create-roundtrip.test.ts @@ -53,7 +53,6 @@ describe.skipIf(!harnessHubEnvAvailable())( const user = await signUpUser(hub.url); const tenant = await createTenant(hub.url, user); const asset = await createAsset(hub.url, user, tenant, { - kind: "skill", name: "freshly-created", }); const token = await mintTenantGitToken(hub.url, user, tenant, { diff --git a/tests/hub-api/grant-evaluate-consumer.test.ts b/tests/hub-api/grant-evaluate-consumer.test.ts index d4090eac3..74315b76d 100644 --- a/tests/hub-api/grant-evaluate-consumer.test.ts +++ b/tests/hub-api/grant-evaluate-consumer.test.ts @@ -104,7 +104,6 @@ function mockSessionService(): SessionService { throw new Error(`mock: sessionService.${name} not implemented`); }; return { - stageWorkflowStep: notImpl("stageWorkflowStep"), endSession: notImpl("endSession"), }; } diff --git a/tests/hub-api/grant-routes.test.ts b/tests/hub-api/grant-routes.test.ts index 2305612f9..aa28f9a53 100644 --- a/tests/hub-api/grant-routes.test.ts +++ b/tests/hub-api/grant-routes.test.ts @@ -73,7 +73,6 @@ function mockSessionService(): SessionService { throw new Error(`mock: sessionService.${name} not implemented`); }; return { - stageWorkflowStep: notImpl("stageWorkflowStep"), endSession: notImpl("endSession"), }; } diff --git a/tests/hub-api/invite-route.test.ts b/tests/hub-api/invite-route.test.ts index c8e8bfc7b..11e0b2c3a 100644 --- a/tests/hub-api/invite-route.test.ts +++ b/tests/hub-api/invite-route.test.ts @@ -89,7 +89,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/lib/git-asset-fixtures.ts b/tests/hub-api/lib/git-asset-fixtures.ts index 8508e45ac..e0839a76b 100644 --- a/tests/hub-api/lib/git-asset-fixtures.ts +++ b/tests/hub-api/lib/git-asset-fixtures.ts @@ -13,12 +13,9 @@ // test failure is actionable. import { type } from "arktype"; -import postgres from "postgres"; import { hexEncode } from "@intx/types"; import type { RepoAction } from "@intx/types/repo"; -import { generateId } from "@intx/hub-common"; -import { loadHarnessDbConfig } from "@intx/test-harness/db-harness"; import { tokenAskpassEnv } from "./git-harness"; @@ -37,7 +34,7 @@ export type CreatedTenant = { export type CreatedAsset = { assetId: string; tenantId: string; - kind: "skill" | "agent-state"; + kind: "skill"; name: string; }; @@ -194,56 +191,13 @@ export async function createTenant( return { tenantId, slug }; } -// Insert a `workflow_definition` directly, bypassing any HTTP surface. The -// agent-state git route resolves the repo through the run that keys on this -// definition, so a real definition must exist for the smart-HTTP request to -// pass tenant binding rather than 404. Returns the definition id any -// session/run row keys on. Runs against the hub's per-test schema via -// search_path. -export async function seedInstanceDefinition( - schema: string, - user: SignedUpUser, - tenant: CreatedTenant, - name: string, -): Promise<{ definitionId: string }> { - const dbConfig = loadHarnessDbConfig(); - const sql = postgres({ - host: dbConfig.host, - port: dbConfig.port, - user: dbConfig.user, - password: dbConfig.password, - database: dbConfig.database, - max: 1, - connection: { search_path: `"${schema.replace(/"/g, '""')}"` }, - }); - try { - const principalRows = await sql< - { id: string }[] - >`select id from principal where tenant_id = ${tenant.tenantId} and kind = 'user' and ref_id = ${user.userId}`; - const creatorPrincipal = principalRows[0]; - if (creatorPrincipal === undefined) { - throw new Error( - `seedInstanceDefinition: no principal for user ${user.userId} in tenant ${tenant.tenantId}`, - ); - } - const definitionId = generateId("workflowDefinition"); - await sql`insert into workflow_definition - (id, tenant_id, creator_principal_id, name) - values (${definitionId}, ${tenant.tenantId}, - ${creatorPrincipal.id}, ${name})`; - return { definitionId }; - } finally { - await sql.end(); - } -} - export async function createAsset( hubUrl: string, user: SignedUpUser, tenant: CreatedTenant, - opts: { kind?: "skill" | "agent-state"; name?: string } = {}, + opts: { name?: string } = {}, ): Promise { - const kind = opts.kind ?? "skill"; + const kind = "skill"; const name = opts.name ?? `asset-${randomSuffix()}`; const res = await apiCall( hubUrl, diff --git a/tests/hub-api/lib/git-harness.ts b/tests/hub-api/lib/git-harness.ts index d3f50d711..0273ba2c3 100644 --- a/tests/hub-api/lib/git-harness.ts +++ b/tests/hub-api/lib/git-harness.ts @@ -441,7 +441,7 @@ export type HubHandle = { /** * On-disk root the hub was spawned with. Tests that need to * pre-stage repo content under `//` - * (e.g. seeding agent-state deploy artifacts ahead of a clone test) + * (e.g. seeding artifacts ahead of a clone test) * read this path. The directory is cleaned up by `stop`; tests must * not delete it themselves. */ diff --git a/tests/hub-api/offering-definition-repoint.test.ts b/tests/hub-api/offering-definition-repoint.test.ts index 7169cd855..4f6afd825 100644 --- a/tests/hub-api/offering-definition-repoint.test.ts +++ b/tests/hub-api/offering-definition-repoint.test.ts @@ -86,7 +86,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/principal-routes.test.ts b/tests/hub-api/principal-routes.test.ts index 0fad4c756..55c0e8ebf 100644 --- a/tests/hub-api/principal-routes.test.ts +++ b/tests/hub-api/principal-routes.test.ts @@ -103,7 +103,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/run-list.test.ts b/tests/hub-api/run-list.test.ts index df4a718da..74b880df5 100644 --- a/tests/hub-api/run-list.test.ts +++ b/tests/hub-api/run-list.test.ts @@ -86,7 +86,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/run-token-auth.test.ts b/tests/hub-api/run-token-auth.test.ts index 111e265e0..9c17a03fb 100644 --- a/tests/hub-api/run-token-auth.test.ts +++ b/tests/hub-api/run-token-auth.test.ts @@ -97,7 +97,6 @@ function mockSessionService(): SessionService { throw new Error(`mock: sessionService.${name} not implemented`); }; return { - stageWorkflowStep: notImpl("stageWorkflowStep"), endSession: notImpl("endSession"), }; } @@ -315,7 +314,6 @@ describe.skipIf(!harnessDbEnvAvailable())( ["HEAD", RUNS], ["GET", `/api/tenants/${TENANT_ID}`], ["PATCH", `/api/tenants/${TENANT_ID}`], - ["GET", `${RUNS}/run_x/state.git/info/refs`], ]; for (const [method, path] of refused) { const res = await asRun(app, path, { method }); diff --git a/tests/hub-api/trigger-delegator-race.test.ts b/tests/hub-api/trigger-delegator-race.test.ts index d28481e26..e26248f78 100644 --- a/tests/hub-api/trigger-delegator-race.test.ts +++ b/tests/hub-api/trigger-delegator-race.test.ts @@ -65,7 +65,6 @@ function notImpl(name: string): never { function mockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/wallet-routes.test.ts b/tests/hub-api/wallet-routes.test.ts index ff4bdb2ea..78b6332fa 100644 --- a/tests/hub-api/wallet-routes.test.ts +++ b/tests/hub-api/wallet-routes.test.ts @@ -88,7 +88,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/workflow-deployment-list.test.ts b/tests/hub-api/workflow-deployment-list.test.ts index d5adef45e..506f43efc 100644 --- a/tests/hub-api/workflow-deployment-list.test.ts +++ b/tests/hub-api/workflow-deployment-list.test.ts @@ -92,7 +92,6 @@ function createMockSidecarRouter(): SidecarRouter { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/hub-api/workflow-lifecycle-policy-routes.test.ts b/tests/hub-api/workflow-lifecycle-policy-routes.test.ts index 5f1441b6a..f191d742b 100644 --- a/tests/hub-api/workflow-lifecycle-policy-routes.test.ts +++ b/tests/hub-api/workflow-lifecycle-policy-routes.test.ts @@ -73,7 +73,6 @@ function mockSessionService(): SessionService { throw new Error(`mock: sessionService.${name} not implemented`); }; return { - stageWorkflowStep: notImpl("stageWorkflowStep"), endSession: notImpl("endSession"), }; } diff --git a/tests/lib/seed.ts b/tests/lib/seed.ts index 08132bc77..cdd278523 100644 --- a/tests/lib/seed.ts +++ b/tests/lib/seed.ts @@ -420,7 +420,6 @@ export async function seedWorkflowRunLaunchSpec( grantSnapshot: s.grantSnapshot, sourceOfferingIds: [], defaultSourceOfferingId: "offering", - deployContent: {}, }); } diff --git a/tests/tool-packaging/end-to-end.test.ts b/tests/tool-packaging/end-to-end.test.ts index b8ea46f49..e217be0d2 100644 --- a/tests/tool-packaging/end-to-end.test.ts +++ b/tests/tool-packaging/end-to-end.test.ts @@ -1,15 +1,14 @@ // End-to-end integration of the tool-package pipeline. // -// Exercises the gluing between the hub-side resolver, the deploy-tree -// round-trip, and the sidecar-side loader+atomic-apply. The test does +// Exercises the gluing between the hub-side resolver and the sidecar-side +// loader+atomic-apply. The test does // not spin up a subprocess sidecar — the same harness builder code // runs in-process here against synthetic fixtures, which is what the // production path runs against real ones. // // What the test demonstrates per scenario: // -// - happy path: resolver walks pins → manifest written to deploy -// tree → readDeployTree round-trips → loader materializes → +// - happy path: resolver walks pins → loader materializes → // applyAtomic swaps → loaded factories registered. // // - integrity.mismatch: bytes served by the mock registry do not @@ -43,7 +42,6 @@ import type { ToolPackagePin, } from "@intx/types/tool-packages"; import { getToolPackageSourceContentIdentity } from "@intx/types/tool-packages"; -import { readDeployTree } from "@intx/hub-agent"; let scratch: string; let fixtureRoot: string; @@ -147,7 +145,7 @@ function fakeFactory(id: string) { } describe("hub→sidecar pipeline (happy path)", () => { - test("resolved manifest round-trips through the deploy tree and loads cleanly", async () => { + test("resolved manifest round-trips through disk and loads cleanly", async () => { const fixture = await buildFixture({ name: "tools-fixture", version: "1.0.0", @@ -188,7 +186,7 @@ describe("hub→sidecar pipeline (happy path)", () => { fixture.integrity, ); - // Persist the manifest as the deploy tree would; round-trip it. + // Persist the manifest; round-trip it. const deployDir = path.join(instanceDir, "deploy"); await fs.mkdir(deployDir, { recursive: true }); await fs.writeFile( @@ -470,9 +468,9 @@ describe("hub→sidecar pipeline (asset-backed registry)", () => { * * The fixture returns the bound `readBlob`/`listBlobs` pair the * resolver consumes, plus a writeMount helper that lays the same - * tarball bytes onto disk at the mount path the deploy pack will + * tarball bytes onto disk at the mount path the mount map will * point the loader at. End-to-end: the resolver picks an entry, - * the deploy tree records `(assetId, mount)`, the loader joins + * the mount map records `(assetId, mount)`, the loader joins * `assetRoot + mount + path` and reads the same bytes back. */ async function buildAssetRegistryFixture( @@ -585,28 +583,9 @@ describe("hub→sidecar pipeline (asset-backed registry)", () => { `tarballs/${fixture.tarballs[0]?.filename ?? ""}`, ); - // Persist the manifest + the asset-mounts file the way the agent - // repo's writeDeployTree would, then read it back via the same - // readDeployTree the sidecar uses. The round-trip locks in that - // the loader's view of `assetMounts` matches the session - // service's write shape. - const deployRoot = path.join(scratch, "agent-tree"); - const deployDir = path.join(deployRoot, "deploy"); - await fs.mkdir(deployDir, { recursive: true }); const mountPath = `package-registries/${assetSource.name}/`; - await fs.writeFile( - path.join(deployDir, "tool-packages-manifest.json"), - JSON.stringify(manifest), - ); - await fs.writeFile( - path.join(deployDir, "asset-mounts.json"), - JSON.stringify({ assetMounts: { [assetId]: mountPath } }), - ); - const tree = await readDeployTree(deployRoot); - expect(tree.assetMounts.get(assetId)).toBe(mountPath); - // Materialize the asset tarballs onto disk at the same mount the - // deploy tree advertises. The loader's default tarball fetcher + // Materialize the asset tarballs onto disk at the mount. The loader's default tarball fetcher // resolves `kind: "asset"` entries against `assetRoot + mount + path`. await fixture.writeMount(path.join(assetRoot, mountPath)); @@ -627,7 +606,7 @@ describe("hub→sidecar pipeline (asset-backed registry)", () => { loader, instanceDir, assetRoot, - assetMounts: tree.assetMounts, + assetMounts: new Map([[assetId, mountPath]]), gitDirs: new Map(), attemptId: "atp_asset", previousDeployId: "none", @@ -638,53 +617,4 @@ describe("hub→sidecar pipeline (asset-backed registry)", () => { expect(result.loaded).toHaveLength(1); expect(result.loaded[0]?.factories[0]?.id).toBe("@asset-vendor/tool/main"); }); - - test("asset registry shadows an HTTP registry of the same name", async () => { - // Build two distinct package contents under the same registry - // name `shared`: one in an asset, one served by HTTP. The asset - // source occupies the slot the HTTP source would otherwise hold; - // the resolver picks the asset's bytes, and the manifest entry - // tags `kind: "asset"`. The session service uses the same - // tie-break (asset wins) when building the per-agent registry - // map from the visible-assets set; this test confirms the rule at - // the resolver layer. - const fixture = await buildAssetRegistryFixture([ - { name: "shared-pkg", version: "1.0.0", exportName: "main" }, - ]); - const assetSource = new AssetRegistrySource({ - name: "shared", - assetId: "asset_inner", - readBlob: fixture.readBlob, - listBlobs: fixture.listBlobs, - }); - // Build a registry map that mirrors the session service's - // collision behavior: insert the asset first, then skip the http - // source because the slot is taken. - const registries = new Map(); - registries.set(assetSource.name, assetSource); - const httpName = "shared"; - if (!registries.has(httpName)) { - registries.set( - httpName, - new HttpRegistrySource({ - name: httpName, - config: { url: "https://r.test" }, - fetchPackument: async () => { - throw new Error( - "HTTP source must not be consulted when an asset shadows it", - ); - }, - }), - ); - } - const resolver = createClosureResolver({ - registries, - defaultRegistry: assetSource.name, - }); - const manifest = await resolver.resolveClosure([ - { name: "shared-pkg", version: "1.0.0" }, - ]); - expect(manifest.entries).toHaveLength(1); - expect(manifest.entries[0]?.source.kind).toBe("asset"); - }); }); diff --git a/tests/workflow-deploy/README.md b/tests/workflow-deploy/README.md index a6686f86f..59149840d 100644 --- a/tests/workflow-deploy/README.md +++ b/tests/workflow-deploy/README.md @@ -33,7 +33,7 @@ written), interrupt the process. `trend.unified.slopeMsPerMessage` (ms added per sustained message). - **`latency-d2-attribution.bench.ts`** -- splits the unified path's per-message substrate cost across the individual git-commit legs - (dequeue, run-event, agent-state WAL, ...); writes `d2-results.json`. + (dequeue, run-event, step-state WAL, ...); writes `d2-results.json`. Read `total.slopeMsPerMessage` and `perLeg[].slopeMsPerMessage`. A healthy substrate holds the slopes flat (near zero) as `--messages` diff --git a/tests/workflow-deploy/action-step-credential.test.ts b/tests/workflow-deploy/action-step-credential.test.ts index 90d624dd2..5d4519ff9 100644 --- a/tests/workflow-deploy/action-step-credential.test.ts +++ b/tests/workflow-deploy/action-step-credential.test.ts @@ -256,13 +256,7 @@ async function deploy(opts: { }); const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: anchorRunId, - tenantId: "tenant-1", - principalId: `prin_${anchorRunId}`, agentAddress: mailAddress, - systemPrompt: "unused (no agent step)", - tools: [], - grants: [], sources: [ { id: "anthropic:mock-model", diff --git a/tests/workflow-deploy/all-action-deploy.test.ts b/tests/workflow-deploy/all-action-deploy.test.ts index ef73dee1b..4fb358f00 100644 --- a/tests/workflow-deploy/all-action-deploy.test.ts +++ b/tests/workflow-deploy/all-action-deploy.test.ts @@ -107,13 +107,7 @@ describe.skipIf(!harnessDbEnvAvailable())( // condition under test. const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_all-action-deploy-1", agentAddress: deploymentMailAddress, - systemPrompt: "unused (no agent step)", - tools: [], - grants: [], sources: [ { id: "anthropic:mock-model", diff --git a/tests/workflow-deploy/approval-tracer-roundtrip.test.ts b/tests/workflow-deploy/approval-tracer-roundtrip.test.ts index d47749b44..aead25e5b 100644 --- a/tests/workflow-deploy/approval-tracer-roundtrip.test.ts +++ b/tests/workflow-deploy/approval-tracer-roundtrip.test.ts @@ -247,7 +247,6 @@ function notImpl(name: string): never { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } @@ -479,13 +478,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/asset-source.e2e.test.ts b/tests/workflow-deploy/asset-source.e2e.test.ts index 1321efc75..a26fb32cb 100644 --- a/tests/workflow-deploy/asset-source.e2e.test.ts +++ b/tests/workflow-deploy/asset-source.e2e.test.ts @@ -361,13 +361,7 @@ describe.skipIf(!harnessDbEnvAvailable())("asset-sourced workflow e2e", () => { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/asset-tool-restarts.e2e.test.ts b/tests/workflow-deploy/asset-tool-restarts.e2e.test.ts new file mode 100644 index 000000000..5f7538797 --- /dev/null +++ b/tests/workflow-deploy/asset-tool-restarts.e2e.test.ts @@ -0,0 +1,467 @@ +// A tool from a workflow's mounted source asset keeps working after a workflow +// child crash and after a sidecar restart. +// +// The workflow package is a tarball inside a hub `package-registry` asset, so the +// sidecar mounts the asset and lays the closure out of the mount. It runs +// +// first -> afterCrash -> second -> afterRestart -> third +// +// where each agent step calls a tool that reads the `package.json` beside the +// evaluated module and reports its own pid. The tool result therefore proves +// that the run's grant still authorizes the tool, that the module was evaluated +// from the closure, and which process ran the call. Before +// the restart the test removes the closure's tarball cache, so the restored +// child's closure is rebuilt from the mount. +// +// Harness justification: SPAWN-REAL. Real hub, real sidecar subprocess, real +// workflow child, and real process deaths. Only inference is mocked. + +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import { createNoopCredentialCipher } from "@intx/crypto"; +import { tenant as tenantTable } from "@intx/db/schema"; +import { generateId } from "@intx/hub-common"; +import { + DEFAULT_ASSET_REF, + deployCodeSourcedWorkflow, + installAndApproveWorkflowDefinition, + type RepoId, +} from "@intx/hub-sessions"; +import { + createTestDb, + harnessDbEnvAvailable, + type TestDb, +} from "@intx/test-harness/db-harness"; +import { + seedAsset, + seedPrincipal, + tenantSlugFromId, +} from "@intx/test-harness/seed"; +import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; +import type { WorkflowDefinitionAssetSource } from "@intx/types/workflow-sources"; +import { + createApprovalSet, + deriveRunAddress, + deriveWorkflowRunRepoId, +} from "@intx/workflow-deploy"; + +import { bundleWorkflowEntry } from "../hub-agent/lib/bundle-workflow-entry"; +import { + SESSION_ID, + WORKFLOW_RUN_TERMINAL_TYPES, + fireMailTrigger, + injectSignal, + killWorkflowHostChild, + listWorkflowHostChildren, + readWorkflowRunEvents, + seedInferenceCredentials, + settleWorkflowRunPacks, + startDeployFlowEnv, + terminateSidecarSubprocess, + waitFor, + waitForFirstRunId, + waitForWorkflowRunComplete, + type DeployFlowEnv, + type SidecarHandle, +} from "../hub-agent/lib/deploy-flow-env"; +import { buildSyntheticNpmPackageTarball } from "../hub-agent/lib/synthetic-npm-package-tarball"; +import { + deriveWireRunGrants, + toolResultTexts, +} from "./nested-tool-invoke-helpers"; +import { crashSidecar, restartSidecar } from "./sleep-restart-helpers"; + +const DEPLOYMENT_DOMAIN = "integration.interchange"; +const DEPLOYMENT_ID = generateId("workflowRun"); +const PACKAGE_NAME = "@wf/asset-tool"; +const PACKAGE_VERSION = "1.0.0"; +const WORKFLOW_ENTRY = "./workflow.mjs"; +const TOOL_NAME = "read_package_identity"; +const TOOL_IDENTITY = `${PACKAGE_NAME}@${PACKAGE_VERSION}`; + +const TENANT_ID = "tnt_asset_tool_restarts"; +const CALLER_PRINCIPAL_ID = "prn_asset_tool_restarts"; +const DEFINITION_ASSET_ID = "ast_asset_tool_restarts_wf"; +const SOURCE_ASSET_ID = "ast_asset_tool_restarts_registry"; + +const HUB_PRINCIPAL = { kind: "hub" } as const; +const SOURCE_REPO_ID: RepoId = { + kind: "package-registry", + id: SOURCE_ASSET_ID, +}; + +const mailAddress = deriveRunAddress({ + runId: DEPLOYMENT_ID, + domain: DEPLOYMENT_DOMAIN, +}); + +const workflowEntrySource = ` +import { promises as fs } from "node:fs"; + +import { defineAgent, defineTool } from "@intx/agent"; +import { awaitSignal, defineWorkflow, step } from "@intx/workflow/definition"; + +const readPackageIdentity = defineTool({ + id: "@wf/asset-tool/read-package-identity", + definitions: [{ name: ${JSON.stringify(TOOL_NAME)} }], + factory: () => ({ + definitions: [ + { + name: ${JSON.stringify(TOOL_NAME)}, + description: "Read the identity of the package this tool ships in", + inputSchema: { type: "object", properties: {} }, + }, + ], + run: async (call) => { + const manifest = JSON.parse( + await fs.readFile(new URL("./package.json", import.meta.url), "utf8"), + ); + return { + callId: call.id, + content: manifest.name + "@" + manifest.version + " pid " + process.pid, + }; + }, + }), +}); + +const agent = (id) => + defineAgent({ + id, + systemPrompt: "You read the package identity.", + tools: [readPackageIdentity], + capabilities: [], + inference: { sources: [{ provider: "anthropic", model: "mock-model" }] }, + }); + +export const workflow = defineWorkflow({ + id: "wf_asset_tool_restarts", + trigger: { type: "mail", to: ${JSON.stringify(mailAddress)} }, + steps: { + first: step({ agent: agent("asset-tool-first") }), + afterCrash: awaitSignal({ name: "afterCrash", after: ["first"] }), + second: step({ agent: agent("asset-tool-second"), after: ["afterCrash"] }), + afterRestart: awaitSignal({ name: "afterRestart", after: ["second"] }), + third: step({ agent: agent("asset-tool-third"), after: ["afterRestart"] }), + }, +}); +`; + +let env: DeployFlowEnv; +let h: TestDb; +let scratchDir: string; +let blobs: Map; +let restarted: SidecarHandle | undefined; +const restartTempDirs: string[] = []; + +function requireRoutable(): Promise { + return waitFor( + () => env.hub.router.getRoutableAddresses().includes(mailAddress), + { diagnostics: env.sidecarDiagnostics }, + ); +} + +// The pid of each tool call so far, asserting the count and the package the +// tool read. +function toolRunPids(expectedRuns: number): number[] { + const texts = env.inference.requests.flatMap(toolResultTexts); + expect(texts).toHaveLength(expectedRuns); + return texts.map((text) => { + const match = /^(.+) pid (\d+)$/.exec(text); + if (match === null) throw new Error(`unexpected tool result: ${text}`); + expect(match[1]).toBe(TOOL_IDENTITY); + return Number(match[2]); + }); +} + +async function hasParkedAt( + runId: string, + signalName: string, +): Promise { + const events = await readWorkflowRunEvents(env, DEPLOYMENT_ID, runId); + return events.some( + (e) => e.type === "SignalAwaited" && e.body["signalName"] === signalName, + ); +} + +async function hasTerminated(runId: string): Promise { + const events = await readWorkflowRunEvents(env, DEPLOYMENT_ID, runId); + return events.some((e) => WORKFLOW_RUN_TERMINAL_TYPES.has(e.type)); +} + +// `injectSignal` has no delivery ack, and a respawned child rejects signals +// while it starts, so resend until the run moves on. An extra delivery stays +// queued, which is harmless because each gate awaits its own signal name. +async function signalUntil( + runId: string, + signalName: string, + moved: () => Promise, +): Promise { + let lastSentAt = 0; + await waitFor( + async () => { + if (await moved()) return true; + if (Date.now() - lastSentAt >= 1_000) { + lastSentAt = Date.now(); + await injectSignal(env, DEPLOYMENT_ID, runId, signalName, {}); + } + return false; + }, + { diagnostics: env.sidecarDiagnostics }, + ); +} + +describe.skipIf(!harnessDbEnvAvailable())( + "a mounted asset's tool across process deaths", + () => { + beforeAll(async () => { + scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), "asset-tool-")); + const fixture = await buildSyntheticNpmPackageTarball({ + packageName: PACKAGE_NAME, + version: PACKAGE_VERSION, + moduleFilename: path.basename(WORKFLOW_ENTRY), + moduleSource: await bundleWorkflowEntry( + scratchDir, + workflowEntrySource, + ), + manifest: { interchange: { workflow: WORKFLOW_ENTRY } }, + }); + + h = await createTestDb(); + await h.db.insert(tenantTable).values({ + id: TENANT_ID, + name: TENANT_ID, + slug: tenantSlugFromId(TENANT_ID), + domain: DEPLOYMENT_DOMAIN, + parentId: null, + }); + await seedPrincipal(h.db, { + id: CALLER_PRINCIPAL_ID, + tenantId: TENANT_ID, + kind: "user", + }); + await seedAsset(h.db, { + id: DEFINITION_ASSET_ID, + tenantId: TENANT_ID, + kind: "workflow", + name: "asset-tool-restarts-wf", + creatorPrincipalId: CALLER_PRINCIPAL_ID, + }); + + // Each step starts a fresh conversation, so this drives the tool once per + // step. + env = await startDeployFlowEnv({ + inferenceToolCall: { toolName: TOOL_NAME, input: {} }, + inferenceToolCallEachRun: true, + }); + + const blobPath = `tarballs/${fixture.tarballFilename}`; + await env.hub.agentRepoStore.repoStore.initRepo(SOURCE_REPO_ID); + await env.hub.agentRepoStore.repoStore.writeTree( + HUB_PRINCIPAL, + SOURCE_REPO_ID, + DEFAULT_ASSET_REF, + { + files: { [blobPath]: fixture.bytes }, + message: "Seed the workflow package tarball", + }, + ); + blobs = new Map([[blobPath, fixture.bytes]]); + }); + + afterAll(async () => { + if (restarted !== undefined) { + await terminateSidecarSubprocess(restarted); + } + for (const dir of restartTempDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }); + } + if (env !== undefined) await env.teardown(); + if (h !== undefined) await h.close(); + if (scratchDir !== undefined) { + await fs.rm(scratchDir, { recursive: true, force: true }); + } + }); + + test("the tool runs before a child crash, after it, and after a sidecar restart", async () => { + const source: WorkflowDefinitionAssetSource = { + kind: "asset", + assetId: SOURCE_ASSET_ID, + package: { format: "tarball" }, + }; + const resolveAttachment = async ( + assetId: string, + ): Promise<{ pack: Uint8Array; ref: string; commitSha: string }> => { + if (assetId !== SOURCE_ASSET_ID) { + throw new Error(`unexpected attachment request ${assetId}`); + } + const commitSha = await env.hub.agentRepoStore.repoStore.resolveRef( + HUB_PRINCIPAL, + SOURCE_REPO_ID, + DEFAULT_ASSET_REF, + ); + if (commitSha === null) { + throw new Error("the source asset has no commit"); + } + const { pack, ref } = await env.hub.agentRepoStore.repoStore.createPack( + HUB_PRINCIPAL, + SOURCE_REPO_ID, + DEFAULT_ASSET_REF, + ); + return { pack, ref, commitSha }; + }; + + const approved = await installAndApproveWorkflowDefinition({ + source, + pin: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, + entry: WORKFLOW_ENTRY, + assetId: DEFINITION_ASSET_ID, + approvals: createApprovalSet([ + "inference.source:anthropic:mock-model", + "director:@intx/agent/default", + `mail.address:${mailAddress}`, + `mail.send:${DEPLOYMENT_DOMAIN}`, + `tool:${TOOL_NAME}`, + ]), + router: env.hub.probeRouter, + db: h.db, + readBlob: async (blobPath) => { + const bytes = blobs.get(blobPath); + if (bytes === undefined) throw new Error(`no blob at ${blobPath}`); + return bytes; + }, + listBlobs: async (dir) => + dir === "tarballs" + ? [...blobs.keys()].map((p) => p.replace(/^tarballs\//, "")) + : [], + resolveAttachment, + }); + if (!approved.approval.ok) { + throw new Error( + `the gate did not approve: ${JSON.stringify(approved.approval)}\n${env.sidecarDiagnostics()}`, + ); + } + const topEntry = approved.closure.entries.find( + (e) => e.name === PACKAGE_NAME, + ); + if (topEntry?.source.kind !== "asset") { + throw new Error("the workflow package is not asset-sourced"); + } + expect(topEntry.source.package.format).toBe("tarball"); + + const inferenceSource: InferenceSource = { + id: "anthropic:mock-model", + provider: "anthropic", + baseURL: `http://localhost:${String(env.inference.server.port)}`, + credentialId: "sk-mock", + model: "mock-model", + }; + const config: HarnessConfig = { + sessionId: SESSION_ID, + agentAddress: mailAddress, + sources: [inferenceSource], + defaultSource: inferenceSource.id, + }; + const sources = { + first: [inferenceSource], + afterCrash: [inferenceSource], + second: [inferenceSource], + afterRestart: [inferenceSource], + third: [inferenceSource], + }; + await seedInferenceCredentials(h.db, TENANT_ID, sources, config); + env.hub.setPrimaryAllocationIdentity(DEPLOYMENT_ID, mailAddress); + await deployCodeSourcedWorkflow({ + approved, + source, + resolveAttachment, + sidecarAllocationRouter: env.hub.router, + allocationTarget: { + allocationId: "allocation-integration-1", + generation: 1, + }, + agentAddress: mailAddress, + config, + sources, + db: h.db, + tenantId: TENANT_ID, + anchorRunId: DEPLOYMENT_ID, + deploymentDomain: DEPLOYMENT_DOMAIN, + credentialCipher: createNoopCredentialCipher(), + }); + const workflowRunRepoId: RepoId = { + kind: "workflow-run", + id: deriveWorkflowRunRepoId(mailAddress), + }; + env.registerDeployment({ + anchorRunId: DEPLOYMENT_ID, + workflowDefinition: { + id: approved.projection.id, + triggers: [{ type: "mail", to: mailAddress }], + steps: {}, + stepOrder: [...approved.projection.stepOrder], + }, + workflowRunRepoId, + workflowRunRef: "refs/heads/main", + mailAddress, + }); + await requireRoutable(); + + await fireMailTrigger(env, mailAddress, { + messageId: "", + grants: deriveWireRunGrants(approved.grantSnapshot), + }); + const runId = await waitForFirstRunId(env, workflowRunRepoId, { + diagnostics: env.sidecarDiagnostics, + }); + await waitFor(() => hasParkedAt(runId, "afterCrash"), { + diagnostics: env.sidecarDiagnostics, + }); + const beforeCrash = toolRunPids(1); + + await settleWorkflowRunPacks(env); + const killedPids = killWorkflowHostChild(env); + expect(killedPids).toEqual(expect.arrayContaining(beforeCrash)); + await waitFor( + () => + listWorkflowHostChildren(env).some( + (pid) => !killedPids.includes(pid), + ), + { diagnostics: env.sidecarDiagnostics }, + ); + expect(env.sidecar.proc.exitCode).toBeNull(); + await signalUntil(runId, "afterCrash", () => + hasParkedAt(runId, "afterRestart"), + ); + // A killed pid can linger as a zombie, so being listed is not enough. + const afterCrash = toolRunPids(2).slice(1); + expect(killedPids.some((pid) => afterCrash.includes(pid))).toBe(false); + expect(listWorkflowHostChildren(env)).toEqual( + expect.arrayContaining(afterCrash), + ); + + const dataDir = await crashSidecar(env, mailAddress); + await fs.rm(path.join(dataDir, "workflow-definition-closure-cache"), { + recursive: true, + }); + restarted = await restartSidecar(env, { + address: mailAddress, + dataDir, + registerTempDir: (dir) => restartTempDirs.push(dir), + }); + await signalUntil(runId, "afterRestart", () => hasTerminated(runId)); + + const terminal = await waitForWorkflowRunComplete( + env, + DEPLOYMENT_ID, + runId, + { diagnostics: env.sidecarDiagnostics }, + ); + expect(terminal.type).toBe("RunCompleted"); + expect(new Set(toolRunPids(3)).size).toBe(3); + }, 180_000); + }, +); diff --git a/tests/workflow-deploy/body-action-restart-env.ts b/tests/workflow-deploy/body-action-restart-env.ts index eda349afb..73b5811c7 100644 --- a/tests/workflow-deploy/body-action-restart-env.ts +++ b/tests/workflow-deploy/body-action-restart-env.ts @@ -122,13 +122,7 @@ export async function startBodyActionRestartEnv(opts: { }); const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: deploymentId, - tenantId: "tenant-1", - principalId: `prin_${deploymentId}`, agentAddress: mailAddress, - systemPrompt: "unused (no agent step)", - tools: [], - grants: [], sources: [ { id: "anthropic:mock-model", diff --git a/tests/workflow-deploy/boot-restore.bench.ts b/tests/workflow-deploy/boot-restore.bench.ts index 9f5b740c4..277019648 100644 --- a/tests/workflow-deploy/boot-restore.bench.ts +++ b/tests/workflow-deploy/boot-restore.bench.ts @@ -12,8 +12,7 @@ // bench: // // 1. SETUP (not timed): stands up N single-step deployments through a first -// router over a scratch data dir, so N `deployment.json` records plus -// their `assets/workflow//workflow.json` and per-step grants land on +// router over a scratch data dir, so N `deployment.json` records land on // disk exactly as a live deploy would leave them. // 2. RESTORE (timed): builds a SECOND router with a FRESH transport (an // empty registration table -- the sidecar-restart model) over the SAME @@ -296,6 +295,8 @@ function deployHostBindings() { resolveInboundMailPolicy: () => resolvedInboundMailPolicy, materializeWorkflowAssets: () => Promise.resolve(undefined), maxInlineAssetPayloadBytes: 32 * 1024 * 1024, + closureCacheMaxBytes: 1_000_000, + registryMaxTarballBytes: 1_000_000, multistepBinaryPath: "/fake/bin/workflow-child", }; } @@ -303,8 +304,7 @@ function deployHostBindings() { /** * Build a `SidecarDeployRouter` over `dataDir` with the same stubbed * host bindings the wiring test's `buildMultistepFixture` uses: a stub - * `sessions`/`keyStore` (the single-step head deploy only inits its repo and - * records the hub key), a disk-backed spawn-test RepoStore, a permissive + * `sessions`/`keyStore`, a disk-backed spawn-test RepoStore, a permissive * source-admission gate, and the supplied in-memory spawner + transport. */ async function buildRouter(args: { @@ -318,25 +318,13 @@ async function buildRouter(args: { return createSidecarDeployRouter({ ...DEFAULT_SIDECAR_CAPACITY, ...deployHostBindings(), - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the workflow path never invokes provisionAgent/persistHubPublicKey; single-step uses the narrow initRepo - 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 -- bench stub; the single-step head deploy records the hub key for pack verification + sessions: { deleteAgentDir: async () => undefined }, keyStore: { - recordHubKey: () => undefined, loadOrGenerateKey: async () => ({ keyPair: await generateKeyPair(), isNew: false, }), - forgetAgent: () => undefined, - } as unknown as Parameters[0]["keyStore"], + }, transport: args.transport, repoStore, signingKeySeed: signingKeyPair.privateKey, @@ -345,15 +333,11 @@ async function buildRouter(args: { assertSourceBuildable: () => undefined, registerDeployment: () => undefined, removeRunRepository: async () => undefined, - removeAgentStateRepository: async () => undefined, unregisterDeployment: () => undefined, reportDeploymentRefTips: async () => ({}), multistepSubprocessSpawner: args.spawner, multistepSubstrateEnv: { SIDECAR_DATA_DIR: args.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 each // deployment's runnable definition by materializing the pin's closure @@ -376,10 +360,7 @@ function singleStepFrame( requestId: `deploy-${agentAddress}`, agentAddress, generation: 1, - agentId: "boot-restore-agent", - hubPublicKey: "hub-pk", - // 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"], + config: { sessionId: "s" }, workflow: { // Source-ref is the only deploy lineage: the frame carries no inline // definition, only the pin the router re-materializes for the child, @@ -419,9 +400,8 @@ async function measureRestore(n: number): Promise { addresses.push(`run_boot_${String(i)}@example.com`); } - // SETUP: deploy each address through the first router so its restore record, - // workflow.json, and step grants land on disk. Not part of the measured - // interval. + // SETUP: deploy each address through the first router so its restore record + // lands on disk. Not part of the measured interval. const setupTransport = createInMemoryTransport(); const setup = makeReadyDrivingSpawner(20000); const routerA = await buildRouter({ diff --git a/tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts b/tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts index c61c7f54e..2f1f3effd 100644 --- a/tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts +++ b/tests/workflow-deploy/child-workflow-inherited-grants-roundtrip.test.ts @@ -191,13 +191,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${PARENT_DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: parentMailAddress, - systemPrompt: "Fallback prompt (overridden per step).", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts index 8e1a7e6e4..52fe01f07 100644 --- a/tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/child-workflow-loop-tool-invoke-roundtrip.test.ts @@ -133,7 +133,7 @@ afterAll(async () => { describe.skipIf(!harnessDbEnvAvailable())( "a loop body inside a spawned child invokes a real tool", () => { - // The per-step credentials, source, and deploy-tree tables are keyed by + // The per-step credentials and source tables are keyed by // base step id, so a body step id colliding with another step's id would // resolve to that step's entry and make the round-trip below pass against // the wrong step. @@ -161,13 +161,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/child-workflow-roundtrip.test.ts b/tests/workflow-deploy/child-workflow-roundtrip.test.ts index 436c2fd62..fc56e53a6 100644 --- a/tests/workflow-deploy/child-workflow-roundtrip.test.ts +++ b/tests/workflow-deploy/child-workflow-roundtrip.test.ts @@ -281,16 +281,10 @@ afterAll(async () => { if (h !== undefined) await h.close(); }); -function makeConfig(anchorRunId: string, address: string): HarnessConfig { +function makeConfig(address: string): HarnessConfig { return { sessionId: SESSION_ID, - agentId: `${anchorRunId}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: address, - systemPrompt: "Fallback prompt (overridden per step).", - tools: [], - grants: [], sources: [ { id: "anthropic:mock-model", @@ -342,7 +336,7 @@ async function deployParent(opts: { deploymentDomain: DEPLOYMENT_DOMAIN, agentAddress: address, approvals: opts.approvals, - config: makeConfig(opts.anchorRunId, address), + config: makeConfig(address), sources, }); expect(handle.publicKey).toBeTruthy(); @@ -844,7 +838,7 @@ describe.skipIf(!harnessDbEnvAvailable())( deploymentDomain: DEPLOYMENT_DOMAIN, agentAddress: parentMailAddress, approvals: operatorApprovals, - config: makeConfig(MAP_NESTED_PARENT_DEPLOYMENT_ID, parentMailAddress), + config: makeConfig(parentMailAddress), sources: { spawn: [inferenceSource] }, }); expect(handle.publicKey).toBeTruthy(); diff --git a/tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts index 1f498cd6e..d43132d2c 100644 --- a/tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/child-workflow-tool-invoke-roundtrip.test.ts @@ -133,13 +133,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${PARENT_DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: parentMailAddress, - systemPrompt: "Fallback prompt (overridden per step).", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts b/tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts index 753144de0..cdc9a4880 100644 --- a/tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts +++ b/tests/workflow-deploy/childworkflow-drain-wait-resumes.test.ts @@ -163,13 +163,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_childworkflow-drain-wait-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/childworkflow-restart-resumes.test.ts b/tests/workflow-deploy/childworkflow-restart-resumes.test.ts index 169886d47..907a9c27b 100644 --- a/tests/workflow-deploy/childworkflow-restart-resumes.test.ts +++ b/tests/workflow-deploy/childworkflow-restart-resumes.test.ts @@ -145,13 +145,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_childworkflow-restart-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts b/tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts index 31c608f7d..4a5d17f53 100644 --- a/tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts +++ b/tests/workflow-deploy/childworkflow-scope-always-roundtrip.test.ts @@ -306,13 +306,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts b/tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts index e8b52c22a..923bd10b2 100644 --- a/tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts +++ b/tests/workflow-deploy/childworkflow-unanswerable-gate.test.ts @@ -245,13 +245,7 @@ async function deployGateWorkflow(opts: { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: anchorRunId, - tenantId: "tenant-1", - principalId: `prin_${anchorRunId}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -327,7 +321,6 @@ function createMockGetSession(userId: string): GetSession { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/workflow-deploy/crash-loop-latch.test.ts b/tests/workflow-deploy/crash-loop-latch.test.ts index 46d34d8c0..9b4a47e9d 100644 --- a/tests/workflow-deploy/crash-loop-latch.test.ts +++ b/tests/workflow-deploy/crash-loop-latch.test.ts @@ -207,13 +207,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_crash-loop-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/crash-respawn-fifo.test.ts b/tests/workflow-deploy/crash-respawn-fifo.test.ts index fb4d08d7c..f2bb6966f 100644 --- a/tests/workflow-deploy/crash-respawn-fifo.test.ts +++ b/tests/workflow-deploy/crash-respawn-fifo.test.ts @@ -142,13 +142,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_crash-respawn-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts b/tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts index e98e3a3a7..e12aa1cf6 100644 --- a/tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts +++ b/tests/workflow-deploy/crash-restart-reconnect-resumes.test.ts @@ -7,8 +7,8 @@ // RunCompleted with effects applied exactly once. // // Shape: deploy a `step1 -> awaitSignal{name:"go"} -> step2` workflow -// through the workflow-deploy orchestrator's multi-step branch (the same -// wiring `multistep-signal.test.ts` uses), fire the mail trigger, and +// through `deployWorkflowSourceForTest` (the same wiring +// `multistep-signal.test.ts` uses), fire the mail trigger, and // drive the run to the mid-run `SignalAwaited` pause. Quiesce the // workflow-run pack pipeline, then KILL the sidecar subprocess (process // death, not a hub-link drop). Start a fresh sidecar against the crashed @@ -165,13 +165,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_crash-restart-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/credential-bound-route-deploy.test.ts b/tests/workflow-deploy/credential-bound-route-deploy.test.ts index 2cf3945d0..c4ecb773e 100644 --- a/tests/workflow-deploy/credential-bound-route-deploy.test.ts +++ b/tests/workflow-deploy/credential-bound-route-deploy.test.ts @@ -345,10 +345,6 @@ describe.skipIf(!harnessDbEnvAvailable())( sidecarRouter: env.hub.router, sidecarAllocationRouter: env.hub.router, agentRepoStore: env.hub.agentRepoStore, - assetService: createAssetService({ - db: h.db, - repoStore: env.hub.agentRepoStore.repoStore, - }), db: h.db, toolPackageRegistries: { httpRegistries: new Map([ diff --git a/tests/workflow-deploy/cross-process-custom-adapter.test.ts b/tests/workflow-deploy/cross-process-custom-adapter.test.ts index e97bc1070..e24958b53 100644 --- a/tests/workflow-deploy/cross-process-custom-adapter.test.ts +++ b/tests/workflow-deploy/cross-process-custom-adapter.test.ts @@ -169,13 +169,7 @@ async function deployCustomProviderWorkflow(opts: { const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: opts.anchorRunId, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: sourceId, }; diff --git a/tests/workflow-deploy/deploy-latency.bench.ts b/tests/workflow-deploy/deploy-latency.bench.ts index 4f6986255..222ae34ae 100644 --- a/tests/workflow-deploy/deploy-latency.bench.ts +++ b/tests/workflow-deploy/deploy-latency.bench.ts @@ -180,13 +180,7 @@ async function measureOneDeploy( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${anchorRunId}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: mailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts b/tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts index 10d6d3fc3..c4a93dfc6 100644 --- a/tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts +++ b/tests/workflow-deploy/deploy-window-reconnect-recovery.test.ts @@ -130,13 +130,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_deploy-window-recovery-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/drain-roundtrip.test.ts b/tests/workflow-deploy/drain-roundtrip.test.ts index 9ee546abc..51b46fda8 100644 --- a/tests/workflow-deploy/drain-roundtrip.test.ts +++ b/tests/workflow-deploy/drain-roundtrip.test.ts @@ -1,8 +1,8 @@ // Drain round-trip integration test. // // Deploys a `step1 -> awaitSignal{name: "never-arrives", -// drainBehavior: "cancel"}` workflow through the workflow-deploy -// orchestrator's multi-step branch, fires the deployment's mail +// drainBehavior: "cancel"}` workflow through +// `deployWorkflowSourceForTest`, fires the deployment's mail // trigger, observes the runtime pause at `SignalAwaited`, then // initiates drain through the production hub -> sidecar -> supervisor // -> workflow-process child pipeline. @@ -30,13 +30,6 @@ // as `StepFailed`; the run terminates as `RunFailed`. The cascade is // asserted end-to-end so a regression in any of the seven hops surfaces // at this test. -// -// The orchestrator's multi-step branch is composed in-test (matching -// the multi-step signal round-trip): the per-step launch callback drives -// `env.hub.sessionService.stageWorkflowStep` (the stage-only path, no warm -// harness) and the `sendMultiStepDeploy` hand-off is supplied against -// `env.hub.router.sendAgentDeploy` so the sidecar's deploy router -// takes the workflow-process spawn path. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdir, readFile, writeFile } from "node:fs/promises"; @@ -169,13 +162,7 @@ describe.skipIf(!harnessDbEnvAvailable())("drain round-trip", () => { const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -348,13 +335,7 @@ describe.skipIf(!harnessDbEnvAvailable())("drain round-trip", () => { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${waitDeploymentId}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -516,13 +497,7 @@ async function deployParkedOnSignal(args: { ]), config: { sessionId: SESSION_ID, - agentId: args.anchorRunId, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: address, - systemPrompt: "Wait for the signal.", - tools: [], - grants: [], sources: [source], defaultSource: source.id, }, diff --git a/tests/workflow-deploy/expired-run-grant-running-run.test.ts b/tests/workflow-deploy/expired-run-grant-running-run.test.ts index 4aa7418e4..7a1209e8c 100644 --- a/tests/workflow-deploy/expired-run-grant-running-run.test.ts +++ b/tests/workflow-deploy/expired-run-grant-running-run.test.ts @@ -4,8 +4,7 @@ // The workflow runs `before`, an agent step with the `hubProbe` tool, then // parks on the `resume` signal, then runs `after`, a second agent step with the // same tool. On the source-ref deploy the run principal's `tool:probe/invoke` -// row is the tool's only authority in the child: the child adds no tool-mark -// floor on that lineage. The mock model calls the probe once in each step. +// row is the tool's only authority in the child. The mock model calls the probe once in each step. // // While the run is parked, the test moves that row's expiry into the past. The // grants the hub sends ahead of the resume signal still carry the row, now with diff --git a/tests/workflow-deploy/fifo-mail-load.test.ts b/tests/workflow-deploy/fifo-mail-load.test.ts index 80c83b3ea..1395809f8 100644 --- a/tests/workflow-deploy/fifo-mail-load.test.ts +++ b/tests/workflow-deploy/fifo-mail-load.test.ts @@ -190,13 +190,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID_LOAD}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/fifo-mail.test.ts b/tests/workflow-deploy/fifo-mail.test.ts index 347aae8f9..5efcfc924 100644 --- a/tests/workflow-deploy/fifo-mail.test.ts +++ b/tests/workflow-deploy/fifo-mail.test.ts @@ -1,7 +1,6 @@ // FIFO mail-trigger serialization round-trip integration test. // -// Deploys a multi-step workflow against the workflow-deploy -// orchestrator's multi-step branch, fires three distinct mails at the +// Deploys a multi-step workflow through `deployWorkflowSourceForTest`, fires three distinct mails at the // deployment's trigger address in quick succession, and verifies that the // first fires the deployment's one stable run while the queued post-terminal // mails are rejected in FIFO order. @@ -156,13 +155,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/fixtures/child-workflow-loop-tool-workflow.ts b/tests/workflow-deploy/fixtures/child-workflow-loop-tool-workflow.ts index b4f444bb5..956160018 100644 --- a/tests/workflow-deploy/fixtures/child-workflow-loop-tool-workflow.ts +++ b/tests/workflow-deploy/fixtures/child-workflow-loop-tool-workflow.ts @@ -20,10 +20,9 @@ // STEP ID DISTINCTNESS. Every step id in this fixture -- the parent's, the // child's, and the loop body's -- is distinct, and anyone changing them must // keep them so. The per-step tables a step resolves against are keyed by -// `baseStepId(stepId)`, and the deploy-tree address resolver keys off the -// PARENT deployment's mailbox and step count, so a body step id equal to -// another step's id resolves to that other step's entry and an assertion about -// the body step would pass against the wrong step. +// `baseStepId(stepId)`, so a body step id equal to another step's id resolves to +// that other step's entry and an assertion about the body step would pass +// against the wrong step. import path from "node:path"; diff --git a/tests/workflow-deploy/fixtures/loop-map-tool-invoke-workflow.ts b/tests/workflow-deploy/fixtures/loop-map-tool-invoke-workflow.ts index 7d12529a8..ec047c577 100644 --- a/tests/workflow-deploy/fixtures/loop-map-tool-invoke-workflow.ts +++ b/tests/workflow-deploy/fixtures/loop-map-tool-invoke-workflow.ts @@ -4,8 +4,8 @@ // top-level steps on the loop's converged and exhausted arms. // // This is the rung where two id transformations compose. `runMap` runs its -// inner step under a scoped id `[]`, and every deploy-asset -// lookup recovers the base id with `baseStepId`; the map itself sits inside a +// inner step under a scoped id `[]`, and every source and +// grant lookup recovers the base id with `baseStepId`; the map itself sits inside a // loop body, whose ids reach the deployment's flat namespace only because the // executable-step walk descends into loop bodies. A per-item tool call // therefore has to resolve `[]` back to a body step id that @@ -28,8 +28,8 @@ // // STEP ID DISTINCTNESS. `LOOP_BODY_MAP_STEP_ID` must differ from every entry in // `TOP_LEVEL_STEP_IDS`, and anyone changing these ids must keep them disjoint. -// The per-step tables a body step resolves against -- the credentials snapshot, -// the pinned inference-source map, and the deploy-tree address resolver -- are +// The per-step tables a body step resolves against -- the credentials snapshot +// and the pinned inference-source map -- are // keyed by `baseStepId(stepId)` in ONE flat namespace shared between the top // level and the loop body. A body step id equal to a top-level step id // therefore resolves to the TOP-LEVEL step's entry, and the round-trip below diff --git a/tests/workflow-deploy/fixtures/loop-nested-tool-invoke-workflow.ts b/tests/workflow-deploy/fixtures/loop-nested-tool-invoke-workflow.ts index b1b9770c3..f9930bcb2 100644 --- a/tests/workflow-deploy/fixtures/loop-nested-tool-invoke-workflow.ts +++ b/tests/workflow-deploy/fixtures/loop-nested-tool-invoke-workflow.ts @@ -28,8 +28,7 @@ // STEP ID DISTINCTNESS. Every id below is distinct from every other id in the // fixture, across all three nesting levels, and anyone changing them must keep // them so. The per-step tables a body step resolves against -- the credentials -// snapshot, the pinned inference-source map, and the deploy-tree address -// resolver -- are keyed by `baseStepId(stepId)` in ONE flat namespace shared by +// snapshot and the pinned inference-source map -- are keyed by `baseStepId(stepId)` in ONE flat namespace shared by // the top level and both loop bodies. A colliding id resolves to the OTHER // step's entry, so the round-trip below would load the wrong step's tools and // pass against a broken system. The ids are deliberately unlike one another for diff --git a/tests/workflow-deploy/fixtures/loop-tool-invoke-workflow.ts b/tests/workflow-deploy/fixtures/loop-tool-invoke-workflow.ts index 716b4b5ff..c51e4ef40 100644 --- a/tests/workflow-deploy/fixtures/loop-tool-invoke-workflow.ts +++ b/tests/workflow-deploy/fixtures/loop-tool-invoke-workflow.ts @@ -20,8 +20,8 @@ // // STEP ID DISTINCTNESS. `LOOP_BODY_STEP_ID` must differ from every entry in // `TOP_LEVEL_STEP_IDS`, and anyone changing these ids must keep them disjoint. -// The per-step tables a body step resolves against -- the credentials snapshot, -// the pinned inference-source map, and the deploy-tree address resolver -- are +// The per-step tables a body step resolves against -- the credentials snapshot +// and the pinned inference-source map -- are // keyed by `baseStepId(stepId)` in ONE flat namespace shared between the top // level and the loop body. A body step id equal to a top-level step id // therefore resolves to the TOP-LEVEL step's entry, and a test asserting that diff --git a/tests/workflow-deploy/fixtures/mail-tool.ts b/tests/workflow-deploy/fixtures/mail-tool.ts index 852f724fa..2360deee4 100644 --- a/tests/workflow-deploy/fixtures/mail-tool.ts +++ b/tests/workflow-deploy/fixtures/mail-tool.ts @@ -31,10 +31,8 @@ import { defineTool, type BaseEnv } from "@intx/agent"; export type MailToolVariant = "fs" | "transport" | "ask"; /** - * The bundle id. The model-facing tool name mirrors the name the synthetic - * tool-package loader synthesized (`:mail_send`) so a migrated test - * keeps the same `tool:@intx/tools-mail/sidecar-bundle:mail_send` grant and - * tool-name strings it asserted against the pinned bundle. + * The bundle id. The model-facing tool name carries it (`:mail_send`), + * so the tool's grant is `tool:@intx/tools-mail/sidecar-bundle:mail_send`. */ export const MAIL_TOOL_BUNDLE_ID = "@intx/tools-mail/sidecar-bundle"; diff --git a/tests/workflow-deploy/fixtures/map-fan-out.ts b/tests/workflow-deploy/fixtures/map-fan-out.ts index e15c9b5f7..142af6f52 100644 --- a/tests/workflow-deploy/fixtures/map-fan-out.ts +++ b/tests/workflow-deploy/fixtures/map-fan-out.ts @@ -10,7 +10,7 @@ // `mail_send` tool. When `withTool` is set the item agent lists // `mailSendTool("fs")` from the sibling `mail-tool.ts` module, so the mock // inference reply lists the tool name -- the proof that a map iteration -// resolves its base step's staged tool tree. +// gets its agent's tools. import path from "node:path"; diff --git a/tests/workflow-deploy/folded-tools-failover-real-agent.test.ts b/tests/workflow-deploy/folded-tools-failover-real-agent.test.ts index 6234765c1..6f759ede0 100644 --- a/tests/workflow-deploy/folded-tools-failover-real-agent.test.ts +++ b/tests/workflow-deploy/folded-tools-failover-real-agent.test.ts @@ -187,13 +187,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_folded-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [deadSource, healthySource], defaultSource: "anthropic:dead-head", }; @@ -325,13 +319,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${NEGCTL_DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_folded-negctl-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [deadSource], defaultSource: "anthropic:dead-head", }; diff --git a/tests/workflow-deploy/hub-access-env.ts b/tests/workflow-deploy/hub-access-env.ts index 298d7aab3..923f8d660 100644 --- a/tests/workflow-deploy/hub-access-env.ts +++ b/tests/workflow-deploy/hub-access-env.ts @@ -329,13 +329,7 @@ export async function startHubAccessEnv(args: { }); const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: anchorRunId, - tenantId: "tenant-1", - principalId: `prin_${anchorRunId}`, agentAddress: mailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [source], defaultSource: MOCK_SOURCE_ID, }; diff --git a/tests/workflow-deploy/hub-link-reconnect.test.ts b/tests/workflow-deploy/hub-link-reconnect.test.ts index 6830f7bd2..56ca59e5b 100644 --- a/tests/workflow-deploy/hub-link-reconnect.test.ts +++ b/tests/workflow-deploy/hub-link-reconnect.test.ts @@ -148,13 +148,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_reconnect-smoke-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts b/tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts index 70a623b5d..5c67955e3 100644 --- a/tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts +++ b/tests/workflow-deploy/inbound-mail-enforcement-real-route.test.ts @@ -192,13 +192,7 @@ async function deployEnforcementWorkflow(opts: { const inferenceSource = inferenceSourceForEnv(env); const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: opts.anchorRunId, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: opts.mailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts b/tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts index 0b2157d03..2523115f0 100644 --- a/tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts +++ b/tests/workflow-deploy/interrupted-pack-reconnect-recovery.test.ts @@ -176,13 +176,7 @@ async function deploySingleStepWorkflow( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${anchorRunId}`, - tenantId: "tenant-1", - principalId: `prin_${anchorRunId}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/latency-d2-attribution.bench.ts b/tests/workflow-deploy/latency-d2-attribution.bench.ts index 7d075dd5c..97030bef3 100644 --- a/tests/workflow-deploy/latency-d2-attribution.bench.ts +++ b/tests/workflow-deploy/latency-d2-attribution.bench.ts @@ -22,7 +22,7 @@ // SUMS them per message and reports the COUNT. // markconsumed consumed/ dedup WRITE, AFTER reply-produced (paid // OUTSIDE the window). -// wal D1 conversation WAL append (agent-state/...), the +// wal D1 conversation WAL append (step state), the // control leg; should be small + flat post-D1. // // The TRUE per-message substrate tax is the SUM of all five, including @@ -375,13 +375,7 @@ async function runUnifiedD2(opts: { const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/latency-gate.bench.ts b/tests/workflow-deploy/latency-gate.bench.ts index 98088da4d..be736588a 100644 --- a/tests/workflow-deploy/latency-gate.bench.ts +++ b/tests/workflow-deploy/latency-gate.bench.ts @@ -346,13 +346,7 @@ async function runUnified(opts: { const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/loop-action-roundtrip.test.ts b/tests/workflow-deploy/loop-action-roundtrip.test.ts index 54ac2c720..d8051ad2c 100644 --- a/tests/workflow-deploy/loop-action-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-action-roundtrip.test.ts @@ -101,13 +101,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_loop-action-roundtrip-1", agentAddress: deploymentMailAddress, - systemPrompt: "unused (loop body is an action)", - tools: [], - grants: [], sources: [ { id: "anthropic:mock-model", diff --git a/tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts b/tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts index e5702977e..a50a572f2 100644 --- a/tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-await-signal-drain-roundtrip.test.ts @@ -143,13 +143,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_loop-drain-cancel-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts b/tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts index 15a99eed9..ecd7d5f99 100644 --- a/tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts +++ b/tests/workflow-deploy/loop-await-signal-restart-resumes.test.ts @@ -151,13 +151,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_loop-await-signal-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts b/tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts index e5fa790d2..93083450d 100644 --- a/tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts +++ b/tests/workflow-deploy/loop-childworkflow-inflight-grandchild-drain.test.ts @@ -142,13 +142,7 @@ async function runGrandchildDrainTest(opts: { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${anchorRunId}`, - tenantId: "tenant-1", - principalId: `prin_${anchorRunId}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts b/tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts index 6ea4a3ac2..8f950321a 100644 --- a/tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-childworkflow-roundtrip.test.ts @@ -186,13 +186,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts index 80d2513dc..49b926397 100644 --- a/tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-map-tool-invoke-roundtrip.test.ts @@ -3,13 +3,13 @@ // // The sibling `loop-tool-invoke-roundtrip.test.ts` proves a plain agent step in // a loop body can call a tool, and `map-fan-out-real-agent.test.ts` proves a -// TOP-LEVEL map iteration resolves its base step's staged tool tree. This +// TOP-LEVEL map iteration gets its agent's tools. This // composes the two, which nothing else does: `runMap` scopes its inner step's -// id to `[]` and every deploy-asset lookup strips that suffix +// id to `[]` and every source and grant lookup strips that suffix // with `baseStepId`, while the map itself lives in a loop body whose ids reach // the deployment's flat namespace only through the executable-step walk's // descent into loop bodies. A per-item tool call has to survive both -// transformations at once to find its credentials, source, and deploy tree. +// transformations at once to find its credentials and source. // // The assertion reads the tool_result's TEXT, not merely its presence. A tool // call the authorize seam blocks still appends a tool_result -- an error one, @@ -142,7 +142,7 @@ describe.skipIf(!harnessDbEnvAvailable())( expect(env.hub.router.getConnectedSidecars()).toContain(SIDECAR_ID); }); - // The per-step credentials, source, and deploy-tree tables are keyed by + // The per-step credentials and source tables are keyed by // base step id in one namespace shared by the top level and the loop body, // so a colliding body step id would resolve to a top-level step's entry // and make the round-trip below pass against the wrong step. @@ -166,13 +166,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/loop-nested-roundtrip.test.ts b/tests/workflow-deploy/loop-nested-roundtrip.test.ts index 6b8dc0a12..ba994af8b 100644 --- a/tests/workflow-deploy/loop-nested-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-nested-roundtrip.test.ts @@ -122,13 +122,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_loop-nested-roundtrip-1", agentAddress: deploymentMailAddress, - systemPrompt: "unused (agents run per loop-body step)", - tools: [], - grants: [], sources: [ { id: "anthropic:mock-model", diff --git a/tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts index 3448da6f9..7b2e8f1ee 100644 --- a/tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-nested-tool-invoke-roundtrip.test.ts @@ -165,7 +165,7 @@ describe.skipIf(!harnessDbEnvAvailable())( expect(env.hub.router.getConnectedSidecars()).toContain(SIDECAR_ID); }); - // The per-step credentials, source, and deploy-tree tables are keyed by + // The per-step credentials and source tables are keyed by // base step id in one namespace shared by the top level and BOTH loop // bodies, so a colliding inner body step id would resolve to another // step's entry and make the round-trip below pass against the wrong step. @@ -189,13 +189,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/loop-roundtrip.test.ts b/tests/workflow-deploy/loop-roundtrip.test.ts index e32624267..f4395e3db 100644 --- a/tests/workflow-deploy/loop-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-roundtrip.test.ts @@ -103,13 +103,7 @@ describe.skipIf(!harnessDbEnvAvailable())( // the shape, so supply one placeholder source it never resolves. const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_loop-roundtrip-1", agentAddress: deploymentMailAddress, - systemPrompt: "unused (no agent step)", - tools: [], - grants: [], sources: [ { id: "anthropic:mock-model", diff --git a/tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts index 799826242..c917c2ba1 100644 --- a/tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/loop-tool-invoke-roundtrip.test.ts @@ -144,7 +144,7 @@ describe.skipIf(!harnessDbEnvAvailable())( expect(env.hub.router.getConnectedSidecars()).toContain(SIDECAR_ID); }); - // The per-step credentials, source, and deploy-tree tables are keyed by + // The per-step credentials and source tables are keyed by // base step id in one namespace shared by the top level and the loop body, // so a colliding body step id would resolve to the top-level step's entry // and make the round-trip below pass against the wrong step. @@ -168,13 +168,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -225,9 +219,8 @@ describe.skipIf(!harnessDbEnvAvailable())( // production route it does not materialize the rows itself -- deriving // them from the frozen snapshot here reproduces the delivery, and keeps // the grant the body authorizes against sourced from the probe walk - // rather than a hand-authored constant. A source-ref deploy has no - // tool-mark floor to fall back on (a source tool's runtime name is the - // bare `definition.name` the walk already emitted a grant for), so this + // rather than a hand-authored constant. A source tool's runtime name is the + // bare `definition.name` the walk already emitted a grant for, so this // IS the body's only authority. const snapshot = handle.approved.grantSnapshot; expect( diff --git a/tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts b/tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts index 9ee818025..a04811e16 100644 --- a/tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts +++ b/tests/workflow-deploy/loop-work-then-signal-restart-resumes.test.ts @@ -149,13 +149,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_loop-work-then-signal-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/mail-edge-cases.test.ts b/tests/workflow-deploy/mail-edge-cases.test.ts index 04433cf03..fe6bdc56d 100644 --- a/tests/workflow-deploy/mail-edge-cases.test.ts +++ b/tests/workflow-deploy/mail-edge-cases.test.ts @@ -537,13 +537,7 @@ async function deployEdgeWorkflow( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${anchorRunId}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts b/tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts index a6b62a7ea..bd772a5d2 100644 --- a/tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts +++ b/tests/workflow-deploy/mail-federation-derived-grants-roundtrip.test.ts @@ -177,7 +177,6 @@ function notImpl(name: string): never { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } diff --git a/tests/workflow-deploy/mail-federation-run-completes.test.ts b/tests/workflow-deploy/mail-federation-run-completes.test.ts index 292a11fcd..94f499fa4 100644 --- a/tests/workflow-deploy/mail-federation-run-completes.test.ts +++ b/tests/workflow-deploy/mail-federation-run-completes.test.ts @@ -330,7 +330,7 @@ describe.skipIf(!harnessDbEnvAvailable())( approvals: buildApprovals(receiverAddress, [ `mail.accept:principal:${SENDER_RUN_PRINCIPAL_ID}`, ]), - config: buildConfig(RECEIVER_ID, receiverAddress, "sk-mock-receiver"), + config: buildConfig(receiverAddress, "sk-mock-receiver"), sources: { [RECEIVER_STEP_ID]: [inferenceSource("sk-mock-receiver")] }, }); @@ -398,7 +398,7 @@ describe.skipIf(!harnessDbEnvAvailable())( deploymentDomain: DEPLOYMENT_DOMAIN, agentAddress: senderAddress, approvals: buildApprovals(senderAddress, [`tool:${MAIL_TOOL_NAME}`]), - config: buildConfig(SENDER_ID, senderAddress, "sk-mock-sender"), + config: buildConfig(senderAddress, "sk-mock-sender"), sources: { [SENDER_STEP_ID]: [inferenceSource("sk-mock-sender")] }, }); @@ -485,20 +485,10 @@ describe.skipIf(!harnessDbEnvAvailable())( }; } - function buildConfig( - anchorRunId: string, - address: string, - credentialId: string, - ): HarnessConfig { + function buildConfig(address: string, credentialId: string): HarnessConfig { return { sessionId: SESSION_ID, - agentId: `${anchorRunId}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: address, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource(credentialId)], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts b/tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts index 759257464..449c6c4b3 100644 --- a/tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts +++ b/tests/workflow-deploy/mail-trigger-derived-grants-roundtrip.test.ts @@ -165,7 +165,6 @@ function notImpl(name: string): never { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } @@ -308,13 +307,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts b/tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts index c1a8bbca8..81f3af616 100644 --- a/tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts +++ b/tests/workflow-deploy/mail-trigger-run-completes-real-route.test.ts @@ -230,13 +230,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/map-fan-out-real-agent.test.ts b/tests/workflow-deploy/map-fan-out-real-agent.test.ts index c037a7582..3790a9116 100644 --- a/tests/workflow-deploy/map-fan-out-real-agent.test.ts +++ b/tests/workflow-deploy/map-fan-out-real-agent.test.ts @@ -2,10 +2,10 @@ // // The proof that a top-level `map` fan-out runs a REAL per-item agent // through the spawned workflow-process child, not the placeholder shape. -// A map iteration runs under a scoped step id `[]`, but -// deploy stages the map step's inference source, tool tree, and grants -// once, under the base map id; three deploy-asset lookups resolve the -// scoped id back to the base so every iteration shares those assets. +// A map iteration runs under a scoped step id `[]`, but the +// deployment holds the map step's inference source and grants once, under the +// base map id; the lookups resolve the scoped id back to the base so every +// iteration shares them. // // The only other map test (`per-level-pipeline-real-agents.test.ts`) runs // the runtime in-memory with a test `buildEnv`, so it never drives the @@ -17,8 +17,7 @@ // output is the agent's deterministic reply. // // The workflow is deliberately multi-step (a leading `seed` step plus the -// `fanout` map) so the deploy stages per-step assets at a per-step address. A -// regressed lookup throws (or materializes nothing) on the scoped id, so the +// `fanout` map). A regressed lookup throws (or materializes nothing) on the scoped id, so the // run would terminate `RunFailed` or the reply would omit the expected // content; the assertions below are the regression tripwires. // @@ -26,9 +25,9 @@ // - No tool: each iteration's reply is the empty-tool-set prefix, guarding // the inference-source resolver (a regressed lookup throws -> RunFailed). // - An inline tool: the per-item agent carries the inline `mail_send` tool, -// so each iteration's reply lists the tool, guarding the tool-deploy-tree -// base resolution (a regressed lookup reads the unstaged scoped address -> -// empty tools -> the tool is absent from the reply). +// so each iteration's reply lists the tool, guarding the agent's tool +// delivery (a regression leaves empty tools -> the tool is absent from the +// reply). // The grant scoped-id lookup keeps its unit coverage // (`credentials-backed-authorize.test.ts`). @@ -232,13 +231,7 @@ async function deployAndRunMap(opts: { const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${opts.anchorRunId}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -299,7 +292,7 @@ async function deployAndRunMap(opts: { // Primary regression tripwire: a regressed scoped-id lookup throws, so the // run would terminate RunFailed. Reaching RunCompleted means every map - // iteration resolved its base step's staged assets and ran. + // iteration resolved its base step's source and grants and ran. const terminal = await waitForWorkflowRunComplete( env, opts.anchorRunId, @@ -370,14 +363,11 @@ describe.skipIf(!harnessDbEnvAvailable())( expect(inferenceRequestCount).toBe(1 + ITEM_COUNT); }); - test("each map iteration materializes an inline tool via base-step resolution", async () => { - // The per-item agent carries the inline `mail_send` tool, staged in the - // map step's deploy tree keyed by the BASE step id. Each iteration - // resolves its scoped id `fanout[i]` back to the base to find that tree, - // materializes the tool, and exposes it to inference -- the mock echoes - // the exposed tool names into the reply. Without the tool-deploy-tree - // base resolution the iteration reads the unstaged scoped address, - // materializes no tools, and the reply omits the tool name. + test("each map iteration exposes its agent's inline tool", async () => { + // The per-item agent carries the inline `mail_send` tool. Each iteration + // builds its agent from the evaluated factories and exposes the tool to + // inference -- the mock echoes the exposed tool names into the reply. A + // regression that drops the factories leaves the reply without the name. const { events, inferenceRequestCount } = await deployAndRunMap({ anchorRunId: TOOL_DEPLOYMENT_ID, definitionAssetId: TOOL_DEFINITION_ASSET_ID, diff --git a/tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts b/tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts index 66d0b113a..fc1dafac0 100644 --- a/tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts +++ b/tests/workflow-deploy/midrun-signal-survives-reconnect.test.ts @@ -5,8 +5,8 @@ // with effects applied exactly once. // // Shape: deploy a `step1 -> awaitSignal{name: "go"} -> step2` workflow -// through the workflow-deploy orchestrator's multi-step branch (the same -// wiring `multistep-signal.test.ts` uses), fire the deployment's mail +// through `deployWorkflowSourceForTest` (the same wiring +// `multistep-signal.test.ts` uses), fire the deployment's mail // trigger, and drive the run to the mid-run `SignalAwaited` pause. With // the run parked at the signal gate, settle the workflow-run pack-push // pipeline and drop the hub link (`settleThenDrop`), wait for the sidecar @@ -140,13 +140,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts b/tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts index 9113f5871..2e01febbd 100644 --- a/tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts +++ b/tests/workflow-deploy/multistep-perstep-reroute-reconnect.test.ts @@ -1,18 +1,16 @@ -// Multi-step per-step re-route survival across a hub-link reconnect. +// Multi-step workflow re-route survival across a hub-link reconnect. // // Proves that after the sidecar's hub link is dropped and reconnects, a // deployed MULTI-STEP workflow's addresses are re-announced and re-routed, // and inter-step mail/signal routing still works end to end. // // Shape: deploy a `step1 -> awaitSignal{name:"go"} -> step2` workflow whose -// deployment address and every per-step derived address are run addresses -// (`isRunAddress` true), so they all share the workflow routing family -// that survives reconnect. Drive one mail trigger through the full inter-step chain +// deployment address is a run address (`isRunAddress` true), the workflow +// routing family that survives reconnect. Drive one mail trigger through the full inter-step chain // (RunStarted -> step1 -> SignalAwaited -> inject signal -> step2 -> // RunCompleted), `settleThenDrop` the hub link, wait for the deployment -// address to re-route through hello/welcome reconnect, assert every -// per-step address is once again a workflow-derived address routing under -// the re-established deployment, then fire a SECOND mail trigger and run the +// address to re-route through hello/welcome reconnect, then fire a SECOND mail +// trigger and run the // whole inter-step chain again. The second run only exists because the // sidecar re-established the link, the hub restored the workflow-derived // deployment address, and inter-step mail/signal routing came back with it. @@ -20,11 +18,8 @@ // The authenticated sidecar identity is allocation-bound to one workflow run // address. Reconnect revalidates that durable identity and the current // allocation generation before restoring the address in `workflowAddresses`. -// The per-step staging addresses are transient bindings (bound only while a -// step's packs land, never persisted into the reconnect set), so the hub route -// that survives the reconnect is the deployment address the steps collapse -// under; inter-step routing itself lives inside the workflow-process child, -// which the surviving deployment address feeds. +// Inter-step routing itself lives inside the workflow-process child, which the +// surviving deployment address feeds. // // Harness justification: SPAWN-REAL. A real hub server, a real sidecar // subprocess, a real workflow-process child, and a test inference provider. @@ -38,8 +33,6 @@ import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; import { createApprovalSet, deriveRunAddress, - deriveStepAddress, - deriveStepAgentId, type ApprovalSet, } from "@intx/workflow-deploy"; import { tenant as tenantTable } from "@intx/db/schema"; @@ -72,11 +65,10 @@ import { import { signalGateEntry } from "./fixtures/signal-gate"; const DEPLOYMENT_DOMAIN = "integration.interchange"; -// A substrate-safe deployment id whose run address and every per-step derived -// address are run addresses (`isRunAddress` true), matching the workflow -// routing family exercised by this test. +// The deployment id is minted in the run address shape, so the derived mail +// address is a run address (`isRunAddress` true), matching the workflow routing +// family exercised by this test. const DEPLOYMENT_ID = "run_multistep_reroute_1"; -const STEP_IDS = ["step1", "step2"] as const; // The definition's own tenant, the caller principal that creates the // definition asset, and the `workflow`-kind asset the frozen definition @@ -129,13 +121,13 @@ afterAll(async () => { }); describe.skipIf(!harnessDbEnvAvailable())( - "multi-step per-step re-route survival across reconnect", + "multi-step re-route survival across reconnect", () => { test("sidecar registers with hub", () => { expect(env.hub.router.getConnectedSidecars()).toContain(SIDECAR_ID); }); - test("every per-step address re-routes and inter-step routing survives reconnect", async () => { + test("the deployment address re-routes and inter-step routing survives reconnect", async () => { const deploymentMailAddress = deriveRunAddress({ runId: DEPLOYMENT_ID, domain: DEPLOYMENT_DOMAIN, @@ -144,24 +136,8 @@ describe.skipIf(!harnessDbEnvAvailable())( // restored by hello/welcome reconnect. expect(isRunAddress(deploymentMailAddress)).toBe(true); - // Every per-step derived address is a run address too, so each one belongs - // to the same workflow routing family that survives reconnect by collapsing - // under the restored deployment address rather than being resurrected - // as its own hub route. - const stepAddresses = STEP_IDS.map((stepId) => - deriveStepAddress({ - runId: DEPLOYMENT_ID, - stepId, - domain: DEPLOYMENT_DOMAIN, - }), - ); - for (const stepAddress of stepAddresses) { - expect(isRunAddress(stepAddress)).toBe(true); - } - // ---- deploy the multi-step workflow ---- - // Two distinct agent system prompts exercise the per-step prompt wiring so - // each per-step `agent-state` repo is provisioned end-to-end. + // Two distinct agent system prompts exercise the per-step prompt wiring. const inferenceSource: InferenceSource = { id: "anthropic:mock-model", provider: "anthropic", @@ -172,13 +148,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_multistep-reroute-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -232,22 +202,6 @@ describe.skipIf(!harnessDbEnvAvailable())( { diagnostics: env.sidecarDiagnostics }, ); - // Per-step `agent-state` repos materialize on the hub, one per step that - // carries an agent (the `awaitSignal` primitive produces none). Their ids - // are the per-step `agentId`s, which is the substrate-safe form of the - // per-step address the orchestrator staged each step's tree under. - for (const stepId of STEP_IDS) { - const stepAgentId = deriveStepAgentId({ - runId: DEPLOYMENT_ID, - stepId, - }); - const stepRepoDir = env.hub.agentRepoStore.repoStore.getRepoDir({ - kind: "agent-state", - id: stepAgentId, - }); - expect(typeof stepRepoDir).toBe("string"); - } - // The deployment address is routable on the hub after the multi-step // deploy: `sendAgentDeploy` recorded it on the router's index. expect(env.hub.router.getRoutableAddresses()).toContain( diff --git a/tests/workflow-deploy/multistep-signal.test.ts b/tests/workflow-deploy/multistep-signal.test.ts index 0d00ff528..51b665fbe 100644 --- a/tests/workflow-deploy/multistep-signal.test.ts +++ b/tests/workflow-deploy/multistep-signal.test.ts @@ -1,30 +1,14 @@ // Multi-step workflow round-trip with signal-await integration test. // // Deploys a `step1 -> awaitSignal{name: "go"} -> step2` workflow through -// the workflow-deploy orchestrator's multi-step branch, fires the +// `deployWorkflowSourceForTest`, fires the // deployment's mail trigger, observes the runtime pause at // `SignalAwaited`, injects a signal via the host-side signal channel, // and asserts the runtime resumes through `step2` to `RunCompleted`. // -// The orchestrator's multi-step branch is composed in-test: the per-step -// launch callback drives `env.hub.sessionService.stageWorkflowStep` (the -// stage-only path, no warm harness) and the `sendMultiStepDeploy` hand-off -// is supplied against `env.hub.router.sendAgentDeploy` so the sidecar's -// deploy router takes the workflow-process spawn path. The deployment -// handle is registered -// on the env via `registerDeployment` so the fixture's `injectSignal`, -// `readWorkflowRunEvents`, and `waitForWorkflowRunComplete` helpers can -// resolve it by id. -// -// The pre-landed `deploy-flow-env` fixture supplies every other helper; -// this file does not modify the fixture. -// -// Architectural-gap discipline: this test was previously authored -// against an un-wired multi-step transport surface. The plumbing that -// makes the deployment-level address routable, threads the workflow -// definition to the sidecar, spawns the workflow-process subprocess, -// and routes per-step pack pushes back to the hub now lands in the -// upstream commits this file's verification depends on. +// The deployment handle is registered on the env so the fixture's +// `injectSignal`, `readWorkflowRunEvents`, and `waitForWorkflowRunComplete` +// helpers can resolve it by id. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; @@ -32,7 +16,6 @@ import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; import { createApprovalSet, deriveRunAddress, - deriveStepAgentId, type ApprovalSet, } from "@intx/workflow-deploy"; import { tenant as tenantTable } from "@intx/db/schema"; @@ -132,13 +115,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -157,8 +134,7 @@ describe.skipIf(!harnessDbEnvAvailable())( // Two distinct agent system prompts exercise the per-step prompt // wiring: each step's agent carries its own prompt in the bundled - // source module, so different prompts produce different deploy trees - // at each per-step `agent-state` repo. + // source module. const entryModule = signalGateEntry({ address: deploymentMailAddress, signalName: "go", @@ -198,37 +174,9 @@ describe.skipIf(!harnessDbEnvAvailable())( { diagnostics: env.sidecarDiagnostics }, ); - // Per-step `agent-state` repos materialize on the hub: one per - // step that carries an agent (the `awaitSignal` primitive does - // not produce a per-step `agent-state` repo because it has no - // `AgentDefinition`). The agent-state repo id is the per-step - // `agentId` (not the per-step mail address): the mail address - // carries `@` and `.` which the substrate's `SAFE_REPO_ID` - // rejects, and the orchestrator's per-step `launchSession` - // already provisions the repo under the safe `agentId`. - const step1AgentId = deriveStepAgentId({ - runId: DEPLOYMENT_ID, - stepId: "step1", - }); - const step2AgentId = deriveStepAgentId({ - runId: DEPLOYMENT_ID, - stepId: "step2", - }); - const step1RepoDir = env.hub.agentRepoStore.repoStore.getRepoDir({ - kind: "agent-state", - id: step1AgentId, - }); - const step2RepoDir = env.hub.agentRepoStore.repoStore.getRepoDir({ - kind: "agent-state", - id: step2AgentId, - }); - expect(typeof step1RepoDir).toBe("string"); - expect(typeof step2RepoDir).toBe("string"); - // The deployment's trigger mail address must be routable on the - // hub. The sidecar's deploy router takes the multi-step branch - // and `sendAgentDeploy` records the deployment-level address on - // the hub router's index, which is what `routeMail` consults. + // hub. The deploy records the deployment-level address on the hub + // router's index, which is what `routeMail` consults. expect(env.hub.router.getRoutableAddresses()).toContain( deploymentMailAddress, ); diff --git a/tests/workflow-deploy/multistep-signed-send.test.ts b/tests/workflow-deploy/multistep-signed-send.test.ts index 449078fb5..b0ca51761 100644 --- a/tests/workflow-deploy/multistep-signed-send.test.ts +++ b/tests/workflow-deploy/multistep-signed-send.test.ts @@ -161,13 +161,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/on-trigger-action-body.test.ts b/tests/workflow-deploy/on-trigger-action-body.test.ts index 01b716f4c..6840be9c5 100644 --- a/tests/workflow-deploy/on-trigger-action-body.test.ts +++ b/tests/workflow-deploy/on-trigger-action-body.test.ts @@ -156,13 +156,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/on-trigger-agent-body.test.ts b/tests/workflow-deploy/on-trigger-agent-body.test.ts index 68180dd3b..595f03103 100644 --- a/tests/workflow-deploy/on-trigger-agent-body.test.ts +++ b/tests/workflow-deploy/on-trigger-agent-body.test.ts @@ -23,8 +23,7 @@ // Harness justification: SPAWN-REAL. Real hub, real sidecar subprocess, real // workflow-process child driving `runOnTrigger` with the production // suspendable-child seam, and a real agent inside the body via the body-only -// invoker. Body agents are tool-less by the deploy guard, so no tool trees are -// staged; this exercises exactly the reachable body-agent path. +// invoker. This exercises exactly the reachable body-agent path. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; @@ -166,13 +165,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/on-trigger-between-events-restart.test.ts b/tests/workflow-deploy/on-trigger-between-events-restart.test.ts index bfee72fd5..99e13abe4 100644 --- a/tests/workflow-deploy/on-trigger-between-events-restart.test.ts +++ b/tests/workflow-deploy/on-trigger-between-events-restart.test.ts @@ -192,13 +192,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_on-trigger-between-events-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts b/tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts index 476906ad7..a41cb6ca0 100644 --- a/tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts +++ b/tests/workflow-deploy/on-trigger-body-sleep-restart-resumes.test.ts @@ -199,13 +199,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_on-trigger-body-sleep-restart-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts b/tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts index 5407a254c..596c8a856 100644 --- a/tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts +++ b/tests/workflow-deploy/on-trigger-childworkflow-roundtrip.test.ts @@ -5,7 +5,7 @@ // onTrigger body"): the walk proves the operator APPROVES the nested child's // grants; this proves the nested child EXECUTES for real. It covers the exact // cross-type shape -- a childWorkflow spawn buried inside an onTrigger section -// body -- whose per-step assets the deploy layer must stage transitively. +// body -- whose grants and sources must resolve transitively. // // A workflow whose single top-level step is an `onTrigger` section is deployed // BY SOURCE-REF (bundle a source entry module into a hub asset, probe it, @@ -195,13 +195,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/on-trigger-credential-tool.test.ts b/tests/workflow-deploy/on-trigger-credential-tool.test.ts index c01b1b7b7..e769269ee 100644 --- a/tests/workflow-deploy/on-trigger-credential-tool.test.ts +++ b/tests/workflow-deploy/on-trigger-credential-tool.test.ts @@ -183,13 +183,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts b/tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts index c3239ce37..f459716e8 100644 --- a/tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts +++ b/tests/workflow-deploy/on-trigger-drain-roundtrip.test.ts @@ -140,13 +140,7 @@ async function deployDrainSection(opts: { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${opts.anchorRunId}`, - tenantId: "tenant-1", - principalId: `prin_${opts.anchorRunId}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/on-trigger-loop-body.test.ts b/tests/workflow-deploy/on-trigger-loop-body.test.ts index a7ece3994..cce674ac7 100644 --- a/tests/workflow-deploy/on-trigger-loop-body.test.ts +++ b/tests/workflow-deploy/on-trigger-loop-body.test.ts @@ -134,13 +134,7 @@ async function deployAndAssertBodyCompletes(opts: { const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: opts.deploymentId, - tenantId: "tenant-1", - principalId: `prin_${opts.deploymentId}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [ { id: "anthropic:mock-model", diff --git a/tests/workflow-deploy/on-trigger-signal-delivery.test.ts b/tests/workflow-deploy/on-trigger-signal-delivery.test.ts index 32fc1c92f..a7005aacb 100644 --- a/tests/workflow-deploy/on-trigger-signal-delivery.test.ts +++ b/tests/workflow-deploy/on-trigger-signal-delivery.test.ts @@ -249,13 +249,7 @@ async function deployAndPark(opts: { const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${anchorRunId}`, - tenantId: "tenant-1", - principalId: `prin_${anchorRunId}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts b/tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts index 561e22ed3..f0fa5ae18 100644 --- a/tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts +++ b/tests/workflow-deploy/on-trigger-tool-invoke-roundtrip.test.ts @@ -185,13 +185,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: `prin_${DEPLOYMENT_ID}`, agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -247,9 +241,8 @@ describe.skipIf(!harnessDbEnvAvailable())( // production route it does not materialize the rows itself -- deriving // them from the frozen snapshot here reproduces the delivery, and keeps // the grant the body authorizes against sourced from the probe walk - // rather than a hand-authored constant. A source-ref deploy has no - // tool-mark floor to fall back on (a source tool's runtime name is the - // bare `definition.name` the walk already emitted a grant for), so this + // rather than a hand-authored constant. A source tool's runtime name is the + // bare `definition.name` the walk already emitted a grant for, so this // IS the body's only authority. const snapshot = handle.approved.grantSnapshot; expect( diff --git a/tests/workflow-deploy/onfailure-action-roundtrip.test.ts b/tests/workflow-deploy/onfailure-action-roundtrip.test.ts index 4e60adb44..53d2ede3b 100644 --- a/tests/workflow-deploy/onfailure-action-roundtrip.test.ts +++ b/tests/workflow-deploy/onfailure-action-roundtrip.test.ts @@ -143,13 +143,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_onfailure-action-roundtrip-1", agentAddress: deploymentMailAddress, - systemPrompt: "unused (every step is an action)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/provisioned-deployment-stand-in.ts b/tests/workflow-deploy/provisioned-deployment-stand-in.ts index 7fcdc9e49..824ac286b 100644 --- a/tests/workflow-deploy/provisioned-deployment-stand-in.ts +++ b/tests/workflow-deploy/provisioned-deployment-stand-in.ts @@ -65,13 +65,7 @@ export function createProvisionedDeploymentStandIn(opts: { } const config: HarnessConfig = { sessionId: args.sessionId, - agentId: args.anchorRunId, - tenantId: args.tenantId, - principalId: args.sourceAuthorityPrincipalId, agentAddress, - systemPrompt: "", - tools: [], - grants: [], sources: [opts.inferenceSource], defaultSource: opts.inferenceSource.id, }; diff --git a/tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts b/tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts index 9e83138ea..433ad3362 100644 --- a/tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts +++ b/tests/workflow-deploy/reconnect-reemits-parked-correlation.test.ts @@ -222,7 +222,6 @@ function notImpl(name: string): never { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } @@ -464,13 +463,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_reconnect-reemit-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/run-mail-send-real-route.test.ts b/tests/workflow-deploy/run-mail-send-real-route.test.ts index 4dbf1283f..b7d2b4250 100644 --- a/tests/workflow-deploy/run-mail-send-real-route.test.ts +++ b/tests/workflow-deploy/run-mail-send-real-route.test.ts @@ -124,7 +124,6 @@ function notImpl(name: string): never { function createMockSessionService(): SessionService { return { - stageWorkflowStep: () => notImpl("stageWorkflowStep"), endSession: () => notImpl("endSession"), }; } @@ -220,13 +219,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/scope-always-roundtrip.test.ts b/tests/workflow-deploy/scope-always-roundtrip.test.ts index b405b2b81..b04f20ce7 100644 --- a/tests/workflow-deploy/scope-always-roundtrip.test.ts +++ b/tests/workflow-deploy/scope-always-roundtrip.test.ts @@ -43,12 +43,9 @@ // two calls carry DISTINCT `tool_use` ids so the re-dispatched first call is not // confused with the second. // -// Why the second call is not defeated by the tool-mark floor: the deployment is -// SOURCE-REF (`deployWorkflowSourceForTest`), and the child skips the tool-mark -// floor on the source-ref lineage -- the frozen snapshot's `tool:` grant -// authorizes the inline tool directly with its own effect. So the mutated -// `allow` in the run grants is the sole matching grant for the tool; there is no -// competing `ask` floor grant to outrank it at equal specificity. +// Why the second call goes through: the frozen snapshot's `tool:` grant +// authorizes the inline tool directly with its own effect, so the mutated `allow` +// in the run grants is the sole matching grant for the tool. // // Harness composition: SPAWN-REAL. A real hub server, a real sidecar subprocess, // a real workflow-process child, and a real migrated Postgres schema. The run is @@ -287,13 +284,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/sidecar-runs-no-workflow-code.test.ts b/tests/workflow-deploy/sidecar-runs-no-workflow-code.test.ts index 5d5dc574f..7c8235143 100644 --- a/tests/workflow-deploy/sidecar-runs-no-workflow-code.test.ts +++ b/tests/workflow-deploy/sidecar-runs-no-workflow-code.test.ts @@ -116,13 +116,7 @@ async function deployMarkingWorkflow( }); const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: params.runId, - tenantId: "tenant-1", - principalId: "prin_sidecar-runs-no-code", agentAddress: address, - systemPrompt: "unused (no agent step)", - tools: [], - grants: [], sources: [ { id: "anthropic:mock-model", diff --git a/tests/workflow-deploy/single-step-conversation-durability.test.ts b/tests/workflow-deploy/single-step-conversation-durability.test.ts index 701445139..6bac45e46 100644 --- a/tests/workflow-deploy/single-step-conversation-durability.test.ts +++ b/tests/workflow-deploy/single-step-conversation-durability.test.ts @@ -226,7 +226,6 @@ async function seedProcessingEntry( messageId: opts.messageId, receivedAt: opts.receivedAt, address: MAILBOX, - mailAuditRef: { store: "test", path: opts.messageId }, rawMessage: base64Encode(rawMessage), }; await fs.writeFile( @@ -602,7 +601,6 @@ describe("single-step conversation durability across respawn (Phase 4.5)", () => steps: [ { stepId: STEP_ID, - address: MAILBOX, grants: [], contentHash: "deadbeef", }, diff --git a/tests/workflow-deploy/single-step-credential-tool.test.ts b/tests/workflow-deploy/single-step-credential-tool.test.ts index 00cd5ebad..18a6f20fe 100644 --- a/tests/workflow-deploy/single-step-credential-tool.test.ts +++ b/tests/workflow-deploy/single-step-credential-tool.test.ts @@ -282,13 +282,7 @@ async function deployProbeSource(opts: { const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: anchorRunId, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: mailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [source], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-full-lifecycle.test.ts b/tests/workflow-deploy/single-step-full-lifecycle.test.ts index 6986edbb6..c4c096029 100644 --- a/tests/workflow-deploy/single-step-full-lifecycle.test.ts +++ b/tests/workflow-deploy/single-step-full-lifecycle.test.ts @@ -228,13 +228,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-grants-bridge.test.ts b/tests/workflow-deploy/single-step-grants-bridge.test.ts index 9893f9439..4a362f19d 100644 --- a/tests/workflow-deploy/single-step-grants-bridge.test.ts +++ b/tests/workflow-deploy/single-step-grants-bridge.test.ts @@ -192,13 +192,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-mail-flag-expunge.test.ts b/tests/workflow-deploy/single-step-mail-flag-expunge.test.ts index 018736d1e..98dfa3fba 100644 --- a/tests/workflow-deploy/single-step-mail-flag-expunge.test.ts +++ b/tests/workflow-deploy/single-step-mail-flag-expunge.test.ts @@ -273,13 +273,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-mail-in-reply-to.test.ts b/tests/workflow-deploy/single-step-mail-in-reply-to.test.ts index 332058172..483fa39a5 100644 --- a/tests/workflow-deploy/single-step-mail-in-reply-to.test.ts +++ b/tests/workflow-deploy/single-step-mail-in-reply-to.test.ts @@ -147,13 +147,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-mail-interactive-turns.test.ts b/tests/workflow-deploy/single-step-mail-interactive-turns.test.ts index bd2ebd974..abccefb67 100644 --- a/tests/workflow-deploy/single-step-mail-interactive-turns.test.ts +++ b/tests/workflow-deploy/single-step-mail-interactive-turns.test.ts @@ -218,13 +218,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-mail-loop.test.ts b/tests/workflow-deploy/single-step-mail-loop.test.ts index c2d0026f4..6c2ec534b 100644 --- a/tests/workflow-deploy/single-step-mail-loop.test.ts +++ b/tests/workflow-deploy/single-step-mail-loop.test.ts @@ -3,7 +3,7 @@ // The proof that the warm single-step agent's inbound mail surface is LIVE: // an arrived mail is visible to the agent's `mail_search` read tool WITHIN the // same turn, and the agent's reply drains back out as a signed outbound send. -// Deploys a warm single-step agent (source-ref lineage) that PINS the real +// Deploys a warm single-step agent (source-ref lineage) that carries the real // `@intx/tools-mail` bundle -- so it carries `mail_search` / `mail_read` / // `mail_wait` -- against the real hub + real sidecar subprocess + a mock // inference fixture, fires a mail, and asserts the loop. @@ -170,13 +170,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -190,7 +184,7 @@ describe.skipIf(!harnessDbEnvAvailable())( // approve-probed: approve exactly the surface the probe discovered (the // inference source, the director, the deployment mail address + send, and - // every `tool:mail_*` the pinned bundle declares), so the gate freezes the + // every `tool:mail_*` the bundle declares), so the gate freezes the // full mail toolset without enumerating each grant here. const handle = await deployWorkflowSourceForTest(env, { entryModule, diff --git a/tests/workflow-deploy/single-step-mail-two-turn-thread.test.ts b/tests/workflow-deploy/single-step-mail-two-turn-thread.test.ts index 5bc583508..ca40d137b 100644 --- a/tests/workflow-deploy/single-step-mail-two-turn-thread.test.ts +++ b/tests/workflow-deploy/single-step-mail-two-turn-thread.test.ts @@ -229,13 +229,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-mail-wait-wake.test.ts b/tests/workflow-deploy/single-step-mail-wait-wake.test.ts index 9768c568a..aefe6dc65 100644 --- a/tests/workflow-deploy/single-step-mail-wait-wake.test.ts +++ b/tests/workflow-deploy/single-step-mail-wait-wake.test.ts @@ -159,13 +159,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-message-input.test.ts b/tests/workflow-deploy/single-step-message-input.test.ts index bb3d04e75..fc710b8ae 100644 --- a/tests/workflow-deploy/single-step-message-input.test.ts +++ b/tests/workflow-deploy/single-step-message-input.test.ts @@ -2,8 +2,8 @@ // // The proof that the inbound mail's BODY reaches the warm agent's // `agent.send` as the step input -- not an empty or placeholder input. -// Deploys a one-step workflow through the workflow-deploy orchestrator's -// multi-step branch (which spawns the workflow-process subprocess) +// Deploys a one-step workflow through `deployWorkflowSourceForTest` (which +// spawns the workflow-process subprocess) // against the real hub + real sidecar subprocess + an echo inference // fixture, fires a mail with a KNOWN body, and asserts the step's // committed reply echoes that body. @@ -152,13 +152,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -292,13 +286,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID_2}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-per-run-grants.test.ts b/tests/workflow-deploy/single-step-per-run-grants.test.ts index a960e9b09..bd1527583 100644 --- a/tests/workflow-deploy/single-step-per-run-grants.test.ts +++ b/tests/workflow-deploy/single-step-per-run-grants.test.ts @@ -153,13 +153,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-pinned-ask-tool.test.ts b/tests/workflow-deploy/single-step-pinned-ask-tool.test.ts index fa3866681..f688edb86 100644 --- a/tests/workflow-deploy/single-step-pinned-ask-tool.test.ts +++ b/tests/workflow-deploy/single-step-pinned-ask-tool.test.ts @@ -71,9 +71,9 @@ const SENTINEL_CONTENT = "should-not-run-until-approved"; // projects over. The install/approve freeze and the anchor `workflow_run` // insert both write against these, so they must exist in the real DB before // the deploy runs. -const TENANT_ID = "tnt_single_step_pinned_ask_tool"; -const CALLER_PRINCIPAL_ID = "prn_single_step_pinned_ask_tool"; -const DEFINITION_ASSET_ID = "ast_single_step_pinned_ask_tool_wf"; +const TENANT_ID = "tnt_single_step_ask_tool"; +const CALLER_PRINCIPAL_ID = "prn_single_step_ask_tool"; +const DEFINITION_ASSET_ID = "ast_single_step_ask_tool_wf"; let env: DeployFlowEnv; let h: TestDb; @@ -135,13 +135,7 @@ describe.skipIf(!harnessDbEnvAvailable())("single-step ask-marked tool", () => { const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-posix-tool.test.ts b/tests/workflow-deploy/single-step-posix-tool.test.ts index bb0b17e69..678ba139e 100644 --- a/tests/workflow-deploy/single-step-posix-tool.test.ts +++ b/tests/workflow-deploy/single-step-posix-tool.test.ts @@ -159,13 +159,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/single-step-real-agent.test.ts b/tests/workflow-deploy/single-step-real-agent.test.ts index baf382da8..c109bebb2 100644 --- a/tests/workflow-deploy/single-step-real-agent.test.ts +++ b/tests/workflow-deploy/single-step-real-agent.test.ts @@ -138,13 +138,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/sleep-restart-keeps-authority.test.ts b/tests/workflow-deploy/sleep-restart-keeps-authority.test.ts index bb884fc15..be3660d0d 100644 --- a/tests/workflow-deploy/sleep-restart-keeps-authority.test.ts +++ b/tests/workflow-deploy/sleep-restart-keeps-authority.test.ts @@ -239,20 +239,10 @@ function mockSource(env: DeployFlowEnv): InferenceSource { }; } -function harnessConfig( - env: DeployFlowEnv, - anchorRunId: string, - mailAddress: string, -): HarnessConfig { +function harnessConfig(env: DeployFlowEnv, mailAddress: string): HarnessConfig { return { sessionId: SESSION_ID, - agentId: anchorRunId, - tenantId: "tenant-1", - principalId: `prin_${anchorRunId}`, agentAddress: mailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [mockSource(env)], defaultSource: "anthropic:mock-model", }; @@ -306,7 +296,7 @@ async function startCredentialRun( deploymentDomain: DEPLOYMENT_DOMAIN, agentAddress: mailAddress, approvals: "approve-probed", - config: harnessConfig(env, anchorRunId, mailAddress), + config: harnessConfig(env, mailAddress), credentialCipher: createNoopCredentialCipher(), }); await waitFor( @@ -483,7 +473,7 @@ describe.skipIf(!harnessDbEnvAvailable())( `mail.send:${DEPLOYMENT_DOMAIN}`, `tool:${MAIL_TOOL_NAME}`, ]), - config: harnessConfig(env, anchorRunId, mailAddress), + config: harnessConfig(env, mailAddress), // The deploy frame needs a source chain for every step, the sleep // included. sources: { [NAP_STEP_ID]: [source], [AGENT_STEP_ID]: [source] }, diff --git a/tests/workflow-deploy/source-catalog.e2e.test.ts b/tests/workflow-deploy/source-catalog.e2e.test.ts index 9c7176c7b..87df14ab3 100644 --- a/tests/workflow-deploy/source-catalog.e2e.test.ts +++ b/tests/workflow-deploy/source-catalog.e2e.test.ts @@ -316,13 +316,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/source-closure-director.e2e.test.ts b/tests/workflow-deploy/source-closure-director.e2e.test.ts index 87b4e3898..18e3654e0 100644 --- a/tests/workflow-deploy/source-closure-director.e2e.test.ts +++ b/tests/workflow-deploy/source-closure-director.e2e.test.ts @@ -352,13 +352,7 @@ async function deployAndRun(spec: { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: spec.anchorRunId, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: spec.address, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/source-credential.e2e.test.ts b/tests/workflow-deploy/source-credential.e2e.test.ts index 2aadd1cc4..7e1206988 100644 --- a/tests/workflow-deploy/source-credential.e2e.test.ts +++ b/tests/workflow-deploy/source-credential.e2e.test.ts @@ -327,13 +327,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/source-inline-tool.e2e.test.ts b/tests/workflow-deploy/source-inline-tool.e2e.test.ts index 3d6179a39..3d9983725 100644 --- a/tests/workflow-deploy/source-inline-tool.e2e.test.ts +++ b/tests/workflow-deploy/source-inline-tool.e2e.test.ts @@ -8,12 +8,11 @@ // the mock inference server to call the inline tool. // // The evaluated closure carries the step agent's live `AnnotatedToolFactory`s -// on `req.agent.toolFactories`; the source-ref arm of the sidecar step -// build-env feeds them straight into the step agent (no pinned manifest is -// staged on this lineage). The tool's runtime name is the bare +// on `req.agent.toolFactories`; the sidecar step build-env feeds them straight +// into the step agent. The tool's runtime name is the bare // `definition.name`, which the probe's capability walk already emitted a // `tool:` grant for into the frozen credentials snapshot, so the run -// authorizes the call through the snapshot directly -- no tool-mark floor. +// authorizes the call through the snapshot directly. // // Three assertions carry the proof that the tool RAN in-child, not tool-less: // 1. the first inference request's tool list contains the inline tool's name @@ -147,13 +146,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/source-mixed-closure.e2e.test.ts b/tests/workflow-deploy/source-mixed-closure.e2e.test.ts index 30ff5e3e0..73ee2da64 100644 --- a/tests/workflow-deploy/source-mixed-closure.e2e.test.ts +++ b/tests/workflow-deploy/source-mixed-closure.e2e.test.ts @@ -405,13 +405,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/source-monorepo-many.e2e.test.ts b/tests/workflow-deploy/source-monorepo-many.e2e.test.ts index bb961fb61..3c4ecea88 100644 --- a/tests/workflow-deploy/source-monorepo-many.e2e.test.ts +++ b/tests/workflow-deploy/source-monorepo-many.e2e.test.ts @@ -361,13 +361,7 @@ async function deployAndRun(spec: { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: spec.anchorRunId, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: spec.address, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/source-monorepo.e2e.test.ts b/tests/workflow-deploy/source-monorepo.e2e.test.ts index 2b6c5ac5f..2cbe31472 100644 --- a/tests/workflow-deploy/source-monorepo.e2e.test.ts +++ b/tests/workflow-deploy/source-monorepo.e2e.test.ts @@ -386,13 +386,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/source-posix-lsp.e2e.test.ts b/tests/workflow-deploy/source-posix-lsp.e2e.test.ts index 6a4aaeeb4..a5c8d5bbe 100644 --- a/tests/workflow-deploy/source-posix-lsp.e2e.test.ts +++ b/tests/workflow-deploy/source-posix-lsp.e2e.test.ts @@ -505,13 +505,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/source-tamper-reject.e2e.test.ts b/tests/workflow-deploy/source-tamper-reject.e2e.test.ts index 3e47531b8..bbd7bf523 100644 --- a/tests/workflow-deploy/source-tamper-reject.e2e.test.ts +++ b/tests/workflow-deploy/source-tamper-reject.e2e.test.ts @@ -282,13 +282,7 @@ describe.skipIf(!harnessDbEnvAvailable())( }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/source-workflow.e2e.test.ts b/tests/workflow-deploy/source-workflow.e2e.test.ts index 1c149be7a..62bf25380 100644 --- a/tests/workflow-deploy/source-workflow.e2e.test.ts +++ b/tests/workflow-deploy/source-workflow.e2e.test.ts @@ -332,13 +332,7 @@ describe.skipIf(!harnessDbEnvAvailable())("source-sourced workflow e2e", () => { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow-deploy/substrate-walk-microbench.bench.ts b/tests/workflow-deploy/substrate-walk-microbench.bench.ts index 406b6fef3..3e21a5514 100644 --- a/tests/workflow-deploy/substrate-walk-microbench.bench.ts +++ b/tests/workflow-deploy/substrate-walk-microbench.bench.ts @@ -1,18 +1,14 @@ -// Direct substrate microbench re-measuring the per-commit cost of the two -// O(N) walks B3a scopes: clearIndexPrefix's git.statusMatrix and the -// workflow-run kind handler's validatePush per-run walk. It drives the -// production write path -- createRepoStore + workflowRunKindHandler + -// writeTree with a `runs//events/` clearPrefix -- exactly as the -// supervisor's run-event bracket commit does, growing the repo's tracked -// file count by adding fresh runs, and measures each stage's per-commit -// time as a function of total file count. +// Direct substrate microbench re-measuring the per-commit cost of the O(N) +// walk B3a scopes: the workflow-run kind handler's validatePush per-run walk. +// It drives the write path -- +// createRepoStore + workflowRunKindHandler + writeTreePreservingPrefix on +// `runs//events/` -- growing the repo's tracked file count by adding +// fresh runs, and measures each stage's per-commit time as a function of +// total file count. // -// For each stage it reports BOTH the scoped (live, B3a) slope and an -// un-scoped reference slope, so the A/B is in one run: -// - statusMatrix: timed narrowed (filepaths:[prefix], live) vs -// un-narrowed (whole repo) against the same on-disk repo. -// - validatePush: timed against the live scoped handler vs a wrapper -// that forces changedPathPrefixes:undefined (validate-all). +// It reports BOTH the scoped (live, B3a) slope and an un-scoped reference slope, +// so the A/B is in one run: validatePush timed against the live scoped handler +// vs a wrapper that forces changedPathPrefixes:undefined (validate-all). // // Run with: bun test tests/workflow-deploy/substrate-walk-microbench.bench.ts // It is named `.bench.ts` so the default `make test` enumeration skips it. @@ -191,20 +187,6 @@ async function timeValidatePush( return elapsed; } -async function timeStatusMatrix( - dir: string, - prefix: string, - narrowed: boolean, -): Promise { - const t0 = performance.now(); - if (narrowed) { - await git.statusMatrix({ fs, dir, filepaths: [prefix] }); - } else { - await git.statusMatrix({ fs, dir }); - } - return performance.now() - t0; -} - function slope(points: { x: number; y: number }[]): number { const n = points.length; const sx = points.reduce((a, p) => a + p.x, 0); @@ -215,7 +197,7 @@ function slope(points: { x: number; y: number }[]): number { } describe("B3a substrate-walk microbench", () => { - test("statusMatrix + validatePush stage slopes drop to ~0 under scoping", async () => { + test("the validatePush stage slope drops to ~0 under scoping", async () => { const dataDir = await makeTempDir("b3a-bench-"); const store = createRepoStore({ dataDir, @@ -235,8 +217,6 @@ describe("B3a substrate-walk microbench", () => { const SAMPLE_EVERY = 20; const samples: { files: number; - smNarrow: number; - smWide: number; vpScoped: number; vpAll: number; }[] = []; @@ -250,12 +230,13 @@ describe("B3a substrate-walk microbench", () => { for (let i = 0; i < TOTAL_RUNS; i++) { const runId = `run-${String(i).padStart(4, "0")}`; const prefix = `runs/${runId}/events/`; - await store.writeTree(HUB, repoId, REF, { - files: { - [`runs/${runId}/events/0.json`]: eventBody(0, "RunStarted"), - [`runs/${runId}/events/1.json`]: eventBody(1, "RunCompleted"), - }, - clearPrefix: prefix, + await store.writeTreePreservingPrefix(HUB, repoId, REF, { + preservePrefix: prefix, + merge: () => + Promise.resolve({ + [`runs/${runId}/events/0.json`]: eventBody(0, "RunStarted"), + [`runs/${runId}/events/1.json`]: eventBody(1, "RunCompleted"), + }), message: `run ${runId}`, }); @@ -263,16 +244,6 @@ describe("B3a substrate-walk microbench", () => { const files = await git.listFiles({ fs, dir, ref: REF }); const fileCount = files.length; // Median of a few reads to damp scheduler noise. - const smNarrow = Math.min( - await timeStatusMatrix(dir, prefix, true), - await timeStatusMatrix(dir, prefix, true), - await timeStatusMatrix(dir, prefix, true), - ); - const smWide = Math.min( - await timeStatusMatrix(dir, prefix, false), - await timeStatusMatrix(dir, prefix, false), - await timeStatusMatrix(dir, prefix, false), - ); const vpScoped = Math.min( await timeValidatePush(workflowRunKindHandler, dir, runId, false), await timeValidatePush(workflowRunKindHandler, dir, runId, false), @@ -283,16 +254,10 @@ describe("B3a substrate-walk microbench", () => { await timeValidatePush(workflowRunKindHandler, dir, runId, true), await timeValidatePush(workflowRunKindHandler, dir, runId, true), ); - samples.push({ files: fileCount, smNarrow, smWide, vpScoped, vpAll }); + samples.push({ files: fileCount, vpScoped, vpAll }); } } - const smNarrowSlope = slope( - samples.map((s) => ({ x: s.files, y: s.smNarrow })), - ); - const smWideSlope = slope( - samples.map((s) => ({ x: s.files, y: s.smWide })), - ); const vpScopedSlope = slope( samples.map((s) => ({ x: s.files, y: s.vpScoped })), ); @@ -301,10 +266,6 @@ describe("B3a substrate-walk microbench", () => { const report = { sampleCount: samples.length, maxFiles: samples[samples.length - 1]?.files ?? 0, - statusMatrix: { - narrowed_slope_ms_per_file: smNarrowSlope, - unnarrowed_slope_ms_per_file: smWideSlope, - }, validatePush: { scoped_slope_ms_per_file: vpScopedSlope, validateAll_slope_ms_per_file: vpAllSlope, @@ -314,10 +275,9 @@ describe("B3a substrate-walk microbench", () => { // eslint-disable-next-line no-console -- microbench result is the artifact console.log(`B3A_MICROBENCH ${JSON.stringify(report)}`); - // The scoped slopes must be a small fraction of the un-scoped slopes: - // the proof the prod scopings flatten the per-commit cost in file + // The scoped slope must be a small fraction of the un-scoped slope: + // the proof the prod scoping flattens the per-commit cost in file // count the way the throwaway's did. - expect(smNarrowSlope).toBeLessThan(smWideSlope * 0.5 + 0.01); expect(vpScopedSlope).toBeLessThan(vpAllSlope * 0.5 + 0.01); }, 120000); }); @@ -501,7 +461,6 @@ describe("P2 consumed/-retention microbench", () => { address: BENCH_ADDRESS, messageId, receivedAt: t, - mailAuditRef: { store: "audit", path: `mail/${messageId}` }, }); await dequeueToProcessing(store, HUB, repoId, BENCH_ADDRESS); const mc0 = performance.now(); diff --git a/tests/workflow-deploy/tarball-route-deploy.test.ts b/tests/workflow-deploy/tarball-route-deploy.test.ts index a3c755ccf..8c4ee1f39 100644 --- a/tests/workflow-deploy/tarball-route-deploy.test.ts +++ b/tests/workflow-deploy/tarball-route-deploy.test.ts @@ -265,16 +265,11 @@ describe.skipIf(!harnessDbEnvAvailable())( }, ); - const assetService = createAssetService({ - db: h.db, - repoStore: env.hub.agentRepoStore.repoStore, - }); sessionService = createSessionService({ loadRunCredentials: async () => null, sidecarRouter: env.hub.router, sidecarAllocationRouter: env.hub.router, agentRepoStore: env.hub.agentRepoStore, - assetService, db: h.db, toolPackageRegistries: { httpRegistries: new Map([ diff --git a/tests/workflow-deploy/unresolvable-director.test.ts b/tests/workflow-deploy/unresolvable-director.test.ts index 6bd927660..c46206f3e 100644 --- a/tests/workflow-deploy/unresolvable-director.test.ts +++ b/tests/workflow-deploy/unresolvable-director.test.ts @@ -113,13 +113,7 @@ describe.skipIf(!harnessDbEnvAvailable())( const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: `${DEPLOYMENT_ID}`, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -143,7 +137,6 @@ describe.skipIf(!harnessDbEnvAvailable())( const initialDeploymentCount = env.deployments.size; const initialDeployAckCount = env.hub.deployAcks.size; - const initialStatePackCount = env.hub.statePacks.length; let captured: unknown = undefined; try { @@ -178,10 +171,9 @@ describe.skipIf(!harnessDbEnvAvailable())( // No partial state: the probe rejects before the anchor row insert, the // deploy frame, or `registerDeployment`, so the rejected deploy must not - // have produced a deployment handle, a deploy-ack, or a state-pack write. + // have produced a deployment handle or a deploy-ack. expect(env.deployments.size).toBe(initialDeploymentCount); expect(env.hub.deployAcks.size).toBe(initialDeployAckCount); - expect(env.hub.statePacks.length).toBe(initialStatePackCount); }); }, ); diff --git a/tests/workflow-deploy/walking-skeleton.e2e.test.ts b/tests/workflow-deploy/walking-skeleton.e2e.test.ts index d8e8f299d..8998fe5e1 100644 --- a/tests/workflow-deploy/walking-skeleton.e2e.test.ts +++ b/tests/workflow-deploy/walking-skeleton.e2e.test.ts @@ -529,13 +529,7 @@ describe.skipIf(!harnessDbEnvAvailable())("walking skeleton e2e", () => { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: deploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; @@ -723,13 +717,7 @@ describe.skipIf(!harnessDbEnvAvailable())("walking skeleton e2e", () => { }; const config: HarnessConfig = { sessionId: SESSION_ID, - agentId: BODY_DEPLOYMENT_ID, - tenantId: "tenant-1", - principalId: "prin_integration-1", agentAddress: bodyDeploymentMailAddress, - systemPrompt: "Fallback prompt (overridden per step by the definition)", - tools: [], - grants: [], sources: [inferenceSource], defaultSource: "anthropic:mock-model", }; diff --git a/tests/workflow/run-local-real-step-invoker.test.ts b/tests/workflow/run-local-real-step-invoker.test.ts index db874e242..bbf399619 100644 --- a/tests/workflow/run-local-real-step-invoker.test.ts +++ b/tests/workflow/run-local-real-step-invoker.test.ts @@ -191,7 +191,7 @@ describe("runLocal with the real step invoker", () => { ); const authorize = createCredentialsBackedAuthorize( resolveGrants, - createGrantEvaluator({ floorFor: () => [] }), + createGrantEvaluator(), ); const invokeStep = createWorkflowStepInvoker({ workflowAuthorize: authorize,