@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.
- package/LICENSE +201 -0
- package/README.md +61 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/composer.d.ts +14 -0
- package/dist/lib/composer.d.ts.map +1 -0
- package/dist/lib/composer.js +595 -0
- package/dist/lib/composer.js.map +1 -0
- package/dist/lib/composition-workspace-manifest.d.ts +44 -0
- package/dist/lib/composition-workspace-manifest.d.ts.map +1 -0
- package/dist/lib/composition-workspace-manifest.js +143 -0
- package/dist/lib/composition-workspace-manifest.js.map +1 -0
- package/dist/lib/dispatch-contract.d.ts +48 -0
- package/dist/lib/dispatch-contract.d.ts.map +1 -0
- package/dist/lib/dispatch-contract.js +8 -0
- package/dist/lib/dispatch-contract.js.map +1 -0
- package/dist/lib/drift-detector.d.ts +40 -0
- package/dist/lib/drift-detector.d.ts.map +1 -0
- package/dist/lib/drift-detector.js +386 -0
- package/dist/lib/drift-detector.js.map +1 -0
- package/dist/lib/environment-resolver.d.ts +84 -0
- package/dist/lib/environment-resolver.d.ts.map +1 -0
- package/dist/lib/environment-resolver.js +181 -0
- package/dist/lib/environment-resolver.js.map +1 -0
- package/dist/lib/errors.d.ts +12 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +27 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/lifecycle-state.d.ts +23 -0
- package/dist/lib/lifecycle-state.d.ts.map +1 -0
- package/dist/lib/lifecycle-state.js +70 -0
- package/dist/lib/lifecycle-state.js.map +1 -0
- package/dist/lib/skill-bundle-template-resolver.d.ts +23 -0
- package/dist/lib/skill-bundle-template-resolver.d.ts.map +1 -0
- package/dist/lib/skill-bundle-template-resolver.js +54 -0
- package/dist/lib/skill-bundle-template-resolver.js.map +1 -0
- package/dist/lib/types.d.ts +141 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +3 -0
- package/dist/lib/types.js.map +1 -0
- package/package.json +45 -0
- package/src/index.ts +34 -0
- package/src/lib/composer.ts +1041 -0
- package/src/lib/composition-workspace-manifest.ts +432 -0
- package/src/lib/dispatch-contract.ts +156 -0
- package/src/lib/drift-detector.ts +497 -0
- package/src/lib/environment-resolver.ts +480 -0
- package/src/lib/errors.ts +43 -0
- package/src/lib/lifecycle-state.ts +139 -0
- package/src/lib/skill-bundle-template-resolver.ts +147 -0
- 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
|
+
}
|