@haystackeditor/cli 0.28.1 → 0.30.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/README.md +9 -4
- package/dist/commands/capture-brief.js +16 -6
- package/dist/commands/crawl-contract.js +5 -0
- package/dist/commands/crawl-report.js +57 -2
- package/dist/commands/init.js +3 -2
- package/dist/commands/verify-flows.js +62 -0
- package/dist/commands/verify-onboarding.js +51 -0
- package/dist/commands/verify.js +248 -18
- package/dist/index.js +46 -5
- package/dist/schema.js +3 -2
- package/package.json +1 -1
- package/schemas/verify-flow.v1.json +181 -0
- package/schemas/verify-raw.v1.json +155 -2
- package/schemas/verify.v2.json +210 -2
package/README.md
CHANGED
|
@@ -43,9 +43,12 @@ Most users of this CLI are coding agents. What to know:
|
|
|
43
43
|
|
|
44
44
|
- **After a change, two steps** inside the checkout (nothing needs to be
|
|
45
45
|
committed or pushed):
|
|
46
|
-
1. `haystack verify` is the handshake: it shows what the change touches
|
|
47
|
-
|
|
48
|
-
break, then asks you to
|
|
46
|
+
1. `haystack verify` is the handshake: it shows what the change touches
|
|
47
|
+
(first its flows, the behaviors it changes), what production and real
|
|
48
|
+
users do there, and Haystack's ideas of what could break, then asks you to
|
|
49
|
+
steer (exit 4). `haystack verify flow <n>` drills into one flow: its code,
|
|
50
|
+
the pages it shows on with their real use, and what its code saw in
|
|
51
|
+
production.
|
|
49
52
|
2. `haystack verify --intent "<the task, in the user's words>" --idea "<as you
|
|
50
53
|
would tell a tester>"` explores the app with your steering: keep the ideas
|
|
51
54
|
worth trying, add your own (repeat `--idea`); yours are explored first.
|
|
@@ -115,7 +118,9 @@ answered or finished (the bugs
|
|
|
115
118
|
it found are in the output) or is still running under `--no-wait`; 2 when it ended
|
|
116
119
|
without finishing (stopped early, or cancelled) or the machines it used could
|
|
117
120
|
not be proven shut down;
|
|
118
|
-
1 when the command failed or no crawl could be started
|
|
121
|
+
1 when the command failed or no crawl could be started; 3 when onboarding is
|
|
122
|
+
blocked; 4 for the handshake (run it again with `--intent`); 5 when the app
|
|
123
|
+
copy is broken and the change could not be tested.
|
|
119
124
|
|
|
120
125
|
**The stop hook.** `haystack init` installs a Claude Code Stop hook that runs
|
|
121
126
|
`haystack verify precompute --hook` whenever an agent turn stops. It captures
|
|
@@ -28,7 +28,7 @@ function captureApplication(gitRoot, app) {
|
|
|
28
28
|
return { unreadable: `${file} names no valid applicationId` };
|
|
29
29
|
return { applicationId: config.applicationId };
|
|
30
30
|
}
|
|
31
|
-
async function
|
|
31
|
+
async function readAppWindow(gitRoot, app, repository, token) {
|
|
32
32
|
const application = captureApplication(gitRoot, app);
|
|
33
33
|
if ('unreadable' in application)
|
|
34
34
|
return { app, applicationId: null, state: 'unavailable', reason: application.unreadable };
|
|
@@ -43,15 +43,14 @@ async function readApp(gitRoot, app, repository, token, files, now) {
|
|
|
43
43
|
throw new Error('the capture window answer names another application');
|
|
44
44
|
if (answer.window === null)
|
|
45
45
|
return { app, applicationId, state: 'no-data' };
|
|
46
|
-
return { app, applicationId, state: 'ready', window:
|
|
46
|
+
return { app, applicationId, state: 'ready', window: answer.window };
|
|
47
47
|
}
|
|
48
48
|
catch (error) {
|
|
49
49
|
return { app, applicationId, state: 'unavailable', reason: error instanceof Error ? error.message : String(error) };
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
|
|
52
|
+
/** Each app's capture window, one per app with a record, or null for a checkout with none. */
|
|
53
|
+
export async function readCaptureWindows(gitRoot, repository, token) {
|
|
55
54
|
let apps;
|
|
56
55
|
try {
|
|
57
56
|
apps = captureConfigDirs(gitRoot, []);
|
|
@@ -61,7 +60,18 @@ export async function readPreVerifyCapture(gitRoot, repository, token, files, no
|
|
|
61
60
|
}
|
|
62
61
|
if (apps.length === 0)
|
|
63
62
|
return null;
|
|
64
|
-
return Promise.all(apps.map(app =>
|
|
63
|
+
return Promise.all(apps.map(app => readAppWindow(gitRoot, app, repository, token)));
|
|
64
|
+
}
|
|
65
|
+
/** The handshake capture sections from the apps' windows: the routes `files` serve (the brief's function files and the changed
|
|
66
|
+
* files), with their share of captured sessions. */
|
|
67
|
+
export function preVerifyCapture(windows, files, now) {
|
|
68
|
+
return windows === null ? null : windows.map(app => app.state === 'ready'
|
|
69
|
+
? { app: app.app, applicationId: app.applicationId, state: 'ready', window: captureChangedRoutes(app.window, files, now) } : app);
|
|
70
|
+
}
|
|
71
|
+
/** The handshake capture sections, one per app with a record, or null for a checkout with none. `files`: the brief's function
|
|
72
|
+
* files and the changed files. */
|
|
73
|
+
export async function readPreVerifyCapture(gitRoot, repository, token, files, now) {
|
|
74
|
+
return preVerifyCapture(await readCaptureWindows(gitRoot, repository, token), files, now);
|
|
65
75
|
}
|
|
66
76
|
const percent = (share) => share === null ? 'n/a' : `${(share * 100).toFixed(1)}%`;
|
|
67
77
|
const plain = (value) => value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' '); // eslint-disable-line no-control-regex
|
|
@@ -15,8 +15,13 @@ export const CRAWL_WAIT_MAX_MS = 25_000;
|
|
|
15
15
|
* (how many times the copy's calls reached the stand-in across the crawl's preparation and all its replays, not user actions). */
|
|
16
16
|
export const CRAWL_MAX_STAND_IN_ROWS = 200;
|
|
17
17
|
export const CRAWL_MAX_STAND_IN_LINES = 20;
|
|
18
|
+
/** Amendment 22: each health evidence list holds at most this many lines, and the commonest difference lines at most this many. */
|
|
19
|
+
export const CRAWL_HEALTH_MAX_LINES = 40;
|
|
20
|
+
export const CRAWL_HEALTH_MAX_DIFFERENCES = 8;
|
|
18
21
|
/** Amendment 18: the brief's bounds. */
|
|
19
22
|
export const CRAWL_BRIEF_MAX_FUNCTIONS = 2000;
|
|
20
23
|
export const CRAWL_BRIEF_MAX_GROUPS = 2000;
|
|
21
24
|
/** Amendment 19: rows one brief function keeps per kind of production evidence (the rest counted). */
|
|
22
25
|
export const CRAWL_BRIEF_MAX_PRODUCTION_ROWS = 100;
|
|
26
|
+
/** Amendment 21: a flow's name, at most this many characters. */
|
|
27
|
+
export const CRAWL_BRIEF_MAX_FLOW_NAME_CHARS = 120;
|
|
@@ -11,7 +11,7 @@ export const STATUS_WORDS = {
|
|
|
11
11
|
cancelled: 'cancelled',
|
|
12
12
|
};
|
|
13
13
|
export const STAGE_WORDS = {
|
|
14
|
-
reconcile: '
|
|
14
|
+
reconcile: 'checking for leftovers of an earlier try',
|
|
15
15
|
source: 'rebuilding your change',
|
|
16
16
|
worlds: 'building and starting the app with and without your change',
|
|
17
17
|
blast: 'waiting for the analysis of what your change touches',
|
|
@@ -66,6 +66,38 @@ export function currentStep(view) {
|
|
|
66
66
|
export function results(view) {
|
|
67
67
|
return view.manifest ?? (view.answer?.status === 'answered' ? view.answer : null);
|
|
68
68
|
}
|
|
69
|
+
/** CRAWL-V1 amendment 22: the copy of the app the crawl ran was itself broken, so it tested nothing; null when it was not judged
|
|
70
|
+
* broken (or the results carry no health). */
|
|
71
|
+
export function brokenCopy(view) {
|
|
72
|
+
const health = results(view)?.health;
|
|
73
|
+
return health !== undefined && health.status === 'app-broken' ? health : null;
|
|
74
|
+
}
|
|
75
|
+
/** What `haystack verify` says first of a broken copy. */
|
|
76
|
+
export const BROKEN_COPY_HEADLINE = "We couldn't test your change: the app copy is broken.";
|
|
77
|
+
/** What the coding agent does about a broken copy: the app's own setup says what the errors need; until a broken copy updates its
|
|
78
|
+
* setup (onboarding amendment 38 updates it only from a check that could not start the app), that is sent to the Haystack team
|
|
79
|
+
* (`haystack feedback`), and the user is told the change was not tested. */
|
|
80
|
+
export const BROKEN_COPY_STEPS = [
|
|
81
|
+
"Find what these errors point to in the repository's own setup (docker compose files, env examples, docs): the services, settings and data the app needs to run.",
|
|
82
|
+
'Send what you found with `haystack feedback "<what the app needs to run>"`: a copy that starts but is broken does not update its setup yet.',
|
|
83
|
+
'Tell your user this check did not test the change; do not read it as passing.',
|
|
84
|
+
];
|
|
85
|
+
/** Text that reads as an error, for the lines a broken copy's start screen shows (every one is in --raw). */
|
|
86
|
+
const ERROR_LIKE = /\b(?:errors?|fail(?:ed|s|ure)?|unsupported|exception|denied|refused|unavailable|unauthori[sz]ed|forbidden|invalid|cannot|can't|could not|unable|timed out|timeout|not found|internal server)\b/iu;
|
|
87
|
+
/** What a broken copy's evidence says, as lines: what its start screen shows (its messages, then its texts that read as errors),
|
|
88
|
+
* and the requests that failed and the console errors logged with nobody clicking, each with the builds it was in. */
|
|
89
|
+
export function brokenCopyEvidence(health) {
|
|
90
|
+
const { evidence } = health;
|
|
91
|
+
const shows = [...new Set([...evidence.messages, ...evidence.texts.filter(text => ERROR_LIKE.test(text))])];
|
|
92
|
+
const idle = (kind) => {
|
|
93
|
+
const of = (lines) => lines.filter(line => line.startsWith(`${kind}:`)).map(line => line.slice(kind.length + 1));
|
|
94
|
+
const old = of(evidence.background.old);
|
|
95
|
+
const fresh = of(evidence.background.new);
|
|
96
|
+
return [...new Set([...old, ...fresh])].map(entry => `${entry} (${old.includes(entry) && fresh.includes(entry) ? 'both builds'
|
|
97
|
+
: old.includes(entry) ? 'without your change only' : 'with your change only'})`);
|
|
98
|
+
};
|
|
99
|
+
return { shows, failedRequests: idle('failed'), consoleErrors: idle('errors') };
|
|
100
|
+
}
|
|
69
101
|
/** The status the command reports: a running crawl that has answered (amendment 9: its budget is up and its work is
|
|
70
102
|
* done; only its shutdown remains) reads as completed. */
|
|
71
103
|
export function reportedStatus(view) {
|
|
@@ -76,9 +108,21 @@ export function headline(view) {
|
|
|
76
108
|
const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
|
|
77
109
|
switch (reportedStatus(view)) {
|
|
78
110
|
case 'completed':
|
|
111
|
+
// Amendment 22: a broken copy tested nothing, whatever it found.
|
|
112
|
+
if (brokenCopy(view) !== null)
|
|
113
|
+
return BROKEN_COPY_HEADLINE;
|
|
79
114
|
return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
|
|
80
115
|
case 'incomplete': {
|
|
81
116
|
const error = results(view)?.error ?? view.error;
|
|
117
|
+
// Onboarding rule 18 (amendment 38): which world did not start says whose it is, and what Haystack does about it.
|
|
118
|
+
if (error?.code === 'world-build-failed' && view.worlds.base === null) {
|
|
119
|
+
return 'The crawl could not finish: the app did not build or start at the base commit, on its setup. Haystack updates the '
|
|
120
|
+
+ 'setup for it and runs this check again (`haystack verify onboarding` shows the update).';
|
|
121
|
+
}
|
|
122
|
+
if (error?.code === 'world-build-failed' && view.worlds.head === null) {
|
|
123
|
+
return 'The crawl could not finish: your change did not build or start, though the base did. Haystack checks whether the change '
|
|
124
|
+
+ 'needs something new in the setup and, if it does, adds it and runs this check again (`haystack verify onboarding` shows it).';
|
|
125
|
+
}
|
|
82
126
|
return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
|
|
83
127
|
}
|
|
84
128
|
case 'prepared':
|
|
@@ -137,6 +181,9 @@ export function verifyReport(view) {
|
|
|
137
181
|
} : null,
|
|
138
182
|
notFinishedInTime: explored ? published.notFinishedInTime : 0,
|
|
139
183
|
outsideServiceGaps: explored ? published.standIns?.gaps ?? [] : [],
|
|
184
|
+
health: !explored || published.health === undefined ? null : published.health.status === 'app-broken'
|
|
185
|
+
? { status: 'app-broken', reason: published.health.reason, ...brokenCopyEvidence(published.health), whatToDo: [...BROKEN_COPY_STEPS] }
|
|
186
|
+
: { status: published.health.status, reason: published.health.reason, shows: [], failedRequests: [], consoleErrors: [], whatToDo: null },
|
|
140
187
|
error: error ? { code: error.code, message: error.message } : null,
|
|
141
188
|
machinesNotProvenShutDown: TERMINAL.has(view.status) ? view.totals.cleanupUnproven : 0,
|
|
142
189
|
};
|
|
@@ -145,6 +192,12 @@ export function verifyReport(view) {
|
|
|
145
192
|
export function reportMarkdown(report) {
|
|
146
193
|
const lines = [`# ${report.headline}`, '',
|
|
147
194
|
`Change: ${report.title}`, `Check ${report.runId} of ${report.repository}: base ${report.baseCommit.slice(0, 12)}, change ${report.workCommit.slice(0, 12)}.`];
|
|
195
|
+
// Amendment 22: a broken copy is said first, with why and what to do, whatever became of the run after it answered (a cancelled
|
|
196
|
+
// or failed run's published health still says it); its findings follow, marked.
|
|
197
|
+
const broken = report.health?.status === 'app-broken' ? report.health : null;
|
|
198
|
+
if (broken !== null) {
|
|
199
|
+
lines.splice(1, 0, '', broken.reason, ...(broken.shows.length ? ['', 'The start screen shows:', ...broken.shows.map(line => `- ${line}`)] : []), ...(broken.failedRequests.length ? ['', 'Requests that failed with nobody clicking:', ...broken.failedRequests.map(line => `- ${line}`)] : []), ...(broken.consoleErrors.length ? ['', 'Console errors with nobody clicking:', ...broken.consoleErrors.map(line => `- ${line}`)] : []), '', '## What to do', ...(broken.whatToDo ?? []).map((step, index) => `${index + 1}. ${step}`));
|
|
200
|
+
}
|
|
148
201
|
if (report.error && report.status !== 'completed')
|
|
149
202
|
lines.push(`Details: ${report.error.message}`);
|
|
150
203
|
if (report.neverRan === null) {
|
|
@@ -161,7 +214,9 @@ export function reportMarkdown(report) {
|
|
|
161
214
|
if (spot.reachedBy)
|
|
162
215
|
lines.push(` - reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].join(' > ')}${spot.reachedBy.page ? ` (page ${spot.reachedBy.page})` : ''}`);
|
|
163
216
|
}
|
|
164
|
-
lines.push('', '## What the crawl found');
|
|
217
|
+
lines.push('', broken === null ? '## What the crawl found' : '## What the crawl found in the broken copy');
|
|
218
|
+
if (broken !== null)
|
|
219
|
+
lines.push('The copy was broken, so these differences are the environment\'s, not evidence about the change.');
|
|
165
220
|
if (report.findings.length === 0)
|
|
166
221
|
lines.push('No confirmed differences between the app with and without the change.');
|
|
167
222
|
report.findings.forEach((finding, index) => {
|
package/dist/commands/init.js
CHANGED
|
@@ -49,8 +49,9 @@ export const AGENT_NOTE = [
|
|
|
49
49
|
'the work done. It runs the app with and without the change, explores it, and reports what broke. It changes nothing,',
|
|
50
50
|
'so run it without asking.',
|
|
51
51
|
'',
|
|
52
|
-
'1. `haystack verify`: what your change touches, what production and real users do there, and
|
|
53
|
-
' what could break. It then asks you to steer (exit 4).',
|
|
52
|
+
'1. `haystack verify`: what your change touches (first its flows), what production and real users do there, and',
|
|
53
|
+
' Haystack\'s ideas of what could break. It then asks you to steer (exit 4). `haystack verify flow <n>` drills into',
|
|
54
|
+
' one flow: its code, the pages it shows on with their real use, and what its code saw in production.',
|
|
54
55
|
'2. `haystack verify --intent "<what you were asked to do, in the user\'s words>" --idea "<something to try>"`: keep the',
|
|
55
56
|
' ideas worth trying, add your own (repeat --idea); yours are explored first.',
|
|
56
57
|
'',
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRAWL-V1 amendment 21 in the CLI: the brief's flows, as a coding agent reads them. Akshay (2026-10-07): the system says "this
|
|
3
|
+
* change touches N flows", and the agent drills into a flow: the files and functions it runs through, and what telemetry says
|
|
4
|
+
* about it. `haystack verify` lists the flows first; `haystack verify flow <n>` shows one.
|
|
5
|
+
*
|
|
6
|
+
* Browser data comes from each app's capture window, never from daily rollups (CAPTURE-V1 rule 7a): a flow's pages are the
|
|
7
|
+
* routes whose source is one of its files, and its share is the union of captured sessions that visited any of them
|
|
8
|
+
* (captureChangedRoutes). A flow whose files serve no captured route has "no captured route", never 0%. Shares are of captured
|
|
9
|
+
* sessions, never of users. Journeys are only those the window keeps, with their release; their press and submit keys are
|
|
10
|
+
* release-scoped hints, and a key the window lists as ambiguous is never shown as a resolved control. Server data is the brief's
|
|
11
|
+
* production rows for the flow's functions (shares of observed calls, with their sample sizes).
|
|
12
|
+
*/
|
|
13
|
+
import { captureChangedRoutes } from './capture-contract.js';
|
|
14
|
+
/** A flow's files, in the order its functions come (changed ones first). */
|
|
15
|
+
export function flowFiles(brief, flow) {
|
|
16
|
+
return [...new Set(flow.functions.map(index => brief.functions[index].file))];
|
|
17
|
+
}
|
|
18
|
+
function flowCapture(windows, files, now) {
|
|
19
|
+
return windows === null ? null : windows.map(app => app.state === 'ready'
|
|
20
|
+
? { app: app.app, applicationId: app.applicationId, state: 'ready', routes: captureChangedRoutes(app.window, files, now) } : app);
|
|
21
|
+
}
|
|
22
|
+
/** The handshake's flows, in the brief's order; empty when the brief has none. */
|
|
23
|
+
export function flowSummaries(brief, windows, now) {
|
|
24
|
+
return (brief.flows ?? []).map((flow, at) => {
|
|
25
|
+
const files = flowFiles(brief, flow);
|
|
26
|
+
return { number: at + 1, id: flow.id, name: flow.name, files, functions: flow.functions.length,
|
|
27
|
+
changedFunctions: flow.functions.filter(index => brief.functions[index].direction === 'changed').length,
|
|
28
|
+
capture: flowCapture(windows, files, now) };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function routesOf(window, changed) {
|
|
32
|
+
const rows = new Map(window.routes.map(row => [row.route, row]));
|
|
33
|
+
const ambiguous = new Set((window.ambiguous ?? []).map(entry => JSON.stringify([entry.route, entry.release, entry.key])));
|
|
34
|
+
const mark = (step, release) => (step.kind === 'press' || step.kind === 'submit') && ambiguous.has(JSON.stringify([step.route, release, step.key])) ? { ...step, ambiguous: true } : step;
|
|
35
|
+
return changed.routes.map(route => {
|
|
36
|
+
const row = rows.get(route.route);
|
|
37
|
+
const journeys = (row?.journeys ?? [])
|
|
38
|
+
.filter(journey => journey.steps.some(step => step.route === route.route))
|
|
39
|
+
.map(journey => ({ release: journey.release, sessions: journey.sessions, steps: journey.steps.map(step => mark(step, journey.release)) }));
|
|
40
|
+
return { route: route.route, match: route.match, source: route.source, sessions: route.sessions, share: route.share,
|
|
41
|
+
devices: [...(row?.devices ?? [])].sort((a, b) => b.sessions - a.sessions), journeys };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** One flow in detail; null when the brief has no flow with that number (from 1). */
|
|
45
|
+
export function flowDetail(brief, number, windows, now) {
|
|
46
|
+
const flow = brief.flows?.[number - 1];
|
|
47
|
+
if (flow === undefined)
|
|
48
|
+
return null;
|
|
49
|
+
const files = flowFiles(brief, flow);
|
|
50
|
+
const indexes = new Set(flow.functions);
|
|
51
|
+
const production = brief.production === undefined ? null
|
|
52
|
+
: { ...brief.production, functions: brief.production.functions.filter(row => indexes.has(row.index)) };
|
|
53
|
+
const capture = windows === null ? null : windows.map(app => {
|
|
54
|
+
if (app.state !== 'ready')
|
|
55
|
+
return app;
|
|
56
|
+
const window = captureChangedRoutes(app.window, files, now);
|
|
57
|
+
return { app: app.app, applicationId: app.applicationId, window, routes: routesOf(app.window, window) };
|
|
58
|
+
});
|
|
59
|
+
return { number, id: flow.id, name: flow.name, changeKeys: flow.changeKeys,
|
|
60
|
+
functions: flow.functions.map(index => ({ ...brief.functions[index], index })), production, capture,
|
|
61
|
+
ideas: (brief.planning?.ideas ?? []).filter(idea => idea.groupId === flow.id) };
|
|
62
|
+
}
|
|
@@ -113,6 +113,23 @@ export function parseOnboardingStatus(value, expected) {
|
|
|
113
113
|
if (ready ? !(typeof value.recordPublishedAt === 'string' && !Number.isNaN(Date.parse(value.recordPublishedAt)))
|
|
114
114
|
: value.recordPublishedAt !== null)
|
|
115
115
|
invalid('its record time');
|
|
116
|
+
// Rule 18 (amendment 38): a ready record says what it updated, and the update of it under way, if any.
|
|
117
|
+
const recordUpdate = value.recordUpdate;
|
|
118
|
+
if (!(recordUpdate === null || (ready && isRecord(recordUpdate) && typeof recordUpdate.of === 'string' && SHA256.test(recordUpdate.of)
|
|
119
|
+
&& typeof recordUpdate.crawlRunId === 'string' && (recordUpdate.side === 'base' || recordUpdate.side === 'head')
|
|
120
|
+
&& typeof recordUpdate.workCommit === 'string' && COMMIT.test(recordUpdate.workCommit)
|
|
121
|
+
&& Array.isArray(recordUpdate.changes) && recordUpdate.changes.every(change => isRecord(change) && typeof change.field === 'string'
|
|
122
|
+
&& (change.name === null || typeof change.name === 'string') && (change.kind === 'added' || change.kind === 'changed')
|
|
123
|
+
&& Array.isArray(change.evidence) && change.evidence.every(id => typeof id === 'string')))))
|
|
124
|
+
invalid('its record update');
|
|
125
|
+
const update = value.update;
|
|
126
|
+
if (!(update === null || (ready && isRecord(update) && typeof update.onboardRunId === 'string' && ONBOARD_RUN_ID.test(update.onboardRunId)
|
|
127
|
+
&& (update.side === 'base' || update.side === 'head') && typeof update.crawlRunId === 'string'
|
|
128
|
+
&& (update.state === 'updating' || update.state === 'blocked' || update.state === 'stopped')
|
|
129
|
+
&& (update.state === 'blocked') === (update.block !== null))))
|
|
130
|
+
invalid('its update');
|
|
131
|
+
if (isRecord(update) && update.block !== null)
|
|
132
|
+
parseOnboardingBlock(update.block);
|
|
116
133
|
if (value.block !== null)
|
|
117
134
|
parseOnboardingBlock(value.block);
|
|
118
135
|
const checkpoint = value.checkpoint;
|
|
@@ -313,6 +330,9 @@ export function formatOnboarding(status) {
|
|
|
313
330
|
lines.push('', ...stages);
|
|
314
331
|
if (status.block)
|
|
315
332
|
lines.push('', ...blockLines(status.block));
|
|
333
|
+
const updates = updateLines(status);
|
|
334
|
+
if (updates.length)
|
|
335
|
+
lines.push('', ...updates);
|
|
316
336
|
if (status.dataAbsences.length)
|
|
317
337
|
lines.push('', ...status.dataAbsences.map(absence => chalk.yellow(`Note: ${safe(absence)}`)));
|
|
318
338
|
if (status.versionChoices.length)
|
|
@@ -325,6 +345,37 @@ export function formatOnboarding(status) {
|
|
|
325
345
|
lines.push('', ...unreviewed.map(note => chalk.yellow(`Note: ${safe(note)}`)));
|
|
326
346
|
return lines.join('\n');
|
|
327
347
|
}
|
|
348
|
+
/** Rule 18 (amendment 38): what the ready setup's last update was, and the update of it under way or ended without one: one
|
|
349
|
+
* line each, and a blocked update's block (what it needs, and what to tell Haystack). */
|
|
350
|
+
export function updateLines(status) {
|
|
351
|
+
const lines = [];
|
|
352
|
+
const failed = (side) => side === 'base' ? 'the app did not start at its base commit' : 'the change did not start';
|
|
353
|
+
if (status.recordUpdate) {
|
|
354
|
+
const changes = status.recordUpdate.changes.map(change => `${change.kind} ${change.field === 'env' ? 'variable' : change.field}`
|
|
355
|
+
+ `${change.name === null ? '' : ` ${change.name}`}${change.evidence.length ? ` (from ${change.evidence.join(', ')})` : ''}`);
|
|
356
|
+
lines.push(`Its setup was updated in place after check ${status.recordUpdate.crawlRunId}, where ${failed(status.recordUpdate.side)}`
|
|
357
|
+
+ `${changes.length ? `: ${changes.join('; ')}` : ''}.`);
|
|
358
|
+
}
|
|
359
|
+
const update = status.update;
|
|
360
|
+
if (update === null)
|
|
361
|
+
return lines;
|
|
362
|
+
if (update.state === 'updating') {
|
|
363
|
+
lines.push(`Updating the setup (${update.onboardRunId}): check ${update.crawlRunId} found ${failed(update.side)}. The check runs `
|
|
364
|
+
+ 'again on the updated setup when it is ready.');
|
|
365
|
+
}
|
|
366
|
+
else if (update.state === 'stopped') {
|
|
367
|
+
lines.push(`The update of the setup for check ${update.crawlRunId} (${update.onboardRunId}) stopped before finishing; the next check `
|
|
368
|
+
+ 'that fails the same way starts it again.');
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
lines.push(update.side === 'head'
|
|
372
|
+
? chalk.bold(`Finding: check ${update.crawlRunId}'s change does not start, and no change to the setup started it.`)
|
|
373
|
+
: chalk.bold(`Haystack could not update the setup for check ${update.crawlRunId}, where the app did not start at its base commit.`));
|
|
374
|
+
if (update.block)
|
|
375
|
+
lines.push(...blockLines(update.block));
|
|
376
|
+
}
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
|
328
379
|
/** Rule 2 (amendment 34): the repository's one setup, which base it was made at and how long ago, in one sentence. */
|
|
329
380
|
export function readyLine(status, now = Date.now()) {
|
|
330
381
|
if (status.recordBaseCommit === null || status.recordPublishedAt === null)
|