@intx/hub-sessions 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +3 -5
  2. package/dist/agent-repo.d.ts +23 -7
  3. package/dist/agent-repo.js +19 -6
  4. package/dist/agent-state-kind.js +18 -63
  5. package/dist/asset-service.d.ts +1 -20
  6. package/dist/asset-service.js +19 -97
  7. package/dist/committed-source-tree.d.ts +10 -0
  8. package/dist/committed-source-tree.js +35 -0
  9. package/dist/credential-push.d.ts +52 -7
  10. package/dist/credential-push.js +178 -22
  11. package/dist/event-collector-registry.d.ts +3 -2
  12. package/dist/event-collector-registry.js +42 -13
  13. package/dist/event-collector.d.ts +12 -2
  14. package/dist/event-collector.js +45 -4
  15. package/dist/hub-session-lookups.d.ts +125 -7
  16. package/dist/hub-session-lookups.js +541 -86
  17. package/dist/hub-session-orchestrator.d.ts +2 -3
  18. package/dist/hub-session-orchestrator.js +22 -56
  19. package/dist/index.d.ts +19 -9
  20. package/dist/index.js +16 -7
  21. package/dist/reconciliation-scheduler.d.ts +14 -0
  22. package/dist/reconciliation-scheduler.js +55 -0
  23. package/dist/repo-store/index.d.ts +2 -1
  24. package/dist/repo-store/index.js +1 -0
  25. package/dist/repo-store/store.d.ts +1 -1
  26. package/dist/repo-store/store.js +138 -1
  27. package/dist/repo-store/subscribe-kind.d.ts +6 -3
  28. package/dist/repo-store/subscribe-kind.js +42 -77
  29. package/dist/repo-store/types.d.ts +94 -6
  30. package/dist/repo-store/user-principal-gate.d.ts +26 -0
  31. package/dist/repo-store/user-principal-gate.js +78 -0
  32. package/dist/session-service.d.ts +252 -126
  33. package/dist/session-service.js +851 -624
  34. package/dist/sidecar-allocation/capability-policy.d.ts +27 -0
  35. package/dist/sidecar-allocation/capability-policy.js +124 -0
  36. package/dist/sidecar-allocation/contracts.d.ts +101 -0
  37. package/dist/sidecar-allocation/contracts.js +26 -0
  38. package/dist/sidecar-allocation/index.d.ts +5 -0
  39. package/dist/sidecar-allocation/index.js +4 -0
  40. package/dist/sidecar-allocation/operation.d.ts +10 -0
  41. package/dist/sidecar-allocation/operation.js +54 -0
  42. package/dist/sidecar-allocation/plugin-registry.d.ts +24 -0
  43. package/dist/sidecar-allocation/plugin-registry.js +61 -0
  44. package/dist/sidecar-allocation/reconciler.d.ts +54 -0
  45. package/dist/sidecar-allocation/reconciler.js +825 -0
  46. package/dist/skill-kind.js +12 -62
  47. package/dist/substrate.d.ts +3 -3
  48. package/dist/substrate.js +1 -1
  49. package/dist/workflow-allocation-service.d.ts +64 -0
  50. package/dist/workflow-allocation-service.js +554 -0
  51. package/dist/workflow-closure-resolution.d.ts +106 -0
  52. package/dist/workflow-closure-resolution.js +123 -0
  53. package/dist/workflow-definition-ensure.d.ts +24 -0
  54. package/dist/workflow-definition-ensure.js +75 -0
  55. package/dist/workflow-dispatch-service.d.ts +42 -0
  56. package/dist/workflow-dispatch-service.js +209 -0
  57. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  58. package/dist/workflow-dispatch-settlement.js +140 -0
  59. package/dist/workflow-kind.d.ts +29 -1
  60. package/dist/workflow-kind.js +143 -139
  61. package/dist/workflow-probe-gate.d.ts +286 -0
  62. package/dist/workflow-probe-gate.js +382 -0
  63. package/dist/workflow-run-kind.d.ts +239 -32
  64. package/dist/workflow-run-kind.js +874 -188
  65. package/dist/workflow-run-reader.d.ts +1 -1
  66. package/dist/workflow-run-reader.js +3 -7
  67. package/dist/workflow-run-restore.d.ts +16 -0
  68. package/dist/workflow-run-restore.js +30 -0
  69. package/dist/workflow-source-closure.d.ts +35 -0
  70. package/dist/workflow-source-closure.js +342 -0
  71. package/dist/workflow-source-pins.d.ts +8 -0
  72. package/dist/workflow-source-pins.js +14 -0
  73. package/dist/ws/index.d.ts +3 -3
  74. package/dist/ws/index.js +2 -2
  75. package/dist/ws/pending-tracker.d.ts +93 -0
  76. package/dist/ws/pending-tracker.js +132 -0
  77. package/dist/ws/sidecar-events.d.ts +139 -37
  78. package/dist/ws/sidecar-events.js +2 -2
  79. package/dist/ws/sidecar-handler.d.ts +218 -60
  80. package/dist/ws/sidecar-handler.js +1713 -732
  81. package/dist/ws/sidecar-handler.test-helpers.d.ts +38 -0
  82. package/dist/ws/sidecar-handler.test-helpers.js +95 -0
  83. package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
  84. package/dist/ws/sidecar-token-authenticator.js +78 -7
  85. package/package.json +14 -13
  86. package/dist/available-skills-stanza.d.ts +0 -21
  87. package/dist/available-skills-stanza.js +0 -32
@@ -1,20 +1,20 @@
1
1
  import { type } from "arktype";
2
- import { and, eq } from "drizzle-orm";
3
- import { createDefaultDirectorRegistry, } from "@intx/agent";
2
+ import { and, eq, isNull } from "drizzle-orm";
4
3
  import { getLogger } from "@intx/log";
5
- import { assembleMessage, assembleSignedContent, createDetachedSignatureFromProvider, } from "@intx/mime";
6
- import { listAssetsForTenant } from "@intx/db";
7
- import { grant as grantTable, workflowDeployment as workflowDeploymentTable, } from "@intx/db/schema";
8
- import { base64Encode, hexEncode } from "@intx/types";
9
- import { generateId } from "@intx/hub-common";
10
- import { sessionAsset as sessionAssetTable, } from "@intx/db/schema";
4
+ import { buildCredentialDelivery, createSidecarAllocationStore, listAssetsForTenant, resolveInferenceMaterials, } from "@intx/db";
5
+ import { workflowDefinition as workflowDefinitionTable, workflowRun as workflowRunTable, } from "@intx/db/schema";
6
+ import { hexEncode } from "@intx/types";
7
+ import { sessionAsset as sessionAssetTable } from "@intx/db/schema";
11
8
  import { AssetRegistrySource, HttpRegistrySource, ManifestInvalidError, createClosureResolver, } from "@intx/tool-packaging";
12
9
  import { ToolPackageManifest, } from "@intx/types/tool-packages";
13
- import { defineWorkflow, } from "@intx/workflow/definition";
14
- import { createWorkflowDeployOrchestrator, deriveDeploymentAddress, walkCapabilities, wrapHarnessAsSingleStepWorkflow, } from "@intx/workflow-deploy";
10
+ import { buildInertProjectionStepSources, collectAgentBearingStepIds, deriveRunAddress, WorkflowDefinitionInvalidError, } from "@intx/workflow-deploy";
15
11
  import { DEFAULT_ASSET_REF, } from "./asset-service.js";
16
- import { buildAvailableSkillsStanza, } from "./available-skills-stanza.js";
17
- import { getSkillIndex } from "./skill-kind.js";
12
+ import { isDeployFrameFailure } from "./ws/sidecar-handler.js";
13
+ import { buildSourceAssetMounts, } from "./workflow-closure-resolution.js";
14
+ import { restoreWorkflowRunToAllocation } from "./workflow-run-restore.js";
15
+ import { committedReadsToSourceTree } from "./committed-source-tree.js";
16
+ import { installAndApproveWorkflowDefinition, } from "./workflow-probe-gate.js";
17
+ import { buildReferencedWorkflowSourcePins } from "./workflow-source-pins.js";
18
18
  const logger = getLogger(["interchange", "hub", "session-service"]);
19
19
  export class SessionLaunchError extends Error {
20
20
  /** Which phase failed: "write", "provision", "pack", or "start". */
@@ -29,9 +29,9 @@ export class SessionLaunchError extends Error {
29
29
  this.leakedAgent = leakedAgent;
30
30
  }
31
31
  }
32
- // Hub-side principal for reading skill repos. Skills are signed by the
33
- // hub itself, and listAgentAssets is being called on the hub to assemble
34
- // packs for delivery to a sidecar so the hub principal is correct.
32
+ // Hub-side principal for reading asset repos. Assets are signed by the
33
+ // hub itself, and the launch fan-out reads them on the hub to assemble
34
+ // packs for delivery to a sidecar -- so the hub principal is correct.
35
35
  const HUB_PRINCIPAL = { kind: "hub" };
36
36
  async function createPackSha(pack) {
37
37
  const digest = await crypto.subtle.digest("SHA-256",
@@ -59,75 +59,6 @@ function collectDistinctAssetIds(manifest) {
59
59
  }
60
60
  return out;
61
61
  }
62
- /**
63
- * Dedup the union of `direct` and `resolved` attachments by asset id
64
- * (taken from `repoId.id`), with `direct` taking precedence whenever
65
- * both name the same asset.
66
- *
67
- * The package-registry "both name the same asset" case is refused
68
- * upstream at the resolver block (a direct attachment plus a resolver
69
- * pin for the same package-registry asset would emit assetMounts at
70
- * the resolver's ref while the direct attachment materializes at the
71
- * operator's chosen ref, leaving the loader to resolve manifest
72
- * entries against tarballs that do not exist at the materialized
73
- * mount). Skill attachments cannot collide via the resolver path —
74
- * the resolver only emits package-registry entries — so the dedup
75
- * still has to handle skill self-collisions defensively and to fall
76
- * through cleanly when both sources happen to name an asset the
77
- * upstream check has not flagged.
78
- *
79
- * The function takes the two sources as named parameters rather than a
80
- * pre-merged list so the precedence rule is structural: a future
81
- * refactor cannot accidentally swap the order by re-arranging an
82
- * intermediate spread.
83
- */
84
- function dedupAttachmentsByAssetId(args) {
85
- const seen = new Set();
86
- const out = [];
87
- for (const att of args.direct) {
88
- if (seen.has(att.repoId.id))
89
- continue;
90
- seen.add(att.repoId.id);
91
- out.push(att);
92
- }
93
- for (const att of args.resolved) {
94
- if (seen.has(att.repoId.id))
95
- continue;
96
- seen.add(att.repoId.id);
97
- out.push(att);
98
- }
99
- return out;
100
- }
101
- /**
102
- * Compute the materialization path for an attachment from the asset's
103
- * kind and name. v1 does not let users override the path — the path is
104
- * a function of the asset, full stop. Today only `skill` has a defined
105
- * mapping (`skills/<asset.name>/`); other kinds reach this code path
106
- * via the `never` branch and throw, per the defensive-coding rule that
107
- * we never silently invent a default for an unhandled kind.
108
- *
109
- * Asset names are validated lowercase-kebab at `createAsset`, which is
110
- * the only entry path into this function, so the resulting path is
111
- * safe under `applyAssetPack`'s per-segment validator.
112
- */
113
- function resolveMountPath(row) {
114
- switch (row.asset.kind) {
115
- case "skill":
116
- return `skills/${row.asset.name}/`;
117
- case "package-registry":
118
- return `package-registries/${row.asset.name}/`;
119
- case "agent-state":
120
- throw new Error(`mount_path_required: agent_asset row ${row.id} references agent-state asset ${row.asset.id}; agent-state attachments are not supported`);
121
- case "workflow":
122
- throw new Error("kind handler not yet registered: workflow");
123
- case "workflow-run":
124
- throw new Error("kind handler not yet registered: workflow-run");
125
- default: {
126
- const exhaustive = row.asset.kind;
127
- throw new Error(`mount_path_required: no default mountPath for asset kind ${String(exhaustive)} on row ${row.id}`);
128
- }
129
- }
130
- }
131
62
  /**
132
63
  * Translate the orchestrator's structural `DeployContent` (which types
133
64
  * `toolPackageManifest` as `unknown`) back into the hub-sessions
@@ -155,118 +86,503 @@ export function bridgeOrchestratorDeployContent(content) {
155
86
  return bridged;
156
87
  }
157
88
  /**
158
- * Wire the workflow-deploy orchestrator's `sendMultiStepDeploy`
159
- * dependency against `SidecarRouter.sendAgentDeploy`. The router
160
- * accepts an optional `workflow` projection on the deploy frame; the
161
- * sidecar's deploy router uses field presence to route the frame to
162
- * the workflow deploy path. The supervisor public key returned by the
163
- * sidecar's `agent.deploy.ack` is threaded back as the
164
- * `MultiStepDeployResult.publicKey`.
89
+ * Emit the source-ref deploy frame onto `SidecarRouter.sendAgentDeploy`. The
90
+ * router accepts an optional `workflow` projection on the deploy frame; the
91
+ * sidecar's deploy router uses field presence to route the frame to the
92
+ * workflow deploy path, and returns the supervisor public key on the
93
+ * `agent.deploy.ack`.
165
94
  *
166
- * Exported so the co-located caller-site test can assert that the
167
- * closure constructed in `launchSession` reaches the wire surface via
168
- * `sendAgentDeploy` with a `workflow` field structurally matching the
169
- * `AgentDeployFrame.workflow` schema.
95
+ * The gate/freeze layer already hashed the inert projection, so the frozen hash
96
+ * and the inert projection ride the frame verbatim -- this never recomputes the
97
+ * content hash. Recomputing over a live wire lineage would diverge from the
98
+ * inert projection the child re-verifies against.
99
+ *
100
+ * Exported so the co-located caller-site test can assert that the constructed
101
+ * closure reaches the wire surface via `sendAgentDeploy` with a `workflow`
102
+ * field structurally matching the `AgentDeployFrame.workflow` schema.
170
103
  */
171
- export async function sendMultiStepDeployFrame(args) {
172
- // The wire validator's projection types `stepOrder` and `triggers`
173
- // as mutable arrays while `WorkflowDefinition` declares them as
174
- // `readonly`. The wire serializer never mutates the arrays; the
175
- // shallow copies pay the readonly-widen at the boundary. Every
176
- // field listed here must match the structural envelope the
177
- // workflow-process child re-validates against on materialization
178
- // (`workflowDefinitionEnvelopeSchema`): `id`, `triggers`, `steps`,
179
- // `stepOrder`, optional `state`. The sidecar deploy router
180
- // serializes this object verbatim into `workflow.json`; a missing
181
- // envelope-required field here would round-trip into the child's
182
- // envelope rejection on disk.
183
- const wireDefinition = {
184
- id: args.definition.id,
185
- triggers: [...args.definition.triggers],
186
- stepOrder: [...args.definition.stepOrder],
187
- steps: args.definition.steps,
188
- ...(args.definition.state !== undefined
189
- ? { state: args.definition.state }
104
+ export async function sendMultiStepDeployFrame(args, signal, beforeSend) {
105
+ signal?.throwIfAborted();
106
+ const workflow = {
107
+ // The deploy frame carries no inline definition: the sidecar evaluates the
108
+ // pinned code closure from `sourceRef` and re-verifies it against
109
+ // `approvedWireHash`. Only the gate-frozen hash and the pin ride the frame.
110
+ sources: args.sources,
111
+ approvedWireHash: args.approvedWireHash,
112
+ sourceRef: args.sourceRef,
113
+ ...(args.credentials !== undefined
114
+ ? { credentials: args.credentials }
115
+ : {}),
116
+ ...(args.referencedDefinitions !== undefined &&
117
+ args.referencedDefinitions.length > 0
118
+ ? { referencedDefinitions: [...args.referencedDefinitions] }
119
+ : {}),
120
+ ...(args.assets !== undefined && args.assets.length > 0
121
+ ? { assets: [...args.assets] }
190
122
  : {}),
191
123
  };
192
- return args.sidecarRouter.sendAgentDeploy(args.agentAddress, args.config, {
193
- definition: wireDefinition,
194
- sources: args.sources,
124
+ return args.sidecarAllocationRouter.sendAgentDeployToAllocation(args.allocationTarget, args.agentAddress, args.config, workflow, signal, beforeSend);
125
+ }
126
+ function isAssetDeployArgs(args) {
127
+ return args.source.kind === "asset";
128
+ }
129
+ /**
130
+ * The single public composition entrypoint for a code-sourced (npm) deploy. It
131
+ * consumes the approve output and builds the source-ref deploy frame internally,
132
+ * so the security-load-bearing hand-off -- frozen wire hash, inert projection,
133
+ * frozen closure -- is assembled in one place from one cohesive object rather
134
+ * than reassembled by each caller. The frozen approval's hash and projection
135
+ * ride the frame verbatim: nothing here recomputes the hash or re-resolves the
136
+ * closure, so the child re-verify over the inert projection matches the gate's
137
+ * freeze.
138
+ *
139
+ * Credential MATERIAL rides ONE `CredentialDelivery` delivered to the child on
140
+ * the frame, unioned from three rails and deduped by credentialId: tool bindings
141
+ * (grant-scoped, resolved here via `buildCredentialDelivery`); every top-level
142
+ * inference source; and every inline body step's inference source. Both inference
143
+ * rails are resolved HERE from the DB under the tenant-ownership authority
144
+ * (`resolveInferenceMaterials`), so the deploy is self-contained -- no caller
145
+ * pre-supplies material, and a spawned body child finds its secret in the cell.
146
+ * The merge is a post-authz union of already-cleared material (tool material is
147
+ * grant-scoped, inference material is tenant-ownership-scoped), never a shared
148
+ * authz check.
149
+ * Credential GRANT enforcement is a SEPARATE layer: the `credential:{id}` /
150
+ * `use` grant the runtime gate checks is minted per-run by run-grant
151
+ * materialization into `runs/<runId>/grants.json`, not carried on this frame --
152
+ * the deploy-time `config.grants` spawn-time snapshot is suppressed once the
153
+ * sidecar wires per-run grant pushes, so it is not the enforcement transport.
154
+ *
155
+ * A gate outcome that did not approve cannot deploy: an unapproved `approval`
156
+ * fails closed here rather than shipping an unfrozen definition.
157
+ *
158
+ * This does the READ-ONLY preparation ONLY: it runs the guards, resolves
159
+ * credential material, pins the body sources, and builds the asset mounts, then
160
+ * returns the frozen definition id and the assembled send args. It emits NO
161
+ * frame and writes NO row, so it has no side effect to unwind. The ordinary path
162
+ * (`deployCodeSourcedWorkflow`) sequences prepare -> INSERT anchor -> emit so
163
+ * the anchor is visible before the frame spawns the child; `emitSourceRefDeployFrame`
164
+ * composes prepare -> emit for the prepared provisioned path, whose anchor row
165
+ * already exists from prepare time. It returns the frozen definition id so each
166
+ * caller writes the same content-addressed identity the gate persisted.
167
+ */
168
+ async function prepareSourceRefDeploy(args) {
169
+ const { approval, projection, closure } = args.approved;
170
+ if (!approval.ok) {
171
+ throw new Error(`deployCodeSourcedWorkflow: refusing to deploy an unapproved workflow (gate reason: ${approval.reason})`);
172
+ }
173
+ // Fail-closed persisted-definition guard. The anchor row this writes carries
174
+ // an FK to `workflow_definition`, so a phantom `definitionId` would otherwise
175
+ // reach the INSERT and fail with a raw constraint violation. A mis-wired
176
+ // caller -- or a test double that skips the approve step's DB writer -- could
177
+ // pass an approval whose definition was never persisted; verify it exists and
178
+ // fail with a domain error before deploying, rather than deploying and then
179
+ // failing the anchor insert into a deployed-but-unanchored state.
180
+ const persistedDefinition = await args.db.query.workflowDefinition.findFirst({
181
+ where: eq(workflowDefinitionTable.id, approval.definitionId),
182
+ columns: { id: true },
183
+ });
184
+ if (persistedDefinition === undefined) {
185
+ throw new Error(`deployCodeSourcedWorkflow: approval.definitionId ${approval.definitionId} does not reference a persisted workflow_definition row`);
186
+ }
187
+ // Coherence guard, run BEFORE the deploy frame: the anchor row's id and its
188
+ // routing address must name the same run. The deployment mail address is
189
+ // frozen into the approved package bytes at authoring time, so its run id is
190
+ // fixed before this runs and the caller owns `anchorRunId`. A mismatched
191
+ // (anchorRunId, agentAddress) pair would let run-grant materialization find
192
+ // the anchor by `address` while `deriveRunAddress` from `anchorRunId` names a
193
+ // different run -- a silent grant-identity split. Fail closed here, before the
194
+ // frame is sent or any row is persisted, rather than deploying an incoherent
195
+ // pair.
196
+ const derivedAddress = deriveRunAddress({
197
+ runId: args.anchorRunId,
198
+ domain: args.deploymentDomain,
199
+ });
200
+ if (derivedAddress !== args.agentAddress) {
201
+ throw new Error(`deployCodeSourcedWorkflow: anchorRunId ${args.anchorRunId} derives address ${derivedAddress} but agentAddress is ${args.agentAddress}`);
202
+ }
203
+ // Resolve the operator-approved credential bindings into delivered material.
204
+ // Tenant-owned resolution keys off the definition's tenant and walks up the
205
+ // hierarchy; it does not consult creator/invoker (the only locator today is
206
+ // `tenant`). A code-sourced deployment has no single authenticated invoker,
207
+ // so invoker is null; when principal-owned locators arrive, the asset creator
208
+ // must be resolved and passed here. A resolution failure is fail-closed.
209
+ const bindings = projection.credentialBindings ?? [];
210
+ let credentials;
211
+ if (bindings.length > 0) {
212
+ if (args.credentialCipher === undefined) {
213
+ throw new Error("deployCodeSourcedWorkflow: definition carries credential bindings but " +
214
+ "no credentialCipher was supplied; cannot resolve credential material");
215
+ }
216
+ const delivery = await buildCredentialDelivery({
217
+ db: args.db,
218
+ tenantId: args.tenantId,
219
+ bindings,
220
+ creatorPrincipalId: null,
221
+ invokerPrincipalId: null,
222
+ credentialCipher: args.credentialCipher,
223
+ });
224
+ if (!delivery.ok) {
225
+ throw new Error(`deployCodeSourcedWorkflow: credential binding resolution failed: ${delivery.reason.message}`);
226
+ }
227
+ credentials = delivery.delivery;
228
+ }
229
+ // Pin per-step inference sources for the projection's inline trigger bodies
230
+ // -- onTrigger sections and childWorkflow children, enumerated transitively.
231
+ // The hub holds only the frozen inert projection, so it enumerates the inline
232
+ // bodies from the wire form and resolves each body step's source through the
233
+ // same resolver + operator-approval gate the top-level steps use
234
+ // (`pickStepInferenceSource` against `approval.approvedSurface`). Each body's
235
+ // wire hash is recomputed from the inert body verbatim, so a body child's
236
+ // re-verify over the re-evaluated closure clears the same barrier a top-level
237
+ // re-verify does. The pinned sources ride OUTSIDE the hash; their trust comes
238
+ // from being resolved here under the approval gate, which is why the pin stays
239
+ // hub-side and is never caller-supplied.
240
+ //
241
+ // These entries ride the `referencedDefinitions` wire field. Each entry's
242
+ // `definition` is the approved inert body def straight from the frozen,
243
+ // hash-covered projection (id set to the ref); the sidecar reads that id to
244
+ // key the per-body approved hash and to stage the body's `sources.json`, which
245
+ // the body child reads to pin its steps. The body child resolves the body
246
+ // DEFINITION itself in-memory from the re-verified closure and hard-fails
247
+ // rather than reading it off disk, so no body workflow.json is staged (see the
248
+ // staging loop in workflow-host-wiring.ts and the anti-fallback guard in
249
+ // workflow-host run-child.ts).
250
+ const referencedDefinitions = await buildReferencedWorkflowSourcePins({
251
+ projection,
252
+ config: args.config,
253
+ operatorApprovals: approval.approvedSurface,
195
254
  });
255
+ // Assemble the ONE credential delivery. Its `materials` cover three rails, each
256
+ // authorized upstream on its own terms, deduped by credentialId into one cell:
257
+ // - tool bindings, grant-scoped through `buildCredentialDelivery` above;
258
+ // - the inference source pinned to each top-level step that can actually
259
+ // invoke inference, tenant-owned;
260
+ // - the same for each inline body step (onTrigger/childWorkflow bodies
261
+ // pinned above), tenant-owned.
262
+ // Which steps those are is read from the hash-covered projection, never from
263
+ // the pinned map: every step carries a pin because the wire shape demands one,
264
+ // but a step that cannot issue a request has no use for a secret. Delivering
265
+ // one anyway decrypts a tenant credential, seals it to the sidecar, and
266
+ // re-delivers it on every reconnect, on behalf of a step that never makes a
267
+ // call.
268
+ // The inference rails are resolved HERE from the DB under the tenant-ownership
269
+ // authority, so this deploy is self-contained: a direct deploy (a test) that
270
+ // seeds the credentials in the DB -- rather than pre-supplying material -- still
271
+ // fills the cell, and a spawned body finds its secret rather than failing closed
272
+ // at resolve time. Precedence on a shared credentialId is tool material first
273
+ // (grant-scoped), then the inference material (tenant-ownership-scoped): the
274
+ // first material for an id wins. Inference sources carry NO binding descriptor
275
+ // -- they reference their credential by id directly.
276
+ const materials = new Map();
277
+ for (const material of credentials?.materials ?? []) {
278
+ materials.set(material.credentialId, material);
279
+ }
280
+ const inferenceCredentialIds = new Set();
281
+ const addAgentBearingCredentials = (definition, pinned, context) => {
282
+ for (const stepId of collectAgentBearingStepIds({ definition, context })) {
283
+ const stepSources = pinned[stepId];
284
+ if (stepSources === undefined) {
285
+ throw new Error(`${context}step ${stepId} can invoke inference but carries no pinned source`);
286
+ }
287
+ for (const source of stepSources) {
288
+ inferenceCredentialIds.add(source.credentialId);
289
+ }
290
+ }
291
+ };
292
+ addAgentBearingCredentials(projection, args.sources, "deployCodeSourcedWorkflow: ");
293
+ for (const body of referencedDefinitions) {
294
+ addAgentBearingCredentials(body.definition, body.sources, `deployCodeSourcedWorkflow body ${body.definition.id}: `);
295
+ }
296
+ if (inferenceCredentialIds.size > 0) {
297
+ if (args.credentialCipher === undefined) {
298
+ throw new Error("deployCodeSourcedWorkflow: pinned inference sources reference credentials " +
299
+ "but no credentialCipher was supplied to resolve them");
300
+ }
301
+ const inferenceMaterials = await resolveInferenceMaterials(args.db, args.tenantId, inferenceCredentialIds, args.credentialCipher);
302
+ for (const material of inferenceMaterials) {
303
+ if (!materials.has(material.credentialId)) {
304
+ materials.set(material.credentialId, material);
305
+ }
306
+ }
307
+ }
308
+ // A delivery with tool bindings always carries their material, so an empty map
309
+ // means no rail contributed anything -- send no delivery. (Tool bindings never
310
+ // produce a descriptor without a material, so bindings-without-materials cannot
311
+ // occur.)
312
+ const credentialDelivery = materials.size > 0
313
+ ? {
314
+ bindings: credentials?.bindings ?? [],
315
+ materials: [...materials.values()],
316
+ }
317
+ : undefined;
318
+ // An asset-sourced pin's `kind:"asset"` closure entries read from source
319
+ // assets the sidecar cannot fetch itself; deliver them inline on the frame so
320
+ // the sidecar checks them out into its durable per-deployment source store. A
321
+ // registry pin fetches its tarballs over HTTP and delivers none.
322
+ const assets = isAssetDeployArgs(args)
323
+ ? await buildSourceAssetMounts(closure, args.resolveAttachment)
324
+ : [];
325
+ const sendArgs = {
326
+ lineage: "source-ref",
327
+ sidecarAllocationRouter: args.sidecarAllocationRouter,
328
+ allocationTarget: args.allocationTarget,
329
+ agentAddress: args.agentAddress,
330
+ config: args.config,
331
+ sources: args.sources,
332
+ approvedWireHash: approval.approvedWireHash,
333
+ sourceRef: { source: args.source, closure },
334
+ ...(credentialDelivery !== undefined
335
+ ? { credentials: credentialDelivery }
336
+ : {}),
337
+ ...(referencedDefinitions.length > 0 ? { referencedDefinitions } : {}),
338
+ ...(assets.length > 0 ? { assets } : {}),
339
+ };
340
+ return { definitionId: approval.definitionId, sendArgs };
196
341
  }
197
342
  /**
198
- * `WorkflowRepoWriter` backed by the hub's repo substrate. Writes the
199
- * orchestrator-produced workflow tree (`workflow.json`,
200
- * `capability-declarations.json`, `.gitignore`) into a `workflow`-kind
201
- * repo keyed by the workflow definition id, committing on the published
202
- * asset ref. The hub principal is the only writer of the workflow repo,
203
- * matching `workflowAuthorize`'s hub-writes / sidecar-reads split.
343
+ * Prepare then emit the source-ref deploy frame, for the prepared provisioned
344
+ * path whose anchor `workflow_run` row already exists (inserted at prepare
345
+ * time). It emits the frame but does NOT touch the anchor row: the caller
346
+ * (`deployPreparedCodeSourcedWorkflow`) stamps the acked key under the
347
+ * allocation-ownership lock. A tagged `DeployFrameFailure` is converted to the
348
+ * `SessionLaunchError` disposition the allocation reconciler consumes, while
349
+ * untagged preparation errors remain safe same-generation retries. The ordinary
350
+ * path does NOT use this wrapper -- it must interleave the anchor INSERT between
351
+ * prepare and emit, so it drives `prepareSourceRefDeploy` and
352
+ * `sendMultiStepDeployFrame` directly.
204
353
  */
205
- function createHubWorkflowRepoWriter(agentRepoStore) {
354
+ // The non-secret projection of a delivery, persisted on the anchor run so the
355
+ // reconnect resync can re-resolve current materials. Secrets never land here.
356
+ function credentialRefsFromDelivery(delivery) {
206
357
  return {
207
- async writeWorkflowRepo(args) {
208
- const repoId = { kind: "workflow", id: args.workflowRepoId };
209
- const files = {};
210
- for (const [path, contents] of args.files) {
211
- files[path] = contents;
212
- }
213
- await agentRepoStore.repoStore.writeTree(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF, { files, message: "Write workflow deploy tree" });
214
- },
358
+ credentialIds: delivery.materials.map((material) => material.credentialId),
359
+ bindings: delivery.bindings,
360
+ };
361
+ }
362
+ async function emitSourceRefDeployFrame(args, reconciliation) {
363
+ const { signal, leaseId } = reconciliation;
364
+ signal.throwIfAborted();
365
+ const { definitionId, sendArgs } = await prepareSourceRefDeploy(args);
366
+ signal.throwIfAborted();
367
+ const allocationStore = createSidecarAllocationStore(args.db);
368
+ const initialization = {
369
+ allocationId: sendArgs.allocationTarget.allocationId,
370
+ generation: sendArgs.allocationTarget.generation,
371
+ anchorRunId: args.anchorRunId,
372
+ tenantId: args.tenantId,
373
+ leaseId,
374
+ signal,
215
375
  };
376
+ let previousPublicKey;
377
+ try {
378
+ const result = await sendMultiStepDeployFrame(sendArgs, signal, async () => {
379
+ const reserved = await allocationStore.beginInitialization(initialization);
380
+ if (reserved === null) {
381
+ throw new Error("Allocation no longer permits this initialization attempt");
382
+ }
383
+ previousPublicKey = reserved.previousPublicKey;
384
+ });
385
+ return {
386
+ publicKey: result.publicKey,
387
+ definitionId,
388
+ ...(sendArgs.credentials !== undefined
389
+ ? { credentialRefs: credentialRefsFromDelivery(sendArgs.credentials) }
390
+ : {}),
391
+ };
392
+ }
393
+ catch (cause) {
394
+ if (!isDeployFrameFailure(cause))
395
+ throw cause;
396
+ // Only a confirmed reservation gives us the key to restore. An ambiguous
397
+ // reservation response leaves its durable marker for conservative cleanup.
398
+ // A confirmed unsent attempt can roll back even after lease cancellation.
399
+ if (!cause.frameSent && previousPublicKey !== undefined) {
400
+ try {
401
+ const cleared = await allocationStore.clearUnsentInitialization({
402
+ ...initialization,
403
+ previousPublicKey,
404
+ });
405
+ if (cleared)
406
+ args.onUnsentInitializationCleared(previousPublicKey);
407
+ }
408
+ catch (error) {
409
+ logger.warn `Could not clear unsent initialization for ${initialization.allocationId}: ${error instanceof Error ? error.message : String(error)}`;
410
+ }
411
+ }
412
+ throw new SessionLaunchError("start", cause, cause.frameSent);
413
+ }
414
+ }
415
+ /**
416
+ * A direct allocation-bound composition entrypoint for tests and low-level
417
+ * callers: prepare, INSERT the deployment's anchor `workflow_run` row, THEN emit
418
+ * the source-ref frame. The anchor row is the deployment's first-class record
419
+ * that owns its routing address and public key. Run-grant materialization keys
420
+ * off this row (address + live status), so WITHOUT it no per-run grants (tool,
421
+ * capability, OR credential) ever materialize for a source-ref deployment. Born
422
+ * "deployed" (live but pre-trigger) with a null public key: the first trigger's
423
+ * materialization flips it to "running" via `anchorWithPrincipal`'s guarded
424
+ * update, which a row born "running" would skip. Its `anchorRunId` equals its
425
+ * own id, so the anchor references itself. The deployer read grant is deferred
426
+ * to the production route, which carries the authenticated deployer principal.
427
+ *
428
+ * ORDERING IS LOAD-BEARING. The anchor row must be committed and visible to the
429
+ * pack-receipt connection BEFORE the frame reaches the wire: the frame spawns
430
+ * the child, whose first events pack races the ack back, and
431
+ * `receiveWorkflowRunPack` fails closed on a missing live anchor. Emitting first
432
+ * (the previous order) rejected that first pack and never bootstrapped the log.
433
+ * This works because `args.db` is the autocommit handle (`DB["db"]`, which the
434
+ * type forbids from being a transaction) and the INSERT is NOT wrapped in a
435
+ * transaction with the emit -- so the row is durably visible the instant the
436
+ * INSERT statement returns. Do NOT relax `db` to a transaction executor or wrap
437
+ * anchor+emit in one transaction to make them atomic: that reopens the race.
438
+ *
439
+ * On emit failure the anchor row is rolled back or fenced by the `frameSent`
440
+ * evidence from the transport. `leakedAgent: false` (safe to fully roll back) is
441
+ * the STRONG claim and is made only on positive proof the frame never reached
442
+ * the wire (`isDeployFrameFailure && frameSent === false`); every other failure
443
+ * -- a sent-but-unacked frame OR any untagged error -- is treated as
444
+ * possibly-live: the anchor is fenced `deployed` -> `failed` and the error is
445
+ * `leakedAgent: true`.
446
+ *
447
+ * The prepared provisioned path does NOT use this composition: its anchor row
448
+ * already exists from prepare time, so it drives `emitSourceRefDeployFrame` and
449
+ * an UPDATE-under-allocation-lock instead.
450
+ */
451
+ export async function deployCodeSourcedWorkflow(args) {
452
+ const { definitionId, sendArgs } = await prepareSourceRefDeploy(args);
453
+ // INSERT the anchor before the frame. A collision or DB error here spawned
454
+ // nothing (no frame went out), so it is a clean, non-leaking failure.
455
+ try {
456
+ await args.db.insert(workflowRunTable).values({
457
+ id: args.anchorRunId,
458
+ tenantId: args.tenantId,
459
+ anchorRunId: args.anchorRunId,
460
+ definitionId,
461
+ address: args.agentAddress,
462
+ publicKey: null,
463
+ status: "deployed",
464
+ createdAt: new Date(),
465
+ // Persist the non-secret shape of the delivery so the reconnect resync
466
+ // can re-resolve current materials for these ids. Secrets never land here.
467
+ ...(sendArgs.credentials !== undefined
468
+ ? { credentialRefs: credentialRefsFromDelivery(sendArgs.credentials) }
469
+ : {}),
470
+ });
471
+ }
472
+ catch (cause) {
473
+ throw new SessionLaunchError("start", cause, false);
474
+ }
475
+ let publicKey;
476
+ try {
477
+ const result = await sendMultiStepDeployFrame(sendArgs);
478
+ publicKey = result.publicKey;
479
+ }
480
+ catch (cause) {
481
+ if (isDeployFrameFailure(cause) && cause.frameSent === false) {
482
+ // Positive proof the frame never reached the wire: nothing spawned, so
483
+ // fully roll the anchor back. The guard (`deployed`, null key) is a
484
+ // tripwire on the `frameSent: false` contract -- a 0-row delete means the
485
+ // row advanced or vanished, so the contract lied and a child may be live;
486
+ // surface that loudly and refuse to claim it is safe to roll back.
487
+ const deleted = await args.db
488
+ .delete(workflowRunTable)
489
+ .where(and(eq(workflowRunTable.id, args.anchorRunId), eq(workflowRunTable.anchorRunId, args.anchorRunId), eq(workflowRunTable.tenantId, args.tenantId), eq(workflowRunTable.status, "deployed"), isNull(workflowRunTable.publicKey)))
490
+ .returning({ id: workflowRunTable.id });
491
+ if (deleted.length === 0) {
492
+ 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`;
493
+ throw new SessionLaunchError("start", cause, true);
494
+ }
495
+ throw new SessionLaunchError("start", cause, false);
496
+ }
497
+ // A sent-but-unacked frame, OR any untagged/unexpected error: no positive
498
+ // proof of a clean send, so treat the agent as possibly-live. Fence the
499
+ // anchor `deployed` -> `failed` (guarded so a self-flip to "running" by a
500
+ // trigger that already landed is left alone). Do NOT delete: a live child
501
+ // needs the anchor to bootstrap.
502
+ const flipped = await args.db
503
+ .update(workflowRunTable)
504
+ .set({ status: "failed" })
505
+ .where(and(eq(workflowRunTable.id, args.anchorRunId), eq(workflowRunTable.anchorRunId, args.anchorRunId), eq(workflowRunTable.tenantId, args.tenantId), eq(workflowRunTable.status, "deployed"), isNull(workflowRunTable.publicKey)))
506
+ .returning({ id: workflowRunTable.id });
507
+ if (flipped.length === 0) {
508
+ // The anchor already advanced past deployed -- a trigger flipped it to
509
+ // "running", so the deploy actually succeeded and the run is progressing
510
+ // despite the ack failure. Leave it; the leaked-agent disposition still
511
+ // holds because the frame was (or may have been) sent.
512
+ logger.warn `anchor-before-frame: anchor ${args.anchorRunId} already advanced past deployed on an unacked/failed emit; the agent is live and the run is progressing despite the ack failure`;
513
+ }
514
+ else {
515
+ 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`;
516
+ }
517
+ throw new SessionLaunchError("start", cause, true);
518
+ }
519
+ // Emit succeeded: stamp the acked key. No status guard -- the key is a fact
520
+ // regardless of whether the pack-ack race already flipped the row to
521
+ // "running", and skipping the stamp there would strand a live run with a null
522
+ // key. A 0-row update is an anomaly (nothing should remove a deployed anchor
523
+ // on the success path), but the deploy succeeded, so log it rather than
524
+ // failing a live run.
525
+ const stamped = await args.db
526
+ .update(workflowRunTable)
527
+ .set({ publicKey })
528
+ .where(and(eq(workflowRunTable.id, args.anchorRunId), eq(workflowRunTable.anchorRunId, args.anchorRunId), eq(workflowRunTable.tenantId, args.tenantId)))
529
+ .returning({ id: workflowRunTable.id });
530
+ if (stamped.length === 0) {
531
+ logger.error `anchor-before-frame: anchor ${args.anchorRunId} vanished before its public key could be stamped on a successful deploy`;
532
+ }
533
+ return { publicKey };
534
+ }
535
+ /** Resolve deferred sender mail after claiming the previous initializer's lease. */
536
+ export async function recoverSenderDeploy(args) {
537
+ const { allocation, reconciliation } = args;
538
+ reconciliation.signal.throwIfAborted();
539
+ // A proven-unsent clear may still restore the previous key after this claim.
540
+ // Do not fail its mail using the claim's stale marker. Cleanup rechecks under
541
+ // the allocation lock; an advanced fence settles failure, while a rolled-back
542
+ // attempt is resolved by its caller or the next claim's completed key.
543
+ if (allocation.initializationLeaseId !== undefined)
544
+ return;
545
+ // Claiming the lease prevents the previous attempt from publishing. Its
546
+ // marker and key now distinguish a committed initialization from failure,
547
+ // even before the worker reconnects or the old response arrives.
548
+ const anchor = await args.db.query.workflowRun.findFirst({
549
+ where: eq(workflowRunTable.id, allocation.anchorRunId),
550
+ columns: { publicKey: true },
551
+ });
552
+ reconciliation.signal.throwIfAborted();
553
+ args.sidecarRouter.noteSenderDeploySettled({ allocationId: allocation.id, generation: allocation.generation }, anchor !== undefined && anchor.publicKey !== null
554
+ ? { recorded: anchor.publicKey }
555
+ : { failed: "Previous deployment initialization did not complete" });
216
556
  }
217
557
  export function createSessionService(deps) {
218
- const { sidecarRouter, agentRepoStore, assetService, db, toolPackageRegistries, } = deps;
558
+ const { sidecarRouter, sidecarAllocationRouter, agentRepoStore, assetService, db, toolPackageRegistries, } = deps;
219
559
  if (assetService !== undefined && db === undefined) {
220
560
  throw new Error("createSessionService: db is required when assetService is set");
221
561
  }
222
562
  if (toolPackageRegistries !== undefined && db === undefined) {
223
563
  throw new Error("createSessionService: db is required when toolPackageRegistries is set");
224
564
  }
565
+ function requireAllocationRouter() {
566
+ if (sidecarAllocationRouter === undefined) {
567
+ throw new Error("Provisioned deployment routing is not configured");
568
+ }
569
+ return sidecarAllocationRouter;
570
+ }
225
571
  /**
226
- * Stage a deploy on the sidecar: resolve assets and tool packages, write
227
- * the deploy tree, provision the agent, and deliver the deploy + asset
228
- * packs (Phases 0-2b). Phase 1's provision has two shapes:
229
- * - `workflowFrame` set: the single-step head hand-off fires the
230
- * deployment `agent.deploy` frame that spawns the workflow-process
231
- * child. Returns the supervisor public key.
232
- * - `stageOnly` set: a multi-step per-step stage binds a transient route
233
- * for the step address, fires a no-spawn provision frame (init repo +
234
- * record hub key), and unbinds the route once the packs land. No
235
- * child.
236
- * A call with neither is rejected -- the legacy warm-harness path
237
- * is gone.
572
+ * Stage one per-step deploy on the sidecar: resolve assets and tool
573
+ * packages, write the deploy tree, provision the step, and deliver the
574
+ * deploy + asset packs (Phases 0-2b). Phase 1 binds a transient route for
575
+ * the step address, fires a no-spawn provision frame (init repo + record
576
+ * hub key), and unbinds the route once the packs land -- no warm harness and
577
+ * no child. The deployment-level workflow frame, sent once after every step
578
+ * is staged, spawns the child. A call without `stageOnly` is rejected -- the
579
+ * legacy warm-harness and single-step-head paths are gone.
238
580
  */
239
581
  async function executeLaunchPhases(params) {
240
- const { agentAddress, agentId, instanceId, config, deployContent } = params;
582
+ const { agentAddress, agentId, runId, config, deployContent } = params;
241
583
  const toolPackagePins = params.toolPackagePins ?? [];
242
584
  const stageOnly = params.stageOnly ?? false;
243
- if (params.workflowFrame !== undefined && stageOnly) {
244
- throw new Error("executeLaunchPhases: workflowFrame and stageOnly are mutually exclusive");
245
- }
246
- const workflowFrame = params.workflowFrame;
247
- // Phase 0: Resolve attached assets first so the skill index is in
248
- // hand before the deploy tree is written. The `<available_skills>`
249
- // stanza describing every attached skill must land in
250
- // `deploy/prompt.md`, so it has to be composed before
251
- // `writeDeployTree` produces the on-disk tree.
252
- let attachments = [];
253
- let availableSkills = [];
254
- if (assetService !== undefined) {
255
- try {
256
- attachments = await resolveAttachments(assetService, agentId);
257
- availableSkills = collectAvailableSkills(attachments);
258
- }
259
- catch (err) {
260
- throw new SessionLaunchError("write", err, false);
261
- }
262
- }
263
- const stanza = buildAvailableSkillsStanza(availableSkills);
264
- let effectiveDeployContent = stanza.length === 0
265
- ? deployContent
266
- : {
267
- ...deployContent,
268
- systemPrompt: `${deployContent.systemPrompt}\n\n${stanza}\n`,
269
- };
585
+ let effectiveDeployContent = deployContent;
270
586
  // Phase 0a-bis: Resolve the agent's tool-package pins into a full
271
587
  // closure manifest. Empty pins skip the resolver entirely. A
272
588
  // ManifestInvalidError (e.g. unsatisfied peer dependency) is a
@@ -308,25 +624,6 @@ export function createSessionService(deps) {
308
624
  }
309
625
  const assetMounts = new Map();
310
626
  try {
311
- // Refuse to mix a direct package-registry attachment with a
312
- // resolver-driven pin against the same asset id. The resolver
313
- // path emits an `assetMounts` entry pointing at the asset's
314
- // DEFAULT_ASSET_REF tip, but a direct attachment may carry any
315
- // ref the operator chose at attach time. The downstream dedup
316
- // in `dedupAttachmentsByAssetId` lets the direct attachment win
317
- // — its bytes would materialize at the operator's chosen ref
318
- // while `assetMounts` still names the resolver's ref, leaving
319
- // the loader to resolve manifest entries against tarballs that
320
- // do not exist at the materialized mount. Surface the conflict
321
- // at launch as a manifest-shaped violation rather than letting
322
- // the integrity mismatch surface deep inside the sidecar apply.
323
- const directPackageRegistryAttachments = attachments.filter((att) => att.assetKind === "package-registry");
324
- for (const assetId of collectDistinctAssetIds(manifest)) {
325
- const conflict = directPackageRegistryAttachments.find((att) => att.repoId.id === assetId);
326
- if (conflict !== undefined) {
327
- throw new ManifestInvalidError(`package-registry asset ${conflict.assetKind}/${conflict.assetName} (${assetId}) is both directly attached to the agent and selected by the tool-package resolver; attach OR pin via tenancy, not both`);
328
- }
329
- }
330
627
  for (const assetId of collectDistinctAssetIds(manifest)) {
331
628
  const asset = assetIndex.get(assetId);
332
629
  if (asset === undefined) {
@@ -339,7 +636,7 @@ export function createSessionService(deps) {
339
636
  }
340
637
  const mountPath = `package-registries/${asset.name}/`;
341
638
  assetMounts.set(assetId, mountPath);
342
- manifestAssetAttachments.push(await resolveDirectAssetAttachment({
639
+ manifestAssetAttachments.push(await resolveAssetAttachment({
343
640
  asset,
344
641
  mountPath,
345
642
  }));
@@ -372,192 +669,76 @@ export function createSessionService(deps) {
372
669
  // route is held only for the pack window and dropped in the `finally`.
373
670
  if (stageOnly) {
374
671
  try {
375
- sidecarRouter.bindStepRoute(agentAddress);
672
+ await requireAllocationRouter().bindAllocatedStepRoute(params.allocationTarget, agentAddress);
376
673
  }
377
674
  catch (err) {
378
675
  throw new SessionLaunchError("provision", err, false);
379
676
  }
380
677
  }
381
678
  try {
382
- // Phase 1: Provision on sidecar. A single-step workflow deploy sends
383
- // the deployment `agent.deploy` frame carrying the workflow definition
384
- // + source pins: the sidecar's deploy router initializes the head repo
385
- // on receipt (so the Phase 2 pack has a repo to apply into) and spawns
386
- // the workflow-process child. A stage-only per-step deploy sends a
679
+ // Phase 1: Provision on sidecar. A stage-only per-step deploy sends a
387
680
  // no-spawn provision frame: the sidecar inits the step's agent-state
388
681
  // repo and records the hub key, but spawns nothing. Firing the frame
389
682
  // before the Phase 2 pack is the ordering barrier -- the repo must
390
- // exist before the pack applies. A workflow frame's ack surfaces the
391
- // supervisor public key to the caller.
392
- let deployAckPublicKey;
683
+ // exist before the pack applies.
393
684
  try {
394
- if (workflowFrame !== undefined) {
395
- const ack = await sendMultiStepDeployFrame({
396
- sidecarRouter,
397
- agentAddress,
398
- config,
399
- definition: workflowFrame.definition,
400
- sources: workflowFrame.sources,
401
- });
402
- deployAckPublicKey = ack.publicKey;
403
- }
404
- else if (stageOnly) {
405
- await sidecarRouter.sendProvisionStep(agentAddress, config);
685
+ if (stageOnly) {
686
+ await requireAllocationRouter().sendProvisionStepToAllocation(params.allocationTarget, agentAddress, config);
406
687
  }
407
688
  else {
408
- // Every caller supplies `workflowFrame` (single-step head) or
409
- // `stageOnly` (multi-step per-step). A deploy with neither has no
410
- // provisioning shape -- the legacy warm-harness path is gone -- so
411
- // fail loud rather than ship a deploy pack the sidecar never
412
- // provisioned a repo for.
413
- throw new Error("executeLaunchPhases: a deploy requires either workflowFrame or stageOnly");
689
+ // Every caller supplies `stageOnly`. A deploy without it has no
690
+ // provisioning shape -- the legacy warm-harness and single-step-head
691
+ // paths are gone -- so fail loud rather than ship a deploy pack the
692
+ // sidecar never provisioned a repo for.
693
+ throw new Error("executeLaunchPhases: a deploy requires stageOnly");
414
694
  }
415
695
  }
416
696
  catch (err) {
417
697
  throw new SessionLaunchError("provision", err, false);
418
698
  }
419
- // Phase 2: Pack delivery. On failure, the warm/workflow paths tear the
420
- // sidecar deployment down; a stage-only step has no supervisor to
421
- // undeploy, so it only drops its transient route (in the `finally`).
422
- // The step's inited agent-state repo is left on the sidecar: the
423
- // orchestrator aborts the whole deploy before the deployment frame is
424
- // sent, so there is nothing to undeploy, and a redeploy of the same
425
- // deployment overwrites the orphaned repo. This is an acceptable minor
426
- // leak on the exceptional staging-failure path, not a live-path cost.
699
+ // Phase 2: Pack delivery. A stage-only step has no supervisor to
700
+ // undeploy, so on failure it only drops its transient route (in the
701
+ // `finally`). The step's inited agent-state repo is left on the sidecar:
702
+ // the deploy aborts before the deployment frame is sent, so there is
703
+ // nothing to undeploy, and a redeploy of the same deployment overwrites
704
+ // the orphaned repo. This is an acceptable minor leak on the exceptional
705
+ // staging-failure path, not a live-path cost.
427
706
  try {
428
- await sidecarRouter.sendPack(agentAddress, pack, ref, commitSha);
707
+ await requireAllocationRouter().sendPackToAllocation(params.allocationTarget, agentAddress, pack, ref, commitSha);
429
708
  }
430
709
  catch (err) {
431
- if (!stageOnly)
432
- await attemptCleanup(agentAddress, "pack", err);
433
- throw new SessionLaunchError("pack", err, false);
710
+ throw new SessionLaunchError("pack", err, !stageOnly);
434
711
  }
435
712
  // Phase 2b: Asset-pack fan-out. For each attached asset, build a
436
- // pack, insert the manifest row, then send the pack. The manifest
437
- // insert MUST happen before the pack send: if the sidecar acks
713
+ // pack, reserve the manifest row, then send the pack. The manifest
714
+ // reservation MUST happen before the pack send: if the sidecar acks
438
715
  // but the row is missing, the session has materialization without
439
- // a recorded manifest. If the row insert fails, the pack send
440
- // must not happen.
441
- //
442
- // The fan-out covers two sources: the agent's direct attachments
443
- // (skills, today) and the package-registry assets the tool-package
444
- // resolver picked from. The latter live behind tenant inheritance
445
- // rather than a per-agent attachment row, so the session service
446
- // synthesizes the attachment view in `manifestAssetAttachments`.
716
+ // a recorded manifest. An allocated replacement may reuse the exact row
717
+ // its predecessor recorded. If reservation fails, no pack is sent.
447
718
  //
448
- // Both sources can name the same `package-registry` asset — a
449
- // direct attachment and a resolver pin would each compute
450
- // `mountPath = "package-registries/<asset.name>/"` and collide on
451
- // the `(instanceId, mountPath)` PK in `session_asset`. Dedup by
452
- // asset id BEFORE the inserts and let the direct attachment win:
453
- // it is an explicit operator action and carries an `agentAssetId`
454
- // the audit query joins against. The resolver-derived row would
455
- // produce the same materialized contents, so dropping it is
456
- // semantically lossless.
457
- const fanOut = dedupAttachmentsByAssetId({
458
- direct: attachments,
459
- resolved: manifestAssetAttachments,
460
- });
719
+ // The fan-out materializes the package-registry assets the
720
+ // tool-package resolver picked. They live behind tenant
721
+ // inheritance rather than a per-agent attachment row, so the
722
+ // session service synthesizes the attachment view in
723
+ // `manifestAssetAttachments`.
724
+ const fanOut = manifestAssetAttachments;
461
725
  if (assetService !== undefined && fanOut.length > 0) {
462
- // Track every successfully committed attachment so a later
463
- // fan-out failure can roll back the earlier rows in lockstep
464
- // with the sidecar undeploy. Without this, fan-out[0] succeeds,
465
- // fan-out[1] fails, attemptCleanup tears down the sidecar — but
466
- // fan-out[0]'s session_asset row survives and a future
467
- // materialization query reads a manifest the sidecar no longer
468
- // honors.
469
- const committed = [];
470
726
  for (const att of fanOut) {
471
727
  try {
472
- await sendAttachmentPack(instanceId, agentAddress, att);
473
- committed.push(att);
728
+ await sendAttachmentPack(runId, agentAddress, att, params.allocationTarget);
474
729
  }
475
730
  catch (err) {
476
- await rollbackCommittedAttachments(instanceId, committed);
477
- if (!stageOnly)
478
- await attemptCleanup(agentAddress, "pack", err);
479
- throw new SessionLaunchError("pack", err, false);
731
+ throw new SessionLaunchError("pack", err, !stageOnly);
480
732
  }
481
733
  }
482
734
  }
483
- return deployAckPublicKey === undefined
484
- ? undefined
485
- : { publicKey: deployAckPublicKey };
486
735
  }
487
736
  finally {
488
737
  if (stageOnly) {
489
- sidecarRouter.unbindStepRoute(agentAddress);
738
+ requireAllocationRouter().unbindAllocatedStepRoute(params.allocationTarget, agentAddress);
490
739
  }
491
740
  }
492
741
  }
493
- /**
494
- * Deploy a one-step workflow once at the head. Reuses the full
495
- * launch-phase machinery (deploy-tree write, pack, asset fan-out) via
496
- * `executeLaunchPhases`, swapping the Phase 1 provision frame for the
497
- * workflow frame. The workflow frame makes the sidecar initialize the
498
- * head repo and spawn the workflow-process child; the follow-up pack
499
- * lands the head's deploy tree. Returns the supervisor's principal
500
- * public key from the frame's ack. A workflow-frame launch always
501
- * yields a deploy-ack key; its absence is a wiring bug, not a
502
- * tolerable case.
503
- */
504
- const deploySingleStepAtHead = async (deployParams) => {
505
- const result = await executeLaunchPhases({
506
- agentAddress: deployParams.agentAddress,
507
- agentId: deployParams.agentId,
508
- instanceId: deployParams.instanceId,
509
- config: deployParams.config,
510
- deployContent: bridgeOrchestratorDeployContent(deployParams.deployContent),
511
- workflowFrame: {
512
- definition: deployParams.definition,
513
- sources: deployParams.sources,
514
- },
515
- ...(deployParams.toolPackagePins !== undefined
516
- ? { toolPackagePins: deployParams.toolPackagePins }
517
- : {}),
518
- });
519
- if (result === undefined) {
520
- throw new Error("single-step deploy at head: executeLaunchPhases returned no deploy-ack public key for a workflow-frame deploy");
521
- }
522
- return result;
523
- };
524
- /**
525
- * Build the workflow-deploy orchestrator (with its launch-session and
526
- * multi-step callbacks) and run one deploy. Shared by `launchSession`
527
- * and `deployWorkflowDefinition`, which differ only in the workflow
528
- * repo writer, the director registry, and the deploy args.
529
- */
530
- async function runWorkflowDeploy(args) {
531
- // The per-step launcher: stage each step's deploy tree WITHOUT a warm
532
- // harness (the supervised child runs the step), with the orchestrator's
533
- // structural `DeployContent` narrowed back to the hub-sessions shape
534
- // first.
535
- const launchSessionCallback = (orchestratorParams) => stageWorkflowStep({
536
- agentAddress: orchestratorParams.agentAddress,
537
- agentId: orchestratorParams.agentId,
538
- instanceId: orchestratorParams.instanceId,
539
- config: orchestratorParams.config,
540
- deployContent: bridgeOrchestratorDeployContent(orchestratorParams.deployContent),
541
- ...(orchestratorParams.toolPackagePins !== undefined
542
- ? { toolPackagePins: orchestratorParams.toolPackagePins }
543
- : {}),
544
- });
545
- const sendMultiStepDeployCallback = (deployParams) => sendMultiStepDeployFrame({
546
- sidecarRouter,
547
- agentAddress: deployParams.agentAddress,
548
- config: deployParams.config,
549
- definition: deployParams.definition,
550
- sources: deployParams.sources,
551
- });
552
- const orchestrator = createWorkflowDeployOrchestrator({
553
- directorRegistry: args.directorRegistry,
554
- workflowRepo: args.workflowRepo,
555
- launchSession: launchSessionCallback,
556
- sendMultiStepDeploy: sendMultiStepDeployCallback,
557
- deploySingleStepAtHead,
558
- });
559
- return orchestrator.deployWorkflow(args.deployArgs);
560
- }
561
742
  /**
562
743
  * Stage one step of a multi-step workflow deploy: bind a transient route
563
744
  * for the step address, fire a no-spawn provision frame (the sidecar inits
@@ -572,246 +753,363 @@ export function createSessionService(deps) {
572
753
  await executeLaunchPhases({
573
754
  agentAddress: params.agentAddress,
574
755
  agentId: params.agentId,
575
- instanceId: params.instanceId,
756
+ runId: params.runId,
576
757
  config: params.config,
577
758
  deployContent: params.deployContent,
578
759
  stageOnly: true,
579
760
  ...(params.toolPackagePins !== undefined
580
761
  ? { toolPackagePins: params.toolPackagePins }
581
762
  : {}),
763
+ allocationTarget: params.allocationTarget,
582
764
  });
583
765
  }
766
+ // Resolve the npm registry config a code-sourced install resolves external
767
+ // deps against, by the registry name. A code-sourced deploy needs the
768
+ // registry map configured; a hub that mounts the deploy surface without it is
769
+ // mis-wired, so this fails loud rather than defaulting a registry URL.
770
+ function requireRegistryConfig(registryName) {
771
+ if (toolPackageRegistries === undefined) {
772
+ throw new Error("deployWorkflowFromSource: the session service has no toolPackageRegistries configured; a code-sourced deploy cannot resolve its dependency closure");
773
+ }
774
+ const config = toolPackageRegistries.httpRegistries.get(registryName);
775
+ if (config === undefined) {
776
+ throw new Error(`deployWorkflowFromSource: no HTTP registry named ${JSON.stringify(registryName)} is configured`);
777
+ }
778
+ return config;
779
+ }
780
+ // Build the git-pack resolver a source/tarball asset arm delivers inline. The
781
+ // pin names one backing asset, so the resolver binds that asset's repo (its
782
+ // kind fixed by the arm) and its default ref; a request for any OTHER asset id
783
+ // is a closure that reaches beyond its single backing asset and fails loud
784
+ // rather than silently packing the wrong repo.
785
+ function bindAssetAttachmentResolver(assetId, repoKind) {
786
+ return async (requestedAssetId) => {
787
+ if (requestedAssetId !== assetId) {
788
+ throw new Error(`deployWorkflowFromSource: closure references asset ${requestedAssetId}, but only the pinned source asset ${assetId} is deliverable`);
789
+ }
790
+ const repoId = { kind: repoKind, id: assetId };
791
+ const commitSha = await agentRepoStore.repoStore.resolveRef(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
792
+ if (commitSha === null) {
793
+ throw new Error(`deployWorkflowFromSource: source asset ${assetId} has no commit on ${DEFAULT_ASSET_REF}`);
794
+ }
795
+ const { pack, ref } = await agentRepoStore.repoStore.createPack(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
796
+ return { pack, ref, commitSha };
797
+ };
798
+ }
799
+ // Assemble the install args for the concrete source arm. Mirrors the
800
+ // `isAssetSourceInstallArgs`/`isAssetTarballInstallArgs` guards the probe gate
801
+ // narrows on: an asset-`source` arm binds committed reads at the pinned commit
802
+ // plus the npm registry for external deps; an asset-`tarball` arm binds the
803
+ // asset's blob reads and a pin; a `registry` arm carries only its registry
804
+ // config and a pin. A `pin` missing where the arm requires it fails closed.
805
+ async function buildInstallArgs(params, resolveAttachment) {
806
+ if (db === undefined) {
807
+ throw new Error("deployWorkflowFromSource requires a db handle to freeze the approval");
808
+ }
809
+ const dbHandle = db;
810
+ const allocationTarget = params.allocationTarget;
811
+ const common = {
812
+ entry: params.entry,
813
+ assetId: params.definitionAssetId,
814
+ approvals: { kind: "approve-probed" },
815
+ router: {
816
+ sendProbe: (args) => requireAllocationRouter().sendProbeToAllocation(allocationTarget, args),
817
+ },
818
+ ...(params.onProbeResult !== undefined
819
+ ? { onProbeResult: params.onProbeResult }
820
+ : {}),
821
+ db: dbHandle,
822
+ };
823
+ const source = params.source;
824
+ if (source.kind === "asset") {
825
+ if (resolveAttachment === null) {
826
+ throw new Error("deployWorkflowFromSource: an asset-sourced deploy requires an attachment resolver");
827
+ }
828
+ if (source.package.format === "source") {
829
+ const committed = await agentRepoStore.repoStore.openCommittedReadsAtCommit(HUB_PRINCIPAL, { kind: "workflow", id: source.assetId }, source.package.commitSha);
830
+ if (committed === null) {
831
+ throw new Error(`deployWorkflowFromSource: source asset ${source.assetId} has no commit ${source.package.commitSha}`);
832
+ }
833
+ const registryName = requireDefaultRegistryName();
834
+ return {
835
+ ...common,
836
+ source,
837
+ reads: committedReadsToSourceTree(committed),
838
+ registryName,
839
+ registryConfig: requireRegistryConfig(registryName),
840
+ resolveAttachment,
841
+ };
842
+ }
843
+ if (params.pin === undefined) {
844
+ throw new Error("deployWorkflowFromSource: an asset-tarball deploy requires a name@range pin");
845
+ }
846
+ if (assetService === undefined) {
847
+ throw new Error("deployWorkflowFromSource: an asset-tarball deploy requires an asset service to read the package blobs");
848
+ }
849
+ const tarballAssetId = source.assetId;
850
+ const tarballService = assetService;
851
+ return {
852
+ ...common,
853
+ source,
854
+ pin: params.pin,
855
+ readBlob: (path) => tarballService.readAssetBlob({ assetId: tarballAssetId, path }),
856
+ listBlobs: (dir) => tarballService.listAssetBlobs({ assetId: tarballAssetId, dir }),
857
+ resolveAttachment,
858
+ };
859
+ }
860
+ if (params.pin === undefined) {
861
+ throw new Error("deployWorkflowFromSource: a registry deploy requires a name@range pin");
862
+ }
863
+ return {
864
+ ...common,
865
+ source,
866
+ pin: params.pin,
867
+ registryConfig: requireRegistryConfig(source.registry),
868
+ };
869
+ }
870
+ function requireDefaultRegistryName() {
871
+ if (toolPackageRegistries === undefined) {
872
+ throw new Error("deployWorkflowFromSource: the session service has no toolPackageRegistries configured; a code-sourced deploy cannot resolve its dependency closure");
873
+ }
874
+ return toolPackageRegistries.defaultRegistry;
875
+ }
876
+ // Bind the pack resolver an asset arm delivers inline. An asset arm delivers
877
+ // its backing repo (its kind fixed by `package.format`); a registry arm
878
+ // fetches its tarballs over HTTP and delivers no asset, so it binds nothing.
879
+ // Both the install (probe) and the deploy rebind the SAME resolver from the
880
+ // source, so a prepared deploy reconstructs it from the frozen `source`.
881
+ function bindSourceAttachmentResolver(source) {
882
+ return source.kind === "asset"
883
+ ? bindAssetAttachmentResolver(source.assetId, source.package.format === "source" ? "workflow" : "package-registry")
884
+ : null;
885
+ }
886
+ // Install + probe + gate + freeze a code-sourced definition, returning the
887
+ // frozen bundle and the (asset-only) attachment resolver. The gate outcome is
888
+ // NOT asserted here: `deployWorkflowFromSource` and `installAndApproveWorkflowSource`
889
+ // each surface a non-approval as their own domain error. This is the common
890
+ // freeze used by direct tests and provisioned prepare runs.
891
+ async function prepareCodeSourcedApproval(params) {
892
+ const resolveAttachment = bindSourceAttachmentResolver(params.source);
893
+ const installArgs = await buildInstallArgs(params, resolveAttachment);
894
+ const approved = await installAndApproveWorkflowDefinition(installArgs);
895
+ return { approved, resolveAttachment };
896
+ }
897
+ // Freeze a code-sourced approval WITHOUT deploying it. The provisioned
898
+ // prepare path persists the returned bundle and deploys it to an allocation
899
+ // later. A non-approval fails closed as an invalid
900
+ // definition.
901
+ async function installAndApproveWorkflowSource(params) {
902
+ const { approved } = await prepareCodeSourcedApproval(params);
903
+ if (!approved.approval.ok) {
904
+ throw new WorkflowDefinitionInvalidError(approved.projection.id, `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`);
905
+ }
906
+ return approved;
907
+ }
584
908
  /**
585
- * Deploy a single-agent instance through the single-step-at-head path: wrap
586
- * the harness as a one-step workflow (the same wrap `launchSession` uses) and
587
- * route it through `deploySingleStepAtHead` with the instance's REAL identity
588
- * -- so the head address IS the instance address and the deploy runs as a
589
- * supervised workflow-process child.
590
- *
591
- * Unlike the orchestrator's `runSingleStepAtHead`, this calls
592
- * `deploySingleStepAtHead` directly with the route's real `agentId`
593
- * (`row.id`), NOT a `deriveDeploymentAgentId(deploymentId)` -- the child
594
- * resolves skills, deploy tree, and tool-package pins by `agentId`, so
595
- * collapsing it to the deployment id would strip the instance's attachments.
596
- * It writes no `workflow_deployment` row (a plain instance has no workflow
597
- * asset). Returns the head's agent-key ack.
909
+ * Update a prepared anchor run's `publicKey` under the allocation-ownership
910
+ * lock. The anchor row was inserted at prepare time; this stamps the
911
+ * supervisor key returned by the deploy ack, but only while the allocation
912
+ * still names this exact accepted generation and unexpired reconciliation
913
+ * lease for this anchor. Lost ownership or cancellation fails closed as a
914
+ * leaked-agent `SessionLaunchError` -- the deploy already reached the sidecar,
915
+ * so the caller must treat the sidecar agent as possibly live. Used by the
916
+ * `deployPreparedCodeSourcedWorkflow` prepared path.
598
917
  */
599
- async function deployInstanceAtHead(params) {
600
- const { agentAddress, agentId, instanceId, config, deployContent } = params;
601
- const singleStepAgent = wrapHarnessAsSingleStepWorkflow({
602
- config,
603
- deployContent,
918
+ async function updateAnchorPublicKeyUnderAllocationLock(args) {
919
+ if (db === undefined) {
920
+ throw new Error("updateAnchorPublicKeyUnderAllocationLock requires a db handle");
921
+ }
922
+ const dbHandle = db;
923
+ try {
924
+ const updated = await createSidecarAllocationStore(dbHandle).completeInitialization({
925
+ allocationId: args.allocationTarget.allocationId,
926
+ generation: args.allocationTarget.generation,
927
+ anchorRunId: args.anchorRunId,
928
+ tenantId: args.tenantId,
929
+ leaseId: args.reconciliation.leaseId,
930
+ signal: args.reconciliation.signal,
931
+ publicKey: args.publicKey,
932
+ ...(args.credentialRefs !== undefined
933
+ ? { credentialRefs: args.credentialRefs }
934
+ : {}),
935
+ });
936
+ if (!updated) {
937
+ throw new Error(`Prepared anchor run ${args.anchorRunId} lost allocation ownership before initialization completed`);
938
+ }
939
+ }
940
+ catch (error) {
941
+ throw new SessionLaunchError("start", error, true);
942
+ }
943
+ }
944
+ /**
945
+ * Deploy a previously-frozen code-sourced approval bundle to a dedicated
946
+ * allocation. The anchor `workflow_run` row already exists from prepare time
947
+ * (with its `definitionId` set), so this UPDATES it under the
948
+ * allocation-ownership lock
949
+ * rather than inserting. No re-probe: the frozen projection/hash/closure ride
950
+ * verbatim from `params.approved`, and the per-step inference sources are
951
+ * re-pinned from the re-resolved chain (deliberately NOT frozen, since a
952
+ * resolved source carries a credential secret).
953
+ */
954
+ async function deployPreparedCodeSourcedWorkflow(params) {
955
+ const { signal } = params.reconciliation;
956
+ signal.throwIfAborted();
957
+ if (db === undefined) {
958
+ throw new Error("deployPreparedCodeSourcedWorkflow requires a db handle to update the prepared anchor run");
959
+ }
960
+ const dbHandle = db;
961
+ const approval = params.approved.approval;
962
+ if (!approval.ok) {
963
+ throw new Error("deployPreparedCodeSourcedWorkflow: refusing to deploy an unapproved workflow bundle");
964
+ }
965
+ const allocationRouter = requireAllocationRouter();
966
+ const source = params.source;
967
+ const resolveAttachment = bindSourceAttachmentResolver(source);
968
+ // Re-pin every top-level step's inference source from the re-resolved chain
969
+ // under the frozen approval -- the same pin the source-ref deploy computes.
970
+ const sources = buildInertProjectionStepSources({
971
+ projection: params.approved.projection,
972
+ config: params.config,
973
+ operatorApprovals: approval.approvedSurface,
604
974
  });
605
- const workflow = defineWorkflow({
606
- id: `wf_${agentId}`,
607
- agent: singleStepAgent,
608
- trigger: { type: "mail", to: agentAddress },
975
+ // Restore the Hub-authoritative run ref onto the exact allocation generation
976
+ // before its address is routed.
977
+ await restoreWorkflowRunToAllocation({
978
+ agentRepoStore,
979
+ allocationRouter,
980
+ allocationTarget: params.allocationTarget,
981
+ agentAddress: params.agentAddress,
982
+ signal,
609
983
  });
610
- // The sole step's id, read off the built definition.
611
- const stepId = workflow.stepOrder[0];
612
- if (stepId === undefined) {
613
- throw new Error(`instance deploy for ${agentAddress}: the wrapped single-step workflow has an empty stepOrder`);
614
- }
615
- // Pin the step's inference sources to the instance's FULL ordered source
616
- // chain so the workflow-process child's reactor fails over across it at
617
- // runtime. The route already resolved and authorized `config.sources`
618
- // against the tenant catalog, so it is pinned directly rather than re-run
619
- // through the orchestrator's operator-approval gate.
620
- //
621
- // Fail loud on the invariant the reactor depends on: the reactor resolves
622
- // its initial source by id (`defaultSource`) and fails over FORWARD-ONLY
623
- // with no wrap, so the default must be element 0 or part of the chain is
624
- // unreachable -- and if the default were last, failover would silently
625
- // no-op. The route guarantees `config.sources[0].id === config.defaultSource`
626
- // (head = active); assert it here so a future reordering fails loudly
627
- // rather than silently disabling failover.
628
- if (config.sources.length === 0) {
629
- throw new Error(`instance deploy for ${agentAddress}: config.sources is empty; at least the default source is required`);
630
- }
631
- if (config.sources[0]?.id !== config.defaultSource) {
632
- throw new Error(`instance deploy for ${agentAddress}: config.sources[0] (${JSON.stringify(config.sources[0]?.id)}) must be the default source ${JSON.stringify(config.defaultSource)}; the reactor fails over forward from the default and would otherwise skip the head`);
633
- }
634
- return deploySingleStepAtHead({
635
- agentAddress,
636
- agentId,
637
- instanceId,
638
- config,
639
- deployContent,
640
- definition: workflow,
641
- sources: { [stepId]: config.sources },
642
- hubPublicKey: hexEncode(agentRepoStore.getSigningPublicKey()),
643
- ...(params.toolPackagePins !== undefined
644
- ? { toolPackagePins: params.toolPackagePins }
984
+ signal.throwIfAborted();
985
+ let restoredPublicKey;
986
+ const commonEmit = {
987
+ onUnsentInitializationCleared(publicKey) {
988
+ restoredPublicKey = publicKey;
989
+ },
990
+ approved: params.approved,
991
+ sidecarAllocationRouter: allocationRouter,
992
+ allocationTarget: params.allocationTarget,
993
+ agentAddress: params.agentAddress,
994
+ config: params.config,
995
+ sources,
996
+ db: dbHandle,
997
+ tenantId: params.tenantId,
998
+ anchorRunId: params.anchorRunId,
999
+ deploymentDomain: params.deploymentDomain,
1000
+ ...(params.credentialCipher !== undefined
1001
+ ? { credentialCipher: params.credentialCipher }
645
1002
  : {}),
646
- });
647
- }
648
- async function deployWorkflowDefinition(params) {
649
- const { tenantId, deploymentId, deploymentDomain, definition, definitionAssetId, config, deployContent, } = params;
650
- // The deploy is initiated by an authorized tenant operator against a
651
- // workflow asset they authored; approve exactly the grant surface the
652
- // definition declares. The same director registry feeds both this
653
- // approval-set derivation and the orchestrator's gate so the walk the
654
- // route approves and the walk the orchestrator enforces are identical.
655
- const directorRegistry = createDefaultDirectorRegistry();
656
- const walk = walkCapabilities(definition, directorRegistry);
657
- const operatorApprovals = new Set([...walk.perStep.values()].flatMap((declarations) => [
658
- ...declarations.grants,
659
- ]));
660
- const result = await runWorkflowDeploy({
661
- workflowRepo: createHubWorkflowRepoWriter(agentRepoStore),
662
- directorRegistry,
663
- deployArgs: {
664
- workflow: definition,
665
- deploymentId,
666
- deploymentDomain,
667
- config,
668
- deployContent,
669
- operatorApprovals,
670
- hubPublicKey: hexEncode(agentRepoStore.getSigningPublicKey()),
671
- ...(params.toolPackagePins !== undefined
672
- ? { toolPackagePins: params.toolPackagePins }
1003
+ };
1004
+ // Branch on the source discriminant so the emit args match the asset/registry
1005
+ // arms: an asset arm carries the rebuilt attachment resolver (asserted
1006
+ // non-null to satisfy the union), a registry arm carries none.
1007
+ let result;
1008
+ const senderAttempt = {
1009
+ ...params.allocationTarget,
1010
+ leaseId: params.reconciliation.leaseId,
1011
+ };
1012
+ signal.throwIfAborted();
1013
+ sidecarRouter.noteSenderDeployStarted(params.agentAddress, senderAttempt);
1014
+ try {
1015
+ // Bracket the allocated pre-ack window: mark the sender's key-record as
1016
+ // mid-flight before the deploy emit so a run that sends mail before its
1017
+ // anchor key is committed parks rather than delivering keyless. The settle
1018
+ // follows durable completion; cancellation leaves the outcome to recovery.
1019
+ if (source.kind === "asset") {
1020
+ if (resolveAttachment === null) {
1021
+ throw new Error("deployPreparedCodeSourcedWorkflow: asset source deploy is missing its attachment resolver");
1022
+ }
1023
+ result = await emitSourceRefDeployFrame({ ...commonEmit, source, resolveAttachment }, params.reconciliation);
1024
+ }
1025
+ else {
1026
+ result = await emitSourceRefDeployFrame({ ...commonEmit, source }, params.reconciliation);
1027
+ }
1028
+ await updateAnchorPublicKeyUnderAllocationLock({
1029
+ tenantId: params.tenantId,
1030
+ anchorRunId: params.anchorRunId,
1031
+ allocationTarget: params.allocationTarget,
1032
+ reconciliation: params.reconciliation,
1033
+ publicKey: result.publicKey,
1034
+ ...(result.credentialRefs !== undefined
1035
+ ? { credentialRefs: result.credentialRefs }
673
1036
  : {}),
674
- },
675
- });
676
- if (db === undefined) {
677
- throw new Error("deployWorkflowDefinition requires a db handle to record the workflow_deployment projection row");
678
- }
679
- const now = new Date();
680
- await db.transaction(async (tx) => {
681
- await tx.insert(workflowDeploymentTable).values({
682
- id: deploymentId,
683
- tenantId,
684
- definitionAssetId,
685
- address: deriveDeploymentAddress({ deploymentId, deploymentDomain }),
686
- // publicKey is left null here; the sidecar's deploy-ack persists the
687
- // deployment's minted key once the child has spawned.
688
- status: "deployed",
689
- createdAt: now,
690
1037
  });
691
- // Seed a read grant on the deployment's workflow-run resource for the
692
- // deploying principal so they can observe run events out of the box,
693
- // mirroring the per-instance agent-state read grant the agent deploy
694
- // path seeds for the creator. Without this a non-owner deployer would
695
- // deploy a workflow they cannot read the runs of.
696
- await tx.insert(grantTable).values({
697
- id: generateId("grant"),
698
- tenantId,
699
- principalId: config.principalId,
700
- resource: `workflow-run:${deploymentId}`,
701
- action: "read",
702
- effect: "allow",
703
- origin: "creator",
704
- createdAt: now,
705
- updatedAt: now,
1038
+ // The anchor's public key is now durable. Wake any mail the run parked
1039
+ // while pre-ack so it delivers with the sender key co-delivered, closing
1040
+ // the window where a run sends before its key is recorded. The write above
1041
+ // happens-before this settle, so a re-drive resolves the recorded key.
1042
+ // `params.agentAddress` is the run's deploy address, byte-identical to the
1043
+ // sender address its mail was sent under (asserted against the anchor at
1044
+ // deploy time), so a settle matches the parked entries.
1045
+ sidecarRouter.noteSenderDeploySettled(senderAttempt, {
1046
+ recorded: result.publicKey,
706
1047
  });
707
- });
708
- return {
709
- deploymentId,
710
- deploymentAddress: deriveDeploymentAddress({
711
- deploymentId,
712
- deploymentDomain,
713
- }),
714
- publicKey: result.publicKey,
715
- };
716
- }
717
- async function rollbackCommittedAttachments(instanceId, committed) {
718
- if (db === undefined)
719
- return;
720
- if (committed.length === 0)
721
- return;
722
- // Per-row try/catch so a single rollback failure does not stop the
723
- // sweep — every committed row needs to come off the books before
724
- // the caller emits the original sendPack error.
725
- for (const att of committed) {
726
- try {
727
- await db
728
- .delete(sessionAssetTable)
729
- .where(and(eq(sessionAssetTable.instanceId, instanceId), eq(sessionAssetTable.mountPath, att.mountPath)));
1048
+ return {
1049
+ anchorRunId: params.anchorRunId,
1050
+ deploymentAddress: params.agentAddress,
1051
+ publicKey: result.publicKey,
1052
+ };
1053
+ }
1054
+ catch (error) {
1055
+ if (restoredPublicKey !== undefined) {
1056
+ sidecarRouter.noteSenderDeploySettled(senderAttempt, restoredPublicKey === null
1057
+ ? { failed: error instanceof Error ? error.message : String(error) }
1058
+ : { recorded: restoredPublicKey });
730
1059
  }
731
- catch (err) {
732
- logger.warn `session_asset rollback failed for earlier-committed instance=${instanceId} mountPath=${att.mountPath}: ${err instanceof Error ? err.message : String(err)}`;
1060
+ // A sent deploy or cancelled publication may have committed despite its
1061
+ // lost response, as may an unsent rollback. Without a confirmed rollback,
1062
+ // recovery owns transport failures too. Only an uncancelled preparation
1063
+ // failure can settle definitively here.
1064
+ else if (!signal.aborted && !(error instanceof SessionLaunchError)) {
1065
+ sidecarRouter.noteSenderDeploySettled(senderAttempt, {
1066
+ failed: error instanceof Error ? error.message : String(error),
1067
+ });
733
1068
  }
1069
+ throw error;
734
1070
  }
735
1071
  }
736
- async function sendAttachmentPack(instanceId, agentAddress, attachment) {
1072
+ async function sendAttachmentPack(runId, agentAddress, attachment, allocationTarget) {
737
1073
  if (db === undefined) {
738
1074
  // Guarded at construction; reassert defensively so the
739
1075
  // narrowing is visible to readers and a future refactor cannot
740
1076
  // accidentally invoke this without a db.
741
1077
  throw new Error("sendAttachmentPack invoked without a db handle");
742
1078
  }
743
- const { agentAssetId, source, mountPath, sourceCommitSha, repoId, pack, ref, } = attachment;
1079
+ const { mountPath, sourceCommitSha, repoId, pack, ref } = attachment;
744
1080
  const assetPackSha = await createPackSha(pack);
745
- // Insert manifest row before the pack send so we never end up in
746
- // the materialized-without-manifest state. Both direct and
747
- // resolver-derived materializations write a row; the `source`
748
- // column records which path produced it, and `agentAssetId` is
749
- // null for resolver-derived rows.
750
- await db.insert(sessionAssetTable).values({
751
- instanceId,
752
- agentAssetId,
1081
+ const record = {
1082
+ runId,
753
1083
  mountPath,
754
1084
  assetPackSha,
755
1085
  sourceCommitSha,
756
- source,
757
- materializedAt: new Date(),
758
- });
759
- try {
760
- await sidecarRouter.sendPack(agentAddress, pack, ref, sourceCommitSha, {
761
- mountPath,
762
- repoId,
1086
+ };
1087
+ // Reserve durable recovery intent before the pack send. A replacement
1088
+ // generation may reuse the exact row its predecessor recorded.
1089
+ const inserted = await db
1090
+ .insert(sessionAssetTable)
1091
+ .values({ ...record, materializedAt: new Date() })
1092
+ .onConflictDoNothing({
1093
+ target: [sessionAssetTable.runId, sessionAssetTable.mountPath],
1094
+ })
1095
+ .returning({ runId: sessionAssetTable.runId });
1096
+ if (inserted.length === 0) {
1097
+ const existing = await db.query.sessionAsset.findFirst({
1098
+ where: and(eq(sessionAssetTable.runId, runId), eq(sessionAssetTable.mountPath, mountPath)),
1099
+ columns: {
1100
+ assetPackSha: true,
1101
+ sourceCommitSha: true,
1102
+ },
763
1103
  });
764
- }
765
- catch (err) {
766
- // Roll back the manifest row when the send fails so the manifest
767
- // and the materialized state on the sidecar can never disagree.
768
- // The forensic value of a manifest-without-materialization row is
769
- // negligible because no agent will read against it. Wrap the
770
- // rollback in its own try/catch so a rollback failure (DB gone,
771
- // connection killed mid-launch) is logged rather than masking the
772
- // primary sendPack error — the caller needs to see the original
773
- // failure, not the secondary one.
774
- try {
775
- await db
776
- .delete(sessionAssetTable)
777
- .where(and(eq(sessionAssetTable.instanceId, instanceId), eq(sessionAssetTable.mountPath, mountPath)));
1104
+ if (existing === undefined) {
1105
+ throw new Error(`session_asset ${runId}/${mountPath} disappeared after its insert conflicted`);
778
1106
  }
779
- catch (rollbackErr) {
780
- const msg = rollbackErr instanceof Error
781
- ? rollbackErr.message
782
- : String(rollbackErr);
783
- logger.warn `session_asset rollback failed for instance=${instanceId} mountPath=${mountPath}: ${msg}`;
1107
+ if (existing.assetPackSha !== assetPackSha ||
1108
+ existing.sourceCommitSha !== sourceCommitSha) {
1109
+ throw new Error(`session_asset ${runId}/${mountPath} conflicts with the allocated workflow's restored asset`);
784
1110
  }
785
- throw err;
786
- }
787
- }
788
- async function resolveAttachments(service, agentId) {
789
- const rows = await service.listAgentAssets(agentId);
790
- const resolved = [];
791
- for (const row of rows) {
792
- resolved.push(await resolveAttachment(row));
793
1111
  }
794
- return resolved;
795
- }
796
- async function resolveAttachment(row) {
797
- const mountPath = resolveMountPath(row);
798
- const repoId = { kind: row.asset.kind, id: row.asset.id };
799
- const sourceCommitSha = await agentRepoStore.repoStore.resolveRef(HUB_PRINCIPAL, repoId, row.ref);
800
- if (sourceCommitSha === null) {
801
- throw new Error(`attachment_ref_unresolved: ${row.asset.kind}/${row.asset.id} has no commit on ${row.ref}`);
802
- }
803
- const { pack, ref: returnedRef } = await agentRepoStore.repoStore.createPack(HUB_PRINCIPAL, repoId, row.ref);
804
- return {
805
- agentAssetId: row.id,
806
- source: "direct",
807
- assetName: row.asset.name,
808
- assetKind: row.asset.kind,
809
- mountPath,
810
- sourceCommitSha,
811
- repoId,
812
- pack,
813
- ref: returnedRef,
814
- };
1112
+ await requireAllocationRouter().sendPackToAllocation(allocationTarget, agentAddress, pack, ref, sourceCommitSha, { mountPath, repoId });
815
1113
  }
816
1114
  /**
817
1115
  * Build a per-agent `ClosureResolver` from the tenant's visible
@@ -895,13 +1193,11 @@ export function createSessionService(deps) {
895
1193
  return { manifest, assetIndex };
896
1194
  }
897
1195
  /**
898
- * Build a `ResolvedAttachment` for an asset the resolver picked
899
- * from but which has no per-agent attachment row. The pack is read
900
- * from the asset's main ref (the same ref the resolver consumed
901
- * tarballs from), and `agentAssetId` is `null` so the fan-out path
902
- * knows to skip the `session_asset` insert.
1196
+ * Build a `ResolvedAttachment` for an asset the tool-package resolver
1197
+ * picked from. The pack is read from the asset's main ref (the same
1198
+ * ref the resolver consumed tarballs from).
903
1199
  */
904
- async function resolveDirectAssetAttachment(args) {
1200
+ async function resolveAssetAttachment(args) {
905
1201
  const repoId = { kind: args.asset.kind, id: args.asset.id };
906
1202
  const sourceCommitSha = await agentRepoStore.repoStore.resolveRef(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
907
1203
  if (sourceCommitSha === null) {
@@ -909,10 +1205,6 @@ export function createSessionService(deps) {
909
1205
  }
910
1206
  const { pack, ref: returnedRef } = await agentRepoStore.repoStore.createPack(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
911
1207
  return {
912
- agentAssetId: null,
913
- source: "resolved",
914
- assetName: args.asset.name,
915
- assetKind: args.asset.kind,
916
1208
  mountPath: args.mountPath,
917
1209
  sourceCommitSha,
918
1210
  repoId,
@@ -920,78 +1212,13 @@ export function createSessionService(deps) {
920
1212
  ref: returnedRef,
921
1213
  };
922
1214
  }
923
- function collectAvailableSkills(resolved) {
924
- const entries = [];
925
- for (const att of resolved) {
926
- if (att.assetKind !== "skill")
927
- continue;
928
- const index = getSkillIndex(att.repoId.id, att.ref);
929
- for (const entry of index) {
930
- entries.push({
931
- qualifiedName: `${att.assetName}/${entry.name}`,
932
- description: entry.description,
933
- workspacePath: `workspace/${att.mountPath}${entry.workspaceSubpath}`,
934
- });
935
- }
936
- }
937
- return entries;
938
- }
939
- async function attemptCleanup(agentAddress, failedPhase, originalErr) {
940
- try {
941
- await sidecarRouter.sendAgentUndeploy(agentAddress, failedPhase);
942
- }
943
- catch (cleanupErr) {
944
- logger.error `Failed to clean up agent ${agentAddress} after ${failedPhase} failure: ${String(cleanupErr)}`;
945
- // Preserve the original error as cause so the root cause is not
946
- // lost when the cleanup also fails.
947
- throw new SessionLaunchError(failedPhase, originalErr, true);
948
- }
949
- }
950
- async function sendUserMessage(params) {
951
- const { agentAddress, from, messageId, date, content, attachments, inReplyTo, references, sessionId, tenantId, cryptoProvider, } = params;
952
- const headers = {
953
- from,
954
- to: [agentAddress],
955
- cc: undefined,
956
- date,
957
- messageId,
958
- subject: undefined,
959
- inReplyTo,
960
- references,
961
- mimeVersion: "1.0",
962
- interchangeType: "conversation.message",
963
- interchangeCorrelationId: undefined,
964
- interchangeTenantId: tenantId,
965
- interchangeAgentId: undefined,
966
- interchangeSessionId: sessionId,
967
- interchangeOfferingId: undefined,
968
- interchangeSchemaVersion: undefined,
969
- traceparent: undefined,
970
- tracestate: undefined,
971
- };
972
- const signedContent = assembleSignedContent({
973
- kind: "conversation",
974
- text: content,
975
- ...(attachments !== undefined ? { attachments } : {}),
976
- });
977
- const signature = await createDetachedSignatureFromProvider(signedContent, cryptoProvider);
978
- const rawMessage = assembleMessage(headers, signedContent, signature);
979
- const base64 = base64Encode(rawMessage);
980
- const delivered = sidecarRouter.routeMail(agentAddress, base64);
981
- if (!delivered) {
982
- throw new Error(`Failed to deliver message to ${agentAddress}: agent is unreachable`);
983
- }
984
- return rawMessage;
985
- }
986
1215
  async function endSession(agentAddress, reason) {
987
1216
  await sidecarRouter.sendAgentUndeploy(agentAddress, reason);
988
1217
  }
989
1218
  return {
990
1219
  stageWorkflowStep,
991
- deployInstanceAtHead,
992
- deploySingleStepAtHead,
993
- deployWorkflowDefinition,
994
- sendUserMessage,
1220
+ installAndApproveWorkflowSource,
1221
+ deployPreparedCodeSourcedWorkflow,
995
1222
  endSession,
996
1223
  };
997
1224
  }