@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.
- package/README.md +3 -5
- package/dist/agent-repo.d.ts +9 -5
- package/dist/agent-repo.js +2 -2
- package/dist/agent-state-kind.js +4 -0
- package/dist/asset-service.d.ts +1 -20
- package/dist/asset-service.js +9 -91
- package/dist/committed-source-tree.d.ts +10 -0
- package/dist/committed-source-tree.js +35 -0
- package/dist/credential-push.d.ts +7 -6
- package/dist/credential-push.js +42 -18
- package/dist/event-collector-registry.d.ts +1 -1
- package/dist/event-collector-registry.js +4 -4
- package/dist/event-collector.d.ts +1 -1
- package/dist/event-collector.js +10 -2
- package/dist/hub-session-lookups.d.ts +125 -7
- package/dist/hub-session-lookups.js +539 -80
- package/dist/hub-session-orchestrator.js +14 -49
- package/dist/index.d.ts +17 -8
- package/dist/index.js +14 -6
- package/dist/repo-store/index.d.ts +1 -1
- package/dist/repo-store/store.d.ts +1 -1
- package/dist/repo-store/store.js +138 -1
- package/dist/repo-store/subscribe-kind.d.ts +6 -3
- package/dist/repo-store/subscribe-kind.js +42 -77
- package/dist/repo-store/types.d.ts +94 -6
- package/dist/session-service.d.ts +277 -96
- package/dist/session-service.js +741 -547
- package/dist/sidecar-allocation/contracts.d.ts +78 -0
- package/dist/sidecar-allocation/contracts.js +21 -0
- package/dist/sidecar-allocation/index.d.ts +4 -0
- package/dist/sidecar-allocation/index.js +3 -0
- package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
- package/dist/sidecar-allocation/placement-policy.js +21 -0
- package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
- package/dist/sidecar-allocation/plugin-registry.js +37 -0
- package/dist/sidecar-allocation/reconciler.d.ts +42 -0
- package/dist/sidecar-allocation/reconciler.js +431 -0
- package/dist/skill-kind.js +4 -0
- package/dist/substrate.d.ts +3 -3
- package/dist/substrate.js +1 -1
- package/dist/workflow-allocation-service.d.ts +58 -0
- package/dist/workflow-allocation-service.js +239 -0
- package/dist/workflow-closure-resolution.d.ts +106 -0
- package/dist/workflow-closure-resolution.js +123 -0
- package/dist/workflow-definition-ensure.d.ts +24 -0
- package/dist/workflow-definition-ensure.js +75 -0
- package/dist/workflow-dispatch-service.d.ts +40 -0
- package/dist/workflow-dispatch-service.js +146 -0
- package/dist/workflow-dispatch-settlement.d.ts +29 -0
- package/dist/workflow-dispatch-settlement.js +140 -0
- package/dist/workflow-kind.d.ts +17 -1
- package/dist/workflow-kind.js +127 -80
- package/dist/workflow-probe-gate.d.ts +214 -0
- package/dist/workflow-probe-gate.js +207 -0
- package/dist/workflow-run-kind.d.ts +128 -14
- package/dist/workflow-run-kind.js +353 -83
- package/dist/workflow-run-reader.d.ts +1 -1
- package/dist/workflow-run-reader.js +3 -7
- package/dist/workflow-run-restore.d.ts +15 -0
- package/dist/workflow-run-restore.js +26 -0
- package/dist/workflow-source-closure.d.ts +35 -0
- package/dist/workflow-source-closure.js +342 -0
- package/dist/ws/index.d.ts +3 -3
- package/dist/ws/index.js +1 -1
- package/dist/ws/sidecar-events.d.ts +100 -12
- package/dist/ws/sidecar-events.js +2 -0
- package/dist/ws/sidecar-handler.d.ts +128 -7
- package/dist/ws/sidecar-handler.js +1069 -135
- package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
- package/dist/ws/sidecar-token-authenticator.js +64 -7
- package/package.json +14 -13
- package/dist/available-skills-stanza.d.ts +0 -21
- package/dist/available-skills-stanza.js +0 -32
package/dist/session-service.js
CHANGED
|
@@ -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,
|
|
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
|
|
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 {
|
|
14
|
-
import {
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
|
33
|
-
// hub itself, and
|
|
34
|
-
// packs for delivery to a sidecar
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* `
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
227
|
-
* the deploy tree, provision the
|
|
228
|
-
* packs (Phases 0-2b). Phase 1
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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,
|
|
368
|
+
const { agentAddress, agentId, runId, config, deployContent } = params;
|
|
241
369
|
const toolPackagePins = params.toolPackagePins ?? [];
|
|
242
370
|
const stageOnly = params.stageOnly ?? false;
|
|
243
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
391
|
-
// supervisor public key to the caller.
|
|
392
|
-
let deployAckPublicKey;
|
|
474
|
+
// exist before the pack applies.
|
|
393
475
|
try {
|
|
394
|
-
if (
|
|
395
|
-
|
|
396
|
-
sidecarRouter,
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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 `
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
|
|
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.
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
437
|
-
//
|
|
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.
|
|
440
|
-
//
|
|
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
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
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
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
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(
|
|
473
|
-
|
|
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(
|
|
477
|
-
if (!stageOnly)
|
|
543
|
+
await rollbackCommittedAttachments(committed);
|
|
544
|
+
if (!stageOnly && params.allocationTarget === undefined) {
|
|
478
545
|
await attemptCleanup(agentAddress, "pack", err);
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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("
|
|
804
|
+
throw new Error("updateAnchorPublicKeyUnderAllocationLock requires a db handle");
|
|
678
805
|
}
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
await
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
710
|
-
deploymentAddress:
|
|
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(
|
|
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
|
|
934
|
+
for (const record of committed) {
|
|
726
935
|
try {
|
|
727
936
|
await db
|
|
728
937
|
.delete(sessionAssetTable)
|
|
729
|
-
.where(and(eq(sessionAssetTable.
|
|
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=${
|
|
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(
|
|
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 {
|
|
952
|
+
const { mountPath, sourceCommitSha, repoId, pack, ref } = attachment;
|
|
744
953
|
const assetPackSha = await createPackSha(pack);
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
757
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
//
|
|
767
|
-
//
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
|
899
|
-
* from
|
|
900
|
-
*
|
|
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
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1185
|
+
deployWorkflowFromSource,
|
|
1186
|
+
installAndApproveWorkflowSource,
|
|
1187
|
+
deployPreparedCodeSourcedWorkflow,
|
|
994
1188
|
sendUserMessage,
|
|
995
1189
|
endSession,
|
|
996
1190
|
};
|