@xemahq/agent-session-runtime 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +61 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +26 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/lib/composer.d.ts +14 -0
  8. package/dist/lib/composer.d.ts.map +1 -0
  9. package/dist/lib/composer.js +595 -0
  10. package/dist/lib/composer.js.map +1 -0
  11. package/dist/lib/composition-workspace-manifest.d.ts +44 -0
  12. package/dist/lib/composition-workspace-manifest.d.ts.map +1 -0
  13. package/dist/lib/composition-workspace-manifest.js +143 -0
  14. package/dist/lib/composition-workspace-manifest.js.map +1 -0
  15. package/dist/lib/dispatch-contract.d.ts +48 -0
  16. package/dist/lib/dispatch-contract.d.ts.map +1 -0
  17. package/dist/lib/dispatch-contract.js +8 -0
  18. package/dist/lib/dispatch-contract.js.map +1 -0
  19. package/dist/lib/drift-detector.d.ts +40 -0
  20. package/dist/lib/drift-detector.d.ts.map +1 -0
  21. package/dist/lib/drift-detector.js +386 -0
  22. package/dist/lib/drift-detector.js.map +1 -0
  23. package/dist/lib/environment-resolver.d.ts +84 -0
  24. package/dist/lib/environment-resolver.d.ts.map +1 -0
  25. package/dist/lib/environment-resolver.js +181 -0
  26. package/dist/lib/environment-resolver.js.map +1 -0
  27. package/dist/lib/errors.d.ts +12 -0
  28. package/dist/lib/errors.d.ts.map +1 -0
  29. package/dist/lib/errors.js +27 -0
  30. package/dist/lib/errors.js.map +1 -0
  31. package/dist/lib/lifecycle-state.d.ts +23 -0
  32. package/dist/lib/lifecycle-state.d.ts.map +1 -0
  33. package/dist/lib/lifecycle-state.js +70 -0
  34. package/dist/lib/lifecycle-state.js.map +1 -0
  35. package/dist/lib/skill-bundle-template-resolver.d.ts +23 -0
  36. package/dist/lib/skill-bundle-template-resolver.d.ts.map +1 -0
  37. package/dist/lib/skill-bundle-template-resolver.js +54 -0
  38. package/dist/lib/skill-bundle-template-resolver.js.map +1 -0
  39. package/dist/lib/types.d.ts +141 -0
  40. package/dist/lib/types.d.ts.map +1 -0
  41. package/dist/lib/types.js +3 -0
  42. package/dist/lib/types.js.map +1 -0
  43. package/package.json +45 -0
  44. package/src/index.ts +34 -0
  45. package/src/lib/composer.ts +1041 -0
  46. package/src/lib/composition-workspace-manifest.ts +432 -0
  47. package/src/lib/dispatch-contract.ts +156 -0
  48. package/src/lib/drift-detector.ts +497 -0
  49. package/src/lib/environment-resolver.ts +480 -0
  50. package/src/lib/errors.ts +43 -0
  51. package/src/lib/lifecycle-state.ts +139 -0
  52. package/src/lib/skill-bundle-template-resolver.ts +147 -0
  53. package/src/lib/types.ts +443 -0
@@ -0,0 +1,1041 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import {
4
+ AWP_V1_SPEC,
5
+ WorkspaceMount,
6
+ WorkspaceMountMode,
7
+ type SlotDefinition,
8
+ type WorkspaceMountPlan,
9
+ type WorkspaceMountPlanEntry,
10
+ } from '@xemahq/kernel-contracts/agent-workspace';
11
+ import {
12
+ BriefcaseReferenceKind,
13
+ type AgentRunRole,
14
+ type Briefcase,
15
+ type BriefcaseReference,
16
+ type BriefcaseUpload,
17
+ type CompiledWorkingFile,
18
+ type MountSource,
19
+ type RenderedAgentRunContextParams,
20
+ } from '@xemahq/kernel-contracts/workflow';
21
+
22
+ import { UnknownSlotError } from './errors';
23
+ import type {
24
+ ComposeWorkspaceImageRequest,
25
+ WorkspaceImageComposer,
26
+ } from './types';
27
+
28
+ // ═══════════════════════════════════════════════════════════════════════════
29
+ // ── WorkspaceImageComposer ──
30
+ //
31
+ // Turns a (manifest, bindInputs, agentContext) tuple into a typed
32
+ // WorkspaceMountPlan. The plan is what the workspace-proxy
33
+ // `/workspace/mounts/apply` handler resolves into bytes-on-disk.
34
+ //
35
+ // The composer auto-emits five platform-rendered slots from the agent
36
+ // block (agents-md, context-json, agent-bundles per delegated subagent,
37
+ // skill-bundles per skill, instructions). User-data slots are emitted
38
+ // from the manifest's mounts block + agentContext refs.
39
+ //
40
+ // Resolution of WHICH agents/skills/instructions to mount happens
41
+ // upstream in llm-registry-api (via the resolvers calling
42
+ // `agent-run-context/render` and the per-resource bundle endpoints).
43
+ // The composer only enumerates which `MountSource` discriminators to
44
+ // emit; the resolver fetches the bytes at apply time.
45
+ // ═══════════════════════════════════════════════════════════════════════════
46
+
47
+ /**
48
+ * Resolved agent metadata the caller passed in via `agentContext`.
49
+ * Keys are arbitrary; the composer recognizes a closed set of common
50
+ * keys to drive the platform-rendered slot emission and falls back to
51
+ * the manifest's declared agent block for everything else.
52
+ */
53
+ interface AgentContextShape {
54
+ readonly delegatedSubAgents?: readonly string[];
55
+ readonly skills?: readonly string[];
56
+ readonly instructions?: readonly string[];
57
+ readonly inputsJsonBase64?: string;
58
+ readonly kbSpaceIds?: readonly string[];
59
+ /**
60
+ * Per-space page selections — emitted as `kb-pages` MountSource
61
+ * entries under `references/kb/<spaceId>/<pageSlug>.md`. Used by the
62
+ * runtime KB-mount feature on Interactive Sessions / Design System Builder
63
+ * (the user picks individual pages from a space to attach to a live
64
+ * session). Empty `pageSlugs` selections are dropped by the composer
65
+ * — fail-fast on an empty selection happens in the resolver.
66
+ */
67
+ readonly kbPageMounts?: ReadonlyArray<{
68
+ readonly spaceId: string;
69
+ readonly pageSlugs: readonly string[];
70
+ }>;
71
+ readonly externalProjectIds?: readonly string[];
72
+ readonly repoRef?: string;
73
+ readonly repoRevision?: string;
74
+ readonly deliverableSpecRef?: string;
75
+ readonly deliverablesRef?: string | readonly string[];
76
+ readonly sessionId?: string;
77
+ /**
78
+ * Per-job hand-off: artifact-store versions emitted by upstream jobs
79
+ * (typically `xema/http@v1` with `outputMode: artifact` or any
80
+ * `xema/emit-artifact@v1` step) that this agent should see as
81
+ * concrete files inside `/workspace/<slot>/<fileName>`. The agent
82
+ * reads them via its tool surface — they do NOT travel through
83
+ * `agent-run-context.workflowInputs.*` or the system prompt.
84
+ *
85
+ * Each entry specifies which workspace slot to mount into; the
86
+ * manifest MUST enable that slot or the composer fails fast at
87
+ * `extractInputArtifactSources`.
88
+ */
89
+ readonly inputArtifacts?: ReadonlyArray<InputArtifactRef>;
90
+ }
91
+
92
+ /**
93
+ * Wire shape for `agentContext.inputArtifacts[]`. Workflow authors
94
+ * populate these by referencing upstream outputs:
95
+ *
96
+ * ```yaml
97
+ * inputArtifacts:
98
+ * - artifactId: ${{ needs.fetch.outputs.artifactId }}
99
+ * version: ${{ needs.fetch.outputs.version }}
100
+ * fileName: page.html
101
+ * payloadField: body
102
+ * ```
103
+ *
104
+ * `slot` defaults to `inputs` — the canonical workspace slot for "data
105
+ * the workflow is feeding into this agent."
106
+ */
107
+ export interface InputArtifactRef {
108
+ readonly artifactId: string;
109
+ readonly version: number;
110
+ readonly fileName: string;
111
+ readonly slot?: string;
112
+ readonly payloadField?: string;
113
+ readonly contentType?: string;
114
+ }
115
+
116
+ export class DefaultWorkspaceImageComposer implements WorkspaceImageComposer {
117
+ async compose(req: ComposeWorkspaceImageRequest): Promise<WorkspaceMountPlan> {
118
+ // Exactly-one-of invariant: the doc on `ComposeWorkspaceImageRequest`
119
+ // promises `workflowRun` xor `interactive`. Enforce it here so
120
+ // `deriveApplyId` can never collapse two unrelated requests onto a
121
+ // shared `applyId` (which would let workspace-proxy dedup
122
+ // unrelated mounts). No silent fallback per the engineering
123
+ // constitution.
124
+ const hasWorkflow = req.workflowRun !== undefined;
125
+ const hasInteractive = req.interactive !== undefined;
126
+ if (hasWorkflow === hasInteractive) {
127
+ throw new Error(
128
+ `ComposeWorkspaceImageRequest must declare exactly one of \`workflowRun\` or \`interactive\` (got workflowRun=${hasWorkflow}, interactive=${hasInteractive}).`,
129
+ );
130
+ }
131
+ const ctx = req.agentContext as AgentContextShape;
132
+ // Surface discriminator. The xor-checked `workflowRun` / `interactive`
133
+ // fields above ARE the kernel surface signal; we project them onto a
134
+ // boolean here for the gates further down. Workflow content
135
+ // (deliverable specs, input artifacts, deliverables) must never leak
136
+ // into a session surface — fail-fast per CLAUDE.md.
137
+ const isWorkflowSurface = hasWorkflow;
138
+ if (!isWorkflowSurface) {
139
+ // Hard-assert: a session must not be built with any workflow-only
140
+ // payload. This catches the "wrong agentContext fed into the session
141
+ // bootstrap" class of misuse before it materialises on disk.
142
+ if (ctx.inputArtifacts !== undefined && ctx.inputArtifacts.length > 0) {
143
+ throw new Error(
144
+ 'WorkspaceImageComposer: agent-session surface received non-empty `agentContext.inputArtifacts` — input artifacts are a workflow-only concept. Drop the field or switch to a workflow surface.',
145
+ );
146
+ }
147
+ if (
148
+ ctx.deliverableSpecRef !== undefined ||
149
+ ctx.deliverablesRef !== undefined ||
150
+ req.agentOverrides?.deliverableSpecRef !== undefined ||
151
+ req.manifest.agent.deliverableSpecRef !== undefined
152
+ ) {
153
+ throw new Error(
154
+ 'WorkspaceImageComposer: agent-session surface received a deliverable-spec / deliverables reference — deliverables are a workflow-only concept. Drop `deliverableSpecRef` / `deliverablesRef` for session calls, or switch to a workflow surface.',
155
+ );
156
+ }
157
+ }
158
+ // Role is caller-supplied. The silent `?? manifest.agent.role`
159
+ // fallback was removed as part of the unified-role refactor — every
160
+ // caller (worker activity, agent-session bootstrap, future
161
+ // entry points) MUST set `agentOverrides.role` explicitly. This
162
+ // prevents the class of bugs where a manifest declared the wrong
163
+ // role and the runtime silently propagated it to the wrong template.
164
+ if (req.agentOverrides?.role === undefined) {
165
+ throw new Error(
166
+ 'ComposeWorkspaceImageRequest.agentOverrides.role is required — pass `manifest.agent.role` explicitly so the renderer can validate role-vs-surface up front.',
167
+ );
168
+ }
169
+ const role: AgentRunRole = req.agentOverrides.role;
170
+ const deliverableSpecRef =
171
+ req.agentOverrides?.deliverableSpecRef ??
172
+ req.manifest.agent.deliverableSpecRef ??
173
+ ctx.deliverableSpecRef;
174
+
175
+ const entries: WorkspaceMountPlanEntry[] = [];
176
+
177
+ // ── Platform-rendered single-file slots ───────────────────────────
178
+ // The three rendered-* sources (agents-md, context-json, system-overlay)
179
+ // all dispatch to llm-registry's `agent-run-context/render`, which
180
+ // returns `{agentsMd, contextJson, systemOverlay}` in a single
181
+ // response. Building them from one shared param object guarantees
182
+ // they hash to the same workspace-proxy cache key — so one
183
+ // mount-apply triggers exactly one upstream render call (instead of
184
+ // three) and the three slots are guaranteed to come from the same
185
+ // render snapshot. Diverging the params is a silent perf+consistency
186
+ // bug; do not do it.
187
+ // Workflow-only: `context.json.workflowInputs.*` is the workflow
188
+ // engine's `with:` block projection. Sessions have no such concept;
189
+ // surfacing it would be a lie. The hard-assert above ensures no
190
+ // workflow-only fields slip through; we still drop the projection
191
+ // here as a defence-in-depth.
192
+ const workflowInputs = isWorkflowSurface
193
+ ? pickWorkflowInputs(req.agentContext)
194
+ : null;
195
+ const renderedMounts = buildRenderedMounts(
196
+ req.manifest.mounts,
197
+ req.manifest.slug,
198
+ req.manifest.version,
199
+ );
200
+ const renderedInputArtifacts = buildRenderedInputArtifacts(ctx.inputArtifacts);
201
+ const renderedWorkingFiles = buildRenderedWorkingFiles(req.manifest.workingFiles);
202
+ const renderedSourceParams: RenderedAgentRunContextParams = {
203
+ orgId: req.orgId,
204
+ projectId: req.projectId,
205
+ agentSlug: req.manifest.agent.slug,
206
+ groupKey: req.manifest.agent.groupKey,
207
+ role,
208
+ ...(deliverableSpecRef === undefined ? {} : { deliverableSpecRef }),
209
+ ...(req.workflowRun?.runId === undefined ? {} : { runId: req.workflowRun.runId }),
210
+ ...(req.workflowRun?.jobRunId === undefined ? {} : { jobRunId: req.workflowRun.jobRunId }),
211
+ ...(req.interactive?.sessionId === undefined
212
+ ? {}
213
+ : { sessionId: req.interactive.sessionId }),
214
+ ...(workflowInputs === null ? {} : { workflowInputs }),
215
+ ...(renderedMounts.length === 0 ? {} : { mounts: renderedMounts }),
216
+ ...(renderedInputArtifacts.length === 0
217
+ ? {}
218
+ : { inputArtifacts: renderedInputArtifacts }),
219
+ ...(renderedWorkingFiles.length === 0
220
+ ? {}
221
+ : { workingFiles: renderedWorkingFiles }),
222
+ };
223
+
224
+ entries.push(
225
+ buildEntry({
226
+ slot: WorkspaceMount.AgentsMd,
227
+ relPath: '',
228
+ mode: WorkspaceMountMode.ReadOnly,
229
+ mountKey: 'agents-md',
230
+ source: { kind: 'rendered-agents-md', ...renderedSourceParams },
231
+ }),
232
+ );
233
+
234
+ entries.push(
235
+ buildEntry({
236
+ slot: WorkspaceMount.ContextJson,
237
+ relPath: '',
238
+ mode: WorkspaceMountMode.ReadOnly,
239
+ mountKey: 'context-json',
240
+ source: { kind: 'rendered-context-json', ...renderedSourceParams },
241
+ }),
242
+ );
243
+
244
+ entries.push(
245
+ buildEntry({
246
+ slot: WorkspaceMount.SystemOverlay,
247
+ relPath: '',
248
+ mode: WorkspaceMountMode.ReadOnly,
249
+ mountKey: 'system-overlay',
250
+ source: { kind: 'rendered-system-overlay', ...renderedSourceParams },
251
+ }),
252
+ );
253
+
254
+ // ── /workspace/tmp (scratch space) ──
255
+ // Always-on, read-write, tarball-persisted. Empty seed so the
256
+ // directory exists from session start; agents drop scratch files in
257
+ // and snapshots carry them across retries automatically.
258
+ entries.push(
259
+ buildEntry({
260
+ slot: WorkspaceMount.Tmp,
261
+ relPath: '.keep',
262
+ mode: WorkspaceMountMode.ReadWrite,
263
+ mountKey: 'tmp:seed',
264
+ source: {
265
+ kind: 'static-literal',
266
+ pathWithinWorkspace: 'tmp/.keep',
267
+ bytes: '',
268
+ },
269
+ }),
270
+ );
271
+
272
+ // ── Agent bundles (primary + delegated subagents) ─────────────────
273
+ entries.push(
274
+ buildEntry({
275
+ slot: WorkspaceMount.AgentBundles,
276
+ relPath: `${req.manifest.agent.slug}.md`,
277
+ mode: WorkspaceMountMode.ReadOnly,
278
+ mountKey: `agent-bundle:${req.manifest.agent.slug}`,
279
+ source: {
280
+ kind: 'agent-definition',
281
+ orgId: req.orgId,
282
+ agentSlug: req.manifest.agent.slug,
283
+ groupKey: req.manifest.agent.groupKey,
284
+ agentMode: 'primary',
285
+ },
286
+ }),
287
+ );
288
+ for (const sub of ctx.delegatedSubAgents ?? []) {
289
+ if (sub === req.manifest.agent.slug) continue; // primary already emitted
290
+ entries.push(
291
+ buildEntry({
292
+ slot: WorkspaceMount.AgentBundles,
293
+ relPath: `${sub}.md`,
294
+ mode: WorkspaceMountMode.ReadOnly,
295
+ mountKey: `agent-bundle:${sub}`,
296
+ source: {
297
+ kind: 'agent-definition',
298
+ orgId: req.orgId,
299
+ agentSlug: sub,
300
+ groupKey: req.manifest.agent.groupKey,
301
+ agentMode: 'subagent',
302
+ },
303
+ }),
304
+ );
305
+ }
306
+
307
+ // ── Skill bundles + /skill launch commands ────────────────────────
308
+ // Each skill is a multi-file BUNDLE (SKILL.md + bundled resources).
309
+ // The entry's relPath is the skill DIRECTORY; the `skill-bundle`
310
+ // resolver yields one file per bundle member, so the writer composes
311
+ // `<skill-bundles slot>/<skillKey>/<resolver relPath>`.
312
+ //
313
+ // Alongside each bundle we emit one OpenCode command file so typing
314
+ // `/<skillKey>` in a session loads and applies that skill natively.
315
+ //
316
+ // Kernel-shipped System skills are PREPENDED to whatever skills the
317
+ // caller already named in `agentContext.skills` (see
318
+ // `.claude/rules/skills-and-composition.md` — `SkillSpace.System`
319
+ // skills are auto-injected into every agent's mounted bundle).
320
+ // `dedupe` collapses any overlap so a caller that re-mentions a
321
+ // System skill in `agentContext.skills` does not double-mount it.
322
+ for (const skillKey of dedupe([
323
+ ...(req.systemSkillSlugs ?? []),
324
+ ...(ctx.skills ?? []),
325
+ ])) {
326
+ entries.push(
327
+ buildEntry({
328
+ slot: WorkspaceMount.SkillBundles,
329
+ relPath: skillKey,
330
+ mode: WorkspaceMountMode.ReadOnly,
331
+ mountKey: `skill-bundle:${skillKey}`,
332
+ source: {
333
+ kind: 'skill-bundle',
334
+ skillKey,
335
+ },
336
+ }),
337
+ );
338
+ const commandRelPath = `${skillKey}.md`;
339
+ entries.push(
340
+ buildEntry({
341
+ slot: WorkspaceMount.Commands,
342
+ relPath: commandRelPath,
343
+ mode: WorkspaceMountMode.ReadOnly,
344
+ mountKey: `skill-command:${skillKey}`,
345
+ source: {
346
+ kind: 'static-literal',
347
+ pathWithinWorkspace: `.opencode/command/${commandRelPath}`,
348
+ bytes: Buffer.from(renderSkillCommand(skillKey), 'utf8').toString(
349
+ 'base64',
350
+ ),
351
+ },
352
+ }),
353
+ );
354
+ }
355
+
356
+ // ── Instruction sections ──────────────────────────────────────────
357
+ for (const sectionKey of dedupe(ctx.instructions ?? [])) {
358
+ entries.push(
359
+ buildEntry({
360
+ slot: WorkspaceMount.Instructions,
361
+ relPath: `${sectionKey}.md`,
362
+ mode: WorkspaceMountMode.ReadOnly,
363
+ mountKey: `instruction:${sectionKey}`,
364
+ source: {
365
+ kind: 'instruction-section',
366
+ orgId: req.orgId,
367
+ sectionKey,
368
+ agentSlug: req.manifest.agent.slug,
369
+ groupKey: req.manifest.agent.groupKey,
370
+ },
371
+ }),
372
+ );
373
+ }
374
+
375
+ // ── Input artifacts (workflow-step output → file in workspace) ──
376
+ // Walks `agentContext.inputArtifacts[]` and lands each artifact
377
+ // version as a single file under its target slot. Slot defaults to
378
+ // `inputs`. The slot MUST be enabled in the manifest — otherwise
379
+ // the workspace-proxy authority guard would reject any tool read on
380
+ // the resulting path, and the workflow author wants a hard signal
381
+ // not a silent skip.
382
+ //
383
+ // Workflow-surface only: the hard-assert at the top of compose()
384
+ // guarantees `ctx.inputArtifacts` is empty on session surfaces, so
385
+ // this loop is effectively a no-op there.
386
+ const enabledSlots = new Set(
387
+ Object.entries(req.manifest.mounts)
388
+ .filter(([, decl]) => decl.enabled)
389
+ .map(([slotKey]) => slotKey),
390
+ );
391
+ for (const ia of ctx.inputArtifacts ?? []) {
392
+ const slotKey = ia.slot ?? 'inputs';
393
+ if (!enabledSlots.has(slotKey)) {
394
+ throw new Error(
395
+ `WorkspaceImageComposer: agentContext.inputArtifacts targets slot '${slotKey}' but the manifest '${req.manifest.slug}@${req.manifest.version}' does not enable it. ` +
396
+ `Enable the slot in the agent manifest or change the inputArtifact's 'slot' to one of: [${[...enabledSlots].join(', ') || '(none)'}].`,
397
+ );
398
+ }
399
+ const slotDef = AWP_V1_SPEC.slots.find((s) => s.key === slotKey);
400
+ if (!slotDef) {
401
+ throw new UnknownSlotError(
402
+ slotKey,
403
+ 'inputArtifacts',
404
+ req.manifest.slug,
405
+ req.manifest.version,
406
+ );
407
+ }
408
+ const fileName = ia.fileName.trim();
409
+ if (fileName.length === 0 || fileName.includes('..') || fileName.startsWith('/')) {
410
+ throw new Error(
411
+ `WorkspaceImageComposer: inputArtifact fileName '${ia.fileName}' is invalid — must be a non-empty relative path without '..' segments.`,
412
+ );
413
+ }
414
+ entries.push(
415
+ buildEntry({
416
+ slot: slotDef.path as WorkspaceMount,
417
+ relPath: fileName,
418
+ mode: WorkspaceMountMode.ReadOnly,
419
+ mountKey: `input-artifact:${ia.artifactId}:v${ia.version}:${fileName}`,
420
+ source: {
421
+ kind: 'artifact-version',
422
+ artifactId: ia.artifactId,
423
+ version: ia.version,
424
+ fileName,
425
+ ...(ia.payloadField !== undefined && { payloadField: ia.payloadField }),
426
+ ...(ia.contentType !== undefined && { contentType: ia.contentType }),
427
+ },
428
+ }),
429
+ );
430
+ }
431
+
432
+ // ── User-data mounts (manifest-declared) ──────────────────────────
433
+ for (const [slotKey, decl] of Object.entries(req.manifest.mounts)) {
434
+ if (!decl.enabled) continue;
435
+ const slotDef = AWP_V1_SPEC.slots.find((s) => s.key === slotKey);
436
+ if (!slotDef) {
437
+ throw new UnknownSlotError(
438
+ slotKey,
439
+ 'manifest.mounts',
440
+ req.manifest.slug,
441
+ req.manifest.version,
442
+ );
443
+ }
444
+ const mode = decl.mode ?? slotDef.defaultMode;
445
+ const mountMode =
446
+ mode === 'read-write' ? WorkspaceMountMode.ReadWrite : WorkspaceMountMode.ReadOnly;
447
+ const sources = extractMountSourcesForSlot(
448
+ slotKey,
449
+ decl,
450
+ ctx,
451
+ deliverableSpecRef,
452
+ req.briefcase,
453
+ isWorkflowSurface,
454
+ );
455
+ for (const { source, relPath, mountKey } of sources) {
456
+ entries.push(
457
+ buildEntry({
458
+ slot: slotDef.path as WorkspaceMount,
459
+ relPath,
460
+ mode: mountMode,
461
+ mountKey,
462
+ source,
463
+ }),
464
+ );
465
+ }
466
+ }
467
+
468
+ // ── Manifest seed files (inline literal or resolved template) ─────
469
+ if (
470
+ req.manifest.requiredTemplateNames.length > 0 &&
471
+ req.templateResolver === undefined
472
+ ) {
473
+ throw new Error(
474
+ `WorkspaceImageComposer: manifest '${req.manifest.slug}@${req.manifest.version}' references templates ` +
475
+ `[${req.manifest.requiredTemplateNames.join(', ')}] but no templateResolver was supplied.`,
476
+ );
477
+ }
478
+ for (const sf of req.manifest.seedFiles) {
479
+ const slotDef = AWP_V1_SPEC.slots.find((s) => s.key === sf.slot);
480
+ if (!slotDef) {
481
+ throw new UnknownSlotError(
482
+ sf.slot,
483
+ 'manifest.seedFiles',
484
+ req.manifest.slug,
485
+ req.manifest.version,
486
+ );
487
+ }
488
+ let bytesBase64: string;
489
+ if (sf.source.kind === 'inline') {
490
+ bytesBase64 = sf.source.bytesBase64;
491
+ } else {
492
+ const rendered = await req.templateResolver!.resolve(
493
+ sf.source.name,
494
+ sf.source.vars,
495
+ );
496
+ bytesBase64 = Buffer.from(rendered, 'utf8').toString('base64');
497
+ }
498
+ // `entry.relPath` is the file's location within the slot — used by
499
+ // the plan validator to dedup target paths AND by the writer as the
500
+ // file's final position. `source.pathWithinWorkspace` is identity
501
+ // metadata only (audit/telemetry), redundantly carrying the same
502
+ // value. The static-literal resolver yields `sub.relPath: ''` so
503
+ // the writer composes `<slot>/<entry.relPath>/<''>` → `<slot>/<entry.relPath>`,
504
+ // which is exactly what we want.
505
+ entries.push(
506
+ buildEntry({
507
+ slot: slotDef.path as WorkspaceMount,
508
+ relPath: sf.relPath,
509
+ mode: WorkspaceMountMode.ReadOnly,
510
+ mountKey: `seed:${sf.slot}:${sf.relPath}`,
511
+ source: {
512
+ kind: 'static-literal',
513
+ pathWithinWorkspace: sf.relPath,
514
+ bytes: bytesBase64,
515
+ },
516
+ }),
517
+ );
518
+ }
519
+
520
+ return {
521
+ applyId: deriveApplyId(req),
522
+ entries,
523
+ };
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Stable apply-id — feeds the workspace-proxy lease key.
529
+ *
530
+ * Includes manifest slug + version + a content hash of the manifest's
531
+ * mount-affecting blocks (`agent`, `mounts`, `seedFiles`,
532
+ * `requiredTemplateNames`, `extends`). The content hash defends against
533
+ * the failure mode where a manifest is mutated in place without bumping
534
+ * `version` — without it, workspace-proxy would serve stale mounts from
535
+ * a cached lease keyed only on the unchanged version. The bind inputs
536
+ * hash + scopeId ensure cross-run / cross-input isolation.
537
+ *
538
+ * Hash is base16; collision probability is negligible at the per-lease
539
+ * scale we operate at.
540
+ */
541
+ function deriveApplyId(req: ComposeWorkspaceImageRequest): string {
542
+ // Exactly one of the two is set — enforced at the top of compose().
543
+ const scopeId = req.workflowRun
544
+ ? `wf:${req.workflowRun.runId}:${req.workflowRun.jobRunId}`
545
+ : `is:${req.interactive!.sessionId}:${req.interactive!.turnGen ?? 0}`;
546
+ const inputsHash = createHash('sha256')
547
+ .update(JSON.stringify(stableSort(req.agentContext)))
548
+ .digest('hex')
549
+ .slice(0, 16);
550
+ const manifestContentHash = deriveManifestContentHash(req.manifest);
551
+ const components = [
552
+ req.orgId,
553
+ req.projectId,
554
+ req.manifest.slug,
555
+ req.manifest.version,
556
+ manifestContentHash,
557
+ req.manifest.agent.slug,
558
+ req.agentOverrides?.role ?? req.manifest.agent.role,
559
+ inputsHash,
560
+ scopeId,
561
+ ];
562
+ return createHash('sha256').update(components.join(':')).digest('hex');
563
+ }
564
+
565
+ /**
566
+ * Hash the manifest fields that drive mount composition. A change to
567
+ * any of these fields produces a different `applyId`, forcing
568
+ * workspace-proxy to allocate a fresh lease and re-resolve mounts. Any
569
+ * non-mount field (`description`, `metadata.author`, …) is excluded so
570
+ * cosmetic edits don't churn the lease.
571
+ */
572
+ function deriveManifestContentHash(
573
+ manifest: ComposeWorkspaceImageRequest['manifest'],
574
+ ): string {
575
+ // `extends` is already flattened into `agent`/`mounts`/`seedFiles`/`env`
576
+ // by the DSL compiler, so hashing those four fields captures the
577
+ // effective workspace shape after inheritance.
578
+ const projection = {
579
+ agent: manifest.agent,
580
+ mounts: manifest.mounts,
581
+ seedFiles: manifest.seedFiles,
582
+ env: manifest.env,
583
+ requiredTemplateNames: manifest.requiredTemplateNames,
584
+ };
585
+ return createHash('sha256')
586
+ .update(JSON.stringify(stableSort(projection)))
587
+ .digest('hex')
588
+ .slice(0, 16);
589
+ }
590
+
591
+ function stableSort(value: unknown): unknown {
592
+ if (value === null || typeof value !== 'object') return value;
593
+ if (Array.isArray(value)) return value.map(stableSort);
594
+ const obj = value as Record<string, unknown>;
595
+ const sorted: Record<string, unknown> = {};
596
+ for (const key of Object.keys(obj).sort()) {
597
+ sorted[key] = stableSort(obj[key]);
598
+ }
599
+ return sorted;
600
+ }
601
+
602
+ function dedupe<T>(items: readonly T[]): readonly T[] {
603
+ return Array.from(new Set(items));
604
+ }
605
+
606
+ /**
607
+ * The OpenCode command-file body for a `/skill` launch command. Typing
608
+ * `/<skillKey>` in a session expands to this prompt — load the skill,
609
+ * then act on whatever the user passed as arguments.
610
+ */
611
+ function renderSkillCommand(skillKey: string): string {
612
+ return [
613
+ '---',
614
+ `description: Load and apply the ${skillKey} skill`,
615
+ '---',
616
+ `Load and apply the \`${skillKey}\` skill, then act on the request: $ARGUMENTS`,
617
+ '',
618
+ ].join('\n');
619
+ }
620
+
621
+ function buildEntry(args: {
622
+ slot: WorkspaceMount;
623
+ relPath: string;
624
+ mode: WorkspaceMountMode;
625
+ mountKey: string;
626
+ source: MountSource;
627
+ }): WorkspaceMountPlanEntry {
628
+ return {
629
+ mountKey: args.mountKey,
630
+ slot: args.slot,
631
+ relPath: args.relPath,
632
+ mode: args.mode,
633
+ source: args.source,
634
+ };
635
+ }
636
+
637
+ interface ExtractedMountSource {
638
+ readonly source: MountSource;
639
+ readonly relPath: string;
640
+ readonly mountKey: string;
641
+ }
642
+
643
+ /**
644
+ * Translate a manifest mount declaration + agentContext into one or
645
+ * more concrete MountSources. Closed switch on slot key — adding a
646
+ * new user-data slot requires extending here.
647
+ */
648
+ function extractMountSourcesForSlot(
649
+ slotKey: string,
650
+ _decl: { readonly config: Readonly<Record<string, unknown>> },
651
+ ctx: AgentContextShape,
652
+ deliverableSpecRef: string | undefined,
653
+ briefcase: Briefcase | undefined,
654
+ isWorkflowSurface: boolean,
655
+ ): readonly ExtractedMountSource[] {
656
+ switch (slotKey) {
657
+ case 'inputs':
658
+ // Workflow-only slot. The inputs slot carries the rendered
659
+ // inputs.json blob serialized from workflow `with:` inputs;
660
+ // sessions have no such concept — the hard-assert at the top of
661
+ // compose() ensures workflow-only payloads never reach a session,
662
+ // and this guard makes the rule explicit at the source-extractor
663
+ // boundary too.
664
+ if (!isWorkflowSurface) return [];
665
+ if (!ctx.inputsJsonBase64) return [];
666
+ return [
667
+ {
668
+ source: {
669
+ kind: 'static-literal',
670
+ pathWithinWorkspace: 'inputs.json',
671
+ bytes: ctx.inputsJsonBase64,
672
+ },
673
+ relPath: 'inputs.json',
674
+ mountKey: 'inputs:json',
675
+ },
676
+ ];
677
+ case 'uploads':
678
+ return extractBriefcaseUploadMounts(briefcase?.uploads);
679
+ case 'references': {
680
+ const out: ExtractedMountSource[] = [];
681
+ for (const spaceId of dedupe(ctx.kbSpaceIds ?? [])) {
682
+ out.push({
683
+ source: { kind: 'kb-space', spaceId },
684
+ relPath: `kb/${spaceId}`,
685
+ mountKey: `references:kb:${spaceId}`,
686
+ });
687
+ }
688
+ for (const mount of ctx.kbPageMounts ?? []) {
689
+ const pageSlugs = dedupe(mount.pageSlugs);
690
+ if (pageSlugs.length === 0) continue;
691
+ out.push({
692
+ source: { kind: 'kb-pages', spaceId: mount.spaceId, pageSlugs },
693
+ relPath: `kb/${mount.spaceId}`,
694
+ // Mount-key disambiguates from the kb-space entry on the same
695
+ // space when both coexist — e.g. user mounted the whole space
696
+ // and later pinned additional specific pages. Slot promote
697
+ // does an atomic `rm -rf` + rename so the two entries' files
698
+ // coexist under `references/kb/<spaceId>/` after apply.
699
+ mountKey: `references:kb-pages:${mount.spaceId}`,
700
+ });
701
+ }
702
+ for (const projectId of dedupe(ctx.externalProjectIds ?? [])) {
703
+ out.push({
704
+ source: { kind: 'scm-repo', repoRef: projectId, ref: 'HEAD' },
705
+ relPath: `external-projects/${projectId}`,
706
+ mountKey: `references:project:${projectId}`,
707
+ });
708
+ }
709
+ for (const entry of extractBriefcaseReferenceMounts(briefcase?.references)) {
710
+ out.push(entry);
711
+ }
712
+ return out;
713
+ }
714
+ case 'repos':
715
+ if (!ctx.repoRef) return [];
716
+ return [
717
+ {
718
+ source: {
719
+ kind: 'scm-repo',
720
+ repoRef: ctx.repoRef,
721
+ ref: ctx.repoRevision ?? 'HEAD',
722
+ },
723
+ relPath: ctx.repoRef,
724
+ mountKey: `repo:${ctx.repoRef}`,
725
+ },
726
+ ];
727
+ case 'deliverable-specs':
728
+ // Workflow-only slot. Sessions never harvest deliverables, so a
729
+ // spec mount would be dead weight at best, misleading at worst.
730
+ // The compose()-level hard-assert ensures `deliverableSpecRef` is
731
+ // undefined on session surfaces, but we mirror the gate here so
732
+ // the slot-extractor switch is the single readable place that
733
+ // documents which slots are workflow-only.
734
+ if (!isWorkflowSurface) return [];
735
+ if (!deliverableSpecRef) return [];
736
+ return [
737
+ {
738
+ source: { kind: 'deliverable-specs', contractKey: deliverableSpecRef },
739
+ relPath: deliverableSpecRef,
740
+ mountKey: `deliverable-specs:${deliverableSpecRef}`,
741
+ },
742
+ ];
743
+ case 'deliverables': {
744
+ // Workflow-only slot — same rationale as `deliverable-specs`.
745
+ if (!isWorkflowSurface) return [];
746
+ const refs = ctx.deliverablesRef
747
+ ? Array.isArray(ctx.deliverablesRef)
748
+ ? ctx.deliverablesRef
749
+ : [ctx.deliverablesRef]
750
+ : [];
751
+ return refs.map((contractKey) => ({
752
+ source: { kind: 'deliverables', contractKey },
753
+ relPath: contractKey,
754
+ mountKey: `deliverables:${contractKey}`,
755
+ }));
756
+ }
757
+ case 'attachments':
758
+ if (!ctx.sessionId) return [];
759
+ return [
760
+ {
761
+ source: { kind: 'session-attachment', sessionId: ctx.sessionId },
762
+ relPath: '',
763
+ mountKey: `attachments:${ctx.sessionId}`,
764
+ },
765
+ ];
766
+ default:
767
+ return [];
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Project `briefcase.uploads[]` onto `artifact-version` MountSources
773
+ * under the `uploads` AWP slot. Each upload becomes one file at
774
+ * `/workspace/uploads/<filename>`. Empty filenames or paths containing
775
+ * traversal segments fail fast — the dispatch endpoint already
776
+ * validates these but we re-check at compose time so an in-memory
777
+ * mutation can never write outside the slot.
778
+ */
779
+ function extractBriefcaseUploadMounts(
780
+ uploads: readonly BriefcaseUpload[] | undefined,
781
+ ): readonly ExtractedMountSource[] {
782
+ if (!uploads || uploads.length === 0) return [];
783
+ return uploads.map((u) => {
784
+ const fileName = u.filename.trim();
785
+ if (fileName.length === 0 || fileName.includes('..') || fileName.startsWith('/')) {
786
+ throw new Error(
787
+ `WorkspaceImageComposer: briefcase upload filename '${u.filename}' is invalid — must be a non-empty relative path without '..' segments.`,
788
+ );
789
+ }
790
+ return {
791
+ source: {
792
+ kind: 'artifact-version',
793
+ artifactId: u.artifact.artifactId,
794
+ version: u.artifact.version,
795
+ fileName,
796
+ ...(u.contentType !== null ? { contentType: u.contentType } : {}),
797
+ },
798
+ relPath: fileName,
799
+ mountKey: `briefcase-upload:${u.artifact.artifactId}:v${u.artifact.version}`,
800
+ } satisfies ExtractedMountSource;
801
+ });
802
+ }
803
+
804
+ /**
805
+ * Project `briefcase.references[]` onto MountSources under the
806
+ * `references` AWP slot. Each reference kind maps to the
807
+ * already-supported MountSource kind:
808
+ *
809
+ * - `kb_page` → `kb-pages` (single page, ref is `<spaceId>/<slug>`)
810
+ * - `kb_space` → `kb-space` (whole space, ref is `<spaceId>`)
811
+ * - `artifact` → `artifact-version` (ref is `<artifactId>@<version>`)
812
+ * - `scm_repo` → `scm-repo` (ref is `<repoRef>@<gitRef>` or `<repoRef>`)
813
+ * - `external_url` → skipped (no on-disk file; surfaces via
814
+ * context.json only — agents see it under workflowInputs)
815
+ */
816
+ function extractBriefcaseReferenceMounts(
817
+ references: readonly BriefcaseReference[] | undefined,
818
+ ): readonly ExtractedMountSource[] {
819
+ if (!references || references.length === 0) return [];
820
+ const out: ExtractedMountSource[] = [];
821
+ for (const ref of references) {
822
+ const entry = briefcaseReferenceToMount(ref);
823
+ if (entry !== null) out.push(entry);
824
+ }
825
+ return out;
826
+ }
827
+
828
+ function briefcaseReferenceToMount(ref: BriefcaseReference): ExtractedMountSource | null {
829
+ switch (ref.kind) {
830
+ case BriefcaseReferenceKind.KB_SPACE:
831
+ return {
832
+ source: { kind: 'kb-space', spaceId: ref.ref },
833
+ relPath: `kb/${ref.ref}`,
834
+ mountKey: `briefcase-reference:kb-space:${ref.ref}`,
835
+ };
836
+ case BriefcaseReferenceKind.KB_PAGE: {
837
+ // ref shape: `<spaceId>/<pageSlug>` (matches the canonical
838
+ // /kb/spaces/:slug/pages/:pageSlug route used elsewhere).
839
+ const sep = ref.ref.indexOf('/');
840
+ if (sep <= 0 || sep === ref.ref.length - 1) {
841
+ throw new Error(
842
+ `briefcase reference kind=kb_page must encode ref as '<spaceId>/<pageSlug>' (got '${ref.ref}').`,
843
+ );
844
+ }
845
+ const spaceId = ref.ref.slice(0, sep);
846
+ const pageSlug = ref.ref.slice(sep + 1);
847
+ return {
848
+ source: { kind: 'kb-pages', spaceId, pageSlugs: [pageSlug] },
849
+ relPath: `kb/${spaceId}`,
850
+ mountKey: `briefcase-reference:kb-page:${spaceId}:${pageSlug}`,
851
+ };
852
+ }
853
+ case BriefcaseReferenceKind.ARTIFACT: {
854
+ // ref shape: `<artifactId>@<version>` (integer version, matches
855
+ // the artifact-store address scheme).
856
+ const at = ref.ref.lastIndexOf('@');
857
+ if (at <= 0) {
858
+ throw new Error(
859
+ `briefcase reference kind=artifact must encode ref as '<artifactId>@<version>' (got '${ref.ref}').`,
860
+ );
861
+ }
862
+ const artifactId = ref.ref.slice(0, at);
863
+ const version = Number.parseInt(ref.ref.slice(at + 1), 10);
864
+ if (!Number.isInteger(version) || version < 1) {
865
+ throw new Error(
866
+ `briefcase reference kind=artifact has invalid version in '${ref.ref}' — must be a positive integer.`,
867
+ );
868
+ }
869
+ const trimmedTitle = ref.title?.trim() ?? '';
870
+ const fileName = trimmedTitle.length > 0 ? trimmedTitle : `${artifactId}-v${version}`;
871
+ return {
872
+ source: {
873
+ kind: 'artifact-version',
874
+ artifactId,
875
+ version,
876
+ fileName,
877
+ },
878
+ relPath: fileName,
879
+ mountKey: `briefcase-reference:artifact:${artifactId}:v${version}`,
880
+ };
881
+ }
882
+ case BriefcaseReferenceKind.SCM_REPO: {
883
+ // ref shape: `<repoRef>` or `<repoRef>@<gitRef>`. The repoRef is
884
+ // opaque to the composer; the SCM resolver decodes it.
885
+ const at = ref.ref.indexOf('@');
886
+ const repoRef = at < 0 ? ref.ref : ref.ref.slice(0, at);
887
+ const gitRef = at < 0 ? 'HEAD' : ref.ref.slice(at + 1);
888
+ return {
889
+ source: { kind: 'scm-repo', repoRef, ref: gitRef },
890
+ relPath: `scm/${repoRef}`,
891
+ mountKey: `briefcase-reference:scm:${repoRef}:${gitRef}`,
892
+ };
893
+ }
894
+ case BriefcaseReferenceKind.EXTERNAL_URL:
895
+ // No on-disk mount — the URL surfaces via context.json's
896
+ // workflowInputs projection. Audit trail logs the ref.
897
+ return null;
898
+ default:
899
+ throw new Error(
900
+ `Unhandled briefcase reference kind '${(ref as { kind: string }).kind}'.`,
901
+ );
902
+ }
903
+ }
904
+
905
+ // Static-only assertion that AWP slot `path`s align with the
906
+ // WorkspaceMount enum we depend on. Kept here so a kernel slot rename
907
+ // fails fast at build time.
908
+ function _assertSlotsCover(): SlotDefinition[] {
909
+ return [...AWP_V1_SPEC.slots];
910
+ }
911
+ void _assertSlotsCover;
912
+
913
+ /**
914
+ * Drop the keys the composer itself consumes (subagents, skills, mount
915
+ * routing hints, etc.) so the remainder is what the caller passed as
916
+ * actual workflow inputs (e.g. the user's request text). Returns null
917
+ * when nothing remains so the rendered-context-json source can omit the
918
+ * field entirely instead of carrying an empty object.
919
+ */
920
+ function pickWorkflowInputs(
921
+ agentContext: Readonly<Record<string, unknown>>,
922
+ ): Readonly<Record<string, unknown>> | null {
923
+ const COMPOSER_RESERVED = new Set<string>([
924
+ 'delegatedSubAgents',
925
+ 'skills',
926
+ 'instructions',
927
+ 'inputsJsonBase64',
928
+ 'kbSpaceIds',
929
+ 'kbPageMounts',
930
+ 'externalProjectIds',
931
+ 'repoRef',
932
+ 'repoRevision',
933
+ 'deliverableSpecRef',
934
+ 'deliverablesRef',
935
+ 'sessionId',
936
+ 'inputArtifacts',
937
+ ]);
938
+ const out: Record<string, unknown> = {};
939
+ for (const [k, v] of Object.entries(agentContext)) {
940
+ if (COMPOSER_RESERVED.has(k)) continue;
941
+ if (v === undefined) continue;
942
+ out[k] = v;
943
+ }
944
+ return Object.keys(out).length === 0 ? null : out;
945
+ }
946
+
947
+ /**
948
+ * Project the manifest's mount declarations into the wire shape the
949
+ * llm-registry renderer consumes — `{ mount: '/workspace/<slot>', mode }`.
950
+ * The renderer derives `context.json.authority.mayWriteWorkspace[]` from
951
+ * this list; the system overlay surfaces it to the agent as the
952
+ * authoritative writable-paths declaration. Disabled mounts are dropped.
953
+ *
954
+ * Unknown slot keys throw `UnknownSlotError` — same fail-fast contract
955
+ * as the user-data loop, so a manifest typo or stale slot name
956
+ * surfaces at the first compose call instead of materializing an
957
+ * incomplete `mayWriteWorkspace[]`.
958
+ */
959
+ /**
960
+ * Project the workflow-supplied input artifacts into the renderer
961
+ * wire shape: an absolute workspace path the agent reads from, plus the
962
+ * artifact lineage for traceability. The renderer surfaces these in
963
+ * AGENTS.md (`# Inputs`) and `context.json.workflow.inputArtifacts[]`,
964
+ * so the agent doesn't have to ls slot directories to discover them.
965
+ */
966
+ function buildRenderedInputArtifacts(
967
+ inputArtifacts: readonly InputArtifactRef[] | undefined,
968
+ ): ReadonlyArray<{
969
+ readonly path: string;
970
+ readonly contentType?: string;
971
+ readonly sourceArtifactId: string;
972
+ readonly sourceVersion: number;
973
+ }> {
974
+ if (!inputArtifacts || inputArtifacts.length === 0) return [];
975
+ const out: Array<{
976
+ path: string;
977
+ contentType?: string;
978
+ sourceArtifactId: string;
979
+ sourceVersion: number;
980
+ }> = [];
981
+ for (const ia of inputArtifacts) {
982
+ const slotKey = ia.slot ?? 'inputs';
983
+ const slotDef = AWP_V1_SPEC.slots.find((s) => s.key === slotKey);
984
+ if (!slotDef) continue;
985
+ out.push({
986
+ path: `${slotDef.path}/${ia.fileName}`,
987
+ ...(ia.contentType !== undefined && { contentType: ia.contentType }),
988
+ sourceArtifactId: ia.artifactId,
989
+ sourceVersion: ia.version,
990
+ });
991
+ }
992
+ return out;
993
+ }
994
+
995
+ /**
996
+ * Project compiled working-file bindings onto the render-request wire shape
997
+ * so llm-registry can surface them on `context.json.workingFiles`.
998
+ */
999
+ function buildRenderedWorkingFiles(
1000
+ workingFiles: readonly CompiledWorkingFile[] | undefined,
1001
+ ): ReadonlyArray<{
1002
+ readonly slug: string;
1003
+ readonly path: string;
1004
+ readonly format: 'markdown' | 'html' | 'json' | 'yaml' | 'text';
1005
+ readonly syncDirection: 'down-only' | 'up-only' | 'bidirectional';
1006
+ readonly sourceKind: string;
1007
+ readonly sourceRef: Readonly<Record<string, string>>;
1008
+ }> {
1009
+ if (!workingFiles || workingFiles.length === 0) return [];
1010
+ return workingFiles.map((wf) => ({
1011
+ slug: wf.slug,
1012
+ path: wf.path,
1013
+ format: wf.format,
1014
+ syncDirection: wf.syncDirection,
1015
+ sourceKind: wf.sourceKind,
1016
+ sourceRef: wf.sourceRef,
1017
+ }));
1018
+ }
1019
+
1020
+ function buildRenderedMounts(
1021
+ mounts: Readonly<Record<string, { enabled: boolean; mode?: 'read-only' | 'read-write' }>>,
1022
+ manifestSlug: string,
1023
+ manifestVersion: string,
1024
+ ): ReadonlyArray<{ mount: string; mode: 'read-only' | 'read-write' }> {
1025
+ const out: { mount: string; mode: 'read-only' | 'read-write' }[] = [];
1026
+ for (const [slotKey, decl] of Object.entries(mounts)) {
1027
+ if (!decl.enabled) continue;
1028
+ const slotDef = AWP_V1_SPEC.slots.find((s) => s.key === slotKey);
1029
+ if (!slotDef) {
1030
+ throw new UnknownSlotError(
1031
+ slotKey,
1032
+ 'rendered.mounts',
1033
+ manifestSlug,
1034
+ manifestVersion,
1035
+ );
1036
+ }
1037
+ const mode = decl.mode ?? slotDef.defaultMode;
1038
+ out.push({ mount: slotDef.path, mode });
1039
+ }
1040
+ return out;
1041
+ }