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.
- package/CHANGELOG.md +161 -0
- package/INSTALL-AGENT.md +44 -4
- package/README.md +122 -4
- package/assets/agents-md-section.md +7 -3
- package/assets/skill/SKILL.md +41 -6
- package/assets/templates/00-goal.md +1 -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/detect.d.ts
CHANGED
|
@@ -7,3 +7,14 @@ export declare const HARNESSES: Harness[];
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function detectHarnesses(dir: string): Harness[];
|
|
9
9
|
export declare function parseHarnessList(list: string): Harness[];
|
|
10
|
+
/**
|
|
11
|
+
* Parses a `--harness` option value, additionally accepting the literal
|
|
12
|
+
* `none` (templates-only mode: install `.ai/workflow/**` and
|
|
13
|
+
* `.ai/runs/.gitkeep` only, no AGENTS.md/CLAUDE.md/harness directories, and
|
|
14
|
+
* an empty `harnesses` array in the manifest). `none` must be the only entry
|
|
15
|
+
* in the list; combining it with a real harness name (`none,claude`) is
|
|
16
|
+
* ambiguous about intent, so it is rejected with a clear message instead of
|
|
17
|
+
* silently picking one interpretation. Every other value delegates to
|
|
18
|
+
* `parseHarnessList` unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseHarnessOption(list: string): Harness[];
|
package/dist/detect.js
CHANGED
|
@@ -37,3 +37,33 @@ export function parseHarnessList(list) {
|
|
|
37
37
|
}
|
|
38
38
|
return parsed;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Parses a `--harness` option value, additionally accepting the literal
|
|
42
|
+
* `none` (templates-only mode: install `.ai/workflow/**` and
|
|
43
|
+
* `.ai/runs/.gitkeep` only, no AGENTS.md/CLAUDE.md/harness directories, and
|
|
44
|
+
* an empty `harnesses` array in the manifest). `none` must be the only entry
|
|
45
|
+
* in the list; combining it with a real harness name (`none,claude`) is
|
|
46
|
+
* ambiguous about intent, so it is rejected with a clear message instead of
|
|
47
|
+
* silently picking one interpretation. Every other value delegates to
|
|
48
|
+
* `parseHarnessList` unchanged.
|
|
49
|
+
*/
|
|
50
|
+
export function parseHarnessOption(list) {
|
|
51
|
+
// Deduplicated before the arity check below so a repeated "none"
|
|
52
|
+
// (`none,none`) is recognized as the same single-entry intent as a
|
|
53
|
+
// plain "none", rather than tripping the "cannot be combined with other
|
|
54
|
+
// harnesses" error meant for an actually different entry like
|
|
55
|
+
// "none,claude".
|
|
56
|
+
const entries = [
|
|
57
|
+
...new Set(list
|
|
58
|
+
.split(",")
|
|
59
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
60
|
+
.filter((entry) => entry !== "")),
|
|
61
|
+
];
|
|
62
|
+
if (entries.includes("none")) {
|
|
63
|
+
if (entries.length > 1) {
|
|
64
|
+
throw new Error(`--harness none selects templates-only mode and cannot be combined with other harnesses; got "${list}"`);
|
|
65
|
+
}
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return parseHarnessList(list);
|
|
69
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import type { Profile, Role } from "./models.js";
|
|
3
|
+
import type { OperatorManifest, OperatorManifestLockOptions, OperatorTarget } from "./operator-manifest.js";
|
|
4
|
+
/**
|
|
5
|
+
* A target's status against the operator's registry. `drift` takes
|
|
6
|
+
* precedence over `divergent`/`version-lag` (a target can carry both a
|
|
7
|
+
* profile/tiers/models divergence and a hash drift at the same time; the
|
|
8
|
+
* drift is the more actionable fact, so it wins the status field while the
|
|
9
|
+
* divergence is still reported on the target). `divergent` in turn takes
|
|
10
|
+
* precedence over `version-lag`: a target can be both divergent and
|
|
11
|
+
* version-lagging (see `versionLag` below), and both facts are reported,
|
|
12
|
+
* but the status is `divergent`. `unverifiable` is separate from both
|
|
13
|
+
* `missing` and `no-manifest`: the target directory or its repo manifest
|
|
14
|
+
* could not be checked at all (a stat failure other than ENOENT, most
|
|
15
|
+
* commonly EACCES on an ancestor directory, or a manifest file present but
|
|
16
|
+
* unreadable/unparseable), so nothing is actually known about this
|
|
17
|
+
* target's real state. Unlike `missing`/`no-manifest`, `--prune` never
|
|
18
|
+
* removes an `unverifiable` target (review round 2, M1): an unreadable
|
|
19
|
+
* target might still be perfectly fine, and dropping its registry row on
|
|
20
|
+
* that basis would be an unrecoverable guess.
|
|
21
|
+
*/
|
|
22
|
+
export type TargetStatus = "clean" | "divergent" | "version-lag" | "drift" | "missing" | "no-manifest" | "unverifiable";
|
|
23
|
+
export interface TargetDivergence {
|
|
24
|
+
profile: boolean;
|
|
25
|
+
tiers: boolean;
|
|
26
|
+
models: boolean;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Per-target report. Only `path`, `status`, `installedVersion`, `pin`,
|
|
30
|
+
* `divergence`, `driftFiles`, `versionLag`, and `reason` are part of the
|
|
31
|
+
* `--json` contract (`targetReportToJson` below picks exactly those); the
|
|
32
|
+
* remaining fields exist to let the human-output printer in `cli.ts`
|
|
33
|
+
* render detail lines without recomputing values `inspectTarget` already
|
|
34
|
+
* worked out.
|
|
35
|
+
*/
|
|
36
|
+
export interface TargetReport {
|
|
37
|
+
path: string;
|
|
38
|
+
status: TargetStatus;
|
|
39
|
+
installedVersion: string | null;
|
|
40
|
+
pin: string | null;
|
|
41
|
+
divergence: TargetDivergence | null;
|
|
42
|
+
driftFiles: string[] | null;
|
|
43
|
+
/** Human-output-only: the repo's own profile, or null when unknown. */
|
|
44
|
+
repoProfile: Profile | null;
|
|
45
|
+
/** Human-output-only: the operator default profile, for the comparison line. */
|
|
46
|
+
operatorProfile: Profile;
|
|
47
|
+
/** Human-output-only: the repo's own tiers flag, or null when unknown. */
|
|
48
|
+
repoTiers: boolean | null;
|
|
49
|
+
/** Human-output-only: the operator default tiers flag, for the comparison line. */
|
|
50
|
+
operatorTiers: boolean;
|
|
51
|
+
/** Human-output-only: roles whose resolved model differs from the operator default. */
|
|
52
|
+
divergentModelRoles: Role[];
|
|
53
|
+
/**
|
|
54
|
+
* Whether this target is lagging the running kit version (no pin
|
|
55
|
+
* recorded, and the installed version differs from the kit version),
|
|
56
|
+
* independent of the final `status` field. Lets the printer show the
|
|
57
|
+
* "installed X, operator Y" line for a target whose status was
|
|
58
|
+
* overridden to `divergent` or `drift` because it is also lagging.
|
|
59
|
+
* Part of the `--json` contract since fix-round-2 (review finding L6):
|
|
60
|
+
* a `--json` consumer previously had no way to see version-lag on a
|
|
61
|
+
* target whose status field reads `divergent` or `drift`.
|
|
62
|
+
*/
|
|
63
|
+
versionLag: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* `null` for every status except `unverifiable`, where it explains what
|
|
66
|
+
* could not be checked: `"directory not accessible"` (the target
|
|
67
|
+
* directory, or a directory on the path to its repo manifest, failed to
|
|
68
|
+
* stat for a reason other than ENOENT) or `"manifest unreadable"` (the
|
|
69
|
+
* directory itself stats fine and the manifest file exists, but it could
|
|
70
|
+
* not be parsed or read). Part of the `--json` contract (review finding
|
|
71
|
+
* M1) so a `--json` consumer can distinguish the two causes without
|
|
72
|
+
* re-deriving them.
|
|
73
|
+
*/
|
|
74
|
+
reason: string | null;
|
|
75
|
+
}
|
|
76
|
+
/** The subset of `TargetReport` that is part of the `--json` contract. */
|
|
77
|
+
export interface TargetReportJson {
|
|
78
|
+
path: string;
|
|
79
|
+
status: TargetStatus;
|
|
80
|
+
installedVersion: string | null;
|
|
81
|
+
pin: string | null;
|
|
82
|
+
divergence: TargetDivergence | null;
|
|
83
|
+
driftFiles: string[] | null;
|
|
84
|
+
versionLag: boolean;
|
|
85
|
+
reason: string | null;
|
|
86
|
+
}
|
|
87
|
+
export declare function targetReportToJson(report: TargetReport): TargetReportJson;
|
|
88
|
+
/**
|
|
89
|
+
* Maps a single target's status to `adopt`'s single-target exit-code
|
|
90
|
+
* contract: 0 for `clean`/`divergent`/`version-lag`, 1 for `drift`, 2 for
|
|
91
|
+
* `missing`/`no-manifest`/`unverifiable`. A pure function, exported and
|
|
92
|
+
* unit-testable directly against all seven {@link TargetStatus} values,
|
|
93
|
+
* rather than left as the inline ternary chain `cli.ts`'s `adopt` action
|
|
94
|
+
* used to carry (fix-round, review findings M3/L5): a live `adopt` run can
|
|
95
|
+
* only ever exercise `clean`/`divergent`/`version-lag`/`drift` in practice
|
|
96
|
+
* (the directory and manifest were just read successfully immediately
|
|
97
|
+
* before this status is computed), so the `missing`/`no-manifest`/
|
|
98
|
+
* `unverifiable` branches were previously pinned by nothing at all.
|
|
99
|
+
*/
|
|
100
|
+
export declare function adoptExitCodeForStatus(status: TargetStatus): 0 | 1 | 2;
|
|
101
|
+
/**
|
|
102
|
+
* Whether `adopt`'s human-mode success line ("Adopted ...") must be
|
|
103
|
+
* suppressed for a target report whose status is `status`: true for exactly
|
|
104
|
+
* the three statuses {@link adoptExitCodeForStatus} maps to exit code 2
|
|
105
|
+
* (`missing`, `no-manifest`, `unverifiable`), statuses that should not
|
|
106
|
+
* occur for a target whose directory and manifest were just verified
|
|
107
|
+
* immediately before this status was computed, so printing the success line
|
|
108
|
+
* ahead of the stderr bug note would be misleading. A pure function,
|
|
109
|
+
* exported and unit-tested directly against all seven {@link TargetStatus}
|
|
110
|
+
* values, rather than left as `cli.ts`'s inline `exitCode === 2` check
|
|
111
|
+
* (fix-round-2), the same reasoning that already pulled
|
|
112
|
+
* `adoptExitCodeForStatus` itself out of an inline ternary chain.
|
|
113
|
+
*/
|
|
114
|
+
export declare function suppressSuccessLine(status: TargetStatus): boolean;
|
|
115
|
+
export interface DoctorReport {
|
|
116
|
+
operatorHome: string;
|
|
117
|
+
operatorVersion: string;
|
|
118
|
+
targets: TargetReport[];
|
|
119
|
+
pruned: string[];
|
|
120
|
+
exitCode: 0 | 1 | 2;
|
|
121
|
+
/**
|
|
122
|
+
* The count of raw `targets` array entries in the on-disk operator
|
|
123
|
+
* manifest that `readOperatorManifest`'s own per-entry validation
|
|
124
|
+
* silently dropped (wrong shape, a missing field, ...), distinct from
|
|
125
|
+
* the targets named in `pruned` above (which were validly-shaped but
|
|
126
|
+
* `missing`/`no-manifest`). Always `0` unless `--prune` both ran and
|
|
127
|
+
* actually wrote (`pruned.length > 0`); `cli.ts`'s human-output prune
|
|
128
|
+
* note prints only when this is greater than zero, naming the count
|
|
129
|
+
* (fix-round-2 review finding M3: the note used to print unconditionally
|
|
130
|
+
* whenever anything at all was pruned, even when the file held no
|
|
131
|
+
* unvalidatable raw entry to report).
|
|
132
|
+
*/
|
|
133
|
+
unvalidatedDropped: number;
|
|
134
|
+
/**
|
|
135
|
+
* Set only when no operator manifest was evaluated; `targets` is then
|
|
136
|
+
* `[]`. `no-operator-manifest`: no file exists at
|
|
137
|
+
* `<operatorHome>/manifest.json`. `operator-manifest-unreadable`: the
|
|
138
|
+
* file exists but `readOperatorManifest` could not parse or validate it
|
|
139
|
+
* (corrupt JSON, or an envelope that does not match this kit).
|
|
140
|
+
*/
|
|
141
|
+
error?: "no-operator-manifest" | "operator-manifest-unreadable";
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Stats `path`, distinguishing "does not exist" (`ENOENT`) from every other
|
|
145
|
+
* stat failure (most commonly `EACCES` on an ancestor directory). Plain
|
|
146
|
+
* `existsSync` cannot make this distinction: it swallows every error alike
|
|
147
|
+
* and returns `false` either way (review round 2, M2), which is exactly
|
|
148
|
+
* what let an inaccessible-but-present target get misreported as
|
|
149
|
+
* `missing`/`no-manifest` and then pruned out of the registry on a guess.
|
|
150
|
+
*/
|
|
151
|
+
export declare function statOrClassify(path: string): {
|
|
152
|
+
kind: "ok";
|
|
153
|
+
stat: Stats;
|
|
154
|
+
} | {
|
|
155
|
+
kind: "enoent";
|
|
156
|
+
} | {
|
|
157
|
+
kind: "error";
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Computes one target's status against the operator's registry. Pure
|
|
161
|
+
* read-only I/O (existence checks, file hashing, reading the target's own
|
|
162
|
+
* repo manifest); no printing, no `process.exit`. Exported standalone so a
|
|
163
|
+
* later `adopt` command can reuse the same per-target computation `doctor`
|
|
164
|
+
* uses.
|
|
165
|
+
*/
|
|
166
|
+
export declare function inspectTarget(target: OperatorTarget, operator: OperatorManifest, kitVersion: string): TargetReport;
|
|
167
|
+
/**
|
|
168
|
+
* Walks the operator manifest's target registry at `home` and reports each
|
|
169
|
+
* target's status. No printing, no `process.exit`: `cli.ts` turns this
|
|
170
|
+
* into human or `--json` output and applies `exitCode` itself.
|
|
171
|
+
*
|
|
172
|
+
* Exit-code contract: 2 when no operator manifest exists (nothing else is
|
|
173
|
+
* evaluated); else 1 if any *remaining* target (after an optional prune) is
|
|
174
|
+
* `drift`, `missing`, `no-manifest`, or `unverifiable`; else 0.
|
|
175
|
+
*
|
|
176
|
+
* `--prune`: targets whose status is `missing` or `no-manifest` are removed
|
|
177
|
+
* from the operator manifest's `targets` array and persisted (only when at
|
|
178
|
+
* least one target was actually removed, mirroring `setup`'s no-op-write
|
|
179
|
+
* avoidance) before the exit code and `targets` in the returned report are
|
|
180
|
+
* computed, so both reflect the post-prune registry. `unverifiable` targets
|
|
181
|
+
* are never removed (see {@link TargetStatus}'s doc comment). `pruned`
|
|
182
|
+
* always lists the removed paths, even when empty. The whole re-read,
|
|
183
|
+
* report computation, and write run inside {@link updateOperatorManifest}'s
|
|
184
|
+
* single locked critical section (review round 1, H1's own fix, reused
|
|
185
|
+
* here rather than doctor keeping a second, unlocked read-modify-write path
|
|
186
|
+
* of its own), so a concurrent `apply`/`setup`/another `doctor --prune`
|
|
187
|
+
* cannot land its own write in between this read and this write.
|
|
188
|
+
*
|
|
189
|
+
* `options.lockOptions` is exposed only so tests can shrink
|
|
190
|
+
* `updateOperatorManifest`'s lock-acquire timeout/staleness/poll windows
|
|
191
|
+
* below their production defaults (see `OperatorManifestLockOptions`);
|
|
192
|
+
* production callers should omit it entirely. `updateOperatorManifest`
|
|
193
|
+
* (via `withOperatorManifestLock`) can itself throw rather than return,
|
|
194
|
+
* most commonly `OperatorManifestLockTimeoutError` (another
|
|
195
|
+
* orchestrator-workflow command holds the lock past the timeout) or any
|
|
196
|
+
* other error raised while acquiring it (e.g. `EACCES` creating the lock
|
|
197
|
+
* directory under a read-only operator home). This function deliberately
|
|
198
|
+
* does not catch either: `cli.ts`'s `doctor` action is the layer that
|
|
199
|
+
* turns such a throw into a `--json`/human-readable exit-2 report,
|
|
200
|
+
* exactly the way it already turns a returned `DoctorReport` into one.
|
|
201
|
+
*/
|
|
202
|
+
export declare function runDoctor(home: string, options?: {
|
|
203
|
+
prune?: boolean;
|
|
204
|
+
lockOptions?: OperatorManifestLockOptions;
|
|
205
|
+
}): DoctorReport;
|
|
206
|
+
/**
|
|
207
|
+
* The extra `--json` key `adopt` adds only for a target status that should
|
|
208
|
+
* be unreachable immediately after a successful directory/manifest read
|
|
209
|
+
* (`missing`/`no-manifest`/`unverifiable`, i.e. wherever
|
|
210
|
+
* {@link adoptExitCodeForStatus} returns `2`): `error:
|
|
211
|
+
* "unexpected-target-status"`, so a `--json` consumer can tell this genuine
|
|
212
|
+
* internal-error case apart from any other result sharing the same exit
|
|
213
|
+
* code (fix-round, review finding M3). Appended at the end of this module,
|
|
214
|
+
* after every other exported member, so adding it does not shift any
|
|
215
|
+
* existing `doctor.ts:` line citation in docs/okf. A pure function,
|
|
216
|
+
* exported and unit-tested directly (the branch itself stays unreachable
|
|
217
|
+
* through a live `adopt` run, the same "unreachable in practice" property
|
|
218
|
+
* `cli.ts`'s own comment on that branch already documents).
|
|
219
|
+
*/
|
|
220
|
+
export declare function adoptJsonExtras(status: TargetStatus): {
|
|
221
|
+
error: "unexpected-target-status";
|
|
222
|
+
} | Record<string, never>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { PACKAGE_VERSION } from "./assets.js";
|
|
5
|
+
import { MANIFEST_PATH, readInstalledManifest } from "./init.js";
|
|
6
|
+
import { DEFAULT_MODELS, ROLES } from "./models.js";
|
|
7
|
+
import { OPERATOR_MANIFEST_FILENAME, operatorManifestState, updateOperatorManifest, } from "./operator-manifest.js";
|
|
8
|
+
export function targetReportToJson(report) {
|
|
9
|
+
return {
|
|
10
|
+
path: report.path,
|
|
11
|
+
status: report.status,
|
|
12
|
+
installedVersion: report.installedVersion,
|
|
13
|
+
pin: report.pin,
|
|
14
|
+
divergence: report.divergence,
|
|
15
|
+
driftFiles: report.driftFiles,
|
|
16
|
+
versionLag: report.versionLag,
|
|
17
|
+
reason: report.reason,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Maps a single target's status to `adopt`'s single-target exit-code
|
|
22
|
+
* contract: 0 for `clean`/`divergent`/`version-lag`, 1 for `drift`, 2 for
|
|
23
|
+
* `missing`/`no-manifest`/`unverifiable`. A pure function, exported and
|
|
24
|
+
* unit-testable directly against all seven {@link TargetStatus} values,
|
|
25
|
+
* rather than left as the inline ternary chain `cli.ts`'s `adopt` action
|
|
26
|
+
* used to carry (fix-round, review findings M3/L5): a live `adopt` run can
|
|
27
|
+
* only ever exercise `clean`/`divergent`/`version-lag`/`drift` in practice
|
|
28
|
+
* (the directory and manifest were just read successfully immediately
|
|
29
|
+
* before this status is computed), so the `missing`/`no-manifest`/
|
|
30
|
+
* `unverifiable` branches were previously pinned by nothing at all.
|
|
31
|
+
*/
|
|
32
|
+
export function adoptExitCodeForStatus(status) {
|
|
33
|
+
switch (status) {
|
|
34
|
+
case "missing":
|
|
35
|
+
case "no-manifest":
|
|
36
|
+
case "unverifiable":
|
|
37
|
+
return 2;
|
|
38
|
+
case "drift":
|
|
39
|
+
return 1;
|
|
40
|
+
case "clean":
|
|
41
|
+
case "divergent":
|
|
42
|
+
case "version-lag":
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether `adopt`'s human-mode success line ("Adopted ...") must be
|
|
48
|
+
* suppressed for a target report whose status is `status`: true for exactly
|
|
49
|
+
* the three statuses {@link adoptExitCodeForStatus} maps to exit code 2
|
|
50
|
+
* (`missing`, `no-manifest`, `unverifiable`), statuses that should not
|
|
51
|
+
* occur for a target whose directory and manifest were just verified
|
|
52
|
+
* immediately before this status was computed, so printing the success line
|
|
53
|
+
* ahead of the stderr bug note would be misleading. A pure function,
|
|
54
|
+
* exported and unit-tested directly against all seven {@link TargetStatus}
|
|
55
|
+
* values, rather than left as `cli.ts`'s inline `exitCode === 2` check
|
|
56
|
+
* (fix-round-2), the same reasoning that already pulled
|
|
57
|
+
* `adoptExitCodeForStatus` itself out of an inline ternary chain.
|
|
58
|
+
*/
|
|
59
|
+
export function suppressSuccessLine(status) {
|
|
60
|
+
return adoptExitCodeForStatus(status) === 2;
|
|
61
|
+
}
|
|
62
|
+
function sha256(content) {
|
|
63
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
64
|
+
}
|
|
65
|
+
/** Resolves a role's model against a possibly-partial models map, falling
|
|
66
|
+
* back to the shipped default the same way `cli.ts`'s `defaultsAsManifest`
|
|
67
|
+
* and `readInstalledManifest`'s per-role degradation both already do. */
|
|
68
|
+
function resolvedModel(models, role) {
|
|
69
|
+
return models[role] ?? DEFAULT_MODELS[role];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Relative paths (from the repo manifest's `files` ledger) whose on-disk
|
|
73
|
+
* sha256 no longer matches the recorded hash, or that are missing/not a
|
|
74
|
+
* regular file on disk. Empty when the target is clean of drift.
|
|
75
|
+
*/
|
|
76
|
+
function computeDriftFiles(targetPath, manifest) {
|
|
77
|
+
const drifted = [];
|
|
78
|
+
for (const [relativePath, recordedHash] of Object.entries(manifest.files)) {
|
|
79
|
+
const filePath = join(targetPath, relativePath);
|
|
80
|
+
let isFile = false;
|
|
81
|
+
try {
|
|
82
|
+
isFile = existsSync(filePath) && statSync(filePath).isFile();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
isFile = false;
|
|
86
|
+
}
|
|
87
|
+
if (!isFile) {
|
|
88
|
+
drifted.push(relativePath);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// A file that exists and stat's as a regular file can still fail to
|
|
92
|
+
// read (permissions, a race with something else removing it, ...). A
|
|
93
|
+
// read failure means this path's drift status against the recorded
|
|
94
|
+
// hash cannot be verified, so it is counted as drift rather than
|
|
95
|
+
// aborting the whole target (and the rest of the registry).
|
|
96
|
+
let content;
|
|
97
|
+
try {
|
|
98
|
+
content = readFileSync(filePath, "utf8");
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
drifted.push(relativePath);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (sha256(content) !== recordedHash) {
|
|
105
|
+
drifted.push(relativePath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return drifted;
|
|
109
|
+
}
|
|
110
|
+
function baseReport(target, operator, status, reason) {
|
|
111
|
+
return {
|
|
112
|
+
path: target.path,
|
|
113
|
+
status,
|
|
114
|
+
installedVersion: null,
|
|
115
|
+
pin: null,
|
|
116
|
+
divergence: null,
|
|
117
|
+
driftFiles: null,
|
|
118
|
+
repoProfile: null,
|
|
119
|
+
operatorProfile: operator.defaults.profile,
|
|
120
|
+
repoTiers: null,
|
|
121
|
+
operatorTiers: operator.defaults.tiers,
|
|
122
|
+
divergentModelRoles: [],
|
|
123
|
+
versionLag: false,
|
|
124
|
+
reason,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Stats `path`, distinguishing "does not exist" (`ENOENT`) from every other
|
|
129
|
+
* stat failure (most commonly `EACCES` on an ancestor directory). Plain
|
|
130
|
+
* `existsSync` cannot make this distinction: it swallows every error alike
|
|
131
|
+
* and returns `false` either way (review round 2, M2), which is exactly
|
|
132
|
+
* what let an inaccessible-but-present target get misreported as
|
|
133
|
+
* `missing`/`no-manifest` and then pruned out of the registry on a guess.
|
|
134
|
+
*/
|
|
135
|
+
export function statOrClassify(path) {
|
|
136
|
+
try {
|
|
137
|
+
return { kind: "ok", stat: statSync(path) };
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return error.code === "ENOENT"
|
|
141
|
+
? { kind: "enoent" }
|
|
142
|
+
: { kind: "error" };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Computes one target's status against the operator's registry. Pure
|
|
147
|
+
* read-only I/O (existence checks, file hashing, reading the target's own
|
|
148
|
+
* repo manifest); no printing, no `process.exit`. Exported standalone so a
|
|
149
|
+
* later `adopt` command can reuse the same per-target computation `doctor`
|
|
150
|
+
* uses.
|
|
151
|
+
*/
|
|
152
|
+
export function inspectTarget(target, operator, kitVersion) {
|
|
153
|
+
const dirStat = statOrClassify(target.path);
|
|
154
|
+
if (dirStat.kind === "enoent") {
|
|
155
|
+
return baseReport(target, operator, "missing", null);
|
|
156
|
+
}
|
|
157
|
+
if (dirStat.kind === "error") {
|
|
158
|
+
return baseReport(target, operator, "unverifiable", "directory not accessible");
|
|
159
|
+
}
|
|
160
|
+
if (!dirStat.stat.isDirectory()) {
|
|
161
|
+
return baseReport(target, operator, "missing", null);
|
|
162
|
+
}
|
|
163
|
+
const manifestStat = statOrClassify(join(target.path, MANIFEST_PATH));
|
|
164
|
+
if (manifestStat.kind === "enoent") {
|
|
165
|
+
return baseReport(target, operator, "no-manifest", null);
|
|
166
|
+
}
|
|
167
|
+
if (manifestStat.kind === "error") {
|
|
168
|
+
return baseReport(target, operator, "unverifiable", "directory not accessible");
|
|
169
|
+
}
|
|
170
|
+
const manifest = readInstalledManifest(target.path);
|
|
171
|
+
if (!manifest) {
|
|
172
|
+
return baseReport(target, operator, "unverifiable", "manifest unreadable");
|
|
173
|
+
}
|
|
174
|
+
const driftFiles = computeDriftFiles(target.path, manifest);
|
|
175
|
+
const divergentModelRoles = ROLES.filter((role) => resolvedModel(manifest.models, role) !==
|
|
176
|
+
resolvedModel(operator.defaults.models, role));
|
|
177
|
+
const divergence = {
|
|
178
|
+
profile: manifest.profile !== operator.defaults.profile,
|
|
179
|
+
tiers: manifest.tiers !== operator.defaults.tiers,
|
|
180
|
+
models: divergentModelRoles.length > 0,
|
|
181
|
+
};
|
|
182
|
+
// A recorded pin suppresses version-lag only when the pin equals the
|
|
183
|
+
// repo's own installed version: that is the expected, deliberate-stay
|
|
184
|
+
// state. When the pin and the installed version differ, the installed
|
|
185
|
+
// manifest no longer reflects what was pinned (someone changed the pin
|
|
186
|
+
// without reapplying, or the installed version drifted some other way),
|
|
187
|
+
// so the target is still version-lag, even though it carries a pin
|
|
188
|
+
// (drift, if also present, still takes precedence over the final status
|
|
189
|
+
// field below). With no pin at all, version-lag compares the installed
|
|
190
|
+
// version against the running kit version, as before.
|
|
191
|
+
const hasPin = typeof manifest.pin === "string" && manifest.pin.length > 0;
|
|
192
|
+
const versionLag = hasPin
|
|
193
|
+
? manifest.pin !== manifest.version
|
|
194
|
+
: manifest.version !== kitVersion;
|
|
195
|
+
let status;
|
|
196
|
+
if (driftFiles.length > 0) {
|
|
197
|
+
status = "drift";
|
|
198
|
+
}
|
|
199
|
+
else if (divergence.profile || divergence.tiers || divergence.models) {
|
|
200
|
+
status = "divergent";
|
|
201
|
+
}
|
|
202
|
+
else if (versionLag) {
|
|
203
|
+
status = "version-lag";
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
status = "clean";
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
path: target.path,
|
|
210
|
+
status,
|
|
211
|
+
installedVersion: manifest.version.length > 0 ? manifest.version : null,
|
|
212
|
+
pin: hasPin ? manifest.pin : null,
|
|
213
|
+
divergence,
|
|
214
|
+
driftFiles: driftFiles.length > 0 ? driftFiles : null,
|
|
215
|
+
repoProfile: manifest.profile,
|
|
216
|
+
operatorProfile: operator.defaults.profile,
|
|
217
|
+
repoTiers: manifest.tiers,
|
|
218
|
+
operatorTiers: operator.defaults.tiers,
|
|
219
|
+
divergentModelRoles,
|
|
220
|
+
versionLag,
|
|
221
|
+
reason: null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const REMOVE_ON_PRUNE = new Set([
|
|
225
|
+
"missing",
|
|
226
|
+
"no-manifest",
|
|
227
|
+
]);
|
|
228
|
+
/**
|
|
229
|
+
* Re-reads `<home>/manifest.json`'s raw JSON (independent of
|
|
230
|
+
* `readOperatorManifest`'s own parsed, validated result) and counts the
|
|
231
|
+
* entries in its `targets` array, or `null` when the file cannot be read
|
|
232
|
+
* or parsed at all. `readOperatorManifest` silently drops any raw target
|
|
233
|
+
* entry that fails its own per-entry shape check, so its parsed
|
|
234
|
+
* `manifest.targets.length` alone cannot tell whether the file held extra,
|
|
235
|
+
* unvalidatable entries; this reads the same bytes a second time to answer
|
|
236
|
+
* exactly that (review round 2, M3).
|
|
237
|
+
*/
|
|
238
|
+
function countRawTargets(home) {
|
|
239
|
+
try {
|
|
240
|
+
const raw = JSON.parse(readFileSync(join(home, OPERATOR_MANIFEST_FILENAME), "utf8"));
|
|
241
|
+
if (typeof raw !== "object" || raw === null)
|
|
242
|
+
return null;
|
|
243
|
+
const candidate = raw;
|
|
244
|
+
return Array.isArray(candidate.targets) ? candidate.targets.length : 0;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Walks the operator manifest's target registry at `home` and reports each
|
|
252
|
+
* target's status. No printing, no `process.exit`: `cli.ts` turns this
|
|
253
|
+
* into human or `--json` output and applies `exitCode` itself.
|
|
254
|
+
*
|
|
255
|
+
* Exit-code contract: 2 when no operator manifest exists (nothing else is
|
|
256
|
+
* evaluated); else 1 if any *remaining* target (after an optional prune) is
|
|
257
|
+
* `drift`, `missing`, `no-manifest`, or `unverifiable`; else 0.
|
|
258
|
+
*
|
|
259
|
+
* `--prune`: targets whose status is `missing` or `no-manifest` are removed
|
|
260
|
+
* from the operator manifest's `targets` array and persisted (only when at
|
|
261
|
+
* least one target was actually removed, mirroring `setup`'s no-op-write
|
|
262
|
+
* avoidance) before the exit code and `targets` in the returned report are
|
|
263
|
+
* computed, so both reflect the post-prune registry. `unverifiable` targets
|
|
264
|
+
* are never removed (see {@link TargetStatus}'s doc comment). `pruned`
|
|
265
|
+
* always lists the removed paths, even when empty. The whole re-read,
|
|
266
|
+
* report computation, and write run inside {@link updateOperatorManifest}'s
|
|
267
|
+
* single locked critical section (review round 1, H1's own fix, reused
|
|
268
|
+
* here rather than doctor keeping a second, unlocked read-modify-write path
|
|
269
|
+
* of its own), so a concurrent `apply`/`setup`/another `doctor --prune`
|
|
270
|
+
* cannot land its own write in between this read and this write.
|
|
271
|
+
*
|
|
272
|
+
* `options.lockOptions` is exposed only so tests can shrink
|
|
273
|
+
* `updateOperatorManifest`'s lock-acquire timeout/staleness/poll windows
|
|
274
|
+
* below their production defaults (see `OperatorManifestLockOptions`);
|
|
275
|
+
* production callers should omit it entirely. `updateOperatorManifest`
|
|
276
|
+
* (via `withOperatorManifestLock`) can itself throw rather than return,
|
|
277
|
+
* most commonly `OperatorManifestLockTimeoutError` (another
|
|
278
|
+
* orchestrator-workflow command holds the lock past the timeout) or any
|
|
279
|
+
* other error raised while acquiring it (e.g. `EACCES` creating the lock
|
|
280
|
+
* directory under a read-only operator home). This function deliberately
|
|
281
|
+
* does not catch either: `cli.ts`'s `doctor` action is the layer that
|
|
282
|
+
* turns such a throw into a `--json`/human-readable exit-2 report,
|
|
283
|
+
* exactly the way it already turns a returned `DoctorReport` into one.
|
|
284
|
+
*/
|
|
285
|
+
export function runDoctor(home, options = {}) {
|
|
286
|
+
const state = operatorManifestState(home);
|
|
287
|
+
if (state.kind !== "ok") {
|
|
288
|
+
// `absent`: no file to read. `unreadable`: the file exists but is
|
|
289
|
+
// corrupt or does not validate. Distinguished here so the CLI can tell
|
|
290
|
+
// an operator who has simply never run `setup` apart from one whose
|
|
291
|
+
// manifest needs repair (the latter must not be papered over by
|
|
292
|
+
// re-running `setup`, which would silently rewrite `targets: []` over
|
|
293
|
+
// a possibly-fine targets array sitting next to the unreadable
|
|
294
|
+
// envelope).
|
|
295
|
+
const error = state.kind === "absent"
|
|
296
|
+
? "no-operator-manifest"
|
|
297
|
+
: "operator-manifest-unreadable";
|
|
298
|
+
return {
|
|
299
|
+
operatorHome: home,
|
|
300
|
+
operatorVersion: PACKAGE_VERSION,
|
|
301
|
+
targets: [],
|
|
302
|
+
pruned: [],
|
|
303
|
+
exitCode: 2,
|
|
304
|
+
unvalidatedDropped: 0,
|
|
305
|
+
error,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const pruned = [];
|
|
309
|
+
let unvalidatedDropped = 0;
|
|
310
|
+
let targets;
|
|
311
|
+
if (options.prune) {
|
|
312
|
+
// `computedReports`/`pruned`/`unvalidatedDropped` are captured from
|
|
313
|
+
// inside `mutate` (mirroring `apply`'s own `resolvedTargetPath`/
|
|
314
|
+
// `alreadyRegistered` capture in cli.ts) since `mutate` can only return
|
|
315
|
+
// an `OperatorManifest | undefined`. `mutate` re-reads and re-computes
|
|
316
|
+
// from scratch rather than reusing `state` above (which may already be
|
|
317
|
+
// stale by the time the lock is granted), the same reasoning `apply`'s
|
|
318
|
+
// own re-read documents.
|
|
319
|
+
let computedReports;
|
|
320
|
+
const result = updateOperatorManifest(home, (current, innerState) => {
|
|
321
|
+
if (innerState.kind !== "ok" || !current) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
const reports = current.targets.map((target) => inspectTarget(target, current, PACKAGE_VERSION));
|
|
325
|
+
computedReports = reports;
|
|
326
|
+
const keepPaths = new Set();
|
|
327
|
+
for (const report of reports) {
|
|
328
|
+
if (REMOVE_ON_PRUNE.has(report.status)) {
|
|
329
|
+
pruned.push(report.path);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
keepPaths.add(report.path);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (pruned.length === 0) {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
const rawTargetCount = countRawTargets(home);
|
|
339
|
+
if (rawTargetCount !== null) {
|
|
340
|
+
unvalidatedDropped = Math.max(0, rawTargetCount - current.targets.length);
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
...current,
|
|
344
|
+
targets: current.targets.filter((target) => keepPaths.has(target.path)),
|
|
345
|
+
updatedAt: new Date().toISOString(),
|
|
346
|
+
};
|
|
347
|
+
}, options.lockOptions);
|
|
348
|
+
if (result.state.kind !== "ok") {
|
|
349
|
+
// The manifest that read `"ok"` in the outer, unlocked check above
|
|
350
|
+
// (`state`) was found gone or unreadable once the lock was actually
|
|
351
|
+
// granted: a concurrent writer's own read-modify-write landed in
|
|
352
|
+
// between that outer read and this one. `state` is now stale and
|
|
353
|
+
// must not be used as a fallback source of targets (that fallback
|
|
354
|
+
// is exactly what previously let a `--prune` run report, and
|
|
355
|
+
// silently keep, target rows against a registry that no longer
|
|
356
|
+
// exists on disk). Report the failure directly instead, the same
|
|
357
|
+
// shape the outer absent/unreadable check above already returns.
|
|
358
|
+
const error = result.state.kind === "absent"
|
|
359
|
+
? "no-operator-manifest"
|
|
360
|
+
: "operator-manifest-unreadable";
|
|
361
|
+
return {
|
|
362
|
+
operatorHome: home,
|
|
363
|
+
operatorVersion: PACKAGE_VERSION,
|
|
364
|
+
targets: [],
|
|
365
|
+
pruned: [],
|
|
366
|
+
exitCode: 2,
|
|
367
|
+
unvalidatedDropped: 0,
|
|
368
|
+
error,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
// `result.state.kind === "ok"` means `mutate` above ran with a truthy
|
|
372
|
+
// `current`, so `computedReports` was always assigned.
|
|
373
|
+
targets = computedReports.filter((report) => !pruned.includes(report.path));
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
targets = state.manifest.targets.map((target) => inspectTarget(target, state.manifest, PACKAGE_VERSION));
|
|
377
|
+
}
|
|
378
|
+
const exitCode = targets.some((report) => report.status === "drift" ||
|
|
379
|
+
report.status === "missing" ||
|
|
380
|
+
report.status === "no-manifest" ||
|
|
381
|
+
report.status === "unverifiable")
|
|
382
|
+
? 1
|
|
383
|
+
: 0;
|
|
384
|
+
return {
|
|
385
|
+
operatorHome: home,
|
|
386
|
+
operatorVersion: PACKAGE_VERSION,
|
|
387
|
+
targets,
|
|
388
|
+
pruned,
|
|
389
|
+
exitCode,
|
|
390
|
+
unvalidatedDropped,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The extra `--json` key `adopt` adds only for a target status that should
|
|
395
|
+
* be unreachable immediately after a successful directory/manifest read
|
|
396
|
+
* (`missing`/`no-manifest`/`unverifiable`, i.e. wherever
|
|
397
|
+
* {@link adoptExitCodeForStatus} returns `2`): `error:
|
|
398
|
+
* "unexpected-target-status"`, so a `--json` consumer can tell this genuine
|
|
399
|
+
* internal-error case apart from any other result sharing the same exit
|
|
400
|
+
* code (fix-round, review finding M3). Appended at the end of this module,
|
|
401
|
+
* after every other exported member, so adding it does not shift any
|
|
402
|
+
* existing `doctor.ts:` line citation in docs/okf. A pure function,
|
|
403
|
+
* exported and unit-tested directly (the branch itself stays unreachable
|
|
404
|
+
* through a live `adopt` run, the same "unreachable in practice" property
|
|
405
|
+
* `cli.ts`'s own comment on that branch already documents).
|
|
406
|
+
*/
|
|
407
|
+
export function adoptJsonExtras(status) {
|
|
408
|
+
return adoptExitCodeForStatus(status) === 2
|
|
409
|
+
? { error: "unexpected-target-status" }
|
|
410
|
+
: {};
|
|
411
|
+
}
|