@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/dist/commands/verify.js
CHANGED
|
@@ -29,11 +29,12 @@ import { findGitRoot } from '../utils/hooks.js';
|
|
|
29
29
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
30
30
|
import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, repoApiPath, } from '../utils/haystack-api.js';
|
|
31
31
|
import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
|
|
32
|
-
import { CRAWL_BRIEF_MAX_FUNCTIONS, CRAWL_BRIEF_MAX_GROUPS, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_IDEAS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_MAX_TEXT_CHARS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.js';
|
|
32
|
+
import { CRAWL_BRIEF_MAX_FUNCTIONS, CRAWL_BRIEF_MAX_FLOW_NAME_CHARS, CRAWL_BRIEF_MAX_GROUPS, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_HEALTH_MAX_DIFFERENCES, CRAWL_HEALTH_MAX_LINES, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_IDEAS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_MAX_TEXT_CHARS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.js';
|
|
33
33
|
import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
|
|
34
|
-
import { SPOT_WORDS, TERMINAL, VERDICT_WORDS, VERDICTS, currentStep, errorWords, headline, plural, reportedStatus, results, verifyReport, } from './crawl-report.js';
|
|
34
|
+
import { BROKEN_COPY_STEPS, SPOT_WORDS, TERMINAL, VERDICT_WORDS, VERDICTS, brokenCopy, brokenCopyEvidence, currentStep, errorWords, headline, plural, reportedStatus, results, verifyReport, } from './crawl-report.js';
|
|
35
35
|
import { formatOnboarding, onboardingExitCode, readOnboardingStatus, readyLine, reportOnboardingState, reviewNotes, standInGapNotes, waitForOnboarding, } from './verify-onboarding.js';
|
|
36
|
-
import { formatCapture,
|
|
36
|
+
import { formatCapture, preVerifyCapture, readCaptureWindows } from './capture-brief.js';
|
|
37
|
+
import { flowDetail, flowSummaries } from './verify-flows.js';
|
|
37
38
|
/** A held read (waitAfter): the service's hold, then the time any read gets. */
|
|
38
39
|
const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
|
|
39
40
|
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
@@ -182,9 +183,30 @@ function checkResults(value, view, what) {
|
|
|
182
183
|
invalid('its ideas');
|
|
183
184
|
if (value.brief !== undefined)
|
|
184
185
|
checkBrief(value.brief);
|
|
186
|
+
if (value.health !== undefined)
|
|
187
|
+
checkHealth(value.health);
|
|
185
188
|
if (value.error !== null)
|
|
186
189
|
checkError(value.error, 'its error');
|
|
187
190
|
}
|
|
191
|
+
/** Amendment 22: the health of the app copy, as the worker bounds it: `not-judged` with its reason, or a judgment. */
|
|
192
|
+
function checkHealth(value) {
|
|
193
|
+
const lines = (item) => isStrings(item, CRAWL_HEALTH_MAX_LINES);
|
|
194
|
+
if (!isRecord(value) || typeof value.reason !== 'string' || value.reason.length === 0)
|
|
195
|
+
invalid('its app health');
|
|
196
|
+
if (value.status === 'not-judged')
|
|
197
|
+
return;
|
|
198
|
+
const evidence = value.evidence;
|
|
199
|
+
const images = value.images;
|
|
200
|
+
if ((value.status !== 'ok' && value.status !== 'app-broken') || typeof value.working !== 'number' || !(value.working >= 0 && value.working <= 1)
|
|
201
|
+
|| !isMember(value.tested, ['tested', 'app_broken', 'not_reached', 'unclear'])
|
|
202
|
+
|| !isRecord(evidence) || !lines(evidence.texts) || !lines(evidence.messages) || !lines(evidence.overlays)
|
|
203
|
+
|| !isRecord(evidence.background) || !lines(evidence.background.old) || !lines(evidence.background.new)
|
|
204
|
+
|| !Array.isArray(evidence.commonDifferences) || evidence.commonDifferences.length > CRAWL_HEALTH_MAX_DIFFERENCES
|
|
205
|
+
|| !evidence.commonDifferences.every(row => isRecord(row) && typeof row.line === 'string' && isCount(row.count))
|
|
206
|
+
|| !isRecord(images) || (images.old !== null && typeof images.old !== 'string')
|
|
207
|
+
|| (images.new !== null && typeof images.new !== 'string'))
|
|
208
|
+
invalid('its app health');
|
|
209
|
+
}
|
|
188
210
|
/** Amendment 18: a brief's shape and bounds, as the worker checks them. */
|
|
189
211
|
function checkBrief(value) {
|
|
190
212
|
const text = (item, empty = false) => typeof item === 'string' && (empty || item.length > 0) && [...item].length <= CRAWL_MAX_TEXT_CHARS;
|
|
@@ -224,6 +246,18 @@ function checkBrief(value) {
|
|
|
224
246
|
&& records(observed.shapes, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, shape => text(shape.shape) && isCount(shape.count)))
|
|
225
247
|
&& estimates(fn))))
|
|
226
248
|
invalid('its brief');
|
|
249
|
+
// Amendment 21: each flow is a planning group, named, with distinct function indexes the brief holds.
|
|
250
|
+
const flows = value.flows;
|
|
251
|
+
if (flows !== undefined) {
|
|
252
|
+
const groupIds = new Set(isRecord(planning) && Array.isArray(planning.groups) ? planning.groups.map(group => group.id) : []);
|
|
253
|
+
const count = value.functions.length;
|
|
254
|
+
if (planning === null || !records(flows, CRAWL_BRIEF_MAX_GROUPS, flow => text(flow.id) && groupIds.has(flow.id)
|
|
255
|
+
&& typeof flow.name === 'string' && flow.name.length > 0 && [...flow.name].length <= CRAWL_BRIEF_MAX_FLOW_NAME_CHARS
|
|
256
|
+
&& texts(flow.changeKeys, CRAWL_BRIEF_MAX_FUNCTIONS) && Array.isArray(flow.functions) && flow.functions.length <= count
|
|
257
|
+
&& flow.functions.every(index => isCount(index) && index < count) && new Set(flow.functions).size === flow.functions.length)
|
|
258
|
+
|| new Set(flows.map(flow => flow.id)).size !== flows.length)
|
|
259
|
+
invalid('its brief');
|
|
260
|
+
}
|
|
227
261
|
}
|
|
228
262
|
function checkManifest(value, view) {
|
|
229
263
|
checkResults(value, view, 'its results');
|
|
@@ -375,6 +409,15 @@ function findingLines(finding, position) {
|
|
|
375
409
|
export function formatCrawl(view) {
|
|
376
410
|
const status = reportedStatus(view);
|
|
377
411
|
const lines = [chalk.bold(headline(view))];
|
|
412
|
+
// Amendment 22: a broken copy tested nothing: said first (the headline), then why, what both builds showed, and what to do. Its
|
|
413
|
+
// findings still follow, marked as the broken copy's.
|
|
414
|
+
// Amendment 22: whatever became of the run after it answered; the exit code keeps the run's own status (crawlExitCode).
|
|
415
|
+
const broken = brokenCopy(view);
|
|
416
|
+
if (broken !== null) {
|
|
417
|
+
const { shows, failedRequests, consoleErrors } = brokenCopyEvidence(broken);
|
|
418
|
+
const listed = (title, items) => (items.length ? [` ${title}`, ...items.map(item => ` ${safe(item)}`)] : []);
|
|
419
|
+
lines.push(` ${safe(broken.reason)}`, ...listed('The start screen shows:', shows), ...listed('Requests that failed with nobody clicking:', failedRequests), ...listed('Console errors with nobody clicking:', consoleErrors), chalk.bold(' What to do:'), ...BROKEN_COPY_STEPS.map((step, index) => ` ${index + 1}. ${step}`), '');
|
|
420
|
+
}
|
|
378
421
|
const published = results(view);
|
|
379
422
|
const error = published?.error ?? view.error;
|
|
380
423
|
if (error && status !== 'completed')
|
|
@@ -398,7 +441,9 @@ export function formatCrawl(view) {
|
|
|
398
441
|
lines.push(' No changed spots were recorded.');
|
|
399
442
|
for (const spot of published.reach.spots)
|
|
400
443
|
lines.push(...spotLines(spot));
|
|
401
|
-
lines.push('', chalk.bold('What the crawl found'));
|
|
444
|
+
lines.push('', chalk.bold(broken === null ? 'What the crawl found' : 'What the crawl found in the broken copy'));
|
|
445
|
+
if (broken !== null)
|
|
446
|
+
lines.push(chalk.yellow(' The copy was broken, so these differences are the environment\'s, not evidence about your change.'));
|
|
402
447
|
// Bugs first, then the undecided, then what the judge called intended.
|
|
403
448
|
const findings = VERDICTS.flatMap(verdict => published.findings.filter(finding => finding.verdict === verdict));
|
|
404
449
|
if (findings.length === 0)
|
|
@@ -462,12 +507,16 @@ export function formatCrawl(view) {
|
|
|
462
507
|
* is done and only its shutdown remains), or still running with --no-wait;
|
|
463
508
|
* 2 ended without finishing (incomplete, cancelled) or finished with cleanup
|
|
464
509
|
* unproven (an answered crawl has not finished its cleanup, so it is not judged); bugs
|
|
465
|
-
* found never change it.
|
|
510
|
+
* found never change it. 5 (amendment 22): it finished, but the copy of the app was
|
|
511
|
+
* broken, so the change was not tested; it wins over unproven cleanup, since what the
|
|
512
|
+
* agent does next is fix how the app runs. The command's own failures exit 1 elsewhere. */
|
|
466
513
|
export { verifyReport };
|
|
467
514
|
export function crawlExitCode(view) {
|
|
468
515
|
const status = reportedStatus(view);
|
|
469
516
|
if (!TERMINAL.has(status))
|
|
470
517
|
return 0;
|
|
518
|
+
if (status === 'completed' && brokenCopy(view) !== null)
|
|
519
|
+
return 5;
|
|
471
520
|
if (status !== 'completed' || (TERMINAL.has(view.status) && view.totals.cleanupUnproven > 0))
|
|
472
521
|
return 2;
|
|
473
522
|
return 0;
|
|
@@ -658,6 +707,8 @@ async function submitCapture(capture, token, found) {
|
|
|
658
707
|
// sender does: admission is idempotent for one capture, so a repeat can only find what the first created.
|
|
659
708
|
const acknowledgment = await readWithRetries(() => postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS)).catch((error) => { throw normalModeFailure(error); });
|
|
660
709
|
const crawl = acknowledgment.crawl;
|
|
710
|
+
// Amendment 12: the handshake submits mode 'prepare', which builds and prepares the change and never starts a crawl.
|
|
711
|
+
const preparing = capture.derivation.request.crawl?.mode === 'prepare';
|
|
661
712
|
const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
|
|
662
713
|
if (crawl === undefined) {
|
|
663
714
|
return { runId: null, onboarding: null,
|
|
@@ -669,14 +720,14 @@ async function submitCapture(capture, token, found) {
|
|
|
669
720
|
}
|
|
670
721
|
if (crawl.status === 'onboarding') {
|
|
671
722
|
return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded yet: `
|
|
672
|
-
+ `the service is preparing it (${crawl.onboardRunId}) and
|
|
723
|
+
+ `the service is preparing it (${crawl.onboardRunId}) and continues with this change when it is ready.` };
|
|
673
724
|
}
|
|
674
725
|
if (crawl.status === 'onboarding-blocked') {
|
|
675
726
|
return { runId: null, onboarding: 'onboarding-blocked', line: `${before}, and the app's onboarding is blocked.` };
|
|
676
727
|
}
|
|
677
728
|
if (found === null) {
|
|
678
729
|
return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
|
|
679
|
-
? `No crawl existed for this change yet; started one (${crawl.runId}).`
|
|
730
|
+
? `No crawl existed for this change yet; ${preparing ? 'started preparing it' : 'started one'} (${crawl.runId}).`
|
|
680
731
|
: `The service already had a crawl for this change (${crawl.runId}).` };
|
|
681
732
|
}
|
|
682
733
|
if (!anotherCrawl(found.stale) && crawl.runId === found.view.runId) {
|
|
@@ -684,7 +735,7 @@ async function submitCapture(capture, token, found) {
|
|
|
684
735
|
? `${before}; checking it now, reusing what was built.` : `${before}; asked the service to run it again.` };
|
|
685
736
|
}
|
|
686
737
|
return { runId: crawl.runId, onboarding: null, line: crawl.status === 'queued'
|
|
687
|
-
? `${before}; started one for this change (${crawl.runId}).`
|
|
738
|
+
? `${before}; ${preparing ? 'started preparing the change again' : 'started one for this change'} (${crawl.runId}).`
|
|
688
739
|
: `${before}; the service already had one for this change (${crawl.runId}).` };
|
|
689
740
|
}
|
|
690
741
|
function checkedChange(baseCommit, workCommit) {
|
|
@@ -782,6 +833,129 @@ function describeEstimate(estimate) {
|
|
|
782
833
|
}
|
|
783
834
|
return lines;
|
|
784
835
|
}
|
|
836
|
+
/** A flow's captured share in one line: per app with routes for it; "no captured route" where its files serve none. */
|
|
837
|
+
function flowShare(flow) {
|
|
838
|
+
if (flow.capture === null)
|
|
839
|
+
return '';
|
|
840
|
+
const parts = flow.capture.map(app => {
|
|
841
|
+
const where = app.app === '.' ? '' : `${safe(app.app)}: `;
|
|
842
|
+
if (app.state === 'unavailable')
|
|
843
|
+
return `${where}captured sessions unreadable`;
|
|
844
|
+
if (app.state === 'no-data')
|
|
845
|
+
return `${where}no captured sessions yet`;
|
|
846
|
+
if (app.routes.routes.length === 0)
|
|
847
|
+
return `${where}no captured route`;
|
|
848
|
+
return `${where}${app.routes.union.share === null ? 'n/a' : percent(app.routes.union.share)} of captured sessions (${app.routes.union.sessions})`
|
|
849
|
+
+ `${app.routes.stale ? ', stale' : ''}`;
|
|
850
|
+
});
|
|
851
|
+
return parts.join('; ');
|
|
852
|
+
}
|
|
853
|
+
/** Amendment 21: the flows a change touches, first in the handshake, with how to see one. */
|
|
854
|
+
export function formatFlows(flows) {
|
|
855
|
+
const lines = [chalk.bold(`Your change touches ${plural(flows.length, 'flow')}:`)];
|
|
856
|
+
for (const flow of flows) {
|
|
857
|
+
const share = flowShare(flow);
|
|
858
|
+
lines.push(` ${flow.number}. ${safe(flow.name)}: ${plural(flow.files.length, 'file')}, ${plural(flow.functions, 'function')}`
|
|
859
|
+
+ `${share ? `; ${share}` : ''}`);
|
|
860
|
+
}
|
|
861
|
+
lines.push(`See one in detail: haystack verify flow <n> (for example \`haystack verify flow 1\`).`);
|
|
862
|
+
return lines.join('\n');
|
|
863
|
+
}
|
|
864
|
+
const SHOWN_FLOW_FUNCTIONS = 40;
|
|
865
|
+
const SHOWN_FLOW_DEVICES = 5;
|
|
866
|
+
const SHOWN_FLOW_JOURNEYS = 5;
|
|
867
|
+
function journeyStep(step) {
|
|
868
|
+
if (step.kind === 'view')
|
|
869
|
+
return `view ${safe(step.route)}`;
|
|
870
|
+
if (step.kind === 'input')
|
|
871
|
+
return `input (${safe(step.control)}) on ${safe(step.route)}`;
|
|
872
|
+
const control = step.kind === 'press' ? `${safe(step.control)} ` : '';
|
|
873
|
+
return `${step.kind} ${control}${step.ambiguous ? '(a control that repeats on the page)' : `"${safe(step.key)}"`} on ${safe(step.route)}`;
|
|
874
|
+
}
|
|
875
|
+
/** Amendment 21: `haystack verify flow <n>`: one flow's code, the pages it shows on with real use, what its code saw in production,
|
|
876
|
+
* and Haystack's ideas for it. */
|
|
877
|
+
export function formatFlowDetail(detail) {
|
|
878
|
+
const lines = [chalk.bold(`Flow ${detail.number}: ${safe(detail.name)}`), '', chalk.bold('Code (changed first, then what it calls or what calls it)')];
|
|
879
|
+
for (const fn of detail.functions.slice(0, SHOWN_FLOW_FUNCTIONS)) {
|
|
880
|
+
lines.push(` ${safe(fn.file)}:${fn.lines[0]}-${fn.lines[1]} ${safe(fn.name)}: `
|
|
881
|
+
+ `${fn.direction === 'changed' ? 'changed' : `${fn.direction}, ${plural(fn.distance, 'step')} away`}`);
|
|
882
|
+
}
|
|
883
|
+
if (detail.functions.length > SHOWN_FLOW_FUNCTIONS)
|
|
884
|
+
lines.push(` and ${plural(detail.functions.length - SHOWN_FLOW_FUNCTIONS, 'more function')} (all in --json)`);
|
|
885
|
+
lines.push('', chalk.bold('Pages it shows on (real use)'));
|
|
886
|
+
if (detail.capture === null)
|
|
887
|
+
lines.push(' Capture is not set up for this repository: run `haystack init` to see how real users reach these pages.');
|
|
888
|
+
else
|
|
889
|
+
for (const app of detail.capture) {
|
|
890
|
+
const where = app.app === '.' ? 'the app' : safe(app.app);
|
|
891
|
+
if ('state' in app && app.state === 'unavailable') {
|
|
892
|
+
lines.push(` ${where}: captured sessions could not be read: ${safe(app.reason)}`);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if ('state' in app && app.state === 'no-data') {
|
|
896
|
+
lines.push(` ${where}: no captured sessions yet.`);
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const shown = app;
|
|
900
|
+
const window = shown.window;
|
|
901
|
+
const days = window.days.length ? `${window.days[0]} to ${window.days.at(-1)}` : 'the window';
|
|
902
|
+
lines.push(` ${where}: ${window.sessions} captured session(s), ${days}; newest data ${window.newestHour === null ? 'none' : `${window.newestHour}:00 UTC`}`
|
|
903
|
+
+ `${window.stale ? ` (stale: ${window.ageHours === null ? 'no data' : `${Math.round(window.ageHours)} hours old`})` : ''}.`);
|
|
904
|
+
const dropped = Object.entries(window.dropped).filter(([, count]) => count > 0);
|
|
905
|
+
const drops = dropped.length ? [` Events dropped at ingest: ${dropped.map(([reason, count]) => `${safe(reason)} ${count}`).join(', ')}.`] : [];
|
|
906
|
+
if (shown.routes.length === 0) {
|
|
907
|
+
lines.push(' No captured route: none of this flow\'s files defines a route captured sessions reached.', ...drops);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
for (const route of shown.routes) {
|
|
911
|
+
lines.push(` ${safe(route.match)} (${safe(route.source)}): ${route.share === null ? 'n/a' : percent(route.share)} of captured sessions (${route.sessions})`);
|
|
912
|
+
if (route.devices.length) {
|
|
913
|
+
lines.push(` Devices: ${route.devices.slice(0, SHOWN_FLOW_DEVICES).map(device => `${safe(device.device)}/${safe(device.engine)}/${safe(device.viewport)} `
|
|
914
|
+
+ `(${device.sessions})`).join(', ')}${route.devices.length > SHOWN_FLOW_DEVICES ? `, ${route.devices.length - SHOWN_FLOW_DEVICES} more (in --json)` : ''}`);
|
|
915
|
+
}
|
|
916
|
+
for (const journey of route.journeys.slice(0, SHOWN_FLOW_JOURNEYS)) {
|
|
917
|
+
lines.push(` Journey (${journey.sessions} sessions, release ${safe(journey.release)}): ${journey.steps.map(journeyStep).join(' > ')}`);
|
|
918
|
+
}
|
|
919
|
+
if (route.journeys.length > SHOWN_FLOW_JOURNEYS)
|
|
920
|
+
lines.push(` and ${plural(route.journeys.length - SHOWN_FLOW_JOURNEYS, 'more journey')} (in --json)`);
|
|
921
|
+
}
|
|
922
|
+
if (shown.routes.length > 1) {
|
|
923
|
+
lines.push(` Together: ${window.union.share === null ? 'n/a' : percent(window.union.share)} of captured sessions (${window.union.sessions}) visited at least one of them.`);
|
|
924
|
+
}
|
|
925
|
+
lines.push(...drops);
|
|
926
|
+
}
|
|
927
|
+
if (detail.capture !== null)
|
|
928
|
+
lines.push(' Shares are of captured (consenting) sessions only, never of users. Journey keys are hints scoped to their release.');
|
|
929
|
+
lines.push('', chalk.bold('What its code saw in production'));
|
|
930
|
+
if (detail.production === null)
|
|
931
|
+
lines.push(' No production telemetry for this repository: set it up to see how this code runs for real users.');
|
|
932
|
+
else if (detail.production.functions.length === 0)
|
|
933
|
+
lines.push(` None of these functions was seen running in the last ${detail.production.windowDays} days.`);
|
|
934
|
+
else {
|
|
935
|
+
const byIndex = new Map(detail.functions.map(fn => [fn.index, fn]));
|
|
936
|
+
for (const row of detail.production.functions) {
|
|
937
|
+
const fn = byIndex.get(row.index);
|
|
938
|
+
lines.push(` ${safe(fn.name)} (${safe(fn.file)}):`);
|
|
939
|
+
for (const branch of row.branches)
|
|
940
|
+
lines.push(` in production: line ${branch.line} was true ${branch.whenTrue}${branch.capped ? '+' : ''} times,`
|
|
941
|
+
+ ` false ${branch.whenFalse}${branch.capped ? '+' : ''} times`);
|
|
942
|
+
for (const observed of row.values)
|
|
943
|
+
lines.push(` in production: line ${observed.line} ${safe(observed.what)} was `
|
|
944
|
+
+ observed.shapes.map(shape => `${safe(shape.shape)} (${shape.count})`).join(', '));
|
|
945
|
+
for (const estimate of row.estimates ?? [])
|
|
946
|
+
lines.push(...describeEstimate(estimate));
|
|
947
|
+
}
|
|
948
|
+
lines.push(` The last ${detail.production.windowDays} days, newest ${safe(detail.production.newestAt)}; shares are of observed calls, never of users`
|
|
949
|
+
+ `${detail.production.partial ? '; the read stopped at its budget, so every count is a lower bound' : ''}.`);
|
|
950
|
+
}
|
|
951
|
+
lines.push('', chalk.bold('What could break here'));
|
|
952
|
+
if (detail.ideas.length === 0)
|
|
953
|
+
lines.push(' No idea of ours was selected for this flow.');
|
|
954
|
+
for (const idea of detail.ideas) {
|
|
955
|
+
lines.push(` - Try: ${safe(idea.exercise)}`, ...(idea.setup.length ? [` Set up first: ${idea.setup.map(safe).join('; ')}`] : []), ` Watch: ${safe(idea.watch)}`, ` It would show as: ${safe(idea.failure)}`);
|
|
956
|
+
}
|
|
957
|
+
return lines.join('\n');
|
|
958
|
+
}
|
|
785
959
|
/** The brief as a coding agent reads it: what the change touches, then Haystack's own ideas, then how to hand back intent and
|
|
786
960
|
* ideas. Every function is in --json; the text shows the changed ones and those one step away, and says how many more. */
|
|
787
961
|
export function formatBrief(brief) {
|
|
@@ -844,16 +1018,24 @@ export function formatBrief(brief) {
|
|
|
844
1018
|
}
|
|
845
1019
|
return lines.join('\n');
|
|
846
1020
|
}
|
|
847
|
-
/** The handshake's question, asked after everything it shows. */
|
|
848
1021
|
/** The command to steer with; it keeps the options the handshake was given that choose the crawl (where, and for how long). */
|
|
849
1022
|
export function steerCommand(options) {
|
|
850
1023
|
const kept = [options.repo ? ` --repo ${options.repo}` : '', options.account ? ` --account ${options.account}` : '',
|
|
851
1024
|
options.minutes ? ` --minutes ${options.minutes}` : ''].join('');
|
|
852
1025
|
return `haystack verify${kept} --intent "<what you were asked to do>" --idea "<something to try>" [--idea ...]`;
|
|
853
1026
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1027
|
+
/** The handshake's question, asked after everything it shows; it speaks of "the ideas above" only when it showed some. */
|
|
1028
|
+
export function steerLines(command, shownIdeas) {
|
|
1029
|
+
return shownIdeas
|
|
1030
|
+
? ['', 'Next: pick the ideas worth trying, add your own, and say what you were asked to do (the task in the user\'s words, not what your code does):',
|
|
1031
|
+
` ${command}`, 'Your ideas are explored first; the ideas above are explored in every crawl too.']
|
|
1032
|
+
: ['', 'Next: say what you were asked to do (the task in the user\'s words, not what your code does) and what to try:',
|
|
1033
|
+
` ${command}`, 'Your ideas are explored first.'];
|
|
1034
|
+
}
|
|
1035
|
+
/** Why a run stopped, as its report says it: the published error, else the run's own. */
|
|
1036
|
+
function stoppedBecause(view) {
|
|
1037
|
+
return results(view)?.error ?? view.error ?? null;
|
|
1038
|
+
}
|
|
857
1039
|
/** The handshake (Akshay, 10/7): a plain `haystack verify` on a change no crawl has started for shows what the change touches,
|
|
858
1040
|
* how production and real users run it, and Haystack's own ideas, then asks the coding agent to run `haystack verify` again
|
|
859
1041
|
* with --intent (and any --idea). It submits the capture in mode 'prepare' when no run of it is preparing, prepared or
|
|
@@ -862,9 +1044,11 @@ const steerLines = (command) => ['',
|
|
|
862
1044
|
async function handshake(capture, identity, change, found, stale, token, options, note) {
|
|
863
1045
|
let view = found;
|
|
864
1046
|
let onboarding = null;
|
|
865
|
-
const finish = (brief, captured) => {
|
|
1047
|
+
const finish = (brief, captured, flows) => {
|
|
1048
|
+
const error = view === null ? null : stoppedBecause(view);
|
|
866
1049
|
printVerifyJson(options, change, onboarding, null, {
|
|
867
|
-
run: view === null ? null : { runId: view.runId, status: view.status
|
|
1050
|
+
run: view === null ? null : { runId: view.runId, status: view.status, error: error ? { code: error.code, message: error.message } : null },
|
|
1051
|
+
brief, capture: captured, flows, next: steerCommand(options),
|
|
868
1052
|
});
|
|
869
1053
|
};
|
|
870
1054
|
if (view === null || (stale !== null && stale !== 'prepare-only')) {
|
|
@@ -901,24 +1085,36 @@ async function handshake(capture, identity, change, found, stale, token, options
|
|
|
901
1085
|
view = await waitForBrief(view, identity, token);
|
|
902
1086
|
const brief = briefOf(view);
|
|
903
1087
|
// CAPTURE-V1 rule 7b: the routes the change touches, with their share of captured sessions, per app; null without capture set up.
|
|
904
|
-
|
|
1088
|
+
// Amendment 21: each flow's routes from the same windows.
|
|
1089
|
+
const now = new Date();
|
|
1090
|
+
const windows = await readCaptureWindows(findGitRoot(), identity.repository, token);
|
|
1091
|
+
const captured = preVerifyCapture(windows, [...new Set([...(brief?.functions.map(fn => fn.file) ?? []), ...change.files])], now);
|
|
1092
|
+
const flows = brief === null ? [] : flowSummaries(brief, windows, now);
|
|
905
1093
|
process.exitCode = 4;
|
|
906
1094
|
if (options.json || options.raw) {
|
|
907
|
-
finish(brief, captured);
|
|
1095
|
+
finish(brief, captured, flows);
|
|
908
1096
|
return;
|
|
909
1097
|
}
|
|
1098
|
+
if (flows.length)
|
|
1099
|
+
console.log(`${formatFlows(flows)}\n`);
|
|
910
1100
|
if (brief !== null)
|
|
911
1101
|
console.log(formatBrief(brief));
|
|
912
1102
|
else if (!TERMINAL.has(reportedStatus(view))) {
|
|
913
1103
|
console.log(`What your change touches is not worked out yet (${currentStep(view)}); run \`haystack verify\` again to see it, or steer now.`);
|
|
914
1104
|
}
|
|
915
1105
|
else {
|
|
916
|
-
|
|
1106
|
+
// A failed prepare leaves no brief (amendment 18); the agent sees why, and that a steered crawl runs those steps again.
|
|
1107
|
+
const error = stoppedBecause(view);
|
|
1108
|
+
console.log(`Run ${view.runId} ended (${view.status}) without a brief${error ? `: ${errorWords(error)}` : ''}.`);
|
|
1109
|
+
if (error) {
|
|
1110
|
+
console.log(` Details: ${safe(error.message)}`);
|
|
1111
|
+
console.log('A steered crawl runs these steps again, so it may stop the same way; if it does, tell us with `haystack feedback`.');
|
|
1112
|
+
}
|
|
917
1113
|
}
|
|
918
1114
|
if (captured !== null)
|
|
919
1115
|
for (const section of captured)
|
|
920
1116
|
console.log(`\n${formatCapture(section)}`);
|
|
921
|
-
console.log(steerLines(steerCommand(options)).join('\n'));
|
|
1117
|
+
console.log(steerLines(steerCommand(options), (brief?.planning?.ideas.length ?? 0) > 0).join('\n'));
|
|
922
1118
|
}
|
|
923
1119
|
/** verify's one JSON document: the report (`--json`) or the service's whole record (`--raw`). */
|
|
924
1120
|
function printVerifyJson(options, change, onboarding, view, handshake) {
|
|
@@ -928,6 +1124,40 @@ function printVerifyJson(options, change, onboarding, view, handshake) {
|
|
|
928
1124
|
: withSchema('verify', { change, onboarding, crawl: view === null ? null : verifyReport(view), ...extra });
|
|
929
1125
|
process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
|
|
930
1126
|
}
|
|
1127
|
+
/** Amendment 21: `haystack verify flow <n>`: one flow of this checkout's change, from the brief of the change's run (never starting
|
|
1128
|
+
* one). Exit 0 with the flow; 1 when there is no run, no brief, no flows or no flow with that number, saying which. */
|
|
1129
|
+
export async function verifyFlowCommand(number, options) {
|
|
1130
|
+
const wanted = Number(number);
|
|
1131
|
+
if (!Number.isInteger(wanted) || wanted < 1)
|
|
1132
|
+
throw new Error(`A flow's number counts from 1, as \`haystack verify\` lists them (got ${JSON.stringify(number)}).`);
|
|
1133
|
+
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'prepare', repositoryOverride(options.repo), undefined, undefined, {});
|
|
1134
|
+
const request = capture.derivation.request;
|
|
1135
|
+
if (!request.crawl)
|
|
1136
|
+
throw new Error(`Nothing to show: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
|
|
1137
|
+
const identity = {
|
|
1138
|
+
repository: `${request.owner}/${request.repository}`, baseCommit: request.baseCommit, workCommit: request.workCommit,
|
|
1139
|
+
treeSha: request.crawl.treeSha, changeTitle: request.crawl.changeTitle, budgetMs: request.crawl.budgetMs ?? null,
|
|
1140
|
+
};
|
|
1141
|
+
const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
|
|
1142
|
+
const view = await readWithRetries(() => readCrawl(identity, auth.token));
|
|
1143
|
+
if (view === null)
|
|
1144
|
+
throw new Error('This change has no run yet: run `haystack verify` first, which lists its flows.');
|
|
1145
|
+
const brief = briefOf(view);
|
|
1146
|
+
if (brief === null)
|
|
1147
|
+
throw new Error(`Run ${view.runId} has no brief yet (${currentStep(view)}): run \`haystack verify\` to wait for it.`);
|
|
1148
|
+
if (!brief.flows?.length) {
|
|
1149
|
+
throw new Error(`Run ${view.runId}'s brief has no flows${brief.planning === null ? ' (no planning ran for this change)' : ''}; \`haystack verify\` shows what it has.`);
|
|
1150
|
+
}
|
|
1151
|
+
const windows = await readCaptureWindows(findGitRoot(), identity.repository, auth.token);
|
|
1152
|
+
const detail = flowDetail(brief, wanted, windows, new Date());
|
|
1153
|
+
if (detail === null)
|
|
1154
|
+
throw new Error(`This change has ${plural(brief.flows.length, 'flow')}; there is no flow ${wanted}.`);
|
|
1155
|
+
if (options.json) {
|
|
1156
|
+
process.stdout.write(`${JSON.stringify(withSchema('verify-flow', { run: { runId: view.runId, status: view.status }, flow: detail }), null, 2)}\n`);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
console.log(formatFlowDetail(detail));
|
|
1160
|
+
}
|
|
931
1161
|
export async function verifyCommand(options) {
|
|
932
1162
|
const json = options.json || options.raw;
|
|
933
1163
|
// In --json mode stdout carries the one document; every other line goes to stderr.
|
package/dist/index.js
CHANGED
|
@@ -120,7 +120,8 @@ an hour). The file is JSON with any of three notes, each plain text:
|
|
|
120
120
|
Leave out what you do not know. Onboarding still proves everything it takes
|
|
121
121
|
from the notes. The app is onboarded once: notes written before that feed its
|
|
122
122
|
onboarding (an onboarding that stopped on a question starts again with them);
|
|
123
|
-
after it,
|
|
123
|
+
after it, the next check that cannot start the app uses them to update the
|
|
124
|
+
setup in place.
|
|
124
125
|
|
|
125
126
|
Every run also plans telemetry for the app: --app names it when the repository
|
|
126
127
|
has several (init never picks one), --origin its production origins and
|
|
@@ -177,10 +178,12 @@ const verify = program
|
|
|
177
178
|
Two calls, run inside a git checkout (nothing needs to be committed or pushed):
|
|
178
179
|
|
|
179
180
|
1. haystack verify
|
|
180
|
-
The handshake. It shows what your change touches
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
The handshake. It shows what your change touches (first the flows: the
|
|
182
|
+
behaviors it changes, each with its files and its share of real use), how
|
|
183
|
+
production and real users run it, and Haystack's own ideas of what could
|
|
184
|
+
break, then asks what you were asked to do and what to try. It builds and
|
|
185
|
+
prepares the change if the stop hook has not, and never starts a crawl.
|
|
186
|
+
Exit 4. \`haystack verify flow <n>\` drills into one flow.
|
|
184
187
|
2. haystack verify --intent "<the task, in the user's words>" --idea "<something to try>"
|
|
185
188
|
The crawl, steered: your ideas (--idea, repeat for more, or --ideas-file)
|
|
186
189
|
are explored before anything else, and the judge checks the app against
|
|
@@ -207,6 +210,16 @@ changed spot, how far the crawl got with it, and the steps that reached it),
|
|
|
207
210
|
what the crawl found (bugs first, with the steps to see each), and the changed
|
|
208
211
|
code it never ran.
|
|
209
212
|
|
|
213
|
+
When the copy of the app was itself broken (error popups or messages from its
|
|
214
|
+
own setup, its own requests failing in both builds), the crawl could not test
|
|
215
|
+
your change, and it says so first: "We couldn't test your change: the app copy
|
|
216
|
+
is broken.", why, what the start screen showed, the requests that failed and
|
|
217
|
+
the console errors, and what to do: find what those errors need in the
|
|
218
|
+
repository's own setup (docker compose files, env examples, docs), send that
|
|
219
|
+
with \`haystack feedback\` (a copy that starts but is broken does not update its
|
|
220
|
+
setup yet), and tell your user the change was not tested. What the crawl
|
|
221
|
+
found in the broken copy is listed after it, marked as such.
|
|
222
|
+
|
|
210
223
|
The first verify on a base the service has not onboarded yet prepares the app
|
|
211
224
|
(reads the repository, plans how to run it, builds its runtime, starts it,
|
|
212
225
|
prepares its data and test accounts, proves a workflow); the command shows each
|
|
@@ -223,6 +236,8 @@ Exit codes:
|
|
|
223
236
|
stopped before finishing
|
|
224
237
|
3 onboarding is blocked: the output says what would unblock it
|
|
225
238
|
4 the handshake: run it again with --intent (and any --idea)
|
|
239
|
+
5 the crawl finished, but the copy of the app was broken, so your change
|
|
240
|
+
was not tested: the output says what to do
|
|
226
241
|
|
|
227
242
|
The hosted fleet run of pushed commits is \`haystack verify hosted start\`.
|
|
228
243
|
|
|
@@ -257,6 +272,32 @@ function verifyCommandOptions(command) {
|
|
|
257
272
|
}
|
|
258
273
|
return options;
|
|
259
274
|
}
|
|
275
|
+
verify
|
|
276
|
+
.command('flow')
|
|
277
|
+
.description('Drill into one flow `haystack verify` listed: its code, the pages it shows on, what its code saw in production')
|
|
278
|
+
.argument('<n>', 'The flow\'s number, as `haystack verify` lists them (from 1)')
|
|
279
|
+
.option('--repo <owner/repo>', 'GitHub repository (default: origin remote)')
|
|
280
|
+
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
281
|
+
.option('--json', 'The flow as one JSON document (see `haystack schema verify-flow`)')
|
|
282
|
+
.addHelpText('after', `
|
|
283
|
+
Reads the brief of this checkout's run (run \`haystack verify\` first); it never
|
|
284
|
+
starts or changes a run. For one flow it shows:
|
|
285
|
+
- its functions: file, lines, and whether each changed or is a step away;
|
|
286
|
+
- the pages it shows on, per app with capture set up: each page's share of
|
|
287
|
+
captured sessions, the devices they used and the paths they took through it,
|
|
288
|
+
with how fresh the data is and what was dropped;
|
|
289
|
+
- what its functions saw in production (Haystack's own telemetry);
|
|
290
|
+
- Haystack's ideas of what could break in it.
|
|
291
|
+
|
|
292
|
+
Examples:
|
|
293
|
+
haystack verify flow 1
|
|
294
|
+
haystack verify flow 2 --json
|
|
295
|
+
`)
|
|
296
|
+
.action(async (n, _options, cmd) => {
|
|
297
|
+
const options = verifyCommandOptions(cmd);
|
|
298
|
+
const { verifyFlowCommand } = await import('./commands/verify.js');
|
|
299
|
+
return runPublicCommand(() => verifyFlowCommand(n, options), options.json);
|
|
300
|
+
});
|
|
260
301
|
verify
|
|
261
302
|
.command('answer')
|
|
262
303
|
.description('Answer a question onboarding asked about your app, then run `haystack init` to continue')
|
package/dist/schema.js
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
export const SCHEMA_VERSIONS = {
|
|
13
13
|
'cloud-verifier': '1.0.0',
|
|
14
14
|
'case-batch': '1.0.1',
|
|
15
|
-
verify: '2.
|
|
16
|
-
'verify-
|
|
15
|
+
verify: '2.3.0',
|
|
16
|
+
'verify-flow': '1.0.0',
|
|
17
|
+
'verify-raw': '1.3.0',
|
|
17
18
|
'verify-answer': '1.0.1',
|
|
18
19
|
'verify-onboarding': '1.0.0',
|
|
19
20
|
init: '1.0.2',
|