@mjasnikovs/pi-task 0.38.15 → 0.38.17
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/dist/config/config.d.ts +26 -0
- package/dist/config/config.js +68 -17
- package/dist/shared/child-process.js +9 -16
- package/dist/task/accept-debt.d.ts +7 -5
- package/dist/task/accept-debt.js +18 -14
- package/dist/task/artifact-closure.js +18 -63
- package/dist/task/auto-orchestrator.js +211 -218
- package/dist/task/autofix-ledger.d.ts +113 -0
- package/dist/task/autofix-ledger.js +152 -0
- package/dist/task/boot-probe.d.ts +109 -1
- package/dist/task/boot-probe.js +139 -23
- package/dist/task/child-runner.d.ts +50 -6
- package/dist/task/child-runner.js +48 -69
- package/dist/task/command-run.d.ts +49 -6
- package/dist/task/command-run.js +154 -18
- package/dist/task/coverage-loop.d.ts +11 -0
- package/dist/task/coverage-loop.js +16 -0
- package/dist/task/external-context.d.ts +9 -12
- package/dist/task/external-context.js +5 -5
- package/dist/task/failure-classifier.d.ts +9 -1
- package/dist/task/failure-classifier.js +9 -0
- package/dist/task/final-gate-fix.d.ts +22 -26
- package/dist/task/final-gate-fix.js +16 -31
- package/dist/task/final-gate.d.ts +10 -2
- package/dist/task/final-gate.js +55 -89
- package/dist/task/fix-child.d.ts +64 -0
- package/dist/task/fix-child.js +66 -0
- package/dist/task/gate-deps.js +20 -13
- package/dist/task/lint-fix.d.ts +7 -0
- package/dist/task/lint-fix.js +45 -9
- package/dist/task/orchestrator.d.ts +33 -24
- package/dist/task/orchestrator.js +75 -46
- package/dist/task/phases.d.ts +120 -34
- package/dist/task/phases.js +221 -134
- package/dist/task/plan-orchestrator.js +2 -2
- package/dist/task/plan-rounds.d.ts +86 -0
- package/dist/task/plan-rounds.js +105 -0
- package/dist/task/plan-session.d.ts +31 -21
- package/dist/task/plan-session.js +97 -120
- package/dist/task/qa-transcript.d.ts +100 -0
- package/dist/task/qa-transcript.js +99 -0
- package/dist/task/question-source.d.ts +117 -0
- package/dist/task/question-source.js +174 -0
- package/dist/task/repo-health-check.d.ts +21 -21
- package/dist/task/repo-health-check.js +43 -112
- package/dist/task/run-end.d.ts +77 -0
- package/dist/task/run-end.js +37 -0
- package/dist/task/run-final-gate.js +71 -79
- package/dist/task/serve-entry.js +6 -57
- package/dist/task/shipped-source.d.ts +67 -0
- package/dist/task/shipped-source.js +144 -0
- package/dist/task/task-gates.d.ts +9 -1
- package/dist/task/task-gates.js +27 -6
- package/dist/task/terminal-outcome.d.ts +1 -1
- package/dist/task/terminal-outcome.js +12 -0
- package/dist/task/verify-work.d.ts +46 -0
- package/dist/task/verify-work.js +51 -3
- package/dist/workers/brave-search.d.ts +7 -0
- package/dist/workers/brave-search.js +36 -55
- package/dist/workers/ddg-search.d.ts +1 -1
- package/dist/workers/ddg-search.js +27 -47
- package/dist/workers/docs-core.d.ts +71 -1
- package/dist/workers/docs-core.js +131 -71
- package/dist/workers/exa-search.d.ts +2 -2
- package/dist/workers/exa-search.js +53 -68
- package/dist/workers/html-clean.js +67 -88
- package/dist/workers/http-request.d.ts +74 -0
- package/dist/workers/http-request.js +103 -0
- package/dist/workers/npm-version.js +37 -42
- package/dist/workers/pi-worker-core.d.ts +13 -2
- package/dist/workers/pi-worker-core.js +35 -25
- package/dist/workers/pi-worker-docs.d.ts +1 -1
- package/dist/workers/pi-worker-docs.js +49 -68
- package/dist/workers/pi-worker-fetch.d.ts +1 -1
- package/dist/workers/pi-worker-fetch.js +20 -21
- package/dist/workers/pi-worker-search.js +6 -4
- package/dist/workers/pi-worker.js +5 -4
- package/dist/workers/search-core.d.ts +1 -1
- package/dist/workers/search-core.js +36 -42
- package/dist/workers/search-types.d.ts +13 -0
- package/dist/workers/search-types.js +27 -0
- package/dist/workers/shared.d.ts +51 -11
- package/dist/workers/shared.js +0 -0
- package/dist/workers/worker-channels.d.ts +60 -0
- package/dist/workers/worker-channels.js +98 -0
- package/package.json +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autofix-ledger — what the run-end RESOLUTION LOOP records, and the decisions
|
|
3
|
+
* that record makes.
|
|
4
|
+
*
|
|
5
|
+
* The same deepening `GateTally` already proved one altitude down. That replaced
|
|
6
|
+
* twelve mutable locals threaded through ~400 lines of `runFinalIntegrationGate`
|
|
7
|
+
* by closure; this replaces six threaded through ~235 lines of
|
|
8
|
+
* `runFinalGateStage` — the attempt count, the accumulated gitignored writes, the
|
|
9
|
+
* stranded sub-fixes, the previous failure signature, the demoted set, and the
|
|
10
|
+
* rejected-edits flag.
|
|
11
|
+
*
|
|
12
|
+
* The reason is not tidiness. `final-gate-progress.ts` holds five pure functions,
|
|
13
|
+
* each called from exactly ONE site inside that loop — extracted for testability
|
|
14
|
+
* while the decisions about ORDERING and CARRY-FORWARD stayed in the caller. That
|
|
15
|
+
* is precisely the shape `isNonProgress`'s own comment indicts:
|
|
16
|
+
*
|
|
17
|
+
* > The fix is NOT a better string pattern — the bug is that the decision was
|
|
18
|
+
* > made downstream from the evidence.
|
|
19
|
+
*
|
|
20
|
+
* mx5 run 21 shipped a product whose every page was blank as a `completed` run
|
|
21
|
+
* through that gap. Here the evidence and the decision sit in one module, and the
|
|
22
|
+
* loop body reads as picker → apply → record.
|
|
23
|
+
*
|
|
24
|
+
* What is NOT here: anything that talks to the user, writes a ledger file, or
|
|
25
|
+
* commits. The loop keeps those, the same way `GateTally` keeps no I/O — a record
|
|
26
|
+
* that performs effects cannot be driven by a test that only wants the verdict.
|
|
27
|
+
*/
|
|
28
|
+
/** The gate-outcome fields this record reads. Structural, so a caller can hand it
|
|
29
|
+
* a `FinalGateOutcome` without this module importing the gate. */
|
|
30
|
+
export interface AutofixOutcomeView {
|
|
31
|
+
reason: string;
|
|
32
|
+
failures?: string[];
|
|
33
|
+
observedFailures?: string[];
|
|
34
|
+
}
|
|
35
|
+
/** What the loop should do about this attempt's ranked-first failure. */
|
|
36
|
+
export interface AttemptVerdict {
|
|
37
|
+
/** The ranked-first failure, trimmed; null when the outcome names none. */
|
|
38
|
+
detail: string | null;
|
|
39
|
+
/**
|
|
40
|
+
* True ⇒ this check is unfalsifiable here: the attempt edited the tree, the
|
|
41
|
+
* gate re-ran, and returned the same first failure as the previous one — and
|
|
42
|
+
* no probe OBSERVED it. Already recorded as demoted when true.
|
|
43
|
+
*/
|
|
44
|
+
demoted: boolean;
|
|
45
|
+
/** The debt reason to carry, present exactly when `demoted`. */
|
|
46
|
+
debtReason?: string;
|
|
47
|
+
}
|
|
48
|
+
export declare class AutofixLedger {
|
|
49
|
+
private readonly _budget;
|
|
50
|
+
/** Autofix attempts made in this loop, including the one in flight. */
|
|
51
|
+
private _attempts;
|
|
52
|
+
/**
|
|
53
|
+
* Gitignored paths the fix passes have written so far. ACCUMULATED across
|
|
54
|
+
* attempts: a `.env` written by a failed attempt is still on disk for the next
|
|
55
|
+
* one, and that attempt's own before/after diff cannot see it (mx5 run 19).
|
|
56
|
+
*/
|
|
57
|
+
private _ignoredWritten;
|
|
58
|
+
/** Sub-fixes a non-converging attempt left uncommitted. REPLACED each attempt. */
|
|
59
|
+
private _stranded;
|
|
60
|
+
/** The previous attempt's normalized ranked-first failure. */
|
|
61
|
+
private _prevFailSig;
|
|
62
|
+
/** Signatures already carried as debt; a re-run reporting them does not re-fail. */
|
|
63
|
+
private readonly _demoted;
|
|
64
|
+
/**
|
|
65
|
+
* A write-guard rejected an attempt whose edits could NOT be discarded, so
|
|
66
|
+
* REJECTED edits are sitting in the tree and the terminal paths must not commit
|
|
67
|
+
* what they find there (mx5 run 14 item 2b — the cheat guard is never weakened
|
|
68
|
+
* to ease committing).
|
|
69
|
+
*/
|
|
70
|
+
private _rejectedEditsInTree;
|
|
71
|
+
constructor(_budget: number);
|
|
72
|
+
/** One autofix attempt is starting. */
|
|
73
|
+
attempt(): number;
|
|
74
|
+
/** Are there attempts left? Drives whether the picker offers the card at all. */
|
|
75
|
+
canAutofix(): boolean;
|
|
76
|
+
attempts(): number;
|
|
77
|
+
/** Gitignored paths this attempt wrote. Accumulated, de-duplicated, order kept. */
|
|
78
|
+
wroteIgnored(paths: readonly string[] | undefined): void;
|
|
79
|
+
ignoredWrites(): readonly string[];
|
|
80
|
+
/** The uncommitted sub-fixes as of now. Replaces, never accumulates. */
|
|
81
|
+
setStranded(paths: readonly string[]): void;
|
|
82
|
+
stranded(): readonly string[];
|
|
83
|
+
/** A guard rejected an attempt and its edits are still in the tree. */
|
|
84
|
+
rejectedEditsRemain(): void;
|
|
85
|
+
/**
|
|
86
|
+
* May a terminal path commit what is in the working tree?
|
|
87
|
+
*
|
|
88
|
+
* False once a guard has rejected an attempt without discarding its edits.
|
|
89
|
+
* This is the ONE question the flag exists to answer, and it is asked at three
|
|
90
|
+
* terminal sites — as a method rather than a bare boolean read at each.
|
|
91
|
+
*/
|
|
92
|
+
mayCommitTree(): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Judge this attempt's gate outcome, and record what the judgement implies.
|
|
95
|
+
*
|
|
96
|
+
* `edited` is the caller's fact — did this attempt change the tree and survive
|
|
97
|
+
* the guards. The observed/non-progress rule and the signature carry-forward
|
|
98
|
+
* are this module's, so they cannot be applied in the wrong order or skipped:
|
|
99
|
+
* a demoted signature is entered into the set and the previous-signature chain
|
|
100
|
+
* is broken in the SAME call that decides to demote.
|
|
101
|
+
*/
|
|
102
|
+
judge(outcome: AutofixOutcomeView | undefined, edited: boolean): AttemptVerdict;
|
|
103
|
+
demotedCount(): number;
|
|
104
|
+
/**
|
|
105
|
+
* What still has to pass for the gate to converge: this outcome's failures with
|
|
106
|
+
* every already-demoted signature dropped. An EMPTY array means converged
|
|
107
|
+
* carrying the demotions as debt; `undefined` means the outcome named no list
|
|
108
|
+
* and there is nothing to reason about.
|
|
109
|
+
*/
|
|
110
|
+
remaining(outcome: AutofixOutcomeView | undefined): string[] | undefined;
|
|
111
|
+
/** Has anything been demoted? Only then can a run converge on the remainder. */
|
|
112
|
+
hasDemotions(): boolean;
|
|
113
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autofix-ledger — what the run-end RESOLUTION LOOP records, and the decisions
|
|
3
|
+
* that record makes.
|
|
4
|
+
*
|
|
5
|
+
* The same deepening `GateTally` already proved one altitude down. That replaced
|
|
6
|
+
* twelve mutable locals threaded through ~400 lines of `runFinalIntegrationGate`
|
|
7
|
+
* by closure; this replaces six threaded through ~235 lines of
|
|
8
|
+
* `runFinalGateStage` — the attempt count, the accumulated gitignored writes, the
|
|
9
|
+
* stranded sub-fixes, the previous failure signature, the demoted set, and the
|
|
10
|
+
* rejected-edits flag.
|
|
11
|
+
*
|
|
12
|
+
* The reason is not tidiness. `final-gate-progress.ts` holds five pure functions,
|
|
13
|
+
* each called from exactly ONE site inside that loop — extracted for testability
|
|
14
|
+
* while the decisions about ORDERING and CARRY-FORWARD stayed in the caller. That
|
|
15
|
+
* is precisely the shape `isNonProgress`'s own comment indicts:
|
|
16
|
+
*
|
|
17
|
+
* > The fix is NOT a better string pattern — the bug is that the decision was
|
|
18
|
+
* > made downstream from the evidence.
|
|
19
|
+
*
|
|
20
|
+
* mx5 run 21 shipped a product whose every page was blank as a `completed` run
|
|
21
|
+
* through that gap. Here the evidence and the decision sit in one module, and the
|
|
22
|
+
* loop body reads as picker → apply → record.
|
|
23
|
+
*
|
|
24
|
+
* What is NOT here: anything that talks to the user, writes a ledger file, or
|
|
25
|
+
* commits. The loop keeps those, the same way `GateTally` keeps no I/O — a record
|
|
26
|
+
* that performs effects cannot be driven by a test that only wants the verdict.
|
|
27
|
+
*/
|
|
28
|
+
import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
|
|
29
|
+
export class AutofixLedger {
|
|
30
|
+
_budget;
|
|
31
|
+
/** Autofix attempts made in this loop, including the one in flight. */
|
|
32
|
+
_attempts = 0;
|
|
33
|
+
/**
|
|
34
|
+
* Gitignored paths the fix passes have written so far. ACCUMULATED across
|
|
35
|
+
* attempts: a `.env` written by a failed attempt is still on disk for the next
|
|
36
|
+
* one, and that attempt's own before/after diff cannot see it (mx5 run 19).
|
|
37
|
+
*/
|
|
38
|
+
_ignoredWritten = [];
|
|
39
|
+
/** Sub-fixes a non-converging attempt left uncommitted. REPLACED each attempt. */
|
|
40
|
+
_stranded = [];
|
|
41
|
+
/** The previous attempt's normalized ranked-first failure. */
|
|
42
|
+
_prevFailSig = null;
|
|
43
|
+
/** Signatures already carried as debt; a re-run reporting them does not re-fail. */
|
|
44
|
+
_demoted = new Set();
|
|
45
|
+
/**
|
|
46
|
+
* A write-guard rejected an attempt whose edits could NOT be discarded, so
|
|
47
|
+
* REJECTED edits are sitting in the tree and the terminal paths must not commit
|
|
48
|
+
* what they find there (mx5 run 14 item 2b — the cheat guard is never weakened
|
|
49
|
+
* to ease committing).
|
|
50
|
+
*/
|
|
51
|
+
_rejectedEditsInTree = false;
|
|
52
|
+
constructor(_budget) {
|
|
53
|
+
this._budget = _budget;
|
|
54
|
+
}
|
|
55
|
+
// ─── Record ──────────────────────────────────────────────────────────────
|
|
56
|
+
/** One autofix attempt is starting. */
|
|
57
|
+
attempt() {
|
|
58
|
+
return ++this._attempts;
|
|
59
|
+
}
|
|
60
|
+
/** Are there attempts left? Drives whether the picker offers the card at all. */
|
|
61
|
+
canAutofix() {
|
|
62
|
+
return this._attempts < this._budget;
|
|
63
|
+
}
|
|
64
|
+
attempts() {
|
|
65
|
+
return this._attempts;
|
|
66
|
+
}
|
|
67
|
+
/** Gitignored paths this attempt wrote. Accumulated, de-duplicated, order kept. */
|
|
68
|
+
wroteIgnored(paths) {
|
|
69
|
+
if (!paths || paths.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
for (const p of paths)
|
|
72
|
+
if (!this._ignoredWritten.includes(p))
|
|
73
|
+
this._ignoredWritten.push(p);
|
|
74
|
+
}
|
|
75
|
+
ignoredWrites() {
|
|
76
|
+
return this._ignoredWritten;
|
|
77
|
+
}
|
|
78
|
+
/** The uncommitted sub-fixes as of now. Replaces, never accumulates. */
|
|
79
|
+
setStranded(paths) {
|
|
80
|
+
this._stranded = [...paths];
|
|
81
|
+
}
|
|
82
|
+
stranded() {
|
|
83
|
+
return this._stranded;
|
|
84
|
+
}
|
|
85
|
+
/** A guard rejected an attempt and its edits are still in the tree. */
|
|
86
|
+
rejectedEditsRemain() {
|
|
87
|
+
this._rejectedEditsInTree = true;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* May a terminal path commit what is in the working tree?
|
|
91
|
+
*
|
|
92
|
+
* False once a guard has rejected an attempt without discarding its edits.
|
|
93
|
+
* This is the ONE question the flag exists to answer, and it is asked at three
|
|
94
|
+
* terminal sites — as a method rather than a bare boolean read at each.
|
|
95
|
+
*/
|
|
96
|
+
mayCommitTree() {
|
|
97
|
+
return !this._rejectedEditsInTree;
|
|
98
|
+
}
|
|
99
|
+
// ─── Decide ──────────────────────────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* Judge this attempt's gate outcome, and record what the judgement implies.
|
|
102
|
+
*
|
|
103
|
+
* `edited` is the caller's fact — did this attempt change the tree and survive
|
|
104
|
+
* the guards. The observed/non-progress rule and the signature carry-forward
|
|
105
|
+
* are this module's, so they cannot be applied in the wrong order or skipped:
|
|
106
|
+
* a demoted signature is entered into the set and the previous-signature chain
|
|
107
|
+
* is broken in the SAME call that decides to demote.
|
|
108
|
+
*/
|
|
109
|
+
judge(outcome, edited) {
|
|
110
|
+
const detail = rankedFirstFailure({
|
|
111
|
+
...(outcome ? { reason: outcome.reason } : {}),
|
|
112
|
+
...(outcome?.failures ? { failures: outcome.failures } : {})
|
|
113
|
+
});
|
|
114
|
+
// Whether a PROBE OBSERVED this failure, read off the SAME outcome the
|
|
115
|
+
// failure came from — exact text identity, never a second string pattern.
|
|
116
|
+
const observed = outcome?.observedFailures?.includes(detail ?? '') === true;
|
|
117
|
+
if (detail !== null
|
|
118
|
+
&& isNonProgress({
|
|
119
|
+
previousSignature: this._prevFailSig,
|
|
120
|
+
currentDetail: detail,
|
|
121
|
+
edited,
|
|
122
|
+
observed
|
|
123
|
+
})) {
|
|
124
|
+
this._demoted.add(normalizeFailureDetail(detail));
|
|
125
|
+
// A demotion ends the chain: the next attempt has no previous signature
|
|
126
|
+
// to match, so one demotion cannot cascade into a second.
|
|
127
|
+
this._prevFailSig = null;
|
|
128
|
+
return { detail, demoted: true, debtReason: unobservedDebtReason(detail) };
|
|
129
|
+
}
|
|
130
|
+
this._prevFailSig = detail !== null ? normalizeFailureDetail(detail) : null;
|
|
131
|
+
return { detail, demoted: false };
|
|
132
|
+
}
|
|
133
|
+
demotedCount() {
|
|
134
|
+
return this._demoted.size;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* What still has to pass for the gate to converge: this outcome's failures with
|
|
138
|
+
* every already-demoted signature dropped. An EMPTY array means converged
|
|
139
|
+
* carrying the demotions as debt; `undefined` means the outcome named no list
|
|
140
|
+
* and there is nothing to reason about.
|
|
141
|
+
*/
|
|
142
|
+
remaining(outcome) {
|
|
143
|
+
const failures = outcome?.failures ?? (outcome ? [outcome.reason] : undefined);
|
|
144
|
+
if (failures === undefined)
|
|
145
|
+
return undefined;
|
|
146
|
+
return applyDemotions(failures, this._demoted);
|
|
147
|
+
}
|
|
148
|
+
/** Has anything been demoted? Only then can a run converge on the remainder. */
|
|
149
|
+
hasDemotions() {
|
|
150
|
+
return this._demoted.size > 0;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -135,6 +135,52 @@ export interface BootDeps {
|
|
|
135
135
|
preferredPort?: () => Promise<number | null>;
|
|
136
136
|
/** Does anything answer HTTP on 127.0.0.1:`port`? Injected for tests. */
|
|
137
137
|
httpProbe?: (port: number) => boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Spawn the boot child. THE SUBJECT of this check, and the one thing `BootDeps`
|
|
140
|
+
* did not seam.
|
|
141
|
+
*
|
|
142
|
+
* Nine fields above inject something the check LOOKS AT; `spawn` was imported
|
|
143
|
+
* directly, so the ~220-line state machine below — seven locals threaded by
|
|
144
|
+
* closure, four `BootOutcome` kinds, five exit arms — was reachable only through
|
|
145
|
+
* a real process on a real clock. Measured: 52 tests / 13.6s, with 300–5000ms
|
|
146
|
+
* grace windows scripted as real `process.execPath -e` children.
|
|
147
|
+
*
|
|
148
|
+
* `BootChild` is defined from what this function CALLS, not from Node's
|
|
149
|
+
* `ChildProcess` — the same way `driveSession(cdp: CdpLike, …)` was defined from
|
|
150
|
+
* the two `Cdp` methods it uses. A scripted fake is a dozen lines.
|
|
151
|
+
*/
|
|
152
|
+
spawnBoot?: (bin: string, args: string[], opts: BootSpawnOptions) => BootChild;
|
|
153
|
+
/**
|
|
154
|
+
* Tear down the child's whole process group. Injected with `spawnBoot`, because
|
|
155
|
+
* a fake child has no group to kill and a real `process.kill(-pid)` against a
|
|
156
|
+
* fake pid would signal something else entirely.
|
|
157
|
+
*/
|
|
158
|
+
killGroup?: (pid: number, signal: NodeJS.Signals) => void;
|
|
159
|
+
}
|
|
160
|
+
/** What `runBootCheck` passes to its spawn. */
|
|
161
|
+
export interface BootSpawnOptions {
|
|
162
|
+
cwd: string;
|
|
163
|
+
detached: true;
|
|
164
|
+
stdio: ['ignore', 'pipe', 'pipe'];
|
|
165
|
+
env: Record<string, string | undefined>;
|
|
166
|
+
}
|
|
167
|
+
/** A stream the boot check reads output from. */
|
|
168
|
+
export interface BootStream {
|
|
169
|
+
on: (event: 'data', cb: (chunk: Buffer | string) => void) => void;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The boot child, as the check actually uses it: a pid, two output streams, an
|
|
173
|
+
* `error` event and an `exit` event carrying (status, signal).
|
|
174
|
+
*/
|
|
175
|
+
export interface BootChild {
|
|
176
|
+
pid?: number | undefined;
|
|
177
|
+
unref?: () => void;
|
|
178
|
+
stdout?: BootStream | null;
|
|
179
|
+
stderr?: BootStream | null;
|
|
180
|
+
on: {
|
|
181
|
+
(event: 'error', cb: (err: Error) => void): void;
|
|
182
|
+
(event: 'exit', cb: (status: number | null, signal: NodeJS.Signals | null) => void): void;
|
|
183
|
+
};
|
|
138
184
|
}
|
|
139
185
|
/**
|
|
140
186
|
* Does the finished run stand up a listening HTTP server? Deterministic, from the
|
|
@@ -294,5 +340,67 @@ export declare function recoverOrphanPort(cwd: string, boot: HealthCommand, firs
|
|
|
294
340
|
outcome: 'orphan-port';
|
|
295
341
|
detail: string;
|
|
296
342
|
port: number | null;
|
|
297
|
-
},
|
|
343
|
+
},
|
|
344
|
+
/** One options object, not four trailing positionals — `bootGraceMs` and a
|
|
345
|
+
* boolean sat adjacent and swapped without a type error. */
|
|
346
|
+
opts: {
|
|
347
|
+
graceMs?: number;
|
|
348
|
+
deps: BootDeps;
|
|
349
|
+
expectServer: boolean;
|
|
350
|
+
}): Promise<BootOutcome>;
|
|
351
|
+
/**
|
|
352
|
+
* Everything the run-end gate records about "did the assembled product start?".
|
|
353
|
+
*
|
|
354
|
+
* This module owned the mechanics but not the CONCEPT: answering that question
|
|
355
|
+
* meant reading ~110 lines of `final-gate.ts` as well, where the four-armed
|
|
356
|
+
* {@link BootOutcome} union was destructured, the render/deep-render/port probe
|
|
357
|
+
* defaults were bound, `recoverOrphanPort` was re-invoked with six positional
|
|
358
|
+
* arguments, and the port-holder diagnosis reached back into `BootDeps` a SECOND
|
|
359
|
+
* time to build its own sentence. CONTEXT.md records the earlier extraction as
|
|
360
|
+
* "a file move, not a re-shaping"; this is the re-shaping it left open.
|
|
361
|
+
*
|
|
362
|
+
* The gate's boot branch is now: call this, write the fields into the tally.
|
|
363
|
+
* `runBootCheck` stays exported unchanged — seven harnesses under `scripts/`
|
|
364
|
+
* drive it directly.
|
|
365
|
+
*/
|
|
366
|
+
export interface BootSectionVerdict {
|
|
367
|
+
/** The bin the gate counts as ATTEMPTED. Absent ⇒ there was nothing to boot. */
|
|
368
|
+
attempted?: string;
|
|
369
|
+
/** A probe LOOKED. False for a skip, and for a project with no launch surface. */
|
|
370
|
+
observed: boolean;
|
|
371
|
+
/** The runner binary never spawned — feeds the full-blindness guard. */
|
|
372
|
+
spawnFailedBin?: string;
|
|
373
|
+
/** The UNOBSERVED note, offered on both branches (a rejected launch script has one). */
|
|
374
|
+
unobservedNote?: string;
|
|
375
|
+
/**
|
|
376
|
+
* The one failure this section can produce, with the rank the gate gives it (0 —
|
|
377
|
+
* boot failures lead the aggregate) and whether a probe OBSERVED it. A harness
|
|
378
|
+
* condition (a port we could not clear) is a failure that nothing observed about
|
|
379
|
+
* the APP, which is why `observed` is a field and not implied by `failure`.
|
|
380
|
+
*/
|
|
381
|
+
failure?: {
|
|
382
|
+
detail: string;
|
|
383
|
+
rank: number;
|
|
384
|
+
observed: boolean;
|
|
385
|
+
};
|
|
386
|
+
/** The label recorded as having RUN, on a clean boot. */
|
|
387
|
+
ranLabel?: string;
|
|
388
|
+
/** UNOBSERVED warnings — a listener that served but whose page could not be judged. */
|
|
389
|
+
warnings: string[];
|
|
390
|
+
}
|
|
391
|
+
export interface BootSectionOptions {
|
|
392
|
+
/** The plan text, for served-app detection. */
|
|
393
|
+
planText?: string;
|
|
394
|
+
/** Grace period for the boot check. */
|
|
395
|
+
graceMs?: number;
|
|
396
|
+
/**
|
|
397
|
+
* Probe overrides. The render, deep-render and preferred-port defaults are
|
|
398
|
+
* bound HERE now: they were the gate's, so a caller that wanted the real boot
|
|
399
|
+
* behaviour had to know to supply three functions it should never have had to
|
|
400
|
+
* name — `findPortHolder` was already defaulted inside this module, and the
|
|
401
|
+
* other three now match it.
|
|
402
|
+
*/
|
|
403
|
+
deps?: BootDeps;
|
|
404
|
+
}
|
|
405
|
+
export declare function runBootSection(cwd: string, opts?: BootSectionOptions): Promise<BootSectionVerdict>;
|
|
298
406
|
export {};
|
package/dist/task/boot-probe.js
CHANGED
|
@@ -23,10 +23,11 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
23
23
|
import { existsSync, readFileSync } from 'node:fs';
|
|
24
24
|
import * as net from 'node:net';
|
|
25
25
|
import * as path from 'node:path';
|
|
26
|
+
import { runRenderCheck } from './render-check.js';
|
|
26
27
|
import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
|
|
27
28
|
import { outputTail } from './command-run.js';
|
|
28
29
|
import { packageScripts, makeHasTarget } from './launch-manifest.js';
|
|
29
|
-
import { collectProjectEnv, pinnedLocalPort } from './deep-render-check.js';
|
|
30
|
+
import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
|
|
30
31
|
/** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
|
|
31
32
|
function commandTokens(member) {
|
|
32
33
|
const t = member.trim().split(/\s+/).filter(Boolean);
|
|
@@ -485,6 +486,30 @@ function holderIsOurs(command, boot) {
|
|
|
485
486
|
return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
|
|
486
487
|
&& (c.includes(` ${script}`) || c.endsWith(script)));
|
|
487
488
|
}
|
|
489
|
+
/** The real spawn. Kept beside the seam so the default is one line to read. */
|
|
490
|
+
function defaultSpawnBoot(bin, args, o) {
|
|
491
|
+
return spawn(bin, args, o);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The real group teardown, best-effort. A group already gone is not an error.
|
|
495
|
+
*
|
|
496
|
+
* Windows has no process groups / negative-pid kill: `taskkill /T` tears down the
|
|
497
|
+
* whole tree (the detached child plus any grandchildren it spawned) and `/F`
|
|
498
|
+
* forces it, so the SIGTERM→SIGKILL escalation collapses to one idempotent call.
|
|
499
|
+
*/
|
|
500
|
+
function defaultKillGroup(pid, sig) {
|
|
501
|
+
try {
|
|
502
|
+
if (process.platform === 'win32') {
|
|
503
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F']);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
process.kill(-pid, sig);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// group already gone
|
|
511
|
+
}
|
|
512
|
+
}
|
|
488
513
|
/**
|
|
489
514
|
* Exercise the start command ONCE. For a CLI project (`expectServer` false) the
|
|
490
515
|
* command's own fate within the grace window decides:
|
|
@@ -546,8 +571,9 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
546
571
|
// the runner and carry its directory on PATH so the boot script's own chain
|
|
547
572
|
// can re-invoke it.
|
|
548
573
|
const runner = resolveRunner(bin);
|
|
574
|
+
const spawnBoot = opts.deps?.spawnBoot ?? defaultSpawnBoot;
|
|
549
575
|
return new Promise(resolve => {
|
|
550
|
-
const child =
|
|
576
|
+
const child = spawnBoot(runner.bin, args, {
|
|
551
577
|
cwd,
|
|
552
578
|
detached: true,
|
|
553
579
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -561,13 +587,17 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
561
587
|
// runner where the group-kill did not take, hanging the whole `bun test
|
|
562
588
|
// --isolate` run on the leaked child's piped stdio). unref() so a child
|
|
563
589
|
// we already tried to kill can never itself keep this process alive.
|
|
564
|
-
child.unref();
|
|
590
|
+
child.unref?.();
|
|
565
591
|
let out = '';
|
|
566
592
|
let err = '';
|
|
567
593
|
let listenerSeen = false;
|
|
568
594
|
const cap = (s) => (s.length > 8000 ? s.slice(-8000) : s);
|
|
569
|
-
child.stdout?.on('data',
|
|
570
|
-
|
|
595
|
+
child.stdout?.on('data', d => {
|
|
596
|
+
out = cap(out + String(d));
|
|
597
|
+
});
|
|
598
|
+
child.stderr?.on('data', d => {
|
|
599
|
+
err = cap(err + String(d));
|
|
600
|
+
});
|
|
571
601
|
let settled = false;
|
|
572
602
|
const settle = (r) => {
|
|
573
603
|
if (settled)
|
|
@@ -578,24 +608,15 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
|
|
|
578
608
|
clearInterval(poll);
|
|
579
609
|
resolve(r);
|
|
580
610
|
};
|
|
611
|
+
const reapGroup = opts.deps?.killGroup ?? defaultKillGroup;
|
|
581
612
|
const killGroup = (sig) => {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
// SIGKILL escalation collapses to one idempotent call.
|
|
590
|
-
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']);
|
|
591
|
-
}
|
|
592
|
-
else {
|
|
593
|
-
process.kill(-child.pid, sig);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
catch {
|
|
597
|
-
// group already gone
|
|
598
|
-
}
|
|
613
|
+
// Truthiness, deliberately: `process.kill(-0, sig)` signals the
|
|
614
|
+
// CALLER's own process group, so a pid of 0 turns a best-effort
|
|
615
|
+
// teardown into self-termination. Node's spawn never yields 0, but
|
|
616
|
+
// `spawnBoot` is a seam now and a fake or future child could.
|
|
617
|
+
if (!child.pid)
|
|
618
|
+
return;
|
|
619
|
+
reapGroup(child.pid, sig);
|
|
599
620
|
};
|
|
600
621
|
const passAndKill = (renderNote) => {
|
|
601
622
|
settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
|
|
@@ -791,7 +812,11 @@ export function bootSkipVerdict(args) {
|
|
|
791
812
|
* the caller emit the harness diagnosis. Never reaps a process we cannot attribute
|
|
792
813
|
* to ourselves.
|
|
793
814
|
*/
|
|
794
|
-
export async function recoverOrphanPort(cwd, boot, first,
|
|
815
|
+
export async function recoverOrphanPort(cwd, boot, first,
|
|
816
|
+
/** One options object, not four trailing positionals — `bootGraceMs` and a
|
|
817
|
+
* boolean sat adjacent and swapped without a type error. */
|
|
818
|
+
opts) {
|
|
819
|
+
const { graceMs: bootGraceMs, deps, expectServer } = opts;
|
|
795
820
|
if (first.port === null)
|
|
796
821
|
return first;
|
|
797
822
|
const holder = (deps.findPortHolder ?? defaultFindPortHolder)(first.port);
|
|
@@ -804,3 +829,94 @@ export async function recoverOrphanPort(cwd, boot, first, bootGraceMs, deps, exp
|
|
|
804
829
|
await new Promise(r => setTimeout(r, 1_500));
|
|
805
830
|
return runBootCheck(cwd, boot, bootGraceMs, { expectServer, deps });
|
|
806
831
|
}
|
|
832
|
+
export async function runBootSection(cwd, opts = {}) {
|
|
833
|
+
const boot = discoverBootCommand(cwd);
|
|
834
|
+
const expectServer = detectsServedApp(cwd, opts.planText);
|
|
835
|
+
const warnings = [];
|
|
836
|
+
if (!boot) {
|
|
837
|
+
// Nothing to boot — but if the reason is that the project's only launch
|
|
838
|
+
// script was REJECTED as not-a-launch (2A), that is not the same thing as a
|
|
839
|
+
// project with no launch surface, and it must not degrade into silence.
|
|
840
|
+
const rejected = rejectedLaunchScript(cwd);
|
|
841
|
+
if (rejected && expectServer) {
|
|
842
|
+
return {
|
|
843
|
+
observed: false,
|
|
844
|
+
warnings,
|
|
845
|
+
unobservedNote: `boot check: this project's only launch script (\`${rejected.name}\`) is not a `
|
|
846
|
+
+ `launch — ${rejected.reason} — so nothing was started and the app was never `
|
|
847
|
+
+ 'observed to run.'
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
return { observed: false, warnings };
|
|
851
|
+
}
|
|
852
|
+
const label = `${boot[0]} ${boot[1].join(' ')}`;
|
|
853
|
+
// Render check (mx5 runs 8/11): for a served app, load the live page in a
|
|
854
|
+
// headless browser and judge the RENDERED DOM — curl can't run JS, so a
|
|
855
|
+
// blank-mount app passed every prior "renders" check. runRenderCheck
|
|
856
|
+
// env-gap-SKIPs when no browser exists, so a box without one never gets a
|
|
857
|
+
// false FAIL.
|
|
858
|
+
//
|
|
859
|
+
// Authenticated deep-render check (mx5 run 17): the page above renders, so now
|
|
860
|
+
// sign in with the account the project's own dotenv declares (the same
|
|
861
|
+
// ADMIN_PHONE/ADMIN_PASSWORD the launch contract's seed step consumes) and
|
|
862
|
+
// require the session to actually work. WEB-ONLY by construction — it hangs off
|
|
863
|
+
// the served-app branch and never runs for C++, Godot, CLI or library projects.
|
|
864
|
+
// It may only FAIL when the SERVER authenticated us; no browser, no
|
|
865
|
+
// credentials, an undrivable form or rejected credentials all skip as env gaps.
|
|
866
|
+
const deps = {
|
|
867
|
+
...opts.deps,
|
|
868
|
+
renderProbe: opts.deps?.renderProbe ?? runRenderCheck,
|
|
869
|
+
deepRenderProbe: opts.deps?.deepRenderProbe ?? (url => runDeepRenderCheck(url, cwd)),
|
|
870
|
+
preferredPort: opts.deps?.preferredPort ?? (() => preferredDeclaredPort(cwd))
|
|
871
|
+
};
|
|
872
|
+
let b = await runBootCheck(cwd, boot, opts.graceMs, { expectServer, deps });
|
|
873
|
+
if (b.outcome === 'orphan-port') {
|
|
874
|
+
b = await recoverOrphanPort(cwd, boot, b, {
|
|
875
|
+
...(opts.graceMs === undefined ? {} : { graceMs: opts.graceMs }),
|
|
876
|
+
deps,
|
|
877
|
+
expectServer
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
const verdict = {
|
|
881
|
+
attempted: boot[0],
|
|
882
|
+
observed: b.outcome !== 'skip',
|
|
883
|
+
warnings,
|
|
884
|
+
...(b.outcome === 'skip' && b.spawnFailed ? { spawnFailedBin: boot[0] } : {})
|
|
885
|
+
};
|
|
886
|
+
const unobserved = bootSkipVerdict({ label, skipped: b.outcome === 'skip', expectServer });
|
|
887
|
+
if (unobserved !== null)
|
|
888
|
+
verdict.unobservedNote = unobserved;
|
|
889
|
+
if (b.outcome === 'fail') {
|
|
890
|
+
// OBSERVED (nexttask 19A). Every path that produces `fail` here is a probe
|
|
891
|
+
// that looked: the render judge saw an empty body, the deep session saw the
|
|
892
|
+
// authenticated half dead, the enumerator saw no listener, or the launch
|
|
893
|
+
// command itself exited non-zero. The one condition that means "we could not
|
|
894
|
+
// look" — no ss/netstat/lsof, mx5 run 14 — returns PASS stamped UNOBSERVED
|
|
895
|
+
// and never reaches here.
|
|
896
|
+
verdict.failure = { detail: `boot check: \`${label}\` ${b.detail}`, rank: 0, observed: true };
|
|
897
|
+
}
|
|
898
|
+
else if (b.outcome === 'orphan-port') {
|
|
899
|
+
// Could not clear the port. Distinct HARNESS diagnosis, never a bare app
|
|
900
|
+
// FAIL: name the port and (when known) the process squatting on it. The
|
|
901
|
+
// holder lookup reads the SAME deps the boot ran under — it used to be a
|
|
902
|
+
// second reach into `BootDeps` from the gate, one layer away from the run.
|
|
903
|
+
const holder = b.port !== null ? (deps.findPortHolder ?? defaultFindPortHolder)(b.port) : null;
|
|
904
|
+
const who = holder ? ` — held by an orphaned process (pid ${holder.pid}: ${holder.command})`
|
|
905
|
+
: b.port !== null ? ` — port ${b.port} is held by another process`
|
|
906
|
+
: '';
|
|
907
|
+
verdict.failure = {
|
|
908
|
+
detail: `boot check: \`${label}\` could not bind: orphaned process / port already in `
|
|
909
|
+
+ `use${who} (harness condition, not an app fault)`,
|
|
910
|
+
rank: 0,
|
|
911
|
+
observed: false
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
else if (b.outcome === 'pass') {
|
|
915
|
+
verdict.ranLabel = label;
|
|
916
|
+
// A listener that served, but whose page could not be OBSERVED to render
|
|
917
|
+
// (no browser, undeterminable port) → UNOBSERVED warning, not a silent pass.
|
|
918
|
+
if (b.renderNote)
|
|
919
|
+
warnings.push(b.renderNote);
|
|
920
|
+
}
|
|
921
|
+
return verdict;
|
|
922
|
+
}
|