@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,291 @@
|
|
|
1
|
+
// @effect-diagnostics nodeBuiltinImport:off — proper-lockfile requires a callback-style fs; the adapter is confined to the lock path
|
|
2
|
+
/**
|
|
3
|
+
* Workspace transition lock.
|
|
4
|
+
*
|
|
5
|
+
* One cross-process lock serializes mutation-class operations on a workspace.
|
|
6
|
+
* A plan-family apply acquires it after confirmation — planning, network
|
|
7
|
+
* acquisition, preview, and the confirmation decision stay lock-free — then
|
|
8
|
+
* revalidates its candidate and applies while holding it; the workspace
|
|
9
|
+
* transaction reuses the invocation's hold, or acquires its own when no
|
|
10
|
+
* plan-family hold exists. A contending invocation waits with a visible
|
|
11
|
+
* reason up to a bound, then terminates blocked with a machine-readable
|
|
12
|
+
* reference to the holder.
|
|
13
|
+
*
|
|
14
|
+
* Each acquisition records a distinct owner token in the holder metadata.
|
|
15
|
+
* Ownership is proven only by an exact token match — never inferred from a
|
|
16
|
+
* pid or from missing or unreadable metadata — and a holder write that fails
|
|
17
|
+
* releases the acquisition instead of holding anonymously.
|
|
18
|
+
*
|
|
19
|
+
* The lock file lives at `.axm/tmp/workspace-transition.lock`; its path is
|
|
20
|
+
* load-bearing for operational tooling and must not move casually.
|
|
21
|
+
*
|
|
22
|
+
* Acquisition is atomic with respect to interruption: the narrow region from
|
|
23
|
+
* the library granting the hold through finalizer registration is masked, so
|
|
24
|
+
* an interrupt can never strand a granted lock, while contention waits stay
|
|
25
|
+
* interruptible. Only the lock-is-held error class is absorbed by the bounded
|
|
26
|
+
* wait; any other acquisition error surfaces immediately as a typed failure.
|
|
27
|
+
*
|
|
28
|
+
* @experimental This API is unstable and may change without notice.
|
|
29
|
+
*/
|
|
30
|
+
import * as nodeFs from "node:fs";
|
|
31
|
+
import { randomBytes } from "node:crypto";
|
|
32
|
+
import * as Deferred from "effect/Deferred";
|
|
33
|
+
import * as Duration from "effect/Duration";
|
|
34
|
+
import * as Effect from "effect/Effect";
|
|
35
|
+
import * as FileSystem from "effect/FileSystem";
|
|
36
|
+
import * as Option from "effect/Option";
|
|
37
|
+
import * as Path from "effect/Path";
|
|
38
|
+
import * as lockfile from "proper-lockfile";
|
|
39
|
+
import { TransitionLockError, WorkspaceDirectoryError, WorkspaceTransitionCompromised, } from "@agentxm/workspace-state";
|
|
40
|
+
export const TRANSITION_LOCK_FILENAME = "workspace-transition.lock";
|
|
41
|
+
// Staleness must tolerate a saturated event loop: a heavy apply starves the
|
|
42
|
+
// mtime-update timer, and a slack smaller than the starvation lets a LIVE
|
|
43
|
+
// holder's lock self-declare compromised — after which release refuses and the
|
|
44
|
+
// dir is left for the next invocation to wait out. 25 s of slack covers the
|
|
45
|
+
// longest observed starvation with room; a crashed holder delays a contender
|
|
46
|
+
// at most this long, still inside the 60 s wait bound.
|
|
47
|
+
const LOCK_STALE_MILLIS = 30_000;
|
|
48
|
+
const LOCK_UPDATE_MILLIS = 5_000;
|
|
49
|
+
const WAIT_INTERVAL = Duration.millis(250);
|
|
50
|
+
/** How long a contending invocation serializes behind the holder. */
|
|
51
|
+
export const TRANSITION_WAIT_BOUND_MILLIS = 60_000;
|
|
52
|
+
/**
|
|
53
|
+
* Held transition locks in this process, keyed by resolved workspace
|
|
54
|
+
* directory. Owned exclusively by `acquireWorkspaceTransitionLock`, whose
|
|
55
|
+
* scope finalizer removes the entry, so an entry's lifetime is exactly the
|
|
56
|
+
* lock's; the workspace transaction consults it to avoid deadlocking on the
|
|
57
|
+
* invocation's own hold and to observe compromise of that hold.
|
|
58
|
+
*/
|
|
59
|
+
// eslint-disable-next-line no-restricted-syntax -- Owned exclusively by acquireWorkspaceTransitionLock: an entry's lifetime is exactly the lock's, and the acquisition's scope finalizer removes it.
|
|
60
|
+
const heldTransitions = new Map();
|
|
61
|
+
export const isWorkspaceTransitionHeldByThisInvocation = (workspaceDir) => heldTransitions.has(workspaceDir);
|
|
62
|
+
/** The live hold for a workspace this process acquired, when one exists. */
|
|
63
|
+
export const heldWorkspaceTransition = (workspaceDir) => heldTransitions.get(workspaceDir);
|
|
64
|
+
const errorCode = (cause) => typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
|
|
65
|
+
? cause.code
|
|
66
|
+
: undefined;
|
|
67
|
+
export const transitionLockPath = (path, workspaceDir) => path.join(workspaceDir, "tmp", TRANSITION_LOCK_FILENAME);
|
|
68
|
+
/**
|
|
69
|
+
* The filesystem handed to `proper-lockfile`. Identical to the platform fs
|
|
70
|
+
* except that removing a transition-lock directory first clears the tool's
|
|
71
|
+
* own holder metadata inside it. The library reclaims a stale lock and
|
|
72
|
+
* releases a held one with a non-recursive `rmdir`; without this, a lock
|
|
73
|
+
* directory left by a crashed process — which always still contains
|
|
74
|
+
* `holder.json` — could never be reclaimed, and every later mutation would
|
|
75
|
+
* wait out its bound and report a false contention. Removal stays
|
|
76
|
+
* non-recursive past that one owned name, so foreign content keeps blocking
|
|
77
|
+
* removal the way it always did.
|
|
78
|
+
*/
|
|
79
|
+
const lockDirectoryFs = {
|
|
80
|
+
...nodeFs,
|
|
81
|
+
rmdir: (target, callback) => {
|
|
82
|
+
if (typeof target === "string" && target.endsWith(TRANSITION_LOCK_FILENAME)) {
|
|
83
|
+
nodeFs.unlink(`${target}/holder.json`, () => {
|
|
84
|
+
nodeFs.rmdir(target, callback);
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
nodeFs.rmdir(target, callback);
|
|
89
|
+
},
|
|
90
|
+
rmdirSync: (target) => {
|
|
91
|
+
if (typeof target === "string" && target.endsWith(TRANSITION_LOCK_FILENAME)) {
|
|
92
|
+
try {
|
|
93
|
+
nodeFs.unlinkSync(`${target}/holder.json`);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Absent metadata blocks nothing; foreign content still blocks rmdir.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
nodeFs.rmdirSync(target);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Acquisition-error classes the bounded contention wait absorbs: the lock is
|
|
104
|
+
* held (`ELOCKED`), or the attempt raced the holder's own acquire, release,
|
|
105
|
+
* or staleness sweep (`ENOENT`/`EEXIST` from proper-lockfile's internal
|
|
106
|
+
* stat-remove-retry). Every other class — permissions, foreign state
|
|
107
|
+
* squatting the path — is not resolved by waiting and surfaces immediately.
|
|
108
|
+
*/
|
|
109
|
+
const isContentionErrorCode = (code) => code === "ELOCKED" || code === "ENOENT" || code === "EEXIST";
|
|
110
|
+
const readHolder = (fs, path, lockPath) => fs.readFileString(path.join(lockPath, "holder.json")).pipe(Effect.map((content) => {
|
|
111
|
+
const parsed = JSON.parse(content);
|
|
112
|
+
if (typeof parsed === "object" &&
|
|
113
|
+
parsed !== null &&
|
|
114
|
+
"command" in parsed &&
|
|
115
|
+
typeof parsed.command === "string" &&
|
|
116
|
+
"pid" in parsed &&
|
|
117
|
+
typeof parsed.pid === "number") {
|
|
118
|
+
return Option.some({
|
|
119
|
+
command: parsed.command,
|
|
120
|
+
pid: parsed.pid,
|
|
121
|
+
...("candidateId" in parsed && typeof parsed.candidateId === "string"
|
|
122
|
+
? { candidateId: parsed.candidateId }
|
|
123
|
+
: {}),
|
|
124
|
+
...("token" in parsed && typeof parsed.token === "string" ? { token: parsed.token } : {}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return Option.none();
|
|
128
|
+
}), Effect.catch(() => Effect.succeed(Option.none())));
|
|
129
|
+
const writeHolder = (fs, path, lockPath, holder) => fs
|
|
130
|
+
.writeFileString(path.join(lockPath, "holder.json"), JSON.stringify(holder))
|
|
131
|
+
.pipe(Effect.mapError((cause) => new TransitionLockError({ path: lockPath, step: "record-holder", cause })));
|
|
132
|
+
/**
|
|
133
|
+
* Acquire the workspace transition lock, waiting up to the bound while
|
|
134
|
+
* another invocation holds it. Resolves `None` when acquired (the release is
|
|
135
|
+
* a scope finalizer) and `Some(contention)` when the bound elapsed.
|
|
136
|
+
*/
|
|
137
|
+
export const acquireWorkspaceTransitionLock = (args) => Effect.gen(function* () {
|
|
138
|
+
const fs = yield* FileSystem.FileSystem;
|
|
139
|
+
const path = yield* Path.Path;
|
|
140
|
+
const workspaceDir = path.resolve(args.workspaceDir);
|
|
141
|
+
const scratchDir = path.join(workspaceDir, "tmp");
|
|
142
|
+
const lockPath = path.join(scratchDir, TRANSITION_LOCK_FILENAME);
|
|
143
|
+
const waitBound = args.waitBoundMillis ?? TRANSITION_WAIT_BOUND_MILLIS;
|
|
144
|
+
const workspaceExisted = yield* fs
|
|
145
|
+
.exists(workspaceDir)
|
|
146
|
+
.pipe(Effect.mapError((cause) => new WorkspaceDirectoryError({ path: workspaceDir, step: "inspect", cause })));
|
|
147
|
+
yield* fs
|
|
148
|
+
.makeDirectory(scratchDir, { recursive: true })
|
|
149
|
+
.pipe(Effect.mapError((cause) => new TransitionLockError({ path: scratchDir, step: "create-scratch", cause })));
|
|
150
|
+
const removeEmptyScratch = fs.readDirectory(scratchDir).pipe(Effect.flatMap((entries) => entries.length === 0
|
|
151
|
+
? fs.remove(scratchDir, { recursive: true, force: false })
|
|
152
|
+
: Effect.void), Effect.ignore);
|
|
153
|
+
const removeNewEmptyWorkspace = workspaceExisted
|
|
154
|
+
? Effect.void
|
|
155
|
+
: fs.readDirectory(workspaceDir).pipe(Effect.flatMap((entries) => entries.length === 0
|
|
156
|
+
? fs.remove(workspaceDir, { recursive: true, force: false })
|
|
157
|
+
: Effect.void), Effect.ignore);
|
|
158
|
+
const compromisedSignal = Deferred.makeUnsafe();
|
|
159
|
+
let waitedMillis = 0;
|
|
160
|
+
let reportedWaiting = false;
|
|
161
|
+
while (true) {
|
|
162
|
+
// One attempt is atomic with respect to interruption: from the library
|
|
163
|
+
// granting the hold through finalizer registration there is no
|
|
164
|
+
// interruptible gap, so an interrupt requested mid-acquisition defers
|
|
165
|
+
// until the release finalizer exists and then releases through it. The
|
|
166
|
+
// mask stays narrow — a single no-retry grant plus local metadata —
|
|
167
|
+
// and the contention wait below remains interruptible.
|
|
168
|
+
const attempt = yield* Effect.uninterruptible(Effect.gen(function* () {
|
|
169
|
+
const granted = yield* Effect.tryPromise({
|
|
170
|
+
try: () => lockfile.lock(workspaceDir, {
|
|
171
|
+
lockfilePath: lockPath,
|
|
172
|
+
realpath: false,
|
|
173
|
+
retries: 0,
|
|
174
|
+
fs: lockDirectoryFs,
|
|
175
|
+
stale: args.timingMillis?.stale ?? LOCK_STALE_MILLIS,
|
|
176
|
+
update: args.timingMillis?.update ?? LOCK_UPDATE_MILLIS,
|
|
177
|
+
// The default handler throws inside a timer: an uncatchable
|
|
178
|
+
// crash that sprays raw frames on stderr. Complete the typed
|
|
179
|
+
// compromise signal instead — the workspace transaction races
|
|
180
|
+
// against it and never continues mutating after ownership is
|
|
181
|
+
// lost.
|
|
182
|
+
onCompromised: (cause) => {
|
|
183
|
+
Deferred.doneUnsafe(compromisedSignal, Effect.fail(new WorkspaceTransitionCompromised({ workspaceDir, lockPath, cause })));
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
catch: (cause) => ({ cause, code: errorCode(cause) }),
|
|
187
|
+
}).pipe(Effect.result);
|
|
188
|
+
if (granted._tag === "Failure") {
|
|
189
|
+
if (isContentionErrorCode(granted.failure.code)) {
|
|
190
|
+
return { _tag: "held" };
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
_tag: "failed",
|
|
194
|
+
error: new TransitionLockError({
|
|
195
|
+
path: lockPath,
|
|
196
|
+
step: "acquire",
|
|
197
|
+
cause: granted.failure.cause,
|
|
198
|
+
}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const release = granted.success;
|
|
202
|
+
const token = randomBytes(16).toString("hex");
|
|
203
|
+
const holderStamped = yield* Effect.gen(function* () {
|
|
204
|
+
const lockInfo = yield* fs
|
|
205
|
+
.stat(lockPath)
|
|
206
|
+
.pipe(Effect.mapError((cause) => new TransitionLockError({ path: lockPath, step: "inspect-timestamp", cause })));
|
|
207
|
+
const lockMtime = yield* Option.match(lockInfo.mtime, {
|
|
208
|
+
onNone: () => Effect.fail(new TransitionLockError({ path: lockPath, step: "missing-timestamp" })),
|
|
209
|
+
onSome: Effect.succeed,
|
|
210
|
+
});
|
|
211
|
+
yield* writeHolder(fs, path, lockPath, {
|
|
212
|
+
...args.holder,
|
|
213
|
+
token,
|
|
214
|
+
});
|
|
215
|
+
// proper-lockfile proves ownership by comparing the directory's
|
|
216
|
+
// mtime with the timestamp it recorded at acquisition. Writing
|
|
217
|
+
// holder.json changes that directory mtime, so restore the exact
|
|
218
|
+
// acquired value before the first refresh observes it.
|
|
219
|
+
yield* fs
|
|
220
|
+
.utimes(lockPath, lockMtime, lockMtime)
|
|
221
|
+
.pipe(Effect.mapError((cause) => new TransitionLockError({ path: lockPath, step: "preserve-timestamp", cause })));
|
|
222
|
+
}).pipe(Effect.result);
|
|
223
|
+
if (holderStamped._tag === "Failure") {
|
|
224
|
+
// The holder metadata is the only ownership evidence this
|
|
225
|
+
// acquisition will ever have; holding without it would be
|
|
226
|
+
// indistinguishable from a successor's half-written acquisition.
|
|
227
|
+
// Release the lock and fail rather than hold anonymously.
|
|
228
|
+
yield* Effect.tryPromise({
|
|
229
|
+
try: () => release(),
|
|
230
|
+
catch: (cause) => new TransitionLockError({ path: lockPath, step: "release", cause }),
|
|
231
|
+
}).pipe(Effect.ignore);
|
|
232
|
+
return { _tag: "failed", error: holderStamped.failure };
|
|
233
|
+
}
|
|
234
|
+
heldTransitions.set(workspaceDir, {
|
|
235
|
+
compromised: Deferred.await(compromisedSignal),
|
|
236
|
+
isCompromised: () => Deferred.isDoneUnsafe(compromisedSignal),
|
|
237
|
+
});
|
|
238
|
+
yield* Effect.addFinalizer(() => Effect.gen(function* () {
|
|
239
|
+
heldTransitions.delete(workspaceDir);
|
|
240
|
+
// Removal requires an exact owner-token match, proven before
|
|
241
|
+
// release() — which would remove the directory unconditionally.
|
|
242
|
+
// Absent or unreadable holder metadata is indistinguishable from
|
|
243
|
+
// a successor that reclaimed the stale hold but has not yet
|
|
244
|
+
// written its holder file, and is never license to remove; the
|
|
245
|
+
// unowned in-process bookkeeping self-resolves as compromised on
|
|
246
|
+
// its next update tick without touching the directory.
|
|
247
|
+
const residualHolder = yield* readHolder(fs, path, lockPath);
|
|
248
|
+
const ownsResidual = Option.match(residualHolder, {
|
|
249
|
+
onNone: () => false,
|
|
250
|
+
onSome: (value) => value.token === token,
|
|
251
|
+
});
|
|
252
|
+
if (ownsResidual) {
|
|
253
|
+
yield* Effect.tryPromise({
|
|
254
|
+
try: () => release(),
|
|
255
|
+
catch: (cause) => new TransitionLockError({ path: lockPath, step: "release", cause }),
|
|
256
|
+
}).pipe(Effect.ignore);
|
|
257
|
+
// A compromised hold makes release() refuse; the directory is
|
|
258
|
+
// still ours by exact token match — remove it directly.
|
|
259
|
+
yield* fs.remove(lockPath, { recursive: true, force: true }).pipe(Effect.ignore);
|
|
260
|
+
}
|
|
261
|
+
yield* removeEmptyScratch;
|
|
262
|
+
yield* removeNewEmptyWorkspace;
|
|
263
|
+
}));
|
|
264
|
+
return { _tag: "acquired" };
|
|
265
|
+
}));
|
|
266
|
+
if (attempt._tag === "acquired") {
|
|
267
|
+
return Option.none();
|
|
268
|
+
}
|
|
269
|
+
if (attempt._tag === "failed") {
|
|
270
|
+
yield* removeEmptyScratch;
|
|
271
|
+
yield* removeNewEmptyWorkspace;
|
|
272
|
+
return yield* attempt.error;
|
|
273
|
+
}
|
|
274
|
+
// The lock is held: serialize behind the holder with a visible reason,
|
|
275
|
+
// up to the bound — the loser serializes or times out into a
|
|
276
|
+
// categorized blocked, never an internal crash.
|
|
277
|
+
const holder = yield* readHolder(fs, path, lockPath);
|
|
278
|
+
if (!reportedWaiting && args.onWaiting !== undefined) {
|
|
279
|
+
reportedWaiting = true;
|
|
280
|
+
yield* args.onWaiting(holder);
|
|
281
|
+
}
|
|
282
|
+
if (waitedMillis >= waitBound) {
|
|
283
|
+
yield* removeEmptyScratch;
|
|
284
|
+
yield* removeNewEmptyWorkspace;
|
|
285
|
+
return Option.some({ holder, waitedMillis });
|
|
286
|
+
}
|
|
287
|
+
yield* Effect.sleep(WAIT_INTERVAL);
|
|
288
|
+
waitedMillis += Duration.toMillis(WAIT_INTERVAL);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
//# sourceMappingURL=transition-lock.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plan apply module.
|
|
3
|
+
*
|
|
4
|
+
* Iterates over plan jobs and their steps, executing `step.run()` for
|
|
5
|
+
* ready/warn steps and promoting error steps to error results without
|
|
6
|
+
* execution. Readiness errors gate the entire plan before any mutation, and
|
|
7
|
+
* jobs fail fast unless they explicitly opt into best-effort execution.
|
|
8
|
+
*
|
|
9
|
+
* This module is the stable kernel home for `applyPlan` and the
|
|
10
|
+
* `OperationHandler` type. Per-extension handlers live in their owning domain
|
|
11
|
+
* packages and resolve this shared contract from here.
|
|
12
|
+
*
|
|
13
|
+
* @experimental This API is unstable and may change without notice.
|
|
14
|
+
*/
|
|
15
|
+
import * as Effect from "effect/Effect";
|
|
16
|
+
import { StepFailure } from "./errors.js";
|
|
17
|
+
import type { CompletedJobStep, ExecutedPlan, JobStepResult, Plan } from "./plan.js";
|
|
18
|
+
/**
|
|
19
|
+
* Type for operation handler functions that take an operation and return
|
|
20
|
+
* an Effect producing a JobStepResult.
|
|
21
|
+
*/
|
|
22
|
+
export type OperationHandler<Op, R = never> = (op: Op) => Effect.Effect<JobStepResult, StepFailure, R>;
|
|
23
|
+
/**
|
|
24
|
+
* Apply a plan by iterating jobs and executing step run closures.
|
|
25
|
+
*
|
|
26
|
+
* Any readiness error gates the complete plan before execution. At runtime,
|
|
27
|
+
* jobs use ordered fail-fast execution by default. A job may explicitly opt
|
|
28
|
+
* into best-effort execution for independent siblings; failures still block
|
|
29
|
+
* all subsequent jobs.
|
|
30
|
+
*
|
|
31
|
+
* Never fails — catches StepFailure and converts to error results.
|
|
32
|
+
*/
|
|
33
|
+
/** Identity of a unit whose run closure is starting. */
|
|
34
|
+
export interface StartedJobStep {
|
|
35
|
+
readonly key?: string;
|
|
36
|
+
readonly label: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ApplyPlanOptions<Output> {
|
|
39
|
+
/** Observes each unit as its run closure starts; never controls execution. */
|
|
40
|
+
readonly onStepStarted?: (step: StartedJobStep) => Effect.Effect<void>;
|
|
41
|
+
/** Observes each unit the moment it completes; never controls execution. */
|
|
42
|
+
readonly onStepCompleted?: (step: CompletedJobStep<Output>) => Effect.Effect<void>;
|
|
43
|
+
}
|
|
44
|
+
export declare const applyPlan: <Requirements, Output>(plan: Plan<Requirements, Output>, options?: ApplyPlanOptions<Output>) => Effect.Effect<ExecutedPlan<Output>, never, Requirements>;
|
|
45
|
+
//# sourceMappingURL=apply-plan.d.ts.map
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plan apply module.
|
|
3
|
+
*
|
|
4
|
+
* Iterates over plan jobs and their steps, executing `step.run()` for
|
|
5
|
+
* ready/warn steps and promoting error steps to error results without
|
|
6
|
+
* execution. Readiness errors gate the entire plan before any mutation, and
|
|
7
|
+
* jobs fail fast unless they explicitly opt into best-effort execution.
|
|
8
|
+
*
|
|
9
|
+
* This module is the stable kernel home for `applyPlan` and the
|
|
10
|
+
* `OperationHandler` type. Per-extension handlers live in their owning domain
|
|
11
|
+
* packages and resolve this shared contract from here.
|
|
12
|
+
*
|
|
13
|
+
* @experimental This API is unstable and may change without notice.
|
|
14
|
+
*/
|
|
15
|
+
import * as Array from "effect/Array";
|
|
16
|
+
import * as Effect from "effect/Effect";
|
|
17
|
+
import { StepFailure } from "./errors.js";
|
|
18
|
+
// -----------------------------------------------------------------------------
|
|
19
|
+
// Implementation
|
|
20
|
+
// -----------------------------------------------------------------------------
|
|
21
|
+
const appendReadinessWarning = (step, result) => {
|
|
22
|
+
if (result.result === "error") {
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
...result,
|
|
27
|
+
warnings: [...(result.warnings ?? []), step.warnMessage],
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const errorStepMessage = (error) => `${error.detail} (${error.category})`;
|
|
31
|
+
const stepEvidence = (step) => ({
|
|
32
|
+
...(step.registryLifecycle === undefined ? {} : { registryLifecycle: step.registryLifecycle }),
|
|
33
|
+
...(step.agentOutcomes === undefined ? {} : { agentOutcomes: step.agentOutcomes }),
|
|
34
|
+
});
|
|
35
|
+
const executeStep = (step) => {
|
|
36
|
+
switch (step.readiness) {
|
|
37
|
+
case "error":
|
|
38
|
+
return Effect.succeed({
|
|
39
|
+
...(step.key === undefined ? {} : { key: step.key }),
|
|
40
|
+
...stepEvidence(step),
|
|
41
|
+
label: step.label,
|
|
42
|
+
result: {
|
|
43
|
+
result: "error",
|
|
44
|
+
message: step.errorMessage,
|
|
45
|
+
error: new StepFailure({
|
|
46
|
+
category: "internal",
|
|
47
|
+
detail: step.errorMessage,
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
case "ready":
|
|
52
|
+
return step.run.pipe(Effect.map((result) => ({
|
|
53
|
+
...(step.key === undefined ? {} : { key: step.key }),
|
|
54
|
+
...stepEvidence(step),
|
|
55
|
+
label: step.label,
|
|
56
|
+
result,
|
|
57
|
+
})), Effect.catch((error) => {
|
|
58
|
+
return Effect.succeed({
|
|
59
|
+
...(step.key === undefined ? {} : { key: step.key }),
|
|
60
|
+
...stepEvidence(step),
|
|
61
|
+
label: step.label,
|
|
62
|
+
result: {
|
|
63
|
+
result: "error",
|
|
64
|
+
message: errorStepMessage(error),
|
|
65
|
+
error,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}));
|
|
69
|
+
case "warn":
|
|
70
|
+
return step.run.pipe(Effect.map((result) => ({
|
|
71
|
+
...(step.key === undefined ? {} : { key: step.key }),
|
|
72
|
+
...stepEvidence(step),
|
|
73
|
+
label: step.label,
|
|
74
|
+
result: appendReadinessWarning(step, result),
|
|
75
|
+
})), Effect.catch((error) => {
|
|
76
|
+
return Effect.succeed({
|
|
77
|
+
...(step.key === undefined ? {} : { key: step.key }),
|
|
78
|
+
...stepEvidence(step),
|
|
79
|
+
label: step.label,
|
|
80
|
+
result: {
|
|
81
|
+
result: "error",
|
|
82
|
+
message: errorStepMessage(error),
|
|
83
|
+
error,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const blockStep = (step, message, blocking, blockedBy) => ({
|
|
90
|
+
...(step.key === undefined ? {} : { key: step.key }),
|
|
91
|
+
...stepEvidence(step),
|
|
92
|
+
label: step.label,
|
|
93
|
+
...(blockedBy === undefined ? {} : { blockedBy }),
|
|
94
|
+
result: {
|
|
95
|
+
result: "error",
|
|
96
|
+
message,
|
|
97
|
+
error: new StepFailure({
|
|
98
|
+
category: "conflict",
|
|
99
|
+
detail: message,
|
|
100
|
+
}),
|
|
101
|
+
blocking,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const applyReadinessGate = (plan) => plan.jobs.map((job) => job.steps.map((step) => step.readiness === "error"
|
|
105
|
+
? {
|
|
106
|
+
label: step.label,
|
|
107
|
+
result: {
|
|
108
|
+
result: "error",
|
|
109
|
+
message: step.errorMessage,
|
|
110
|
+
error: new StepFailure({ category: "conflict", detail: step.errorMessage }),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
: blockStep(step, "blocked by plan readiness error", { class: "precondition-unmet" })));
|
|
114
|
+
/**
|
|
115
|
+
* Run one unit with its start and settlement observations. The started fact
|
|
116
|
+
* is recorded before the run begins, and the settlement fact is recorded
|
|
117
|
+
* before any interruptible boundary follows the run's completion — an
|
|
118
|
+
* interrupt arriving with the completion can otherwise erase the only
|
|
119
|
+
* in-memory evidence that the unit's durable effect was committed. The run
|
|
120
|
+
* itself stays interruptible; the mask covers only the observations.
|
|
121
|
+
*/
|
|
122
|
+
const runStepObserved = (step, observeStart, observeStep) => Effect.uninterruptibleMask((restore) => observeStart(step).pipe(Effect.andThen(restore(executeStep(step))), Effect.tap(observeStep)));
|
|
123
|
+
const executeFailFastJob = (job, observeStart, observeStep) => Effect.gen(function* () {
|
|
124
|
+
const completed = [];
|
|
125
|
+
let failed = false;
|
|
126
|
+
let failedStepId;
|
|
127
|
+
for (const step of job.steps) {
|
|
128
|
+
if (failed) {
|
|
129
|
+
const blocked = blockStep(step, "blocked by earlier step failure", {
|
|
130
|
+
class: "operation-aborted",
|
|
131
|
+
...(failedStepId === undefined ? {} : { reference: failedStepId }),
|
|
132
|
+
});
|
|
133
|
+
completed.push(blocked);
|
|
134
|
+
yield* Effect.uninterruptible(observeStep(blocked));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const result = yield* runStepObserved(step, observeStart, observeStep);
|
|
138
|
+
completed.push(result);
|
|
139
|
+
if (result.result.result === "error" && !failed) {
|
|
140
|
+
failed = true;
|
|
141
|
+
failedStepId = result.key ?? result.label;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return completed;
|
|
145
|
+
});
|
|
146
|
+
const executeBestEffortJob = (job, observeStart, observeStep) => Effect.forEach(job.steps, (step) => runStepObserved(step, observeStart, observeStep), {
|
|
147
|
+
concurrency: job.concurrency,
|
|
148
|
+
});
|
|
149
|
+
const executeDependencyAwareJob = (job, observeStart, observeStep) => Effect.gen(function* () {
|
|
150
|
+
const stepsByKey = new Map(job.steps.flatMap((step) => (step.key === undefined ? [] : [[step.key, step]])));
|
|
151
|
+
const completed = new Map();
|
|
152
|
+
const unkeyed = job.steps.filter((step) => step.key === undefined);
|
|
153
|
+
for (const step of unkeyed) {
|
|
154
|
+
const result = yield* runStepObserved(step, observeStart, observeStep);
|
|
155
|
+
completed.set(`label:${step.label}`, result);
|
|
156
|
+
}
|
|
157
|
+
while ([...stepsByKey.keys()].some((key) => !completed.has(key))) {
|
|
158
|
+
const remaining = [...stepsByKey.entries()].filter(([key]) => !completed.has(key));
|
|
159
|
+
const blocked = remaining.filter(([, step]) => (step.dependsOn ?? []).some((dependency) => {
|
|
160
|
+
const result = completed.get(dependency);
|
|
161
|
+
return result !== undefined && result.result.result === "error";
|
|
162
|
+
}));
|
|
163
|
+
for (const [key, step] of blocked) {
|
|
164
|
+
const blockedBy = (step.dependsOn ?? []).filter((dependency) => {
|
|
165
|
+
const result = completed.get(dependency);
|
|
166
|
+
return result !== undefined && result.result.result === "error";
|
|
167
|
+
});
|
|
168
|
+
const blocked = blockStep(step, `blocked by failed dependency: ${blockedBy.join(", ")}`, {
|
|
169
|
+
class: "dependency-failed",
|
|
170
|
+
...(blockedBy[0] === undefined ? {} : { reference: blockedBy[0] }),
|
|
171
|
+
}, blockedBy);
|
|
172
|
+
completed.set(key, blocked);
|
|
173
|
+
yield* Effect.uninterruptible(observeStep(blocked));
|
|
174
|
+
}
|
|
175
|
+
const ready = remaining.filter(([key, step]) => !completed.has(key) &&
|
|
176
|
+
(step.dependsOn ?? []).every((dependency) => {
|
|
177
|
+
const result = completed.get(dependency);
|
|
178
|
+
return result === undefined
|
|
179
|
+
? !stepsByKey.has(dependency)
|
|
180
|
+
: result.result.result !== "error";
|
|
181
|
+
}));
|
|
182
|
+
if (ready.length === 0) {
|
|
183
|
+
for (const [key, step] of remaining) {
|
|
184
|
+
if (completed.has(key))
|
|
185
|
+
continue;
|
|
186
|
+
completed.set(key, blockStep(step, "blocked by an unresolved dependency cycle", { class: "dependency-cycle" }, step.dependsOn ?? []));
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
const results = yield* Effect.forEach(ready, ([, step]) => runStepObserved(step, observeStart, observeStep), { concurrency: job.concurrency });
|
|
191
|
+
for (const result of results) {
|
|
192
|
+
if (result.key !== undefined)
|
|
193
|
+
completed.set(result.key, result);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return job.steps.map((step) => completed.get(step.key ?? `label:${step.label}`) ??
|
|
197
|
+
blockStep(step, "blocked by an unresolved execution dependency", {
|
|
198
|
+
class: "dependency-cycle",
|
|
199
|
+
}));
|
|
200
|
+
});
|
|
201
|
+
export const applyPlan = (plan, options) => Effect.gen(function* () {
|
|
202
|
+
const observeStep = options?.onStepCompleted ?? (() => Effect.void);
|
|
203
|
+
const observeStart = options?.onStepStarted ?? (() => Effect.void);
|
|
204
|
+
const hasReadinessError = plan.jobs.some((job) => job.steps.some((step) => step.readiness === "error"));
|
|
205
|
+
let blocked = false;
|
|
206
|
+
const jobResults = hasReadinessError
|
|
207
|
+
? applyReadinessGate(plan)
|
|
208
|
+
: yield* Effect.forEach(plan.jobs, (job) => blocked
|
|
209
|
+
? Effect.succeed(job.steps.map((step) => blockStep(step, "blocked by earlier job failure", {
|
|
210
|
+
class: "operation-aborted",
|
|
211
|
+
})))
|
|
212
|
+
: (job.steps.some((step) => (step.dependsOn ?? []).length > 0)
|
|
213
|
+
? executeDependencyAwareJob(job, observeStart, observeStep)
|
|
214
|
+
: job.executionPolicy === "best-effort"
|
|
215
|
+
? executeBestEffortJob(job, observeStart, observeStep)
|
|
216
|
+
: executeFailFastJob(job, observeStart, observeStep)).pipe(Effect.tap((steps) => {
|
|
217
|
+
if (steps.some((step) => step.result.result === "error")) {
|
|
218
|
+
blocked = true;
|
|
219
|
+
}
|
|
220
|
+
return Effect.void;
|
|
221
|
+
})), { concurrency: 1 });
|
|
222
|
+
return {
|
|
223
|
+
_tag: "ExecutedPlan",
|
|
224
|
+
name: plan.name,
|
|
225
|
+
description: plan.description,
|
|
226
|
+
...(plan.releaseAge === undefined ? {} : { releaseAge: plan.releaseAge }),
|
|
227
|
+
...(plan.preconditions === undefined ? {} : { preconditions: plan.preconditions }),
|
|
228
|
+
...(plan.riskConditions === undefined ? {} : { riskConditions: plan.riskConditions }),
|
|
229
|
+
jobs: Array.map(jobResults, (steps, i) => ({
|
|
230
|
+
concurrency: plan.jobs[i]?.concurrency ?? 1,
|
|
231
|
+
...(plan.jobs[i]?.executionPolicy === undefined
|
|
232
|
+
? {}
|
|
233
|
+
: { executionPolicy: plan.jobs[i].executionPolicy }),
|
|
234
|
+
steps,
|
|
235
|
+
})),
|
|
236
|
+
};
|
|
237
|
+
}).pipe(Effect.withSpan("Plan.applyPlan"));
|
|
238
|
+
//# sourceMappingURL=apply-plan.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error category vocabulary for serialized plan and step data.
|
|
3
|
+
*
|
|
4
|
+
* The categories are the same strings as the CLI's `AppErrorCode` so machine
|
|
5
|
+
* output stays byte-identical across the package boundary; the conversion
|
|
6
|
+
* boundary beside the CLI error vocabulary asserts the parity at compile
|
|
7
|
+
* time. The kernel owns the vocabulary because plans, journals, and machine
|
|
8
|
+
* output serialize it; it never owns titles, exit codes, or rendering.
|
|
9
|
+
*
|
|
10
|
+
* @experimental This API is unstable and may change without notice.
|
|
11
|
+
*/
|
|
12
|
+
import * as Schema from "effect/Schema";
|
|
13
|
+
/** Every category a plan, step result, or risk condition may serialize. */
|
|
14
|
+
export declare const OPERATION_ERROR_CATEGORIES: readonly ["issues", "usage", "not_found", "auth", "forbidden", "conflict", "rate_limit", "network", "validation", "internal", "unavailable", "quota", "auth_required", "auth_expired", "auth_denied", "timeout"];
|
|
15
|
+
export declare const OperationErrorCategorySchema: Schema.Literals<readonly ["issues", "usage", "not_found", "auth", "forbidden", "conflict", "rate_limit", "network", "validation", "internal", "unavailable", "quota", "auth_required", "auth_expired", "auth_denied", "timeout"]>;
|
|
16
|
+
export type OperationErrorCategory = (typeof OPERATION_ERROR_CATEGORIES)[number];
|
|
17
|
+
declare const StepFailure_base: Schema.Class<StepFailure, Schema.TaggedStruct<"StepFailure", {
|
|
18
|
+
readonly category: Schema.Literals<readonly ["issues", "usage", "not_found", "auth", "forbidden", "conflict", "rate_limit", "network", "validation", "internal", "unavailable", "quota", "auth_required", "auth_expired", "auth_denied", "timeout"]>;
|
|
19
|
+
readonly detail: Schema.String;
|
|
20
|
+
readonly suggestions: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
21
|
+
readonly description: Schema.String;
|
|
22
|
+
readonly cmd: Schema.optional<Schema.String>;
|
|
23
|
+
readonly url: Schema.optional<Schema.String>;
|
|
24
|
+
}>>>;
|
|
25
|
+
readonly cause: Schema.optional<Schema.Unknown>;
|
|
26
|
+
}>, import("effect/Cause").YieldableError>;
|
|
27
|
+
/**
|
|
28
|
+
* The one serializable failure a plan step settles with. Step authors own the
|
|
29
|
+
* category choice and the user-facing detail sentence; `suggestions` carries
|
|
30
|
+
* only display data the boundary cannot reconstruct from fields, and `cause`
|
|
31
|
+
* carries the typed feature error or raw cause for diagnostic chains. The CLI
|
|
32
|
+
* boundary owns rendering, exit codes, and the AppError envelope.
|
|
33
|
+
*/
|
|
34
|
+
export declare class StepFailure extends StepFailure_base {
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Detail sentence for a stale execution candidate; the CLI conversion emits
|
|
38
|
+
* it verbatim so blocked output stays byte-identical.
|
|
39
|
+
*/
|
|
40
|
+
export declare const STALE_CANDIDATE_DETAIL = "The execution candidate became stale before apply.";
|
|
41
|
+
declare const StaleExecutionCandidate_base: Schema.Class<StaleExecutionCandidate, Schema.TaggedStruct<"StaleExecutionCandidate", {
|
|
42
|
+
/** The plan name of the candidate that went stale. */
|
|
43
|
+
readonly candidate: Schema.String;
|
|
44
|
+
}>, import("effect/Cause").YieldableError>;
|
|
45
|
+
/**
|
|
46
|
+
* The frozen execution candidate's material preimages changed between
|
|
47
|
+
* validation and apply. Detected by tag, never by detail-string comparison.
|
|
48
|
+
*/
|
|
49
|
+
export declare class StaleExecutionCandidate extends StaleExecutionCandidate_base {
|
|
50
|
+
}
|
|
51
|
+
declare const CandidateFingerprintFailed_base: Schema.Class<CandidateFingerprintFailed, Schema.TaggedStruct<"CandidateFingerprintFailed", {
|
|
52
|
+
/** The material path whose preimage could not be read. */
|
|
53
|
+
readonly target: Schema.String;
|
|
54
|
+
readonly cause: Schema.Unknown;
|
|
55
|
+
}>, import("effect/Cause").YieldableError>;
|
|
56
|
+
/** Fingerprinting one execution-material path failed. */
|
|
57
|
+
export declare class CandidateFingerprintFailed extends CandidateFingerprintFailed_base {
|
|
58
|
+
}
|
|
59
|
+
declare const ApprovalRecoveryMissing_base: Schema.Class<ApprovalRecoveryMissing, Schema.TaggedStruct<"ApprovalRecoveryMissing", {}>, import("effect/Cause").YieldableError>;
|
|
60
|
+
/**
|
|
61
|
+
* An apply-mode execution reached the plan pipeline without approval
|
|
62
|
+
* recovery metadata: a caller violated the `PlanExecution` contract. The CLI
|
|
63
|
+
* boundary owns the rendering.
|
|
64
|
+
*/
|
|
65
|
+
export declare class ApprovalRecoveryMissing extends ApprovalRecoveryMissing_base {
|
|
66
|
+
}
|
|
67
|
+
declare const PlanInteractionFailed_base: Schema.Class<PlanInteractionFailed, Schema.TaggedStruct<"PlanInteractionFailed", {
|
|
68
|
+
readonly category: Schema.Literals<readonly ["issues", "usage", "not_found", "auth", "forbidden", "conflict", "rate_limit", "network", "validation", "internal", "unavailable", "quota", "auth_required", "auth_expired", "auth_denied", "timeout"]>;
|
|
69
|
+
readonly detail: Schema.String;
|
|
70
|
+
readonly suggestions: Schema.optional<Schema.$Array<Schema.Struct<{
|
|
71
|
+
readonly description: Schema.String;
|
|
72
|
+
readonly cmd: Schema.optional<Schema.String>;
|
|
73
|
+
readonly url: Schema.optional<Schema.String>;
|
|
74
|
+
}>>>;
|
|
75
|
+
readonly cause: Schema.optional<Schema.Unknown>;
|
|
76
|
+
}>, import("effect/Cause").YieldableError>;
|
|
77
|
+
/**
|
|
78
|
+
* The plan interaction implementation could not complete a presentation or
|
|
79
|
+
* confirmation exchange. The implementation owns wording and category choice;
|
|
80
|
+
* the kernel only transports the failure to the boundary that renders it.
|
|
81
|
+
*/
|
|
82
|
+
export declare class PlanInteractionFailed extends PlanInteractionFailed_base {
|
|
83
|
+
}
|
|
84
|
+
export {};
|
|
85
|
+
//# sourceMappingURL=errors.d.ts.map
|