@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,480 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── EnvironmentResolver — single source of truth for execution env ──
3
+ //
4
+ // Wraps the existing `WorkspaceImageComposer` (which produces mount
5
+ // plans) and adds the dimensions the manifest now declares: skills,
6
+ // MCP services, credentials, permissions, persistence paths, preview
7
+ // surface, sub-agents, default model, env vars, plus the resolved
8
+ // agent identity itself. Output is a `ResolvedEnvironment` — the
9
+ // snapshot every entry point (workflow agent.activity, interactive-
10
+ // session bootstrap) hands to `applyWorkerControlSetup`.
11
+ //
12
+ // Precedence chain (top wins):
13
+ //
14
+ // 1. ExplicitOverrides (session/run model override, workflow `with.model`)
15
+ // 2. SurfaceContext.surfaceDefaults (caller-derived surface knobs)
16
+ // 3. Workspace manifest (`spec.*`)
17
+ // 4. Intrinsic agent floor (enforced HERE — never overridable)
18
+ //
19
+ // The TOOL floor (`INTRINSIC_FLOOR_TOOLS`) is materialised on every
20
+ // resolution into `ResolvedEnvironment.intrinsicFloorTools`. The set is
21
+ // fixed and closed; nothing in the request can subtract from it.
22
+ // Sub-agent overrides are ADDITIVE, never destructive: an explicit
23
+ // override appends or refines the model on a slug; it cannot remove
24
+ // a manifest-declared sub-agent (and downstream the agent's intrinsic
25
+ // floor cannot be removed either).
26
+ // ═══════════════════════════════════════════════════════════════════════════
27
+
28
+ import { createHash } from 'node:crypto';
29
+
30
+ import {
31
+ INTRINSIC_FLOOR_TOOLS,
32
+ IntrinsicFloorTool,
33
+ } from '@xemahq/kernel-contracts/agent-composition';
34
+ import type { WorkspaceMountPlan } from '@xemahq/kernel-contracts/agent-workspace';
35
+ import {
36
+ ToolProviderKind,
37
+ type ToolSelectionEntry,
38
+ } from '@xemahq/kernel-contracts/mcp-tool';
39
+ import type { CompiledWorkspaceManifest } from '@xemahq/dsl/workspace-manifest';
40
+ import {
41
+ ManifestSurface,
42
+ type AgentRunRole,
43
+ type Briefcase,
44
+ type BriefcaseMcpTool,
45
+ type CompiledManifestCredential,
46
+ type CompiledManifestDisplay,
47
+ type CompiledManifestPermissions,
48
+ type CompiledManifestPersistence,
49
+ type CompiledManifestOutputSurface,
50
+ type CompiledManifestSkillRef,
51
+ type ModelRef,
52
+ type SubAgentBinding,
53
+ } from '@xemahq/kernel-contracts/workflow';
54
+
55
+ import { ManifestSurfaceMismatchError } from './errors';
56
+ import type { TemplateResolver, WorkspaceImageComposer } from './types';
57
+
58
+ /**
59
+ * Discriminated surface context — declares whether the resolver is
60
+ * being invoked from a workflow step or an interactive session. The
61
+ * resolver validates `manifest.metadata.surfaceCompat` against this
62
+ * value and fails fast on mismatch (per CLAUDE.md, no silent fallback).
63
+ */
64
+ export type SurfaceContext =
65
+ | {
66
+ readonly kind: typeof ManifestSurface.WORKFLOW;
67
+ readonly orgId: string;
68
+ readonly projectId: string;
69
+ readonly runId: string;
70
+ readonly jobRunId: string;
71
+ }
72
+ | {
73
+ readonly kind: typeof ManifestSurface.AGENT_SESSION;
74
+ readonly orgId: string;
75
+ readonly projectId: string;
76
+ readonly sessionId: string;
77
+ readonly turnGen?: number;
78
+ };
79
+
80
+ /**
81
+ * Per-call explicit overrides. These layer ON TOP of the manifest's
82
+ * declarations and the agent's intrinsic floor (which is enforced
83
+ * downstream — the resolver does not see it). Overrides should only
84
+ * carry values the caller actually wants to assert; absent fields
85
+ * inherit from the manifest.
86
+ */
87
+ export interface ExplicitEnvironmentOverrides {
88
+ /** Replaces `manifest.agent.defaultModel` for this call. */
89
+ readonly model?: ModelRef;
90
+ /**
91
+ * Additive — appended after manifest-declared sub-agents. Duplicate
92
+ * slugs collapse with override winning (so a caller can refine the
93
+ * model on a manifest-declared delegate without removing it).
94
+ */
95
+ readonly subAgents?: readonly SubAgentBinding[];
96
+ /**
97
+ * Replaces `manifest.agent.role` for this call. Used by surfaces
98
+ * that legitimately re-role the same manifest (e.g. a reviewer
99
+ * borrow of a builder manifest).
100
+ */
101
+ readonly role?: AgentRunRole;
102
+ /**
103
+ * Replaces `manifest.agent.deliverableSpecRef` for this call.
104
+ */
105
+ readonly deliverableSpecRef?: string;
106
+ }
107
+
108
+ export interface EnvironmentResolverRequest {
109
+ readonly manifest: CompiledWorkspaceManifest;
110
+ readonly surface: SurfaceContext;
111
+ readonly overrides?: ExplicitEnvironmentOverrides;
112
+ /** Free-form context threaded to mount-source extractors. */
113
+ readonly agentContext: Readonly<Record<string, unknown>>;
114
+ /** Optional template resolver for seed-file template entries. */
115
+ readonly templateResolver?: TemplateResolver;
116
+ /**
117
+ * Kernel-shipped System-scope skill slugs to PREPEND to the mounted
118
+ * skill list. The resolver forwards this verbatim to the composer
119
+ * (see `ComposeWorkspaceImageRequest.systemSkillSlugs`). The caller
120
+ * is the entry-point service (workflow agent.activity, agent-session
121
+ * bootstrap) — it fetches the list from `skill-registry-api` once
122
+ * per dispatch. Omitted / empty = no System layer mounted (e.g.
123
+ * unit tests).
124
+ */
125
+ readonly systemSkillSlugs?: readonly string[];
126
+ /**
127
+ * Optional MCP stanza resolver (Phase D1). Translates the manifest's
128
+ * `toolSelection` into a `mcpServers` map suitable for direct write
129
+ * into `opencode.jsonc:mcp`. When absent, the resolved environment
130
+ * carries no MCP stanzas — appropriate for workflow surfaces today
131
+ * (no MCP support) and tests. Session-api wires its
132
+ * `SessionToolResolverService` in via this hook so the same
133
+ * snapshot covers MCP alongside agents + skills + permissions.
134
+ */
135
+ readonly mcpResolver?: McpStanzaResolver;
136
+ /**
137
+ * Run-scoped Briefcase from `ActionBaseInput.briefcase`. The resolver
138
+ * unions `briefcase.mcpTools[]` with `manifest.toolSelection` before
139
+ * calling the mcpResolver and emitting `resolved.toolSelection`,
140
+ * and threads the briefcase through to the composer for upload /
141
+ * reference mount entries. Workflow declarations are never
142
+ * SUBTRACTED — briefcase tools are strictly additive.
143
+ */
144
+ readonly briefcase?: Briefcase;
145
+ }
146
+
147
+ /**
148
+ * Kernel-tier abstraction for "given a tool selection + tenancy +
149
+ * session context, return the `mcpServers` stanza opencode.jsonc
150
+ * expects". Implementations live in Platform packages
151
+ * (`SessionToolResolverService` in `agent-session-api`) so the
152
+ * kernel never imports mcp-gateway-api directly.
153
+ */
154
+ export interface McpStanzaResolver {
155
+ resolve(
156
+ selection: readonly ToolSelectionEntry[],
157
+ context: McpStanzaResolverContext,
158
+ ): Promise<Record<string, unknown>>;
159
+ }
160
+
161
+ export interface McpStanzaResolverContext {
162
+ readonly orgId: string;
163
+ readonly projectId: string;
164
+ readonly sessionId?: string;
165
+ readonly actorSubject?: string;
166
+ }
167
+
168
+ /**
169
+ * Fully resolved execution environment — the snapshot every entry
170
+ * point hands to `applyWorkerControlSetup`. Deterministic in
171
+ * (manifest, surface, overrides, agentContext): `hash` is a sha256 of
172
+ * the canonical-form JSON so resume drift detection can compare two
173
+ * resolutions cheaply.
174
+ */
175
+ export interface ResolvedEnvironment {
176
+ readonly surface: ManifestSurface;
177
+ readonly manifest: {
178
+ readonly slug: string;
179
+ readonly version: string;
180
+ };
181
+ readonly display: CompiledManifestDisplay;
182
+ readonly agent: {
183
+ readonly slug: string;
184
+ readonly role: AgentRunRole;
185
+ readonly deliverableSpecRef?: string;
186
+ readonly model?: ModelRef;
187
+ };
188
+ readonly subAgents: readonly SubAgentBinding[];
189
+ readonly skills: readonly CompiledManifestSkillRef[];
190
+ readonly toolSelection: readonly ToolSelectionEntry[];
191
+ /**
192
+ * Baseline tools every agent receives regardless of manifest — `bash`,
193
+ * `read`, `write`, `edit`, `grep`, `glob`, `ls`, `task`, `todo_write`,
194
+ * `memory`. Fixed, closed, and not overridable: the resolver writes
195
+ * the same set on every resolution. Downstream consumers (worker
196
+ * activity, session-bundle apply) merge this into the agent's runtime
197
+ * tool surface before handing to OpenCode. Strings are the wire-shape
198
+ * tool names (members of the `IntrinsicFloorTool` enum).
199
+ */
200
+ readonly intrinsicFloorTools: readonly IntrinsicFloorTool[];
201
+ readonly credentials: readonly CompiledManifestCredential[];
202
+ readonly permissions: CompiledManifestPermissions;
203
+ readonly persistence: CompiledManifestPersistence;
204
+ readonly outputSurface: CompiledManifestOutputSurface;
205
+ readonly mountPlan: WorkspaceMountPlan;
206
+ readonly env: readonly { readonly name: string; readonly value: string }[];
207
+ /**
208
+ * Phase D1: resolved `mcpServers` map ready to drop into
209
+ * `opencode.jsonc:mcp`. Empty `{}` when the manifest declares no
210
+ * toolSelection or when the request omits an `mcpResolver`. Keeps
211
+ * the snapshot self-contained so downstream callers (workflow
212
+ * activity, session-api bundle apply) do not have to re-resolve
213
+ * MCP independently.
214
+ */
215
+ readonly mcpServers: Readonly<Record<string, unknown>>;
216
+ /**
217
+ * Deterministic sha256 of the canonical-form JSON of every other
218
+ * field. Drift detection diffs the structured fields; this hash is
219
+ * a cheap "are we different at all?" early exit.
220
+ */
221
+ readonly hash: string;
222
+ }
223
+
224
+ export interface EnvironmentResolver {
225
+ resolve(req: EnvironmentResolverRequest): Promise<ResolvedEnvironment>;
226
+ }
227
+
228
+ export class DefaultEnvironmentResolver implements EnvironmentResolver {
229
+ constructor(private readonly composer: WorkspaceImageComposer) {}
230
+
231
+ async resolve(req: EnvironmentResolverRequest): Promise<ResolvedEnvironment> {
232
+ this.assertSurfaceCompat(req.manifest, req.surface);
233
+
234
+ const role = req.overrides?.role ?? req.manifest.agent.role;
235
+ const deliverableSpecRef =
236
+ req.overrides?.deliverableSpecRef ?? req.manifest.agent.deliverableSpecRef;
237
+
238
+ const mountPlan = await this.composer.compose({
239
+ orgId: req.surface.orgId,
240
+ projectId: req.surface.projectId,
241
+ manifest: req.manifest,
242
+ agentContext: req.agentContext,
243
+ ...(req.surface.kind === ManifestSurface.WORKFLOW
244
+ ? {
245
+ workflowRun: {
246
+ runId: req.surface.runId,
247
+ jobRunId: req.surface.jobRunId,
248
+ },
249
+ }
250
+ : {
251
+ interactive: {
252
+ sessionId: req.surface.sessionId,
253
+ ...(req.surface.turnGen !== undefined
254
+ ? { turnGen: req.surface.turnGen }
255
+ : {}),
256
+ },
257
+ }),
258
+ agentOverrides: {
259
+ role,
260
+ ...(deliverableSpecRef !== undefined ? { deliverableSpecRef } : {}),
261
+ },
262
+ ...(req.templateResolver !== undefined
263
+ ? { templateResolver: req.templateResolver }
264
+ : {}),
265
+ ...(req.systemSkillSlugs !== undefined
266
+ ? { systemSkillSlugs: req.systemSkillSlugs }
267
+ : {}),
268
+ ...(req.briefcase !== undefined ? { briefcase: req.briefcase } : {}),
269
+ });
270
+
271
+ const subAgents = mergeSubAgents(
272
+ req.manifest.agent.subAgents,
273
+ req.overrides?.subAgents,
274
+ );
275
+ const model = req.overrides?.model ?? req.manifest.agent.defaultModel;
276
+
277
+ const toolSelection = mergeBriefcaseToolSelection(
278
+ req.manifest.toolSelection,
279
+ req.briefcase?.mcpTools,
280
+ );
281
+
282
+ // Phase H.2: capability federation. When an `mcpResolver` is wired
283
+ // (session-boot, workflow-runtime-worker) the resolver ALWAYS runs —
284
+ // the returned stanza is the fixed three meta-tool surface
285
+ // (`xema-capabilities-{list,describe,invoke}`), regardless of
286
+ // selection. `toolSelection` is still threaded through for snapshot
287
+ // provenance / audit, but the wire-side resolver ignores it. Empty
288
+ // `{}` only when no resolver is wired (unit tests + workflow surfaces
289
+ // that don't talk to mcp-gateway yet).
290
+ let mcpServers: Readonly<Record<string, unknown>> = Object.freeze({});
291
+ if (req.mcpResolver) {
292
+ const ctx: McpStanzaResolverContext =
293
+ req.surface.kind === ManifestSurface.AGENT_SESSION
294
+ ? {
295
+ orgId: req.surface.orgId,
296
+ projectId: req.surface.projectId,
297
+ sessionId: req.surface.sessionId,
298
+ }
299
+ : {
300
+ orgId: req.surface.orgId,
301
+ projectId: req.surface.projectId,
302
+ };
303
+ mcpServers = await req.mcpResolver.resolve(toolSelection, ctx);
304
+ }
305
+
306
+ const resolved: Omit<ResolvedEnvironment, 'hash'> = {
307
+ surface: req.surface.kind,
308
+ manifest: {
309
+ slug: req.manifest.slug,
310
+ version: req.manifest.version,
311
+ },
312
+ display: req.manifest.display,
313
+ agent: {
314
+ slug: req.manifest.agent.slug,
315
+ role,
316
+ ...(deliverableSpecRef !== undefined ? { deliverableSpecRef } : {}),
317
+ ...(model !== undefined ? { model } : {}),
318
+ },
319
+ subAgents,
320
+ skills: req.manifest.skills,
321
+ toolSelection,
322
+ // Floor is a fixed, closed set — emitted on every resolution. The
323
+ // request cannot subtract from it; the manifest cannot subtract
324
+ // from it. Downstream consumers MUST merge this set with the
325
+ // agent's authored permission block before handing to OpenCode.
326
+ intrinsicFloorTools: INTRINSIC_FLOOR_TOOLS,
327
+ credentials: req.manifest.credentials,
328
+ permissions: req.manifest.permissions,
329
+ persistence: req.manifest.persistence,
330
+ outputSurface: req.manifest.outputSurface,
331
+ mountPlan,
332
+ env: req.manifest.env,
333
+ mcpServers,
334
+ };
335
+
336
+ return { ...resolved, hash: hashResolved(resolved) };
337
+ }
338
+
339
+ private assertSurfaceCompat(
340
+ manifest: CompiledWorkspaceManifest,
341
+ surface: SurfaceContext,
342
+ ): void {
343
+ if (!manifest.surfaceCompat.includes(surface.kind)) {
344
+ throw new ManifestSurfaceMismatchError(
345
+ `Manifest '${manifest.slug}@${manifest.version}' declares surfaceCompat=[${manifest.surfaceCompat.join(', ')}] but was resolved on '${surface.kind}'.`,
346
+ );
347
+ }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Merge manifest-declared sub-agents with caller-supplied overrides.
353
+ * Overrides are additive: matching slugs collapse with override
354
+ * winning (so the caller can refine a manifest-declared delegate's
355
+ * model without redeclaring its alias). Insertion order is preserved
356
+ * so downstream `applyWorkerControlSetup` materialises a deterministic
357
+ * agent list.
358
+ */
359
+ /**
360
+ * Exported so callers that materialise a session before holding a full
361
+ * `ResolvedEnvironment` (the workflow activity's pre-resolve setup
362
+ * call, the agent-session resume path) can compose the same
363
+ * authoritative sub-agent list the resolver would produce. Single
364
+ * source of truth — pickers MUST NOT roll their own merge.
365
+ */
366
+ export function mergeSubAgents(
367
+ base: readonly SubAgentBinding[],
368
+ overrides: readonly SubAgentBinding[] | undefined,
369
+ ): readonly SubAgentBinding[] {
370
+ if (!overrides || overrides.length === 0) {return base;}
371
+ const overrideBySlug = new Map<string, SubAgentBinding>(
372
+ overrides.map((s) => [s.slug, s]),
373
+ );
374
+ const baseSlugs = new Set<string>();
375
+ const out: SubAgentBinding[] = [];
376
+ for (const b of base) {
377
+ baseSlugs.add(b.slug);
378
+ out.push(overrideBySlug.get(b.slug) ?? b);
379
+ }
380
+ for (const o of overrides) {
381
+ if (!baseSlugs.has(o.slug)) {out.push(o);}
382
+ }
383
+ return out;
384
+ }
385
+
386
+ /**
387
+ * Union the manifest's static toolSelection with the run-scoped
388
+ * briefcase tools. Briefcase entries are appended (manifest order
389
+ * wins for stable opencode key ordering); duplicates by
390
+ * `(kind, providerKind, resourceId, toolName?)` collapse.
391
+ *
392
+ * `BriefcaseMcpTool.providerKind` is wire-shape `string`; we validate
393
+ * against `ToolProviderKind` here so an out-of-enum value fails fast
394
+ * at dispatch instead of silently bypassing the manifest contract.
395
+ */
396
+ function mergeBriefcaseToolSelection(
397
+ manifestSelection: readonly ToolSelectionEntry[],
398
+ briefcaseTools: readonly BriefcaseMcpTool[] | undefined,
399
+ ): readonly ToolSelectionEntry[] {
400
+ if (!briefcaseTools || briefcaseTools.length === 0) return manifestSelection;
401
+ const out: ToolSelectionEntry[] = [...manifestSelection];
402
+ const seen = new Set<string>(out.map(toolSelectionKey));
403
+ for (const t of briefcaseTools) {
404
+ if (!isToolProviderKind(t.providerKind)) {
405
+ throw new Error(
406
+ `briefcase.mcpTools[].providerKind='${t.providerKind}' is not a known ToolProviderKind — dispatch must validate at the engine boundary.`,
407
+ );
408
+ }
409
+ const entry: ToolSelectionEntry =
410
+ t.kind === 'tool'
411
+ ? {
412
+ kind: 'tool',
413
+ providerKind: t.providerKind,
414
+ resourceId: t.resourceId,
415
+ toolName: requireToolName(t),
416
+ }
417
+ : {
418
+ kind: 'provider',
419
+ providerKind: t.providerKind,
420
+ resourceId: t.resourceId,
421
+ };
422
+ const key = toolSelectionKey(entry);
423
+ if (seen.has(key)) continue;
424
+ seen.add(key);
425
+ out.push(entry);
426
+ }
427
+ return out;
428
+ }
429
+
430
+ function toolSelectionKey(entry: ToolSelectionEntry): string {
431
+ return entry.kind === 'tool'
432
+ ? `tool:${entry.providerKind}:${entry.resourceId}:${entry.toolName}`
433
+ : `provider:${entry.providerKind}:${entry.resourceId}`;
434
+ }
435
+
436
+ function isToolProviderKind(value: string): value is ToolProviderKind {
437
+ return (Object.values(ToolProviderKind) as string[]).includes(value);
438
+ }
439
+
440
+ function requireToolName(t: BriefcaseMcpTool): string {
441
+ if (t.toolName === undefined || t.toolName.length === 0) {
442
+ throw new Error(
443
+ `briefcase.mcpTools[].toolName is required when kind='tool' (providerKind='${t.providerKind}', resourceId='${t.resourceId}').`,
444
+ );
445
+ }
446
+ return t.toolName;
447
+ }
448
+
449
+ /**
450
+ * Canonical-form sha256 of the resolved environment minus the hash
451
+ * itself. Used for cheap drift detection: if two snapshots hash to the
452
+ * same value the structured diff is guaranteed to be empty.
453
+ *
454
+ * Excludes `mountPlan` from the hash because mount-plan composition
455
+ * embeds caller-supplied lease keys / timestamps that don't actually
456
+ * change the environment shape — the comparator focuses on what the
457
+ * manifest declares, not on transient plan-side identifiers. Mount-
458
+ * level drift surfaces through the structured diff instead.
459
+ */
460
+ function hashResolved(
461
+ resolved: Omit<ResolvedEnvironment, 'hash'>,
462
+ ): string {
463
+ const { mountPlan: _mountPlan, ...rest } = resolved;
464
+ return createHash('sha256').update(canonicalJson(rest)).digest('hex');
465
+ }
466
+
467
+ function canonicalJson(value: unknown): string {
468
+ if (value === null || typeof value !== 'object') {
469
+ return JSON.stringify(value);
470
+ }
471
+ if (Array.isArray(value)) {
472
+ return `[${value.map(canonicalJson).join(',')}]`;
473
+ }
474
+ const entries = Object.entries(value as Record<string, unknown>)
475
+ .filter(([, v]) => v !== undefined)
476
+ .sort(([a], [b]) => a.localeCompare(b));
477
+ return `{${entries
478
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`)
479
+ .join(',')}}`;
480
+ }
@@ -0,0 +1,43 @@
1
+ import { AWP_V1_SPEC } from '@xemahq/kernel-contracts/agent-workspace';
2
+
3
+ /**
4
+ * Thrown by the composer when a manifest references an AWP slot key
5
+ * that is not declared in `AWP_V1_SPEC.slots`. Fail-fast — the
6
+ * alternative (silent skip) yields an incomplete workspace with no
7
+ * signal to the operator, masking typos and stale slot names from
8
+ * older spec versions.
9
+ */
10
+ /**
11
+ * Thrown by `DefaultEnvironmentResolver.resolve()` when the resolved
12
+ * manifest's `metadata.surfaceCompat` does not include the surface
13
+ * the resolver is being invoked on (e.g. a workflow step pointing at
14
+ * a manifest declared `surfaceCompat: [agent-session]`). Fail-
15
+ * fast — the alternative is a silently broken dispatch where the
16
+ * agent boots on a surface the manifest authoring time never
17
+ * considered.
18
+ */
19
+ export class ManifestSurfaceMismatchError extends Error {
20
+ override readonly name = 'ManifestSurfaceMismatchError';
21
+ }
22
+
23
+ export class UnknownSlotError extends Error {
24
+ override readonly name = 'UnknownSlotError';
25
+
26
+ constructor(
27
+ readonly slotKey: string,
28
+ readonly origin:
29
+ | 'manifest.mounts'
30
+ | 'manifest.seedFiles'
31
+ | 'rendered.mounts'
32
+ | 'inputArtifacts',
33
+ readonly manifestSlug: string,
34
+ readonly manifestVersion: string,
35
+ ) {
36
+ super(
37
+ `Workspace manifest '${manifestSlug}@${manifestVersion}' references slot '${slotKey}' from ${origin}, ` +
38
+ `but AWP_V1_SPEC declares no such slot. Known slots: [${AWP_V1_SPEC.slots
39
+ .map((s) => s.key)
40
+ .join(', ')}].`,
41
+ );
42
+ }
43
+ }
@@ -0,0 +1,139 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── Agent-session lifecycle state machine ──
3
+ //
4
+ // Pure enums + transitions. Mirrors the `SessionStatus` enum persisted by
5
+ // the agent-session-api service; the worker imports from here (the
6
+ // service's persistence-layer enums are not importable across the apps
7
+ // boundary).
8
+ //
9
+ // Single source of truth for "can this session be paused right now?".
10
+ // Both interactive-session callers AND the workflow-runtime-worker
11
+ // dispatch activity consult `canTransition()` before issuing the HTTP
12
+ // call so misuse fails fast at compile time rather than as a 409 from
13
+ // the service.
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+
16
+ /**
17
+ * Lifecycle state of an agent-session row. Mirrors the persisted enum on
18
+ * the agent-session-api service. Closed set.
19
+ */
20
+ export const SessionLifecycleState = {
21
+ creating: 'creating',
22
+ provisioning: 'provisioning',
23
+ active: 'active',
24
+ paused: 'paused',
25
+ recovering: 'recovering',
26
+ completing: 'completing',
27
+ completed: 'completed',
28
+ failed: 'failed',
29
+ archived: 'archived',
30
+ } as const;
31
+
32
+ export type SessionLifecycleStateValue =
33
+ (typeof SessionLifecycleState)[keyof typeof SessionLifecycleState];
34
+
35
+ /**
36
+ * Who created the session row. Mirrors the persisted `SessionOwnerKind`.
37
+ *
38
+ * - `interactive_session` — user-facing chat sessions
39
+ * - `pipeline_run` — workflow-runtime-worker redraft loop spines
40
+ * - `chat_thread` — reserved (no dispatcher today)
41
+ *
42
+ * The CI enum-parity check verifies this stays in lockstep with the
43
+ * persisted enum.
44
+ */
45
+ export const SessionOwnerKind = {
46
+ interactive_session: 'interactive_session',
47
+ pipeline_run: 'pipeline_run',
48
+ chat_thread: 'chat_thread',
49
+ } as const;
50
+
51
+ export type SessionOwnerKindValue =
52
+ (typeof SessionOwnerKind)[keyof typeof SessionOwnerKind];
53
+
54
+ /**
55
+ * Per-state transition matrix. `canTransition(from, to)` returns true
56
+ * iff the transition is valid.
57
+ *
58
+ * Notable transitions:
59
+ * - `paused → recovering` (resume claim)
60
+ * - `recovering → active` (resume success)
61
+ * - `recovering → paused` (resume rollback)
62
+ * - `active → completing → completed` (terminal success)
63
+ * - any-non-terminal → `failed` (terminal failure)
64
+ * - `paused | completed | failed → archived` (cleanup sweep)
65
+ */
66
+ const TRANSITIONS: Readonly<
67
+ Record<SessionLifecycleStateValue, ReadonlySet<SessionLifecycleStateValue>>
68
+ > = {
69
+ creating: new Set([
70
+ SessionLifecycleState.provisioning,
71
+ SessionLifecycleState.failed,
72
+ ]),
73
+ provisioning: new Set([
74
+ SessionLifecycleState.active,
75
+ SessionLifecycleState.failed,
76
+ ]),
77
+ active: new Set([
78
+ SessionLifecycleState.paused,
79
+ SessionLifecycleState.completing,
80
+ SessionLifecycleState.failed,
81
+ ]),
82
+ paused: new Set([
83
+ SessionLifecycleState.recovering,
84
+ SessionLifecycleState.archived,
85
+ SessionLifecycleState.failed,
86
+ ]),
87
+ recovering: new Set([
88
+ SessionLifecycleState.active,
89
+ SessionLifecycleState.paused,
90
+ SessionLifecycleState.failed,
91
+ ]),
92
+ completing: new Set([
93
+ SessionLifecycleState.completed,
94
+ SessionLifecycleState.failed,
95
+ ]),
96
+ completed: new Set([SessionLifecycleState.archived]),
97
+ failed: new Set([SessionLifecycleState.archived]),
98
+ archived: new Set(),
99
+ };
100
+
101
+ export function canTransition(
102
+ from: SessionLifecycleStateValue,
103
+ to: SessionLifecycleStateValue,
104
+ ): boolean {
105
+ return TRANSITIONS[from].has(to);
106
+ }
107
+
108
+ /**
109
+ * Terminal states — no further transitions possible (except the
110
+ * `completed | failed → archived` cleanup sweep). Callers use this to
111
+ * short-circuit "is this session done?" checks.
112
+ */
113
+ export function isTerminal(state: SessionLifecycleStateValue): boolean {
114
+ return (
115
+ state === SessionLifecycleState.completed ||
116
+ state === SessionLifecycleState.failed ||
117
+ state === SessionLifecycleState.archived
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Pausable states — the session has a live worker that can be told to
123
+ * snapshot. `provisioning` is intentionally excluded: the worker isn't
124
+ * ready yet, so a pause request would 409. Callers receiving such a
125
+ * 409 should retry with backoff once `active` is reached.
126
+ */
127
+ export function isPausable(state: SessionLifecycleStateValue): boolean {
128
+ return state === SessionLifecycleState.active;
129
+ }
130
+
131
+ /**
132
+ * Resumable states. `paused` is the canonical case. `failed` rows are
133
+ * NOT resumable from here — they require explicit operator action
134
+ * (the resume API would 409). `recovering` is a transient state owned
135
+ * by an in-flight resume; another caller observing it should back off.
136
+ */
137
+ export function isResumable(state: SessionLifecycleStateValue): boolean {
138
+ return state === SessionLifecycleState.paused;
139
+ }