@intx/hub-sessions 0.1.2 → 0.2.2
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 +84 -1
- package/dist/agent-repo.d.ts +89 -0
- package/dist/agent-repo.js +109 -0
- package/dist/agent-state-kind.d.ts +12 -0
- package/dist/agent-state-kind.js +185 -0
- package/dist/asset-service.d.ts +123 -0
- package/dist/asset-service.js +349 -0
- package/dist/available-skills-stanza.d.ts +21 -0
- package/dist/available-skills-stanza.js +32 -0
- package/dist/credential-push.d.ts +32 -0
- package/dist/credential-push.js +85 -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 +357 -0
- package/dist/hub-session-lookups.d.ts +17 -0
- package/dist/hub-session-lookups.js +204 -0
- package/dist/hub-session-orchestrator.d.ts +25 -0
- package/dist/hub-session-orchestrator.js +122 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -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 +1692 -0
- package/dist/repo-store/subscribe-kind.d.ts +53 -0
- package/dist/repo-store/subscribe-kind.js +179 -0
- package/dist/repo-store/types.d.ts +483 -0
- package/dist/repo-store/types.js +42 -0
- package/dist/session-service.d.ts +235 -0
- package/dist/session-service.js +997 -0
- package/dist/skill-kind.d.ts +41 -0
- package/dist/skill-kind.js +288 -0
- package/dist/substrate.d.ts +8 -0
- package/dist/substrate.js +21 -0
- package/dist/workflow-kind.d.ts +21 -0
- package/dist/workflow-kind.js +263 -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 +326 -0
- package/dist/workflow-run-kind.js +2646 -0
- package/dist/workflow-run-reader.d.ts +47 -0
- package/dist/workflow-run-reader.js +157 -0
- package/dist/ws/index.d.ts +3 -0
- package/dist/ws/index.js +3 -0
- package/dist/ws/sidecar-events.d.ts +134 -0
- package/dist/ws/sidecar-events.js +70 -0
- package/dist/ws/sidecar-handler.d.ts +184 -0
- package/dist/ws/sidecar-handler.js +1603 -0
- package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
- package/dist/ws/sidecar-token-authenticator.js +24 -0
- package/package.json +34 -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,997 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
3
|
+
import { createDefaultDirectorRegistry, } from "@intx/agent";
|
|
4
|
+
import { getLogger } from "@intx/log";
|
|
5
|
+
import { assembleMessage, assembleSignedContent, createDetachedSignatureFromProvider, } from "@intx/mime";
|
|
6
|
+
import { listAssetsForTenant } from "@intx/db";
|
|
7
|
+
import { grant as grantTable, workflowDeployment as workflowDeploymentTable, } from "@intx/db/schema";
|
|
8
|
+
import { base64Encode, hexEncode } from "@intx/types";
|
|
9
|
+
import { generateId } from "@intx/hub-common";
|
|
10
|
+
import { sessionAsset as sessionAssetTable, } from "@intx/db/schema";
|
|
11
|
+
import { AssetRegistrySource, HttpRegistrySource, ManifestInvalidError, createClosureResolver, } from "@intx/tool-packaging";
|
|
12
|
+
import { ToolPackageManifest, } from "@intx/types/tool-packages";
|
|
13
|
+
import { defineWorkflow, } from "@intx/workflow/definition";
|
|
14
|
+
import { createWorkflowDeployOrchestrator, deriveDeploymentAddress, walkCapabilities, wrapHarnessAsSingleStepWorkflow, } from "@intx/workflow-deploy";
|
|
15
|
+
import { DEFAULT_ASSET_REF, } from "./asset-service.js";
|
|
16
|
+
import { buildAvailableSkillsStanza, } from "./available-skills-stanza.js";
|
|
17
|
+
import { getSkillIndex } from "./skill-kind.js";
|
|
18
|
+
const logger = getLogger(["interchange", "hub", "session-service"]);
|
|
19
|
+
export class SessionLaunchError extends Error {
|
|
20
|
+
/** Which phase failed: "write", "provision", "pack", or "start". */
|
|
21
|
+
phase;
|
|
22
|
+
/** True if the sidecar has a provisioned agent that could not be cleaned up. */
|
|
23
|
+
leakedAgent;
|
|
24
|
+
constructor(phase, cause, leakedAgent) {
|
|
25
|
+
const msg = cause instanceof Error ? cause.message : "Session launch failed";
|
|
26
|
+
super(msg, { cause });
|
|
27
|
+
this.name = "SessionLaunchError";
|
|
28
|
+
this.phase = phase;
|
|
29
|
+
this.leakedAgent = leakedAgent;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Hub-side principal for reading skill repos. Skills are signed by the
|
|
33
|
+
// hub itself, and listAgentAssets is being called on the hub to assemble
|
|
34
|
+
// packs for delivery to a sidecar — so the hub principal is correct.
|
|
35
|
+
const HUB_PRINCIPAL = { kind: "hub" };
|
|
36
|
+
async function createPackSha(pack) {
|
|
37
|
+
const digest = await crypto.subtle.digest("SHA-256",
|
|
38
|
+
// 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)
|
|
39
|
+
pack);
|
|
40
|
+
return hexEncode(new Uint8Array(digest));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Walk a resolved tool-package manifest and return every distinct
|
|
44
|
+
* `assetId` referenced by a `kind: "asset"` entry. Order is the
|
|
45
|
+
* resolver's BFS order so the fan-out below is deterministic for
|
|
46
|
+
* tests; a `Set` would be wrong here because tests assert specific
|
|
47
|
+
* orderings.
|
|
48
|
+
*/
|
|
49
|
+
function collectDistinctAssetIds(manifest) {
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const entry of manifest.entries) {
|
|
53
|
+
if (entry.source.kind !== "asset")
|
|
54
|
+
continue;
|
|
55
|
+
if (seen.has(entry.source.assetId))
|
|
56
|
+
continue;
|
|
57
|
+
seen.add(entry.source.assetId);
|
|
58
|
+
out.push(entry.source.assetId);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Dedup the union of `direct` and `resolved` attachments by asset id
|
|
64
|
+
* (taken from `repoId.id`), with `direct` taking precedence whenever
|
|
65
|
+
* both name the same asset.
|
|
66
|
+
*
|
|
67
|
+
* The package-registry "both name the same asset" case is refused
|
|
68
|
+
* upstream at the resolver block (a direct attachment plus a resolver
|
|
69
|
+
* pin for the same package-registry asset would emit assetMounts at
|
|
70
|
+
* the resolver's ref while the direct attachment materializes at the
|
|
71
|
+
* operator's chosen ref, leaving the loader to resolve manifest
|
|
72
|
+
* entries against tarballs that do not exist at the materialized
|
|
73
|
+
* mount). Skill attachments cannot collide via the resolver path —
|
|
74
|
+
* the resolver only emits package-registry entries — so the dedup
|
|
75
|
+
* still has to handle skill self-collisions defensively and to fall
|
|
76
|
+
* through cleanly when both sources happen to name an asset the
|
|
77
|
+
* upstream check has not flagged.
|
|
78
|
+
*
|
|
79
|
+
* The function takes the two sources as named parameters rather than a
|
|
80
|
+
* pre-merged list so the precedence rule is structural: a future
|
|
81
|
+
* refactor cannot accidentally swap the order by re-arranging an
|
|
82
|
+
* intermediate spread.
|
|
83
|
+
*/
|
|
84
|
+
function dedupAttachmentsByAssetId(args) {
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const att of args.direct) {
|
|
88
|
+
if (seen.has(att.repoId.id))
|
|
89
|
+
continue;
|
|
90
|
+
seen.add(att.repoId.id);
|
|
91
|
+
out.push(att);
|
|
92
|
+
}
|
|
93
|
+
for (const att of args.resolved) {
|
|
94
|
+
if (seen.has(att.repoId.id))
|
|
95
|
+
continue;
|
|
96
|
+
seen.add(att.repoId.id);
|
|
97
|
+
out.push(att);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Compute the materialization path for an attachment from the asset's
|
|
103
|
+
* kind and name. v1 does not let users override the path — the path is
|
|
104
|
+
* a function of the asset, full stop. Today only `skill` has a defined
|
|
105
|
+
* mapping (`skills/<asset.name>/`); other kinds reach this code path
|
|
106
|
+
* via the `never` branch and throw, per the defensive-coding rule that
|
|
107
|
+
* we never silently invent a default for an unhandled kind.
|
|
108
|
+
*
|
|
109
|
+
* Asset names are validated lowercase-kebab at `createAsset`, which is
|
|
110
|
+
* the only entry path into this function, so the resulting path is
|
|
111
|
+
* safe under `applyAssetPack`'s per-segment validator.
|
|
112
|
+
*/
|
|
113
|
+
function resolveMountPath(row) {
|
|
114
|
+
switch (row.asset.kind) {
|
|
115
|
+
case "skill":
|
|
116
|
+
return `skills/${row.asset.name}/`;
|
|
117
|
+
case "package-registry":
|
|
118
|
+
return `package-registries/${row.asset.name}/`;
|
|
119
|
+
case "agent-state":
|
|
120
|
+
throw new Error(`mount_path_required: agent_asset row ${row.id} references agent-state asset ${row.asset.id}; agent-state attachments are not supported`);
|
|
121
|
+
case "workflow":
|
|
122
|
+
throw new Error("kind handler not yet registered: workflow");
|
|
123
|
+
case "workflow-run":
|
|
124
|
+
throw new Error("kind handler not yet registered: workflow-run");
|
|
125
|
+
default: {
|
|
126
|
+
const exhaustive = row.asset.kind;
|
|
127
|
+
throw new Error(`mount_path_required: no default mountPath for asset kind ${String(exhaustive)} on row ${row.id}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Translate the orchestrator's structural `DeployContent` (which types
|
|
133
|
+
* `toolPackageManifest` as `unknown`) back into the hub-sessions
|
|
134
|
+
* `DeployContent` shape. The orchestrator round-trips whatever the
|
|
135
|
+
* caller supplied, but the surface type widens `toolPackageManifest` to
|
|
136
|
+
* `unknown`; the validator narrows it back to the canonical shape
|
|
137
|
+
* `agentRepoStore.writeDeployTree` consumes.
|
|
138
|
+
*
|
|
139
|
+
* Exported so a test fixture that forwards orchestrator-shaped deploy
|
|
140
|
+
* content into `launchSession` narrows it the same validated way the
|
|
141
|
+
* production multi-step callback does, rather than casting `unknown`.
|
|
142
|
+
*/
|
|
143
|
+
export function bridgeOrchestratorDeployContent(content) {
|
|
144
|
+
const bridged = { systemPrompt: content.systemPrompt };
|
|
145
|
+
if (content.toolPackageManifest !== undefined) {
|
|
146
|
+
const validated = ToolPackageManifest(content.toolPackageManifest);
|
|
147
|
+
if (validated instanceof type.errors) {
|
|
148
|
+
throw new Error(`orchestrator deploy content carries an invalid toolPackageManifest: ${validated.summary}`);
|
|
149
|
+
}
|
|
150
|
+
bridged.toolPackageManifest = validated;
|
|
151
|
+
}
|
|
152
|
+
if (content.assetMounts !== undefined) {
|
|
153
|
+
bridged.assetMounts = content.assetMounts;
|
|
154
|
+
}
|
|
155
|
+
return bridged;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Wire the workflow-deploy orchestrator's `sendMultiStepDeploy`
|
|
159
|
+
* dependency against `SidecarRouter.sendAgentDeploy`. The router
|
|
160
|
+
* accepts an optional `workflow` projection on the deploy frame; the
|
|
161
|
+
* sidecar's deploy router uses field presence to route the frame to
|
|
162
|
+
* the workflow deploy path. The supervisor public key returned by the
|
|
163
|
+
* sidecar's `agent.deploy.ack` is threaded back as the
|
|
164
|
+
* `MultiStepDeployResult.publicKey`.
|
|
165
|
+
*
|
|
166
|
+
* Exported so the co-located caller-site test can assert that the
|
|
167
|
+
* closure constructed in `launchSession` reaches the wire surface via
|
|
168
|
+
* `sendAgentDeploy` with a `workflow` field structurally matching the
|
|
169
|
+
* `AgentDeployFrame.workflow` schema.
|
|
170
|
+
*/
|
|
171
|
+
export async function sendMultiStepDeployFrame(args) {
|
|
172
|
+
// The wire validator's projection types `stepOrder` and `triggers`
|
|
173
|
+
// as mutable arrays while `WorkflowDefinition` declares them as
|
|
174
|
+
// `readonly`. The wire serializer never mutates the arrays; the
|
|
175
|
+
// shallow copies pay the readonly-widen at the boundary. Every
|
|
176
|
+
// field listed here must match the structural envelope the
|
|
177
|
+
// workflow-process child re-validates against on materialization
|
|
178
|
+
// (`workflowDefinitionEnvelopeSchema`): `id`, `triggers`, `steps`,
|
|
179
|
+
// `stepOrder`, optional `state`. The sidecar deploy router
|
|
180
|
+
// serializes this object verbatim into `workflow.json`; a missing
|
|
181
|
+
// envelope-required field here would round-trip into the child's
|
|
182
|
+
// envelope rejection on disk.
|
|
183
|
+
const wireDefinition = {
|
|
184
|
+
id: args.definition.id,
|
|
185
|
+
triggers: [...args.definition.triggers],
|
|
186
|
+
stepOrder: [...args.definition.stepOrder],
|
|
187
|
+
steps: args.definition.steps,
|
|
188
|
+
...(args.definition.state !== undefined
|
|
189
|
+
? { state: args.definition.state }
|
|
190
|
+
: {}),
|
|
191
|
+
};
|
|
192
|
+
return args.sidecarRouter.sendAgentDeploy(args.agentAddress, args.config, {
|
|
193
|
+
definition: wireDefinition,
|
|
194
|
+
sources: args.sources,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* `WorkflowRepoWriter` backed by the hub's repo substrate. Writes the
|
|
199
|
+
* orchestrator-produced workflow tree (`workflow.json`,
|
|
200
|
+
* `capability-declarations.json`, `.gitignore`) into a `workflow`-kind
|
|
201
|
+
* repo keyed by the workflow definition id, committing on the published
|
|
202
|
+
* asset ref. The hub principal is the only writer of the workflow repo,
|
|
203
|
+
* matching `workflowAuthorize`'s hub-writes / sidecar-reads split.
|
|
204
|
+
*/
|
|
205
|
+
function createHubWorkflowRepoWriter(agentRepoStore) {
|
|
206
|
+
return {
|
|
207
|
+
async writeWorkflowRepo(args) {
|
|
208
|
+
const repoId = { kind: "workflow", id: args.workflowRepoId };
|
|
209
|
+
const files = {};
|
|
210
|
+
for (const [path, contents] of args.files) {
|
|
211
|
+
files[path] = contents;
|
|
212
|
+
}
|
|
213
|
+
await agentRepoStore.repoStore.writeTree(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF, { files, message: "Write workflow deploy tree" });
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
export function createSessionService(deps) {
|
|
218
|
+
const { sidecarRouter, agentRepoStore, assetService, db, toolPackageRegistries, } = deps;
|
|
219
|
+
if (assetService !== undefined && db === undefined) {
|
|
220
|
+
throw new Error("createSessionService: db is required when assetService is set");
|
|
221
|
+
}
|
|
222
|
+
if (toolPackageRegistries !== undefined && db === undefined) {
|
|
223
|
+
throw new Error("createSessionService: db is required when toolPackageRegistries is set");
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Stage a deploy on the sidecar: resolve assets and tool packages, write
|
|
227
|
+
* the deploy tree, provision the agent, and deliver the deploy + asset
|
|
228
|
+
* packs (Phases 0-2b). Phase 1's provision has two shapes:
|
|
229
|
+
* - `workflowFrame` set: the single-step head hand-off fires the
|
|
230
|
+
* deployment `agent.deploy` frame that spawns the workflow-process
|
|
231
|
+
* child. Returns the supervisor public key.
|
|
232
|
+
* - `stageOnly` set: a multi-step per-step stage binds a transient route
|
|
233
|
+
* for the step address, fires a no-spawn provision frame (init repo +
|
|
234
|
+
* record hub key), and unbinds the route once the packs land. No
|
|
235
|
+
* child.
|
|
236
|
+
* A call with neither is rejected -- the legacy warm-harness path
|
|
237
|
+
* is gone.
|
|
238
|
+
*/
|
|
239
|
+
async function executeLaunchPhases(params) {
|
|
240
|
+
const { agentAddress, agentId, instanceId, config, deployContent } = params;
|
|
241
|
+
const toolPackagePins = params.toolPackagePins ?? [];
|
|
242
|
+
const stageOnly = params.stageOnly ?? false;
|
|
243
|
+
if (params.workflowFrame !== undefined && stageOnly) {
|
|
244
|
+
throw new Error("executeLaunchPhases: workflowFrame and stageOnly are mutually exclusive");
|
|
245
|
+
}
|
|
246
|
+
const workflowFrame = params.workflowFrame;
|
|
247
|
+
// Phase 0: Resolve attached assets first so the skill index is in
|
|
248
|
+
// hand before the deploy tree is written. The `<available_skills>`
|
|
249
|
+
// stanza describing every attached skill must land in
|
|
250
|
+
// `deploy/prompt.md`, so it has to be composed before
|
|
251
|
+
// `writeDeployTree` produces the on-disk tree.
|
|
252
|
+
let attachments = [];
|
|
253
|
+
let availableSkills = [];
|
|
254
|
+
if (assetService !== undefined) {
|
|
255
|
+
try {
|
|
256
|
+
attachments = await resolveAttachments(assetService, agentId);
|
|
257
|
+
availableSkills = collectAvailableSkills(attachments);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
throw new SessionLaunchError("write", err, false);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const stanza = buildAvailableSkillsStanza(availableSkills);
|
|
264
|
+
let effectiveDeployContent = stanza.length === 0
|
|
265
|
+
? deployContent
|
|
266
|
+
: {
|
|
267
|
+
...deployContent,
|
|
268
|
+
systemPrompt: `${deployContent.systemPrompt}\n\n${stanza}\n`,
|
|
269
|
+
};
|
|
270
|
+
// Phase 0a-bis: Resolve the agent's tool-package pins into a full
|
|
271
|
+
// closure manifest. Empty pins skip the resolver entirely. A
|
|
272
|
+
// ManifestInvalidError (e.g. unsatisfied peer dependency) is a
|
|
273
|
+
// launch-time failure — the deploy never ships and the sidecar
|
|
274
|
+
// is not touched.
|
|
275
|
+
//
|
|
276
|
+
// The resolver runs once per launch with no cross-launch caching;
|
|
277
|
+
// the packument cache scopes only within a single closure walk.
|
|
278
|
+
// Acceptable at the current N (handful of agents, small pin sets
|
|
279
|
+
// per agent) — a tenant-scoped packument cache or a per-pin set
|
|
280
|
+
// resolved-manifest cache would be the obvious scaling lever
|
|
281
|
+
// when launch latency becomes the bottleneck.
|
|
282
|
+
const manifestAssetAttachments = [];
|
|
283
|
+
if (toolPackagePins.length > 0) {
|
|
284
|
+
if (toolPackageRegistries === undefined) {
|
|
285
|
+
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);
|
|
286
|
+
}
|
|
287
|
+
if (assetService === undefined) {
|
|
288
|
+
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);
|
|
289
|
+
}
|
|
290
|
+
let manifest;
|
|
291
|
+
let assetIndex;
|
|
292
|
+
try {
|
|
293
|
+
const built = await buildAndResolve({
|
|
294
|
+
agentId,
|
|
295
|
+
tenantId: config.tenantId,
|
|
296
|
+
pins: toolPackagePins,
|
|
297
|
+
registries: toolPackageRegistries,
|
|
298
|
+
assetService,
|
|
299
|
+
});
|
|
300
|
+
manifest = built.manifest;
|
|
301
|
+
assetIndex = built.assetIndex;
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
if (err instanceof ManifestInvalidError) {
|
|
305
|
+
logger.warn `tool-package manifest validation failed for agent ${agentId}: ${err.message}`;
|
|
306
|
+
}
|
|
307
|
+
throw new SessionLaunchError("write", err, false);
|
|
308
|
+
}
|
|
309
|
+
const assetMounts = new Map();
|
|
310
|
+
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
|
+
for (const assetId of collectDistinctAssetIds(manifest)) {
|
|
331
|
+
const asset = assetIndex.get(assetId);
|
|
332
|
+
if (asset === undefined) {
|
|
333
|
+
// The asset id appears in the manifest but is not in the
|
|
334
|
+
// tenant-visible asset set. This can only happen if the
|
|
335
|
+
// resolver's registry map and the asset index disagree —
|
|
336
|
+
// the same scan populated both, so reaching this branch
|
|
337
|
+
// would indicate an upstream invariant violation.
|
|
338
|
+
throw new Error(`resolved tool-package manifest references asset ${assetId} which is not visible to tenant ${config.tenantId}`);
|
|
339
|
+
}
|
|
340
|
+
const mountPath = `package-registries/${asset.name}/`;
|
|
341
|
+
assetMounts.set(assetId, mountPath);
|
|
342
|
+
manifestAssetAttachments.push(await resolveDirectAssetAttachment({
|
|
343
|
+
asset,
|
|
344
|
+
mountPath,
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
throw new SessionLaunchError("write", err, false);
|
|
350
|
+
}
|
|
351
|
+
effectiveDeployContent = {
|
|
352
|
+
...effectiveDeployContent,
|
|
353
|
+
toolPackageManifest: manifest,
|
|
354
|
+
...(assetMounts.size > 0 ? { assetMounts } : {}),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// Phase 0b: Write deploy tree and produce packfile (hub-local, no
|
|
358
|
+
// sidecar state to clean up if this fails).
|
|
359
|
+
let pack;
|
|
360
|
+
let commitSha;
|
|
361
|
+
let ref;
|
|
362
|
+
try {
|
|
363
|
+
await agentRepoStore.writeDeployTree(agentId, effectiveDeployContent);
|
|
364
|
+
({ pack, commitSha, ref } =
|
|
365
|
+
await agentRepoStore.createDeployPack(agentId));
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
throw new SessionLaunchError("write", err, false);
|
|
369
|
+
}
|
|
370
|
+
// A stage-only per-step deploy binds a transient route for the step
|
|
371
|
+
// address so the packs below route to the deployment's sidecar; the
|
|
372
|
+
// route is held only for the pack window and dropped in the `finally`.
|
|
373
|
+
if (stageOnly) {
|
|
374
|
+
try {
|
|
375
|
+
sidecarRouter.bindStepRoute(agentAddress);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
throw new SessionLaunchError("provision", err, false);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
// Phase 1: Provision on sidecar. A single-step workflow deploy sends
|
|
383
|
+
// the deployment `agent.deploy` frame carrying the workflow definition
|
|
384
|
+
// + source pins: the sidecar's deploy router initializes the head repo
|
|
385
|
+
// on receipt (so the Phase 2 pack has a repo to apply into) and spawns
|
|
386
|
+
// the workflow-process child. A stage-only per-step deploy sends a
|
|
387
|
+
// no-spawn provision frame: the sidecar inits the step's agent-state
|
|
388
|
+
// repo and records the hub key, but spawns nothing. Firing the frame
|
|
389
|
+
// before the Phase 2 pack is the ordering barrier -- the repo must
|
|
390
|
+
// exist before the pack applies. A workflow frame's ack surfaces the
|
|
391
|
+
// supervisor public key to the caller.
|
|
392
|
+
let deployAckPublicKey;
|
|
393
|
+
try {
|
|
394
|
+
if (workflowFrame !== undefined) {
|
|
395
|
+
const ack = await sendMultiStepDeployFrame({
|
|
396
|
+
sidecarRouter,
|
|
397
|
+
agentAddress,
|
|
398
|
+
config,
|
|
399
|
+
definition: workflowFrame.definition,
|
|
400
|
+
sources: workflowFrame.sources,
|
|
401
|
+
});
|
|
402
|
+
deployAckPublicKey = ack.publicKey;
|
|
403
|
+
}
|
|
404
|
+
else if (stageOnly) {
|
|
405
|
+
await sidecarRouter.sendProvisionStep(agentAddress, config);
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
// Every caller supplies `workflowFrame` (single-step head) or
|
|
409
|
+
// `stageOnly` (multi-step per-step). A deploy with neither has no
|
|
410
|
+
// provisioning shape -- the legacy warm-harness path is gone -- so
|
|
411
|
+
// fail loud rather than ship a deploy pack the sidecar never
|
|
412
|
+
// provisioned a repo for.
|
|
413
|
+
throw new Error("executeLaunchPhases: a deploy requires either workflowFrame or stageOnly");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
throw new SessionLaunchError("provision", err, false);
|
|
418
|
+
}
|
|
419
|
+
// Phase 2: Pack delivery. On failure, the warm/workflow paths tear the
|
|
420
|
+
// sidecar deployment down; a stage-only step has no supervisor to
|
|
421
|
+
// undeploy, so it only drops its transient route (in the `finally`).
|
|
422
|
+
// The step's inited agent-state repo is left on the sidecar: the
|
|
423
|
+
// orchestrator aborts the whole deploy before the deployment frame is
|
|
424
|
+
// sent, so there is nothing to undeploy, and a redeploy of the same
|
|
425
|
+
// deployment overwrites the orphaned repo. This is an acceptable minor
|
|
426
|
+
// leak on the exceptional staging-failure path, not a live-path cost.
|
|
427
|
+
try {
|
|
428
|
+
await sidecarRouter.sendPack(agentAddress, pack, ref, commitSha);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
if (!stageOnly)
|
|
432
|
+
await attemptCleanup(agentAddress, "pack", err);
|
|
433
|
+
throw new SessionLaunchError("pack", err, false);
|
|
434
|
+
}
|
|
435
|
+
// Phase 2b: Asset-pack fan-out. For each attached asset, build a
|
|
436
|
+
// pack, insert the manifest row, then send the pack. The manifest
|
|
437
|
+
// insert MUST happen before the pack send: if the sidecar acks
|
|
438
|
+
// but the row is missing, the session has materialization without
|
|
439
|
+
// a recorded manifest. If the row insert fails, the pack send
|
|
440
|
+
// must not happen.
|
|
441
|
+
//
|
|
442
|
+
// The fan-out covers two sources: the agent's direct attachments
|
|
443
|
+
// (skills, today) and the package-registry assets the tool-package
|
|
444
|
+
// resolver picked from. The latter live behind tenant inheritance
|
|
445
|
+
// rather than a per-agent attachment row, so the session service
|
|
446
|
+
// synthesizes the attachment view in `manifestAssetAttachments`.
|
|
447
|
+
//
|
|
448
|
+
// Both sources can name the same `package-registry` asset — a
|
|
449
|
+
// direct attachment and a resolver pin would each compute
|
|
450
|
+
// `mountPath = "package-registries/<asset.name>/"` and collide on
|
|
451
|
+
// the `(instanceId, mountPath)` PK in `session_asset`. Dedup by
|
|
452
|
+
// asset id BEFORE the inserts and let the direct attachment win:
|
|
453
|
+
// it is an explicit operator action and carries an `agentAssetId`
|
|
454
|
+
// the audit query joins against. The resolver-derived row would
|
|
455
|
+
// produce the same materialized contents, so dropping it is
|
|
456
|
+
// semantically lossless.
|
|
457
|
+
const fanOut = dedupAttachmentsByAssetId({
|
|
458
|
+
direct: attachments,
|
|
459
|
+
resolved: manifestAssetAttachments,
|
|
460
|
+
});
|
|
461
|
+
if (assetService !== undefined && fanOut.length > 0) {
|
|
462
|
+
// Track every successfully committed attachment so a later
|
|
463
|
+
// fan-out failure can roll back the earlier rows in lockstep
|
|
464
|
+
// with the sidecar undeploy. Without this, fan-out[0] succeeds,
|
|
465
|
+
// fan-out[1] fails, attemptCleanup tears down the sidecar — but
|
|
466
|
+
// fan-out[0]'s session_asset row survives and a future
|
|
467
|
+
// materialization query reads a manifest the sidecar no longer
|
|
468
|
+
// honors.
|
|
469
|
+
const committed = [];
|
|
470
|
+
for (const att of fanOut) {
|
|
471
|
+
try {
|
|
472
|
+
await sendAttachmentPack(instanceId, agentAddress, att);
|
|
473
|
+
committed.push(att);
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
await rollbackCommittedAttachments(instanceId, committed);
|
|
477
|
+
if (!stageOnly)
|
|
478
|
+
await attemptCleanup(agentAddress, "pack", err);
|
|
479
|
+
throw new SessionLaunchError("pack", err, false);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return deployAckPublicKey === undefined
|
|
484
|
+
? undefined
|
|
485
|
+
: { publicKey: deployAckPublicKey };
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
if (stageOnly) {
|
|
489
|
+
sidecarRouter.unbindStepRoute(agentAddress);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
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
|
+
/**
|
|
562
|
+
* Stage one step of a multi-step workflow deploy: bind a transient route
|
|
563
|
+
* for the step address, fire a no-spawn provision frame (the sidecar inits
|
|
564
|
+
* the step's agent-state repo and records the hub key), deliver the deploy
|
|
565
|
+
* and asset packs, and unbind the route -- no warm harness. The multi-step
|
|
566
|
+
* branch stages every step this way, then fires ONE deployment-level
|
|
567
|
+
* workflow frame that writes the step grants and spawns the supervised
|
|
568
|
+
* workflow-process child; the child reads each step's staged deploy tree
|
|
569
|
+
* from disk and runs the step itself.
|
|
570
|
+
*/
|
|
571
|
+
async function stageWorkflowStep(params) {
|
|
572
|
+
await executeLaunchPhases({
|
|
573
|
+
agentAddress: params.agentAddress,
|
|
574
|
+
agentId: params.agentId,
|
|
575
|
+
instanceId: params.instanceId,
|
|
576
|
+
config: params.config,
|
|
577
|
+
deployContent: params.deployContent,
|
|
578
|
+
stageOnly: true,
|
|
579
|
+
...(params.toolPackagePins !== undefined
|
|
580
|
+
? { toolPackagePins: params.toolPackagePins }
|
|
581
|
+
: {}),
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Deploy a single-agent instance through the single-step-at-head path: wrap
|
|
586
|
+
* the harness as a one-step workflow (the same wrap `launchSession` uses) and
|
|
587
|
+
* route it through `deploySingleStepAtHead` with the instance's REAL identity
|
|
588
|
+
* -- so the head address IS the instance address and the deploy runs as a
|
|
589
|
+
* supervised workflow-process child.
|
|
590
|
+
*
|
|
591
|
+
* Unlike the orchestrator's `runSingleStepAtHead`, this calls
|
|
592
|
+
* `deploySingleStepAtHead` directly with the route's real `agentId`
|
|
593
|
+
* (`row.id`), NOT a `deriveDeploymentAgentId(deploymentId)` -- the child
|
|
594
|
+
* resolves skills, deploy tree, and tool-package pins by `agentId`, so
|
|
595
|
+
* collapsing it to the deployment id would strip the instance's attachments.
|
|
596
|
+
* It writes no `workflow_deployment` row (a plain instance has no workflow
|
|
597
|
+
* asset). Returns the head's agent-key ack.
|
|
598
|
+
*/
|
|
599
|
+
async function deployInstanceAtHead(params) {
|
|
600
|
+
const { agentAddress, agentId, instanceId, config, deployContent } = params;
|
|
601
|
+
const singleStepAgent = wrapHarnessAsSingleStepWorkflow({
|
|
602
|
+
config,
|
|
603
|
+
deployContent,
|
|
604
|
+
});
|
|
605
|
+
const workflow = defineWorkflow({
|
|
606
|
+
id: `wf_${agentId}`,
|
|
607
|
+
agent: singleStepAgent,
|
|
608
|
+
trigger: { type: "mail", to: agentAddress },
|
|
609
|
+
});
|
|
610
|
+
// The sole step's id, read off the built definition.
|
|
611
|
+
const stepId = workflow.stepOrder[0];
|
|
612
|
+
if (stepId === undefined) {
|
|
613
|
+
throw new Error(`instance deploy for ${agentAddress}: the wrapped single-step workflow has an empty stepOrder`);
|
|
614
|
+
}
|
|
615
|
+
// Pin the step's inference sources to the instance's FULL ordered source
|
|
616
|
+
// chain so the workflow-process child's reactor fails over across it at
|
|
617
|
+
// runtime. The route already resolved and authorized `config.sources`
|
|
618
|
+
// against the tenant catalog, so it is pinned directly rather than re-run
|
|
619
|
+
// through the orchestrator's operator-approval gate.
|
|
620
|
+
//
|
|
621
|
+
// Fail loud on the invariant the reactor depends on: the reactor resolves
|
|
622
|
+
// its initial source by id (`defaultSource`) and fails over FORWARD-ONLY
|
|
623
|
+
// with no wrap, so the default must be element 0 or part of the chain is
|
|
624
|
+
// unreachable -- and if the default were last, failover would silently
|
|
625
|
+
// no-op. The route guarantees `config.sources[0].id === config.defaultSource`
|
|
626
|
+
// (head = active); assert it here so a future reordering fails loudly
|
|
627
|
+
// rather than silently disabling failover.
|
|
628
|
+
if (config.sources.length === 0) {
|
|
629
|
+
throw new Error(`instance deploy for ${agentAddress}: config.sources is empty; at least the default source is required`);
|
|
630
|
+
}
|
|
631
|
+
if (config.sources[0]?.id !== config.defaultSource) {
|
|
632
|
+
throw new Error(`instance deploy for ${agentAddress}: config.sources[0] (${JSON.stringify(config.sources[0]?.id)}) must be the default source ${JSON.stringify(config.defaultSource)}; the reactor fails over forward from the default and would otherwise skip the head`);
|
|
633
|
+
}
|
|
634
|
+
return deploySingleStepAtHead({
|
|
635
|
+
agentAddress,
|
|
636
|
+
agentId,
|
|
637
|
+
instanceId,
|
|
638
|
+
config,
|
|
639
|
+
deployContent,
|
|
640
|
+
definition: workflow,
|
|
641
|
+
sources: { [stepId]: config.sources },
|
|
642
|
+
hubPublicKey: hexEncode(agentRepoStore.getSigningPublicKey()),
|
|
643
|
+
...(params.toolPackagePins !== undefined
|
|
644
|
+
? { toolPackagePins: params.toolPackagePins }
|
|
645
|
+
: {}),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
async function deployWorkflowDefinition(params) {
|
|
649
|
+
const { tenantId, deploymentId, deploymentDomain, definition, definitionAssetId, config, deployContent, } = params;
|
|
650
|
+
// The deploy is initiated by an authorized tenant operator against a
|
|
651
|
+
// workflow asset they authored; approve exactly the grant surface the
|
|
652
|
+
// definition declares. The same director registry feeds both this
|
|
653
|
+
// approval-set derivation and the orchestrator's gate so the walk the
|
|
654
|
+
// route approves and the walk the orchestrator enforces are identical.
|
|
655
|
+
const directorRegistry = createDefaultDirectorRegistry();
|
|
656
|
+
const walk = walkCapabilities(definition, directorRegistry);
|
|
657
|
+
const operatorApprovals = new Set([...walk.perStep.values()].flatMap((declarations) => [
|
|
658
|
+
...declarations.grants,
|
|
659
|
+
]));
|
|
660
|
+
const result = await runWorkflowDeploy({
|
|
661
|
+
workflowRepo: createHubWorkflowRepoWriter(agentRepoStore),
|
|
662
|
+
directorRegistry,
|
|
663
|
+
deployArgs: {
|
|
664
|
+
workflow: definition,
|
|
665
|
+
deploymentId,
|
|
666
|
+
deploymentDomain,
|
|
667
|
+
config,
|
|
668
|
+
deployContent,
|
|
669
|
+
operatorApprovals,
|
|
670
|
+
hubPublicKey: hexEncode(agentRepoStore.getSigningPublicKey()),
|
|
671
|
+
...(params.toolPackagePins !== undefined
|
|
672
|
+
? { toolPackagePins: params.toolPackagePins }
|
|
673
|
+
: {}),
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
if (db === undefined) {
|
|
677
|
+
throw new Error("deployWorkflowDefinition requires a db handle to record the workflow_deployment projection row");
|
|
678
|
+
}
|
|
679
|
+
const now = new Date();
|
|
680
|
+
await db.transaction(async (tx) => {
|
|
681
|
+
await tx.insert(workflowDeploymentTable).values({
|
|
682
|
+
id: deploymentId,
|
|
683
|
+
tenantId,
|
|
684
|
+
definitionAssetId,
|
|
685
|
+
address: deriveDeploymentAddress({ deploymentId, deploymentDomain }),
|
|
686
|
+
// publicKey is left null here; the sidecar's deploy-ack persists the
|
|
687
|
+
// deployment's minted key once the child has spawned.
|
|
688
|
+
status: "deployed",
|
|
689
|
+
createdAt: now,
|
|
690
|
+
});
|
|
691
|
+
// Seed a read grant on the deployment's workflow-run resource for the
|
|
692
|
+
// deploying principal so they can observe run events out of the box,
|
|
693
|
+
// mirroring the per-instance agent-state read grant the agent deploy
|
|
694
|
+
// path seeds for the creator. Without this a non-owner deployer would
|
|
695
|
+
// deploy a workflow they cannot read the runs of.
|
|
696
|
+
await tx.insert(grantTable).values({
|
|
697
|
+
id: generateId("grant"),
|
|
698
|
+
tenantId,
|
|
699
|
+
principalId: config.principalId,
|
|
700
|
+
resource: `workflow-run:${deploymentId}`,
|
|
701
|
+
action: "read",
|
|
702
|
+
effect: "allow",
|
|
703
|
+
origin: "creator",
|
|
704
|
+
createdAt: now,
|
|
705
|
+
updatedAt: now,
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
return {
|
|
709
|
+
deploymentId,
|
|
710
|
+
deploymentAddress: deriveDeploymentAddress({
|
|
711
|
+
deploymentId,
|
|
712
|
+
deploymentDomain,
|
|
713
|
+
}),
|
|
714
|
+
publicKey: result.publicKey,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
async function rollbackCommittedAttachments(instanceId, committed) {
|
|
718
|
+
if (db === undefined)
|
|
719
|
+
return;
|
|
720
|
+
if (committed.length === 0)
|
|
721
|
+
return;
|
|
722
|
+
// Per-row try/catch so a single rollback failure does not stop the
|
|
723
|
+
// sweep — every committed row needs to come off the books before
|
|
724
|
+
// the caller emits the original sendPack error.
|
|
725
|
+
for (const att of committed) {
|
|
726
|
+
try {
|
|
727
|
+
await db
|
|
728
|
+
.delete(sessionAssetTable)
|
|
729
|
+
.where(and(eq(sessionAssetTable.instanceId, instanceId), eq(sessionAssetTable.mountPath, att.mountPath)));
|
|
730
|
+
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
logger.warn `session_asset rollback failed for earlier-committed instance=${instanceId} mountPath=${att.mountPath}: ${err instanceof Error ? err.message : String(err)}`;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async function sendAttachmentPack(instanceId, agentAddress, attachment) {
|
|
737
|
+
if (db === undefined) {
|
|
738
|
+
// Guarded at construction; reassert defensively so the
|
|
739
|
+
// narrowing is visible to readers and a future refactor cannot
|
|
740
|
+
// accidentally invoke this without a db.
|
|
741
|
+
throw new Error("sendAttachmentPack invoked without a db handle");
|
|
742
|
+
}
|
|
743
|
+
const { agentAssetId, source, mountPath, sourceCommitSha, repoId, pack, ref, } = attachment;
|
|
744
|
+
const assetPackSha = await createPackSha(pack);
|
|
745
|
+
// Insert manifest row before the pack send so we never end up in
|
|
746
|
+
// the materialized-without-manifest state. Both direct and
|
|
747
|
+
// resolver-derived materializations write a row; the `source`
|
|
748
|
+
// column records which path produced it, and `agentAssetId` is
|
|
749
|
+
// null for resolver-derived rows.
|
|
750
|
+
await db.insert(sessionAssetTable).values({
|
|
751
|
+
instanceId,
|
|
752
|
+
agentAssetId,
|
|
753
|
+
mountPath,
|
|
754
|
+
assetPackSha,
|
|
755
|
+
sourceCommitSha,
|
|
756
|
+
source,
|
|
757
|
+
materializedAt: new Date(),
|
|
758
|
+
});
|
|
759
|
+
try {
|
|
760
|
+
await sidecarRouter.sendPack(agentAddress, pack, ref, sourceCommitSha, {
|
|
761
|
+
mountPath,
|
|
762
|
+
repoId,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
// Roll back the manifest row when the send fails so the manifest
|
|
767
|
+
// and the materialized state on the sidecar can never disagree.
|
|
768
|
+
// The forensic value of a manifest-without-materialization row is
|
|
769
|
+
// negligible because no agent will read against it. Wrap the
|
|
770
|
+
// rollback in its own try/catch so a rollback failure (DB gone,
|
|
771
|
+
// connection killed mid-launch) is logged rather than masking the
|
|
772
|
+
// primary sendPack error — the caller needs to see the original
|
|
773
|
+
// failure, not the secondary one.
|
|
774
|
+
try {
|
|
775
|
+
await db
|
|
776
|
+
.delete(sessionAssetTable)
|
|
777
|
+
.where(and(eq(sessionAssetTable.instanceId, instanceId), eq(sessionAssetTable.mountPath, mountPath)));
|
|
778
|
+
}
|
|
779
|
+
catch (rollbackErr) {
|
|
780
|
+
const msg = rollbackErr instanceof Error
|
|
781
|
+
? rollbackErr.message
|
|
782
|
+
: String(rollbackErr);
|
|
783
|
+
logger.warn `session_asset rollback failed for instance=${instanceId} mountPath=${mountPath}: ${msg}`;
|
|
784
|
+
}
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
async function resolveAttachments(service, agentId) {
|
|
789
|
+
const rows = await service.listAgentAssets(agentId);
|
|
790
|
+
const resolved = [];
|
|
791
|
+
for (const row of rows) {
|
|
792
|
+
resolved.push(await resolveAttachment(row));
|
|
793
|
+
}
|
|
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
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Build a per-agent `ClosureResolver` from the tenant's visible
|
|
818
|
+
* package-registry assets plus the statically-configured HTTP
|
|
819
|
+
* registries, then run the closure resolution against `pins`.
|
|
820
|
+
*
|
|
821
|
+
* Returns the resolved manifest and an asset-id-keyed index of the
|
|
822
|
+
* package-registry assets the resolver knew about, so the caller can
|
|
823
|
+
* derive mount paths from the asset name without a second DB hit.
|
|
824
|
+
*/
|
|
825
|
+
async function buildAndResolve(args) {
|
|
826
|
+
if (db === undefined) {
|
|
827
|
+
// Guarded at construction; restate for the narrowing.
|
|
828
|
+
throw new Error("buildAndResolve invoked without a db handle");
|
|
829
|
+
}
|
|
830
|
+
const visibleAssets = await listAssetsForTenant(db, args.tenantId, "package-registry");
|
|
831
|
+
const registryMap = new Map();
|
|
832
|
+
// `assetIndex` carries only the assets the resolver might have
|
|
833
|
+
// read from — i.e. one row per registry name, the one that won
|
|
834
|
+
// its `(kind, name)` slot. Shadowed assets that lost the
|
|
835
|
+
// collision are deliberately excluded: the resolver can never
|
|
836
|
+
// reach them, so the fan-out path must never see them in the
|
|
837
|
+
// index either. The walker walks leaf-to-root inside
|
|
838
|
+
// `listAssetsForTenant`, so the first occurrence of any
|
|
839
|
+
// `(kind, name)` wins — we replay the same shadowing here.
|
|
840
|
+
// Shadowed assets — those that lose the `(kind, name)` collision
|
|
841
|
+
// contest at a lower tenancy level — are dropped entirely from
|
|
842
|
+
// the per-launch registry map. They never appear in `assetIndex`
|
|
843
|
+
// either, so the fan-out that translates `kind: "asset"` manifest
|
|
844
|
+
// entries back to asset rows cannot reach them. This matches the
|
|
845
|
+
// resolver's view: a closure built from this map sees exactly
|
|
846
|
+
// the assets the resolver would have read from, and shadowed
|
|
847
|
+
// tarballs are invisible to both layers.
|
|
848
|
+
const assetIndex = new Map();
|
|
849
|
+
for (const row of visibleAssets) {
|
|
850
|
+
if (registryMap.has(row.name))
|
|
851
|
+
continue;
|
|
852
|
+
const asset = {
|
|
853
|
+
id: row.id,
|
|
854
|
+
tenantId: row.tenantId,
|
|
855
|
+
kind: "package-registry",
|
|
856
|
+
name: row.name,
|
|
857
|
+
displayName: row.displayName,
|
|
858
|
+
creatorPrincipalId: row.creatorPrincipalId,
|
|
859
|
+
createdAt: row.createdAt,
|
|
860
|
+
updatedAt: row.updatedAt,
|
|
861
|
+
};
|
|
862
|
+
assetIndex.set(asset.id, asset);
|
|
863
|
+
registryMap.set(asset.name, new AssetRegistrySource({
|
|
864
|
+
name: asset.name,
|
|
865
|
+
assetId: asset.id,
|
|
866
|
+
readBlob: (path) => args.assetService.readAssetBlob({
|
|
867
|
+
assetId: asset.id,
|
|
868
|
+
path,
|
|
869
|
+
}),
|
|
870
|
+
listBlobs: (dir) => args.assetService.listAssetBlobs({
|
|
871
|
+
assetId: asset.id,
|
|
872
|
+
dir,
|
|
873
|
+
}),
|
|
874
|
+
}));
|
|
875
|
+
}
|
|
876
|
+
for (const [name, cfg] of args.registries.httpRegistries) {
|
|
877
|
+
// Asset wins on collision with an HTTP registry of the same
|
|
878
|
+
// name; symmetric with the inner-shadows-outer rule that
|
|
879
|
+
// governs the tenant walker.
|
|
880
|
+
if (registryMap.has(name))
|
|
881
|
+
continue;
|
|
882
|
+
registryMap.set(name, new HttpRegistrySource({ name, config: cfg }));
|
|
883
|
+
}
|
|
884
|
+
if (!registryMap.has(args.registries.defaultRegistry)) {
|
|
885
|
+
throw new Error(`agent ${args.agentId}: defaultRegistry "${args.registries.defaultRegistry}" is neither a tenant-visible package-registry asset nor a configured HTTP registry`);
|
|
886
|
+
}
|
|
887
|
+
const resolver = createClosureResolver({
|
|
888
|
+
registries: registryMap,
|
|
889
|
+
defaultRegistry: args.registries.defaultRegistry,
|
|
890
|
+
...(args.registries.scopeRouting !== undefined
|
|
891
|
+
? { scopeRouting: args.registries.scopeRouting }
|
|
892
|
+
: {}),
|
|
893
|
+
});
|
|
894
|
+
const manifest = await resolver.resolveClosure(args.pins);
|
|
895
|
+
return { manifest, assetIndex };
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Build a `ResolvedAttachment` for an asset the resolver picked
|
|
899
|
+
* from but which has no per-agent attachment row. The pack is read
|
|
900
|
+
* from the asset's main ref (the same ref the resolver consumed
|
|
901
|
+
* tarballs from), and `agentAssetId` is `null` so the fan-out path
|
|
902
|
+
* knows to skip the `session_asset` insert.
|
|
903
|
+
*/
|
|
904
|
+
async function resolveDirectAssetAttachment(args) {
|
|
905
|
+
const repoId = { kind: args.asset.kind, id: args.asset.id };
|
|
906
|
+
const sourceCommitSha = await agentRepoStore.repoStore.resolveRef(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
|
|
907
|
+
if (sourceCommitSha === null) {
|
|
908
|
+
throw new Error(`tool-package asset ${args.asset.kind}/${args.asset.id} has no commit on ${DEFAULT_ASSET_REF}`);
|
|
909
|
+
}
|
|
910
|
+
const { pack, ref: returnedRef } = await agentRepoStore.repoStore.createPack(HUB_PRINCIPAL, repoId, DEFAULT_ASSET_REF);
|
|
911
|
+
return {
|
|
912
|
+
agentAssetId: null,
|
|
913
|
+
source: "resolved",
|
|
914
|
+
assetName: args.asset.name,
|
|
915
|
+
assetKind: args.asset.kind,
|
|
916
|
+
mountPath: args.mountPath,
|
|
917
|
+
sourceCommitSha,
|
|
918
|
+
repoId,
|
|
919
|
+
pack,
|
|
920
|
+
ref: returnedRef,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
function collectAvailableSkills(resolved) {
|
|
924
|
+
const entries = [];
|
|
925
|
+
for (const att of resolved) {
|
|
926
|
+
if (att.assetKind !== "skill")
|
|
927
|
+
continue;
|
|
928
|
+
const index = getSkillIndex(att.repoId.id, att.ref);
|
|
929
|
+
for (const entry of index) {
|
|
930
|
+
entries.push({
|
|
931
|
+
qualifiedName: `${att.assetName}/${entry.name}`,
|
|
932
|
+
description: entry.description,
|
|
933
|
+
workspacePath: `workspace/${att.mountPath}${entry.workspaceSubpath}`,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return entries;
|
|
938
|
+
}
|
|
939
|
+
async function attemptCleanup(agentAddress, failedPhase, originalErr) {
|
|
940
|
+
try {
|
|
941
|
+
await sidecarRouter.sendAgentUndeploy(agentAddress, failedPhase);
|
|
942
|
+
}
|
|
943
|
+
catch (cleanupErr) {
|
|
944
|
+
logger.error `Failed to clean up agent ${agentAddress} after ${failedPhase} failure: ${String(cleanupErr)}`;
|
|
945
|
+
// Preserve the original error as cause so the root cause is not
|
|
946
|
+
// lost when the cleanup also fails.
|
|
947
|
+
throw new SessionLaunchError(failedPhase, originalErr, true);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
async function sendUserMessage(params) {
|
|
951
|
+
const { agentAddress, from, messageId, date, content, attachments, inReplyTo, references, sessionId, tenantId, cryptoProvider, } = params;
|
|
952
|
+
const headers = {
|
|
953
|
+
from,
|
|
954
|
+
to: [agentAddress],
|
|
955
|
+
cc: undefined,
|
|
956
|
+
date,
|
|
957
|
+
messageId,
|
|
958
|
+
subject: undefined,
|
|
959
|
+
inReplyTo,
|
|
960
|
+
references,
|
|
961
|
+
mimeVersion: "1.0",
|
|
962
|
+
interchangeType: "conversation.message",
|
|
963
|
+
interchangeCorrelationId: undefined,
|
|
964
|
+
interchangeTenantId: tenantId,
|
|
965
|
+
interchangeAgentId: undefined,
|
|
966
|
+
interchangeSessionId: sessionId,
|
|
967
|
+
interchangeOfferingId: undefined,
|
|
968
|
+
interchangeSchemaVersion: undefined,
|
|
969
|
+
traceparent: undefined,
|
|
970
|
+
tracestate: undefined,
|
|
971
|
+
};
|
|
972
|
+
const signedContent = assembleSignedContent({
|
|
973
|
+
kind: "conversation",
|
|
974
|
+
text: content,
|
|
975
|
+
...(attachments !== undefined ? { attachments } : {}),
|
|
976
|
+
});
|
|
977
|
+
const signature = await createDetachedSignatureFromProvider(signedContent, cryptoProvider);
|
|
978
|
+
const rawMessage = assembleMessage(headers, signedContent, signature);
|
|
979
|
+
const base64 = base64Encode(rawMessage);
|
|
980
|
+
const delivered = sidecarRouter.routeMail(agentAddress, base64);
|
|
981
|
+
if (!delivered) {
|
|
982
|
+
throw new Error(`Failed to deliver message to ${agentAddress}: agent is unreachable`);
|
|
983
|
+
}
|
|
984
|
+
return rawMessage;
|
|
985
|
+
}
|
|
986
|
+
async function endSession(agentAddress, reason) {
|
|
987
|
+
await sidecarRouter.sendAgentUndeploy(agentAddress, reason);
|
|
988
|
+
}
|
|
989
|
+
return {
|
|
990
|
+
stageWorkflowStep,
|
|
991
|
+
deployInstanceAtHead,
|
|
992
|
+
deploySingleStepAtHead,
|
|
993
|
+
deployWorkflowDefinition,
|
|
994
|
+
sendUserMessage,
|
|
995
|
+
endSession,
|
|
996
|
+
};
|
|
997
|
+
}
|