@haystackeditor/cli 0.23.2 → 0.24.0
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 +9 -0
- package/dist/commands/db-profile-upload.js +20 -1
- package/dist/commands/init.js +23 -4
- 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 +34 -2
- package/dist/commands/verify-precompute.js +13 -4
- package/dist/commands/verify.js +287 -4
- package/dist/index.js +58 -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
|
@@ -18,7 +18,7 @@ function loadBabel() {
|
|
|
18
18
|
// @babel/core 8 is ESM-only. require() of ESM works from Node 22.12, but 22.12
|
|
19
19
|
// prints an ExperimentalWarning for it, so the command path loads Babel with
|
|
20
20
|
// import() first and loadBabel() then reuses that module.
|
|
21
|
-
async function preloadBabel() {
|
|
21
|
+
export async function preloadBabel() {
|
|
22
22
|
babelModule ??= await import('@babel/core');
|
|
23
23
|
}
|
|
24
24
|
const INSTRUMENTED_MARKER = '__haystack_telemetry_instrumented_v1__';
|
|
@@ -250,7 +250,13 @@ function runtimeControlDeclarations(t, controlName, helperName, specifier, modul
|
|
|
250
250
|
return [
|
|
251
251
|
t.variableDeclaration('const', [t.variableDeclarator(t.identifier(controlName), t.callExpression(t.functionExpression(null, [], t.blockStatement([
|
|
252
252
|
t.tryStatement(t.blockStatement([
|
|
253
|
-
t.variableDeclaration('const', [t.variableDeclarator(t.identifier('processHost'),
|
|
253
|
+
t.variableDeclaration('const', [t.variableDeclarator(t.identifier('processHost'),
|
|
254
|
+
// A bundled module must not name node:process: the bundler
|
|
255
|
+
// would resolve it as a module of its own. The global is the
|
|
256
|
+
// same object in a Node server.
|
|
257
|
+
moduleType === 'bundled'
|
|
258
|
+
? t.memberExpression(t.identifier('globalThis'), t.identifier('process'))
|
|
259
|
+
: t.callExpression(t.identifier('require'), [t.stringLiteral('node:process')]))]),
|
|
254
260
|
t.ifStatement(t.binaryExpression('in', t.stringLiteral(runtimeBlockedChannel), t.identifier('processHost')), t.returnStatement(fallbackModule('processHost'))),
|
|
255
261
|
t.variableDeclaration('const', [t.variableDeclarator(t.identifier('loaded'), t.memberExpression(t.identifier('processHost'), t.stringLiteral(runtimeGuardChannel), true))]),
|
|
256
262
|
t.returnStatement(t.conditionalExpression(t.logicalExpression('&&', t.identifier('loaded'), t.binaryExpression('===', t.unaryExpression('typeof', t.memberExpression(t.identifier('loaded'), t.identifier('safeSelectProbe'))), t.stringLiteral('function'))), t.identifier('loaded'), fallbackModule('processHost'))),
|
|
@@ -266,9 +272,10 @@ function passthroughHelper(t, helperName) {
|
|
|
266
272
|
function sitePassthroughHelper(t, helperName) {
|
|
267
273
|
return t.functionDeclaration(t.identifier(helperName), [t.identifier('value')], t.blockStatement([t.returnStatement(t.identifier('value'))]));
|
|
268
274
|
}
|
|
269
|
-
function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, controlChannel, runtimeIntegrity, includedQualifiedNames) {
|
|
275
|
+
function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, controlChannel, runtimeIntegrity, includedQualifiedNames, parserPlugins, recordSourcePositions, skipDirective, moduleScopeProbes, }) {
|
|
270
276
|
if (hasGeneratedInstrumentation(code))
|
|
271
277
|
return null;
|
|
278
|
+
let skippedByDirective = false;
|
|
272
279
|
const probes = { branches: 0, parameters: 0, bindings: 0, returns: 0, throws: 0 };
|
|
273
280
|
const ordinals = new WeakMap();
|
|
274
281
|
const functionIdentities = new WeakMap();
|
|
@@ -297,13 +304,24 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
297
304
|
const fn = path.isFunction?.() ? path : path.getFunctionParent();
|
|
298
305
|
return fn ? functionIdentities.get(fn.node) ?? `${functionName(fn)}:unmapped` : '(top):0';
|
|
299
306
|
};
|
|
300
|
-
const site = (fn, qualifiedName, owner, kind, structuralKey,
|
|
307
|
+
const site = (fn, qualifiedName, owner, kind, structuralKey,
|
|
308
|
+
// Candidate nodes for the site's source position, most specific first.
|
|
309
|
+
// Nodes this pass synthesizes (the return ensureBlock wraps around an
|
|
310
|
+
// arrow's expression body) have no location; the next candidate does.
|
|
311
|
+
positionNodes, label, allowedFields = []) => {
|
|
301
312
|
const targetId = `site-${shortHash([sourcePath, owner, kind, structuralKey])}`;
|
|
302
313
|
const value = `${sourcePath}#${fn}:${targetId}${label ? `(${label})` : ''}`;
|
|
303
314
|
// Leave room under the ingestion endpoint's 512-character key cap for a
|
|
304
315
|
// static field label and its longest value-shape outcome.
|
|
305
316
|
if (value.length > 360)
|
|
306
317
|
throw new Error(`Telemetry site exceeds the ingestion key limit in ${sourcePath}`);
|
|
318
|
+
let position = null;
|
|
319
|
+
if (recordSourcePositions) {
|
|
320
|
+
const start = positionNodes.find(node => node?.loc?.start)?.loc.start;
|
|
321
|
+
if (!start)
|
|
322
|
+
throw new Error(`Telemetry site ${value} has no source position`);
|
|
323
|
+
position = { line: start.line, column: start.column };
|
|
324
|
+
}
|
|
307
325
|
sites.set(value, {
|
|
308
326
|
probe_key: value,
|
|
309
327
|
site_id: targetId,
|
|
@@ -314,6 +332,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
314
332
|
structural_key: structuralKey,
|
|
315
333
|
...(label ? { label } : {}),
|
|
316
334
|
allowed_fields: [...allowedFields],
|
|
335
|
+
...(position ?? {}),
|
|
317
336
|
});
|
|
318
337
|
return value;
|
|
319
338
|
};
|
|
@@ -336,8 +355,9 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
336
355
|
const functionPath = path.isFunction?.() ? path : path.getFunctionParent();
|
|
337
356
|
return Boolean(functionPath && includedQualifiedNames.has(qualifiedFunctionName(functionPath)));
|
|
338
357
|
};
|
|
358
|
+
const outsideProbedScope = (path) => !moduleScopeProbes && !path.getFunctionParent();
|
|
339
359
|
const wrapTest = (path, syntax) => {
|
|
340
|
-
if (skipFileProbes || insideAccessor(path) || !selectedFunction(path))
|
|
360
|
+
if (skipFileProbes || insideAccessor(path) || !selectedFunction(path) || outsideProbedScope(path))
|
|
341
361
|
return;
|
|
342
362
|
const testPath = path.get('test');
|
|
343
363
|
const test = testPath?.node;
|
|
@@ -347,7 +367,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
347
367
|
const functionPath = path.getFunctionParent();
|
|
348
368
|
const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
|
|
349
369
|
const ordinal = nextOrdinal(path, `branch:${syntax}`);
|
|
350
|
-
const branchSite = site(fn, qualifiedName, ownerIdentity(path), 'condition', `${syntax}:${ordinal}
|
|
370
|
+
const branchSite = site(fn, qualifiedName, ownerIdentity(path), 'condition', `${syntax}:${ordinal}`, [test, path.node]);
|
|
351
371
|
testPath.replaceWith(optionalProbeCall(t, siteProbeName(path, '__hstBranch', branchSite), test));
|
|
352
372
|
testPath.skip();
|
|
353
373
|
probes.branches++;
|
|
@@ -356,6 +376,11 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
356
376
|
visitor: {
|
|
357
377
|
Program: {
|
|
358
378
|
enter(path) {
|
|
379
|
+
if (skipDirective && (path.node.directives ?? []).some((directive) => directive.value?.value === skipDirective)) {
|
|
380
|
+
skippedByDirective = true;
|
|
381
|
+
path.stop();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
359
384
|
helperName = path.scope.generateUidIdentifier('__haystackTelemetryPassthrough').name;
|
|
360
385
|
siteHelperName = path.scope.generateUidIdentifier('__haystackTelemetrySitePassthrough').name;
|
|
361
386
|
runtimeControlName = path.scope.generateUidIdentifier('__haystackTelemetryControl').name;
|
|
@@ -424,7 +449,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
424
449
|
for (const [parameterIndex, parameter] of path.node.params.entries()) {
|
|
425
450
|
for (const [bindingIndex, identifier] of parameterBindings(t, parameter).entries()) {
|
|
426
451
|
const fields = sourceFieldsForParameter(path, identifier.name);
|
|
427
|
-
const parameterSite = site(fn, qualifiedFunctionName(path), ownerIdentity(path), 'parameter', `${parameterIndex}:${bindingIndex}:${identifier.name}`, `parameter:${identifier.name}`, fields);
|
|
452
|
+
const parameterSite = site(fn, qualifiedFunctionName(path), ownerIdentity(path), 'parameter', `${parameterIndex}:${bindingIndex}:${identifier.name}`, [identifier, parameter, path.node], `parameter:${identifier.name}`, fields);
|
|
428
453
|
statements.push(t.expressionStatement(optionalProbeCall(t, siteProbeName(path, '__hstValue', parameterSite), t.identifier(identifier.name))));
|
|
429
454
|
probes.parameters++;
|
|
430
455
|
}
|
|
@@ -448,7 +473,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
448
473
|
},
|
|
449
474
|
VariableDeclarator: {
|
|
450
475
|
exit(path) {
|
|
451
|
-
if (skipFileProbes || insideAccessor(path) || !selectedFunction(path))
|
|
476
|
+
if (skipFileProbes || insideAccessor(path) || !selectedFunction(path) || outsideProbedScope(path))
|
|
452
477
|
return;
|
|
453
478
|
if (!t.isIdentifier(path.node.id) || !path.node.init || !t.isExpression(path.node.init))
|
|
454
479
|
return;
|
|
@@ -456,7 +481,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
456
481
|
const functionPath = path.getFunctionParent();
|
|
457
482
|
const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
|
|
458
483
|
const ordinal = nextOrdinal(path, `binding:${path.node.id.name}`);
|
|
459
|
-
const valueSite = site(fn, qualifiedName, ownerIdentity(path), 'binding', `${path.node.id.name}:${ordinal}`, `binding:${path.node.id.name}`);
|
|
484
|
+
const valueSite = site(fn, qualifiedName, ownerIdentity(path), 'binding', `${path.node.id.name}:${ordinal}`, [path.node], `binding:${path.node.id.name}`);
|
|
460
485
|
path.get('init').replaceWith(optionalProbeCall(t, siteProbeName(path, '__hstValue', valueSite), path.node.init));
|
|
461
486
|
path.get('init').skip();
|
|
462
487
|
probes.bindings++;
|
|
@@ -473,7 +498,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
473
498
|
const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
|
|
474
499
|
const ordinal = nextOrdinal(path, 'return');
|
|
475
500
|
const fields = sourceFieldsForReturnedObject(path.node.argument);
|
|
476
|
-
const returnSite = site(fn, qualifiedName, ownerIdentity(path), 'return', String(ordinal), 'return', fields);
|
|
501
|
+
const returnSite = site(fn, qualifiedName, ownerIdentity(path), 'return', String(ordinal), [path.node, path.node.argument], 'return', fields);
|
|
477
502
|
path.get('argument').replaceWith(optionalProbeCall(t, siteProbeName(path, '__hstValue', returnSite), path.node.argument));
|
|
478
503
|
path.get('argument').skip();
|
|
479
504
|
probes.returns++;
|
|
@@ -481,7 +506,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
481
506
|
},
|
|
482
507
|
ThrowStatement: {
|
|
483
508
|
exit(path) {
|
|
484
|
-
if (skipFileProbes || insideAccessor(path) || !selectedFunction(path))
|
|
509
|
+
if (skipFileProbes || insideAccessor(path) || !selectedFunction(path) || outsideProbedScope(path))
|
|
485
510
|
return;
|
|
486
511
|
if (!path.node.argument || !t.isExpression(path.node.argument))
|
|
487
512
|
return;
|
|
@@ -489,7 +514,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
489
514
|
const functionPath = path.getFunctionParent();
|
|
490
515
|
const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
|
|
491
516
|
const ordinal = nextOrdinal(path, 'throw');
|
|
492
|
-
const throwSite = site(fn, qualifiedName, ownerIdentity(path), 'throw', String(ordinal), 'throw');
|
|
517
|
+
const throwSite = site(fn, qualifiedName, ownerIdentity(path), 'throw', String(ordinal), [path.node, path.node.argument], 'throw');
|
|
493
518
|
path.get('argument').replaceWith(optionalProbeCall(t, siteProbeName(path, '__hstThrow', throwSite), path.node.argument));
|
|
494
519
|
path.get('argument').skip();
|
|
495
520
|
probes.throws++;
|
|
@@ -502,7 +527,7 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
502
527
|
const transformed = babel.transformSync(code, {
|
|
503
528
|
parserOpts: {
|
|
504
529
|
sourceType: 'unambiguous',
|
|
505
|
-
plugins:
|
|
530
|
+
plugins: parserPlugins,
|
|
506
531
|
},
|
|
507
532
|
plugins: [plugin],
|
|
508
533
|
babelrc: false,
|
|
@@ -512,6 +537,8 @@ function instrumentSource(code, sourcePath, runtimeSpecifier, moduleType, contro
|
|
|
512
537
|
retainLines: true,
|
|
513
538
|
compact: false,
|
|
514
539
|
});
|
|
540
|
+
if (skippedByDirective)
|
|
541
|
+
return null;
|
|
515
542
|
if (!transformed?.code)
|
|
516
543
|
throw new Error(`Instrumentation produced no code for ${sourcePath}`);
|
|
517
544
|
if (!helperName || !siteHelperName || !runtimeControlName) {
|
|
@@ -662,6 +689,24 @@ function sourceForOutput(output, dist, sourceRoot) {
|
|
|
662
689
|
function runtimeAssetPath() {
|
|
663
690
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'telemetry', RUNTIME_FILENAME);
|
|
664
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* The runtime source bound to one control channel, plus the integrity token
|
|
694
|
+
* generated probes present to it and the hash a bootstrap verifies before
|
|
695
|
+
* evaluating it. Deterministic in the channel, so a bundler loader running in
|
|
696
|
+
* several worker processes and the step that later writes the runtime agree
|
|
697
|
+
* without exchanging anything.
|
|
698
|
+
*/
|
|
699
|
+
export function authenticateTelemetryRuntime(controlChannel) {
|
|
700
|
+
const runtimeTemplate = readFileSync(runtimeAssetPath(), 'utf8');
|
|
701
|
+
if (!runtimeTemplate.includes(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER) ||
|
|
702
|
+
!runtimeTemplate.includes(RUNTIME_INTEGRITY_PLACEHOLDER)) {
|
|
703
|
+
throw new Error('Telemetry runtime is missing its authentication placeholders');
|
|
704
|
+
}
|
|
705
|
+
const runtimeChannelSource = runtimeTemplate.replaceAll(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER, controlChannel);
|
|
706
|
+
const integrity = createHash('sha256').update(runtimeChannelSource).digest('hex');
|
|
707
|
+
const source = runtimeChannelSource.replaceAll(RUNTIME_INTEGRITY_PLACEHOLDER, integrity);
|
|
708
|
+
return { source, integrity, hash: createHash('sha256').update(source).digest('hex') };
|
|
709
|
+
}
|
|
665
710
|
function readExistingManifest(path) {
|
|
666
711
|
if (!existsSync(path))
|
|
667
712
|
return null;
|
|
@@ -741,15 +786,7 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
|
|
|
741
786
|
normalizedPath(relative(dist, output)),
|
|
742
787
|
normalizeLineEndings(readFileSync(output, 'utf8')),
|
|
743
788
|
]))}`;
|
|
744
|
-
const
|
|
745
|
-
if (!runtimeTemplate.includes(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER) ||
|
|
746
|
-
!runtimeTemplate.includes(RUNTIME_INTEGRITY_PLACEHOLDER)) {
|
|
747
|
-
throw new Error('Telemetry runtime is missing its authentication placeholders');
|
|
748
|
-
}
|
|
749
|
-
const runtimeChannelSource = runtimeTemplate.replaceAll(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER, controlChannel);
|
|
750
|
-
const runtimeIntegrity = createHash('sha256').update(runtimeChannelSource).digest('hex');
|
|
751
|
-
const authenticatedRuntime = runtimeChannelSource.replaceAll(RUNTIME_INTEGRITY_PLACEHOLDER, runtimeIntegrity);
|
|
752
|
-
const authenticatedRuntimeHash = createHash('sha256').update(authenticatedRuntime).digest('hex');
|
|
789
|
+
const { source: authenticatedRuntime, integrity: runtimeIntegrity, hash: authenticatedRuntimeHash, } = authenticateTelemetryRuntime(controlChannel);
|
|
753
790
|
const pending = [];
|
|
754
791
|
const skipped = [];
|
|
755
792
|
for (const output of outputFiles) {
|
|
@@ -765,7 +802,19 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
|
|
|
765
802
|
if (!SAFE_SOURCE_PATH.test(sourcePath)) {
|
|
766
803
|
throw new Error(`Source path contains a telemetry-reserved character (# or |): ${sourcePath}`);
|
|
767
804
|
}
|
|
768
|
-
const transformed = instrumentSource(readFileSync(output, 'utf8'),
|
|
805
|
+
const transformed = instrumentSource(readFileSync(output, 'utf8'), {
|
|
806
|
+
sourcePath,
|
|
807
|
+
runtimeSpecifier: runtimeSpecifierForOutput(output, dist),
|
|
808
|
+
moduleType: moduleTypeForOutput(output, dist),
|
|
809
|
+
controlChannel,
|
|
810
|
+
runtimeIntegrity,
|
|
811
|
+
includedQualifiedNames: selectiveInstrumentation ? selectedSymbolsBySource.get(sourcePath) ?? new Set() : null,
|
|
812
|
+
parserPlugins: ['jsx'],
|
|
813
|
+
// Compiled output has no source map here: its coordinates are not the
|
|
814
|
+
// source's, so sites carry none (see InstrumentedSite.line).
|
|
815
|
+
recordSourcePositions: false,
|
|
816
|
+
moduleScopeProbes: true,
|
|
817
|
+
});
|
|
769
818
|
if (transformed)
|
|
770
819
|
pending.push({ output, source: sourcePath, ...transformed });
|
|
771
820
|
}
|
|
@@ -818,6 +867,128 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
|
|
|
818
867
|
writeFileSync(manifestPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
819
868
|
return result;
|
|
820
869
|
}
|
|
870
|
+
// ─── Bundled (source-level) instrumentation ─────────────────────────────────
|
|
871
|
+
// A bundler packs server code into chunks that mirror no source file, so the
|
|
872
|
+
// compiled-output pass above cannot map them. These two entry points let a
|
|
873
|
+
// bundler loader instrument each source module as it is compiled, and a
|
|
874
|
+
// post-compile step write the runtime beside the build. The runtime is never
|
|
875
|
+
// bundled: a preload evaluates it by its real path.
|
|
876
|
+
// `<T>value` casts are JSX in a .tsx file, so typescript and jsx go together
|
|
877
|
+
// only there. Every set parses legacy decorators (TypeScript's
|
|
878
|
+
// experimentalDecorators: `@Entity()` classes, members and parameters), which
|
|
879
|
+
// the generator prints back where they were; `@` is a syntax error anywhere
|
|
880
|
+
// else, so the plugin only widens what parses. Import attributes (`with
|
|
881
|
+
// { type: 'json' }`) and `using` / `await using` are on by default in Babel 8
|
|
882
|
+
// and named so the accepted syntax is stated here; Babel 8 removed the older
|
|
883
|
+
// `assert { }` form, so such a module is one the loader leaves unchanged.
|
|
884
|
+
const SHARED_PARSER_PLUGINS = ['decorators-legacy', 'importAttributes', 'explicitResourceManagement'];
|
|
885
|
+
const BUNDLED_PARSER_PLUGINS = {
|
|
886
|
+
'.ts': ['typescript', ...SHARED_PARSER_PLUGINS],
|
|
887
|
+
'.mts': ['typescript', ...SHARED_PARSER_PLUGINS],
|
|
888
|
+
'.cts': ['typescript', ...SHARED_PARSER_PLUGINS],
|
|
889
|
+
'.tsx': ['typescript', 'jsx', ...SHARED_PARSER_PLUGINS],
|
|
890
|
+
'.js': ['jsx', ...SHARED_PARSER_PLUGINS],
|
|
891
|
+
'.jsx': ['jsx', ...SHARED_PARSER_PLUGINS],
|
|
892
|
+
'.mjs': ['jsx', ...SHARED_PARSER_PLUGINS],
|
|
893
|
+
'.cjs': ['jsx', ...SHARED_PARSER_PLUGINS],
|
|
894
|
+
};
|
|
895
|
+
export const BUNDLED_SOURCE_EXTENSIONS = Object.keys(BUNDLED_PARSER_PLUGINS);
|
|
896
|
+
const BUNDLED_REGISTER_FILENAME = 'register.cjs';
|
|
897
|
+
/**
|
|
898
|
+
* Instrument one source module inside a bundler. `sourcePath` is the
|
|
899
|
+
* repository-relative path every site records, so production counts join the
|
|
900
|
+
* change's files and line ranges exactly. A module Babel cannot parse is
|
|
901
|
+
* reported, not thrown: the bundler's compiler may accept syntax Babel does
|
|
902
|
+
* not, and telemetry must never be the reason a build fails. Any other error
|
|
903
|
+
* is ours and stays loud.
|
|
904
|
+
*/
|
|
905
|
+
export function instrumentBundledModule(code, options) {
|
|
906
|
+
const parserPlugins = BUNDLED_PARSER_PLUGINS[extname(options.sourcePath)];
|
|
907
|
+
if (!parserPlugins)
|
|
908
|
+
throw new Error(`Telemetry cannot parse ${options.sourcePath}: unsupported extension`);
|
|
909
|
+
if (!SAFE_SOURCE_PATH.test(options.sourcePath) || options.sourcePath.startsWith('/') ||
|
|
910
|
+
options.sourcePath.split('/').some(part => !part || part === '.' || part === '..')) {
|
|
911
|
+
throw new Error(`Telemetry source path must be repository-relative without # or |: ${options.sourcePath}`);
|
|
912
|
+
}
|
|
913
|
+
let instrumented;
|
|
914
|
+
try {
|
|
915
|
+
instrumented = instrumentSource(code, {
|
|
916
|
+
sourcePath: options.sourcePath,
|
|
917
|
+
runtimeSpecifier: '',
|
|
918
|
+
moduleType: 'bundled',
|
|
919
|
+
controlChannel: options.controlChannel,
|
|
920
|
+
runtimeIntegrity: options.runtimeIntegrity,
|
|
921
|
+
includedQualifiedNames: null,
|
|
922
|
+
parserPlugins,
|
|
923
|
+
recordSourcePositions: true,
|
|
924
|
+
skipDirective: 'use client',
|
|
925
|
+
moduleScopeProbes: false,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
catch (error) {
|
|
929
|
+
if (error?.code !== 'BABEL_PARSE_ERROR')
|
|
930
|
+
throw error;
|
|
931
|
+
// The parser's own one-line reason and position, e.g. "Missing semicolon.
|
|
932
|
+
// (1:30)"; the code frame below it would copy customer source into the log.
|
|
933
|
+
const reason = String(error.message).split('\n')[0].replace(/^unknown:\s*/, '');
|
|
934
|
+
return { kind: 'unparsed', reason };
|
|
935
|
+
}
|
|
936
|
+
return instrumented ? { kind: 'instrumented', ...instrumented } : { kind: 'unchanged' };
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Write the runtime, its manifest and the preload that installs it into
|
|
940
|
+
* `<outputDirectory>/.haystack-telemetry/`. The runtime reads the manifest
|
|
941
|
+
* beside its own real file; nothing here depends on where a bundler put the
|
|
942
|
+
* chunks. Returns the preload's absolute path for NODE_OPTIONS=--require.
|
|
943
|
+
*/
|
|
944
|
+
export function writeBundledTelemetryRuntime(options) {
|
|
945
|
+
const modules = [...options.modules].sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
|
|
946
|
+
const sitesByProbe = new Map();
|
|
947
|
+
for (const module of modules) {
|
|
948
|
+
for (const site of module.sites) {
|
|
949
|
+
const existing = sitesByProbe.get(site.probe_key);
|
|
950
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(site)) {
|
|
951
|
+
throw new Error(`Telemetry site ${site.probe_key} was compiled from two different versions of ${site.source_path}`);
|
|
952
|
+
}
|
|
953
|
+
sitesByProbe.set(site.probe_key, site);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
const sites = [...sitesByProbe.values()].sort((left, right) => left.site_id.localeCompare(right.site_id));
|
|
957
|
+
if (sites.length === 0)
|
|
958
|
+
throw new Error('Telemetry instrumented no server source; nothing to install');
|
|
959
|
+
if (sites.length > MAX_INSTRUMENTED_SITES) {
|
|
960
|
+
throw new Error(`Build contains ${sites.length} telemetry sites; maximum is ${MAX_INSTRUMENTED_SITES}`);
|
|
961
|
+
}
|
|
962
|
+
// The build identity covers exactly what was instrumented (every site's
|
|
963
|
+
// coordinates), so two builds of identical server source share one identity
|
|
964
|
+
// and any source change produces a new one.
|
|
965
|
+
const buildId = shortHash([options.bundler, ...sites.map(site => JSON.stringify(site))]);
|
|
966
|
+
const runtime = authenticateTelemetryRuntime(options.controlChannel);
|
|
967
|
+
const outputDirectory = resolve(options.outputDirectory);
|
|
968
|
+
const runtimeDirectory = join(outputDirectory, RUNTIME_DIRECTORY);
|
|
969
|
+
mkdirSync(runtimeDirectory, { recursive: true });
|
|
970
|
+
const manifest = {
|
|
971
|
+
schema_version: MANIFEST_SCHEMA_VERSION,
|
|
972
|
+
build_id: buildId,
|
|
973
|
+
bundler: options.bundler,
|
|
974
|
+
source_root: options.sourceRoot,
|
|
975
|
+
entry: null,
|
|
976
|
+
runtime: `${RUNTIME_DIRECTORY}/${RUNTIME_FILENAME}`,
|
|
977
|
+
instrumented_files: modules.map(module => ({ source_path: module.sourcePath, probes: module.probes })),
|
|
978
|
+
skipped_unparsed_files: [...new Map(options.unparsed.map(file => [file.sourcePath, file])).values()]
|
|
979
|
+
.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath))
|
|
980
|
+
.map(file => ({ source_path: file.sourcePath, reason: file.reason })),
|
|
981
|
+
sites,
|
|
982
|
+
};
|
|
983
|
+
writeFileSync(join(runtimeDirectory, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
984
|
+
writeFileSync(join(runtimeDirectory, RUNTIME_FILENAME), runtime.source);
|
|
985
|
+
const registerPath = join(runtimeDirectory, BUNDLED_REGISTER_FILENAME);
|
|
986
|
+
// The same hash-verified, cache-isolated evaluation the compiled-output
|
|
987
|
+
// entry bootstrap performs, run as a preload before the server's first
|
|
988
|
+
// module so every bundled probe binds on its first evaluation.
|
|
989
|
+
writeFileSync(registerPath, bootstrapFor(registerPath, outputDirectory, buildId, runtime.hash, `${options.controlChannel}_runtimeGuard`, `${options.controlChannel}_runtimeBlocked`));
|
|
990
|
+
return { registerPath, manifest };
|
|
991
|
+
}
|
|
821
992
|
export async function telemetryInstrumentCommand(distDirectory, options) {
|
|
822
993
|
let includeSymbols = options.includeSymbols;
|
|
823
994
|
if (options.includeSymbolsFile) {
|
|
@@ -14,11 +14,13 @@ import { classifyHttpError, readWithRetries, SERVICE_SILENCE_LIMIT_MS, ServiceSi
|
|
|
14
14
|
import { trackOnboardingEvent } from '../utils/telemetry.js';
|
|
15
15
|
import { gatewayFetch } from './case-batch.js';
|
|
16
16
|
import { ONBOARDING_STAGES, ONBOARDING_VERSION, } from './onboarding-contract.js';
|
|
17
|
+
import { sameNotes } from './onboarding-notes.js';
|
|
17
18
|
import { deriveBaseSha, EXPLICIT_WALL_MS, resolveOriginRepository } from './verify-precompute.js';
|
|
18
19
|
import { findGitRoot } from '../utils/hooks.js';
|
|
19
20
|
const ONBOARDING_PATH = '/api/agent/cloud-verifier/onboarding';
|
|
20
21
|
const FACTS_PATH = `${ONBOARDING_PATH}/facts`;
|
|
21
22
|
const ANSWERS_PATH = `${ONBOARDING_PATH}/answers`;
|
|
23
|
+
const NOTES_PATH = `${ONBOARDING_PATH}/notes`;
|
|
22
24
|
const ONBOARD_RUN_ID = /^ob_[0-9a-f]{48}$/;
|
|
23
25
|
const SHA256 = /^[0-9a-f]{64}$/;
|
|
24
26
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
@@ -95,6 +97,8 @@ export function parseOnboardingStatus(value, expected) {
|
|
|
95
97
|
invalid('its version choices');
|
|
96
98
|
if (!isStrings(value.standIns) || !isStrings(value.unsetProviders) || !isStrings(value.standInGaps))
|
|
97
99
|
invalid('its stand-ins');
|
|
100
|
+
if (!(value.worldMemoryMib === null || (typeof value.worldMemoryMib === 'number' && Number.isInteger(value.worldMemoryMib))))
|
|
101
|
+
invalid('its world memory');
|
|
98
102
|
if (value.block !== null)
|
|
99
103
|
parseOnboardingBlock(value.block);
|
|
100
104
|
const checkpoint = value.checkpoint;
|
|
@@ -229,9 +233,15 @@ export function onboardingStep(status) {
|
|
|
229
233
|
const stages = status.checkpoint?.execution.stages ?? [];
|
|
230
234
|
const done = stages.filter(stage => stage.status === 'ok' || stage.status === 'reused').map(stage => stage.stage);
|
|
231
235
|
const current = status.checkpoint?.stage;
|
|
236
|
+
// A deferred run's checkpoint is its last attempt's (stage cleanup): it is waiting, not cleaning up (2026-10-05, Excalidraw
|
|
237
|
+
// showed "cleaning up" for ten minutes behind a duplicate onboarding of the same commit).
|
|
232
238
|
const doing = status.state !== 'onboarding' ? status.state
|
|
233
|
-
:
|
|
234
|
-
|
|
239
|
+
: status.checkpoint?.error?.code === 'deferred' ? 'waiting for an earlier onboarding of this commit to finish, then reusing it'
|
|
240
|
+
: current === undefined ? 'queued' : STAGE_WORDS[current];
|
|
241
|
+
// A world larger than the first size is what onboarding found the app needs; every crawl's VMs are that size.
|
|
242
|
+
const memory = status.state === 'ready' && status.worldMemoryMib !== null && status.worldMemoryMib > 4096
|
|
243
|
+
? ` Its copies run with ${status.worldMemoryMib} MiB of memory: it ran out at 4096.` : '';
|
|
244
|
+
return `Onboarding: ${doing}${done.length ? ` (done: ${done.join(', ')})` : ''}.${memory}`;
|
|
235
245
|
}
|
|
236
246
|
/** The stage list, one line per stage the run has reached. */
|
|
237
247
|
export function onboardingStageLines(status) {
|
|
@@ -369,6 +379,28 @@ export async function verifyAnswerCommand(questionId, choice, options) {
|
|
|
369
379
|
return;
|
|
370
380
|
}
|
|
371
381
|
}
|
|
382
|
+
/** Makes the coding agent's notes the repository's onboarding notes (rule 7, amendment 18), unless they are exactly these
|
|
383
|
+
* already: 'unchanged' then, so running init again with the same notes starts no new onboarding. */
|
|
384
|
+
export async function writeOnboardingNotes(repository, notes, token) {
|
|
385
|
+
for (let attempt = 1;; attempt += 1) {
|
|
386
|
+
const facts = await readFacts(repository, token);
|
|
387
|
+
if (sameNotes(facts.facts.notes, notes))
|
|
388
|
+
return 'unchanged';
|
|
389
|
+
const request = { repository, expectedFactsDigest: facts.factsDigest, notes };
|
|
390
|
+
const response = await gatewayFetch(`${NOTES_PATH}?repository=${encodeURIComponent(repository)}`, token, {
|
|
391
|
+
method: 'POST', body: JSON.stringify(request),
|
|
392
|
+
});
|
|
393
|
+
if (response.status === 409 && attempt < 3) {
|
|
394
|
+
await response.body?.cancel();
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (!response.ok)
|
|
398
|
+
throw await classifyHttpError(response, `Haystack API ${NOTES_PATH}`);
|
|
399
|
+
if (!sameNotes(parseFactsView(await body(response, 'facts'), repository).facts.notes, notes))
|
|
400
|
+
invalid('the notes it answered with');
|
|
401
|
+
return 'written';
|
|
402
|
+
}
|
|
403
|
+
}
|
|
372
404
|
const clock = () => new Date().toTimeString().slice(0, 8);
|
|
373
405
|
/** `haystack verify onboarding`: the onboarding of the base `haystack verify` would use for this
|
|
374
406
|
* checkout, read without starting anything; --wait follows it while it runs. */
|
|
@@ -264,7 +264,11 @@ export async function derivePrecomputeRequest(gitRoot, owner, repository, deadli
|
|
|
264
264
|
/** How long the crawl may take (CRAWL-V1 amendment 8); undefined: the service's default. */
|
|
265
265
|
budgetMs,
|
|
266
266
|
/** Amendment 12: 'prepare' builds and freezes only (the turn-end hook); 'crawl' is `haystack verify`. */
|
|
267
|
-
mode = 'crawl'
|
|
267
|
+
mode = 'crawl',
|
|
268
|
+
/** Amendment 16: `haystack verify --pool`; undefined: the service decides ('auto'). */
|
|
269
|
+
pool,
|
|
270
|
+
/** Amendment 17: the coding agent's `--intent` and `--idea`s; each absent when it gave none. */
|
|
271
|
+
agent = {}) {
|
|
268
272
|
// Capture this before git and compression work so delivery latency cannot
|
|
269
273
|
// make an older stop look newer than a later one.
|
|
270
274
|
const observedAt = Date.now();
|
|
@@ -292,7 +296,8 @@ mode = 'crawl') {
|
|
|
292
296
|
cacheKey,
|
|
293
297
|
patchGzBase64: patchGzip.toString('base64'),
|
|
294
298
|
patchSha256: createHash('sha256').update(patchGzip).digest('hex'),
|
|
295
|
-
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode } }),
|
|
299
|
+
...(workCommit === baseSha ? {} : { crawl: { treeSha, changeTitle, ...(budgetMs === undefined ? {} : { budgetMs }), mode, ...(pool === undefined ? {} : { pool }),
|
|
300
|
+
...(agent.intent === undefined ? {} : { intent: agent.intent }), ...(agent.ideas === undefined ? {} : { ideas: agent.ideas }) } }),
|
|
296
301
|
},
|
|
297
302
|
};
|
|
298
303
|
}
|
|
@@ -519,7 +524,11 @@ export async function captureCheckout(deadline,
|
|
|
519
524
|
/** Amendment 12: the turn-end hook and `verify precompute` ask to prepare (build and freeze); `haystack verify` to crawl. */
|
|
520
525
|
mode, repository,
|
|
521
526
|
/** `haystack verify --minutes`; undefined: the repository's .haystack.json crawl.minutes, else the service's default. */
|
|
522
|
-
minutes
|
|
527
|
+
minutes,
|
|
528
|
+
/** `haystack verify --pool`; undefined: the service decides. */
|
|
529
|
+
pool,
|
|
530
|
+
/** `haystack verify --intent` and `--idea` (CRAWL-V1 amendment 17). */
|
|
531
|
+
agent = {}) {
|
|
523
532
|
const gitRoot = findGitRoot();
|
|
524
533
|
if (!gitRoot) {
|
|
525
534
|
throw new PrecomputeFailure('Run this command from a git repository.');
|
|
@@ -527,7 +536,7 @@ minutes) {
|
|
|
527
536
|
const metadataDirectory = walkerMetadataDirectory(gitRoot, deadline);
|
|
528
537
|
const target = repository ?? resolveOriginRepository(gitRoot);
|
|
529
538
|
const chosen = minutes ?? configuredCrawlMinutes(gitRoot);
|
|
530
|
-
const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode);
|
|
539
|
+
const derivation = await derivePrecomputeRequest(gitRoot, target.owner, target.repository, deadline, chosen === undefined ? undefined : chosen * 60_000, mode, pool, agent);
|
|
531
540
|
return { metadataDirectory, derivation };
|
|
532
541
|
}
|
|
533
542
|
/** The explicit (non-hook) submission of a capture: one synchronous
|