@xemahq/agent-session-runtime 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,151 +0,0 @@
1
- // ═══════════════════════════════════════════════════════════════════════════
2
- // ── Agent-session dispatch wire contract ──
3
- //
4
- // Typed shapes the workflow-runtime-worker activity passes to (and
5
- // receives from) `agent-session-api`'s
6
- // `POST /internal/sessions/:id/dispatch` endpoint.
7
- //
8
- // These live in the Kernel package so the worker can depend on them
9
- // without pulling in the (large) Orval client at type-check time. The
10
- // generated Orval models in `@xemahq/agent-session-api-client` MUST be
11
- // structurally compatible — CI enforces it via a type-equality check
12
- // in `biomes/agent-runtime/api/agent-session-api/test/contract-compat.test.ts`.
13
- // ═══════════════════════════════════════════════════════════════════════════
14
-
15
- import type { ArtifactRef } from '@xemahq/kernel-contracts/workflow';
16
-
17
- /**
18
- * Mode discriminator on the dispatch envelope. Drives whether the
19
- * service creates a fresh Session row or resumes an existing one.
20
- */
21
- export const AgentSessionDispatchMode = {
22
- /** Iter 1: mint a new Session, provision a worker, run one turn, pause. */
23
- create_and_run: 'create_and_run',
24
- /** Iter N+1: resume an existing Session, mount revision inputs, run one turn, pause. */
25
- resume_and_run: 'resume_and_run',
26
- } as const;
27
-
28
- export type AgentSessionDispatchModeValue =
29
- (typeof AgentSessionDispatchMode)[keyof typeof AgentSessionDispatchMode];
30
-
31
- /**
32
- * A single revision input file to seed into `/workspace/inputs/...`
33
- * BEFORE the agent's turn starts. The dispatch service writes the file
34
- * via workspace-proxy's mount-apply call (one round-trip), so the
35
- * agent's first read of the workspace sees it.
36
- *
37
- * The path is RELATIVE to `/workspace/inputs/` and validated against
38
- * path-traversal (`..`, absolute paths, symlinks). Slot membership is
39
- * enforced — only paths inside the manifest's declared `inputs` slot
40
- * can be written.
41
- */
42
- export interface RevisionInput {
43
- /**
44
- * Path under `/workspace/inputs/`, e.g. `revisions/revision-2-<iso>.md`.
45
- * Validated: no leading slash, no `..`, must match the declared
46
- * inputs-slot subpath whitelist.
47
- */
48
- readonly path: string;
49
- /** Base64-encoded file bytes. UTF-8 markdown is the common case. */
50
- readonly contentBase64: string;
51
- }
52
-
53
- /**
54
- * Per-iteration prompt-side context the worker hands to the agent.
55
- * Kept small on purpose: the bulk of the iteration's reviewer-feedback
56
- * lives in the revision file, not here. The agent reads the file path
57
- * out of `revisionFilePath` and `read`s it from the workspace.
58
- */
59
- export interface DispatchPromptContext {
60
- /** Free-form task statement (same shape as `agentContext.prompt` today). */
61
- readonly prompt: string;
62
- /**
63
- * Path inside the workspace where the agent will find the actionable
64
- * revision directive for this iteration. Omitted on iter 1.
65
- * Example: `inputs/revisions/revision-2-2026-05-19T12-30-00Z.md`.
66
- */
67
- readonly revisionFilePath?: string;
68
- /**
69
- * Iteration number for telemetry / heartbeat. `1` for create-and-run,
70
- * `>=2` for resume-and-run.
71
- */
72
- readonly iteration: number;
73
- }
74
-
75
- /**
76
- * `mode: create_and_run` payload. The service creates a Session, calls
77
- * pool acquire, then drives one turn.
78
- */
79
- export interface CreateAndRunDispatch {
80
- readonly mode: typeof AgentSessionDispatchMode.create_and_run;
81
- /** ownerKind is forced to `pipeline_run` on this code path. */
82
- readonly pipelineRunId: string;
83
- readonly jobRunId: string;
84
- /** Stable spine identity within the workflow run. */
85
- readonly jobKey: string;
86
- /** Immutable AgentRevision selected by workflow resolution. */
87
- readonly agentRevisionId: string;
88
- /** SHA-256 of the selected AgentRevision content. */
89
- readonly agentContentHash: string;
90
- readonly agentSlug: string;
91
- readonly promptContext: DispatchPromptContext;
92
- }
93
-
94
- /**
95
- * `mode: resume_and_run` payload. The service resumes the named
96
- * Session (must be `paused`), writes the revision inputs into the
97
- * workspace as part of mount-apply, then drives one turn.
98
- */
99
- export interface ResumeAndRunDispatch {
100
- readonly mode: typeof AgentSessionDispatchMode.resume_and_run;
101
- readonly sessionId: string;
102
- readonly revisionInputs: readonly RevisionInput[];
103
- readonly promptContext: DispatchPromptContext;
104
- }
105
-
106
- export type AgentSessionDispatchRequest =
107
- | CreateAndRunDispatch
108
- | ResumeAndRunDispatch;
109
-
110
- /**
111
- * Response from the dispatch endpoint. Carries the harvested outputs
112
- * from the turn AND the session's new (post-pause) snapshot generation
113
- * so the caller can correlate the iteration with its MinIO blob.
114
- *
115
- * The `sessionId` is echoed back so a `create_and_run` caller can
116
- * record it on Temporal state for the next iteration.
117
- */
118
- export interface AgentSessionDispatchResponse {
119
- readonly sessionId: string;
120
- readonly snapshotGeneration: number;
121
- readonly pausedAt: string;
122
- /** Harvested deliverables (artifact refs emitted by the agent's turn). */
123
- readonly deliverables: readonly ArtifactRef[];
124
- /**
125
- * Free-form structured output (e.g. inquiry answers). Same shape as
126
- * today's `RawAgentActivityOutput.structuredOutput`.
127
- */
128
- readonly structuredOutput: unknown;
129
- /**
130
- * Markdown response artifact ref (if the manifest declared one).
131
- * Same shape as today's `RawAgentActivityOutput.response`.
132
- */
133
- readonly response: ArtifactRef | null;
134
- }
135
-
136
- /**
137
- * Body for `POST /internal/sessions/:id/inputs/append`. Used by
138
- * out-of-band callers (e.g. interactive-session UI file uploads) when
139
- * the fast path through mount-apply isn't applicable. Internal-only;
140
- * never exposed through the public Gateway.
141
- */
142
- export interface InputsAppendRequest {
143
- readonly path: string;
144
- readonly contentBase64: string;
145
- }
146
-
147
- export interface InputsAppendResponse {
148
- readonly written: boolean;
149
- /** SHA-256 of the bytes as written. Useful for idempotency checks. */
150
- readonly sha256: string;
151
- }
@@ -1,497 +0,0 @@
1
- // ═══════════════════════════════════════════════════════════════════════════
2
- // ── Environment drift detector ──
3
- //
4
- // Diffs two `ResolvedEnvironment` snapshots and emits a structured
5
- // drift report. Used by agent-session resume to compare the
6
- // snapshot from the previous run against a freshly-resolved snapshot
7
- // at resume time — re-resolve is the source of truth, drift is what
8
- // surfaces to the operator.
9
- //
10
- // Drift kinds are a closed enum (`DriftKind`). Severity is `info |
11
- // warning` — purely advisory: NO drift blocks a resume. Resume always
12
- // proceeds with the freshly-resolved environment; the drift list is
13
- // surfaced to the operator (timeline entry + the `SessionResumed`
14
- // degradation toast) so they see what changed. `warning` flags the
15
- // changes worth a second look (a removed sub-agent/skill/credential, a
16
- // changed agent, a tightened permission); `info` is benign drift
17
- // (version bumps, additions, value tweaks).
18
- // ═══════════════════════════════════════════════════════════════════════════
19
-
20
- import type { ToolSelectionEntry } from '@xemahq/kernel-contracts/mcp-tool';
21
- import type {
22
- CompiledManifestCredential,
23
- CompiledManifestSkillRef,
24
- SubAgentBinding,
25
- } from '@xemahq/kernel-contracts/workflow';
26
-
27
- import type { ResolvedEnvironment } from './environment-resolver';
28
-
29
- export enum DriftKind {
30
- AGENT_CHANGED = 'agent-changed',
31
- ROLE_CHANGED = 'role-changed',
32
- MANIFEST_VERSION_CHANGED = 'manifest-version-changed',
33
- MODEL_CHANGED = 'model-changed',
34
- SUBAGENT_ADDED = 'subagent-added',
35
- SUBAGENT_REMOVED = 'subagent-removed',
36
- SUBAGENT_MODEL_CHANGED = 'subagent-model-changed',
37
- SKILL_ADDED = 'skill-added',
38
- SKILL_REMOVED = 'skill-removed',
39
- SKILL_VERSION_CHANGED = 'skill-version-changed',
40
- TOOL_SELECTION_ADDED = 'tool-selection-added',
41
- TOOL_SELECTION_REMOVED = 'tool-selection-removed',
42
- CREDENTIAL_ADDED = 'credential-added',
43
- CREDENTIAL_REMOVED = 'credential-removed',
44
- CREDENTIAL_KIND_CHANGED = 'credential-kind-changed',
45
- PERMISSION_TIGHTENED = 'permission-tightened',
46
- PERMISSION_RELAXED = 'permission-relaxed',
47
- PERSISTENCE_PATH_ADDED = 'persistence-path-added',
48
- PERSISTENCE_PATH_REMOVED = 'persistence-path-removed',
49
- OUTPUT_SURFACE_CHANGED = 'output-surface-changed',
50
- ENV_VAR_ADDED = 'env-var-added',
51
- ENV_VAR_REMOVED = 'env-var-removed',
52
- ENV_VAR_VALUE_CHANGED = 'env-var-value-changed',
53
- MOUNT_ADDED = 'mount-added',
54
- MOUNT_REMOVED = 'mount-removed',
55
- MOUNT_MODE_CHANGED = 'mount-mode-changed',
56
- }
57
-
58
- /**
59
- * Advisory drift severity — `info` (benign) or `warning` (worth a look).
60
- * There is deliberately no `critical`: resume is non-blocking by design,
61
- * so severity only drives how the operator's drift
62
- * toast renders, never whether the session resumes.
63
- */
64
- export type DriftSeverity = 'info' | 'warning';
65
-
66
- export interface EnvironmentDrift {
67
- readonly kind: DriftKind;
68
- readonly severity: DriftSeverity;
69
- readonly path: string;
70
- readonly before?: unknown;
71
- readonly after?: unknown;
72
- readonly message: string;
73
- }
74
-
75
- /**
76
- * Diff two `ResolvedEnvironment` snapshots. Returns a possibly-empty
77
- * array of structured drifts. Order is stable (insertion order across
78
- * the structured walk) so callers can render a deterministic timeline
79
- * entry.
80
- *
81
- * Equal hashes short-circuit to `[]` — the hash covers every
82
- * structured field, so two snapshots with the same hash are
83
- * guaranteed to produce no drift.
84
- */
85
- export function diffResolvedEnvironment(
86
- prev: ResolvedEnvironment,
87
- next: ResolvedEnvironment,
88
- ): readonly EnvironmentDrift[] {
89
- if (prev.hash === next.hash) {return [];}
90
-
91
- const drifts: EnvironmentDrift[] = [];
92
-
93
- diffAgent(prev, next, drifts);
94
- diffSubAgents(prev.subAgents, next.subAgents, drifts);
95
- diffSkills(prev.skills, next.skills, drifts);
96
- diffToolSelection(prev.toolSelection, next.toolSelection, drifts);
97
- diffCredentials(prev.credentials, next.credentials, drifts);
98
- diffPermissions(prev, next, drifts);
99
- diffPersistence(prev, next, drifts);
100
- diffOutputSurface(prev, next, drifts);
101
- diffEnv(prev, next, drifts);
102
- diffMounts(prev, next, drifts);
103
-
104
- return drifts;
105
- }
106
-
107
- function diffAgent(
108
- prev: ResolvedEnvironment,
109
- next: ResolvedEnvironment,
110
- out: EnvironmentDrift[],
111
- ): void {
112
- if (prev.manifest.version !== next.manifest.version) {
113
- out.push({
114
- kind: DriftKind.MANIFEST_VERSION_CHANGED,
115
- severity: 'info',
116
- path: '$.manifest.version',
117
- before: prev.manifest.version,
118
- after: next.manifest.version,
119
- message: `Manifest version moved ${prev.manifest.version} → ${next.manifest.version}.`,
120
- });
121
- }
122
- if (prev.agent.slug !== next.agent.slug) {
123
- out.push({
124
- kind: DriftKind.AGENT_CHANGED,
125
- severity: 'warning',
126
- path: '$.agent.slug',
127
- before: prev.agent.slug,
128
- after: next.agent.slug,
129
- message: `Primary agent slug changed ${prev.agent.slug} → ${next.agent.slug}; the session resumes with the new agent.`,
130
- });
131
- }
132
- if (prev.agent.role !== next.agent.role) {
133
- out.push({
134
- kind: DriftKind.ROLE_CHANGED,
135
- severity: 'info',
136
- path: '$.agent.role',
137
- before: prev.agent.role,
138
- after: next.agent.role,
139
- message: `Agent role changed ${prev.agent.role} → ${next.agent.role}; AGENTS.md will be re-rendered.`,
140
- });
141
- }
142
- const prevModel = JSON.stringify(prev.agent.model ?? null);
143
- const nextModel = JSON.stringify(next.agent.model ?? null);
144
- if (prevModel !== nextModel) {
145
- out.push({
146
- kind: DriftKind.MODEL_CHANGED,
147
- severity: 'info',
148
- path: '$.agent.model',
149
- before: prev.agent.model,
150
- after: next.agent.model,
151
- message: 'Primary agent default model changed.',
152
- });
153
- }
154
- }
155
-
156
- function diffSubAgents(
157
- prev: readonly SubAgentBinding[],
158
- next: readonly SubAgentBinding[],
159
- out: EnvironmentDrift[],
160
- ): void {
161
- const prevBySlug = new Map(prev.map((s) => [s.slug, s]));
162
- const nextBySlug = new Map(next.map((s) => [s.slug, s]));
163
- for (const [slug, n] of nextBySlug) {
164
- const p = prevBySlug.get(slug);
165
- if (!p) {
166
- out.push({
167
- kind: DriftKind.SUBAGENT_ADDED,
168
- severity: 'info',
169
- path: `$.subAgents[${slug}]`,
170
- after: n,
171
- message: `Sub-agent '${slug}' added.`,
172
- });
173
- continue;
174
- }
175
- if (JSON.stringify(p.modelOverride ?? null) !== JSON.stringify(n.modelOverride ?? null)) {
176
- out.push({
177
- kind: DriftKind.SUBAGENT_MODEL_CHANGED,
178
- severity: 'info',
179
- path: `$.subAgents[${slug}].modelOverride`,
180
- before: p.modelOverride,
181
- after: n.modelOverride,
182
- message: `Sub-agent '${slug}' model override changed.`,
183
- });
184
- }
185
- }
186
- for (const [slug, p] of prevBySlug) {
187
- if (!nextBySlug.has(slug)) {
188
- out.push({
189
- kind: DriftKind.SUBAGENT_REMOVED,
190
- severity: 'warning',
191
- path: `$.subAgents[${slug}]`,
192
- before: p,
193
- message: `Sub-agent '${slug}' removed; existing delegations may fail until the session is restarted.`,
194
- });
195
- }
196
- }
197
- }
198
-
199
- function diffSkills(
200
- prev: readonly CompiledManifestSkillRef[],
201
- next: readonly CompiledManifestSkillRef[],
202
- out: EnvironmentDrift[],
203
- ): void {
204
- const prevBySlug = new Map(prev.map((s) => [s.slug, s]));
205
- const nextBySlug = new Map(next.map((s) => [s.slug, s]));
206
- for (const [slug, n] of nextBySlug) {
207
- const p = prevBySlug.get(slug);
208
- if (!p) {
209
- out.push({
210
- kind: DriftKind.SKILL_ADDED,
211
- severity: 'info',
212
- path: `$.skills[${slug}]`,
213
- after: n,
214
- message: `Skill '${slug}' added.`,
215
- });
216
- continue;
217
- }
218
- if (p.version !== n.version) {
219
- out.push({
220
- kind: DriftKind.SKILL_VERSION_CHANGED,
221
- severity: 'info',
222
- path: `$.skills[${slug}].version`,
223
- before: p.version,
224
- after: n.version,
225
- message: `Skill '${slug}' version moved ${p.version ?? '*'} → ${n.version ?? '*'}.`,
226
- });
227
- }
228
- }
229
- for (const [slug, p] of prevBySlug) {
230
- if (!nextBySlug.has(slug)) {
231
- out.push({
232
- kind: DriftKind.SKILL_REMOVED,
233
- severity: 'warning',
234
- path: `$.skills[${slug}]`,
235
- before: p,
236
- message: `Skill '${slug}' removed.`,
237
- });
238
- }
239
- }
240
- }
241
-
242
- function toolSelectionKey(entry: ToolSelectionEntry): string {
243
- return entry.kind === 'tool'
244
- ? `tool:${entry.providerKind}:${entry.resourceId}:${entry.toolName}`
245
- : `provider:${entry.providerKind}:${entry.resourceId}`;
246
- }
247
-
248
- function diffToolSelection(
249
- prev: readonly ToolSelectionEntry[],
250
- next: readonly ToolSelectionEntry[],
251
- out: EnvironmentDrift[],
252
- ): void {
253
- const prevByKey = new Map(prev.map((e) => [toolSelectionKey(e), e]));
254
- const nextByKey = new Map(next.map((e) => [toolSelectionKey(e), e]));
255
- for (const [key, n] of nextByKey) {
256
- if (!prevByKey.has(key)) {
257
- out.push({
258
- kind: DriftKind.TOOL_SELECTION_ADDED,
259
- severity: 'info',
260
- path: `$.toolSelection[${key}]`,
261
- after: n,
262
- message: `Tool selection entry '${key}' added.`,
263
- });
264
- }
265
- }
266
- for (const [key, p] of prevByKey) {
267
- if (!nextByKey.has(key)) {
268
- out.push({
269
- kind: DriftKind.TOOL_SELECTION_REMOVED,
270
- severity: 'warning',
271
- path: `$.toolSelection[${key}]`,
272
- before: p,
273
- message: `Tool selection entry '${key}' removed.`,
274
- });
275
- }
276
- }
277
- }
278
-
279
- function diffCredentials(
280
- prev: readonly CompiledManifestCredential[],
281
- next: readonly CompiledManifestCredential[],
282
- out: EnvironmentDrift[],
283
- ): void {
284
- const prevByName = new Map(prev.map((c) => [c.name, c]));
285
- const nextByName = new Map(next.map((c) => [c.name, c]));
286
- for (const [name, n] of nextByName) {
287
- const p = prevByName.get(name);
288
- if (!p) {
289
- out.push({
290
- kind: DriftKind.CREDENTIAL_ADDED,
291
- severity: 'info',
292
- path: `$.credentials[${name}]`,
293
- after: n,
294
- message: `Credential '${name}' added.`,
295
- });
296
- continue;
297
- }
298
- if (p.kind !== n.kind) {
299
- out.push({
300
- kind: DriftKind.CREDENTIAL_KIND_CHANGED,
301
- severity: 'warning',
302
- path: `$.credentials[${name}].kind`,
303
- before: p.kind,
304
- after: n.kind,
305
- message: `Credential '${name}' kind changed ${p.kind} → ${n.kind}; the resolver will re-route it on resume.`,
306
- });
307
- }
308
- }
309
- for (const [name, p] of prevByName) {
310
- if (!nextByName.has(name)) {
311
- out.push({
312
- kind: DriftKind.CREDENTIAL_REMOVED,
313
- // Resume is non-blocking even for a previously-required
314
- // credential — the restore path degrades to a still-usable
315
- // provider. `warning` simply flags it for the operator.
316
- severity: 'warning',
317
- path: `$.credentials[${name}]`,
318
- before: p,
319
- message: `Credential '${name}' removed${p.required ? ' (was required; the session resumes with the remaining credentials)' : ''}.`,
320
- });
321
- }
322
- }
323
- }
324
-
325
- function diffPermissions(
326
- prev: ResolvedEnvironment,
327
- next: ResolvedEnvironment,
328
- out: EnvironmentDrift[],
329
- ): void {
330
- const prevAllow = new Set(prev.permissions.tools.allow);
331
- const nextAllow = new Set(next.permissions.tools.allow);
332
- const prevDeny = new Set(prev.permissions.tools.deny);
333
- const nextDeny = new Set(next.permissions.tools.deny);
334
- // A tool is "tightened" if it was previously allowed and is now
335
- // explicitly denied OR removed from the allow-list.
336
- for (const tool of prevAllow) {
337
- if (!nextAllow.has(tool) || nextDeny.has(tool)) {
338
- out.push({
339
- kind: DriftKind.PERMISSION_TIGHTENED,
340
- severity: 'warning',
341
- path: `$.permissions.tools.${tool}`,
342
- before: 'allow',
343
- after: nextDeny.has(tool) ? 'deny' : 'unset',
344
- message: `Tool '${tool}' is no longer allowed.`,
345
- });
346
- }
347
- }
348
- for (const tool of nextAllow) {
349
- if (!prevAllow.has(tool) || prevDeny.has(tool)) {
350
- out.push({
351
- kind: DriftKind.PERMISSION_RELAXED,
352
- severity: 'info',
353
- path: `$.permissions.tools.${tool}`,
354
- before: prevDeny.has(tool) ? 'deny' : 'unset',
355
- after: 'allow',
356
- message: `Tool '${tool}' is now allowed.`,
357
- });
358
- }
359
- }
360
- }
361
-
362
- function diffPersistence(
363
- prev: ResolvedEnvironment,
364
- next: ResolvedEnvironment,
365
- out: EnvironmentDrift[],
366
- ): void {
367
- const prevPaths = new Set(prev.persistence.paths);
368
- const nextPaths = new Set(next.persistence.paths);
369
- for (const path of nextPaths) {
370
- if (!prevPaths.has(path)) {
371
- out.push({
372
- kind: DriftKind.PERSISTENCE_PATH_ADDED,
373
- severity: 'info',
374
- path: `$.persistence.paths`,
375
- after: path,
376
- message: `Persistence path '${path}' added; future pauses will snapshot it.`,
377
- });
378
- }
379
- }
380
- for (const path of prevPaths) {
381
- if (!nextPaths.has(path)) {
382
- out.push({
383
- kind: DriftKind.PERSISTENCE_PATH_REMOVED,
384
- severity: 'info',
385
- path: `$.persistence.paths`,
386
- before: path,
387
- message: `Persistence path '${path}' removed; future pauses will not snapshot it.`,
388
- });
389
- }
390
- }
391
- }
392
-
393
- function diffOutputSurface(
394
- prev: ResolvedEnvironment,
395
- next: ResolvedEnvironment,
396
- out: EnvironmentDrift[],
397
- ): void {
398
- if (
399
- prev.outputSurface.kind !== next.outputSurface.kind ||
400
- prev.outputSurface.port !== next.outputSurface.port ||
401
- prev.outputSurface.healthPath !== next.outputSurface.healthPath ||
402
- prev.outputSurface.mode !== next.outputSurface.mode
403
- ) {
404
- out.push({
405
- kind: DriftKind.OUTPUT_SURFACE_CHANGED,
406
- severity: 'info',
407
- path: '$.outputSurface',
408
- before: prev.outputSurface,
409
- after: next.outputSurface,
410
- message: 'Output surface configuration changed.',
411
- });
412
- }
413
- }
414
-
415
- function diffEnv(
416
- prev: ResolvedEnvironment,
417
- next: ResolvedEnvironment,
418
- out: EnvironmentDrift[],
419
- ): void {
420
- const prevByName = new Map(prev.env.map((e) => [e.name, e.value]));
421
- const nextByName = new Map(next.env.map((e) => [e.name, e.value]));
422
- for (const [name, value] of nextByName) {
423
- const prevValue = prevByName.get(name);
424
- if (prevValue === undefined) {
425
- out.push({
426
- kind: DriftKind.ENV_VAR_ADDED,
427
- severity: 'info',
428
- path: `$.env[${name}]`,
429
- after: value,
430
- message: `Env var '${name}' added.`,
431
- });
432
- } else if (prevValue !== value) {
433
- out.push({
434
- kind: DriftKind.ENV_VAR_VALUE_CHANGED,
435
- severity: 'info',
436
- path: `$.env[${name}]`,
437
- before: prevValue,
438
- after: value,
439
- message: `Env var '${name}' value changed.`,
440
- });
441
- }
442
- }
443
- for (const name of prevByName.keys()) {
444
- if (!nextByName.has(name)) {
445
- out.push({
446
- kind: DriftKind.ENV_VAR_REMOVED,
447
- severity: 'warning',
448
- path: `$.env[${name}]`,
449
- before: prevByName.get(name),
450
- message: `Env var '${name}' removed.`,
451
- });
452
- }
453
- }
454
- }
455
-
456
- function diffMounts(
457
- prev: ResolvedEnvironment,
458
- next: ResolvedEnvironment,
459
- out: EnvironmentDrift[],
460
- ): void {
461
- const prevSlots = new Map(prev.mountPlan.entries.map((e) => [e.slot, e]));
462
- const nextSlots = new Map(next.mountPlan.entries.map((e) => [e.slot, e]));
463
- for (const [slot, entry] of nextSlots) {
464
- const prevEntry = prevSlots.get(slot);
465
- if (!prevEntry) {
466
- out.push({
467
- kind: DriftKind.MOUNT_ADDED,
468
- severity: 'info',
469
- path: `$.mounts[${slot}]`,
470
- after: { mountKey: entry.mountKey },
471
- message: `Mount slot '${slot}' added.`,
472
- });
473
- continue;
474
- }
475
- if (prevEntry.mode !== entry.mode) {
476
- out.push({
477
- kind: DriftKind.MOUNT_MODE_CHANGED,
478
- severity: 'warning',
479
- path: `$.mounts[${slot}].mode`,
480
- before: prevEntry.mode,
481
- after: entry.mode,
482
- message: `Mount slot '${slot}' mode changed ${prevEntry.mode} → ${entry.mode}.`,
483
- });
484
- }
485
- }
486
- for (const slot of prevSlots.keys()) {
487
- if (!nextSlots.has(slot)) {
488
- out.push({
489
- kind: DriftKind.MOUNT_REMOVED,
490
- severity: 'warning',
491
- path: `$.mounts[${slot}]`,
492
- before: { slot },
493
- message: `Mount slot '${slot}' removed.`,
494
- });
495
- }
496
- }
497
- }