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
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { runInit } from "./init.js";
|
|
|
2
2
|
export type { InitOptions } from "./init.js";
|
|
3
3
|
export { runUninstall } from "./uninstall.js";
|
|
4
4
|
export type { UninstallReport } from "./uninstall.js";
|
|
5
|
-
export { detectHarnesses, parseHarnessList, HARNESSES } from "./detect.js";
|
|
5
|
+
export { detectHarnesses, parseHarnessList, parseHarnessOption, HARNESSES, } from "./detect.js";
|
|
6
6
|
export type { Harness } from "./detect.js";
|
|
7
7
|
export { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, MODEL_ALIASES, MODEL_CLASSES, PROFILES, ROLES, ROLE_TIERS, TIER_DEFS, claudeModelValue, isProfile, opencodeModelValue, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
|
|
8
8
|
export type { ModelAlias, ModelClass, Profile, Role, Tier } from "./models.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { runInit } from "./init.js";
|
|
2
2
|
export { runUninstall } from "./uninstall.js";
|
|
3
|
-
export { detectHarnesses, parseHarnessList, HARNESSES } from "./detect.js";
|
|
3
|
+
export { detectHarnesses, parseHarnessList, parseHarnessOption, HARNESSES, } from "./detect.js";
|
|
4
4
|
export { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, MODEL_ALIASES, MODEL_CLASSES, PROFILES, ROLES, ROLE_TIERS, TIER_DEFS, claudeModelValue, isProfile, opencodeModelValue, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
|
|
5
5
|
export { PACKAGE_VERSION } from "./assets.js";
|
package/dist/init.d.ts
CHANGED
|
@@ -36,7 +36,17 @@ export interface InitOptions {
|
|
|
36
36
|
* by the role's own preselected model.
|
|
37
37
|
*/
|
|
38
38
|
opencodeClassModels?: Record<ModelClass, string | undefined>;
|
|
39
|
+
/**
|
|
40
|
+
* Repo kit-version pin (distinct from the actually-installed `version`),
|
|
41
|
+
* so a later `apply` command can gate on it. A `string` sets a new
|
|
42
|
+
* recorded kit-version pin; `null` clears an existing pin; `undefined`
|
|
43
|
+
* (the default, omitted) carries the previous manifest's pin forward
|
|
44
|
+
* unchanged. An empty or whitespace-only string is normalized to a clear
|
|
45
|
+
* too, the same as `null`.
|
|
46
|
+
*/
|
|
47
|
+
pin?: string | null;
|
|
39
48
|
}
|
|
49
|
+
export declare const MANIFEST_PATH: string;
|
|
40
50
|
export interface Manifest {
|
|
41
51
|
kit: string;
|
|
42
52
|
version: string;
|
|
@@ -52,6 +62,31 @@ export interface Manifest {
|
|
|
52
62
|
*/
|
|
53
63
|
files: Record<string, string>;
|
|
54
64
|
installedAt: string;
|
|
65
|
+
/**
|
|
66
|
+
* Optional kit-version pin recorded for this repo (distinct from
|
|
67
|
+
* `version`, the actually-installed kit version). Absent when no pin was
|
|
68
|
+
* ever recorded or an existing one was cleared.
|
|
69
|
+
*/
|
|
70
|
+
pin?: string;
|
|
71
|
+
/**
|
|
72
|
+
* True only when the raw manifest JSON's `harnesses` field was itself an
|
|
73
|
+
* array AND that raw array had zero elements -- i.e. the operator
|
|
74
|
+
* recorded an explicit empty harness set (a real `--harness none`
|
|
75
|
+
* install). An array with entries that all fail the known-harness filter
|
|
76
|
+
* (e.g. `["cursor"]`, or `["Claude"]` with the wrong case) also filters
|
|
77
|
+
* down to `harnesses: []` but must NOT set this flag: the raw field was
|
|
78
|
+
* never actually recorded as empty, it just failed to name anything this
|
|
79
|
+
* kit recognizes, and treating that the same as a deliberate `none` would
|
|
80
|
+
* silently degrade a live install to templates-only on a plain re-run
|
|
81
|
+
* (see CHANGELOG). A missing/malformed `harnesses` field (not an array at
|
|
82
|
+
* all) is the same "not a recorded empty set" case and also leaves this
|
|
83
|
+
* `false`. Only `readInstalledManifest` ever sets this from an actual
|
|
84
|
+
* on-disk manifest. A synthetic previous
|
|
85
|
+
* (e.g. `apply`'s `buildApplyPrevious` in cli.ts) leaves it `undefined`,
|
|
86
|
+
* which the harnesses-stickiness gate in `cli-inputs.ts` treats as "not
|
|
87
|
+
* recorded" and therefore never sticky.
|
|
88
|
+
*/
|
|
89
|
+
harnessesRecordedEmpty?: boolean;
|
|
55
90
|
}
|
|
56
91
|
/**
|
|
57
92
|
* A manifest can be hand-written or tampered with, and uninstall deletes by
|
package/dist/init.js
CHANGED
|
@@ -6,7 +6,7 @@ import { HARNESSES } from "./detect.js";
|
|
|
6
6
|
import { CLASS_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, READ_ONLY_ROLES, ROLES, ROLE_TIERS, TIER_DEFS, assertValidModelId, claudeModelValue, isProfile, opencodeModelValue, rolesForProfile, } from "./models.js";
|
|
7
7
|
import { emptyReport, ensureClaudeImport, installFile, upsertMarkerSection, } from "./writers.js";
|
|
8
8
|
const SKILL_NAME = "orchestrator-workflow";
|
|
9
|
-
const MANIFEST_PATH = join(".ai", "workflow", "manifest.json");
|
|
9
|
+
export const MANIFEST_PATH = join(".ai", "workflow", "manifest.json"); // shared with doctor.ts/cli.ts (L9); see readInstalledManifest below
|
|
10
10
|
function sha256(content) {
|
|
11
11
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
12
12
|
}
|
|
@@ -44,7 +44,17 @@ export function readInstalledManifest(targetDir) {
|
|
|
44
44
|
const candidate = raw;
|
|
45
45
|
if (candidate.kit !== SKILL_NAME)
|
|
46
46
|
return undefined;
|
|
47
|
-
|
|
47
|
+
// Captured before filtering, and deliberately on the RAW array's own
|
|
48
|
+
// length, not the filtered one: an invalid array element (a string, an
|
|
49
|
+
// unknown harness name) also filters down to `harnesses: []` below, but
|
|
50
|
+
// must not be mistaken for a deliberate recorded `harnesses: []` -- see
|
|
51
|
+
// the `Manifest.harnessesRecordedEmpty` doc comment above for why.
|
|
52
|
+
const rawHarnessesIsArray = Array.isArray(candidate.harnesses);
|
|
53
|
+
const rawHarnesses = rawHarnessesIsArray
|
|
54
|
+
? candidate.harnesses
|
|
55
|
+
: [];
|
|
56
|
+
const harnessesRecordedEmpty = rawHarnessesIsArray && rawHarnesses.length === 0;
|
|
57
|
+
const harnesses = rawHarnesses.filter((value) => HARNESSES.includes(value));
|
|
48
58
|
const models = {};
|
|
49
59
|
if (typeof candidate.models === "object" && candidate.models !== null) {
|
|
50
60
|
for (const role of ROLES) {
|
|
@@ -81,15 +91,28 @@ export function readInstalledManifest(targetDir) {
|
|
|
81
91
|
// (the same per-field-degradation style as `profile` above) rather than
|
|
82
92
|
// throwing on a legacy manifest.
|
|
83
93
|
const tiers = typeof candidate.tiers === "boolean" ? candidate.tiers : false;
|
|
94
|
+
// A hand-written or damaged manifest may carry a non-string `pin`; that
|
|
95
|
+
// degrades to "no recorded pin" here (the same per-field-degradation
|
|
96
|
+
// style as `profile`/`tiers` above) rather than throwing. An empty or
|
|
97
|
+
// whitespace-only stored `pin` degrades the same way: it can never
|
|
98
|
+
// usefully name a kit version to gate on, so it is dropped rather than
|
|
99
|
+
// carried forward as a value nothing can act on.
|
|
84
100
|
return {
|
|
85
101
|
kit: SKILL_NAME,
|
|
86
102
|
version: typeof candidate.version === "string" ? candidate.version : "",
|
|
87
103
|
harnesses,
|
|
104
|
+
harnessesRecordedEmpty,
|
|
88
105
|
models: models,
|
|
89
106
|
profile,
|
|
90
107
|
tiers,
|
|
91
108
|
files,
|
|
92
109
|
installedAt: typeof candidate.installedAt === "string" ? candidate.installedAt : "",
|
|
110
|
+
// The kit-version pin is deliberately free-form here: unlike the
|
|
111
|
+
// fields above it never reaches generated frontmatter, a shell, or a
|
|
112
|
+
// path, and the command that gates on it validates the value itself.
|
|
113
|
+
...(typeof candidate.pin === "string" && candidate.pin.trim() !== ""
|
|
114
|
+
? { pin: candidate.pin.trim() }
|
|
115
|
+
: {}),
|
|
93
116
|
};
|
|
94
117
|
}
|
|
95
118
|
function yamlQuote(value) {
|
|
@@ -265,6 +288,18 @@ export function runInit(options) {
|
|
|
265
288
|
const tiers = options.tiers ?? false;
|
|
266
289
|
const report = emptyReport();
|
|
267
290
|
const previous = readInstalledManifest(targetDir);
|
|
291
|
+
// `null` clears an existing pin, a string sets a new one, and omitted
|
|
292
|
+
// (`undefined`) carries the previous manifest's pin forward unchanged. An
|
|
293
|
+
// empty or whitespace-only string is normalized to a clear as well: it can
|
|
294
|
+
// never usefully name a kit version to gate on, so treating it as a
|
|
295
|
+
// sticky value would let a stray empty input linger unnoticed instead of
|
|
296
|
+
// clearing the pin the caller most likely meant.
|
|
297
|
+
const normalizedPin = typeof options.pin === "string"
|
|
298
|
+
? options.pin.trim() === ""
|
|
299
|
+
? null
|
|
300
|
+
: options.pin.trim()
|
|
301
|
+
: options.pin;
|
|
302
|
+
const pin = normalizedPin === null ? undefined : (normalizedPin ?? previous?.pin);
|
|
268
303
|
const installedFiles = {};
|
|
269
304
|
// Both leftover-note loops below are ledger-driven, not enumeration-
|
|
270
305
|
// driven: they only ever push a note for a relative path that the
|
|
@@ -331,6 +366,64 @@ export function runInit(options) {
|
|
|
331
366
|
}
|
|
332
367
|
}
|
|
333
368
|
}
|
|
369
|
+
// Dropping a harness this run (a previously installed harness no longer
|
|
370
|
+
// in options.harnesses -- including the whole set collapsing to
|
|
371
|
+
// `--harness none`) leaves that harness's files on disk but out of the
|
|
372
|
+
// manifest's file ledger, the same untracked-leftover shape as the two
|
|
373
|
+
// note loops above; surface it the same way instead of a silent leftover
|
|
374
|
+
// for `uninstall` to trip over later. Unlike the two loops above (which
|
|
375
|
+
// only ever touch claude/opencode agent files), this one also covers
|
|
376
|
+
// codex's SKILL.md under `.agents/`, since codex has no per-role agent
|
|
377
|
+
// files but its skill file is still a ledger-tracked kit-owned file.
|
|
378
|
+
//
|
|
379
|
+
// Deliberately keyed off `HARNESSES` (every known harness) and
|
|
380
|
+
// `previous.files` (the raw file ledger) rather than `previous.harnesses`
|
|
381
|
+
// (the sanitized, filtered harness list): a damaged/hand-edited manifest
|
|
382
|
+
// whose raw `harnesses` field lists a valid harness under an unrecognized
|
|
383
|
+
// name (e.g. `["cursor"]` where it once said `["claude"]`) filters that
|
|
384
|
+
// harness's name out of `previous.harnesses` entirely, but its files are
|
|
385
|
+
// still sitting in `previous.files` under `.claude/`; deriving the
|
|
386
|
+
// dropped-harness set from `previous.harnesses` would silently miss those
|
|
387
|
+
// notes (see CHANGELOG). Checking each
|
|
388
|
+
// known harness's own file-ledger prefix directly is immune to that: a
|
|
389
|
+
// harness with no files in the ledger under its prefix produces no notes
|
|
390
|
+
// either way, whether or not `previous.harnesses` ever named it.
|
|
391
|
+
if (previous) {
|
|
392
|
+
const harnessDirs = {
|
|
393
|
+
claude: ".claude",
|
|
394
|
+
codex: ".agents",
|
|
395
|
+
opencode: ".opencode",
|
|
396
|
+
};
|
|
397
|
+
for (const harness of HARNESSES) {
|
|
398
|
+
if (options.harnesses.includes(harness))
|
|
399
|
+
continue;
|
|
400
|
+
const prefix = harnessDirs[harness] + sep;
|
|
401
|
+
for (const relativePath of Object.keys(previous.files)) {
|
|
402
|
+
if (relativePath.startsWith(prefix)) {
|
|
403
|
+
report.notes.push(`${relativePath}: now untracked after --harness dropped ${harness}; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// AGENTS.md/CLAUDE.md are never ledger-tracked in the first place
|
|
408
|
+
// (upsertMarkerSection/ensureClaudeImport below write them directly,
|
|
409
|
+
// not through installKitFile, so they never enter previous.files);
|
|
410
|
+
// when the harness set collapses to none this run, this install skips
|
|
411
|
+
// writing them (see the `options.harnesses.length > 0` guard below),
|
|
412
|
+
// so note them by on-disk existence instead of a ledger lookup -- the
|
|
413
|
+
// same untracked signal for a file the ledger never recorded. Gated on
|
|
414
|
+
// ledger evidence (any known harness's file prefix present in
|
|
415
|
+
// `previous.files`), not on the sanitized `previous.harnesses`, for the
|
|
416
|
+
// same reason as the loop above: a damaged manifest can filter a valid
|
|
417
|
+
// harness out of `previous.harnesses` while its files remain recorded.
|
|
418
|
+
const hadTrackedHarnessFiles = Object.keys(previous.files).some((relativePath) => HARNESSES.some((harness) => relativePath.startsWith(harnessDirs[harness] + sep)));
|
|
419
|
+
if (hadTrackedHarnessFiles && options.harnesses.length === 0) {
|
|
420
|
+
for (const name of ["AGENTS.md", "CLAUDE.md"]) {
|
|
421
|
+
if (existsSync(join(targetDir, name))) {
|
|
422
|
+
report.notes.push(`${name}: now untracked after --harness dropped to none; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
334
427
|
/**
|
|
335
428
|
* Installs a kit-owned file. An unedited file (it still matches the hash
|
|
336
429
|
* recorded at install time) is updated in place when the kit content
|
|
@@ -360,10 +453,15 @@ export function runInit(options) {
|
|
|
360
453
|
}
|
|
361
454
|
installKitFile(join(".ai", "runs", ".gitkeep"), "");
|
|
362
455
|
// Codex and opencode read AGENTS.md natively; Claude Code gets it via the
|
|
363
|
-
// CLAUDE.md import. The policy section is therefore installed
|
|
364
|
-
//
|
|
365
|
-
// fenced section and the import line are ever
|
|
366
|
-
|
|
456
|
+
// CLAUDE.md import. The policy section is therefore installed whenever any
|
|
457
|
+
// harness is selected, regardless of which one. AGENTS.md and CLAUDE.md
|
|
458
|
+
// are user-owned: only the fenced section and the import line are ever
|
|
459
|
+
// touched. `options.harnesses.length === 0` is templates-only mode
|
|
460
|
+
// (`--harness none`): only `.ai/workflow/**` and `.ai/runs/.gitkeep` are
|
|
461
|
+
// written, so AGENTS.md is left untouched (and never created) too.
|
|
462
|
+
if (options.harnesses.length > 0) {
|
|
463
|
+
upsertMarkerSection(report, join(targetDir, "AGENTS.md"), readAsset("agents-md-section.md"));
|
|
464
|
+
}
|
|
367
465
|
const skill = readAsset(join("skill", "SKILL.md"));
|
|
368
466
|
if (options.harnesses.includes("claude")) {
|
|
369
467
|
installKitFile(join(".claude", "skills", SKILL_NAME, "SKILL.md"), skill);
|
|
@@ -427,6 +525,7 @@ export function runInit(options) {
|
|
|
427
525
|
profile,
|
|
428
526
|
tiers,
|
|
429
527
|
files: installedFiles,
|
|
528
|
+
...(pin !== undefined ? { pin } : {}),
|
|
430
529
|
};
|
|
431
530
|
const manifestPath = join(targetDir, MANIFEST_PATH);
|
|
432
531
|
if (previous &&
|
|
@@ -438,6 +537,7 @@ export function runInit(options) {
|
|
|
438
537
|
profile: previous.profile,
|
|
439
538
|
tiers: previous.tiers,
|
|
440
539
|
files: previous.files,
|
|
540
|
+
...(previous.pin !== undefined ? { pin: previous.pin } : {}),
|
|
441
541
|
}) === JSON.stringify(desired)) {
|
|
442
542
|
report.skipped.push(manifestPath);
|
|
443
543
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type { Harness } from "./detect.js";
|
|
2
|
+
import type { Profile, Role } from "./models.js";
|
|
3
|
+
export declare const OPERATOR_HOME_DIRNAME = ".orchestrator-workflow";
|
|
4
|
+
export declare const OPERATOR_HOME_ENV = "ORCHESTRATOR_WORKFLOW_HOME";
|
|
5
|
+
export declare const OPERATOR_MANIFEST_FILENAME = "manifest.json";
|
|
6
|
+
/**
|
|
7
|
+
* Operator-level defaults applied when a target is (re-)applied without its
|
|
8
|
+
* own explicit flags. `models` is a `Partial<Record<Role, string>>` rather
|
|
9
|
+
* than a full `Record`, mirroring `readInstalledManifest`'s per-role
|
|
10
|
+
* degradation: a hand-written or legacy operator manifest may carry only
|
|
11
|
+
* some roles, and the rest should fall back to `DEFAULT_MODELS` at the call
|
|
12
|
+
* site rather than forcing every role to be present here.
|
|
13
|
+
*/
|
|
14
|
+
export interface OperatorManifestDefaults {
|
|
15
|
+
harnesses: Harness[];
|
|
16
|
+
profile: Profile;
|
|
17
|
+
tiers: boolean;
|
|
18
|
+
models: Partial<Record<Role, string>>;
|
|
19
|
+
}
|
|
20
|
+
/** One target directory this operator has applied the kit to. */
|
|
21
|
+
export interface OperatorTarget {
|
|
22
|
+
path: string;
|
|
23
|
+
lastAppliedVersion: string;
|
|
24
|
+
lastAppliedAt: string;
|
|
25
|
+
}
|
|
26
|
+
export interface OperatorManifest {
|
|
27
|
+
kit: "orchestrator-workflow";
|
|
28
|
+
schemaVersion: 1;
|
|
29
|
+
defaults: OperatorManifestDefaults;
|
|
30
|
+
targets: OperatorTarget[];
|
|
31
|
+
createdAt: string;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the operator-level home directory. Precedence: an explicit
|
|
36
|
+
* argument, then `ORCHESTRATOR_WORKFLOW_HOME`, then `~/.orchestrator-workflow/`.
|
|
37
|
+
* Both the explicit argument and the env var are made absolute via
|
|
38
|
+
* `node:path`'s `resolve` (relative to `process.cwd()`), matching the
|
|
39
|
+
* Precedence: an explicit argument, then the environment override, then the
|
|
40
|
+
* default directory under the user's home.
|
|
41
|
+
* access, no directory creation, no env-var reads beyond the lookup itself.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveOperatorHome(explicit?: string): string;
|
|
44
|
+
/** Creates a fresh operator manifest with no targets yet applied. */
|
|
45
|
+
export declare function createOperatorManifest(defaults: OperatorManifestDefaults, now?: string): OperatorManifest;
|
|
46
|
+
/**
|
|
47
|
+
* Reads the operator-level manifest at `<home>/manifest.json`, if any. The
|
|
48
|
+
* file can be hand-written or damaged, so every field is sanitized the same
|
|
49
|
+
* way `readInstalledManifest` sanitizes a per-repo manifest: anything
|
|
50
|
+
* invalid degrades to a safe default instead of throwing. Only the
|
|
51
|
+
* envelope fields (`kit`, `schemaVersion`) are hard requirements; a
|
|
52
|
+
* mismatch there means "not a manifest we recognize" and the whole read
|
|
53
|
+
* returns `undefined` rather than guessing.
|
|
54
|
+
*/
|
|
55
|
+
export declare function readOperatorManifest(home: string): OperatorManifest | undefined;
|
|
56
|
+
/** Default lock-acquire timeout, in milliseconds. Deliberately kept above
|
|
57
|
+
* {@link DEFAULT_LOCK_STALE_MS}: a caller that starts anywhere from 0ms up
|
|
58
|
+
* to `DEFAULT_LOCK_STALE_MS` after a killed holder left its lock behind
|
|
59
|
+
* must still live long enough, polling, to see that lock cross the
|
|
60
|
+
* staleness threshold and reclaim it, rather than timing out first. */
|
|
61
|
+
export declare const DEFAULT_LOCK_TIMEOUT_MS = 40000;
|
|
62
|
+
/** Default age, in milliseconds, past which a held lock directory is
|
|
63
|
+
* treated as abandoned and reclaimed. See {@link DEFAULT_LOCK_TIMEOUT_MS}
|
|
64
|
+
* for why this must stay smaller than the timeout. */
|
|
65
|
+
export declare const DEFAULT_LOCK_STALE_MS = 30000;
|
|
66
|
+
/** Default delay, in milliseconds, between acquire retries. */
|
|
67
|
+
export declare const DEFAULT_LOCK_POLL_MS = 20;
|
|
68
|
+
/** Options accepted by {@link withOperatorManifestLock}, exposed only so
|
|
69
|
+
* tests can shrink the timeout/staleness/poll windows below their
|
|
70
|
+
* production defaults; callers outside test code should omit this
|
|
71
|
+
* entirely. */
|
|
72
|
+
export interface OperatorManifestLockOptions {
|
|
73
|
+
timeoutMs?: number;
|
|
74
|
+
staleMs?: number;
|
|
75
|
+
pollMs?: number;
|
|
76
|
+
}
|
|
77
|
+
/** Thrown by {@link withOperatorManifestLock} when the lock could not be
|
|
78
|
+
* acquired within `timeoutMs`. Callers distinguish this from any error
|
|
79
|
+
* `fn` itself might throw so they can print lock-specific operator
|
|
80
|
+
* guidance instead of a generic failure. */
|
|
81
|
+
export declare class OperatorManifestLockTimeoutError extends Error {
|
|
82
|
+
constructor(lockPath: string);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Decides, from a lock directory's age measured *after* it was renamed
|
|
86
|
+
* aside during a reclaim attempt, whether that renamed copy should be
|
|
87
|
+
* destroyed (a genuinely abandoned lock) or handed back to its real owner
|
|
88
|
+
* (a lock that turned out fresh once re-checked; see
|
|
89
|
+
* {@link withOperatorManifestLock}'s reclaim comment for why this second
|
|
90
|
+
* check exists at all). `postAgeMs` is `undefined` when the renamed copy
|
|
91
|
+
* could no longer be stat'd by the time this runs (already gone, e.g. a
|
|
92
|
+
* third acquisition raced in and took it over): that is treated as
|
|
93
|
+
* hand-back-safe, not stale-and-destroy, since a lock this call cannot
|
|
94
|
+
* age must not be destroyed on its behalf — the same "when in doubt,
|
|
95
|
+
* don't tear down what might still be someone else's critical section"
|
|
96
|
+
* stance {@link withOperatorManifestLock}'s doc comment describes for its
|
|
97
|
+
* own `finally` release guard. A pure function so both branches, and the
|
|
98
|
+
* `undefined` case, are unit-testable without spawning a lock directory.
|
|
99
|
+
*/
|
|
100
|
+
export declare function shouldDestroyReclaimedLock(postAgeMs: number | undefined, staleMs: number): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Runs `fn` while holding an advisory, same-machine lock on the operator
|
|
103
|
+
* manifest at `<home>/manifest.json`, so a read-modify-write sequence
|
|
104
|
+
* (read the manifest, compute an updated copy, write it back) that this
|
|
105
|
+
* function wraps end to end cannot interleave with another process's own
|
|
106
|
+
* read-modify-write against the same `home`. {@link updateOperatorManifest}
|
|
107
|
+
* is the only call site that should ever use this directly; it is what
|
|
108
|
+
* makes the locked read-modify-write the sole write path to the manifest.
|
|
109
|
+
*
|
|
110
|
+
* Mechanics: `mkdirSync(<home>/.manifest.lock)` is used as the mutex,
|
|
111
|
+
* since directory creation is atomic on every filesystem Node targets
|
|
112
|
+
* (POSIX `mkdir(2)`, Windows `CreateDirectory`): a second, concurrent
|
|
113
|
+
* `mkdirSync` call for the same path fails with `EEXIST` rather than
|
|
114
|
+
* silently succeeding, exactly the primitive a mutual-exclusion lock
|
|
115
|
+
* needs. Right after `mkdirSync` succeeds, a fresh random token is written
|
|
116
|
+
* to `<lockPath>/owner`: this is the lock's owner identity, and it is what
|
|
117
|
+
* makes both the stale-lock reclaim below and the release in `finally`
|
|
118
|
+
* safe under contention (see each for why).
|
|
119
|
+
*
|
|
120
|
+
* A caller that finds the lock held retries with a short synchronous sleep
|
|
121
|
+
* (`sleepSync`, `Atomics.wait` on a throwaway `SharedArrayBuffer`, not a
|
|
122
|
+
* CPU-spinning busy loop) until either it acquires the lock or `timeoutMs`
|
|
123
|
+
* (default {@link DEFAULT_LOCK_TIMEOUT_MS}) elapses, in which case it
|
|
124
|
+
* throws {@link OperatorManifestLockTimeoutError} without ever calling
|
|
125
|
+
* `fn`. On every failed attempt (not just the first), a lock directory
|
|
126
|
+
* older than `staleMs` (default {@link DEFAULT_LOCK_STALE_MS}, checked via
|
|
127
|
+
* its mtime) is treated as abandoned, most likely left behind by a process
|
|
128
|
+
* that crashed or was killed between acquiring and releasing it, and
|
|
129
|
+
* reclaim is attempted: `renameSync(lockPath, <lockPath>.<token>.stale)`
|
|
130
|
+
* moves it out of the way, then the renamed copy is removed. `renameSync`
|
|
131
|
+
* on a POSIX filesystem is atomic, so of any two waiters racing to reclaim
|
|
132
|
+
* the same stale-looking directory, at most one rename can ever succeed;
|
|
133
|
+
* the loser's `renameSync` throws (the source is already gone) and it
|
|
134
|
+
* falls through to the normal retry/timeout handling instead of also
|
|
135
|
+
* entering the critical section. Re-checking staleness on every attempt
|
|
136
|
+
* (rather than once per call) is safe precisely because of that atomicity:
|
|
137
|
+
* repeating the check cannot itself cause two callers to both believe they
|
|
138
|
+
* reclaimed the same lock, it only means a caller that starts partway
|
|
139
|
+
* through another's abandoned-lock window still gets a chance to reclaim
|
|
140
|
+
* it once that window is crossed, instead of being stuck waiting out the
|
|
141
|
+
* full timeout.
|
|
142
|
+
*
|
|
143
|
+
* The staleness check and the rename are still two separate syscalls, not
|
|
144
|
+
* one atomic operation, so a second process could complete an entire fresh
|
|
145
|
+
* acquisition of its own in the gap between them; the winning `renameSync`
|
|
146
|
+
* would then have relocated that fresh, actively-held lock rather than the
|
|
147
|
+
* abandoned one the check inspected. This is closed by re-checking age a
|
|
148
|
+
* second time on the renamed copy, which only the caller that just renamed
|
|
149
|
+
* it can observe (so this second read is itself race-free): a lock that
|
|
150
|
+
* was genuinely fresh still reads as fresh there, and is handed back
|
|
151
|
+
* (renamed to `lockPath` again) rather than destroyed, so its real owner
|
|
152
|
+
* is undisturbed (short of the exceedingly narrow case where a third
|
|
153
|
+
* acquisition lands in that same brief hand-back gap, at which point there
|
|
154
|
+
* is nothing left to hand it back to; that owner's own eventual release
|
|
155
|
+
* still no-ops safely, see `finally` below).
|
|
156
|
+
*
|
|
157
|
+
* This lock is advisory (nothing stops a caller from touching the
|
|
158
|
+
* manifest file without going through it, exactly like a POSIX file
|
|
159
|
+
* lock) and same-machine only (a directory on a network filesystem
|
|
160
|
+
* shared across hosts is not a safe mutex primitive here); it protects
|
|
161
|
+
* cooperating `orchestrator-workflow` processes on one machine against
|
|
162
|
+
* each other, not against an uncooperative writer or a multi-host setup.
|
|
163
|
+
* `home` is created first (`mkdirSync(home, { recursive: true })`) since
|
|
164
|
+
* the lock directory lives inside it and a first-ever `apply`/`setup`
|
|
165
|
+
* against a brand-new operator home would otherwise have nowhere to put
|
|
166
|
+
* it. The lock is released in `finally`, including when `fn` throws, but
|
|
167
|
+
* only if the owner file inside it still holds this call's own token: if
|
|
168
|
+
* another process's stale-lock reclaim has since taken the directory over
|
|
169
|
+
* (the true holder ran long enough past `staleMs` for a waiter to evict
|
|
170
|
+
* it), this call's own token no longer matches what is in the owner file,
|
|
171
|
+
* and removing the directory here would tear down a lock that is no
|
|
172
|
+
* longer this call's to release, leaving the new owner's critical section
|
|
173
|
+
* unprotected mid-flight. Skipping the removal in that case is the
|
|
174
|
+
* correct, if imperfect, response: the directory is left for its actual
|
|
175
|
+
* current owner to release normally.
|
|
176
|
+
*/
|
|
177
|
+
export declare function withOperatorManifestLock<T>(home: string, fn: () => T, options?: OperatorManifestLockOptions): T;
|
|
178
|
+
/** The three states an operator-manifest read can land in: no file at
|
|
179
|
+
* `<home>/manifest.json` at all (`absent`, the fresh-operator case); a file
|
|
180
|
+
* that exists but `readOperatorManifest` could not turn into a manifest
|
|
181
|
+
* (`unreadable`, e.g. corrupt JSON, an unrecognized envelope, or a read
|
|
182
|
+
* failure such as a permissions error); or a file that parsed and
|
|
183
|
+
* validated (`ok`, with `manifest` set). Kept distinct from plain
|
|
184
|
+
* `readOperatorManifest`'s `OperatorManifest | undefined` so a caller (this
|
|
185
|
+
* module's own callers today, `apply`'s CLI action; `doctor`, once it
|
|
186
|
+
* exists, tomorrow) can tell "nothing set up yet" apart from "something is
|
|
187
|
+
* there and broken", since the two call for different operator advice: the
|
|
188
|
+
* former says run `setup`, the latter says back up and repair (or remove)
|
|
189
|
+
* the file first, since blindly running `setup` again would silently wipe
|
|
190
|
+
* whatever registry data survives in the damaged file.
|
|
191
|
+
*/
|
|
192
|
+
export type OperatorManifestState = {
|
|
193
|
+
kind: "absent";
|
|
194
|
+
} | {
|
|
195
|
+
kind: "unreadable";
|
|
196
|
+
} | {
|
|
197
|
+
kind: "ok";
|
|
198
|
+
manifest: OperatorManifest;
|
|
199
|
+
};
|
|
200
|
+
export declare function operatorManifestState(home: string): OperatorManifestState;
|
|
201
|
+
/**
|
|
202
|
+
* The single locked read-modify-write entry point for the operator
|
|
203
|
+
* manifest: every write to `<home>/manifest.json` (`setup`, `apply`,
|
|
204
|
+
* `doctor --prune`, and `adopt`'s own registration step in cli.ts, all four
|
|
205
|
+
* today) goes through this function rather than ever calling the lock or
|
|
206
|
+
* the raw writer directly, so no command can bypass the lock and race
|
|
207
|
+
* another's read-modify-write.
|
|
208
|
+
*
|
|
209
|
+
* The whole re-read, `mutate`, and write run inside one
|
|
210
|
+
* `withOperatorManifestLock` critical section: `mutate` is handed the
|
|
211
|
+
* manifest re-read *inside the lock* (`current`, `undefined` when
|
|
212
|
+
* `state.kind` is not `"ok"`) rather than whatever the caller may have read
|
|
213
|
+
* before calling this, since that earlier read can already be stale by the
|
|
214
|
+
* time the lock is granted (another locked writer's own read-modify-write
|
|
215
|
+
* could have landed in between). `state` is the full {@link
|
|
216
|
+
* OperatorManifestState} the re-read produced, handed to `mutate` alongside
|
|
217
|
+
* `current` so it can distinguish "no manifest yet" from "manifest present
|
|
218
|
+
* but unreadable" when that distinction changes what it should do.
|
|
219
|
+
*
|
|
220
|
+
* `mutate` returning `undefined` means "do not write anything": the
|
|
221
|
+
* manifest is left exactly as re-read, and the returned `written` is
|
|
222
|
+
* `false`. Returning an `OperatorManifest` writes it (via the internal,
|
|
223
|
+
* unlocked writer, safe here since the write happens inside the lock) and
|
|
224
|
+
* `written` is `true`; the written manifest is also returned as
|
|
225
|
+
* `manifest` for a caller that wants it without a further read. A write
|
|
226
|
+
* that refreshes an already-existing manifest (`current` truthy) is also
|
|
227
|
+
* stamped with a fresh `updatedAt` here, unless `mutate`'s own returned
|
|
228
|
+
* value already carries a distinct one of its own, in which case that
|
|
229
|
+
* value is written verbatim (see the write itself, below, for the exact
|
|
230
|
+
* condition).
|
|
231
|
+
*/
|
|
232
|
+
export declare function updateOperatorManifest(home: string, mutate: (current: OperatorManifest | undefined, state: OperatorManifestState) => OperatorManifest | undefined, options?: OperatorManifestLockOptions): {
|
|
233
|
+
state: OperatorManifestState;
|
|
234
|
+
written: boolean;
|
|
235
|
+
manifest?: OperatorManifest;
|
|
236
|
+
};
|
|
237
|
+
/**
|
|
238
|
+
* The operator-facing message for `apply`'s locked registration step
|
|
239
|
+
* finding the operator manifest not `"ok"` (unreadable or gone) once the
|
|
240
|
+
* lock was granted. The two cases get distinct wording: an unreadable
|
|
241
|
+
* manifest says the kit install itself already succeeded and only the
|
|
242
|
+
* registry write failed (unlike the *pre-install* unreadable check, which
|
|
243
|
+
* runs before any install work and so must not claim one happened), while
|
|
244
|
+
* a manifest gone missing mid-lock keeps its own separately-worded advice.
|
|
245
|
+
* A pure, exported function (rather than inlined at its one call site in
|
|
246
|
+
* cli.ts) so the wording can be unit-tested without spawning the CLI.
|
|
247
|
+
*/
|
|
248
|
+
export declare function applyRegistrationFailureMessage(manifestKind: "unreadable" | "absent", manifestPath: string, targetDir: string): string;
|
|
249
|
+
/**
|
|
250
|
+
* Realpath that never throws: a recorded target whose directory has since
|
|
251
|
+
* been removed or moved (the `missing` case a later doctor reports) must not
|
|
252
|
+
* make an unrelated upsert fail, so the stored path is compared as written
|
|
253
|
+
* when it can no longer be resolved. Exported so cli.ts resolves a target
|
|
254
|
+
* path the same guarded way this module does internally, rather than
|
|
255
|
+
* keeping its own duplicate copy of the same guard.
|
|
256
|
+
*/
|
|
257
|
+
export declare function safeRealpath(candidate: string): string;
|
|
258
|
+
/**
|
|
259
|
+
* Returns a new manifest with `targetPath` recorded as applied, plus
|
|
260
|
+
* whether `targetPath` was already registered (an update) rather than
|
|
261
|
+
* newly added, so a caller does not need its own separate, and
|
|
262
|
+
* potentially inconsistent, check against the same targets array. Pure:
|
|
263
|
+
* does not mutate `manifest` or its nested `targets` array/entries.
|
|
264
|
+
* Targets are deduplicated by realpath (`safeRealpath`, guarded against a
|
|
265
|
+
* target directory that no longer exists) rather than raw string
|
|
266
|
+
* equality, so the same directory reached via a symlink or a differently
|
|
267
|
+
* cased/relative path still updates the existing entry in place instead of
|
|
268
|
+
* appending a duplicate; the update branch also rewrites the stored
|
|
269
|
+
* `path` to the resolved realpath, so an entry once written as a raw,
|
|
270
|
+
* non-realpath string (a hand-edited manifest, or one written before this
|
|
271
|
+
* normalization existed) is normalized going forward instead of needing
|
|
272
|
+
* `safeRealpath` on every future comparison against it.
|
|
273
|
+
*/
|
|
274
|
+
export declare function upsertOperatorTarget(manifest: OperatorManifest, targetPath: string, appliedVersion: string, appliedAt: string): {
|
|
275
|
+
manifest: OperatorManifest;
|
|
276
|
+
alreadyRegistered: boolean;
|
|
277
|
+
};
|