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