@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.
- package/LICENSE +110 -0
- package/README.md +12 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +42 -0
- package/dist/src/live.d.ts +13 -0
- package/dist/src/live.js +12 -0
- package/dist/src/operations/augment-plan.d.ts +25 -0
- package/dist/src/operations/augment-plan.js +51 -0
- package/dist/src/operations/load-workspace.d.ts +43 -0
- package/dist/src/operations/load-workspace.js +77 -0
- package/dist/src/operations/scan-plan-readiness.d.ts +22 -0
- package/dist/src/operations/scan-plan-readiness.js +41 -0
- package/dist/src/operations/transaction.d.ts +58 -0
- package/dist/src/operations/transaction.js +359 -0
- package/dist/src/operations/transition-lock.d.ts +66 -0
- package/dist/src/operations/transition-lock.js +291 -0
- package/dist/src/plan/apply-plan.d.ts +45 -0
- package/dist/src/plan/apply-plan.js +238 -0
- package/dist/src/plan/errors.d.ts +85 -0
- package/dist/src/plan/errors.js +101 -0
- package/dist/src/plan/execution-candidate.d.ts +20 -0
- package/dist/src/plan/execution-candidate.js +95 -0
- package/dist/src/plan/interruption-signal.d.ts +18 -0
- package/dist/src/plan/interruption-signal.js +13 -0
- package/dist/src/plan/job-step-message.d.ts +7 -0
- package/dist/src/plan/job-step-message.js +7 -0
- package/dist/src/plan/operation-events.d.ts +62 -0
- package/dist/src/plan/operation-events.js +41 -0
- package/dist/src/plan/operation-journal.d.ts +69 -0
- package/dist/src/plan/operation-journal.js +52 -0
- package/dist/src/plan/operation-resolution.d.ts +219 -0
- package/dist/src/plan/operation-resolution.js +324 -0
- package/dist/src/plan/plan-execution.d.ts +75 -0
- package/dist/src/plan/plan-execution.js +117 -0
- package/dist/src/plan/plan.d.ts +248 -0
- package/dist/src/plan/plan.js +95 -0
- package/dist/src/plan/resolve-plan-interaction.d.ts +79 -0
- package/dist/src/plan/resolve-plan-interaction.js +52 -0
- package/dist/src/plan/resolve-plan.d.ts +42 -0
- package/dist/src/plan/resolve-plan.js +694 -0
- package/dist/src/plan/step-failure-conversions.d.ts +33 -0
- package/dist/src/plan/step-failure-conversions.js +177 -0
- package/dist/src/testing.d.ts +11 -0
- package/dist/src/testing.js +11 -0
- 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
|