@b00dea/cockpit 0.1.0 → 0.1.1
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 +99 -0
- package/README.md +3 -0
- package/cockpit.schema.json +73 -3
- package/dist/autonomy.d.ts +3 -2
- package/dist/autonomy.js +30 -13
- package/dist/browser/commands.d.ts +57 -2
- package/dist/browser/commands.js +114 -10
- package/dist/browser/runtime.d.ts +1 -0
- package/dist/browser/runtime.js +4 -1
- package/dist/browser/surfaces.d.ts +7 -7
- package/dist/browser/surfaces.js +5 -0
- package/dist/canonical-json.d.ts +2 -0
- package/dist/canonical-json.js +5 -0
- package/dist/cli-main.d.ts +40 -3
- package/dist/cli-main.js +241 -62
- package/dist/command-table.d.ts +19 -11
- package/dist/command-table.js +190 -38
- package/dist/config/load.d.ts +16 -4
- package/dist/config/load.js +25 -3
- package/dist/config/unconfirmed-toml.d.ts +9 -0
- package/dist/config/unconfirmed-toml.js +29 -0
- package/dist/diagnostics.js +10 -1
- package/dist/docs/check.d.ts +16 -0
- package/dist/docs/check.js +197 -0
- package/dist/docs/decision-record.d.ts +34 -0
- package/dist/docs/decision-record.js +135 -0
- package/dist/docs/decisions.d.ts +16 -0
- package/dist/docs/decisions.js +103 -0
- package/dist/docs/markdown.d.ts +36 -0
- package/dist/docs/markdown.js +88 -0
- package/dist/docs/references.d.ts +32 -0
- package/dist/docs/references.js +148 -0
- package/dist/environment/application/confirm.d.ts +35 -0
- package/dist/environment/application/confirm.js +122 -0
- package/dist/environment/application/destructive.js +6 -2
- package/dist/environment/application/doctor.d.ts +12 -7
- package/dist/environment/application/doctor.js +20 -8
- package/dist/environment/application/guard.d.ts +12 -6
- package/dist/environment/application/guard.js +87 -32
- package/dist/environment/application/health.d.ts +14 -5
- package/dist/environment/application/health.js +8 -2
- package/dist/environment/application/lifecycle-context.d.ts +10 -3
- package/dist/environment/application/lifecycle-state.js +4 -3
- package/dist/environment/application/lifecycle.d.ts +2 -1
- package/dist/environment/application/lifecycle.js +11 -3
- package/dist/environment/application/services.d.ts +60 -0
- package/dist/environment/application/services.js +89 -0
- package/dist/environment/application/siblings.d.ts +43 -0
- package/dist/environment/application/siblings.js +269 -0
- package/dist/environment/claims.d.ts +10 -10
- package/dist/environment/claims.js +3 -11
- package/dist/environment/compose-topology.d.ts +15 -7
- package/dist/environment/compose-topology.js +100 -23
- package/dist/environment/contract.d.ts +42 -9
- package/dist/environment/contract.js +33 -0
- package/dist/environment/executable.d.ts +12 -0
- package/dist/environment/executable.js +46 -23
- package/dist/environment/generated-application/check.js +2 -0
- package/dist/environment/generated-application/prepare.js +2 -0
- package/dist/environment/generated-application/shared.d.ts +10 -2
- package/dist/environment/generated-application/shared.js +6 -1
- package/dist/environment/generated-application/surface-outputs.js +23 -12
- package/dist/environment/generated-application/surfaces.js +16 -11
- package/dist/environment/generated-application/sync.js +19 -4
- package/dist/environment/git-observation.d.ts +25 -6
- package/dist/environment/git-observation.js +37 -28
- package/dist/environment/health-composition.d.ts +3 -2
- package/dist/environment/health-composition.js +19 -2
- package/dist/environment/init-application.d.ts +39 -14
- package/dist/environment/init-application.js +39 -10
- package/dist/environment/lifecycle-composition.d.ts +4 -2
- package/dist/environment/lifecycle-composition.js +84 -17
- package/dist/environment/lifecycle-stage.d.ts +8 -3
- package/dist/environment/lifecycle-stage.js +11 -1
- package/dist/environment/logs.d.ts +1 -1
- package/dist/environment/map-limit.d.ts +2 -0
- package/dist/environment/map-limit.js +13 -0
- package/dist/environment/mcp-observation.d.ts +9 -0
- package/dist/environment/mcp-observation.js +46 -0
- package/dist/environment/observations.d.ts +1 -1
- package/dist/environment/observations.js +2 -0
- package/dist/environment/operator-application.d.ts +35 -6
- package/dist/environment/operator-application.js +42 -0
- package/dist/environment/presence.d.ts +4 -0
- package/dist/environment/presence.js +26 -0
- package/dist/environment/read-composition.d.ts +8 -3
- package/dist/environment/read-composition.js +53 -8
- package/dist/environment/readiness-composition.d.ts +20 -1
- package/dist/environment/readiness-composition.js +23 -2
- package/dist/environment/reclaim.d.ts +56 -19
- package/dist/environment/reclaim.js +147 -42
- package/dist/environment/runtime.d.ts +64 -18
- package/dist/environment/runtime.js +73 -3
- package/dist/environment/served-checkout-grants.d.ts +53 -0
- package/dist/environment/served-checkout-grants.js +182 -0
- package/dist/environment/sibling-checkouts.d.ts +119 -0
- package/dist/environment/sibling-checkouts.js +254 -0
- package/dist/environment/sibling-observation.d.ts +27 -0
- package/dist/environment/sibling-observation.js +45 -0
- package/dist/environment/sibling-pull.d.ts +23 -0
- package/dist/environment/sibling-pull.js +95 -0
- package/dist/environment/stack-inputs.d.ts +18 -0
- package/dist/environment/stack-inputs.js +210 -0
- package/dist/environment/stack-inventory-prune.d.ts +31 -0
- package/dist/environment/stack-inventory-prune.js +129 -0
- package/dist/environment/stack-inventory-report.d.ts +44 -0
- package/dist/environment/stack-inventory-report.js +78 -0
- package/dist/environment/stack-inventory.d.ts +94 -0
- package/dist/environment/stack-inventory.js +418 -0
- package/dist/environment/state.d.ts +48 -9
- package/dist/environment/state.js +142 -2
- package/dist/environment/tool-registration.js +3 -7
- package/dist/environment-exec/execute.d.ts +6 -3
- package/dist/environment-exec/execute.js +4 -0
- package/dist/fix-catalogue.d.ts +1 -1
- package/dist/generated-environment/adopt.js +4 -8
- package/dist/guards/answer-confirmation.d.ts +41 -0
- package/dist/guards/answer-confirmation.js +109 -0
- package/dist/guards/evaluator.d.ts +20 -3
- package/dist/guards/evaluator.js +39 -7
- package/dist/guards/local-identity.d.ts +6 -2
- package/dist/guards/local-identity.js +10 -4
- package/dist/guards/policy-change.d.ts +43 -0
- package/dist/guards/policy-change.js +159 -0
- package/dist/guards/policy-pin.d.ts +1 -1
- package/dist/guards/policy-pin.js +14 -12
- package/dist/init/command.d.ts +4 -3
- package/dist/init/command.js +36 -16
- package/dist/init/derive.d.ts +8 -6
- package/dist/init/derive.js +140 -36
- package/dist/init/draft.d.ts +13 -11
- package/dist/init/draft.js +54 -31
- package/dist/init/render.d.ts +18 -6
- package/dist/init/render.js +31 -9
- package/dist/repository-path.d.ts +9 -0
- package/dist/repository-path.js +16 -0
- package/dist/schema.d.ts +42 -75
- package/dist/schema.js +62 -11
- package/dist/surfaces/harness-registry.d.ts +80 -0
- package/dist/surfaces/harness-registry.js +29 -0
- package/dist/surfaces/mcp.d.ts +67 -17
- package/dist/surfaces/mcp.js +194 -31
- package/dist/surfaces/projection.d.ts +7 -7
- package/dist/surfaces/projection.js +41 -6
- package/dist/surfaces/render.d.ts +5 -44
- package/dist/surfaces/render.js +5 -2
- package/dist/team-metadata.js +2 -1
- package/dist/testing/test-protection.d.ts +34 -0
- package/dist/testing/test-protection.js +137 -0
- package/dist/testing/weakening.d.ts +15 -0
- package/dist/testing/weakening.js +30 -0
- package/dist/work-orders/application-closure.d.ts +7 -0
- package/dist/work-orders/application-closure.js +76 -0
- package/dist/work-orders/application-context.d.ts +162 -0
- package/dist/work-orders/application-context.js +137 -0
- package/dist/work-orders/application-decisions.d.ts +27 -0
- package/dist/work-orders/application-decisions.js +121 -0
- package/dist/work-orders/application-grading.d.ts +11 -0
- package/dist/work-orders/application-grading.js +212 -0
- package/dist/work-orders/application.d.ts +4 -97
- package/dist/work-orders/application.js +94 -254
- package/dist/work-orders/composition.d.ts +35 -58
- package/dist/work-orders/composition.js +216 -24
- package/dist/work-orders/contract.d.ts +22 -6
- package/dist/work-orders/contract.js +34 -16
- package/dist/work-orders/decisions.d.ts +23 -0
- package/dist/work-orders/decisions.js +55 -0
- package/dist/work-orders/store.d.ts +54 -5
- package/dist/work-orders/store.js +118 -24
- package/docs/README.md +6 -3
- package/docs/capability-matrix.md +17 -9
- package/docs/cli-reference.md +209 -1
- package/docs/command-list.md +12 -1
- package/docs/concepts.md +6 -4
- package/docs/configuration-essentials.md +7 -2
- package/docs/configuration-reference.md +18 -3
- package/docs/docs-check.md +76 -0
- package/docs/environment.md +194 -6
- package/docs/faq.md +5 -3
- package/docs/getting-started.md +109 -25
- package/docs/harness-surfaces.md +69 -2
- package/docs/safety-and-autonomy.md +56 -12
- package/docs/troubleshooting.md +29 -8
- package/docs/tutorial.md +3 -2
- package/docs/work-and-browser.md +180 -14
- package/libexec/browser-operation.cjs +86 -2
- package/package.json +2 -2
- package/surfaces/skills/cockpit-browser.skill.md +7 -2
- package/surfaces/skills/cockpit-explain.skill.md +1 -1
- package/surfaces/skills/cockpit-fix.skill.md +1 -1
- package/surfaces/skills/cockpit-setup.skill.md +3 -3
- package/surfaces/skills/cockpit-work.skill.md +1 -1
- package/surfaces/skills/procedures/explain.md +9 -4
- package/surfaces/skills/procedures/fix.md +25 -11
- package/surfaces/skills/procedures/setup.md +113 -40
- package/surfaces/skills/procedures/work.md +45 -16
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface ProseLine {
|
|
2
|
+
readonly line: number;
|
|
3
|
+
readonly prose: string;
|
|
4
|
+
readonly comments: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
interface MarkdownLink {
|
|
7
|
+
readonly line: number;
|
|
8
|
+
readonly label: string;
|
|
9
|
+
readonly target: string;
|
|
10
|
+
readonly index: number;
|
|
11
|
+
readonly end: number;
|
|
12
|
+
}
|
|
13
|
+
export interface WalkedMarkdown {
|
|
14
|
+
readonly lines: readonly ProseLine[];
|
|
15
|
+
readonly fences: readonly Readonly<{
|
|
16
|
+
line: number;
|
|
17
|
+
info: string;
|
|
18
|
+
}>[];
|
|
19
|
+
/** Each line inside a fenced block, with the info string of its fence. */
|
|
20
|
+
readonly fencedLines: readonly Readonly<{
|
|
21
|
+
line: number;
|
|
22
|
+
info: string;
|
|
23
|
+
text: string;
|
|
24
|
+
}>[];
|
|
25
|
+
readonly links: readonly MarkdownLink[];
|
|
26
|
+
}
|
|
27
|
+
/** The visible prose of a page, line by line: fenced code and front matter removed, HTML comments lifted out. */
|
|
28
|
+
export declare function walkMarkdownProse(body: string): WalkedMarkdown;
|
|
29
|
+
/**
|
|
30
|
+
* The repository path a link target or cited path names, resolved against `base`, the directory it
|
|
31
|
+
* is relative to ('' for the repository root): its fragment, query, `:line[:column]` suffix and
|
|
32
|
+
* trailing slashes dropped, and its percent escapes decoded. Undefined for an empty, absolute or
|
|
33
|
+
* external (`scheme:`) target. The result may still climb out with `../`; the caller decides.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveLinkTarget(base: string, target: string): string | undefined;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The Markdown reading shared by the repository's own docs lint and `cockpit docs check`: one walker,
|
|
2
|
+
// so both agree on what counts as prose, code and a link.
|
|
3
|
+
import { posix } from 'node:path';
|
|
4
|
+
/** The visible prose of a page, line by line: fenced code and front matter removed, HTML comments lifted out. */
|
|
5
|
+
export function walkMarkdownProse(body) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
const fences = [];
|
|
8
|
+
const fencedLines = [];
|
|
9
|
+
const links = [];
|
|
10
|
+
const rows = body.split(/\r?\n/);
|
|
11
|
+
let fence;
|
|
12
|
+
const start = rows[0] === '---' ? rows.indexOf('---', 1) + 1 : 0;
|
|
13
|
+
let comment;
|
|
14
|
+
for (let index = start; index < rows.length; index += 1) {
|
|
15
|
+
const number = index + 1;
|
|
16
|
+
let text = rows[index];
|
|
17
|
+
const marker = /^\s*(`{3,}|~{3,})(.*)$/.exec(text);
|
|
18
|
+
if (fence !== undefined) {
|
|
19
|
+
if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length && marker[2].trim() === '')
|
|
20
|
+
fence = undefined;
|
|
21
|
+
else
|
|
22
|
+
fencedLines.push({ line: number, info: fences.at(-1).info, text });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (marker && comment === undefined) {
|
|
26
|
+
fence = marker[1];
|
|
27
|
+
fences.push({ line: number, info: marker[2].trim() });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const comments = [];
|
|
31
|
+
let prose = '';
|
|
32
|
+
while (text !== '') {
|
|
33
|
+
if (comment) {
|
|
34
|
+
const close = text.indexOf('-->');
|
|
35
|
+
comment.text += ` ${close === -1 ? text : text.slice(0, close)}`;
|
|
36
|
+
if (close === -1) {
|
|
37
|
+
text = '';
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
text = text.slice(close + 3);
|
|
41
|
+
const owner = lines.find(row => row.line === comment.line);
|
|
42
|
+
const done = comment.text.trim().replace(/\s+/g, ' ');
|
|
43
|
+
if (owner)
|
|
44
|
+
owner.comments.push(done);
|
|
45
|
+
else
|
|
46
|
+
comments.push(done);
|
|
47
|
+
comment = undefined;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const open = text.indexOf('<!--');
|
|
51
|
+
if (open === -1) {
|
|
52
|
+
prose += text;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
prose += text.slice(0, open);
|
|
56
|
+
comment = { line: number, text: '' };
|
|
57
|
+
text = text.slice(open + 4);
|
|
58
|
+
}
|
|
59
|
+
lines.push({ line: number, prose, comments });
|
|
60
|
+
const visible = prose.replace(/`+[^`]*`+/g, span => ' '.repeat(span.length));
|
|
61
|
+
for (const match of visible.matchAll(/\[([^\]]*)\]\(([^)\s]*)[^)]*\)/g)) {
|
|
62
|
+
links.push({ line: number, label: match[1], target: match[2], index: match.index, end: match.index + match[0].length });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (comment)
|
|
66
|
+
throw new Error(`HTML comment opened on line ${comment.line} is not closed`);
|
|
67
|
+
return { lines, fences, fencedLines, links };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The repository path a link target or cited path names, resolved against `base`, the directory it
|
|
71
|
+
* is relative to ('' for the repository root): its fragment, query, `:line[:column]` suffix and
|
|
72
|
+
* trailing slashes dropped, and its percent escapes decoded. Undefined for an empty, absolute or
|
|
73
|
+
* external (`scheme:`) target. The result may still climb out with `../`; the caller decides.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveLinkTarget(base, target) {
|
|
76
|
+
let value = target.split(/[#?]/, 1)[0].replace(/:\d+(?::\d+)?$/, '').replace(/\/+$/, '');
|
|
77
|
+
// A `%` that starts no valid escape is part of the file name, as CommonMark reads it.
|
|
78
|
+
try {
|
|
79
|
+
value = decodeURI(value);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (!(error instanceof URIError))
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
if (value === '' || value.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(value))
|
|
86
|
+
return undefined;
|
|
87
|
+
return posix.normalize(posix.join(base, value));
|
|
88
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Diagnostic } from '../environment/contract.js';
|
|
2
|
+
import { type WalkedMarkdown } from './markdown.js';
|
|
3
|
+
interface RepositoryHistory {
|
|
4
|
+
/** Each path a commit deleted or renamed away, newest first, mapped to its new name for a rename. */
|
|
5
|
+
readonly removed: ReadonlyMap<string, string | undefined>;
|
|
6
|
+
/** Every make target any version of a Makefile defined. */
|
|
7
|
+
readonly targets: ReadonlySet<string>;
|
|
8
|
+
}
|
|
9
|
+
export interface LivingDocument {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly walked: WalkedMarkdown;
|
|
12
|
+
}
|
|
13
|
+
/** `git log --format= --name-status -z -M --diff-filter=DR` output, newest commit first. */
|
|
14
|
+
export declare function parseRemovedPaths(output: string): Map<string, string | undefined>;
|
|
15
|
+
/** The targets the given Makefile lines define, including .PHONY names; special and pattern targets are skipped. */
|
|
16
|
+
export declare function makeTargets(lines: Iterable<string>): Set<string>;
|
|
17
|
+
/** The targets ever defined, from `git log -p` over the Makefiles: every line was added once. */
|
|
18
|
+
export declare function historicalMakeTargets(patch: string): Set<string>;
|
|
19
|
+
/**
|
|
20
|
+
* The stale references in the given documents. `files` are the repository's files now; a cited
|
|
21
|
+
* directory counts as present while any file is under it.
|
|
22
|
+
*/
|
|
23
|
+
export declare function staleReferences(input: Readonly<{
|
|
24
|
+
repository: string;
|
|
25
|
+
documents: readonly LivingDocument[];
|
|
26
|
+
files: ReadonlySet<string>;
|
|
27
|
+
targets: ReadonlySet<string>;
|
|
28
|
+
history: RepositoryHistory;
|
|
29
|
+
/** Whether the path is on disk; an ignored file is absent from `files` but not removed. */
|
|
30
|
+
present: (path: string) => boolean;
|
|
31
|
+
}>): Diagnostic[];
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Stale references in living documents: a path or `make` target a document cites that the
|
|
2
|
+
// repository once had and has since removed. Keying on history is the whole precision story: a
|
|
3
|
+
// backlog full of proposals ("add `make park`") names things that never existed, and those are
|
|
4
|
+
// left alone, while `make publish` after its target was deleted is exactly the rot to report. No
|
|
5
|
+
// allowlist is needed, and a link that never resolved is a link checker's business.
|
|
6
|
+
import { posix } from 'node:path';
|
|
7
|
+
import { resolveLinkTarget } from './markdown.js';
|
|
8
|
+
/** `git log --format= --name-status -z -M --diff-filter=DR` output, newest commit first. */
|
|
9
|
+
export function parseRemovedPaths(output) {
|
|
10
|
+
const removed = new Map();
|
|
11
|
+
const tokens = output.split('\0').filter(token => token !== '');
|
|
12
|
+
for (let index = 0; index < tokens.length;) {
|
|
13
|
+
const status = tokens[index++];
|
|
14
|
+
if (status === 'D') {
|
|
15
|
+
const path = tokens[index++];
|
|
16
|
+
if (!removed.has(path))
|
|
17
|
+
removed.set(path, undefined);
|
|
18
|
+
}
|
|
19
|
+
else if (/^R\d*$/.test(status)) {
|
|
20
|
+
const from = tokens[index++];
|
|
21
|
+
const to = tokens[index++];
|
|
22
|
+
if (!removed.has(from))
|
|
23
|
+
removed.set(from, to);
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
throw new Error(`unexpected git name-status record ${JSON.stringify(status)}`);
|
|
27
|
+
}
|
|
28
|
+
return removed;
|
|
29
|
+
}
|
|
30
|
+
const RULE = /^([^\s:#=$][^:#=$]*?)\s*::?(?!=)/;
|
|
31
|
+
/** The targets the given Makefile lines define, including .PHONY names; special and pattern targets are skipped. */
|
|
32
|
+
export function makeTargets(lines) {
|
|
33
|
+
const targets = new Set();
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const phony = /^\.PHONY\s*:(.*)$/.exec(line);
|
|
36
|
+
const names = phony ? phony[1] : RULE.exec(line)?.[1];
|
|
37
|
+
for (const name of names?.trim().split(/\s+/) ?? []) {
|
|
38
|
+
if (name !== '' && !name.startsWith('.') && !name.includes('%'))
|
|
39
|
+
targets.add(name);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return targets;
|
|
43
|
+
}
|
|
44
|
+
/** The targets ever defined, from `git log -p` over the Makefiles: every line was added once. */
|
|
45
|
+
export function historicalMakeTargets(patch) {
|
|
46
|
+
return makeTargets(patch.split('\n').filter(line => /^[+-]/.test(line) && !/^(?:\+\+\+|---) /.test(line)).map(line => line.slice(1)));
|
|
47
|
+
}
|
|
48
|
+
const SHELL_FENCES = new Set(['', 'sh', 'bash', 'shell', 'zsh', 'console', 'terminal', 'shell-session']);
|
|
49
|
+
const PATH_SPAN = /^[A-Za-z0-9._@+~-][A-Za-z0-9._@+~/-]*$/;
|
|
50
|
+
// `make a b` cites a and b; `make -C dir x` or `make -f other.mk x` cites another Makefile, so not x.
|
|
51
|
+
function citedTargets(text) {
|
|
52
|
+
const targets = [];
|
|
53
|
+
for (const match of text.matchAll(/(?:^|[\s;&|(`$])make((?:[ \t]+[^\s;&|)`]+)*)/g)) {
|
|
54
|
+
const words = match[1].trim().split(/\s+/).filter(Boolean);
|
|
55
|
+
if (words.some(word => /^-(?:C|f)|^--(?:directory|file|makefile)\b/.test(word)))
|
|
56
|
+
continue;
|
|
57
|
+
for (const word of words) {
|
|
58
|
+
if (word.startsWith('-') || word.includes('='))
|
|
59
|
+
continue;
|
|
60
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9._/-]*$/.test(word))
|
|
61
|
+
break;
|
|
62
|
+
targets.push(word);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return targets;
|
|
66
|
+
}
|
|
67
|
+
function citedPath(document, raw, relativeToDocument) {
|
|
68
|
+
const value = raw.trim().replace(/^<|>$/g, '');
|
|
69
|
+
const relative = relativeToDocument || value.startsWith('./') || value.startsWith('../');
|
|
70
|
+
const resolved = resolveLinkTarget(relative ? posix.dirname(document) : '', value);
|
|
71
|
+
return resolved === undefined || resolved === '.' || resolved.startsWith('../') ? undefined : resolved;
|
|
72
|
+
}
|
|
73
|
+
// Every directory above the given files: `a/b/c.md` contributes `a` and `a/b`.
|
|
74
|
+
const ancestors = (files) => new Set([...files].flatMap(file => file.split('/').slice(0, -1).map((_, index, parts) => parts.slice(0, index + 1).join('/'))));
|
|
75
|
+
/**
|
|
76
|
+
* The stale references in the given documents. `files` are the repository's files now; a cited
|
|
77
|
+
* directory counts as present while any file is under it.
|
|
78
|
+
*/
|
|
79
|
+
export function staleReferences(input) {
|
|
80
|
+
const findings = [];
|
|
81
|
+
const directories = ancestors(input.files);
|
|
82
|
+
const exists = (path) => input.files.has(path) || directories.has(path);
|
|
83
|
+
const removedDirectories = ancestors(input.history.removed.keys());
|
|
84
|
+
// Follow renames to where the content lives now; a chain that ends in a deletion is a removal.
|
|
85
|
+
const successor = (path) => {
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
let current = path;
|
|
88
|
+
while (input.history.removed.has(current) && !seen.has(current)) {
|
|
89
|
+
seen.add(current);
|
|
90
|
+
const next = input.history.removed.get(current);
|
|
91
|
+
if (next === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
if (exists(next))
|
|
94
|
+
return next;
|
|
95
|
+
current = next;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
99
|
+
for (const document of input.documents) {
|
|
100
|
+
const reported = new Set();
|
|
101
|
+
const start = findings.length;
|
|
102
|
+
const where = (line) => `${input.repository}${document.path}:${line}`;
|
|
103
|
+
const cite = (line, path) => {
|
|
104
|
+
if (path === undefined || exists(path) || !input.history.removed.has(path) && !removedDirectories.has(path) || input.present(path))
|
|
105
|
+
return;
|
|
106
|
+
const key = `${line}\0${path}`;
|
|
107
|
+
if (reported.has(key))
|
|
108
|
+
return;
|
|
109
|
+
reported.add(key);
|
|
110
|
+
const renamed = successor(path);
|
|
111
|
+
findings.push({ severity: 'error', code: 'DOCS_REFERENCE_REMOVED', target: where(line),
|
|
112
|
+
message: renamed === undefined
|
|
113
|
+
? `${where(line)} cites ${path}, which the repository history removed`
|
|
114
|
+
: `${where(line)} cites ${path}, which the repository history renamed to ${renamed}`,
|
|
115
|
+
fix: 'cite the path that replaced it (a rename is named in the message), or rewrite or delete the sentence if what it described is gone; then rerun npx --no cockpit docs check --config <your cockpit.toml>' });
|
|
116
|
+
};
|
|
117
|
+
const citeTargets = (line, text) => {
|
|
118
|
+
for (const target of citedTargets(text)) {
|
|
119
|
+
const key = `${line}\0make ${target}`;
|
|
120
|
+
if (input.targets.has(target) || !input.history.targets.has(target) || reported.has(key))
|
|
121
|
+
continue;
|
|
122
|
+
reported.add(key);
|
|
123
|
+
findings.push({ severity: 'error', code: 'DOCS_TARGET_REMOVED', target: where(line),
|
|
124
|
+
message: `${where(line)} runs make ${target}, a target the Makefiles once defined and no longer do`,
|
|
125
|
+
fix: 'cite the target or command that replaced it, or rewrite or delete the sentence if the step is gone; then rerun npx --no cockpit docs check --config <your cockpit.toml>' });
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
for (const row of document.walked.lines) {
|
|
129
|
+
for (const span of row.prose.matchAll(/(`+)([^`]+)\1/g)) {
|
|
130
|
+
const value = span[2].trim();
|
|
131
|
+
if (PATH_SPAN.test(value))
|
|
132
|
+
cite(row.line, citedPath(document.path, value, false));
|
|
133
|
+
else
|
|
134
|
+
citeTargets(row.line, value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const link of document.walked.links)
|
|
138
|
+
cite(link.line, citedPath(document.path, link.target, true));
|
|
139
|
+
for (const row of document.walked.fencedLines) {
|
|
140
|
+
if (SHELL_FENCES.has(row.info.split(/\s+/, 1)[0].toLowerCase()))
|
|
141
|
+
citeTargets(row.line, row.text.replace(/^\s*[$%>#]\s+/, ''));
|
|
142
|
+
}
|
|
143
|
+
// Spans, links and fences are read in separate passes; the report follows the document.
|
|
144
|
+
const line = (finding) => Number(finding.target.slice(finding.target.lastIndexOf(':') + 1));
|
|
145
|
+
findings.splice(start, findings.length - start, ...findings.slice(start).sort((left, right) => line(left) - line(right)));
|
|
146
|
+
}
|
|
147
|
+
return findings;
|
|
148
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type UnconfirmedAnswerName } from '../../guards/answer-confirmation.js';
|
|
2
|
+
import { type Diagnostic } from '../contract.js';
|
|
3
|
+
import type { EvaluatedObservation } from '../observations.js';
|
|
4
|
+
export interface ConfirmCommandInput {
|
|
5
|
+
readonly configPath: string;
|
|
6
|
+
readonly answer: UnconfirmedAnswerName;
|
|
7
|
+
}
|
|
8
|
+
type Observed = Readonly<{
|
|
9
|
+
status: number;
|
|
10
|
+
diagnostics: readonly Diagnostic[];
|
|
11
|
+
}>;
|
|
12
|
+
export interface ConfirmCommandAdapters {
|
|
13
|
+
/** The preflight command, run against the configuration on disk. */
|
|
14
|
+
readonly preflight: (input: Readonly<{
|
|
15
|
+
configPath: string;
|
|
16
|
+
}>) => Promise<Observed & Readonly<{
|
|
17
|
+
result: Readonly<{
|
|
18
|
+
checks: readonly EvaluatedObservation[];
|
|
19
|
+
}>;
|
|
20
|
+
}>>;
|
|
21
|
+
/** The check command, run against the configuration on disk. */
|
|
22
|
+
readonly check: (input: Readonly<{
|
|
23
|
+
configPath: string;
|
|
24
|
+
}>) => Promise<Observed>;
|
|
25
|
+
readonly now: () => Date;
|
|
26
|
+
}
|
|
27
|
+
export interface ConfirmCommandOutcome {
|
|
28
|
+
readonly status: 0 | 2;
|
|
29
|
+
readonly artifact: string | null;
|
|
30
|
+
readonly destination: null;
|
|
31
|
+
readonly result: unknown;
|
|
32
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
33
|
+
}
|
|
34
|
+
export declare function runConfirm(input: ConfirmCommandInput, adapters: ConfirmCommandAdapters): Promise<ConfirmCommandOutcome>;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Sequences `cockpit confirm <answer>`: refuse an answer only a person may confirm, run the existing
|
|
2
|
+
// observation that verifies the rest against this machine and the running product, record what ran,
|
|
3
|
+
// then drop the answer from `unconfirmed` in cockpit.toml. The record goes first, so a failed edit
|
|
4
|
+
// leaves a record nothing uses, never a removed entry the guard cannot account for.
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { canonicalJson } from '../../canonical-json.js';
|
|
7
|
+
import { loadCockpitConfig, readBoundedInput } from '../../config/load.js';
|
|
8
|
+
import { removeUnconfirmedAnswer } from '../../config/unconfirmed-toml.js';
|
|
9
|
+
import { writeVerifiedConfig } from '../../config/verified-write.js';
|
|
10
|
+
import { cockpitCommand } from '../../fix-catalogue.js';
|
|
11
|
+
import { ANSWER_CONFIRMATION_PATH, ANSWER_CONFIRMATIONS, confirmationConfigurationSha256, encodeAnswerConfirmations, parseAnswerConfirmations, personConfirmationFix, } from '../../guards/answer-confirmation.js';
|
|
12
|
+
import { writeCockpitLocalFiles } from '../../guards/local-identity.js';
|
|
13
|
+
import { refusalOutcome } from '../../refusal.js';
|
|
14
|
+
import { sha256Hex } from '../../sha256.js';
|
|
15
|
+
import { ENVIRONMENT_CODES, hasErrorCode } from '../contract.js';
|
|
16
|
+
import { declaredRuntime } from '../declaration.js';
|
|
17
|
+
// The rows of preflight that say the runtime measured on this machine meets the declared floor.
|
|
18
|
+
const RUNTIME_FLOOR_CODES = [ENVIRONMENT_CODES.MACHINE_MEMORY_BELOW_FLOOR, ENVIRONMENT_CODES.MACHINE_CPU_BELOW_FLOOR,
|
|
19
|
+
ENVIRONMENT_CODES.MACHINE_DISK_BELOW_FLOOR, ENVIRONMENT_CODES.RUNTIME_UNMEASURED];
|
|
20
|
+
// Preflight passes only with the stack up; this row says the port family it holds is leased on the
|
|
21
|
+
// declared bases with current listener evidence.
|
|
22
|
+
const PORT_FAMILY_CODES = [ENVIRONMENT_CODES.PORT_FAMILY_COLLISION];
|
|
23
|
+
const failing = (diagnostics) => diagnostics.filter(row => row.severity === 'error');
|
|
24
|
+
async function observe(observation, configPath, config, adapters) {
|
|
25
|
+
// A machine floor alone is still measured; the stack and its Compose files need a declared runtime.
|
|
26
|
+
if (observation !== 'runtime-floor' && declaredRuntime(config.environment) !== 'compose') {
|
|
27
|
+
return { passed: false, reason: 'cockpit.toml declares no Compose files, so there is no stack or topology to observe', diagnostics: [] };
|
|
28
|
+
}
|
|
29
|
+
if (observation === 'check') {
|
|
30
|
+
const checked = await adapters.check({ configPath });
|
|
31
|
+
return checked.status === 0 ? { passed: true, evidence: { status: checked.status, diagnostics: checked.diagnostics } }
|
|
32
|
+
: { passed: false, reason: 'check did not pass', diagnostics: failing(checked.diagnostics) };
|
|
33
|
+
}
|
|
34
|
+
const preflight = await adapters.preflight({ configPath });
|
|
35
|
+
const codes = observation === 'preflight' ? PORT_FAMILY_CODES : RUNTIME_FLOOR_CODES;
|
|
36
|
+
const rows = preflight.result.checks.filter(check => codes.includes(check.code));
|
|
37
|
+
const unmet = codes.filter(code => !rows.some(row => row.code === code && row.verdict === 'pass'));
|
|
38
|
+
const evidence = { checks: rows.map(({ code, verdict, source, measured, observedAt }) => ({ code, verdict, source, measured, observedAt })) };
|
|
39
|
+
if (observation === 'preflight' && preflight.status !== 0) {
|
|
40
|
+
return { passed: false, reason: 'preflight did not pass, so the stack is not up and healthy on the declared ports', diagnostics: failing(preflight.diagnostics) };
|
|
41
|
+
}
|
|
42
|
+
return unmet.length === 0 ? { passed: true, evidence }
|
|
43
|
+
: { passed: false, reason: `preflight did not observe ${unmet.join(', ')} passing`, diagnostics: failing(preflight.diagnostics) };
|
|
44
|
+
}
|
|
45
|
+
async function readConfirmations(root) {
|
|
46
|
+
try {
|
|
47
|
+
const read = await readBoundedInput(join(root, ANSWER_CONFIRMATION_PATH));
|
|
48
|
+
return read.kind === 'complete' ? read.bytes : undefined;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (hasErrorCode(error, 'ENOENT'))
|
|
52
|
+
return undefined;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function runConfirm(input, adapters) {
|
|
57
|
+
const { answer } = input;
|
|
58
|
+
const confirmation = ANSWER_CONFIRMATIONS[answer];
|
|
59
|
+
if (confirmation.by === 'person') {
|
|
60
|
+
return refusalOutcome('CONFIRM_PERSON_REQUIRED', `${answer} is confirmed only by a person: ${confirmation.trustDecision ? `it is a trust decision, because ${confirmation.why}` : confirmation.why}`, answer, personConfirmationFix([answer], input.configPath));
|
|
61
|
+
}
|
|
62
|
+
const loaded = await loadCockpitConfig(input.configPath);
|
|
63
|
+
if (loaded.kind !== 'complete')
|
|
64
|
+
return { status: 2, artifact: null, destination: null, result: null, diagnostics: loaded.diagnostics };
|
|
65
|
+
const configPath = loaded.result.source.path;
|
|
66
|
+
const config = loaded.result.config;
|
|
67
|
+
if (!config.unconfirmed?.includes(answer)) {
|
|
68
|
+
return refusalOutcome('CONFIRM_ANSWER_NOT_LISTED', `${answer} is not listed under unconfirmed in ${configPath}, so there is nothing to confirm`, 'unconfirmed', `run ${cockpitCommand('doctor', { config: configPath })}; DECLARATION_UNCONFIRMED names the answers still to confirm`);
|
|
69
|
+
}
|
|
70
|
+
const ranAt = adapters.now().toISOString();
|
|
71
|
+
const verdict = await observe(confirmation.observation, configPath, config, adapters);
|
|
72
|
+
if (!verdict.passed) {
|
|
73
|
+
const refused = refusalOutcome('CONFIRM_OBSERVATION_FAILED', `${answer} stays unconfirmed: ${verdict.reason}; confirming it needs ${confirmation.verifies}`, answer, `clear the diagnostics reported beside this one, then run ${cockpitCommand('confirm', { config: configPath, operand: answer })} again; if the value itself is wrong, correct it in cockpit.toml first`);
|
|
74
|
+
return { ...refused, diagnostics: [...refused.diagnostics, ...verdict.diagnostics] };
|
|
75
|
+
}
|
|
76
|
+
// The observation re-reads cockpit.toml itself, so the record binds to what it observed only if the
|
|
77
|
+
// file is still the one loaded above. An agent edit made meanwhile cannot be reverted before this
|
|
78
|
+
// read: the guard holds every call but sync and check once the file differs from the synced one.
|
|
79
|
+
const after = await loadCockpitConfig(configPath);
|
|
80
|
+
if (after.kind !== 'complete' || after.result.source.sha256 !== loaded.result.source.sha256) {
|
|
81
|
+
return refusalOutcome(ENVIRONMENT_CODES.CONFIG_CHANGED_DURING_EXECUTION, 'cockpit.toml changed while the observation ran, so it cannot show which configuration passed; nothing was recorded or removed', configPath, `run ${cockpitCommand('confirm', { config: configPath, operand: answer })} again`);
|
|
82
|
+
}
|
|
83
|
+
const root = dirname(configPath);
|
|
84
|
+
const record = { observation: confirmation.observation, ranAt, result: 'passed',
|
|
85
|
+
evidenceSha256: sha256Hex(canonicalJson(verdict.evidence)), configurationSha256: confirmationConfigurationSha256(config) };
|
|
86
|
+
const written = await writeCockpitLocalFiles(root, [{ name: 'answer-confirmations',
|
|
87
|
+
bytes: encodeAnswerConfirmations({ ...parseAnswerConfirmations(await readConfirmations(root)), [answer]: record }) }]);
|
|
88
|
+
if (written.kind !== 'complete') {
|
|
89
|
+
return refusalOutcome('CONFIRM_RECORD_UNWRITABLE', `the observation passed, but its record could not be written: ${written.message}; cockpit.toml was not changed`, ANSWER_CONFIRMATION_PATH, `make ${ANSWER_CONFIRMATION_PATH} writable for this user, then run ${cockpitCommand('confirm', { config: configPath, operand: answer })} again`);
|
|
90
|
+
}
|
|
91
|
+
const remaining = config.unconfirmed.filter(row => row !== answer);
|
|
92
|
+
const expected = canonicalJson({ ...config, unconfirmed: remaining.length ? remaining : undefined });
|
|
93
|
+
const exact = (candidate) => canonicalJson(candidate) === expected;
|
|
94
|
+
const rerun = `run ${cockpitCommand('confirm', { config: configPath, operand: answer })} again`;
|
|
95
|
+
const byHand = personConfirmationFix([answer], configPath);
|
|
96
|
+
const edited = await writeVerifiedConfig({
|
|
97
|
+
path: configPath, expectedSha256: loaded.result.source.sha256, temporaryTag: 'confirm',
|
|
98
|
+
edit: text => {
|
|
99
|
+
const edit = removeUnconfirmedAnswer(text, answer);
|
|
100
|
+
return edit.kind === 'edited' ? edit.text : text;
|
|
101
|
+
},
|
|
102
|
+
isExactChange: exact,
|
|
103
|
+
messages: {
|
|
104
|
+
changedBefore: { message: 'cockpit.toml changed while the observation ran; nothing was removed', fix: rerun },
|
|
105
|
+
changedDuring: { message: 'cockpit.toml changed while the edit was being prepared; nothing was removed', fix: rerun },
|
|
106
|
+
invalid: reason => ({ message: `removing ${answer} would leave cockpit.toml invalid (${reason}); nothing was removed`, fix: byHand }),
|
|
107
|
+
notExact: { message: `cockpit.toml does not list ${answer} on one unconfirmed = [...] line this command can edit; nothing was removed`, fix: byHand },
|
|
108
|
+
writtenMismatch: { message: `${answer} was removed, but re-reading cockpit.toml shows a change beyond it`,
|
|
109
|
+
fix: `review cockpit.toml for the change you did not make before running ${cockpitCommand('sync', { config: configPath, args: '--dry-run' })}` },
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
if (edited.kind !== 'complete')
|
|
113
|
+
return refusalOutcome(edited.code, edited.message, configPath, edited.fix);
|
|
114
|
+
const sync = cockpitCommand('sync', { config: configPath });
|
|
115
|
+
return {
|
|
116
|
+
status: 0, destination: null, diagnostics: [],
|
|
117
|
+
artifact: `Confirmed ${answer}: ${confirmation.verifies}. Removed it from unconfirmed in ${configPath} and recorded the observation in ${ANSWER_CONFIRMATION_PATH}.\n`
|
|
118
|
+
+ `Run ${sync} to apply the edit; an agent may where [autonomy] sync = "agent", because the recorded observation makes the removal non-widening.\n`,
|
|
119
|
+
result: { answer, observation: confirmation.observation, ranAt, outcome: 'passed', evidenceSha256: record.evidenceSha256,
|
|
120
|
+
record: ANSWER_CONFIRMATION_PATH, unconfirmed: remaining, next: sync },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
import { cleanupFencedExecution, repairFencedExecution } from '../../environment-exec/execute.js';
|
|
5
5
|
import { ENVIRONMENT_CODES, forwardedRefusal } from '../contract.js';
|
|
6
6
|
import { readState } from '../state.js';
|
|
@@ -13,6 +13,7 @@ import { selectDestructiveInstance } from '../instance.js';
|
|
|
13
13
|
import { issuedDestructiveRuns, lifecycleContextRefusal, provisionalClaims, withLifecycleRunNotices, } from './lifecycle-context.js';
|
|
14
14
|
import { exactCommandConfigPath, invalidateCompletedLifecycle, lifecycleSuggestion, publishDrainOutcome, publishDrainOutcomeRecord, } from './lifecycle-state.js';
|
|
15
15
|
import { lifecycleRefusal, uniqueDiagnostics } from './shared.js';
|
|
16
|
+
import { forgetStackInputs } from '../stack-inputs.js';
|
|
16
17
|
function destructiveRepairRequired(context) {
|
|
17
18
|
return lifecycleRefusal(ENVIRONMENT_CODES.DRAIN_ATTEMPT_REPAIR_REQUIRED, 'cockpit cannot prove the interrupted destructive executor stopped, so it will not overlap it with another destructive action; preserve the coordination state for explicit repair', context.stackId);
|
|
18
19
|
}
|
|
@@ -264,7 +265,10 @@ export async function runDown(input, adapters) {
|
|
|
264
265
|
const loaded = await loadDestructiveContext(input, adapters);
|
|
265
266
|
if (loaded.kind !== 'complete')
|
|
266
267
|
return loaded;
|
|
267
|
-
|
|
268
|
+
const outcome = await settleProvisionalClaim(loaded.result, await downWithContext(loaded.result, adapters));
|
|
269
|
+
// A removed stack runs nothing a later doctor could call stale.
|
|
270
|
+
const forgotten = outcome.kind === 'complete' && outcome.result.change === 'removed' ? await forgetStackInputs(dirname(loaded.result.source.path)) : [];
|
|
271
|
+
return withLifecycleRunNotices(loaded.result, forgotten.length === 0 ? outcome : { ...outcome, diagnostics: uniqueDiagnostics([...outcome.diagnostics, ...forgotten]) });
|
|
268
272
|
}
|
|
269
273
|
async function downWithContext(context, adapters) {
|
|
270
274
|
const inspection = await inspectDestructiveRecoveryWithRepair(context, 'down');
|
|
@@ -2,6 +2,7 @@ import { type EnvironmentDiagnostic } from '../contract.js';
|
|
|
2
2
|
import type { EvaluatorTool } from '../../schema.js';
|
|
3
3
|
import type { SkippedStateNamespace } from '../state.js';
|
|
4
4
|
import { type GeneratedEnvironmentCurrentness } from '../generated-application/shared.js';
|
|
5
|
+
import type { McpServer } from '../../surfaces/mcp.js';
|
|
5
6
|
import { type ReadinessReport } from '../../readiness.js';
|
|
6
7
|
import type { ReleaseFreshness } from '../../freshness.js';
|
|
7
8
|
import { proposeGeneratedEnvironmentRepair } from '../../repair.js';
|
|
@@ -11,17 +12,17 @@ import { type ComparisonRemoteEvaluation, type ObservationCommandAdapters } from
|
|
|
11
12
|
export interface DoctorCommandInput {
|
|
12
13
|
readonly configPath: string;
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
interface DoctorSurfaceOverrideInspectionComplete {
|
|
15
16
|
readonly kind: 'complete';
|
|
16
17
|
readonly activeOverrides: readonly ActiveSurfaceOverride[];
|
|
17
18
|
readonly changedOverrideOriginals: readonly SurfaceUpgradeFinding[];
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
interface DoctorSurfaceOverrideInspectionUnavailable {
|
|
20
21
|
readonly kind: 'unavailable';
|
|
21
22
|
readonly code: string;
|
|
22
23
|
readonly reason: string;
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
interface DoctorSurfaceOverrideInspectionRefusal {
|
|
25
26
|
readonly kind: 'refusal';
|
|
26
27
|
readonly code: string;
|
|
27
28
|
readonly reason: string;
|
|
@@ -34,9 +35,13 @@ export interface DoctorCommandAdapters extends ObservationCommandAdapters {
|
|
|
34
35
|
readonly inspectHookTrust?: () => Promise<readonly EnvironmentDiagnostic[]>;
|
|
35
36
|
/** A warning when work orders exist but their independent evaluator cannot run on this machine. */
|
|
36
37
|
readonly inspectEvaluator?: (evaluators: readonly EvaluatorTool[]) => Promise<readonly EnvironmentDiagnostic[]>;
|
|
38
|
+
/** Warnings about what each approved MCP server needs before a tool can start it; never starts one. */
|
|
39
|
+
readonly inspectMcpStartup?: (servers: readonly McpServer[]) => Promise<readonly EnvironmentDiagnostic[]>;
|
|
37
40
|
/** Warnings about prerequisites the stack checks do not cover: the local install, sibling remotes, committed secrets. */
|
|
38
41
|
readonly inspectReadiness?: () => Promise<readonly EnvironmentDiagnostic[]>;
|
|
39
42
|
/** Other cockpits' state namespaces this version skipped, or why the inventory could not be read; read after load has selected this cockpit's state root. */
|
|
43
|
+
/** Warnings about stacks on this machine that no checkout can reach, read after load has selected this cockpit's state root. */
|
|
44
|
+
readonly inspectStacks?: () => Promise<readonly EnvironmentDiagnostic[]>;
|
|
40
45
|
readonly readUnreadableState?: () => Promise<Readonly<{
|
|
41
46
|
kind: 'complete';
|
|
42
47
|
skipped: readonly SkippedStateNamespace[];
|
|
@@ -57,13 +62,13 @@ interface DoctorFreshness {
|
|
|
57
62
|
readonly release: ReleaseFreshness;
|
|
58
63
|
readonly repair?: ReturnType<typeof proposeGeneratedEnvironmentRepair>;
|
|
59
64
|
}
|
|
60
|
-
|
|
61
|
-
readonly subject: 'runtime' | 'reset-volumes' | 'seed';
|
|
65
|
+
interface DoctorDeclaration {
|
|
66
|
+
readonly subject: 'runtime' | 'reset-volumes' | 'seed' | 'docs';
|
|
62
67
|
readonly state: 'declared' | 'not-declared';
|
|
63
68
|
readonly source: string;
|
|
64
69
|
readonly reason: string;
|
|
65
70
|
}
|
|
66
|
-
|
|
71
|
+
interface ApplicationDoctorEvaluation {
|
|
67
72
|
readonly verdict: PreflightEvaluation['verdict'];
|
|
68
73
|
readonly checks: readonly DoctorCheck[];
|
|
69
74
|
readonly comparisons: readonly ComparisonRemoteEvaluation[];
|
|
@@ -74,7 +79,7 @@ export interface ApplicationDoctorEvaluation {
|
|
|
74
79
|
/** The reported diagnostics grouped under the prerequisites cockpit depends on; absent when cockpit.toml does not load. */
|
|
75
80
|
readonly readiness?: ReadinessReport;
|
|
76
81
|
}
|
|
77
|
-
|
|
82
|
+
type DoctorCommandResult = Readonly<{
|
|
78
83
|
kind: 'complete';
|
|
79
84
|
status: 0;
|
|
80
85
|
diagnostics: readonly EnvironmentDiagnostic[];
|
|
@@ -11,6 +11,8 @@ import { loadCockpitConfig, evaluatorTools } from '../../config/load.js';
|
|
|
11
11
|
import { projectDoctor } from '../observations.js';
|
|
12
12
|
import { applicationDiagnostic, evaluateApplicationHealth, healthDiagnostics, issueHealthObservationSource, prepareHealthObservation, } from './health.js';
|
|
13
13
|
import { ownDataValues } from './shared.js';
|
|
14
|
+
import { cockpitCommand } from '../../fix-catalogue.js';
|
|
15
|
+
import { unconfirmedFix } from '../../guards/answer-confirmation.js';
|
|
14
16
|
import { unreadableStateWarning } from './lifecycle-state.js';
|
|
15
17
|
// What reset and up do to product data rests on these declarations; doctor names an absent one
|
|
16
18
|
// instead of letting it read as a passed check.
|
|
@@ -27,6 +29,9 @@ async function doctorDeclarations(loading) {
|
|
|
27
29
|
: 'environment.compose_files is not declared; runtime commands refuse'),
|
|
28
30
|
row('reset-volumes', environment.resettable_volumes.length > 0, 'environment.resettable_volumes', 'reset may delete and recreate these volumes'),
|
|
29
31
|
row('seed', environment.seed !== undefined, 'environment.seed', 'up runs the seed when a resettable volume is new'),
|
|
32
|
+
// Docs rot is not environment health, so doctor names the check rather than running it.
|
|
33
|
+
...loaded.result.config.docs === undefined ? [] : [{ subject: 'docs', state: 'declared', source: 'docs',
|
|
34
|
+
reason: `docs check reads the declared living documents and decision records; doctor does not run it: ${cockpitCommand('docs check', { config: loaded.result.source.path })}` }],
|
|
30
35
|
];
|
|
31
36
|
}
|
|
32
37
|
// The guard denies agent commands that reach these hosts. Two empty lists deny nothing, and that
|
|
@@ -72,7 +77,8 @@ async function doctorTestingGateDiagnostics(loading) {
|
|
|
72
77
|
}];
|
|
73
78
|
}
|
|
74
79
|
// init records answers no person stated under `unconfirmed`. They look exactly like stated values
|
|
75
|
-
// in the file, so doctor keeps naming them until
|
|
80
|
+
// in the file, so doctor keeps naming them until each is confirmed: by the observation that verifies
|
|
81
|
+
// it, or by a person where it is a trust decision or no observation settles it.
|
|
76
82
|
async function doctorUnconfirmedDiagnostics(loading) {
|
|
77
83
|
const loaded = await loading;
|
|
78
84
|
if (loaded.kind !== 'complete' || !loaded.result.config.unconfirmed)
|
|
@@ -83,7 +89,7 @@ async function doctorUnconfirmedDiagnostics(loading) {
|
|
|
83
89
|
code: 'DECLARATION_UNCONFIRMED',
|
|
84
90
|
message: `${loaded.result.config.unconfirmed.join(', ')} in cockpit.toml were chosen by the setup agent or by no one named, not stated by a person, so they may not describe this product`,
|
|
85
91
|
target: 'unconfirmed',
|
|
86
|
-
fix:
|
|
92
|
+
fix: unconfirmedFix(loaded.result.config.unconfirmed),
|
|
87
93
|
}];
|
|
88
94
|
}
|
|
89
95
|
// The same weakening `check` fails on: a policy cockpit.toml sets below the one the last sync recorded
|
|
@@ -165,16 +171,22 @@ export async function runDoctor(input, adapters) {
|
|
|
165
171
|
return loaded.kind === 'complete' && loaded.result.config.surfaces.tools.includes('codexcli')
|
|
166
172
|
? adapters.inspectHookTrust?.() ?? [] : [];
|
|
167
173
|
};
|
|
168
|
-
const
|
|
174
|
+
const currentness = prepareGeneratedEnvironmentCurrentness(input);
|
|
175
|
+
const mcpStartup = async () => adapters.inspectMcpStartup?.((await currentness).mcpServers) ?? [];
|
|
176
|
+
// The machine-wide state reads are independent of the configuration checks, so they run beside them.
|
|
177
|
+
const statePending = adapters.readUnreadableState?.();
|
|
178
|
+
const stacksPending = adapters.inspectStacks?.();
|
|
179
|
+
const [outcome, guardHosts, unconfirmed, policyWeakened, testingGate, hookTrust, evaluator, readinessWarnings, servedSiblings, mcp] = await Promise.all([doctorHealth(input, adapters, config, currentness),
|
|
169
180
|
doctorGuardHostDiagnostics(config), doctorUnconfirmedDiagnostics(config), doctorPolicyDiagnostics(config), doctorTestingGateDiagnostics(config), hookTrustFindings(),
|
|
170
|
-
doctorEvaluatorDiagnostics(config, adapters), adapters.inspectReadiness?.() ?? []]);
|
|
171
|
-
const stateInventory = await
|
|
181
|
+
doctorEvaluatorDiagnostics(config, adapters), adapters.inspectReadiness?.() ?? [], config.then(async (loaded) => adapters.inspectServedSiblings?.(loaded.kind === 'complete' ? loaded : undefined) ?? []), mcpStartup()]);
|
|
182
|
+
const stateInventory = await statePending;
|
|
172
183
|
const unreadableState = stateInventory?.kind === 'refusal'
|
|
173
184
|
// An inventory that cannot be read, such as one another command holds locked, is no clean one.
|
|
174
185
|
? stateInventory.diagnostics.map((diagnostic) => ({ ...diagnostic, severity: 'warning',
|
|
175
186
|
message: `doctor could not read this machine's cockpit state, so it did not check for other cockpits' state this version cannot read: ${diagnostic.message}` }))
|
|
176
187
|
: [unreadableStateWarning(stateInventory?.skipped ?? [])].filter((warning) => warning !== undefined);
|
|
177
|
-
const
|
|
188
|
+
const orphanedStacks = await stacksPending ?? [];
|
|
189
|
+
const warnings = [...unconfirmed, ...policyWeakened, ...guardHosts, ...testingGate, ...hookTrust, ...evaluator, ...readinessWarnings, ...servedSiblings, ...mcp, ...unreadableState, ...orphanedStacks];
|
|
178
190
|
const reported = warnings.length === 0 ? outcome
|
|
179
191
|
: { ...outcome, diagnostics: [...outcome.diagnostics, ...warnings] };
|
|
180
192
|
// Doctor reports on a configuration; without one there is nothing to report on, and a zero
|
|
@@ -191,11 +203,11 @@ export async function runDoctor(input, adapters) {
|
|
|
191
203
|
diagnostics: [...loaded.diagnostics.filter(({ code, target }) => !forwarded.has(`${code}\0${target}`)), ...reported.diagnostics],
|
|
192
204
|
};
|
|
193
205
|
}
|
|
194
|
-
async function doctorHealth(input, adapters, config) {
|
|
206
|
+
async function doctorHealth(input, adapters, config, currentness) {
|
|
195
207
|
const surfaceOverrides = inspectDoctorSurfaceOverrides(input.configPath, adapters);
|
|
196
208
|
const declarations = doctorDeclarations(config);
|
|
197
209
|
const freshness = Promise.all([
|
|
198
|
-
|
|
210
|
+
currentness,
|
|
199
211
|
adapters.discoverReleaseFreshness?.() ?? Promise.resolve({ kind: 'unknown', reason: 'release discovery is not bound' }),
|
|
200
212
|
]).then(([currentness, release]) => ({
|
|
201
213
|
local: { kind: currentness.outcome.kind, status: currentness.outcome.status, diagnostics: currentness.outcome.diagnostics },
|