@intx/hub-sessions 0.1.2 → 0.3.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 (113) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +82 -1
  3. package/dist/agent-repo.d.ts +93 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +189 -0
  7. package/dist/asset-service.d.ts +104 -0
  8. package/dist/asset-service.js +267 -0
  9. package/dist/committed-source-tree.d.ts +10 -0
  10. package/dist/committed-source-tree.js +35 -0
  11. package/dist/credential-push.d.ts +33 -0
  12. package/dist/credential-push.js +109 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +365 -0
  17. package/dist/hub-session-lookups.d.ts +135 -0
  18. package/dist/hub-session-lookups.js +663 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +87 -0
  21. package/dist/index.d.ts +27 -0
  22. package/dist/index.js +24 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1829 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +56 -0
  30. package/dist/repo-store/subscribe-kind.js +144 -0
  31. package/dist/repo-store/types.d.ts +571 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +416 -0
  34. package/dist/session-service.js +1191 -0
  35. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  36. package/dist/sidecar-allocation/contracts.js +21 -0
  37. package/dist/sidecar-allocation/index.d.ts +4 -0
  38. package/dist/sidecar-allocation/index.js +3 -0
  39. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  40. package/dist/sidecar-allocation/placement-policy.js +21 -0
  41. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  42. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  43. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  44. package/dist/sidecar-allocation/reconciler.js +431 -0
  45. package/dist/skill-kind.d.ts +41 -0
  46. package/dist/skill-kind.js +292 -0
  47. package/dist/substrate.d.ts +8 -0
  48. package/dist/substrate.js +21 -0
  49. package/dist/workflow-allocation-service.d.ts +58 -0
  50. package/dist/workflow-allocation-service.js +239 -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 +40 -0
  56. package/dist/workflow-dispatch-service.js +146 -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 +37 -0
  60. package/dist/workflow-kind.js +310 -0
  61. package/dist/workflow-probe-gate.d.ts +214 -0
  62. package/dist/workflow-probe-gate.js +207 -0
  63. package/dist/workflow-run-event-log.d.ts +21 -0
  64. package/dist/workflow-run-event-log.js +51 -0
  65. package/dist/workflow-run-kind.d.ts +440 -0
  66. package/dist/workflow-run-kind.js +2916 -0
  67. package/dist/workflow-run-reader.d.ts +47 -0
  68. package/dist/workflow-run-reader.js +153 -0
  69. package/dist/workflow-run-restore.d.ts +15 -0
  70. package/dist/workflow-run-restore.js +26 -0
  71. package/dist/workflow-source-closure.d.ts +35 -0
  72. package/dist/workflow-source-closure.js +342 -0
  73. package/dist/ws/index.d.ts +3 -0
  74. package/dist/ws/index.js +3 -0
  75. package/dist/ws/sidecar-events.d.ts +222 -0
  76. package/dist/ws/sidecar-events.js +72 -0
  77. package/dist/ws/sidecar-handler.d.ts +305 -0
  78. package/dist/ws/sidecar-handler.js +2537 -0
  79. package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
  80. package/dist/ws/sidecar-token-authenticator.js +81 -0
  81. package/package.json +35 -12
  82. package/src/agent-repo.test.ts +0 -310
  83. package/src/agent-repo.ts +0 -165
  84. package/src/agent-state-kind.test.ts +0 -247
  85. package/src/agent-state-kind.ts +0 -204
  86. package/src/asset-service.test.ts +0 -540
  87. package/src/asset-service.ts +0 -378
  88. package/src/available-skills-stanza.test.ts +0 -87
  89. package/src/available-skills-stanza.ts +0 -47
  90. package/src/credential-push.ts +0 -65
  91. package/src/event-collector-registry.test.ts +0 -73
  92. package/src/event-collector-registry.ts +0 -171
  93. package/src/event-collector.test.ts +0 -1387
  94. package/src/event-collector.ts +0 -424
  95. package/src/hub-session-lookups.ts +0 -206
  96. package/src/hub-session-orchestrator.test.ts +0 -510
  97. package/src/hub-session-orchestrator.ts +0 -213
  98. package/src/index.ts +0 -78
  99. package/src/repo-store/index.ts +0 -15
  100. package/src/repo-store/store.test.ts +0 -1169
  101. package/src/repo-store/store.ts +0 -428
  102. package/src/repo-store/types.ts +0 -253
  103. package/src/session-service.test.ts +0 -895
  104. package/src/session-service.ts +0 -464
  105. package/src/skill-kind.test.ts +0 -599
  106. package/src/skill-kind.ts +0 -350
  107. package/src/ws/index.ts +0 -18
  108. package/src/ws/sidecar-events.test.ts +0 -96
  109. package/src/ws/sidecar-events.ts +0 -231
  110. package/src/ws/sidecar-handler.test.ts +0 -2217
  111. package/src/ws/sidecar-handler.ts +0 -1574
  112. package/tsconfig.json +0 -4
  113. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,1191 @@
1
+ import { type } from "arktype";
2
+ import { and, eq } from "drizzle-orm";
3
+ import { getLogger } from "@intx/log";
4
+ import { assembleMessage, assembleSignedContent, createDetachedSignatureFromProvider, } from "@intx/mime";
5
+ import { buildCredentialDelivery, listAssetsForTenant, } from "@intx/db";
6
+ import { grant as grantTable, sidecarAllocation as sidecarAllocationTable, workflowDefinition as workflowDefinitionTable, workflowRun as workflowRunTable, } from "@intx/db/schema";
7
+ import { base64Encode, hexEncode } from "@intx/types";
8
+ import { generateId } from "@intx/hub-common";
9
+ import { sessionAsset as sessionAssetTable } from "@intx/db/schema";
10
+ import { AssetRegistrySource, HttpRegistrySource, ManifestInvalidError, createClosureResolver, } from "@intx/tool-packaging";
11
+ import { ToolPackageManifest, } from "@intx/types/tool-packages";
12
+ import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash";
13
+ import { buildInertProjectionStepSources, deriveRunAddress, enumerateInertOnTriggerBodies, pickStepInferenceSource, WorkflowDefinitionInvalidError, } from "@intx/workflow-deploy";
14
+ import { DEFAULT_ASSET_REF, } from "./asset-service.js";
15
+ import { buildSourceAssetMounts, } from "./workflow-closure-resolution.js";
16
+ import { restoreWorkflowRunToAllocation } from "./workflow-run-restore.js";
17
+ import { committedReadsToSourceTree } from "./committed-source-tree.js";
18
+ import { installAndApproveWorkflowDefinition, } from "./workflow-probe-gate.js";
19
+ const logger = getLogger(["interchange", "hub", "session-service"]);
20
+ export class SessionLaunchError extends Error {
21
+ /** Which phase failed: "write", "provision", "pack", or "start". */
22
+ phase;
23
+ /** True if the sidecar has a provisioned agent that could not be cleaned up. */
24
+ leakedAgent;
25
+ constructor(phase, cause, leakedAgent) {
26
+ const msg = cause instanceof Error ? cause.message : "Session launch failed";
27
+ super(msg, { cause });
28
+ this.name = "SessionLaunchError";
29
+ this.phase = phase;
30
+ this.leakedAgent = leakedAgent;
31
+ }
32
+ }
33
+ // Hub-side principal for reading asset repos. Assets are signed by the
34
+ // hub itself, and the launch fan-out reads them on the hub to assemble
35
+ // packs for delivery to a sidecar -- so the hub principal is correct.
36
+ const HUB_PRINCIPAL = { kind: "hub" };
37
+ async function createPackSha(pack) {
38
+ const digest = await crypto.subtle.digest("SHA-256",
39
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ArrayBuffer-backed at the call site; Web Crypto's BufferSource type rejects Uint8Array<ArrayBufferLike> under TS 5.9 (microsoft/TypeScript#62240)
40
+ pack);
41
+ return hexEncode(new Uint8Array(digest));
42
+ }
43
+ /**
44
+ * Walk a resolved tool-package manifest and return every distinct
45
+ * `assetId` referenced by a `kind: "asset"` entry. Order is the
46
+ * resolver's BFS order so the fan-out below is deterministic for
47
+ * tests; a `Set` would be wrong here because tests assert specific
48
+ * orderings.
49
+ */
50
+ function collectDistinctAssetIds(manifest) {
51
+ const seen = new Set();
52
+ const out = [];
53
+ for (const entry of manifest.entries) {
54
+ if (entry.source.kind !== "asset")
55
+ continue;
56
+ if (seen.has(entry.source.assetId))
57
+ continue;
58
+ seen.add(entry.source.assetId);
59
+ out.push(entry.source.assetId);
60
+ }
61
+ return out;
62
+ }
63
+ /**
64
+ * Translate the orchestrator's structural `DeployContent` (which types
65
+ * `toolPackageManifest` as `unknown`) back into the hub-sessions
66
+ * `DeployContent` shape. The orchestrator round-trips whatever the
67
+ * caller supplied, but the surface type widens `toolPackageManifest` to
68
+ * `unknown`; the validator narrows it back to the canonical shape
69
+ * `agentRepoStore.writeDeployTree` consumes.
70
+ *
71
+ * Exported so a test fixture that forwards orchestrator-shaped deploy
72
+ * content into `launchSession` narrows it the same validated way the
73
+ * production multi-step callback does, rather than casting `unknown`.
74
+ */
75
+ export function bridgeOrchestratorDeployContent(content) {
76
+ const bridged = { systemPrompt: content.systemPrompt };
77
+ if (content.toolPackageManifest !== undefined) {
78
+ const validated = ToolPackageManifest(content.toolPackageManifest);
79
+ if (validated instanceof type.errors) {
80
+ throw new Error(`orchestrator deploy content carries an invalid toolPackageManifest: ${validated.summary}`);
81
+ }
82
+ bridged.toolPackageManifest = validated;
83
+ }
84
+ if (content.assetMounts !== undefined) {
85
+ bridged.assetMounts = content.assetMounts;
86
+ }
87
+ return bridged;
88
+ }
89
+ /**
90
+ * Emit the source-ref deploy frame onto `SidecarRouter.sendAgentDeploy`. The
91
+ * router accepts an optional `workflow` projection on the deploy frame; the
92
+ * sidecar's deploy router uses field presence to route the frame to the
93
+ * workflow deploy path, and returns the supervisor public key on the
94
+ * `agent.deploy.ack`.
95
+ *
96
+ * The gate/freeze layer already hashed the inert projection, so the frozen hash
97
+ * and the inert projection ride the frame verbatim -- this never recomputes the
98
+ * content hash. Recomputing over a live wire lineage would diverge from the
99
+ * inert projection the child re-verifies against.
100
+ *
101
+ * Exported so the co-located caller-site test can assert that the constructed
102
+ * closure reaches the wire surface via `sendAgentDeploy` with a `workflow`
103
+ * field structurally matching the `AgentDeployFrame.workflow` schema.
104
+ */
105
+ export async function sendMultiStepDeployFrame(args) {
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] }
122
+ : {}),
123
+ };
124
+ // A prepared exclusive deploy routes its frame to the dedicated allocation; a
125
+ // shared deploy sends it on the shared router. The frozen projection/hash/pin
126
+ // ride verbatim in both cases -- only the transport differs.
127
+ if (args.allocationTarget !== undefined) {
128
+ if (args.sidecarAllocationRouter === undefined) {
129
+ throw new Error("Exclusive deployment routing is not configured");
130
+ }
131
+ return args.sidecarAllocationRouter.sendAgentDeployToAllocation(args.allocationTarget, args.agentAddress, args.config, workflow);
132
+ }
133
+ return args.sidecarRouter.sendAgentDeploy(args.agentAddress, args.config, workflow);
134
+ }
135
+ function isAssetDeployArgs(args) {
136
+ return args.source.kind === "asset";
137
+ }
138
+ /**
139
+ * The single public composition entrypoint for a code-sourced (npm) deploy. It
140
+ * consumes the approve output and builds the source-ref deploy frame internally,
141
+ * so the security-load-bearing hand-off -- frozen wire hash, inert projection,
142
+ * frozen closure -- is assembled in one place from one cohesive object rather
143
+ * than reassembled by each caller. The frozen approval's hash and projection
144
+ * ride the frame verbatim: nothing here recomputes the hash or re-resolves the
145
+ * closure, so the child re-verify over the inert projection matches the gate's
146
+ * freeze.
147
+ *
148
+ * Credential MATERIAL for the definition's tenant-owned bindings is resolved
149
+ * here (`buildCredentialDelivery`) and delivered to the child on the frame.
150
+ * Credential GRANT enforcement is a SEPARATE layer: the `credential:{id}` /
151
+ * `use` grant the runtime gate checks is minted per-run by run-grant
152
+ * materialization into `runs/<runId>/grants.json`, not carried on this frame --
153
+ * the deploy-time `config.grants` spawn-time snapshot is suppressed once the
154
+ * sidecar wires per-run grant pushes, so it is not the enforcement transport.
155
+ *
156
+ * A gate outcome that did not approve cannot deploy: an unapproved `approval`
157
+ * fails closed here rather than shipping an unfrozen definition.
158
+ *
159
+ * This emits the source-ref deploy frame ONLY -- it does NOT write the anchor
160
+ * `workflow_run` row. `deployCodeSourcedWorkflow` wraps it with the shared-path
161
+ * INSERT; the prepared exclusive path wraps it with an UPDATE-under-lock of the
162
+ * anchor row that already exists from prepare time. It returns the frozen
163
+ * definition id so each wrapper writes the same content-addressed identity the
164
+ * gate persisted.
165
+ */
166
+ async function emitSourceRefDeployFrame(args) {
167
+ const { approval, projection, closure } = args.approved;
168
+ if (!approval.ok) {
169
+ throw new Error(`deployCodeSourcedWorkflow: refusing to deploy an unapproved workflow (gate reason: ${approval.reason})`);
170
+ }
171
+ // Fail-closed persisted-definition guard. The anchor row this writes carries
172
+ // an FK to `workflow_definition`, so a phantom `definitionId` would otherwise
173
+ // reach the INSERT and fail with a raw constraint violation. A mis-wired
174
+ // caller -- or a test double that skips the approve step's DB writer -- could
175
+ // pass an approval whose definition was never persisted; verify it exists and
176
+ // fail with a domain error before deploying, rather than deploying and then
177
+ // failing the anchor insert into a deployed-but-unanchored state.
178
+ const persistedDefinition = await args.db.query.workflowDefinition.findFirst({
179
+ where: eq(workflowDefinitionTable.id, approval.definitionId),
180
+ columns: { id: true },
181
+ });
182
+ if (persistedDefinition === undefined) {
183
+ throw new Error(`deployCodeSourcedWorkflow: approval.definitionId ${approval.definitionId} does not reference a persisted workflow_definition row`);
184
+ }
185
+ // Coherence guard, run BEFORE the deploy frame: the anchor row's id and its
186
+ // routing address must name the same run. The deployment mail address is
187
+ // frozen into the approved package bytes at authoring time, so its run id is
188
+ // fixed before this runs and the caller owns `anchorRunId`. A mismatched
189
+ // (anchorRunId, agentAddress) pair would let run-grant materialization find
190
+ // the anchor by `address` while `deriveRunAddress` from `anchorRunId` names a
191
+ // different run -- a silent grant-identity split. Fail closed here, before the
192
+ // frame is sent or any row is persisted, rather than deploying an incoherent
193
+ // pair.
194
+ const derivedAddress = deriveRunAddress({
195
+ runId: args.anchorRunId,
196
+ domain: args.deploymentDomain,
197
+ });
198
+ if (derivedAddress !== args.agentAddress) {
199
+ throw new Error(`deployCodeSourcedWorkflow: anchorRunId ${args.anchorRunId} derives address ${derivedAddress} but agentAddress is ${args.agentAddress}`);
200
+ }
201
+ // Resolve the operator-approved credential bindings into delivered material.
202
+ // Tenant-owned resolution keys off the definition's tenant and walks up the
203
+ // hierarchy; it does not consult creator/invoker (the only locator today is
204
+ // `tenant`). A code-sourced deployment has no single authenticated invoker,
205
+ // so invoker is null; when principal-owned locators arrive, the asset creator
206
+ // must be resolved and passed here. A resolution failure is fail-closed.
207
+ const bindings = projection.credentialBindings ?? [];
208
+ let credentials;
209
+ if (bindings.length > 0) {
210
+ if (args.credentialCipher === undefined) {
211
+ throw new Error("deployCodeSourcedWorkflow: definition carries credential bindings but " +
212
+ "no credentialCipher was supplied; cannot resolve credential material");
213
+ }
214
+ const delivery = await buildCredentialDelivery({
215
+ db: args.db,
216
+ tenantId: args.tenantId,
217
+ bindings,
218
+ creatorPrincipalId: null,
219
+ invokerPrincipalId: null,
220
+ credentialCipher: args.credentialCipher,
221
+ });
222
+ if (!delivery.ok) {
223
+ throw new Error(`deployCodeSourcedWorkflow: credential binding resolution failed: ${delivery.reason.message}`);
224
+ }
225
+ credentials = delivery.delivery;
226
+ }
227
+ // Pin per-step inference sources for the projection's inline onTrigger bodies.
228
+ // The hub holds only the frozen inert projection, so it enumerates the inline
229
+ // bodies from the wire form and resolves each body step's source through the
230
+ // same resolver + operator-approval gate the top-level steps use
231
+ // (`pickStepInferenceSource` against `approval.approvedGrants`). Each body's
232
+ // wire hash is recomputed from the inert body verbatim, so a body child's
233
+ // re-verify over the re-evaluated closure clears the same barrier a top-level
234
+ // re-verify does. The pinned sources ride OUTSIDE the hash; their trust comes
235
+ // from being resolved here under the approval gate, which is why the pin stays
236
+ // hub-side and is never caller-supplied.
237
+ //
238
+ // These entries ride the `referencedDefinitions` wire field. Each entry's
239
+ // `definition` is the approved inert body def straight from the frozen,
240
+ // hash-covered projection (id set to the ref); the sidecar reads that id to
241
+ // key the per-body approved hash and to stage the body's `sources.json`, which
242
+ // the body child reads to pin its steps. The body child resolves the body
243
+ // DEFINITION itself in-memory from the re-verified closure and hard-fails
244
+ // rather than reading it off disk, so no body workflow.json is staged (see the
245
+ // staging loop in workflow-host-wiring.ts and the anti-fallback guard in
246
+ // workflow-host run-child.ts).
247
+ const referencedDefinitions = await Promise.all(enumerateInertOnTriggerBodies(projection).map(async (body) => {
248
+ const sources = {};
249
+ for (const bodyStepId of body.definition.stepOrder) {
250
+ // Agent-bearing body steps run inference and need a source pinned
251
+ // through the approval gate. A non-agent body step (sleep,
252
+ // awaitSignal) declares no preference and runs no inference, so it
253
+ // advertises no `inference.source` grant the gate could approve --
254
+ // but the deploy frame's coverage contract still requires a source
255
+ // entry for EVERY body step. Pin the deploy's default source as an
256
+ // inert placeholder for such a step: the body child resolves a
257
+ // step's source only when that step invokes inference, so this entry
258
+ // is never read, which is why it needs no operator approval.
259
+ const preferred = body.preferredByStep[bodyStepId] ?? null;
260
+ if (preferred === null) {
261
+ const placeholder = args.config.sources.find((s) => s.id === args.config.defaultSource);
262
+ if (placeholder === undefined) {
263
+ throw new WorkflowDefinitionInvalidError(body.ref, `non-agent body step ${bodyStepId} needs an inert placeholder source, but the deploy config carries no defaultSource entry to pin`);
264
+ }
265
+ sources[bodyStepId] = [placeholder];
266
+ continue;
267
+ }
268
+ sources[bodyStepId] = [
269
+ pickStepInferenceSource({
270
+ preferred,
271
+ stepId: bodyStepId,
272
+ workflowId: body.ref,
273
+ config: args.config,
274
+ operatorApprovals: approval.approvedGrants,
275
+ }),
276
+ ];
277
+ }
278
+ return {
279
+ definition: body.definition,
280
+ sources,
281
+ approvedWireHash: await computeWireDefinitionHash(body.definition),
282
+ };
283
+ }));
284
+ // An asset-sourced pin's `kind:"asset"` closure entries read from source
285
+ // assets the sidecar cannot fetch itself; deliver them inline on the frame so
286
+ // the sidecar checks them out into its durable per-deployment source store. A
287
+ // registry pin fetches its tarballs over HTTP and delivers none.
288
+ const assets = isAssetDeployArgs(args)
289
+ ? await buildSourceAssetMounts(closure, args.resolveAttachment)
290
+ : [];
291
+ const result = await sendMultiStepDeployFrame({
292
+ lineage: "source-ref",
293
+ sidecarRouter: args.sidecarRouter,
294
+ ...(args.sidecarAllocationRouter !== undefined
295
+ ? { sidecarAllocationRouter: args.sidecarAllocationRouter }
296
+ : {}),
297
+ ...(args.allocationTarget !== undefined
298
+ ? { allocationTarget: args.allocationTarget }
299
+ : {}),
300
+ agentAddress: args.agentAddress,
301
+ config: args.config,
302
+ sources: args.sources,
303
+ approvedWireHash: approval.approvedWireHash,
304
+ sourceRef: { source: args.source, closure },
305
+ ...(credentials !== undefined ? { credentials } : {}),
306
+ ...(referencedDefinitions.length > 0 ? { referencedDefinitions } : {}),
307
+ ...(assets.length > 0 ? { assets } : {}),
308
+ });
309
+ return { publicKey: result.publicKey, definitionId: approval.definitionId };
310
+ }
311
+ /**
312
+ * The single public composition entrypoint for a SHARED code-sourced (npm)
313
+ * deploy: emit the source-ref frame, then INSERT the deployment's anchor
314
+ * `workflow_run` row -- the deployment's first-class record that owns its
315
+ * routing address and public key. Run-grant materialization keys off this row
316
+ * (address + live status), so WITHOUT it no per-run grants (tool, capability, OR
317
+ * credential) ever materialize for a source-ref deployment. Born "deployed"
318
+ * (live but pre-trigger): the first trigger's materialization flips it to
319
+ * "running" via `anchorWithPrincipal`'s guarded update, which a row born
320
+ * "running" would skip. Its `anchorRunId` equals its own id, so the anchor
321
+ * references itself. The deployer read grant is deferred to the production
322
+ * route, which carries the authenticated deployer principal; this stays a
323
+ * single insert with no grant row to pair atomically.
324
+ *
325
+ * The prepared exclusive path does NOT use this wrapper: its anchor row already
326
+ * exists from prepare time, so it wraps `emitSourceRefDeployFrame` with an
327
+ * UPDATE-under-allocation-lock instead of this INSERT.
328
+ */
329
+ export async function deployCodeSourcedWorkflow(args) {
330
+ const { publicKey, definitionId } = await emitSourceRefDeployFrame(args);
331
+ await args.db.insert(workflowRunTable).values({
332
+ id: args.anchorRunId,
333
+ tenantId: args.tenantId,
334
+ anchorRunId: args.anchorRunId,
335
+ definitionId,
336
+ address: args.agentAddress,
337
+ publicKey,
338
+ status: "deployed",
339
+ createdAt: new Date(),
340
+ });
341
+ return { publicKey };
342
+ }
343
+ export function createSessionService(deps) {
344
+ const { sidecarRouter, sidecarAllocationRouter, agentRepoStore, assetService, db, toolPackageRegistries, } = deps;
345
+ if (assetService !== undefined && db === undefined) {
346
+ throw new Error("createSessionService: db is required when assetService is set");
347
+ }
348
+ if (toolPackageRegistries !== undefined && db === undefined) {
349
+ throw new Error("createSessionService: db is required when toolPackageRegistries is set");
350
+ }
351
+ function requireAllocationRouter() {
352
+ if (sidecarAllocationRouter === undefined) {
353
+ throw new Error("Exclusive deployment routing is not configured");
354
+ }
355
+ return sidecarAllocationRouter;
356
+ }
357
+ /**
358
+ * Stage one per-step deploy on the sidecar: resolve assets and tool
359
+ * packages, write the deploy tree, provision the step, and deliver the
360
+ * deploy + asset packs (Phases 0-2b). Phase 1 binds a transient route for
361
+ * the step address, fires a no-spawn provision frame (init repo + record
362
+ * hub key), and unbinds the route once the packs land -- no warm harness and
363
+ * no child. The deployment-level workflow frame, sent once after every step
364
+ * is staged, spawns the child. A call without `stageOnly` is rejected -- the
365
+ * legacy warm-harness and single-step-head paths are gone.
366
+ */
367
+ async function executeLaunchPhases(params) {
368
+ const { agentAddress, agentId, runId, config, deployContent } = params;
369
+ const toolPackagePins = params.toolPackagePins ?? [];
370
+ const stageOnly = params.stageOnly ?? false;
371
+ let effectiveDeployContent = deployContent;
372
+ // Phase 0a-bis: Resolve the agent's tool-package pins into a full
373
+ // closure manifest. Empty pins skip the resolver entirely. A
374
+ // ManifestInvalidError (e.g. unsatisfied peer dependency) is a
375
+ // launch-time failure — the deploy never ships and the sidecar
376
+ // is not touched.
377
+ //
378
+ // The resolver runs once per launch with no cross-launch caching;
379
+ // the packument cache scopes only within a single closure walk.
380
+ // Acceptable at the current N (handful of agents, small pin sets
381
+ // per agent) — a tenant-scoped packument cache or a per-pin set
382
+ // resolved-manifest cache would be the obvious scaling lever
383
+ // when launch latency becomes the bottleneck.
384
+ const manifestAssetAttachments = [];
385
+ if (toolPackagePins.length > 0) {
386
+ if (toolPackageRegistries === undefined) {
387
+ 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);
388
+ }
389
+ if (assetService === undefined) {
390
+ 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);
391
+ }
392
+ let manifest;
393
+ let assetIndex;
394
+ try {
395
+ const built = await buildAndResolve({
396
+ agentId,
397
+ tenantId: config.tenantId,
398
+ pins: toolPackagePins,
399
+ registries: toolPackageRegistries,
400
+ assetService,
401
+ });
402
+ manifest = built.manifest;
403
+ assetIndex = built.assetIndex;
404
+ }
405
+ catch (err) {
406
+ if (err instanceof ManifestInvalidError) {
407
+ logger.warn `tool-package manifest validation failed for agent ${agentId}: ${err.message}`;
408
+ }
409
+ throw new SessionLaunchError("write", err, false);
410
+ }
411
+ const assetMounts = new Map();
412
+ try {
413
+ for (const assetId of collectDistinctAssetIds(manifest)) {
414
+ const asset = assetIndex.get(assetId);
415
+ if (asset === undefined) {
416
+ // The asset id appears in the manifest but is not in the
417
+ // tenant-visible asset set. This can only happen if the
418
+ // resolver's registry map and the asset index disagree —
419
+ // the same scan populated both, so reaching this branch
420
+ // would indicate an upstream invariant violation.
421
+ throw new Error(`resolved tool-package manifest references asset ${assetId} which is not visible to tenant ${config.tenantId}`);
422
+ }
423
+ const mountPath = `package-registries/${asset.name}/`;
424
+ assetMounts.set(assetId, mountPath);
425
+ manifestAssetAttachments.push(await resolveAssetAttachment({
426
+ asset,
427
+ mountPath,
428
+ }));
429
+ }
430
+ }
431
+ catch (err) {
432
+ throw new SessionLaunchError("write", err, false);
433
+ }
434
+ effectiveDeployContent = {
435
+ ...effectiveDeployContent,
436
+ toolPackageManifest: manifest,
437
+ ...(assetMounts.size > 0 ? { assetMounts } : {}),
438
+ };
439
+ }
440
+ // Phase 0b: Write deploy tree and produce packfile (hub-local, no
441
+ // sidecar state to clean up if this fails).
442
+ let pack;
443
+ let commitSha;
444
+ let ref;
445
+ try {
446
+ await agentRepoStore.writeDeployTree(agentId, effectiveDeployContent);
447
+ ({ pack, commitSha, ref } =
448
+ await agentRepoStore.createDeployPack(agentId));
449
+ }
450
+ catch (err) {
451
+ throw new SessionLaunchError("write", err, false);
452
+ }
453
+ // A stage-only per-step deploy binds a transient route for the step
454
+ // address so the packs below route to the deployment's sidecar; the
455
+ // route is held only for the pack window and dropped in the `finally`.
456
+ if (stageOnly) {
457
+ try {
458
+ if (params.allocationTarget === undefined) {
459
+ sidecarRouter.bindStepRoute(agentAddress);
460
+ }
461
+ else {
462
+ await requireAllocationRouter().bindAllocatedStepRoute(params.allocationTarget, agentAddress);
463
+ }
464
+ }
465
+ catch (err) {
466
+ throw new SessionLaunchError("provision", err, false);
467
+ }
468
+ }
469
+ try {
470
+ // Phase 1: Provision on sidecar. A stage-only per-step deploy sends a
471
+ // no-spawn provision frame: the sidecar inits the step's agent-state
472
+ // repo and records the hub key, but spawns nothing. Firing the frame
473
+ // before the Phase 2 pack is the ordering barrier -- the repo must
474
+ // exist before the pack applies.
475
+ try {
476
+ if (stageOnly) {
477
+ if (params.allocationTarget === undefined) {
478
+ await sidecarRouter.sendProvisionStep(agentAddress, config);
479
+ }
480
+ else {
481
+ await requireAllocationRouter().sendProvisionStepToAllocation(params.allocationTarget, agentAddress, config);
482
+ }
483
+ }
484
+ else {
485
+ // Every caller supplies `stageOnly`. A deploy without it has no
486
+ // provisioning shape -- the legacy warm-harness and single-step-head
487
+ // paths are gone -- so fail loud rather than ship a deploy pack the
488
+ // sidecar never provisioned a repo for.
489
+ throw new Error("executeLaunchPhases: a deploy requires stageOnly");
490
+ }
491
+ }
492
+ catch (err) {
493
+ throw new SessionLaunchError("provision", err, false);
494
+ }
495
+ // Phase 2: Pack delivery. A stage-only step has no supervisor to
496
+ // undeploy, so on failure it only drops its transient route (in the
497
+ // `finally`). The step's inited agent-state repo is left on the sidecar:
498
+ // the deploy aborts before the deployment frame is sent, so there is
499
+ // nothing to undeploy, and a redeploy of the same deployment overwrites
500
+ // the orphaned repo. This is an acceptable minor leak on the exceptional
501
+ // staging-failure path, not a live-path cost.
502
+ try {
503
+ if (params.allocationTarget === undefined) {
504
+ await sidecarRouter.sendPack(agentAddress, pack, ref, commitSha);
505
+ }
506
+ else {
507
+ await requireAllocationRouter().sendPackToAllocation(params.allocationTarget, agentAddress, pack, ref, commitSha);
508
+ }
509
+ }
510
+ catch (err) {
511
+ if (!stageOnly && params.allocationTarget === undefined) {
512
+ await attemptCleanup(agentAddress, "pack", err);
513
+ }
514
+ throw new SessionLaunchError("pack", err, !stageOnly && params.allocationTarget !== undefined);
515
+ }
516
+ // Phase 2b: Asset-pack fan-out. For each attached asset, build a
517
+ // pack, reserve the manifest row, then send the pack. The manifest
518
+ // reservation MUST happen before the pack send: if the sidecar acks
519
+ // but the row is missing, the session has materialization without
520
+ // a recorded manifest. An allocated replacement may reuse the exact
521
+ // row its predecessor recorded; ordinary launches still require a new
522
+ // row. If reservation fails, the pack send must not happen.
523
+ //
524
+ // The fan-out materializes the package-registry assets the
525
+ // tool-package resolver picked. They live behind tenant
526
+ // inheritance rather than a per-agent attachment row, so the
527
+ // session service synthesizes the attachment view in
528
+ // `manifestAssetAttachments`.
529
+ const fanOut = manifestAssetAttachments;
530
+ if (assetService !== undefined && fanOut.length > 0) {
531
+ // Track the rows this attempt owns so a later fan-out failure can roll
532
+ // them back in lockstep with the sidecar undeploy. Allocated rows are
533
+ // durable recovery intent, not attempt-owned materialization state, so
534
+ // replacement failures must leave them in place for the next worker.
535
+ const committed = [];
536
+ for (const att of fanOut) {
537
+ try {
538
+ const committedRecord = await sendAttachmentPack(runId, agentAddress, att, params.allocationTarget);
539
+ if (committedRecord !== null)
540
+ committed.push(committedRecord);
541
+ }
542
+ catch (err) {
543
+ await rollbackCommittedAttachments(committed);
544
+ if (!stageOnly && params.allocationTarget === undefined) {
545
+ await attemptCleanup(agentAddress, "pack", err);
546
+ }
547
+ throw new SessionLaunchError("pack", err, !stageOnly && params.allocationTarget !== undefined);
548
+ }
549
+ }
550
+ }
551
+ }
552
+ finally {
553
+ if (stageOnly) {
554
+ if (params.allocationTarget === undefined) {
555
+ sidecarRouter.unbindStepRoute(agentAddress);
556
+ }
557
+ else {
558
+ requireAllocationRouter().unbindAllocatedStepRoute(params.allocationTarget, agentAddress);
559
+ }
560
+ }
561
+ }
562
+ }
563
+ /**
564
+ * Stage one step of a multi-step workflow deploy: bind a transient route
565
+ * for the step address, fire a no-spawn provision frame (the sidecar inits
566
+ * the step's agent-state repo and records the hub key), deliver the deploy
567
+ * and asset packs, and unbind the route -- no warm harness. The multi-step
568
+ * branch stages every step this way, then fires ONE deployment-level
569
+ * workflow frame that writes the step grants and spawns the supervised
570
+ * workflow-process child; the child reads each step's staged deploy tree
571
+ * from disk and runs the step itself.
572
+ */
573
+ async function stageWorkflowStep(params) {
574
+ await executeLaunchPhases({
575
+ agentAddress: params.agentAddress,
576
+ agentId: params.agentId,
577
+ runId: params.runId,
578
+ config: params.config,
579
+ deployContent: params.deployContent,
580
+ stageOnly: true,
581
+ ...(params.toolPackagePins !== undefined
582
+ ? { toolPackagePins: params.toolPackagePins }
583
+ : {}),
584
+ ...(params.allocationTarget !== undefined
585
+ ? { allocationTarget: params.allocationTarget }
586
+ : {}),
587
+ });
588
+ }
589
+ // Resolve the npm registry config a code-sourced install resolves external
590
+ // deps against, by the registry name. A code-sourced deploy needs the
591
+ // registry map configured; a hub that mounts the deploy surface without it is
592
+ // mis-wired, so this fails loud rather than defaulting a registry URL.
593
+ function requireRegistryConfig(registryName) {
594
+ if (toolPackageRegistries === undefined) {
595
+ throw new Error("deployWorkflowFromSource: the session service has no toolPackageRegistries configured; a code-sourced deploy cannot resolve its dependency closure");
596
+ }
597
+ const config = toolPackageRegistries.httpRegistries.get(registryName);
598
+ if (config === undefined) {
599
+ throw new Error(`deployWorkflowFromSource: no HTTP registry named ${JSON.stringify(registryName)} is configured`);
600
+ }
601
+ return config;
602
+ }
603
+ // Build the git-pack resolver a source/tarball asset arm delivers inline. The
604
+ // pin names one backing asset, so the resolver binds that asset's repo (its
605
+ // kind fixed by the arm) and its default ref; a request for any OTHER asset id
606
+ // is a closure that reaches beyond its single backing asset and fails loud
607
+ // rather than silently packing the wrong repo.
608
+ function bindAssetAttachmentResolver(assetId, repoKind) {
609
+ return async (requestedAssetId) => {
610
+ if (requestedAssetId !== assetId) {
611
+ throw new Error(`deployWorkflowFromSource: closure references asset ${requestedAssetId}, but only the pinned source asset ${assetId} is deliverable`);
612
+ }
613
+ const repoId = { kind: repoKind, id: assetId };
614
+ const commitSha = await agentRepoStore.repoStore.resolveRef(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
615
+ if (commitSha === null) {
616
+ throw new Error(`deployWorkflowFromSource: source asset ${assetId} has no commit on ${DEFAULT_ASSET_REF}`);
617
+ }
618
+ const { pack, ref } = await agentRepoStore.repoStore.createPack(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
619
+ return { pack, ref, commitSha };
620
+ };
621
+ }
622
+ // Assemble the install args for the concrete source arm. Mirrors the
623
+ // `isAssetSourceInstallArgs`/`isAssetTarballInstallArgs` guards the probe gate
624
+ // narrows on: an asset-`source` arm binds committed reads at the pinned commit
625
+ // plus the npm registry for external deps; an asset-`tarball` arm binds the
626
+ // asset's blob reads and a pin; a `registry` arm carries only its registry
627
+ // config and a pin. A `pin` missing where the arm requires it fails closed.
628
+ async function buildInstallArgs(params, resolveAttachment) {
629
+ if (db === undefined) {
630
+ throw new Error("deployWorkflowFromSource requires a db handle to freeze the approval");
631
+ }
632
+ const dbHandle = db;
633
+ const common = {
634
+ entry: params.entry,
635
+ assetId: params.definitionAssetId,
636
+ approvals: { mode: "approve-probed" },
637
+ router: sidecarRouter,
638
+ db: dbHandle,
639
+ };
640
+ const source = params.source;
641
+ if (source.kind === "asset") {
642
+ if (resolveAttachment === null) {
643
+ throw new Error("deployWorkflowFromSource: an asset-sourced deploy requires an attachment resolver");
644
+ }
645
+ if (source.package.format === "source") {
646
+ const committed = await agentRepoStore.repoStore.openCommittedReadsAtCommit(HUB_PRINCIPAL, { kind: "workflow", id: source.assetId }, source.package.commitSha);
647
+ if (committed === null) {
648
+ throw new Error(`deployWorkflowFromSource: source asset ${source.assetId} has no commit ${source.package.commitSha}`);
649
+ }
650
+ const registryName = requireDefaultRegistryName();
651
+ return {
652
+ ...common,
653
+ source,
654
+ reads: committedReadsToSourceTree(committed),
655
+ registryName,
656
+ registryConfig: requireRegistryConfig(registryName),
657
+ resolveAttachment,
658
+ };
659
+ }
660
+ if (params.pin === undefined) {
661
+ throw new Error("deployWorkflowFromSource: an asset-tarball deploy requires a name@range pin");
662
+ }
663
+ if (assetService === undefined) {
664
+ throw new Error("deployWorkflowFromSource: an asset-tarball deploy requires an asset service to read the package blobs");
665
+ }
666
+ const tarballAssetId = source.assetId;
667
+ const tarballService = assetService;
668
+ return {
669
+ ...common,
670
+ source,
671
+ pin: params.pin,
672
+ readBlob: (path) => tarballService.readAssetBlob({ assetId: tarballAssetId, path }),
673
+ listBlobs: (dir) => tarballService.listAssetBlobs({ assetId: tarballAssetId, dir }),
674
+ resolveAttachment,
675
+ };
676
+ }
677
+ if (params.pin === undefined) {
678
+ throw new Error("deployWorkflowFromSource: a registry deploy requires a name@range pin");
679
+ }
680
+ return {
681
+ ...common,
682
+ source,
683
+ pin: params.pin,
684
+ registryConfig: requireRegistryConfig(source.registry),
685
+ };
686
+ }
687
+ function requireDefaultRegistryName() {
688
+ if (toolPackageRegistries === undefined) {
689
+ throw new Error("deployWorkflowFromSource: the session service has no toolPackageRegistries configured; a code-sourced deploy cannot resolve its dependency closure");
690
+ }
691
+ return toolPackageRegistries.defaultRegistry;
692
+ }
693
+ // Bind the pack resolver an asset arm delivers inline. An asset arm delivers
694
+ // its backing repo (its kind fixed by `package.format`); a registry arm
695
+ // fetches its tarballs over HTTP and delivers no asset, so it binds nothing.
696
+ // Both the install (probe) and the deploy rebind the SAME resolver from the
697
+ // source, so a prepared deploy reconstructs it from the frozen `source`.
698
+ function bindSourceAttachmentResolver(source) {
699
+ return source.kind === "asset"
700
+ ? bindAssetAttachmentResolver(source.assetId, source.package.format === "source" ? "workflow" : "package-registry")
701
+ : null;
702
+ }
703
+ // Install + probe + gate + freeze a code-sourced definition, returning the
704
+ // frozen bundle and the (asset-only) attachment resolver. The gate outcome is
705
+ // NOT asserted here: `deployWorkflowFromSource` and `installAndApproveWorkflowSource`
706
+ // each surface a non-approval as their own domain error. This is the shared
707
+ // freeze both the shared deploy and the exclusive prepare run.
708
+ async function prepareCodeSourcedApproval(params) {
709
+ const resolveAttachment = bindSourceAttachmentResolver(params.source);
710
+ const installArgs = await buildInstallArgs(params, resolveAttachment);
711
+ const approved = await installAndApproveWorkflowDefinition(installArgs);
712
+ return { approved, resolveAttachment };
713
+ }
714
+ // Freeze a code-sourced approval on shared capacity WITHOUT deploying it. The
715
+ // exclusive prepare path persists the returned bundle and deploys it to a
716
+ // dedicated allocation later. A non-approval fails closed as an invalid
717
+ // definition.
718
+ async function installAndApproveWorkflowSource(params) {
719
+ const { approved } = await prepareCodeSourcedApproval(params);
720
+ if (!approved.approval.ok) {
721
+ throw new WorkflowDefinitionInvalidError(approved.projection.id, `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`);
722
+ }
723
+ return approved;
724
+ }
725
+ async function deployWorkflowFromSource(params) {
726
+ if (db === undefined) {
727
+ throw new Error("deployWorkflowFromSource requires a db handle to record the deployment's anchor run");
728
+ }
729
+ const source = params.source;
730
+ const { approved, resolveAttachment } = await prepareCodeSourcedApproval(params);
731
+ if (!approved.approval.ok) {
732
+ throw new WorkflowDefinitionInvalidError(approved.projection.id, `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`);
733
+ }
734
+ // Pin every top-level step's inference source under the frozen approval,
735
+ // then hand the frozen bundle to the source-ref deploy.
736
+ const sources = buildInertProjectionStepSources({
737
+ projection: approved.projection,
738
+ config: params.config,
739
+ operatorApprovals: approved.approval.approvedGrants,
740
+ });
741
+ const commonDeploy = {
742
+ approved,
743
+ sidecarRouter,
744
+ agentAddress: params.agentAddress,
745
+ config: params.config,
746
+ sources,
747
+ db,
748
+ tenantId: params.tenantId,
749
+ anchorRunId: params.anchorRunId,
750
+ deploymentDomain: params.deploymentDomain,
751
+ };
752
+ // Branch on the source discriminant so the deploy args match the
753
+ // asset/registry arms of `DeployCodeSourcedWorkflowArgs`: an asset arm
754
+ // carries the attachment resolver (asserted non-null here to satisfy the
755
+ // union and fail loud on a mis-wired caller), a registry arm carries none.
756
+ let result;
757
+ if (source.kind === "asset") {
758
+ if (resolveAttachment === null) {
759
+ throw new Error("deployWorkflowFromSource: asset source deploy is missing its attachment resolver");
760
+ }
761
+ result = await deployCodeSourcedWorkflow({
762
+ ...commonDeploy,
763
+ source,
764
+ resolveAttachment,
765
+ });
766
+ }
767
+ else {
768
+ result = await deployCodeSourcedWorkflow({ ...commonDeploy, source });
769
+ }
770
+ // Seed the deploying principal's read grant on the deployment's workflow-run
771
+ // resource. `deployCodeSourcedWorkflow` wrote the anchor row but deliberately
772
+ // leaves this grant to the route, which carries the authenticated deployer
773
+ // principal.
774
+ const now = new Date();
775
+ await db.insert(grantTable).values({
776
+ id: generateId("grant"),
777
+ tenantId: params.tenantId,
778
+ principalId: params.config.principalId,
779
+ resource: `workflow-run:${params.anchorRunId}`,
780
+ action: "read",
781
+ effect: "allow",
782
+ origin: "creator",
783
+ createdAt: now,
784
+ updatedAt: now,
785
+ });
786
+ return {
787
+ anchorRunId: params.anchorRunId,
788
+ deploymentAddress: params.agentAddress,
789
+ publicKey: result.publicKey,
790
+ };
791
+ }
792
+ /**
793
+ * Update a prepared anchor run's `publicKey` under the allocation-ownership
794
+ * lock. The anchor row was inserted at prepare time; this stamps the
795
+ * supervisor key returned by the deploy ack, but only while the allocation
796
+ * still names this exact accepted generation for this anchor. A lost lock (the
797
+ * allocation moved on, another worker took the generation) fails closed as a
798
+ * leaked-agent `SessionLaunchError` -- the deploy already reached the sidecar,
799
+ * so the caller must treat the sidecar agent as possibly live. Used by the
800
+ * `deployPreparedCodeSourcedWorkflow` prepared path.
801
+ */
802
+ async function updateAnchorPublicKeyUnderAllocationLock(args) {
803
+ if (db === undefined) {
804
+ throw new Error("updateAnchorPublicKeyUnderAllocationLock requires a db handle");
805
+ }
806
+ const dbHandle = db;
807
+ try {
808
+ const updated = await dbHandle.transaction(async (tx) => {
809
+ const [allocation] = await tx
810
+ .select({
811
+ id: sidecarAllocationTable.id,
812
+ anchorRunId: sidecarAllocationTable.anchorRunId,
813
+ status: sidecarAllocationTable.status,
814
+ generation: sidecarAllocationTable.generation,
815
+ ensureAcceptedGeneration: sidecarAllocationTable.ensureAcceptedGeneration,
816
+ })
817
+ .from(sidecarAllocationTable)
818
+ .where(eq(sidecarAllocationTable.id, args.allocationTarget.allocationId))
819
+ .limit(1)
820
+ .for("update");
821
+ if (allocation === undefined ||
822
+ allocation.anchorRunId !== args.anchorRunId ||
823
+ allocation.status !== "allocated" ||
824
+ allocation.generation !== args.allocationTarget.generation ||
825
+ allocation.ensureAcceptedGeneration !==
826
+ args.allocationTarget.generation) {
827
+ return null;
828
+ }
829
+ const [anchor] = await tx
830
+ .update(workflowRunTable)
831
+ .set({ publicKey: args.publicKey })
832
+ .where(and(eq(workflowRunTable.id, args.anchorRunId), eq(workflowRunTable.anchorRunId, args.anchorRunId), eq(workflowRunTable.tenantId, args.tenantId)))
833
+ .returning({ id: workflowRunTable.id });
834
+ return anchor ?? null;
835
+ });
836
+ if (updated === null) {
837
+ throw new Error(`Prepared anchor run ${args.anchorRunId} lost allocation ownership before initialization completed`);
838
+ }
839
+ }
840
+ catch (error) {
841
+ throw new SessionLaunchError("start", error, true);
842
+ }
843
+ }
844
+ /**
845
+ * Deploy a previously-frozen code-sourced approval bundle to a dedicated
846
+ * allocation. The anchor `workflow_run` row already exists from prepare time
847
+ * (with its `definitionId` set), so this UPDATES it under the
848
+ * allocation-ownership lock
849
+ * rather than inserting. No re-probe: the frozen projection/hash/closure ride
850
+ * verbatim from `params.approved`, and the per-step inference sources are
851
+ * re-pinned from the re-resolved chain (deliberately NOT frozen, since a
852
+ * resolved source carries a credential secret).
853
+ */
854
+ async function deployPreparedCodeSourcedWorkflow(params) {
855
+ if (db === undefined) {
856
+ throw new Error("deployPreparedCodeSourcedWorkflow requires a db handle to update the prepared anchor run");
857
+ }
858
+ const dbHandle = db;
859
+ const approval = params.approved.approval;
860
+ if (!approval.ok) {
861
+ throw new Error("deployPreparedCodeSourcedWorkflow: refusing to deploy an unapproved workflow bundle");
862
+ }
863
+ const allocationRouter = requireAllocationRouter();
864
+ const source = params.source;
865
+ const resolveAttachment = bindSourceAttachmentResolver(source);
866
+ // Re-pin every top-level step's inference source from the re-resolved chain
867
+ // under the frozen approval -- the same pin the shared deploy computes.
868
+ const sources = buildInertProjectionStepSources({
869
+ projection: params.approved.projection,
870
+ config: params.config,
871
+ operatorApprovals: approval.approvedGrants,
872
+ });
873
+ // Restore the Hub-authoritative run ref onto the exact allocation generation
874
+ // before its address is routed.
875
+ await restoreWorkflowRunToAllocation({
876
+ agentRepoStore,
877
+ allocationRouter,
878
+ allocationTarget: params.allocationTarget,
879
+ agentAddress: params.agentAddress,
880
+ });
881
+ const commonEmit = {
882
+ approved: params.approved,
883
+ sidecarRouter,
884
+ sidecarAllocationRouter: allocationRouter,
885
+ allocationTarget: params.allocationTarget,
886
+ agentAddress: params.agentAddress,
887
+ config: params.config,
888
+ sources,
889
+ db: dbHandle,
890
+ tenantId: params.tenantId,
891
+ anchorRunId: params.anchorRunId,
892
+ deploymentDomain: params.deploymentDomain,
893
+ ...(params.credentialCipher !== undefined
894
+ ? { credentialCipher: params.credentialCipher }
895
+ : {}),
896
+ };
897
+ // Branch on the source discriminant so the emit args match the asset/registry
898
+ // arms: an asset arm carries the rebuilt attachment resolver (asserted
899
+ // non-null to satisfy the union), a registry arm carries none.
900
+ let result;
901
+ if (source.kind === "asset") {
902
+ if (resolveAttachment === null) {
903
+ throw new Error("deployPreparedCodeSourcedWorkflow: asset source deploy is missing its attachment resolver");
904
+ }
905
+ result = await emitSourceRefDeployFrame({
906
+ ...commonEmit,
907
+ source,
908
+ resolveAttachment,
909
+ });
910
+ }
911
+ else {
912
+ result = await emitSourceRefDeployFrame({ ...commonEmit, source });
913
+ }
914
+ await updateAnchorPublicKeyUnderAllocationLock({
915
+ tenantId: params.tenantId,
916
+ anchorRunId: params.anchorRunId,
917
+ allocationTarget: params.allocationTarget,
918
+ publicKey: result.publicKey,
919
+ });
920
+ return {
921
+ anchorRunId: params.anchorRunId,
922
+ deploymentAddress: params.agentAddress,
923
+ publicKey: result.publicKey,
924
+ };
925
+ }
926
+ async function rollbackCommittedAttachments(committed) {
927
+ if (db === undefined)
928
+ return;
929
+ if (committed.length === 0)
930
+ return;
931
+ // Per-row try/catch so a single rollback failure does not stop the
932
+ // sweep — every committed row needs to come off the books before
933
+ // the caller emits the original sendPack error.
934
+ for (const record of committed) {
935
+ try {
936
+ await db
937
+ .delete(sessionAssetTable)
938
+ .where(and(eq(sessionAssetTable.runId, record.runId), eq(sessionAssetTable.mountPath, record.mountPath), eq(sessionAssetTable.assetPackSha, record.assetPackSha), eq(sessionAssetTable.sourceCommitSha, record.sourceCommitSha)));
939
+ }
940
+ catch (err) {
941
+ logger.warn `session_asset rollback failed for earlier-committed instance=${record.runId} mountPath=${record.mountPath}: ${err instanceof Error ? err.message : String(err)}`;
942
+ }
943
+ }
944
+ }
945
+ async function sendAttachmentPack(runId, agentAddress, attachment, allocationTarget) {
946
+ if (db === undefined) {
947
+ // Guarded at construction; reassert defensively so the
948
+ // narrowing is visible to readers and a future refactor cannot
949
+ // accidentally invoke this without a db.
950
+ throw new Error("sendAttachmentPack invoked without a db handle");
951
+ }
952
+ const { mountPath, sourceCommitSha, repoId, pack, ref } = attachment;
953
+ const assetPackSha = await createPackSha(pack);
954
+ const record = {
955
+ runId,
956
+ mountPath,
957
+ assetPackSha,
958
+ sourceCommitSha,
959
+ };
960
+ // Reserve the manifest row before the pack send so we never end up in the
961
+ // materialized-without-manifest state. Only an allocated launch may reuse
962
+ // an identical row: replacement workers keep the stable instance id and
963
+ // mount path, while the shared path retains its duplicate-launch guard.
964
+ const rollbackRecord = allocationTarget === undefined ? record : null;
965
+ if (allocationTarget === undefined) {
966
+ await db
967
+ .insert(sessionAssetTable)
968
+ .values({ ...record, materializedAt: new Date() });
969
+ }
970
+ else {
971
+ const inserted = await db
972
+ .insert(sessionAssetTable)
973
+ .values({ ...record, materializedAt: new Date() })
974
+ .onConflictDoNothing({
975
+ target: [sessionAssetTable.runId, sessionAssetTable.mountPath],
976
+ })
977
+ .returning({ runId: sessionAssetTable.runId });
978
+ if (inserted.length === 0) {
979
+ const existing = await db.query.sessionAsset.findFirst({
980
+ where: and(eq(sessionAssetTable.runId, runId), eq(sessionAssetTable.mountPath, mountPath)),
981
+ columns: {
982
+ assetPackSha: true,
983
+ sourceCommitSha: true,
984
+ },
985
+ });
986
+ if (existing === undefined) {
987
+ throw new Error(`session_asset ${runId}/${mountPath} disappeared after its insert conflicted`);
988
+ }
989
+ if (existing.assetPackSha !== assetPackSha ||
990
+ existing.sourceCommitSha !== sourceCommitSha) {
991
+ throw new Error(`session_asset ${runId}/${mountPath} conflicts with the allocated workflow's restored asset`);
992
+ }
993
+ }
994
+ }
995
+ try {
996
+ const options = { mountPath, repoId };
997
+ if (allocationTarget === undefined) {
998
+ await sidecarRouter.sendPack(agentAddress, pack, ref, sourceCommitSha, options);
999
+ }
1000
+ else {
1001
+ await requireAllocationRouter().sendPackToAllocation(allocationTarget, agentAddress, pack, ref, sourceCommitSha, options);
1002
+ }
1003
+ }
1004
+ catch (err) {
1005
+ // Shared launches own the row they just created and roll it back when
1006
+ // the send fails. Allocated rows are durable recovery intent: even a row
1007
+ // first inserted by this attempt can already be reused by another
1008
+ // reconciler, so no replacement attempt may delete it.
1009
+ // The forensic value of a manifest-without-materialization row is
1010
+ // negligible because no agent will read against it. Wrap the
1011
+ // rollback in its own try/catch so a rollback failure (DB gone,
1012
+ // connection killed mid-launch) is logged rather than masking the
1013
+ // primary sendPack error — the caller needs to see the original
1014
+ // failure, not the secondary one.
1015
+ if (rollbackRecord !== null) {
1016
+ try {
1017
+ await db
1018
+ .delete(sessionAssetTable)
1019
+ .where(and(eq(sessionAssetTable.runId, rollbackRecord.runId), eq(sessionAssetTable.mountPath, rollbackRecord.mountPath), eq(sessionAssetTable.assetPackSha, rollbackRecord.assetPackSha), eq(sessionAssetTable.sourceCommitSha, rollbackRecord.sourceCommitSha)));
1020
+ }
1021
+ catch (rollbackErr) {
1022
+ const msg = rollbackErr instanceof Error
1023
+ ? rollbackErr.message
1024
+ : String(rollbackErr);
1025
+ logger.warn `session_asset rollback failed for instance=${runId} mountPath=${mountPath}: ${msg}`;
1026
+ }
1027
+ }
1028
+ throw err;
1029
+ }
1030
+ return rollbackRecord;
1031
+ }
1032
+ /**
1033
+ * Build a per-agent `ClosureResolver` from the tenant's visible
1034
+ * package-registry assets plus the statically-configured HTTP
1035
+ * registries, then run the closure resolution against `pins`.
1036
+ *
1037
+ * Returns the resolved manifest and an asset-id-keyed index of the
1038
+ * package-registry assets the resolver knew about, so the caller can
1039
+ * derive mount paths from the asset name without a second DB hit.
1040
+ */
1041
+ async function buildAndResolve(args) {
1042
+ if (db === undefined) {
1043
+ // Guarded at construction; restate for the narrowing.
1044
+ throw new Error("buildAndResolve invoked without a db handle");
1045
+ }
1046
+ const visibleAssets = await listAssetsForTenant(db, args.tenantId, "package-registry");
1047
+ const registryMap = new Map();
1048
+ // `assetIndex` carries only the assets the resolver might have
1049
+ // read from — i.e. one row per registry name, the one that won
1050
+ // its `(kind, name)` slot. Shadowed assets that lost the
1051
+ // collision are deliberately excluded: the resolver can never
1052
+ // reach them, so the fan-out path must never see them in the
1053
+ // index either. The walker walks leaf-to-root inside
1054
+ // `listAssetsForTenant`, so the first occurrence of any
1055
+ // `(kind, name)` wins — we replay the same shadowing here.
1056
+ // Shadowed assets — those that lose the `(kind, name)` collision
1057
+ // contest at a lower tenancy level — are dropped entirely from
1058
+ // the per-launch registry map. They never appear in `assetIndex`
1059
+ // either, so the fan-out that translates `kind: "asset"` manifest
1060
+ // entries back to asset rows cannot reach them. This matches the
1061
+ // resolver's view: a closure built from this map sees exactly
1062
+ // the assets the resolver would have read from, and shadowed
1063
+ // tarballs are invisible to both layers.
1064
+ const assetIndex = new Map();
1065
+ for (const row of visibleAssets) {
1066
+ if (registryMap.has(row.name))
1067
+ continue;
1068
+ const asset = {
1069
+ id: row.id,
1070
+ tenantId: row.tenantId,
1071
+ kind: "package-registry",
1072
+ name: row.name,
1073
+ displayName: row.displayName,
1074
+ creatorPrincipalId: row.creatorPrincipalId,
1075
+ createdAt: row.createdAt,
1076
+ updatedAt: row.updatedAt,
1077
+ };
1078
+ assetIndex.set(asset.id, asset);
1079
+ registryMap.set(asset.name, new AssetRegistrySource({
1080
+ name: asset.name,
1081
+ assetId: asset.id,
1082
+ readBlob: (path) => args.assetService.readAssetBlob({
1083
+ assetId: asset.id,
1084
+ path,
1085
+ }),
1086
+ listBlobs: (dir) => args.assetService.listAssetBlobs({
1087
+ assetId: asset.id,
1088
+ dir,
1089
+ }),
1090
+ }));
1091
+ }
1092
+ for (const [name, cfg] of args.registries.httpRegistries) {
1093
+ // Asset wins on collision with an HTTP registry of the same
1094
+ // name; symmetric with the inner-shadows-outer rule that
1095
+ // governs the tenant walker.
1096
+ if (registryMap.has(name))
1097
+ continue;
1098
+ registryMap.set(name, new HttpRegistrySource({ name, config: cfg }));
1099
+ }
1100
+ if (!registryMap.has(args.registries.defaultRegistry)) {
1101
+ throw new Error(`agent ${args.agentId}: defaultRegistry "${args.registries.defaultRegistry}" is neither a tenant-visible package-registry asset nor a configured HTTP registry`);
1102
+ }
1103
+ const resolver = createClosureResolver({
1104
+ registries: registryMap,
1105
+ defaultRegistry: args.registries.defaultRegistry,
1106
+ ...(args.registries.scopeRouting !== undefined
1107
+ ? { scopeRouting: args.registries.scopeRouting }
1108
+ : {}),
1109
+ });
1110
+ const manifest = await resolver.resolveClosure(args.pins);
1111
+ return { manifest, assetIndex };
1112
+ }
1113
+ /**
1114
+ * Build a `ResolvedAttachment` for an asset the tool-package resolver
1115
+ * picked from. The pack is read from the asset's main ref (the same
1116
+ * ref the resolver consumed tarballs from).
1117
+ */
1118
+ async function resolveAssetAttachment(args) {
1119
+ const repoId = { kind: args.asset.kind, id: args.asset.id };
1120
+ const sourceCommitSha = await agentRepoStore.repoStore.resolveRef(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
1121
+ if (sourceCommitSha === null) {
1122
+ throw new Error(`tool-package asset ${args.asset.kind}/${args.asset.id} has no commit on ${DEFAULT_ASSET_REF}`);
1123
+ }
1124
+ const { pack, ref: returnedRef } = await agentRepoStore.repoStore.createPack(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
1125
+ return {
1126
+ mountPath: args.mountPath,
1127
+ sourceCommitSha,
1128
+ repoId,
1129
+ pack,
1130
+ ref: returnedRef,
1131
+ };
1132
+ }
1133
+ async function attemptCleanup(agentAddress, failedPhase, originalErr) {
1134
+ try {
1135
+ await sidecarRouter.sendAgentUndeploy(agentAddress, failedPhase);
1136
+ }
1137
+ catch (cleanupErr) {
1138
+ logger.error `Failed to clean up agent ${agentAddress} after ${failedPhase} failure: ${String(cleanupErr)}`;
1139
+ // Preserve the original error as cause so the root cause is not
1140
+ // lost when the cleanup also fails.
1141
+ throw new SessionLaunchError(failedPhase, originalErr, true);
1142
+ }
1143
+ }
1144
+ async function sendUserMessage(params) {
1145
+ const { agentAddress, from, messageId, date, content, attachments, inReplyTo, references, sessionId, tenantId, cryptoProvider, } = params;
1146
+ const headers = {
1147
+ from,
1148
+ to: [agentAddress],
1149
+ cc: undefined,
1150
+ date,
1151
+ messageId,
1152
+ subject: undefined,
1153
+ inReplyTo,
1154
+ references,
1155
+ mimeVersion: "1.0",
1156
+ interchangeType: "conversation.message",
1157
+ interchangeCorrelationId: undefined,
1158
+ interchangeTenantId: tenantId,
1159
+ interchangeAgentId: undefined,
1160
+ interchangeSessionId: sessionId,
1161
+ interchangeOfferingId: undefined,
1162
+ interchangeSchemaVersion: undefined,
1163
+ traceparent: undefined,
1164
+ tracestate: undefined,
1165
+ };
1166
+ const signedContent = assembleSignedContent({
1167
+ kind: "conversation",
1168
+ text: content,
1169
+ ...(attachments !== undefined ? { attachments } : {}),
1170
+ });
1171
+ const signature = await createDetachedSignatureFromProvider(signedContent, cryptoProvider);
1172
+ const rawMessage = assembleMessage(headers, signedContent, signature);
1173
+ const base64 = base64Encode(rawMessage);
1174
+ const delivered = sidecarRouter.routeMail(agentAddress, base64, messageId);
1175
+ if (!delivered) {
1176
+ throw new Error(`Failed to deliver message to ${agentAddress}: agent is unreachable`);
1177
+ }
1178
+ return rawMessage;
1179
+ }
1180
+ async function endSession(agentAddress, reason) {
1181
+ await sidecarRouter.sendAgentUndeploy(agentAddress, reason);
1182
+ }
1183
+ return {
1184
+ stageWorkflowStep,
1185
+ deployWorkflowFromSource,
1186
+ installAndApproveWorkflowSource,
1187
+ deployPreparedCodeSourcedWorkflow,
1188
+ sendUserMessage,
1189
+ endSession,
1190
+ };
1191
+ }