@agentxm/workspace-operations 0.28.4-bootstrap.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.
Files changed (45) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +12 -0
  3. package/dist/src/index.d.ts +31 -0
  4. package/dist/src/index.js +42 -0
  5. package/dist/src/live.d.ts +13 -0
  6. package/dist/src/live.js +12 -0
  7. package/dist/src/operations/augment-plan.d.ts +25 -0
  8. package/dist/src/operations/augment-plan.js +51 -0
  9. package/dist/src/operations/load-workspace.d.ts +43 -0
  10. package/dist/src/operations/load-workspace.js +77 -0
  11. package/dist/src/operations/scan-plan-readiness.d.ts +22 -0
  12. package/dist/src/operations/scan-plan-readiness.js +41 -0
  13. package/dist/src/operations/transaction.d.ts +58 -0
  14. package/dist/src/operations/transaction.js +359 -0
  15. package/dist/src/operations/transition-lock.d.ts +66 -0
  16. package/dist/src/operations/transition-lock.js +291 -0
  17. package/dist/src/plan/apply-plan.d.ts +45 -0
  18. package/dist/src/plan/apply-plan.js +238 -0
  19. package/dist/src/plan/errors.d.ts +85 -0
  20. package/dist/src/plan/errors.js +101 -0
  21. package/dist/src/plan/execution-candidate.d.ts +20 -0
  22. package/dist/src/plan/execution-candidate.js +95 -0
  23. package/dist/src/plan/interruption-signal.d.ts +18 -0
  24. package/dist/src/plan/interruption-signal.js +13 -0
  25. package/dist/src/plan/job-step-message.d.ts +7 -0
  26. package/dist/src/plan/job-step-message.js +7 -0
  27. package/dist/src/plan/operation-events.d.ts +62 -0
  28. package/dist/src/plan/operation-events.js +41 -0
  29. package/dist/src/plan/operation-journal.d.ts +69 -0
  30. package/dist/src/plan/operation-journal.js +52 -0
  31. package/dist/src/plan/operation-resolution.d.ts +219 -0
  32. package/dist/src/plan/operation-resolution.js +324 -0
  33. package/dist/src/plan/plan-execution.d.ts +75 -0
  34. package/dist/src/plan/plan-execution.js +117 -0
  35. package/dist/src/plan/plan.d.ts +248 -0
  36. package/dist/src/plan/plan.js +95 -0
  37. package/dist/src/plan/resolve-plan-interaction.d.ts +79 -0
  38. package/dist/src/plan/resolve-plan-interaction.js +52 -0
  39. package/dist/src/plan/resolve-plan.d.ts +42 -0
  40. package/dist/src/plan/resolve-plan.js +694 -0
  41. package/dist/src/plan/step-failure-conversions.d.ts +33 -0
  42. package/dist/src/plan/step-failure-conversions.js +177 -0
  43. package/dist/src/testing.d.ts +11 -0
  44. package/dist/src/testing.js +11 -0
  45. package/package.json +61 -0
@@ -0,0 +1,694 @@
1
+ /**
2
+ * Plan preview/apply function.
3
+ *
4
+ * Orchestrates `augmentPlanWithReconciliation`, `scanPlanReadiness`,
5
+ * and `applyPlan` with the `ResolvePlanInteraction` port, and produces
6
+ * one `OperationResolution` at every termination path. Channels project the
7
+ * returned resolution; presentation and prompting live behind the port, and
8
+ * per-type outcome refinement behind the optional
9
+ * `ConfiguredAgentOutcomesProvider` port.
10
+ *
11
+ * This is a free function, not a method on WorkspaceMutationsService.
12
+ *
13
+ * @experimental This API is unstable and may change without notice.
14
+ */
15
+ import * as Cause from "effect/Cause";
16
+ import * as FileSystem from "effect/FileSystem";
17
+ import * as Path from "effect/Path";
18
+ import * as Effect from "effect/Effect";
19
+ import * as Layer from "effect/Layer";
20
+ import * as Option from "effect/Option";
21
+ import { ApprovalRecoveryMissing, STALE_CANDIDATE_DETAIL, StaleExecutionCandidate, StepFailure, } from "./errors.js";
22
+ import { applyPlan } from "./apply-plan.js";
23
+ import { isExecutionCandidateFresh, makeExecutionCandidate, } from "./execution-candidate.js";
24
+ import { augmentPlanWithReconciliation } from "../operations/augment-plan.js";
25
+ import { scanPlanReadiness } from "../operations/scan-plan-readiness.js";
26
+ import { declaredAtomicity, executedUnits, makeOperationResolution, plannedUnits, unitIdOf, } from "./operation-resolution.js";
27
+ import { appendResolvedUnit, appendStartedUnit, recordJournalPhase, recordOperationJournal, } from "./operation-journal.js";
28
+ import { publishLifecycleEvent, publishPhaseStarted } from "./operation-events.js";
29
+ import { WorkspaceMutations } from "@agentxm/workspace-state";
30
+ import { readPendingClosureRestorationFailures, WorkspaceRestorationIncomplete, } from "@agentxm/workspace-state";
31
+ import { rollbackWorkspaceClosure, settleWorkspaceClosure, withWorkspaceClosure, } from "../operations/transaction.js";
32
+ import { readFootprint } from "@agentxm/workspace-state";
33
+ import { InterruptionSignalSource } from "./interruption-signal.js";
34
+ import { ResolvePlanInteraction } from "./resolve-plan-interaction.js";
35
+ import { confirmationRecoverySuggestions, namedPolicyRecoverySuggestions, } from "./plan-execution.js";
36
+ import { ConfiguredAgentOutcomesProvider } from "@agentxm/workspace-state";
37
+ import { isMcpServerApplicableToAgent } from "@agentxm/workspace-state";
38
+ import { configuredAgentLifecycleOutcomes } from "@agentxm/workspace-state";
39
+ import { candidateFingerprintFailedToStepFailure, configuredAgentOutcomesUnavailableToStepFailure, restorationIncompleteToStepFailure, workspaceStateReadFailureToStepFailure, workspaceTransactionFailureToStepFailure, } from "./step-failure-conversions.js";
40
+ /** Publish a phase transition to the lifecycle stream and the journal. */
41
+ const enterPhase = (phase) => publishPhaseStarted(phase).pipe(Effect.andThen(recordJournalPhase(phase)));
42
+ const withPlannedAgentOutcomes = (plan, outcomes) => ({
43
+ ...plan,
44
+ jobs: plan.jobs.map((job) => ({
45
+ ...job,
46
+ steps: job.steps.map((step) => ({
47
+ ...step,
48
+ agentOutcomes: step.agentOutcomes === undefined || step.agentOutcomes.length === 0
49
+ ? outcomes
50
+ : step.agentOutcomes,
51
+ ...(step.artifact === undefined
52
+ ? {}
53
+ : {
54
+ artifact: {
55
+ ...step.artifact,
56
+ agentOutcomes: step.artifact.agentOutcomes === undefined ||
57
+ step.artifact.agentOutcomes.length === 0
58
+ ? outcomes
59
+ : step.artifact.agentOutcomes,
60
+ },
61
+ }),
62
+ })),
63
+ })),
64
+ });
65
+ const withExecutedAgentOutcomes = (plan, outcomes) => ({
66
+ ...plan,
67
+ jobs: plan.jobs.map((job) => ({
68
+ ...job,
69
+ steps: job.steps.map((step) => ({
70
+ ...step,
71
+ agentOutcomes: outcomes,
72
+ ...(step.result.result === "success" && step.result.artifact !== undefined
73
+ ? {
74
+ result: {
75
+ ...step.result,
76
+ artifact: {
77
+ ...step.result.artifact,
78
+ agentOutcomes: step.result.artifact.agentOutcomes === undefined ||
79
+ step.result.artifact.agentOutcomes.length === 0
80
+ ? outcomes
81
+ : step.result.artifact.agentOutcomes,
82
+ },
83
+ },
84
+ }
85
+ : {}),
86
+ })),
87
+ })),
88
+ });
89
+ /**
90
+ * Preview or apply (display, confirm, and execute) a plan using the workspace read model.
91
+ *
92
+ * Steps:
93
+ * 1. Augment plan with lockfile reconciliation if needed
94
+ * 2. Scan for errors/warnings
95
+ * 3. Construct and display the exact candidate
96
+ * 4. Fail closed on blockers and missing named policies
97
+ * 5. Preview or approve confirmable semantic risk
98
+ * 6. Revalidate and apply the same candidate
99
+ *
100
+ * Every termination path resolves to one `OperationResolution`.
101
+ */
102
+ export const previewOrApplyPlan = Effect.fn("previewOrApplyPlan")(function* (plan, options) {
103
+ const ws = yield* WorkspaceMutations;
104
+ const interaction = yield* ResolvePlanInteraction;
105
+ const fs = yield* FileSystem.FileSystem;
106
+ const path = yield* Path.Path;
107
+ const fsLayer = Layer.mergeAll(Layer.succeed(FileSystem.FileSystem, fs), Layer.succeed(Path.Path, path));
108
+ const mode = options.execution.request.mode;
109
+ yield* publishPhaseStarted("planning");
110
+ // Step 1: Lockfile reconciliation
111
+ const augmented = yield* interaction.withPlanningProgress(plan.name, () => augmentPlanWithReconciliation(plan, () => ws.getLockfileState()));
112
+ const operations = options.execution.configuredAgentOperations ?? [];
113
+ const configuredAgents = operations.length === 0 ? [] : yield* ws.getConfiguredAgents();
114
+ const configuredMcpServers = operations.some(({ extensionType }) => extensionType === "mcp-server")
115
+ ? yield* ws.getConfiguredMcpServerEntries()
116
+ : {};
117
+ const outcomesProvider = yield* Effect.serviceOption(ConfiguredAgentOutcomesProvider);
118
+ const outcomesOverrideFor = (extensionType) => Option.isSome(outcomesProvider)
119
+ ? outcomesProvider.value.byExtensionType[extensionType]
120
+ : undefined;
121
+ const outcomesFor = (operation, state) => {
122
+ const mcpEntry = operation.extensionType === "mcp-server" ? configuredMcpServers[operation.name] : undefined;
123
+ const generic = configuredAgentLifecycleOutcomes({
124
+ type: operation.extensionType,
125
+ name: operation.name,
126
+ agentIds: configuredAgents,
127
+ scope: ws.scope,
128
+ state,
129
+ targetState: operation.plannedState,
130
+ installed: state === "projected",
131
+ observedAgentIds: state === "projected" ? configuredAgents : [],
132
+ ...(mcpEntry === undefined
133
+ ? {}
134
+ : {
135
+ applicableAgentIds: configuredAgents.filter((agentId) => isMcpServerApplicableToAgent(mcpEntry, agentId)),
136
+ }),
137
+ });
138
+ const override = outcomesOverrideFor(operation.extensionType);
139
+ if (operation.plannedState === "enabled" && override !== undefined) {
140
+ return override(state).pipe(Effect.map((outcomes) => outcomes.filter(({ name }) => name === operation.name)), Effect.map((outcomes) => (outcomes.length === 0 ? generic : outcomes)), Effect.catch(() => Effect.succeed(generic)));
141
+ }
142
+ return Effect.succeed(generic);
143
+ };
144
+ const projectedOutcomes = (yield* Effect.forEach(operations, (operation) => outcomesFor(operation, "projected"))).flat();
145
+ const augmentedPlan = operations.length === 0
146
+ ? augmented.plan
147
+ : withPlannedAgentOutcomes(augmented.plan, projectedOutcomes);
148
+ // Step 2: Scan readiness and construct semantic risk conditions.
149
+ const readiness = scanPlanReadiness(augmentedPlan);
150
+ const declaredConditionIds = new Set((augmentedPlan.riskConditions ?? []).map((condition) => condition.id));
151
+ const readinessBlockers = augmentedPlan.jobs.flatMap((job) => job.steps.flatMap((step) => step.readiness === "error"
152
+ ? (step.blockingConditionIds ?? []).length > 0 &&
153
+ (step.blockingConditionIds ?? []).every((id) => declaredConditionIds.has(id))
154
+ ? []
155
+ : [
156
+ {
157
+ level: "blocked",
158
+ id: step.key ?? step.label,
159
+ detail: step.errorMessage,
160
+ errorCode: "conflict",
161
+ },
162
+ ]
163
+ : []));
164
+ const preconditionBlockers = (augmentedPlan.preconditions ?? []).flatMap((precondition) => precondition.status === "unmet"
165
+ ? [
166
+ {
167
+ level: "blocked",
168
+ id: precondition.id,
169
+ detail: precondition.detail ?? precondition.label,
170
+ errorCode: precondition.blockedOn === "human"
171
+ ? "auth_required"
172
+ : "conflict",
173
+ },
174
+ ]
175
+ : []);
176
+ const riskConditions = [
177
+ ...(augmentedPlan.riskConditions ?? []),
178
+ ...readinessBlockers,
179
+ ...preconditionBlockers,
180
+ ];
181
+ const candidatePlan = {
182
+ ...augmentedPlan,
183
+ ...(riskConditions.length === 0 ? {} : { riskConditions }),
184
+ };
185
+ const candidate = yield* makeExecutionCandidate(candidatePlan, {
186
+ settingsPath: ws.layout.settingsPath,
187
+ lockPath: ws.layout.lockPath,
188
+ baseDir: ws.baseDir,
189
+ }).pipe(Effect.provide(fsLayer));
190
+ const atomicity = declaredAtomicity(candidatePlan);
191
+ const resolutionBase = {
192
+ name: candidatePlan.name,
193
+ description: candidatePlan.description,
194
+ mode,
195
+ candidateId: candidate.id,
196
+ releaseAge: candidatePlan.releaseAge,
197
+ preconditions: candidatePlan.preconditions,
198
+ riskConditions: riskConditions.length === 0 ? undefined : riskConditions,
199
+ presentation: candidatePlan.presentation,
200
+ };
201
+ yield* recordOperationJournal({
202
+ name: candidatePlan.name,
203
+ description: candidatePlan.description,
204
+ mode,
205
+ candidateId: candidate.id,
206
+ atomicity: { declared: atomicity, applied: atomicity },
207
+ ...(candidatePlan.presentation === undefined
208
+ ? {}
209
+ : { presentation: candidatePlan.presentation }),
210
+ ...(candidatePlan.releaseAge === undefined ? {} : { releaseAge: candidatePlan.releaseAge }),
211
+ ...(candidatePlan.preconditions === undefined
212
+ ? {}
213
+ : { preconditions: candidatePlan.preconditions }),
214
+ ...(riskConditions.length === 0 ? {} : { riskConditions }),
215
+ plannedUnits: plannedUnits(candidatePlan.jobs),
216
+ phase: "planning",
217
+ startedUnitIds: [],
218
+ resolved: [],
219
+ restoresOnFailure: candidatePlan.executionCapabilities?.rollback !== "non-rollbackable",
220
+ });
221
+ const notExecuted = (over) => makeOperationResolution({
222
+ ...resolutionBase,
223
+ atomicity: { declared: atomicity, applied: "closure-atomic" },
224
+ units: over.units ?? plannedUnits(candidatePlan.jobs),
225
+ blocking: over.blocking,
226
+ declined: over.declined,
227
+ failure: over.failure,
228
+ suggestions: candidatePlan.failureSuggestions,
229
+ });
230
+ // Step 3: Display the immutable candidate before any policy terminal or effect.
231
+ if (mode === "preview") {
232
+ yield* enterPhase("preview");
233
+ }
234
+ const hasConfirmableRisk = riskConditions.some((condition) => condition.level === "confirmable");
235
+ yield* interaction.presentPlan(candidatePlan, { mode });
236
+ // Step 4: Hard blockers dominate preview, overrides, and confirmation.
237
+ if (readiness.hasErrors) {
238
+ const firstError = readinessBlockers[0];
239
+ return notExecuted({
240
+ blocking: {
241
+ class: "precondition-unmet",
242
+ subject: firstError?.id ?? candidatePlan.name,
243
+ phase: "planning",
244
+ detail: firstError?.detail ?? readiness.errorMessages[0] ?? "The plan cannot proceed.",
245
+ causeCode: firstError?.errorCode ?? "conflict",
246
+ },
247
+ });
248
+ }
249
+ const blocked = riskConditions.find((condition) => condition.level === "blocked");
250
+ if (blocked !== undefined) {
251
+ return notExecuted({
252
+ blocking: {
253
+ class: "precondition-unmet",
254
+ subject: blocked.id,
255
+ phase: "planning",
256
+ detail: blocked.detail,
257
+ causeCode: blocked.errorCode,
258
+ reference: blocked.id,
259
+ },
260
+ });
261
+ }
262
+ // Step 5: Preview is speculative and never grants approval to a later invocation.
263
+ if (options.execution.request.mode === "preview") {
264
+ return makeOperationResolution({
265
+ ...resolutionBase,
266
+ atomicity: { declared: atomicity, applied: "closure-atomic" },
267
+ units: plannedUnits(candidatePlan.jobs),
268
+ });
269
+ }
270
+ if (!("approvalRecovery" in options.execution)) {
271
+ return yield* new ApprovalRecoveryMissing();
272
+ }
273
+ const applyExecution = options.execution;
274
+ const overrideConditions = riskConditions.filter((condition) => condition.level === "override-required");
275
+ const missingOverrides = overrideConditions.filter((condition) => !applyExecution.request.acceptedPolicies.has(condition.policy));
276
+ if (missingOverrides.length > 0) {
277
+ const first = missingOverrides[0];
278
+ const escapes = namedPolicyRecoverySuggestions(applyExecution.approvalRecovery, missingOverrides.map((condition) => condition.requiredFlag));
279
+ return makeOperationResolution({
280
+ ...resolutionBase,
281
+ atomicity: { declared: atomicity, applied: "closure-atomic" },
282
+ units: plannedUnits(candidatePlan.jobs),
283
+ blocking: {
284
+ class: "override-required",
285
+ subject: first?.id ?? candidatePlan.name,
286
+ phase: "confirmation",
287
+ detail: first?.detail ?? "A named policy override is required.",
288
+ ...(escapes[0] === undefined ? {} : { escape: escapes[0] }),
289
+ },
290
+ suggestions: escapes,
291
+ });
292
+ }
293
+ const hasSteps = candidatePlan.jobs.some((job) => job.steps.length > 0);
294
+ if (hasSteps &&
295
+ hasConfirmableRisk &&
296
+ applyExecution.request.confirmableRiskApproval === "prompt-if-interactive") {
297
+ if (!(yield* interaction.isConfirmationAvailable)) {
298
+ const confirmable = riskConditions.find((condition) => condition.level === "confirmable");
299
+ const escapes = confirmationRecoverySuggestions(applyExecution.approvalRecovery);
300
+ return makeOperationResolution({
301
+ ...resolutionBase,
302
+ atomicity: { declared: atomicity, applied: "closure-atomic" },
303
+ units: plannedUnits(candidatePlan.jobs),
304
+ blocking: {
305
+ class: "approval-required",
306
+ subject: confirmable?.id ?? candidatePlan.name,
307
+ phase: "confirmation",
308
+ detail: confirmable?.detail ?? "This plan requires confirmation before it can apply.",
309
+ ...(escapes[0] === undefined ? {} : { escape: escapes[0] }),
310
+ },
311
+ suggestions: escapes,
312
+ });
313
+ }
314
+ yield* enterPhase("confirmation");
315
+ const confirmation = yield* interaction.confirmApplyChanges(applyExecution.approvalRecovery);
316
+ if (confirmation !== "approved") {
317
+ return notExecuted({ declined: true });
318
+ }
319
+ }
320
+ // Step 6: Acquire the workspace transition — planning, network acquisition,
321
+ // preview, and confirmation ran without it — then revalidate every material
322
+ // candidate preimage and apply the exact candidate while holding it.
323
+ const totalUnits = candidate.plan.jobs.reduce((count, job) => count + job.steps.length, 0);
324
+ const resolvedUnitState = (step) => executedUnits({
325
+ _tag: "ExecutedPlan",
326
+ name: candidatePlan.name,
327
+ description: candidatePlan.description,
328
+ jobs: [{ concurrency: 1, steps: [step] }],
329
+ }, { restored: false })[0]?.state ?? "committed";
330
+ let startedUnits = 0;
331
+ let resolvedUnits = 0;
332
+ const applyFreshCandidate = Effect.gen(function* () {
333
+ yield* enterPhase("validation");
334
+ if (!(yield* isExecutionCandidateFresh(candidate).pipe(Effect.provide(fsLayer)))) {
335
+ return yield* new StaleExecutionCandidate({ candidate: candidatePlan.name });
336
+ }
337
+ if (options.beforeApply !== undefined) {
338
+ yield* options.beforeApply(candidate);
339
+ if (!(yield* isExecutionCandidateFresh(candidate).pipe(Effect.provide(fsLayer)))) {
340
+ return yield* new StaleExecutionCandidate({ candidate: candidatePlan.name });
341
+ }
342
+ }
343
+ yield* enterPhase("apply");
344
+ // Each unit is one semantic closure: its run executes under its closure
345
+ // identity so the transaction attributes every snapshot to it, and its
346
+ // settlement (below) either commits or rolls back exactly that closure.
347
+ const closureScopedPlan = {
348
+ ...candidate.plan,
349
+ jobs: candidate.plan.jobs.map((job) => ({
350
+ ...job,
351
+ steps: job.steps.map((step) => step.readiness === "error"
352
+ ? step
353
+ : { ...step, run: withWorkspaceClosure(unitIdOf(step))(step.run) }),
354
+ })),
355
+ };
356
+ return yield* applyPlan(closureScopedPlan, {
357
+ // The started fact is journaled before the run's first effect, so an
358
+ // interruption mid-run reports the unit in flight, never not attempted.
359
+ onStepStarted: (step) => appendStartedUnit(unitIdOf(step)).pipe(Effect.andThen(publishLifecycleEvent((atNanos) => ({
360
+ _tag: "UnitStarted",
361
+ unitId: unitIdOf(step),
362
+ label: step.label,
363
+ index: startedUnits++,
364
+ total: totalUnits,
365
+ atNanos,
366
+ })))),
367
+ // Settlement runs before the next interruptible boundary: the journal
368
+ // fact and the closure's snapshot disposition are recorded together —
369
+ // a settled closure's commits stand, a failed closure restores only
370
+ // itself, and later ready closures continue.
371
+ onStepCompleted: (step) => appendResolvedUnit(step).pipe(Effect.andThen(step.result.result === "error"
372
+ ? rollbackWorkspaceClosure(unitIdOf(step))
373
+ : settleWorkspaceClosure(unitIdOf(step))), Effect.andThen(publishLifecycleEvent((atNanos) => ({
374
+ _tag: "UnitResolved",
375
+ unitId: unitIdOf(step),
376
+ label: step.label,
377
+ state: resolvedUnitState(step),
378
+ index: resolvedUnits++,
379
+ total: totalUnits,
380
+ atNanos,
381
+ })))),
382
+ });
383
+ });
384
+ const applyCandidate = Effect.gen(function* () {
385
+ const result = yield* applyFreshCandidate.pipe(Effect.mapError((error) => ({
386
+ error: error._tag === "CandidateFingerprintFailed"
387
+ ? candidateFingerprintFailedToStepFailure(error)
388
+ : error,
389
+ })));
390
+ const failedStep = result.jobs
391
+ .flatMap((job) => job.steps)
392
+ .find((step) => step.result.result === "error");
393
+ // Closures settle independently: a failed closure rolled back only
394
+ // itself at its settlement boundary, and settled commits stand. The one
395
+ // apply-level failure is a closure rollback that did not complete and
396
+ // verify — the typed restoration fact derives the retained truth from
397
+ // the in-memory pending record alone.
398
+ const pendingRestoration = yield* readPendingClosureRestorationFailures;
399
+ if (Option.isSome(pendingRestoration) && pendingRestoration.value.failures.length > 0) {
400
+ const pending = pendingRestoration.value;
401
+ const first = pending.failures[0];
402
+ const stepError = failedStep !== undefined && failedStep.result.result === "error"
403
+ ? failedStep.result.error
404
+ : new StepFailure({
405
+ category: "internal",
406
+ detail: "a closure rollback did not complete",
407
+ });
408
+ return yield* Effect.fail({
409
+ error: new StepFailure({
410
+ category: stepError.category,
411
+ detail: failedStep?.result.result === "error" ? failedStep.result.message : stepError.detail,
412
+ cause: stepError,
413
+ }),
414
+ attemptedExecution: result,
415
+ restoration: new WorkspaceRestorationIncomplete({
416
+ terminationCause: "failure",
417
+ transitionCause: Cause.fail(stepError),
418
+ restorationCause: first?.restorationCause,
419
+ snapshotDir: pending.snapshotDir,
420
+ retained: pending.failures.flatMap((failure) => failure.retained),
421
+ closureIds: pending.failures.map((failure) => failure.closureId),
422
+ }),
423
+ });
424
+ }
425
+ if (operations.length === 0) {
426
+ return { ...result, candidateId: candidate.id };
427
+ }
428
+ const currentOutcomes = (yield* Effect.forEach(operations, (operation) => {
429
+ const override = outcomesOverrideFor(operation.extensionType);
430
+ return operation.plannedState === "enabled" && override !== undefined
431
+ ? override("current").pipe(Effect.map((outcomes) => outcomes.filter(({ name }) => name === operation.name)), Effect.mapError(configuredAgentOutcomesUnavailableToStepFailure))
432
+ : operation.plannedState === "enabled"
433
+ ? ws.records.getExtensionInventory(operation.extensionType, {}).pipe(Effect.mapError(workspaceStateReadFailureToStepFailure), Effect.map((inventory) => inventory.items.find((item) => item.name === operation.name)?.agentOutcomes ??
434
+ configuredAgentLifecycleOutcomes({
435
+ type: operation.extensionType,
436
+ name: operation.name,
437
+ agentIds: configuredAgents,
438
+ scope: ws.scope,
439
+ state: "current",
440
+ targetState: "enabled",
441
+ installed: false,
442
+ })))
443
+ : Effect.succeed(configuredAgentLifecycleOutcomes({
444
+ type: operation.extensionType,
445
+ name: operation.name,
446
+ agentIds: configuredAgents,
447
+ scope: ws.scope,
448
+ state: "current",
449
+ targetState: operation.plannedState,
450
+ installed: false,
451
+ }));
452
+ })).flat();
453
+ const incomplete = currentOutcomes.find(({ outcome }) => outcome === "blocked" || outcome === "failed");
454
+ const executedWithOutcomes = withExecutedAgentOutcomes(result, currentOutcomes);
455
+ if (incomplete !== undefined) {
456
+ return yield* Effect.fail({
457
+ error: new StepFailure({
458
+ category: "conflict",
459
+ detail: `${incomplete.extensionType} ${incomplete.name} did not converge for ${incomplete.agentId}: ${incomplete.reason}`,
460
+ }),
461
+ attemptedExecution: executedWithOutcomes,
462
+ });
463
+ }
464
+ return {
465
+ ...executedWithOutcomes,
466
+ candidateId: candidate.id,
467
+ };
468
+ });
469
+ const isPlanApplyFailureShape = (value) => typeof value === "object" &&
470
+ value !== null &&
471
+ "error" in value &&
472
+ typeof value.error === "object" &&
473
+ value.error !== null &&
474
+ "_tag" in value.error &&
475
+ (value.error._tag === "StepFailure" || value.error._tag === "StaleExecutionCandidate");
476
+ const guardedApply = (candidatePlan.executionCapabilities?.rollback === "non-rollbackable"
477
+ ? applyCandidate
478
+ : ws.runTransaction({
479
+ targets: [],
480
+ transition: applyCandidate,
481
+ validate: () => Effect.void,
482
+ onRestorationStarted: enterPhase("restoration"),
483
+ // Closures protect the shared settings and lockfile at their own
484
+ // first touch; claiming them here would let a late failure tear an
485
+ // earlier closure's settled commit out of the shared files.
486
+ claimDefaultTargets: false,
487
+ })).pipe(Effect.mapError((failure) => {
488
+ if (failure instanceof WorkspaceRestorationIncomplete) {
489
+ // The transition's own failure travels inside the typed value; the
490
+ // resolution derives units and failure from it, and the recovery
491
+ // requirement from the restoration fact — never from disk.
492
+ const transitionFailure = Cause.findErrorOption(failure.transitionCause);
493
+ const inner = Option.getOrUndefined(transitionFailure);
494
+ const innerApplyFailure = isPlanApplyFailureShape(inner) ? inner : undefined;
495
+ return {
496
+ error: innerApplyFailure?.error ?? restorationIncompleteToStepFailure(failure),
497
+ ...(innerApplyFailure?.attemptedExecution === undefined
498
+ ? {}
499
+ : { attemptedExecution: innerApplyFailure.attemptedExecution }),
500
+ restoration: failure,
501
+ };
502
+ }
503
+ return "error" in failure
504
+ ? failure
505
+ : { error: workspaceTransactionFailureToStepFailure(failure) };
506
+ }));
507
+ const applyResult = yield* Effect.scoped(Effect.gen(function* () {
508
+ const contention = yield* ws.acquireTransition({
509
+ command: applyExecution.approvalRecovery.command.join(" "),
510
+ candidateId: candidate.id,
511
+ onWaiting: (holder) => interaction.noteTransitionWait(holder),
512
+ });
513
+ if (Option.isSome(contention)) {
514
+ return { type: "contention", contention: contention.value };
515
+ }
516
+ return yield* interaction.withApplyProgress(candidatePlan.name, () => guardedApply.pipe(Effect.match({
517
+ onFailure: (error) => ({ type: "failure", error }),
518
+ onSuccess: (value) => ({ type: "success", value }),
519
+ })));
520
+ }));
521
+ if (applyResult.type === "contention") {
522
+ const reference = Option.match(applyResult.contention.holder, {
523
+ onNone: () => undefined,
524
+ onSome: (holder) => `${holder.command} (pid ${holder.pid})`,
525
+ });
526
+ const escape = { description: "Wait for the holding operation to finish, then rerun." };
527
+ return makeOperationResolution({
528
+ ...resolutionBase,
529
+ atomicity: { declared: atomicity, applied: "closure-atomic" },
530
+ units: plannedUnits(candidatePlan.jobs),
531
+ blocking: {
532
+ class: "resource-conflict",
533
+ subject: candidatePlan.name,
534
+ phase: "validation",
535
+ detail: `another operation holds the workspace transition${reference === undefined ? "" : ` (${reference})`}; waited ${Math.round(applyResult.contention.waitedMillis / 1000)}s`,
536
+ causeCode: "conflict",
537
+ ...(reference === undefined ? {} : { reference }),
538
+ escape,
539
+ },
540
+ suggestions: [escape],
541
+ });
542
+ }
543
+ const observedFootprint = (yield* readFootprint)
544
+ .map((entry) => ({
545
+ path: path.isAbsolute(entry.path) ? path.relative(ws.baseDir, entry.path) : entry.path,
546
+ change: entry.change,
547
+ }))
548
+ // The footprint reports durable workspace changes; scratch outside the
549
+ // workspace base (scoped temp staging) is removed with the invocation.
550
+ .filter((entry) => !entry.path.startsWith(".."));
551
+ const footprint = observedFootprint.length === 0
552
+ ? undefined
553
+ : observedFootprint
554
+ .filter((entry, index) => observedFootprint.findIndex((other) => other.path === entry.path && other.change === entry.change) === index)
555
+ // Identity order (code-unit), so twin runs report identical bytes
556
+ // regardless of concurrent write scheduling.
557
+ .sort((left, right) => left.path < right.path
558
+ ? -1
559
+ : left.path > right.path
560
+ ? 1
561
+ : left.change < right.change
562
+ ? -1
563
+ : left.change > right.change
564
+ ? 1
565
+ : 0);
566
+ if (applyResult.type === "failure") {
567
+ const failure = applyResult.error.error;
568
+ const attempted = applyResult.error.attemptedExecution;
569
+ // Restoration failure is a typed fact on the transaction's error channel;
570
+ // the resolution derives the retained set, disposition, and exit from
571
+ // that value alone — never from re-reading disk. Nothing persists in the
572
+ // workspace: the next mutation plans from the current workspace state.
573
+ const restoration = applyResult.error.restoration;
574
+ const interruptionSignal = restoration?.terminationCause === "interruption"
575
+ ? Option.match(yield* Effect.serviceOption(InterruptionSignalSource), {
576
+ onNone: () => "SIGINT",
577
+ onSome: (source) => source.requestedSignal() ?? "SIGINT",
578
+ })
579
+ : undefined;
580
+ const rollbackFailed = restoration !== undefined;
581
+ const restorationRecovery = restoration === undefined
582
+ ? undefined
583
+ : {
584
+ retained: [...restoration.retained],
585
+ ...(restoration.snapshotDir === undefined
586
+ ? {}
587
+ : { snapshotDir: restoration.snapshotDir }),
588
+ actions: [
589
+ {
590
+ description: "Re-run the command; the next mutation plans from the current workspace state.",
591
+ },
592
+ ],
593
+ };
594
+ const restoring = candidatePlan.executionCapabilities?.rollback !== "non-rollbackable";
595
+ const executed = attempted === undefined
596
+ ? plannedUnits(candidatePlan.jobs)
597
+ : executedUnits(attempted);
598
+ // Closures settled independently: commits stand, and each failed closure
599
+ // restored only itself. A closure named by the restoration fact kept the
600
+ // state its rollback could not undo; every other failed closure's
601
+ // effects were restored.
602
+ const retainedClosures = new Set(restoration?.closureIds ?? []);
603
+ const units = executed.map((unit) => {
604
+ if (unit.state === "failed" && restoring) {
605
+ return {
606
+ ...unit,
607
+ disposition: retainedClosures.has(unit.id)
608
+ ? "retained"
609
+ : "restored",
610
+ };
611
+ }
612
+ if (unit.state === "committed" && rollbackFailed && restoration?.closureIds === undefined) {
613
+ // An operation-level restoration failure (not scoped to closures):
614
+ // committed effects were retained as the failure left them.
615
+ return { ...unit, disposition: "retained" };
616
+ }
617
+ return unit;
618
+ });
619
+ const staleUnit = units.find((unit) => unit.blocking?.class === "stale-candidate");
620
+ if (failure._tag === "StaleExecutionCandidate" || staleUnit !== undefined) {
621
+ return makeOperationResolution({
622
+ ...resolutionBase,
623
+ atomicity: { declared: atomicity, applied: "closure-atomic" },
624
+ units,
625
+ blocking: {
626
+ class: "stale-candidate",
627
+ subject: staleUnit?.id ?? candidatePlan.name,
628
+ phase: "validation",
629
+ detail: staleUnit?.blocking?.detail ?? STALE_CANDIDATE_DETAIL,
630
+ escape: { description: "Rerun the command to resolve a fresh candidate." },
631
+ },
632
+ suggestions: [{ description: "Rerun the command to resolve a fresh candidate." }],
633
+ });
634
+ }
635
+ return makeOperationResolution({
636
+ ...resolutionBase,
637
+ atomicity: {
638
+ declared: atomicity,
639
+ applied: rollbackFailed ? "non-rollbackable" : atomicity,
640
+ },
641
+ units,
642
+ failure,
643
+ footprint,
644
+ ...(restorationRecovery === undefined ? {} : { recovery: restorationRecovery }),
645
+ ...(interruptionSignal === undefined
646
+ ? {}
647
+ : {
648
+ interruption: {
649
+ signal: interruptionSignal,
650
+ disposition: "retained",
651
+ },
652
+ }),
653
+ suggestions: failure.suggestions ?? candidatePlan.failureSuggestions,
654
+ });
655
+ }
656
+ const executed = applyResult.value;
657
+ const restoringPlan = candidatePlan.executionCapabilities?.rollback !== "non-rollbackable";
658
+ // Mixed commits and failures are a first-class partial outcome: settled
659
+ // closures stand, and each failed closure of a restoring plan rolled back
660
+ // only itself — its disposition says so.
661
+ const executedResolved = executedUnits(executed).map((unit) => unit.state === "failed" && restoringPlan ? { ...unit, disposition: "restored" } : unit);
662
+ // A restoring plan's first failed closure supplies the operation-level
663
+ // failure so the cause class keeps deciding the exit and the human summary
664
+ // names the failure. Non-rollbackable families (remote mutations such as
665
+ // publish) keep their long-standing behavior: unit outcomes alone carry the
666
+ // failures, and an operation-level failure still means the plan itself
667
+ // could not execute.
668
+ const firstFailed = restoringPlan
669
+ ? executedResolved.find((unit) => unit.state === "failed")
670
+ : undefined;
671
+ const failure = firstFailed?.error === undefined
672
+ ? undefined
673
+ : new StepFailure({
674
+ category: firstFailed.error.category,
675
+ detail: firstFailed.message ?? firstFailed.error.detail,
676
+ cause: firstFailed.error,
677
+ ...(firstFailed.error.suggestions === undefined
678
+ ? {}
679
+ : { suggestions: firstFailed.error.suggestions }),
680
+ });
681
+ return makeOperationResolution({
682
+ ...resolutionBase,
683
+ atomicity: { declared: atomicity, applied: atomicity },
684
+ units: executedResolved,
685
+ ...(failure === undefined ? {} : { failure }),
686
+ footprint,
687
+ ...(firstFailed === undefined
688
+ ? {}
689
+ : {
690
+ suggestions: firstFailed.error?.suggestions ?? candidatePlan.failureSuggestions,
691
+ }),
692
+ });
693
+ });
694
+ //# sourceMappingURL=resolve-plan.js.map