@intx/hub-sessions 0.2.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 (73) hide show
  1. package/README.md +3 -5
  2. package/dist/agent-repo.d.ts +9 -5
  3. package/dist/agent-repo.js +2 -2
  4. package/dist/agent-state-kind.js +4 -0
  5. package/dist/asset-service.d.ts +1 -20
  6. package/dist/asset-service.js +9 -91
  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 +7 -6
  10. package/dist/credential-push.js +42 -18
  11. package/dist/event-collector-registry.d.ts +1 -1
  12. package/dist/event-collector-registry.js +4 -4
  13. package/dist/event-collector.d.ts +1 -1
  14. package/dist/event-collector.js +10 -2
  15. package/dist/hub-session-lookups.d.ts +125 -7
  16. package/dist/hub-session-lookups.js +539 -80
  17. package/dist/hub-session-orchestrator.js +14 -49
  18. package/dist/index.d.ts +17 -8
  19. package/dist/index.js +14 -6
  20. package/dist/repo-store/index.d.ts +1 -1
  21. package/dist/repo-store/store.d.ts +1 -1
  22. package/dist/repo-store/store.js +138 -1
  23. package/dist/repo-store/subscribe-kind.d.ts +6 -3
  24. package/dist/repo-store/subscribe-kind.js +42 -77
  25. package/dist/repo-store/types.d.ts +94 -6
  26. package/dist/session-service.d.ts +277 -96
  27. package/dist/session-service.js +741 -547
  28. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  29. package/dist/sidecar-allocation/contracts.js +21 -0
  30. package/dist/sidecar-allocation/index.d.ts +4 -0
  31. package/dist/sidecar-allocation/index.js +3 -0
  32. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  33. package/dist/sidecar-allocation/placement-policy.js +21 -0
  34. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  35. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  36. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  37. package/dist/sidecar-allocation/reconciler.js +431 -0
  38. package/dist/skill-kind.js +4 -0
  39. package/dist/substrate.d.ts +3 -3
  40. package/dist/substrate.js +1 -1
  41. package/dist/workflow-allocation-service.d.ts +58 -0
  42. package/dist/workflow-allocation-service.js +239 -0
  43. package/dist/workflow-closure-resolution.d.ts +106 -0
  44. package/dist/workflow-closure-resolution.js +123 -0
  45. package/dist/workflow-definition-ensure.d.ts +24 -0
  46. package/dist/workflow-definition-ensure.js +75 -0
  47. package/dist/workflow-dispatch-service.d.ts +40 -0
  48. package/dist/workflow-dispatch-service.js +146 -0
  49. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  50. package/dist/workflow-dispatch-settlement.js +140 -0
  51. package/dist/workflow-kind.d.ts +17 -1
  52. package/dist/workflow-kind.js +127 -80
  53. package/dist/workflow-probe-gate.d.ts +214 -0
  54. package/dist/workflow-probe-gate.js +207 -0
  55. package/dist/workflow-run-kind.d.ts +128 -14
  56. package/dist/workflow-run-kind.js +353 -83
  57. package/dist/workflow-run-reader.d.ts +1 -1
  58. package/dist/workflow-run-reader.js +3 -7
  59. package/dist/workflow-run-restore.d.ts +15 -0
  60. package/dist/workflow-run-restore.js +26 -0
  61. package/dist/workflow-source-closure.d.ts +35 -0
  62. package/dist/workflow-source-closure.js +342 -0
  63. package/dist/ws/index.d.ts +3 -3
  64. package/dist/ws/index.js +1 -1
  65. package/dist/ws/sidecar-events.d.ts +100 -12
  66. package/dist/ws/sidecar-events.js +2 -0
  67. package/dist/ws/sidecar-handler.d.ts +128 -7
  68. package/dist/ws/sidecar-handler.js +1069 -135
  69. package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
  70. package/dist/ws/sidecar-token-authenticator.js +64 -7
  71. package/package.json +14 -13
  72. package/dist/available-skills-stanza.d.ts +0 -21
  73. package/dist/available-skills-stanza.js +0 -32
@@ -1,20 +1,21 @@
1
1
  import { type } from "arktype";
2
2
  import { and, eq } from "drizzle-orm";
3
- import { createDefaultDirectorRegistry, } from "@intx/agent";
4
3
  import { getLogger } from "@intx/log";
5
4
  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";
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";
8
7
  import { base64Encode, hexEncode } from "@intx/types";
9
8
  import { generateId } from "@intx/hub-common";
10
- import { sessionAsset as sessionAssetTable, } from "@intx/db/schema";
9
+ import { sessionAsset as sessionAssetTable } from "@intx/db/schema";
11
10
  import { AssetRegistrySource, HttpRegistrySource, ManifestInvalidError, createClosureResolver, } from "@intx/tool-packaging";
12
11
  import { ToolPackageManifest, } from "@intx/types/tool-packages";
13
- import { defineWorkflow, } from "@intx/workflow/definition";
14
- import { createWorkflowDeployOrchestrator, deriveDeploymentAddress, walkCapabilities, wrapHarnessAsSingleStepWorkflow, } from "@intx/workflow-deploy";
12
+ import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash";
13
+ import { buildInertProjectionStepSources, deriveRunAddress, enumerateInertOnTriggerBodies, pickStepInferenceSource, WorkflowDefinitionInvalidError, } from "@intx/workflow-deploy";
15
14
  import { DEFAULT_ASSET_REF, } from "./asset-service.js";
16
- import { buildAvailableSkillsStanza, } from "./available-skills-stanza.js";
17
- import { getSkillIndex } from "./skill-kind.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";
18
19
  const logger = getLogger(["interchange", "hub", "session-service"]);
19
20
  export class SessionLaunchError extends Error {
20
21
  /** Which phase failed: "write", "provision", "pack", or "start". */
@@ -29,9 +30,9 @@ export class SessionLaunchError extends Error {
29
30
  this.leakedAgent = leakedAgent;
30
31
  }
31
32
  }
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.
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.
35
36
  const HUB_PRINCIPAL = { kind: "hub" };
36
37
  async function createPackSha(pack) {
37
38
  const digest = await crypto.subtle.digest("SHA-256",
@@ -59,75 +60,6 @@ function collectDistinctAssetIds(manifest) {
59
60
  }
60
61
  return out;
61
62
  }
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
63
  /**
132
64
  * Translate the orchestrator's structural `DeployContent` (which types
133
65
  * `toolPackageManifest` as `unknown`) back into the hub-sessions
@@ -155,118 +87,288 @@ export function bridgeOrchestratorDeployContent(content) {
155
87
  return bridged;
156
88
  }
157
89
  /**
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`.
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`.
165
95
  *
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.
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.
170
104
  */
171
105
  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 }
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,
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,
194
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 } : {}),
195
308
  });
309
+ return { publicKey: result.publicKey, definitionId: approval.definitionId };
196
310
  }
197
311
  /**
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.
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.
204
328
  */
205
- function createHubWorkflowRepoWriter(agentRepoStore) {
206
- 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
- },
215
- };
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 };
216
342
  }
217
343
  export function createSessionService(deps) {
218
- const { sidecarRouter, agentRepoStore, assetService, db, toolPackageRegistries, } = deps;
344
+ const { sidecarRouter, sidecarAllocationRouter, agentRepoStore, assetService, db, toolPackageRegistries, } = deps;
219
345
  if (assetService !== undefined && db === undefined) {
220
346
  throw new Error("createSessionService: db is required when assetService is set");
221
347
  }
222
348
  if (toolPackageRegistries !== undefined && db === undefined) {
223
349
  throw new Error("createSessionService: db is required when toolPackageRegistries is set");
224
350
  }
351
+ function requireAllocationRouter() {
352
+ if (sidecarAllocationRouter === undefined) {
353
+ throw new Error("Exclusive deployment routing is not configured");
354
+ }
355
+ return sidecarAllocationRouter;
356
+ }
225
357
  /**
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.
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.
238
366
  */
239
367
  async function executeLaunchPhases(params) {
240
- const { agentAddress, agentId, instanceId, config, deployContent } = params;
368
+ const { agentAddress, agentId, runId, config, deployContent } = params;
241
369
  const toolPackagePins = params.toolPackagePins ?? [];
242
370
  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
- };
371
+ let effectiveDeployContent = deployContent;
270
372
  // Phase 0a-bis: Resolve the agent's tool-package pins into a full
271
373
  // closure manifest. Empty pins skip the resolver entirely. A
272
374
  // ManifestInvalidError (e.g. unsatisfied peer dependency) is a
@@ -308,25 +410,6 @@ export function createSessionService(deps) {
308
410
  }
309
411
  const assetMounts = new Map();
310
412
  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
413
  for (const assetId of collectDistinctAssetIds(manifest)) {
331
414
  const asset = assetIndex.get(assetId);
332
415
  if (asset === undefined) {
@@ -339,7 +422,7 @@ export function createSessionService(deps) {
339
422
  }
340
423
  const mountPath = `package-registries/${asset.name}/`;
341
424
  assetMounts.set(assetId, mountPath);
342
- manifestAssetAttachments.push(await resolveDirectAssetAttachment({
425
+ manifestAssetAttachments.push(await resolveAssetAttachment({
343
426
  asset,
344
427
  mountPath,
345
428
  }));
@@ -372,192 +455,111 @@ export function createSessionService(deps) {
372
455
  // route is held only for the pack window and dropped in the `finally`.
373
456
  if (stageOnly) {
374
457
  try {
375
- sidecarRouter.bindStepRoute(agentAddress);
458
+ if (params.allocationTarget === undefined) {
459
+ sidecarRouter.bindStepRoute(agentAddress);
460
+ }
461
+ else {
462
+ await requireAllocationRouter().bindAllocatedStepRoute(params.allocationTarget, agentAddress);
463
+ }
376
464
  }
377
465
  catch (err) {
378
466
  throw new SessionLaunchError("provision", err, false);
379
467
  }
380
468
  }
381
469
  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
470
+ // Phase 1: Provision on sidecar. A stage-only per-step deploy sends a
387
471
  // no-spawn provision frame: the sidecar inits the step's agent-state
388
472
  // repo and records the hub key, but spawns nothing. Firing the frame
389
473
  // 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;
474
+ // exist before the pack applies.
393
475
  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);
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
+ }
406
483
  }
407
484
  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");
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");
414
490
  }
415
491
  }
416
492
  catch (err) {
417
493
  throw new SessionLaunchError("provision", err, false);
418
494
  }
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.
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.
427
502
  try {
428
- await sidecarRouter.sendPack(agentAddress, pack, ref, commitSha);
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
+ }
429
509
  }
430
510
  catch (err) {
431
- if (!stageOnly)
511
+ if (!stageOnly && params.allocationTarget === undefined) {
432
512
  await attemptCleanup(agentAddress, "pack", err);
433
- throw new SessionLaunchError("pack", err, false);
513
+ }
514
+ throw new SessionLaunchError("pack", err, !stageOnly && params.allocationTarget !== undefined);
434
515
  }
435
516
  // 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
517
+ // pack, reserve the manifest row, then send the pack. The manifest
518
+ // reservation MUST happen before the pack send: if the sidecar acks
438
519
  // 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`.
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.
447
523
  //
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
- });
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;
461
530
  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.
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.
469
535
  const committed = [];
470
536
  for (const att of fanOut) {
471
537
  try {
472
- await sendAttachmentPack(instanceId, agentAddress, att);
473
- committed.push(att);
538
+ const committedRecord = await sendAttachmentPack(runId, agentAddress, att, params.allocationTarget);
539
+ if (committedRecord !== null)
540
+ committed.push(committedRecord);
474
541
  }
475
542
  catch (err) {
476
- await rollbackCommittedAttachments(instanceId, committed);
477
- if (!stageOnly)
543
+ await rollbackCommittedAttachments(committed);
544
+ if (!stageOnly && params.allocationTarget === undefined) {
478
545
  await attemptCleanup(agentAddress, "pack", err);
479
- throw new SessionLaunchError("pack", err, false);
546
+ }
547
+ throw new SessionLaunchError("pack", err, !stageOnly && params.allocationTarget !== undefined);
480
548
  }
481
549
  }
482
550
  }
483
- return deployAckPublicKey === undefined
484
- ? undefined
485
- : { publicKey: deployAckPublicKey };
486
551
  }
487
552
  finally {
488
553
  if (stageOnly) {
489
- sidecarRouter.unbindStepRoute(agentAddress);
554
+ if (params.allocationTarget === undefined) {
555
+ sidecarRouter.unbindStepRoute(agentAddress);
556
+ }
557
+ else {
558
+ requireAllocationRouter().unbindAllocatedStepRoute(params.allocationTarget, agentAddress);
559
+ }
490
560
  }
491
561
  }
492
562
  }
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
563
  /**
562
564
  * Stage one step of a multi-step workflow deploy: bind a transient route
563
565
  * for the step address, fire a no-spawn provision frame (the sidecar inits
@@ -572,149 +574,356 @@ export function createSessionService(deps) {
572
574
  await executeLaunchPhases({
573
575
  agentAddress: params.agentAddress,
574
576
  agentId: params.agentId,
575
- instanceId: params.instanceId,
577
+ runId: params.runId,
576
578
  config: params.config,
577
579
  deployContent: params.deployContent,
578
580
  stageOnly: true,
579
581
  ...(params.toolPackagePins !== undefined
580
582
  ? { toolPackagePins: params.toolPackagePins }
581
583
  : {}),
584
+ ...(params.allocationTarget !== undefined
585
+ ? { allocationTarget: params.allocationTarget }
586
+ : {}),
582
587
  });
583
588
  }
584
- /**
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.
598
- */
599
- async function deployInstanceAtHead(params) {
600
- const { agentAddress, agentId, instanceId, config, deployContent } = params;
601
- const singleStepAgent = wrapHarnessAsSingleStepWorkflow({
602
- config,
603
- deployContent,
604
- });
605
- const workflow = defineWorkflow({
606
- id: `wf_${agentId}`,
607
- agent: singleStepAgent,
608
- trigger: { type: "mail", to: agentAddress },
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,
609
740
  });
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 }
645
- : {}),
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,
646
785
  });
786
+ return {
787
+ anchorRunId: params.anchorRunId,
788
+ deploymentAddress: params.agentAddress,
789
+ publicKey: result.publicKey,
790
+ };
647
791
  }
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 }
673
- : {}),
674
- },
675
- });
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) {
676
803
  if (db === undefined) {
677
- throw new Error("deployWorkflowDefinition requires a db handle to record the workflow_deployment projection row");
804
+ throw new Error("updateAnchorPublicKeyUnderAllocationLock requires a db handle");
678
805
  }
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,
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;
690
835
  });
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,
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,
706
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,
707
919
  });
708
920
  return {
709
- deploymentId,
710
- deploymentAddress: deriveDeploymentAddress({
711
- deploymentId,
712
- deploymentDomain,
713
- }),
921
+ anchorRunId: params.anchorRunId,
922
+ deploymentAddress: params.agentAddress,
714
923
  publicKey: result.publicKey,
715
924
  };
716
925
  }
717
- async function rollbackCommittedAttachments(instanceId, committed) {
926
+ async function rollbackCommittedAttachments(committed) {
718
927
  if (db === undefined)
719
928
  return;
720
929
  if (committed.length === 0)
@@ -722,96 +931,103 @@ export function createSessionService(deps) {
722
931
  // Per-row try/catch so a single rollback failure does not stop the
723
932
  // sweep — every committed row needs to come off the books before
724
933
  // the caller emits the original sendPack error.
725
- for (const att of committed) {
934
+ for (const record of committed) {
726
935
  try {
727
936
  await db
728
937
  .delete(sessionAssetTable)
729
- .where(and(eq(sessionAssetTable.instanceId, instanceId), eq(sessionAssetTable.mountPath, att.mountPath)));
938
+ .where(and(eq(sessionAssetTable.runId, record.runId), eq(sessionAssetTable.mountPath, record.mountPath), eq(sessionAssetTable.assetPackSha, record.assetPackSha), eq(sessionAssetTable.sourceCommitSha, record.sourceCommitSha)));
730
939
  }
731
940
  catch (err) {
732
- logger.warn `session_asset rollback failed for earlier-committed instance=${instanceId} mountPath=${att.mountPath}: ${err instanceof Error ? err.message : String(err)}`;
941
+ logger.warn `session_asset rollback failed for earlier-committed instance=${record.runId} mountPath=${record.mountPath}: ${err instanceof Error ? err.message : String(err)}`;
733
942
  }
734
943
  }
735
944
  }
736
- async function sendAttachmentPack(instanceId, agentAddress, attachment) {
945
+ async function sendAttachmentPack(runId, agentAddress, attachment, allocationTarget) {
737
946
  if (db === undefined) {
738
947
  // Guarded at construction; reassert defensively so the
739
948
  // narrowing is visible to readers and a future refactor cannot
740
949
  // accidentally invoke this without a db.
741
950
  throw new Error("sendAttachmentPack invoked without a db handle");
742
951
  }
743
- const { agentAssetId, source, mountPath, sourceCommitSha, repoId, pack, ref, } = attachment;
952
+ const { mountPath, sourceCommitSha, repoId, pack, ref } = attachment;
744
953
  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,
954
+ const record = {
955
+ runId,
753
956
  mountPath,
754
957
  assetPackSha,
755
958
  sourceCommitSha,
756
- source,
757
- materializedAt: new Date(),
758
- });
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
+ }
759
995
  try {
760
- await sidecarRouter.sendPack(agentAddress, pack, ref, sourceCommitSha, {
761
- mountPath,
762
- repoId,
763
- });
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
+ }
764
1003
  }
765
1004
  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.
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.
768
1009
  // The forensic value of a manifest-without-materialization row is
769
1010
  // negligible because no agent will read against it. Wrap the
770
1011
  // rollback in its own try/catch so a rollback failure (DB gone,
771
1012
  // connection killed mid-launch) is logged rather than masking the
772
1013
  // primary sendPack error — the caller needs to see the original
773
1014
  // failure, not the secondary one.
774
- try {
775
- await db
776
- .delete(sessionAssetTable)
777
- .where(and(eq(sessionAssetTable.instanceId, instanceId), eq(sessionAssetTable.mountPath, mountPath)));
778
- }
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}`;
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
+ }
784
1027
  }
785
1028
  throw err;
786
1029
  }
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
- }
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
- };
1030
+ return rollbackRecord;
815
1031
  }
816
1032
  /**
817
1033
  * Build a per-agent `ClosureResolver` from the tenant's visible
@@ -895,13 +1111,11 @@ export function createSessionService(deps) {
895
1111
  return { manifest, assetIndex };
896
1112
  }
897
1113
  /**
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.
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).
903
1117
  */
904
- async function resolveDirectAssetAttachment(args) {
1118
+ async function resolveAssetAttachment(args) {
905
1119
  const repoId = { kind: args.asset.kind, id: args.asset.id };
906
1120
  const sourceCommitSha = await agentRepoStore.repoStore.resolveRef(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
907
1121
  if (sourceCommitSha === null) {
@@ -909,10 +1123,6 @@ export function createSessionService(deps) {
909
1123
  }
910
1124
  const { pack, ref: returnedRef } = await agentRepoStore.repoStore.createPack(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
911
1125
  return {
912
- agentAssetId: null,
913
- source: "resolved",
914
- assetName: args.asset.name,
915
- assetKind: args.asset.kind,
916
1126
  mountPath: args.mountPath,
917
1127
  sourceCommitSha,
918
1128
  repoId,
@@ -920,22 +1130,6 @@ export function createSessionService(deps) {
920
1130
  ref: returnedRef,
921
1131
  };
922
1132
  }
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
1133
  async function attemptCleanup(agentAddress, failedPhase, originalErr) {
940
1134
  try {
941
1135
  await sidecarRouter.sendAgentUndeploy(agentAddress, failedPhase);
@@ -977,7 +1171,7 @@ export function createSessionService(deps) {
977
1171
  const signature = await createDetachedSignatureFromProvider(signedContent, cryptoProvider);
978
1172
  const rawMessage = assembleMessage(headers, signedContent, signature);
979
1173
  const base64 = base64Encode(rawMessage);
980
- const delivered = sidecarRouter.routeMail(agentAddress, base64);
1174
+ const delivered = sidecarRouter.routeMail(agentAddress, base64, messageId);
981
1175
  if (!delivered) {
982
1176
  throw new Error(`Failed to deliver message to ${agentAddress}: agent is unreachable`);
983
1177
  }
@@ -988,9 +1182,9 @@ export function createSessionService(deps) {
988
1182
  }
989
1183
  return {
990
1184
  stageWorkflowStep,
991
- deployInstanceAtHead,
992
- deploySingleStepAtHead,
993
- deployWorkflowDefinition,
1185
+ deployWorkflowFromSource,
1186
+ installAndApproveWorkflowSource,
1187
+ deployPreparedCodeSourcedWorkflow,
994
1188
  sendUserMessage,
995
1189
  endSession,
996
1190
  };