orchestrator-workflow 0.24.0 → 0.26.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/CHANGELOG.md +329 -0
- package/INSTALL-AGENT.md +44 -4
- package/README.md +125 -8
- package/assets/agents/reviewer.md +6 -0
- package/assets/agents-md-section.md +22 -3
- package/assets/skill/SKILL.md +96 -8
- package/assets/templates/00-goal.md +1 -0
- package/assets/templates/03-decisions.md +13 -0
- package/assets/templates/05-review-findings.md +3 -0
- package/dist/cli-inputs.d.ts +71 -0
- package/dist/cli-inputs.js +253 -0
- package/dist/cli.js +883 -183
- package/dist/detect.d.ts +11 -0
- package/dist/detect.js +30 -0
- package/dist/doctor.d.ts +222 -0
- package/dist/doctor.js +411 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/init.d.ts +35 -0
- package/dist/init.js +106 -6
- package/dist/operator-manifest.d.ts +277 -0
- package/dist/operator-manifest.js +538 -0
- package/package.json +1 -1
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { HARNESSES } from "./detect.js";
|
|
6
|
+
import { DEFAULT_PROFILE, ROLES, assertValidModelId, isProfile, } from "./models.js";
|
|
7
|
+
export const OPERATOR_HOME_DIRNAME = ".orchestrator-workflow";
|
|
8
|
+
export const OPERATOR_HOME_ENV = "ORCHESTRATOR_WORKFLOW_HOME";
|
|
9
|
+
export const OPERATOR_MANIFEST_FILENAME = "manifest.json";
|
|
10
|
+
/**
|
|
11
|
+
* Resolves the operator-level home directory. Precedence: an explicit
|
|
12
|
+
* argument, then `ORCHESTRATOR_WORKFLOW_HOME`, then `~/.orchestrator-workflow/`.
|
|
13
|
+
* Both the explicit argument and the env var are made absolute via
|
|
14
|
+
* `node:path`'s `resolve` (relative to `process.cwd()`), matching the
|
|
15
|
+
* Precedence: an explicit argument, then the environment override, then the
|
|
16
|
+
* default directory under the user's home.
|
|
17
|
+
* access, no directory creation, no env-var reads beyond the lookup itself.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveOperatorHome(explicit) {
|
|
20
|
+
if (typeof explicit === "string" && explicit.length > 0) {
|
|
21
|
+
return resolve(explicit);
|
|
22
|
+
}
|
|
23
|
+
const envValue = process.env[OPERATOR_HOME_ENV];
|
|
24
|
+
if (typeof envValue === "string" && envValue.length > 0) {
|
|
25
|
+
return resolve(envValue);
|
|
26
|
+
}
|
|
27
|
+
return join(homedir(), OPERATOR_HOME_DIRNAME);
|
|
28
|
+
}
|
|
29
|
+
/** Creates a fresh operator manifest with no targets yet applied. */
|
|
30
|
+
export function createOperatorManifest(defaults, now) {
|
|
31
|
+
const timestamp = now ?? new Date().toISOString();
|
|
32
|
+
return {
|
|
33
|
+
kit: "orchestrator-workflow",
|
|
34
|
+
schemaVersion: 1,
|
|
35
|
+
defaults,
|
|
36
|
+
targets: [],
|
|
37
|
+
createdAt: timestamp,
|
|
38
|
+
updatedAt: timestamp,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Reads the operator-level manifest at `<home>/manifest.json`, if any. The
|
|
43
|
+
* file can be hand-written or damaged, so every field is sanitized the same
|
|
44
|
+
* way `readInstalledManifest` sanitizes a per-repo manifest: anything
|
|
45
|
+
* invalid degrades to a safe default instead of throwing. Only the
|
|
46
|
+
* envelope fields (`kit`, `schemaVersion`) are hard requirements; a
|
|
47
|
+
* mismatch there means "not a manifest we recognize" and the whole read
|
|
48
|
+
* returns `undefined` rather than guessing.
|
|
49
|
+
*/
|
|
50
|
+
export function readOperatorManifest(home) {
|
|
51
|
+
const path = join(home, OPERATOR_MANIFEST_FILENAME);
|
|
52
|
+
if (!existsSync(path))
|
|
53
|
+
return undefined;
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (typeof raw !== "object" || raw === null)
|
|
62
|
+
return undefined;
|
|
63
|
+
const candidate = raw;
|
|
64
|
+
if (candidate.kit !== "orchestrator-workflow")
|
|
65
|
+
return undefined;
|
|
66
|
+
if (candidate.schemaVersion !== 1)
|
|
67
|
+
return undefined;
|
|
68
|
+
const rawDefaults = typeof candidate.defaults === "object" && candidate.defaults !== null
|
|
69
|
+
? candidate.defaults
|
|
70
|
+
: {};
|
|
71
|
+
const harnesses = (Array.isArray(rawDefaults.harnesses) ? rawDefaults.harnesses : []).filter((value) => HARNESSES.includes(value));
|
|
72
|
+
const models = {};
|
|
73
|
+
if (typeof rawDefaults.models === "object" && rawDefaults.models !== null) {
|
|
74
|
+
for (const role of ROLES) {
|
|
75
|
+
const value = rawDefaults.models[role];
|
|
76
|
+
if (typeof value !== "string")
|
|
77
|
+
continue;
|
|
78
|
+
try {
|
|
79
|
+
assertValidModelId(value);
|
|
80
|
+
models[role] = value;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Invalid model ids are dropped; the role falls back to defaults.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const profile = typeof rawDefaults.profile === "string" && isProfile(rawDefaults.profile)
|
|
88
|
+
? rawDefaults.profile
|
|
89
|
+
: DEFAULT_PROFILE;
|
|
90
|
+
const tiers = typeof rawDefaults.tiers === "boolean" ? rawDefaults.tiers : false;
|
|
91
|
+
const targets = (Array.isArray(candidate.targets) ? candidate.targets : []).filter((value) => {
|
|
92
|
+
if (typeof value !== "object" || value === null)
|
|
93
|
+
return false;
|
|
94
|
+
const t = value;
|
|
95
|
+
return (typeof t.path === "string" &&
|
|
96
|
+
isAbsolute(t.path) &&
|
|
97
|
+
typeof t.lastAppliedVersion === "string" &&
|
|
98
|
+
typeof t.lastAppliedAt === "string");
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
kit: "orchestrator-workflow",
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
defaults: { harnesses, profile, tiers, models },
|
|
104
|
+
targets,
|
|
105
|
+
createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : "",
|
|
106
|
+
updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : "",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Writes the operator-level manifest, creating `home` if it does not yet
|
|
111
|
+
* exist. Same two-space-indented-plus-trailing-newline JSON shape
|
|
112
|
+
* `readInstalledManifest`'s writer (init.ts) uses for the per-repo manifest.
|
|
113
|
+
*
|
|
114
|
+
* Written atomically: the content lands in a `.<pid>.<random>.tmp` sibling
|
|
115
|
+
* file first, then `renameSync` swaps it over the real path. A rename onto
|
|
116
|
+
* an existing file is atomic on the same filesystem (POSIX and NTFS both
|
|
117
|
+
* guarantee this), so a reader never observes a partially written file, and
|
|
118
|
+
* two concurrent writers each still write their own complete file whole,
|
|
119
|
+
* rather than interleaving bytes into a shared corrupt one. This alone
|
|
120
|
+
* narrows, but does not close, the operator-manifest lost-update window: the
|
|
121
|
+
* remaining race is between one writer's fresh read and its rename, not
|
|
122
|
+
* within the write itself.
|
|
123
|
+
*
|
|
124
|
+
* Deliberately **not exported**. Closing that remaining race is {@link
|
|
125
|
+
* updateOperatorManifest}'s job, not this function's: every read-modify-
|
|
126
|
+
* write sequence against the operator manifest (`setup`'s and `apply`'s
|
|
127
|
+
* registration step, both in cli.ts) must go through that single locked
|
|
128
|
+
* entry point instead of calling this raw writer directly, so no command
|
|
129
|
+
* can bypass the lock. This function stays around only as the primitive
|
|
130
|
+
* `updateOperatorManifest` builds on; even this module's own tests exercise
|
|
131
|
+
* writes through `updateOperatorManifest` rather than calling this directly,
|
|
132
|
+
* so no test accidentally models a call path production code no longer has.
|
|
133
|
+
*/
|
|
134
|
+
function writeOperatorManifestUnlocked(home, manifest) {
|
|
135
|
+
mkdirSync(home, { recursive: true });
|
|
136
|
+
const path = join(home, OPERATOR_MANIFEST_FILENAME);
|
|
137
|
+
const tmpPath = join(home, `${OPERATOR_MANIFEST_FILENAME}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
138
|
+
writeFileSync(tmpPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
139
|
+
renameSync(tmpPath, path);
|
|
140
|
+
}
|
|
141
|
+
const OPERATOR_MANIFEST_LOCK_DIRNAME = ".manifest.lock";
|
|
142
|
+
const OPERATOR_MANIFEST_LOCK_OWNER_FILENAME = "owner";
|
|
143
|
+
/** Default lock-acquire timeout, in milliseconds. Deliberately kept above
|
|
144
|
+
* {@link DEFAULT_LOCK_STALE_MS}: a caller that starts anywhere from 0ms up
|
|
145
|
+
* to `DEFAULT_LOCK_STALE_MS` after a killed holder left its lock behind
|
|
146
|
+
* must still live long enough, polling, to see that lock cross the
|
|
147
|
+
* staleness threshold and reclaim it, rather than timing out first. */
|
|
148
|
+
export const DEFAULT_LOCK_TIMEOUT_MS = 40_000;
|
|
149
|
+
/** Default age, in milliseconds, past which a held lock directory is
|
|
150
|
+
* treated as abandoned and reclaimed. See {@link DEFAULT_LOCK_TIMEOUT_MS}
|
|
151
|
+
* for why this must stay smaller than the timeout. */
|
|
152
|
+
export const DEFAULT_LOCK_STALE_MS = 30_000;
|
|
153
|
+
/** Default delay, in milliseconds, between acquire retries. */
|
|
154
|
+
export const DEFAULT_LOCK_POLL_MS = 20;
|
|
155
|
+
/** Thrown by {@link withOperatorManifestLock} when the lock could not be
|
|
156
|
+
* acquired within `timeoutMs`. Callers distinguish this from any error
|
|
157
|
+
* `fn` itself might throw so they can print lock-specific operator
|
|
158
|
+
* guidance instead of a generic failure. */
|
|
159
|
+
export class OperatorManifestLockTimeoutError extends Error {
|
|
160
|
+
constructor(lockPath) {
|
|
161
|
+
super(`Timed out waiting for the operator manifest lock at ${lockPath}`);
|
|
162
|
+
this.name = "OperatorManifestLockTimeoutError";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function isEexist(error) {
|
|
166
|
+
return (error instanceof Error && error.code === "EEXIST");
|
|
167
|
+
}
|
|
168
|
+
/** Synchronous sleep via `Atomics.wait` on a throwaway `SharedArrayBuffer`,
|
|
169
|
+
* used instead of a busy loop so a waiter parks rather than spinning the
|
|
170
|
+
* CPU while it holds no work to do. `withOperatorManifestLock`'s callers
|
|
171
|
+
* are synchronous CLI code paths (a single `fn()` call wrapping a
|
|
172
|
+
* read-modify-write), so a synchronous sleep is what keeps the lock
|
|
173
|
+
* acquisition loop itself synchronous end to end; an async/Promise-based
|
|
174
|
+
* sleep would force every caller of this function to become async too. */
|
|
175
|
+
function sleepSync(ms) {
|
|
176
|
+
const sharedBuffer = new SharedArrayBuffer(4);
|
|
177
|
+
const view = new Int32Array(sharedBuffer);
|
|
178
|
+
Atomics.wait(view, 0, 0, ms);
|
|
179
|
+
}
|
|
180
|
+
/** Age, in milliseconds, of the directory at `path` (via its mtime), or
|
|
181
|
+
* `undefined` if it cannot be stat'd (already gone, races with another
|
|
182
|
+
* process's own reclaim/release). */
|
|
183
|
+
function lockAgeMs(path) {
|
|
184
|
+
try {
|
|
185
|
+
return Date.now() - statSync(path).mtimeMs;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** A fresh, unguessable per-acquisition identifier, written into the lock
|
|
192
|
+
* directory's owner file right after it is created and compared back on
|
|
193
|
+
* release, so a call only ever removes a lock directory it still actually
|
|
194
|
+
* owns (see {@link withOperatorManifestLock}'s release step). */
|
|
195
|
+
function randomLockToken() {
|
|
196
|
+
return randomBytes(16).toString("hex");
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Decides, from a lock directory's age measured *after* it was renamed
|
|
200
|
+
* aside during a reclaim attempt, whether that renamed copy should be
|
|
201
|
+
* destroyed (a genuinely abandoned lock) or handed back to its real owner
|
|
202
|
+
* (a lock that turned out fresh once re-checked; see
|
|
203
|
+
* {@link withOperatorManifestLock}'s reclaim comment for why this second
|
|
204
|
+
* check exists at all). `postAgeMs` is `undefined` when the renamed copy
|
|
205
|
+
* could no longer be stat'd by the time this runs (already gone, e.g. a
|
|
206
|
+
* third acquisition raced in and took it over): that is treated as
|
|
207
|
+
* hand-back-safe, not stale-and-destroy, since a lock this call cannot
|
|
208
|
+
* age must not be destroyed on its behalf — the same "when in doubt,
|
|
209
|
+
* don't tear down what might still be someone else's critical section"
|
|
210
|
+
* stance {@link withOperatorManifestLock}'s doc comment describes for its
|
|
211
|
+
* own `finally` release guard. A pure function so both branches, and the
|
|
212
|
+
* `undefined` case, are unit-testable without spawning a lock directory.
|
|
213
|
+
*/
|
|
214
|
+
export function shouldDestroyReclaimedLock(postAgeMs, staleMs) {
|
|
215
|
+
return postAgeMs !== undefined && postAgeMs > staleMs;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Runs `fn` while holding an advisory, same-machine lock on the operator
|
|
219
|
+
* manifest at `<home>/manifest.json`, so a read-modify-write sequence
|
|
220
|
+
* (read the manifest, compute an updated copy, write it back) that this
|
|
221
|
+
* function wraps end to end cannot interleave with another process's own
|
|
222
|
+
* read-modify-write against the same `home`. {@link updateOperatorManifest}
|
|
223
|
+
* is the only call site that should ever use this directly; it is what
|
|
224
|
+
* makes the locked read-modify-write the sole write path to the manifest.
|
|
225
|
+
*
|
|
226
|
+
* Mechanics: `mkdirSync(<home>/.manifest.lock)` is used as the mutex,
|
|
227
|
+
* since directory creation is atomic on every filesystem Node targets
|
|
228
|
+
* (POSIX `mkdir(2)`, Windows `CreateDirectory`): a second, concurrent
|
|
229
|
+
* `mkdirSync` call for the same path fails with `EEXIST` rather than
|
|
230
|
+
* silently succeeding, exactly the primitive a mutual-exclusion lock
|
|
231
|
+
* needs. Right after `mkdirSync` succeeds, a fresh random token is written
|
|
232
|
+
* to `<lockPath>/owner`: this is the lock's owner identity, and it is what
|
|
233
|
+
* makes both the stale-lock reclaim below and the release in `finally`
|
|
234
|
+
* safe under contention (see each for why).
|
|
235
|
+
*
|
|
236
|
+
* A caller that finds the lock held retries with a short synchronous sleep
|
|
237
|
+
* (`sleepSync`, `Atomics.wait` on a throwaway `SharedArrayBuffer`, not a
|
|
238
|
+
* CPU-spinning busy loop) until either it acquires the lock or `timeoutMs`
|
|
239
|
+
* (default {@link DEFAULT_LOCK_TIMEOUT_MS}) elapses, in which case it
|
|
240
|
+
* throws {@link OperatorManifestLockTimeoutError} without ever calling
|
|
241
|
+
* `fn`. On every failed attempt (not just the first), a lock directory
|
|
242
|
+
* older than `staleMs` (default {@link DEFAULT_LOCK_STALE_MS}, checked via
|
|
243
|
+
* its mtime) is treated as abandoned, most likely left behind by a process
|
|
244
|
+
* that crashed or was killed between acquiring and releasing it, and
|
|
245
|
+
* reclaim is attempted: `renameSync(lockPath, <lockPath>.<token>.stale)`
|
|
246
|
+
* moves it out of the way, then the renamed copy is removed. `renameSync`
|
|
247
|
+
* on a POSIX filesystem is atomic, so of any two waiters racing to reclaim
|
|
248
|
+
* the same stale-looking directory, at most one rename can ever succeed;
|
|
249
|
+
* the loser's `renameSync` throws (the source is already gone) and it
|
|
250
|
+
* falls through to the normal retry/timeout handling instead of also
|
|
251
|
+
* entering the critical section. Re-checking staleness on every attempt
|
|
252
|
+
* (rather than once per call) is safe precisely because of that atomicity:
|
|
253
|
+
* repeating the check cannot itself cause two callers to both believe they
|
|
254
|
+
* reclaimed the same lock, it only means a caller that starts partway
|
|
255
|
+
* through another's abandoned-lock window still gets a chance to reclaim
|
|
256
|
+
* it once that window is crossed, instead of being stuck waiting out the
|
|
257
|
+
* full timeout.
|
|
258
|
+
*
|
|
259
|
+
* The staleness check and the rename are still two separate syscalls, not
|
|
260
|
+
* one atomic operation, so a second process could complete an entire fresh
|
|
261
|
+
* acquisition of its own in the gap between them; the winning `renameSync`
|
|
262
|
+
* would then have relocated that fresh, actively-held lock rather than the
|
|
263
|
+
* abandoned one the check inspected. This is closed by re-checking age a
|
|
264
|
+
* second time on the renamed copy, which only the caller that just renamed
|
|
265
|
+
* it can observe (so this second read is itself race-free): a lock that
|
|
266
|
+
* was genuinely fresh still reads as fresh there, and is handed back
|
|
267
|
+
* (renamed to `lockPath` again) rather than destroyed, so its real owner
|
|
268
|
+
* is undisturbed (short of the exceedingly narrow case where a third
|
|
269
|
+
* acquisition lands in that same brief hand-back gap, at which point there
|
|
270
|
+
* is nothing left to hand it back to; that owner's own eventual release
|
|
271
|
+
* still no-ops safely, see `finally` below).
|
|
272
|
+
*
|
|
273
|
+
* This lock is advisory (nothing stops a caller from touching the
|
|
274
|
+
* manifest file without going through it, exactly like a POSIX file
|
|
275
|
+
* lock) and same-machine only (a directory on a network filesystem
|
|
276
|
+
* shared across hosts is not a safe mutex primitive here); it protects
|
|
277
|
+
* cooperating `orchestrator-workflow` processes on one machine against
|
|
278
|
+
* each other, not against an uncooperative writer or a multi-host setup.
|
|
279
|
+
* `home` is created first (`mkdirSync(home, { recursive: true })`) since
|
|
280
|
+
* the lock directory lives inside it and a first-ever `apply`/`setup`
|
|
281
|
+
* against a brand-new operator home would otherwise have nowhere to put
|
|
282
|
+
* it. The lock is released in `finally`, including when `fn` throws, but
|
|
283
|
+
* only if the owner file inside it still holds this call's own token: if
|
|
284
|
+
* another process's stale-lock reclaim has since taken the directory over
|
|
285
|
+
* (the true holder ran long enough past `staleMs` for a waiter to evict
|
|
286
|
+
* it), this call's own token no longer matches what is in the owner file,
|
|
287
|
+
* and removing the directory here would tear down a lock that is no
|
|
288
|
+
* longer this call's to release, leaving the new owner's critical section
|
|
289
|
+
* unprotected mid-flight. Skipping the removal in that case is the
|
|
290
|
+
* correct, if imperfect, response: the directory is left for its actual
|
|
291
|
+
* current owner to release normally.
|
|
292
|
+
*/
|
|
293
|
+
export function withOperatorManifestLock(home, fn, options = {}) {
|
|
294
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
295
|
+
const staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_MS;
|
|
296
|
+
const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
|
|
297
|
+
mkdirSync(home, { recursive: true });
|
|
298
|
+
const lockPath = join(home, OPERATOR_MANIFEST_LOCK_DIRNAME);
|
|
299
|
+
const ownerPath = join(lockPath, OPERATOR_MANIFEST_LOCK_OWNER_FILENAME);
|
|
300
|
+
const deadline = Date.now() + timeoutMs;
|
|
301
|
+
let ownToken;
|
|
302
|
+
for (;;) {
|
|
303
|
+
try {
|
|
304
|
+
mkdirSync(lockPath);
|
|
305
|
+
ownToken = randomLockToken();
|
|
306
|
+
writeFileSync(ownerPath, ownToken, "utf8");
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (!isEexist(error))
|
|
311
|
+
throw error;
|
|
312
|
+
const age = lockAgeMs(lockPath);
|
|
313
|
+
if (age !== undefined && age > staleMs) {
|
|
314
|
+
const staleRenamePath = `${lockPath}.${randomLockToken()}.stale`;
|
|
315
|
+
let renamedAway = false;
|
|
316
|
+
try {
|
|
317
|
+
renameSync(lockPath, staleRenamePath);
|
|
318
|
+
renamedAway = true;
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// Lost the reclaim race (another process renamed or re-created
|
|
322
|
+
// it first); fall through to the normal retry/timeout handling
|
|
323
|
+
// below rather than ever treating this as an acquisition.
|
|
324
|
+
}
|
|
325
|
+
if (renamedAway) {
|
|
326
|
+
// The age check above and this rename are two separate syscalls,
|
|
327
|
+
// not one atomic operation: another process could complete an
|
|
328
|
+
// entire fresh acquisition (mkdir + owner-file write) of its own
|
|
329
|
+
// in the gap between them, in which case this rename would have
|
|
330
|
+
// just relocated that fresh, actively-held lock rather than the
|
|
331
|
+
// abandoned one the check above inspected. Re-checking age on
|
|
332
|
+
// the renamed copy closes that gap: only this call can now
|
|
333
|
+
// observe `staleRenamePath`, so this second read is race-free,
|
|
334
|
+
// and a live lock's mtime (set moments ago by its real owner)
|
|
335
|
+
// still reads as fresh here even though the rename already
|
|
336
|
+
// moved it.
|
|
337
|
+
const postAge = lockAgeMs(staleRenamePath);
|
|
338
|
+
if (shouldDestroyReclaimedLock(postAge, staleMs)) {
|
|
339
|
+
rmSync(staleRenamePath, { recursive: true, force: true });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
// Turned out fresh: hand it back rather than destroying a lock
|
|
343
|
+
// its real owner still believes it holds. If `lockPath` was
|
|
344
|
+
// re-created by yet another process in the brief window since
|
|
345
|
+
// the rename above (rare: it requires a second, unrelated
|
|
346
|
+
// acquisition landing in this same narrow gap), there is
|
|
347
|
+
// nothing sane left to give it back to; the discarded copy's
|
|
348
|
+
// real owner still finishes `fn()` unaffected; its own release
|
|
349
|
+
// no-ops safely once it finds its owner file gone (see the
|
|
350
|
+
// `finally` below).
|
|
351
|
+
try {
|
|
352
|
+
renameSync(staleRenamePath, lockPath);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
rmSync(staleRenamePath, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (Date.now() >= deadline) {
|
|
360
|
+
throw new OperatorManifestLockTimeoutError(lockPath);
|
|
361
|
+
}
|
|
362
|
+
sleepSync(pollMs);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
return fn();
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
try {
|
|
370
|
+
const currentOwner = readFileSync(ownerPath, "utf8");
|
|
371
|
+
if (currentOwner === ownToken) {
|
|
372
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
373
|
+
}
|
|
374
|
+
// Otherwise another process's stale-lock reclaim has already taken
|
|
375
|
+
// over this lock directory since this call acquired it; it is no
|
|
376
|
+
// longer this call's to remove (see the doc comment above).
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Already gone, e.g. reclaimed by another process's stale-lock
|
|
380
|
+
// recovery after this call overran `staleMs` before reaching here;
|
|
381
|
+
// nothing left for this call to release.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
export function operatorManifestState(home) {
|
|
386
|
+
const path = join(home, OPERATOR_MANIFEST_FILENAME);
|
|
387
|
+
if (!existsSync(path))
|
|
388
|
+
return { kind: "absent" };
|
|
389
|
+
const manifest = readOperatorManifest(home);
|
|
390
|
+
return manifest ? { kind: "ok", manifest } : { kind: "unreadable" };
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The single locked read-modify-write entry point for the operator
|
|
394
|
+
* manifest: every write to `<home>/manifest.json` (`setup`, `apply`,
|
|
395
|
+
* `doctor --prune`, and `adopt`'s own registration step in cli.ts, all four
|
|
396
|
+
* today) goes through this function rather than ever calling the lock or
|
|
397
|
+
* the raw writer directly, so no command can bypass the lock and race
|
|
398
|
+
* another's read-modify-write.
|
|
399
|
+
*
|
|
400
|
+
* The whole re-read, `mutate`, and write run inside one
|
|
401
|
+
* `withOperatorManifestLock` critical section: `mutate` is handed the
|
|
402
|
+
* manifest re-read *inside the lock* (`current`, `undefined` when
|
|
403
|
+
* `state.kind` is not `"ok"`) rather than whatever the caller may have read
|
|
404
|
+
* before calling this, since that earlier read can already be stale by the
|
|
405
|
+
* time the lock is granted (another locked writer's own read-modify-write
|
|
406
|
+
* could have landed in between). `state` is the full {@link
|
|
407
|
+
* OperatorManifestState} the re-read produced, handed to `mutate` alongside
|
|
408
|
+
* `current` so it can distinguish "no manifest yet" from "manifest present
|
|
409
|
+
* but unreadable" when that distinction changes what it should do.
|
|
410
|
+
*
|
|
411
|
+
* `mutate` returning `undefined` means "do not write anything": the
|
|
412
|
+
* manifest is left exactly as re-read, and the returned `written` is
|
|
413
|
+
* `false`. Returning an `OperatorManifest` writes it (via the internal,
|
|
414
|
+
* unlocked writer, safe here since the write happens inside the lock) and
|
|
415
|
+
* `written` is `true`; the written manifest is also returned as
|
|
416
|
+
* `manifest` for a caller that wants it without a further read. A write
|
|
417
|
+
* that refreshes an already-existing manifest (`current` truthy) is also
|
|
418
|
+
* stamped with a fresh `updatedAt` here, unless `mutate`'s own returned
|
|
419
|
+
* value already carries a distinct one of its own, in which case that
|
|
420
|
+
* value is written verbatim (see the write itself, below, for the exact
|
|
421
|
+
* condition).
|
|
422
|
+
*/
|
|
423
|
+
export function updateOperatorManifest(home, mutate, options = {}) {
|
|
424
|
+
return withOperatorManifestLock(home, () => {
|
|
425
|
+
const state = operatorManifestState(home);
|
|
426
|
+
const current = state.kind === "ok" ? state.manifest : undefined;
|
|
427
|
+
const next = mutate(current, state);
|
|
428
|
+
if (next === undefined) {
|
|
429
|
+
return { state, written: false };
|
|
430
|
+
}
|
|
431
|
+
// A write that refreshes an already-existing manifest (`current`
|
|
432
|
+
// truthy) gets a fresh `updatedAt` here whenever the `mutate`
|
|
433
|
+
// callback that produced `next` did not already set one of its own
|
|
434
|
+
// (`next.updatedAt` still reads as `current.updatedAt`): `setup`'s
|
|
435
|
+
// defaults refresh, `apply`'s and `adopt`'s target
|
|
436
|
+
// registration/refresh all write through this one path via a
|
|
437
|
+
// `mutate` built on `upsertOperatorTarget`/a plain object spread,
|
|
438
|
+
// neither of which ever touches `updatedAt` itself, so before this
|
|
439
|
+
// fix only `setup` (which set it manually) and `doctor --prune`
|
|
440
|
+
// (same) actually bumped it; `apply`/`adopt` silently left a stale
|
|
441
|
+
// `updatedAt` on every registration (fix-round, review finding
|
|
442
|
+
// L10). A `mutate` that already computed its own distinct
|
|
443
|
+
// `updatedAt` (`doctor --prune`'s own manual bump, or a test that
|
|
444
|
+
// deliberately writes a fully custom manifest wholesale) is left
|
|
445
|
+
// exactly as returned, so this does not stomp on an intentional
|
|
446
|
+
// value. A brand-new manifest (`current` undefined, `next`
|
|
447
|
+
// typically built by `createOperatorManifest`) is likewise left
|
|
448
|
+
// untouched: it already carries a single, self-consistent
|
|
449
|
+
// `createdAt`/`updatedAt` pair from its own construction, and
|
|
450
|
+
// re-stamping only `updatedAt` here would needlessly split that
|
|
451
|
+
// pair by a few milliseconds.
|
|
452
|
+
const toWrite = current && next.updatedAt === current.updatedAt
|
|
453
|
+
? { ...next, updatedAt: new Date().toISOString() }
|
|
454
|
+
: next;
|
|
455
|
+
writeOperatorManifestUnlocked(home, toWrite);
|
|
456
|
+
return { state, written: true, manifest: toWrite };
|
|
457
|
+
}, options);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* The operator-facing message for `apply`'s locked registration step
|
|
461
|
+
* finding the operator manifest not `"ok"` (unreadable or gone) once the
|
|
462
|
+
* lock was granted. The two cases get distinct wording: an unreadable
|
|
463
|
+
* manifest says the kit install itself already succeeded and only the
|
|
464
|
+
* registry write failed (unlike the *pre-install* unreadable check, which
|
|
465
|
+
* runs before any install work and so must not claim one happened), while
|
|
466
|
+
* a manifest gone missing mid-lock keeps its own separately-worded advice.
|
|
467
|
+
* A pure, exported function (rather than inlined at its one call site in
|
|
468
|
+
* cli.ts) so the wording can be unit-tested without spawning the CLI.
|
|
469
|
+
*/
|
|
470
|
+
export function applyRegistrationFailureMessage(manifestKind, manifestPath, targetDir) {
|
|
471
|
+
return manifestKind === "unreadable"
|
|
472
|
+
? `Operator manifest at ${manifestPath} is unreadable; the kit was installed into ${targetDir} but could not be registered. Back it up and repair it, or remove it and run \`orchestrator-workflow setup\` again, then re-apply to register it.`
|
|
473
|
+
: `Operator manifest at ${manifestPath} is gone; ${targetDir} was installed but could not be registered. Run \`orchestrator-workflow setup\` and re-apply to register it.`;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Realpath that never throws: a recorded target whose directory has since
|
|
477
|
+
* been removed or moved (the `missing` case a later doctor reports) must not
|
|
478
|
+
* make an unrelated upsert fail, so the stored path is compared as written
|
|
479
|
+
* when it can no longer be resolved. Exported so cli.ts resolves a target
|
|
480
|
+
* path the same guarded way this module does internally, rather than
|
|
481
|
+
* keeping its own duplicate copy of the same guard.
|
|
482
|
+
*/
|
|
483
|
+
export function safeRealpath(candidate) {
|
|
484
|
+
try {
|
|
485
|
+
return realpathSync(candidate);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return candidate;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Returns a new manifest with `targetPath` recorded as applied, plus
|
|
493
|
+
* whether `targetPath` was already registered (an update) rather than
|
|
494
|
+
* newly added, so a caller does not need its own separate, and
|
|
495
|
+
* potentially inconsistent, check against the same targets array. Pure:
|
|
496
|
+
* does not mutate `manifest` or its nested `targets` array/entries.
|
|
497
|
+
* Targets are deduplicated by realpath (`safeRealpath`, guarded against a
|
|
498
|
+
* target directory that no longer exists) rather than raw string
|
|
499
|
+
* equality, so the same directory reached via a symlink or a differently
|
|
500
|
+
* cased/relative path still updates the existing entry in place instead of
|
|
501
|
+
* appending a duplicate; the update branch also rewrites the stored
|
|
502
|
+
* `path` to the resolved realpath, so an entry once written as a raw,
|
|
503
|
+
* non-realpath string (a hand-edited manifest, or one written before this
|
|
504
|
+
* normalization existed) is normalized going forward instead of needing
|
|
505
|
+
* `safeRealpath` on every future comparison against it.
|
|
506
|
+
*/
|
|
507
|
+
export function upsertOperatorTarget(manifest, targetPath, appliedVersion, appliedAt) {
|
|
508
|
+
const resolvedPath = safeRealpath(targetPath);
|
|
509
|
+
const existingIndex = manifest.targets.findIndex((target) => safeRealpath(target.path) === resolvedPath);
|
|
510
|
+
const alreadyRegistered = existingIndex !== -1;
|
|
511
|
+
const targets = manifest.targets.map((target) => ({ ...target }));
|
|
512
|
+
if (existingIndex === -1) {
|
|
513
|
+
targets.push({
|
|
514
|
+
path: resolvedPath,
|
|
515
|
+
lastAppliedVersion: appliedVersion,
|
|
516
|
+
lastAppliedAt: appliedAt,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
targets[existingIndex] = {
|
|
521
|
+
...targets[existingIndex],
|
|
522
|
+
path: resolvedPath,
|
|
523
|
+
lastAppliedVersion: appliedVersion,
|
|
524
|
+
lastAppliedAt: appliedAt,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
manifest: {
|
|
529
|
+
...manifest,
|
|
530
|
+
defaults: {
|
|
531
|
+
...manifest.defaults,
|
|
532
|
+
models: { ...manifest.defaults.models },
|
|
533
|
+
},
|
|
534
|
+
targets,
|
|
535
|
+
},
|
|
536
|
+
alreadyRegistered,
|
|
537
|
+
};
|
|
538
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orchestrator-workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Installer for an orchestrator-led agent workflow: .ai/ run state, an AGENTS.md policy section, and per-harness subagent definitions for Claude Code, OpenAI Codex, and opencode",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|