orchestrator-workflow 0.25.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.
@@ -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.25.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",