@haystackeditor/cli 0.23.3 → 0.24.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 +4 -1
- package/dist/assets/telemetry/runtime.cjs +8 -0
- package/dist/commands/crawl-contract.js +8 -0
- package/dist/commands/init.js +30 -8
- package/dist/commands/onboarding-notes.js +63 -0
- package/dist/commands/telemetry.d.ts +154 -0
- package/dist/commands/telemetry.js +194 -23
- package/dist/commands/verify-onboarding.js +24 -0
- package/dist/commands/verify-precompute.js +9 -4
- package/dist/commands/verify.js +264 -4
- package/dist/index.js +57 -2
- package/dist/schema.js +4 -3
- package/dist/telemetry/next-loader.cjs +93 -0
- package/dist/telemetry/next.d.ts +33 -0
- package/dist/telemetry/next.js +168 -0
- package/dist/utils/haystack-api.js +14 -9
- package/package.json +9 -2
- package/schemas/init.v1.json +4 -3
- package/schemas/pre-verify.v1.json +178 -0
- package/schemas/verify-answer.v1.json +8 -1
- package/schemas/verify.v1.json +22 -1
package/dist/commands/verify.js
CHANGED
|
@@ -22,13 +22,14 @@
|
|
|
22
22
|
* A blocked onboarding prints why and exactly what would unblock it.
|
|
23
23
|
*/
|
|
24
24
|
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
25
26
|
import chalk from 'chalk';
|
|
26
27
|
import { withSchema } from '../schema.js';
|
|
27
28
|
import { findGitRoot } from '../utils/hooks.js';
|
|
28
29
|
import { resolveAuthContext } from '../utils/auth.js';
|
|
29
30
|
import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSilentError, transientReadFailure, } from '../utils/haystack-api.js';
|
|
30
31
|
import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
|
|
31
|
-
import { CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.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
33
|
import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
|
|
33
34
|
import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, standInGapNotes, waitForOnboarding } from './verify-onboarding.js';
|
|
34
35
|
const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
|
|
@@ -219,9 +220,51 @@ function checkResults(value, view, what) {
|
|
|
219
220
|
invalid('its outside-service coverage');
|
|
220
221
|
if (Object.hasOwn(reach, 'planning'))
|
|
221
222
|
checkPlanningCoverage(reach.planning);
|
|
223
|
+
// Amendment 17: each idea of the request, in its order, naming only findings these results carry.
|
|
224
|
+
const ideas = value.ideas;
|
|
225
|
+
const findingIds = new Set(value.findings.map(finding => finding.id));
|
|
226
|
+
// Texts as the worker bounds them: nonempty, at most CRAWL_MAX_TEXT_CHARS code points (Astra).
|
|
227
|
+
const bounded = (value) => typeof value === 'string' && value.length > 0 && [...value].length <= CRAWL_MAX_TEXT_CHARS;
|
|
228
|
+
if (ideas !== undefined && (!Array.isArray(ideas) || ideas.length === 0 || ideas.length > CRAWL_MAX_IDEAS || !ideas.every((idea, index) => isRecord(idea)
|
|
229
|
+
&& idea.id === `idea-${index + 1}` && bounded(idea.text) && bounded(idea.reason)
|
|
230
|
+
&& (idea.state === 'covered' || idea.state === 'blocked' || idea.state === 'unfinished')
|
|
231
|
+
&& isStrings(idea.findingIds, CRAWL_MAX_FINDINGS) && idea.findingIds.every(id => findingIds.has(id)))))
|
|
232
|
+
invalid('its ideas');
|
|
233
|
+
if (value.brief !== undefined)
|
|
234
|
+
checkBrief(value.brief);
|
|
222
235
|
if (value.error !== null)
|
|
223
236
|
checkError(value.error, 'its error');
|
|
224
237
|
}
|
|
238
|
+
/** Amendment 18: a brief's shape and bounds, as the worker checks them. */
|
|
239
|
+
function checkBrief(value) {
|
|
240
|
+
const text = (item, empty = false) => typeof item === 'string' && (empty || item.length > 0) && [...item].length <= CRAWL_MAX_TEXT_CHARS;
|
|
241
|
+
const texts = (item, maximum) => Array.isArray(item) && item.length <= maximum && item.every(entry => text(entry));
|
|
242
|
+
const records = (item, maximum, each) => Array.isArray(item) && item.length <= maximum && item.every(row => isRecord(row) && each(row));
|
|
243
|
+
if (!isRecord(value) || !records(value.functions, CRAWL_BRIEF_MAX_FUNCTIONS, fn => text(fn.file) && text(fn.name, true)
|
|
244
|
+
&& Array.isArray(fn.lines) && fn.lines.length === 2 && fn.lines.every(isCount) && isCount(fn.distance)
|
|
245
|
+
&& (fn.direction === 'changed' || fn.direction === 'upstream' || fn.direction === 'downstream'))
|
|
246
|
+
|| !records(value.leftOut, CRAWL_BRIEF_MAX_GROUPS, row => text(row.reason) && isCount(row.count)))
|
|
247
|
+
invalid('its brief');
|
|
248
|
+
const planning = value.planning;
|
|
249
|
+
if (planning !== null && (!isRecord(planning)
|
|
250
|
+
|| !records(planning.groups, CRAWL_BRIEF_MAX_GROUPS, group => text(group.id) && texts(group.changeKeys, CRAWL_BRIEF_MAX_FUNCTIONS)
|
|
251
|
+
&& records(group.patterns, CRAWL_BRIEF_MAX_GROUPS, pattern => text(pattern.entryId) && text(pattern.mechanism) && text(pattern.symptom, true)))
|
|
252
|
+
|| !records(planning.unresolved, CRAWL_BRIEF_MAX_GROUPS, change => text(change.changeKey) && text(change.reason))
|
|
253
|
+
|| !records(planning.ideas, CRAWL_MAX_IDEAS, idea => ['id', 'groupId', 'pattern', 'why', 'failure', 'exercise', 'watch', 'start']
|
|
254
|
+
.every(field => text(idea[field])) && texts(idea.setup, CRAWL_MAX_IDEAS))))
|
|
255
|
+
invalid('its brief');
|
|
256
|
+
// Amendment 19: each production row names a function the brief holds.
|
|
257
|
+
const production = value.production;
|
|
258
|
+
const line = (item) => isCount(item) && item >= 1;
|
|
259
|
+
if (production !== undefined && (!isRecord(production) || !isCount(production.windowDays) || !isCount(production.payloads)
|
|
260
|
+
|| !text(production.newestAt, true) || typeof production.partial !== 'boolean' || !records(production.functions, CRAWL_BRIEF_MAX_FUNCTIONS, fn => isCount(fn.index)
|
|
261
|
+
&& fn.index < value.functions.length && isCount(fn.branchesOmitted) && isCount(fn.valuesOmitted)
|
|
262
|
+
&& records(fn.branches, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, branch => line(branch.line) && isCount(branch.whenTrue)
|
|
263
|
+
&& isCount(branch.whenFalse) && typeof branch.capped === 'boolean')
|
|
264
|
+
&& records(fn.values, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, observed => line(observed.line) && text(observed.what)
|
|
265
|
+
&& records(observed.shapes, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, shape => text(shape.shape) && isCount(shape.count))))))
|
|
266
|
+
invalid('its brief');
|
|
267
|
+
}
|
|
225
268
|
function checkManifest(value, view) {
|
|
226
269
|
checkResults(value, view, 'its results');
|
|
227
270
|
if (value.status !== view.status)
|
|
@@ -451,6 +494,15 @@ export function formatCrawl(view) {
|
|
|
451
494
|
lines.push(` ${plural(published.notFinishedInTime, 'difference')} ${published.notFinishedInTime === 1 ? 'was' : 'were'}`
|
|
452
495
|
+ ' still being checked when its time ran out.');
|
|
453
496
|
}
|
|
497
|
+
// Amendment 17: what became of each idea the coding agent gave, with the numbers of the findings above that came from it.
|
|
498
|
+
if (published.ideas !== undefined) {
|
|
499
|
+
const numbered = new Map(findings.map((finding, index) => [finding.id, index + 1]));
|
|
500
|
+
lines.push('', chalk.bold('Your ideas'));
|
|
501
|
+
for (const idea of published.ideas) {
|
|
502
|
+
const found = idea.findingIds.map(id => numbered.get(id)).filter((n) => n !== undefined);
|
|
503
|
+
lines.push(` ${idea.state === 'covered' ? 'tried' : idea.state === 'blocked' ? 'could not try' : 'not finished'}: ${safe(idea.text)}`, ` ${safe(idea.reason)}`, ...(found.length ? [` Findings from it: ${found.map(n => `#${n}`).join(', ')}`] : []));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
454
506
|
lines.push('', chalk.bold('What the crawl never ran'));
|
|
455
507
|
if (published.reach.unreachedChangedFiles.length === 0)
|
|
456
508
|
lines.push(' Every changed file ran at least once.');
|
|
@@ -514,6 +566,28 @@ function poolOption(value) {
|
|
|
514
566
|
throw new Error(`--pool must be one of ${CRAWL_POOLS.join(', ')}.`);
|
|
515
567
|
return value;
|
|
516
568
|
}
|
|
569
|
+
/** --intent, --idea and --ideas-file (CRAWL-V1 amendment 17): each text trimmed, 1 to CRAWL_MAX_TEXT_CHARS characters; at most
|
|
570
|
+
* CRAWL_MAX_IDEAS ideas, each once. Absent ones stay absent, so a crawl asked without them keeps its identity. */
|
|
571
|
+
export function agentDirection(options) {
|
|
572
|
+
const text = (value, what) => {
|
|
573
|
+
const trimmed = value.trim();
|
|
574
|
+
if (trimmed.length === 0 || [...trimmed].length > CRAWL_MAX_TEXT_CHARS)
|
|
575
|
+
throw new Error(`${what} must be 1 to ${CRAWL_MAX_TEXT_CHARS} characters.`);
|
|
576
|
+
return trimmed;
|
|
577
|
+
};
|
|
578
|
+
const fromFile = options.ideasFile === undefined ? [] : (() => {
|
|
579
|
+
const parsed = JSON.parse(readFileSync(options.ideasFile, 'utf8'));
|
|
580
|
+
if (!Array.isArray(parsed) || !parsed.every(item => typeof item === 'string'))
|
|
581
|
+
throw new Error('--ideas-file must hold a JSON array of strings.');
|
|
582
|
+
return parsed;
|
|
583
|
+
})();
|
|
584
|
+
const ideas = [...options.idea ?? [], ...fromFile].map(idea => text(idea, 'Each idea'));
|
|
585
|
+
if (ideas.length > CRAWL_MAX_IDEAS)
|
|
586
|
+
throw new Error(`A crawl takes at most ${CRAWL_MAX_IDEAS} ideas; ${ideas.length} were given.`);
|
|
587
|
+
if (new Set(ideas).size !== ideas.length)
|
|
588
|
+
throw new Error('The same idea was given twice.');
|
|
589
|
+
return { ...(options.intent === undefined ? {} : { intent: text(options.intent, '--intent') }), ...(ideas.length ? { ideas } : {}) };
|
|
590
|
+
}
|
|
517
591
|
/** --minutes: whole minutes from 1 to 30 (CRAWL-V1 amendment 8). */
|
|
518
592
|
function minutesOption(value) {
|
|
519
593
|
if (value === undefined)
|
|
@@ -713,13 +787,198 @@ function finishWithOnboarding(status, json, change) {
|
|
|
713
787
|
console.log(formatOnboarding(status));
|
|
714
788
|
process.exitCode = onboardingExitCode(status);
|
|
715
789
|
}
|
|
790
|
+
/** The brief a crawl view holds (CRAWL-V1 amendment 18): its sealed manifest's, else its latest answer's. */
|
|
791
|
+
function briefOf(view) {
|
|
792
|
+
return view.manifest?.brief ?? view.answer?.brief ?? null;
|
|
793
|
+
}
|
|
794
|
+
/** Follows a run until it holds a brief or ends (amendment 18): its own read held until it changes, as waitForCrawl's is. */
|
|
795
|
+
async function waitForBrief(first, identity, token) {
|
|
796
|
+
const interrupted = () => {
|
|
797
|
+
console.error('\nStopped waiting. The change keeps being prepared; run `haystack pre-verify` again to see its brief.');
|
|
798
|
+
process.exit(130);
|
|
799
|
+
};
|
|
800
|
+
process.once('SIGINT', interrupted);
|
|
801
|
+
try {
|
|
802
|
+
let view = first;
|
|
803
|
+
progress(`Waiting for the brief of ${view.runId}: the change is built and prepared first (Ctrl-C stops waiting).`);
|
|
804
|
+
progress(currentStep(view));
|
|
805
|
+
let failing = false;
|
|
806
|
+
let answeredAt = Date.now();
|
|
807
|
+
while (briefOf(view) === null && !TERMINAL.has(reportedStatus(view))) {
|
|
808
|
+
let next;
|
|
809
|
+
try {
|
|
810
|
+
next = await readCrawlRun(view.runId, identity, token, view.updatedAt);
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
const why = transientReadFailure(error);
|
|
814
|
+
if (why === null)
|
|
815
|
+
throw error;
|
|
816
|
+
if (Date.now() - answeredAt >= SERVICE_SILENCE_LIMIT_MS)
|
|
817
|
+
throw new ServiceSilentError('crawl', 'haystack pre-verify');
|
|
818
|
+
if (!failing)
|
|
819
|
+
progress(`The run's status is unavailable for now (${why}); reading it again.`);
|
|
820
|
+
failing = true;
|
|
821
|
+
await new Promise(resolve => setTimeout(resolve, TRANSIENT_READ_PAUSE_MS));
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
failing = false;
|
|
825
|
+
answeredAt = Date.now();
|
|
826
|
+
if (!TERMINAL.has(reportedStatus(next)) && currentStep(next) !== currentStep(view))
|
|
827
|
+
progress(currentStep(next));
|
|
828
|
+
view = next;
|
|
829
|
+
}
|
|
830
|
+
return view;
|
|
831
|
+
}
|
|
832
|
+
finally {
|
|
833
|
+
process.removeListener('SIGINT', interrupted);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/** The brief as a coding agent reads it: what the change touches, then Haystack's own ideas, then how to hand back intent and
|
|
837
|
+
* ideas. Every function is in --json; the text shows the changed ones and those one step away, and says how many more. */
|
|
838
|
+
export function formatBrief(brief) {
|
|
839
|
+
const lines = [chalk.bold('What your change touches')];
|
|
840
|
+
const near = brief.functions.filter(fn => fn.distance <= 1);
|
|
841
|
+
// Amendment 19: what production saw of each function shown, from the repository's own telemetry.
|
|
842
|
+
const seen = new Map((brief.production?.functions ?? []).map(row => [row.index, row]));
|
|
843
|
+
if (brief.functions.length === 0)
|
|
844
|
+
lines.push(' The blast radius found no affected functions.');
|
|
845
|
+
brief.functions.forEach((fn, index) => {
|
|
846
|
+
if (fn.distance > 1)
|
|
847
|
+
return;
|
|
848
|
+
lines.push(` ${fn.direction === 'changed' ? 'changed' : `${fn.direction}, ${plural(fn.distance, 'step')} away`}: `
|
|
849
|
+
+ `${safe(fn.file)}:${fn.lines[0]}-${fn.lines[1]} ${safe(fn.name)}`);
|
|
850
|
+
const row = seen.get(index);
|
|
851
|
+
if (row === undefined)
|
|
852
|
+
return;
|
|
853
|
+
for (const branch of row.branches)
|
|
854
|
+
lines.push(` in production: line ${branch.line} was true ${branch.whenTrue}${branch.capped ? '+' : ''} times,`
|
|
855
|
+
+ ` false ${branch.whenFalse}${branch.capped ? '+' : ''} times`);
|
|
856
|
+
if (row.branchesOmitted)
|
|
857
|
+
lines.push(` and ${plural(row.branchesOmitted, 'more branch site')} (in --json)`);
|
|
858
|
+
for (const observed of row.values)
|
|
859
|
+
lines.push(` in production: line ${observed.line} ${safe(observed.what)} was `
|
|
860
|
+
+ observed.shapes.map(shape => `${safe(shape.shape)} (${shape.count})`).join(', '));
|
|
861
|
+
if (row.valuesOmitted)
|
|
862
|
+
lines.push(` and ${plural(row.valuesOmitted, 'more observed value')} (in --json)`);
|
|
863
|
+
});
|
|
864
|
+
if (brief.production) {
|
|
865
|
+
const shownSeen = brief.production.functions.filter(row => brief.functions[row.index].distance <= 1).length;
|
|
866
|
+
lines.push(` Production telemetry: the last ${brief.production.windowDays} days, newest ${safe(brief.production.newestAt)};`
|
|
867
|
+
+ ` ${plural(brief.production.functions.length, 'function')} seen running (${shownSeen} above). Counts marked + are lower bounds`
|
|
868
|
+
+ `${brief.production.partial ? '; the read stopped at its budget, so every count is a lower bound' : ''}.`);
|
|
869
|
+
}
|
|
870
|
+
else
|
|
871
|
+
lines.push(' No production telemetry for this repository: set it up to see how this code runs for real users.');
|
|
872
|
+
if (brief.functions.length > near.length) {
|
|
873
|
+
lines.push(` ${plural(brief.functions.length - near.length, 'more function')} further away (all of them are in --json).`);
|
|
874
|
+
}
|
|
875
|
+
for (const item of brief.leftOut)
|
|
876
|
+
lines.push(` Left out: ${item.count} (${safe(item.reason)})`);
|
|
877
|
+
lines.push('', chalk.bold('What could break, from failure patterns that fit this change'));
|
|
878
|
+
if (brief.planning === null)
|
|
879
|
+
lines.push(' No planning ran for this change: no ideas of ours to offer.');
|
|
880
|
+
else {
|
|
881
|
+
if (brief.planning.ideas.length === 0)
|
|
882
|
+
lines.push(' No situation was planned for this change.');
|
|
883
|
+
brief.planning.ideas.forEach((idea, index) => lines.push(` ${index + 1}. Try: ${safe(idea.exercise)}`, ...(idea.setup.length ? [` Set up first: ${idea.setup.map(safe).join('; ')}`] : []), ` Watch: ${safe(idea.watch)}`, ` Why: ${safe(idea.why)}`, ` It would show as: ${safe(idea.failure)}`, ` Starts from: ${safe(idea.start)}`));
|
|
884
|
+
if (brief.planning.unresolved.length) {
|
|
885
|
+
lines.push(' Changes no behavior group holds (no ideas of ours for these):');
|
|
886
|
+
for (const change of brief.planning.unresolved)
|
|
887
|
+
lines.push(` ${safe(change.changeKey)}: ${safe(change.reason)}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
lines.push('', 'Next: pick the ideas worth trying, add your own, and say what the change is for:', ' haystack verify --intent "<what the change is for>" --idea "<something to try>" [--idea ...]', 'Your ideas are explored first; the ideas above are explored in every crawl too.');
|
|
891
|
+
return lines.join('\n');
|
|
892
|
+
}
|
|
893
|
+
/** `haystack pre-verify` (CRAWL-V1 amendment 18): the brief of the current change, before any crawl. It captures the checkout as
|
|
894
|
+
* the turn-end hook does, prepares it when no run of that capture is under way or done (it never widens a run to a crawl), and
|
|
895
|
+
* prints the run's brief. Exit codes: 0 with a brief, 2 when the run ended without one, 3 onboarding blocked, 1 otherwise. */
|
|
896
|
+
export async function preVerifyCommand(options) {
|
|
897
|
+
const note = (line) => { if (options.json)
|
|
898
|
+
console.error(line);
|
|
899
|
+
else
|
|
900
|
+
console.log(line); };
|
|
901
|
+
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'prepare', repositoryOverride(options.repo));
|
|
902
|
+
const request = capture.derivation.request;
|
|
903
|
+
const change = checkedChange(request.baseCommit, request.workCommit);
|
|
904
|
+
const finish = (onboarding, view) => {
|
|
905
|
+
if (options.json)
|
|
906
|
+
process.stdout.write(`${JSON.stringify(withSchema('pre-verify', { change, onboarding,
|
|
907
|
+
run: view === null ? null : { runId: view.runId, status: view.status }, brief: view === null ? null : briefOf(view) }), null, 2)}\n`);
|
|
908
|
+
};
|
|
909
|
+
if (!request.crawl) {
|
|
910
|
+
note(`Nothing to check: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
|
|
911
|
+
finish(null, null);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
note(checkedLine(change));
|
|
915
|
+
const identity = { repository: `${request.owner}/${request.repository}`, baseCommit: request.baseCommit,
|
|
916
|
+
workCommit: request.workCommit, treeSha: request.crawl.treeSha, changeTitle: request.crawl.changeTitle, budgetMs: request.crawl.budgetMs ?? null };
|
|
917
|
+
const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
|
|
918
|
+
let view = await readWithRetries(() => readCrawl(identity, auth.token));
|
|
919
|
+
let onboarding = null;
|
|
920
|
+
const stale = view === null ? null : staleCrawl(view, identity);
|
|
921
|
+
// A run of this capture that is preparing, prepared or crawling holds (or will hold) the brief; any other is asked for again,
|
|
922
|
+
// in mode 'prepare' (amendment 12: that never narrows a crawl).
|
|
923
|
+
if (view === null || (stale !== null && stale !== 'prepare-only')) {
|
|
924
|
+
const found = view === null || stale === null ? null : { view, stale };
|
|
925
|
+
let submitted = await submitCapture(capture, auth.token, found);
|
|
926
|
+
note(submitted.line);
|
|
927
|
+
if (submitted.onboarding !== null) {
|
|
928
|
+
onboarding = await readWithRetries(() => readOnboardingStatus(identity.repository, identity.baseCommit, auth.token));
|
|
929
|
+
reportOnboardingState(onboarding);
|
|
930
|
+
if (options.wait !== false && onboarding.state === 'onboarding') {
|
|
931
|
+
onboarding = await waitForOnboarding(onboarding, auth.token, progress, 'haystack pre-verify');
|
|
932
|
+
reportOnboardingState(onboarding);
|
|
933
|
+
}
|
|
934
|
+
if (onboarding.state !== 'ready') {
|
|
935
|
+
if (options.json)
|
|
936
|
+
finish(onboarding, null);
|
|
937
|
+
else
|
|
938
|
+
console.log(formatOnboarding(onboarding));
|
|
939
|
+
process.exitCode = onboardingExitCode(onboarding);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
note('The app is onboarded for this base.');
|
|
943
|
+
submitted = await submitCapture(capture, auth.token, found);
|
|
944
|
+
note(submitted.line);
|
|
945
|
+
}
|
|
946
|
+
if (submitted.runId === null) {
|
|
947
|
+
finish(onboarding, null);
|
|
948
|
+
process.exitCode = 1;
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const runId = submitted.runId;
|
|
952
|
+
view = await readWithRetries(() => readCrawlRun(runId, identity, auth.token));
|
|
953
|
+
}
|
|
954
|
+
if (options.wait !== false && briefOf(view) === null && !TERMINAL.has(reportedStatus(view)))
|
|
955
|
+
view = await waitForBrief(view, identity, auth.token);
|
|
956
|
+
const brief = briefOf(view);
|
|
957
|
+
if (options.json)
|
|
958
|
+
finish(onboarding, view);
|
|
959
|
+
else if (brief !== null)
|
|
960
|
+
console.log(formatBrief(brief));
|
|
961
|
+
else if (!TERMINAL.has(reportedStatus(view)))
|
|
962
|
+
console.log(`The brief is not ready yet: ${currentStep(view)}. Run \`haystack pre-verify\` again to see it.`);
|
|
963
|
+
// What gets a brief now depends on how the run ended (Bugbot): a `prepared` one is crawled by `haystack verify`, whose answers
|
|
964
|
+
// carry it; a completed one is never run again, so only a new capture (any edit) is prepared afresh; an incomplete or cancelled
|
|
965
|
+
// one is asked for again by the next pre-verify.
|
|
966
|
+
else
|
|
967
|
+
console.log(`Run ${view.runId} ended (${view.status}) without a brief${view.error ? `: ${errorWords(view.error)}` : ''}. `
|
|
968
|
+
+ (view.status === 'prepared' ? 'Run `haystack verify` to crawl the change; its results carry the brief.'
|
|
969
|
+
: view.status === 'completed' ? 'It was checked before briefs existed and is not run again: change anything in the checkout and run `haystack pre-verify` again.'
|
|
970
|
+
: 'Run `haystack pre-verify` again to prepare it again.'));
|
|
971
|
+
if (brief === null && TERMINAL.has(reportedStatus(view)))
|
|
972
|
+
process.exitCode = 2;
|
|
973
|
+
}
|
|
716
974
|
export async function verifyCommand(options) {
|
|
717
975
|
// In --json mode stdout carries the one document; every other line goes to stderr.
|
|
718
976
|
const note = (line) => { if (options.json)
|
|
719
977
|
console.error(line);
|
|
720
978
|
else
|
|
721
979
|
console.log(line); };
|
|
722
|
-
const
|
|
980
|
+
const agent = agentDirection(options);
|
|
981
|
+
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'crawl', repositoryOverride(options.repo), minutesOption(options.minutes), poolOption(options.pool), agent);
|
|
723
982
|
const request = capture.derivation.request;
|
|
724
983
|
const change = checkedChange(request.baseCommit, request.workCommit);
|
|
725
984
|
if (!request.crawl) {
|
|
@@ -745,8 +1004,9 @@ export async function verifyCommand(options) {
|
|
|
745
1004
|
let view = await readWithRetries(() => readCrawl(identity, auth.token));
|
|
746
1005
|
let onboarding = null;
|
|
747
1006
|
const stale = view === null ? null : staleCrawl(view, identity);
|
|
748
|
-
// A pinned pool
|
|
749
|
-
|
|
1007
|
+
// A pinned pool (amendment 16), an intent or ideas (amendment 17) make their own crawl, which the capture read cannot tell
|
|
1008
|
+
// apart: always submit, and follow that run (the same ones again are the same run).
|
|
1009
|
+
if (view === null || stale !== null || options.pool !== undefined || agent.intent !== undefined || agent.ideas !== undefined) {
|
|
750
1010
|
const found = view === null || stale === null ? null : { view, stale };
|
|
751
1011
|
let submitted = await submitCapture(capture, auth.token, found);
|
|
752
1012
|
note(submitted.line);
|
package/dist/index.js
CHANGED
|
@@ -85,6 +85,7 @@ const editRulesCommand = lazy(() => import('./commands/rules.js'), 'editRulesCom
|
|
|
85
85
|
const validateRulesCommand = lazy(() => import('./commands/rules.js'), 'validateRulesCommand');
|
|
86
86
|
const systemMapValidateCommand = lazy(() => import('./commands/system-map.js'), 'systemMapValidateCommand');
|
|
87
87
|
const verifyCommand = lazy(() => import('./commands/verify.js'), 'verifyCommand');
|
|
88
|
+
const preVerifyCommand = lazy(() => import('./commands/verify.js'), 'preVerifyCommand');
|
|
88
89
|
const headlessLoginCommand = lazy(() => import('./commands/tokens.js'), 'headlessLoginCommand');
|
|
89
90
|
const headlessLogoutCommand = lazy(() => import('./commands/tokens.js'), 'headlessLogoutCommand');
|
|
90
91
|
const listTokensCommand = lazy(() => import('./commands/tokens.js'), 'listTokensCommand');
|
|
@@ -146,6 +147,7 @@ program
|
|
|
146
147
|
.command('init')
|
|
147
148
|
.description('Set this repository up for `haystack verify` and start onboarding the app')
|
|
148
149
|
.option('-y, --yes', 'Make the changes without asking')
|
|
150
|
+
.option('--notes <file>', 'A JSON file (- for stdin) of what you know about running the app; see below')
|
|
149
151
|
.option('--json', 'The result as one JSON document on stdout (see `haystack schema init`)')
|
|
150
152
|
.addHelpText('after', `
|
|
151
153
|
There is no setup file: Haystack works out how to run the app from what the
|
|
@@ -161,6 +163,18 @@ Then it starts onboarding the app, so it is usually ready by the first
|
|
|
161
163
|
\`haystack verify\`. Running it again changes only what is missing and shows
|
|
162
164
|
where onboarding is (\`haystack verify onboarding\` shows that too).
|
|
163
165
|
|
|
166
|
+
--notes tells onboarding what a coding agent working here already knows, so it
|
|
167
|
+
does not have to work it out (a wrong guess at the start command can cost half
|
|
168
|
+
an hour). The file is JSON with any of three notes, each plain text:
|
|
169
|
+
run how production installs, builds and starts the app: the commands
|
|
170
|
+
and their directory, the port, the databases and services and their
|
|
171
|
+
versions, migrations, the environment variables it needs (names and
|
|
172
|
+
what they are for, never secret values)
|
|
173
|
+
signIn how a person gets an account and signs in, and the kinds of users
|
|
174
|
+
workflow what a signed-in user mainly does, step by step
|
|
175
|
+
Leave out what you do not know. Onboarding still proves everything it takes
|
|
176
|
+
from the notes. New notes start a new onboarding; the same notes again do not.
|
|
177
|
+
|
|
164
178
|
Exit codes:
|
|
165
179
|
0 set up: onboarding is running or the app is ready
|
|
166
180
|
1 the command failed
|
|
@@ -174,7 +188,7 @@ Exit codes:
|
|
|
174
188
|
Examples:
|
|
175
189
|
haystack init
|
|
176
190
|
haystack init --yes
|
|
177
|
-
haystack init --yes --json
|
|
191
|
+
haystack init --yes --json --notes haystack-notes.json
|
|
178
192
|
`)
|
|
179
193
|
.action(options => runPublicCommand(() => initCommand(options), options.json));
|
|
180
194
|
program
|
|
@@ -231,6 +245,9 @@ const verify = program
|
|
|
231
245
|
.option('--no-wait', 'Print the crawl\'s current state and return without waiting')
|
|
232
246
|
.option('--minutes <n>', 'How long the crawl may take, 1-30 (default: .haystack.json crawl.minutes, else 3)')
|
|
233
247
|
.addOption(new Option('--pool <pool>', 'Where the crawl\'s clones are created: fleet, freestyle or auto (default auto)').hideHelp())
|
|
248
|
+
.option('--intent <sentence>', 'What your change is for, in a sentence: the judge reads it so a difference you meant is not called a bug')
|
|
249
|
+
.option('--idea <text>', 'Something to try in the app, said as you would to a tester (repeat for more); each is explored first', (value, previous = []) => [...previous, value])
|
|
250
|
+
.option('--ideas-file <path>', 'A JSON array of more ideas')
|
|
234
251
|
.option('--json', 'The crawl as one JSON document on stdout (see `haystack schema verify`)')
|
|
235
252
|
.addHelpText('after', `
|
|
236
253
|
Run inside a git checkout; nothing needs to be committed or pushed. It captures
|
|
@@ -242,6 +259,12 @@ one it finds was cancelled, stopped before finishing or is being cancelled, it
|
|
|
242
259
|
submits the capture (the service starts the crawl, or runs the stopped one
|
|
243
260
|
again), says so, and follows that crawl.
|
|
244
261
|
|
|
262
|
+
The coding agent that made the change can say what it is for (--intent) and
|
|
263
|
+
what to try (--idea, as many as it likes, or --ideas-file). Each idea is
|
|
264
|
+
explored before anything else, and the answer says what became of it: tried,
|
|
265
|
+
could not try (and why), or not finished in time. Other ideas make another
|
|
266
|
+
crawl of the same code; the same ones again follow the same crawl.
|
|
267
|
+
|
|
245
268
|
A crawl builds the app with and without your change, starts from the changed
|
|
246
269
|
code, explores outward with both side by side, and double-checks and judges
|
|
247
270
|
every difference it can within its time: 3 minutes unless the repository's
|
|
@@ -282,6 +305,28 @@ Examples:
|
|
|
282
305
|
haystack verify hosted status cv_<48 lowercase hex characters> --wait
|
|
283
306
|
`)
|
|
284
307
|
.action(options => runPublicCommand(() => verifyCommand(options), options.json));
|
|
308
|
+
program
|
|
309
|
+
.command('pre-verify')
|
|
310
|
+
.description('Before a check: what your change touches, and what could break in it')
|
|
311
|
+
.option('--repo <owner/repo>', 'GitHub repository (default: origin remote)')
|
|
312
|
+
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
313
|
+
.option('--no-wait', 'Print what is known now and return without waiting for the brief')
|
|
314
|
+
.option('--json', 'The brief as one JSON document on stdout (see `haystack schema pre-verify`)')
|
|
315
|
+
.addHelpText('after', `
|
|
316
|
+
Run inside a git checkout, like haystack verify. It captures the checkout the
|
|
317
|
+
same way, builds and prepares the change when nothing has yet (the turn-end
|
|
318
|
+
hook usually has), and prints its brief: the functions the change touches, the
|
|
319
|
+
changed ones and those one step away first (every one is in --json), and
|
|
320
|
+
Haystack's own ideas of what could break, each with what to set up, what to do
|
|
321
|
+
and what to watch. It never starts a crawl.
|
|
322
|
+
|
|
323
|
+
Then hand back what the change is for and what to try:
|
|
324
|
+
haystack verify --intent "<what the change is for>" --idea "<something to try>"
|
|
325
|
+
|
|
326
|
+
Exit codes: 0 with a brief; 2 when the run ended without one; 3 when the app's
|
|
327
|
+
onboarding is blocked; 1 when the command failed.
|
|
328
|
+
`)
|
|
329
|
+
.action(options => runPublicCommand(() => preVerifyCommand(options), options.json));
|
|
285
330
|
/**
|
|
286
331
|
* Options for a `verify` subcommand: its own values, plus any option the user
|
|
287
332
|
* passed to an ancestor (`haystack verify --no-wait hosted status ...`).
|
|
@@ -889,7 +934,17 @@ program
|
|
|
889
934
|
.option('--json', 'Output the answer and every consulted customer-facing source as JSON')
|
|
890
935
|
.option('--session <id>', 'Continue a previous Ask Haystack session')
|
|
891
936
|
.action((ref, question, options) => runPublicCommand(() => askHaystackCommand(ref, question, options), options.json));
|
|
892
|
-
const telemetry = program.command('telemetry').description('Add privacy-safe production telemetry without an application SDK')
|
|
937
|
+
const telemetry = program.command('telemetry').description('Add privacy-safe production telemetry without an application SDK')
|
|
938
|
+
.addHelpText('after', `
|
|
939
|
+
Next.js bundles its server code, so instead of \`instrument\` add the CLI as a
|
|
940
|
+
dependency and wrap next.config (Next 15.4+, Turbopack or --webpack):
|
|
941
|
+
import { withHaystackTelemetry } from '@haystackeditor/cli/next';
|
|
942
|
+
export default withHaystackTelemetry(nextConfig);
|
|
943
|
+
\`next build\` then instruments git-tracked server source and prints the
|
|
944
|
+
NODE_OPTIONS=--require=... preload to start the server with. \`next start\`
|
|
945
|
+
loads next.config, so it needs the CLI as a production dependency; standalone
|
|
946
|
+
output (server.js) does not load next.config, so a dev dependency suffices.
|
|
947
|
+
`);
|
|
893
948
|
telemetry
|
|
894
949
|
.command('instrument <dist>')
|
|
895
950
|
.description('Instrument compiled Node.js output without changing application source')
|
package/dist/schema.js
CHANGED
|
@@ -20,10 +20,11 @@ export const SCHEMA_VERSIONS = {
|
|
|
20
20
|
action: '1.0.0',
|
|
21
21
|
'cloud-verifier': '1.0.0',
|
|
22
22
|
'case-batch': '1.0.1',
|
|
23
|
-
verify: '1.0.
|
|
24
|
-
'verify
|
|
23
|
+
verify: '1.0.4',
|
|
24
|
+
'pre-verify': '1.0.1',
|
|
25
|
+
'verify-answer': '1.0.1',
|
|
25
26
|
'verify-onboarding': '1.0.0',
|
|
26
|
-
init: '1.0.
|
|
27
|
+
init: '1.0.1',
|
|
27
28
|
login: '1.0.0',
|
|
28
29
|
error: '1.0.0',
|
|
29
30
|
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Bundler loader behind `withHaystackTelemetry` (./next.ts). It speaks the
|
|
4
|
+
* webpack loader API subset Turbopack also implements (getOptions, async,
|
|
5
|
+
* resourcePath) and is CommonJS because both bundlers' loader runners can
|
|
6
|
+
* require() it; the instrumenter itself is ESM and is imported on first use.
|
|
7
|
+
*
|
|
8
|
+
* Per module: leave it alone unless it is git-tracked repository source
|
|
9
|
+
* outside node_modules; otherwise instrument it at its repository path and
|
|
10
|
+
* record its sites in this build's session directory for the post-compile
|
|
11
|
+
* step. A module that is not instrumented is returned byte-for-byte.
|
|
12
|
+
*/
|
|
13
|
+
const node_child_process_1 = require("node:child_process");
|
|
14
|
+
const node_crypto_1 = require("node:crypto");
|
|
15
|
+
const node_fs_1 = require("node:fs");
|
|
16
|
+
const node_path_1 = require("node:path");
|
|
17
|
+
// Must equal the directory names in ./next.ts (an ESM module this CommonJS
|
|
18
|
+
// file cannot import synchronously).
|
|
19
|
+
const MODULES_DIRECTORY = 'modules';
|
|
20
|
+
const UNPARSED_DIRECTORY = 'unparsed';
|
|
21
|
+
let telemetry = null;
|
|
22
|
+
const trackedByRoot = new Map();
|
|
23
|
+
function loadTelemetry() {
|
|
24
|
+
telemetry ??= import('../commands/telemetry.js').then(async (module) => {
|
|
25
|
+
await module.preloadBabel();
|
|
26
|
+
return module;
|
|
27
|
+
});
|
|
28
|
+
return telemetry;
|
|
29
|
+
}
|
|
30
|
+
/** Git-tracked files under the repository root, once per loader process. */
|
|
31
|
+
function trackedFiles(sourceRoot) {
|
|
32
|
+
let tracked = trackedByRoot.get(sourceRoot);
|
|
33
|
+
if (!tracked) {
|
|
34
|
+
const listed = (0, node_child_process_1.execFileSync)('git', ['ls-files', '-z'], {
|
|
35
|
+
cwd: sourceRoot,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
38
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
tracked = new Set(listed.split('\0').filter(Boolean));
|
|
41
|
+
trackedByRoot.set(sourceRoot, tracked);
|
|
42
|
+
}
|
|
43
|
+
return tracked;
|
|
44
|
+
}
|
|
45
|
+
async function instrument(context, source, options) {
|
|
46
|
+
const relativePath = (0, node_path_1.relative)(options.sourceRoot, context.resourcePath);
|
|
47
|
+
if (!relativePath || relativePath.startsWith('..') || (0, node_path_1.isAbsolute)(relativePath))
|
|
48
|
+
return null;
|
|
49
|
+
const sourcePath = relativePath.split(node_path_1.sep).join('/');
|
|
50
|
+
if (sourcePath.split('/').includes('node_modules') || !trackedFiles(options.sourceRoot).has(sourcePath)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const result = (await loadTelemetry()).instrumentBundledModule(source, {
|
|
54
|
+
sourcePath,
|
|
55
|
+
controlChannel: options.controlChannel,
|
|
56
|
+
runtimeIntegrity: options.runtimeIntegrity,
|
|
57
|
+
});
|
|
58
|
+
if (result.kind === 'unchanged')
|
|
59
|
+
return null;
|
|
60
|
+
if (result.kind === 'unparsed') {
|
|
61
|
+
// Ship it as written and name it in the build's report: the bundler's
|
|
62
|
+
// compiler accepted syntax Babel does not, which is no reason to fail.
|
|
63
|
+
const record = { sourcePath, reason: result.reason };
|
|
64
|
+
writeSessionRecord(context, options, UNPARSED_DIRECTORY, sourcePath, source, record);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
// A module with nothing to probe (only module-scope declarations, types)
|
|
68
|
+
// ships unchanged and adds nothing to the manifest.
|
|
69
|
+
if (result.sites.length === 0)
|
|
70
|
+
return null;
|
|
71
|
+
const record = { sourcePath, probes: result.probes, sites: result.sites };
|
|
72
|
+
writeSessionRecord(context, options, MODULES_DIRECTORY, sourcePath, source, record);
|
|
73
|
+
return result.code;
|
|
74
|
+
}
|
|
75
|
+
function writeSessionRecord(context, options, directoryName, sourcePath, source, record) {
|
|
76
|
+
// The record must be written by every build that ships this module, so a
|
|
77
|
+
// persisted webpack cache entry may not stand in for running the loader.
|
|
78
|
+
context.cacheable?.(false);
|
|
79
|
+
// One record per (path, text). Next compiles one file once per server layer
|
|
80
|
+
// (RSC, SSR), possibly concurrently; identical input gives identical bytes,
|
|
81
|
+
// and the rename keeps a reader from seeing a half-written file.
|
|
82
|
+
const name = (0, node_crypto_1.createHash)('sha256').update(sourcePath).update('\0').update(source).digest('hex');
|
|
83
|
+
const directory = (0, node_path_1.join)(options.sessionDirectory, directoryName);
|
|
84
|
+
(0, node_fs_1.mkdirSync)(directory, { recursive: true });
|
|
85
|
+
const temporary = (0, node_path_1.join)(directory, `.${name}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
86
|
+
(0, node_fs_1.writeFileSync)(temporary, JSON.stringify(record));
|
|
87
|
+
(0, node_fs_1.renameSync)(temporary, (0, node_path_1.join)(directory, `${name}.json`));
|
|
88
|
+
}
|
|
89
|
+
function haystackTelemetryNextLoader(source, sourceMap) {
|
|
90
|
+
const callback = this.async();
|
|
91
|
+
instrument(this, source, this.getOptions()).then(code => code === null ? callback(null, source, sourceMap) : callback(null, code), (error) => callback(error instanceof Error ? error : new Error(String(error))));
|
|
92
|
+
}
|
|
93
|
+
module.exports = haystackTelemetryNextLoader;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type FileProbeCounts, type InstrumentedSite } from '../commands/telemetry.js';
|
|
2
|
+
/** Options the config hands every loader invocation; JSON for Turbopack. */
|
|
3
|
+
export interface NextTelemetryLoaderOptions {
|
|
4
|
+
/** Repository root (git top level); every site path is relative to it. */
|
|
5
|
+
sourceRoot: string;
|
|
6
|
+
/** This build's hand-off directory between loaders and the post-compile step. */
|
|
7
|
+
sessionDirectory: string;
|
|
8
|
+
controlChannel: string;
|
|
9
|
+
runtimeIntegrity: string;
|
|
10
|
+
}
|
|
11
|
+
/** One instrumented module, as a loader records it for the post-compile step. */
|
|
12
|
+
export interface NextTelemetryModuleRecord {
|
|
13
|
+
sourcePath: string;
|
|
14
|
+
probes: FileProbeCounts;
|
|
15
|
+
sites: InstrumentedSite[];
|
|
16
|
+
}
|
|
17
|
+
/** A server module shipped uninstrumented because Babel could not parse it. */
|
|
18
|
+
export interface NextTelemetryUnparsedRecord {
|
|
19
|
+
sourcePath: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Wrap a Next config (object or function). Every phase but the production
|
|
24
|
+
* build returns the application's config unchanged.
|
|
25
|
+
*
|
|
26
|
+
* Generic so the application's own types pass through: a typed
|
|
27
|
+
* `(phase, { defaultConfig }: { defaultConfig: NextConfig }) => NextConfig`
|
|
28
|
+
* keeps its context type (a fixed `unknown` parameter would reject it under
|
|
29
|
+
* strictFunctionTypes), and the result is Next's `NextConfig` when that is
|
|
30
|
+
* what went in, without this package importing `next`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function withHaystackTelemetry<Config extends object, Context>(nextConfig: (phase: string, context: Context) => Config | Promise<Config>): (phase: string, context: Context) => Promise<Config>;
|
|
33
|
+
export declare function withHaystackTelemetry<Config extends object>(nextConfig: Config): (phase: string, context?: unknown) => Promise<Config>;
|