@nathapp/nax 0.75.3 → 0.75.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/nax.js +402 -43
- package/flows/nax-finish/nax-finish.flow.ts +155 -16
- package/flows/nax-finish/steps/acceptance.ts +28 -5
- package/flows/nax-finish/steps/forge.ts +55 -6
- package/flows/nax-finish/steps/git.ts +61 -27
- package/flows/nax-finish/types.ts +14 -0
- package/package.json +1 -1
package/dist/nax.js
CHANGED
|
@@ -17317,7 +17317,11 @@ var init_schemas_reporters = __esm(() => {
|
|
|
17317
17317
|
maxBatchSize: exports_external.number().int().positive().default(64),
|
|
17318
17318
|
flushIntervalMs: exports_external.number().int().positive().default(5000),
|
|
17319
17319
|
maxQueueSize: exports_external.number().int().positive().default(2048),
|
|
17320
|
-
phases: exports_external.array(exports_external.string()).optional()
|
|
17320
|
+
phases: exports_external.array(exports_external.string()).optional(),
|
|
17321
|
+
logs: exports_external.object({
|
|
17322
|
+
enabled: exports_external.boolean().default(false),
|
|
17323
|
+
level: exports_external.enum(["silent", "error", "warn", "info", "debug"]).default("info")
|
|
17324
|
+
}).default({ enabled: false, level: "info" })
|
|
17321
17325
|
}).default({
|
|
17322
17326
|
enabled: false,
|
|
17323
17327
|
headers: {},
|
|
@@ -17327,7 +17331,8 @@ var init_schemas_reporters = __esm(() => {
|
|
|
17327
17331
|
heartbeatIntervalMs: 1e4,
|
|
17328
17332
|
maxBatchSize: 64,
|
|
17329
17333
|
flushIntervalMs: 5000,
|
|
17330
|
-
maxQueueSize: 2048
|
|
17334
|
+
maxQueueSize: 2048,
|
|
17335
|
+
logs: { enabled: false, level: "info" }
|
|
17331
17336
|
});
|
|
17332
17337
|
ReportersConfigSchema = exports_external.object({
|
|
17333
17338
|
webhook: WebhookReporterConfigSchema,
|
|
@@ -17347,7 +17352,8 @@ var init_schemas_reporters = __esm(() => {
|
|
|
17347
17352
|
heartbeatIntervalMs: 1e4,
|
|
17348
17353
|
maxBatchSize: 64,
|
|
17349
17354
|
flushIntervalMs: 5000,
|
|
17350
|
-
maxQueueSize: 2048
|
|
17355
|
+
maxQueueSize: 2048,
|
|
17356
|
+
logs: { enabled: false, level: "info" }
|
|
17351
17357
|
}
|
|
17352
17358
|
});
|
|
17353
17359
|
});
|
|
@@ -18388,6 +18394,30 @@ var init_redact = __esm(() => {
|
|
|
18388
18394
|
];
|
|
18389
18395
|
});
|
|
18390
18396
|
|
|
18397
|
+
// src/logger/sink-registry.ts
|
|
18398
|
+
class SinkRegistry {
|
|
18399
|
+
sinks = [];
|
|
18400
|
+
add(sink) {
|
|
18401
|
+
this.sinks.push(sink);
|
|
18402
|
+
return () => {
|
|
18403
|
+
const idx = this.sinks.indexOf(sink);
|
|
18404
|
+
if (idx !== -1) {
|
|
18405
|
+
this.sinks.splice(idx, 1);
|
|
18406
|
+
}
|
|
18407
|
+
};
|
|
18408
|
+
}
|
|
18409
|
+
dispatch(entry) {
|
|
18410
|
+
for (const sink of this.sinks) {
|
|
18411
|
+
try {
|
|
18412
|
+
sink({ ...entry });
|
|
18413
|
+
} catch (error48) {
|
|
18414
|
+
process.stderr.write(`[logger] Sink threw: ${error48}
|
|
18415
|
+
`);
|
|
18416
|
+
}
|
|
18417
|
+
}
|
|
18418
|
+
}
|
|
18419
|
+
}
|
|
18420
|
+
|
|
18391
18421
|
// src/logger/logger.ts
|
|
18392
18422
|
import { mkdirSync } from "fs";
|
|
18393
18423
|
import { appendFile } from "fs/promises";
|
|
@@ -18400,6 +18430,7 @@ class Logger {
|
|
|
18400
18430
|
suppressConsole;
|
|
18401
18431
|
writeQueueTail = Promise.resolve();
|
|
18402
18432
|
pendingLines = [];
|
|
18433
|
+
sinkRegistry = new SinkRegistry;
|
|
18403
18434
|
constructor(options) {
|
|
18404
18435
|
this.level = options.level;
|
|
18405
18436
|
this.filePath = options.filePath;
|
|
@@ -18443,10 +18474,11 @@ class Logger {
|
|
|
18443
18474
|
...sessionRole && { sessionRole },
|
|
18444
18475
|
...strippedData && { data: strippedData }
|
|
18445
18476
|
};
|
|
18477
|
+
const entry = redactEntry(rawEntry);
|
|
18478
|
+
this.sinkRegistry.dispatch(entry);
|
|
18446
18479
|
const consoleEnabled = this.shouldLog(level) && !this.suppressConsole;
|
|
18447
18480
|
if (!consoleEnabled && !this.filePath)
|
|
18448
18481
|
return;
|
|
18449
|
-
const entry = redactEntry(rawEntry);
|
|
18450
18482
|
if (consoleEnabled) {
|
|
18451
18483
|
let consoleOutput = null;
|
|
18452
18484
|
if (this.formatterMode) {
|
|
@@ -18530,8 +18562,19 @@ ${JSON.stringify(entry.data, null, 2)}`;
|
|
|
18530
18562
|
debug: (stage, message, data) => this.log("debug", stage, message, data, storyId)
|
|
18531
18563
|
};
|
|
18532
18564
|
}
|
|
18565
|
+
addSink(sink) {
|
|
18566
|
+
return this.sinkRegistry.add(sink);
|
|
18567
|
+
}
|
|
18533
18568
|
close() {}
|
|
18534
18569
|
}
|
|
18570
|
+
function addSink(sink) {
|
|
18571
|
+
if (!instance) {
|
|
18572
|
+
throw new NaxError("Logger not initialized. Call initLogger() before addSink().", "LOGGER_NOT_INITIALIZED", {
|
|
18573
|
+
stage: "logger"
|
|
18574
|
+
});
|
|
18575
|
+
}
|
|
18576
|
+
return instance.addSink(sink);
|
|
18577
|
+
}
|
|
18535
18578
|
function initLogger(options = { level: "silent" }) {
|
|
18536
18579
|
if (instance) {
|
|
18537
18580
|
throw new Error("Logger already initialized. Call getLogger() to access existing instance.");
|
|
@@ -18560,6 +18603,7 @@ function resetLogger() {
|
|
|
18560
18603
|
}
|
|
18561
18604
|
var LOG_LEVEL_PRIORITY, MAX_BATCH_BYTES, instance = null, noopLogger;
|
|
18562
18605
|
var init_logger = __esm(() => {
|
|
18606
|
+
init_errors();
|
|
18563
18607
|
init_log_format();
|
|
18564
18608
|
init_formatters();
|
|
18565
18609
|
init_redact();
|
|
@@ -42579,7 +42623,7 @@ var package_default;
|
|
|
42579
42623
|
var init_package = __esm(() => {
|
|
42580
42624
|
package_default = {
|
|
42581
42625
|
name: "@nathapp/nax",
|
|
42582
|
-
version: "0.75.
|
|
42626
|
+
version: "0.75.4",
|
|
42583
42627
|
description: "AI Coding Agent Orchestrator \u2014 loops until done",
|
|
42584
42628
|
type: "module",
|
|
42585
42629
|
bin: {
|
|
@@ -42683,8 +42727,8 @@ var init_version = __esm(() => {
|
|
|
42683
42727
|
NAX_VERSION = package_default.version;
|
|
42684
42728
|
NAX_COMMIT = (() => {
|
|
42685
42729
|
try {
|
|
42686
|
-
if (/^[0-9a-f]{6,10}$/.test("
|
|
42687
|
-
return "
|
|
42730
|
+
if (/^[0-9a-f]{6,10}$/.test("5aee16bf"))
|
|
42731
|
+
return "5aee16bf";
|
|
42688
42732
|
} catch {}
|
|
42689
42733
|
try {
|
|
42690
42734
|
const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
|
|
@@ -58554,15 +58598,7 @@ async function runNonBlockingFix(args, overrides = {}) {
|
|
|
58554
58598
|
if (!exhausted) {
|
|
58555
58599
|
const gateVerdict = args.keptTreeRegressed?.();
|
|
58556
58600
|
if (gateVerdict?.regressed) {
|
|
58557
|
-
logger
|
|
58558
|
-
storyId: args.storyId,
|
|
58559
|
-
regressedKeys: gateVerdict.regressedKeys.slice(0, MAX_LOGGED_REGRESSED_KEYS),
|
|
58560
|
-
regressedKeyCount: gateVerdict.regressedKeys.length,
|
|
58561
|
-
baselineKeySize: gateVerdict.baselineKeySize,
|
|
58562
|
-
keyless: gateVerdict.keyless,
|
|
58563
|
-
memoExcludedKeyCount: gateVerdict.memoExcludedKeys.length,
|
|
58564
|
-
flakeTriageRan: false
|
|
58565
|
-
});
|
|
58601
|
+
logGateRegression(logger, args.storyId, "kept tree regressed the full-suite gate \u2014 restoring (ADR-024 \xA73)", gateVerdict);
|
|
58566
58602
|
return restoreToSnapshot(args, _deps, restoreRef, phaseOutputsSnapshot, phaseCostsSnapshot, logger);
|
|
58567
58603
|
}
|
|
58568
58604
|
const cap = args.cfg.sourceDiffCap;
|
|
@@ -58590,8 +58626,23 @@ async function runNonBlockingFix(args, overrides = {}) {
|
|
|
58590
58626
|
logger?.info("non-blocking-fix", "best-effort fix kept", { storyId: args.storyId });
|
|
58591
58627
|
return { ran: true, kept: true, restored: false };
|
|
58592
58628
|
}
|
|
58629
|
+
const exhaustedGateVerdict = args.keptTreeRegressed?.();
|
|
58630
|
+
if (exhaustedGateVerdict?.regressed) {
|
|
58631
|
+
logGateRegression(logger, args.storyId, "best-effort fix exhausted with the full-suite gate red", exhaustedGateVerdict);
|
|
58632
|
+
}
|
|
58593
58633
|
return restoreToSnapshot(args, _deps, restoreRef, phaseOutputsSnapshot, phaseCostsSnapshot, logger);
|
|
58594
58634
|
}
|
|
58635
|
+
function logGateRegression(logger, storyId, message, verdict) {
|
|
58636
|
+
logger?.info("non-blocking-fix", message, {
|
|
58637
|
+
storyId,
|
|
58638
|
+
regressedKeys: verdict.regressedKeys.slice(0, MAX_LOGGED_REGRESSED_KEYS),
|
|
58639
|
+
regressedKeyCount: verdict.regressedKeys.length,
|
|
58640
|
+
baselineKeySize: verdict.baselineKeySize,
|
|
58641
|
+
keyless: verdict.keyless,
|
|
58642
|
+
memoExcludedKeyCount: verdict.memoExcludedKeys.length,
|
|
58643
|
+
flakeTriageRan: false
|
|
58644
|
+
});
|
|
58645
|
+
}
|
|
58595
58646
|
async function restoreToSnapshot(args, _deps, restoreRef, phaseOutputsSnapshot, phaseCostsSnapshot, logger) {
|
|
58596
58647
|
await _deps.rollbackToRef(args.workdir, restoreRef);
|
|
58597
58648
|
for (const key of Object.keys(args.phaseOutputs))
|
|
@@ -58744,10 +58795,18 @@ function gateFailureKeys(gateOutput) {
|
|
|
58744
58795
|
continue;
|
|
58745
58796
|
if (f.category === "flaky-test")
|
|
58746
58797
|
continue;
|
|
58747
|
-
keys.add(
|
|
58798
|
+
keys.add(gateFindingKey(f));
|
|
58748
58799
|
}
|
|
58749
58800
|
return keys;
|
|
58750
58801
|
}
|
|
58802
|
+
function gateFindingKey(finding) {
|
|
58803
|
+
return `${finding.file ?? ""}::${finding.rule ?? ""}`;
|
|
58804
|
+
}
|
|
58805
|
+
function isQuarantinedFlake(finding, quarantineMemo) {
|
|
58806
|
+
if (finding.source !== "test-runner")
|
|
58807
|
+
return false;
|
|
58808
|
+
return quarantineMemo?.has(gateFindingKey(finding)) === true;
|
|
58809
|
+
}
|
|
58751
58810
|
function describeGateRegression(input) {
|
|
58752
58811
|
const { gateOutput, baselineKeys, gateName, storyId, quarantineMemo } = input;
|
|
58753
58812
|
const notRegressed = {
|
|
@@ -59447,9 +59506,12 @@ var init_run_phase = __esm(() => {
|
|
|
59447
59506
|
});
|
|
59448
59507
|
|
|
59449
59508
|
// src/execution/story-orchestrator/rectification.ts
|
|
59450
|
-
function shouldSkipPhaseForRectification(
|
|
59509
|
+
function shouldSkipPhaseForRectification(input) {
|
|
59510
|
+
const { phase, state, phaseOutputs, nbfPath } = input;
|
|
59451
59511
|
if (phase.kind !== "full-suite-gate")
|
|
59452
59512
|
return false;
|
|
59513
|
+
if (nbfPath)
|
|
59514
|
+
return false;
|
|
59453
59515
|
const verifierName = state.verifier?.slot.op.name;
|
|
59454
59516
|
if (!verifierName)
|
|
59455
59517
|
return false;
|
|
@@ -59458,7 +59520,7 @@ function shouldSkipPhaseForRectification(phase, state, phaseOutputs) {
|
|
|
59458
59520
|
function gatherRectificationFindings(phaseOutputs, phases, state) {
|
|
59459
59521
|
const findings = [];
|
|
59460
59522
|
for (const phase of phases) {
|
|
59461
|
-
if (shouldSkipPhaseForRectification(phase, state, phaseOutputs))
|
|
59523
|
+
if (shouldSkipPhaseForRectification({ phase, state, phaseOutputs }))
|
|
59462
59524
|
continue;
|
|
59463
59525
|
for (const f of extractPhaseFindings(phaseOutputs[phase.slot.op.name])) {
|
|
59464
59526
|
if (f.category === "flaky-test")
|
|
@@ -59539,7 +59601,9 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
|
|
|
59539
59601
|
return {};
|
|
59540
59602
|
}
|
|
59541
59603
|
let initialFindings;
|
|
59604
|
+
let nbfPath = false;
|
|
59542
59605
|
if (overrides?.initialFindings) {
|
|
59606
|
+
nbfPath = true;
|
|
59543
59607
|
initialFindings = [...overrides.initialFindings];
|
|
59544
59608
|
} else {
|
|
59545
59609
|
const gateName = state.fullSuiteGate?.slot.op.name;
|
|
@@ -59589,10 +59653,11 @@ async function runRectification(ctx, state, phaseCosts, phaseOutputs, overrides)
|
|
|
59589
59653
|
let shortCircuited = false;
|
|
59590
59654
|
for (const phase of phases) {
|
|
59591
59655
|
await runPhase(ctx, phase.slot, phaseCosts, phaseOutputs);
|
|
59592
|
-
if (shouldSkipPhaseForRectification(phase, state, phaseOutputs))
|
|
59656
|
+
if (shouldSkipPhaseForRectification({ phase, state, phaseOutputs, nbfPath }))
|
|
59593
59657
|
continue;
|
|
59594
59658
|
const output = phaseOutputs[phase.slot.op.name];
|
|
59595
|
-
|
|
59659
|
+
const phaseFindings = extractPhaseFindings(output);
|
|
59660
|
+
findings.push(...nbfPath ? phaseFindings.filter((f) => !isQuarantinedFlake(f, ctx.runtime.quarantineMemo)) : phaseFindings);
|
|
59596
59661
|
if (!phasePassed(phase.slot.op.name, output, ctx.storyId)) {
|
|
59597
59662
|
getSafeLogger()?.warn("story-orchestrator", "Short-circuiting revalidation on phase failure", {
|
|
59598
59663
|
storyId: ctx.storyId,
|
|
@@ -64807,15 +64872,36 @@ var init_config2 = __esm(() => {
|
|
|
64807
64872
|
});
|
|
64808
64873
|
|
|
64809
64874
|
// src/plugins/builtin/nax-finish/telegram.ts
|
|
64875
|
+
function buildEscalationMessage(feature, reason, findings) {
|
|
64876
|
+
const head = `nax-finish escalated ${feature}: ${reason}`;
|
|
64877
|
+
if (findings.length === 0)
|
|
64878
|
+
return head;
|
|
64879
|
+
const footerReserve = `
|
|
64880
|
+
\u2026and ${findings.length} more`.length;
|
|
64881
|
+
const lines = [];
|
|
64882
|
+
let used = head.length;
|
|
64883
|
+
for (const f of findings) {
|
|
64884
|
+
const title = f.title.length > MAX_FINDING_TITLE_CHARS ? `${f.title.slice(0, MAX_FINDING_TITLE_CHARS)}\u2026` : f.title;
|
|
64885
|
+
const line = `
|
|
64886
|
+
- [${f.severity}] ${title}`;
|
|
64887
|
+
if (used + line.length + footerReserve > TELEGRAM_MAX_MESSAGE_CHARS)
|
|
64888
|
+
break;
|
|
64889
|
+
lines.push(line);
|
|
64890
|
+
used += line.length;
|
|
64891
|
+
}
|
|
64892
|
+
const omitted = findings.length - lines.length;
|
|
64893
|
+
return `${head}${lines.join("")}${omitted > 0 ? `
|
|
64894
|
+
\u2026and ${omitted} more` : ""}`;
|
|
64895
|
+
}
|
|
64810
64896
|
async function sendTelegramNotify(cfg, text) {
|
|
64811
64897
|
const res = await _telegramDeps.fetch(`https://api.telegram.org/bot${cfg.token}/sendMessage`, {
|
|
64812
64898
|
method: "POST",
|
|
64813
64899
|
headers: { "content-type": "application/json" },
|
|
64814
|
-
body: JSON.stringify({ chat_id: cfg.chatId, text
|
|
64900
|
+
body: JSON.stringify({ chat_id: cfg.chatId, text })
|
|
64815
64901
|
});
|
|
64816
64902
|
return res.ok;
|
|
64817
64903
|
}
|
|
64818
|
-
var _telegramDeps;
|
|
64904
|
+
var TELEGRAM_MAX_MESSAGE_CHARS = 4096, MAX_FINDING_TITLE_CHARS = 120, _telegramDeps;
|
|
64819
64905
|
var init_telegram2 = __esm(() => {
|
|
64820
64906
|
_telegramDeps = { fetch: (...a) => fetch(...a) };
|
|
64821
64907
|
});
|
|
@@ -64970,8 +65056,32 @@ var init_nax_finish = __esm(() => {
|
|
|
64970
65056
|
message: `nax-finish flow exited ${res.exitCode} (no result file)${tail ? `: ${tail}` : ""}`
|
|
64971
65057
|
};
|
|
64972
65058
|
}
|
|
64973
|
-
if (result.status === "escalated"
|
|
64974
|
-
|
|
65059
|
+
if (result.status === "escalated") {
|
|
65060
|
+
const problems = [];
|
|
65061
|
+
let delivered = !escalateTelegram && !result.deliveryError;
|
|
65062
|
+
if (escalateTelegram && creds) {
|
|
65063
|
+
const sent = await _naxFinishDeps.notify(creds, buildEscalationMessage(result.feature, result.escalationReason ?? "", result.findings ?? []));
|
|
65064
|
+
if (sent)
|
|
65065
|
+
delivered = true;
|
|
65066
|
+
else
|
|
65067
|
+
problems.push("Telegram rejected the message");
|
|
65068
|
+
}
|
|
65069
|
+
if (!delivered) {
|
|
65070
|
+
if (result.deliveryError)
|
|
65071
|
+
problems.push(`the flow could not post it: ${result.deliveryError}`);
|
|
65072
|
+
if (problems.length === 0)
|
|
65073
|
+
problems.push("no escalation channel was reachable");
|
|
65074
|
+
ctx.logger.warn("nax-finish escalation was not delivered", {
|
|
65075
|
+
feature: result.feature,
|
|
65076
|
+
reasons: problems,
|
|
65077
|
+
escalationReason: result.escalationReason
|
|
65078
|
+
});
|
|
65079
|
+
return {
|
|
65080
|
+
success: false,
|
|
65081
|
+
message: `nax-finish: escalated but undelivered \u2014 ${problems.join("; ")}`,
|
|
65082
|
+
url: result.url
|
|
65083
|
+
};
|
|
65084
|
+
}
|
|
64975
65085
|
}
|
|
64976
65086
|
return { success: true, message: `nax-finish: ${result.status}`, url: result.url };
|
|
64977
65087
|
} catch (err) {
|
|
@@ -65114,6 +65224,7 @@ var init_batch_queue = __esm(() => {
|
|
|
65114
65224
|
});
|
|
65115
65225
|
|
|
65116
65226
|
// src/plugins/builtin/otel-reporter/otlp.ts
|
|
65227
|
+
import { hostname as hostname3 } from "os";
|
|
65117
65228
|
function attr(key, value) {
|
|
65118
65229
|
return typeof value === "number" ? { key, value: { doubleValue: value } } : { key, value: { stringValue: value } };
|
|
65119
65230
|
}
|
|
@@ -65133,8 +65244,25 @@ function buildHistogramPoint(values, bounds, attributes, timeUnixNano) {
|
|
|
65133
65244
|
function buildCounterPoint(count, attributes, timeUnixNano) {
|
|
65134
65245
|
return { attributes, timeUnixNano, asInt: String(count) };
|
|
65135
65246
|
}
|
|
65136
|
-
function buildResourceAttributes(
|
|
65137
|
-
|
|
65247
|
+
function buildResourceAttributes(input) {
|
|
65248
|
+
const attrs = [
|
|
65249
|
+
attr("service.name", input.serviceName),
|
|
65250
|
+
attr("nax.run_id", input.runId),
|
|
65251
|
+
attr("nax.version", NAX_VERSION),
|
|
65252
|
+
attr("process.pid", process.pid)
|
|
65253
|
+
];
|
|
65254
|
+
try {
|
|
65255
|
+
attrs.push(attr("host.name", hostname3()));
|
|
65256
|
+
} catch {}
|
|
65257
|
+
if (input.feature !== undefined)
|
|
65258
|
+
attrs.push(attr("nax.feature", input.feature));
|
|
65259
|
+
if (input.project !== undefined)
|
|
65260
|
+
attrs.push(attr("nax.project", input.project));
|
|
65261
|
+
if (input.git?.branch !== undefined)
|
|
65262
|
+
attrs.push(attr("nax.git.branch", input.git.branch));
|
|
65263
|
+
if (input.git?.sha !== undefined)
|
|
65264
|
+
attrs.push(attr("nax.git.sha", input.git.sha));
|
|
65265
|
+
return attrs;
|
|
65138
65266
|
}
|
|
65139
65267
|
function buildTracesPayload(p) {
|
|
65140
65268
|
const span = {
|
|
@@ -65160,7 +65288,18 @@ function buildTracesPayload(p) {
|
|
|
65160
65288
|
return {
|
|
65161
65289
|
resourceSpans: [
|
|
65162
65290
|
{
|
|
65163
|
-
resource: {
|
|
65291
|
+
resource: {
|
|
65292
|
+
attributes: buildResourceAttributes({
|
|
65293
|
+
serviceName: p.serviceName,
|
|
65294
|
+
runId: p.runId,
|
|
65295
|
+
feature: p.feature,
|
|
65296
|
+
project: p.project,
|
|
65297
|
+
git: {
|
|
65298
|
+
branch: p.gitBranch,
|
|
65299
|
+
sha: p.gitSha
|
|
65300
|
+
}
|
|
65301
|
+
})
|
|
65302
|
+
},
|
|
65164
65303
|
scopeSpans: [{ scope: { name: "nax" }, spans: [span, ...p.extraSpans ?? []] }]
|
|
65165
65304
|
}
|
|
65166
65305
|
]
|
|
@@ -65187,7 +65326,18 @@ function buildMetricsPayload(p) {
|
|
|
65187
65326
|
return {
|
|
65188
65327
|
resourceMetrics: [
|
|
65189
65328
|
{
|
|
65190
|
-
resource: {
|
|
65329
|
+
resource: {
|
|
65330
|
+
attributes: buildResourceAttributes({
|
|
65331
|
+
serviceName: p.serviceName,
|
|
65332
|
+
runId: p.runId,
|
|
65333
|
+
feature: p.feature,
|
|
65334
|
+
project: p.project,
|
|
65335
|
+
git: {
|
|
65336
|
+
branch: p.gitBranch,
|
|
65337
|
+
sha: p.gitSha
|
|
65338
|
+
}
|
|
65339
|
+
})
|
|
65340
|
+
},
|
|
65191
65341
|
scopeMetrics: [
|
|
65192
65342
|
{
|
|
65193
65343
|
scope: { name: "nax" },
|
|
@@ -65198,6 +65348,9 @@ function buildMetricsPayload(p) {
|
|
|
65198
65348
|
]
|
|
65199
65349
|
};
|
|
65200
65350
|
}
|
|
65351
|
+
var init_otlp = __esm(() => {
|
|
65352
|
+
init_version();
|
|
65353
|
+
});
|
|
65201
65354
|
|
|
65202
65355
|
// src/plugins/builtin/otel-reporter/heartbeat.ts
|
|
65203
65356
|
function startHeartbeat(opts) {
|
|
@@ -65250,7 +65403,12 @@ function buildHeartbeatMetricsPayload(p) {
|
|
|
65250
65403
|
return {
|
|
65251
65404
|
resourceMetrics: [
|
|
65252
65405
|
{
|
|
65253
|
-
resource: {
|
|
65406
|
+
resource: {
|
|
65407
|
+
attributes: buildResourceAttributes({
|
|
65408
|
+
serviceName: p.serviceName,
|
|
65409
|
+
runId: p.snapshot.attributes.runId
|
|
65410
|
+
})
|
|
65411
|
+
},
|
|
65254
65412
|
scopeMetrics: [
|
|
65255
65413
|
{
|
|
65256
65414
|
scope: { name: "nax" },
|
|
@@ -65268,6 +65426,7 @@ function buildHeartbeatMetricsPayload(p) {
|
|
|
65268
65426
|
var STAGE2 = "otel-reporter-heartbeat";
|
|
65269
65427
|
var init_heartbeat = __esm(() => {
|
|
65270
65428
|
init_logger2();
|
|
65429
|
+
init_otlp();
|
|
65271
65430
|
});
|
|
65272
65431
|
|
|
65273
65432
|
// src/plugins/builtin/otel-reporter/ids.ts
|
|
@@ -65281,6 +65440,78 @@ function randomHex(bytes) {
|
|
|
65281
65440
|
}
|
|
65282
65441
|
var newTraceId = () => randomHex(16), newSpanId = () => randomHex(8);
|
|
65283
65442
|
|
|
65443
|
+
// src/plugins/builtin/otel-reporter/logs.ts
|
|
65444
|
+
function entryTimestampMs(entry) {
|
|
65445
|
+
return new Date(entry.timestamp).getTime();
|
|
65446
|
+
}
|
|
65447
|
+
function toLogRecord(entry) {
|
|
65448
|
+
const timeUnixNano = msToUnixNano(entryTimestampMs(entry));
|
|
65449
|
+
const { number: severityNumber, text: severityText } = SEVERITY[entry.level];
|
|
65450
|
+
const attributes = [attr("nax.stage", entry.stage)];
|
|
65451
|
+
if (entry.storyId !== undefined)
|
|
65452
|
+
attributes.push(attr("nax.story_id", entry.storyId));
|
|
65453
|
+
if (entry.sessionRole !== undefined)
|
|
65454
|
+
attributes.push(attr("nax.session_role", entry.sessionRole));
|
|
65455
|
+
const data = entry.data ?? {};
|
|
65456
|
+
const nonScalars = {};
|
|
65457
|
+
for (const [key, value] of Object.entries(data)) {
|
|
65458
|
+
if (typeof value === "string") {
|
|
65459
|
+
attributes.push(attr(`nax.data.${key}`, truncate3(value)));
|
|
65460
|
+
} else if (typeof value === "number") {
|
|
65461
|
+
if (Number.isFinite(value)) {
|
|
65462
|
+
attributes.push(attr(`nax.data.${key}`, value));
|
|
65463
|
+
} else {
|
|
65464
|
+
nonScalars[key] = value;
|
|
65465
|
+
}
|
|
65466
|
+
} else if (typeof value === "boolean") {
|
|
65467
|
+
attributes.push(attr(`nax.data.${key}`, String(value)));
|
|
65468
|
+
} else {
|
|
65469
|
+
nonScalars[key] = value;
|
|
65470
|
+
}
|
|
65471
|
+
}
|
|
65472
|
+
if (Object.keys(nonScalars).length > 0) {
|
|
65473
|
+
attributes.push(attr("nax.data_json", truncate3(JSON.stringify(nonScalars))));
|
|
65474
|
+
}
|
|
65475
|
+
return {
|
|
65476
|
+
body: { stringValue: entry.message },
|
|
65477
|
+
timeUnixNano,
|
|
65478
|
+
severityNumber,
|
|
65479
|
+
severityText,
|
|
65480
|
+
attributes
|
|
65481
|
+
};
|
|
65482
|
+
}
|
|
65483
|
+
function buildLogsPayload(entries, resource) {
|
|
65484
|
+
const logRecords = entries.map(toLogRecord);
|
|
65485
|
+
return {
|
|
65486
|
+
resourceLogs: [
|
|
65487
|
+
{
|
|
65488
|
+
resource: {
|
|
65489
|
+
attributes: buildResourceAttributes(resource)
|
|
65490
|
+
},
|
|
65491
|
+
scopeLogs: [{ scope: { name: "nax" }, logRecords }]
|
|
65492
|
+
}
|
|
65493
|
+
]
|
|
65494
|
+
};
|
|
65495
|
+
}
|
|
65496
|
+
function truncate3(value) {
|
|
65497
|
+
if (value.length <= DATA_JSON_MAX)
|
|
65498
|
+
return value;
|
|
65499
|
+
const marker = TRUNCATION_MARKER;
|
|
65500
|
+
const keep = DATA_JSON_MAX - marker.length;
|
|
65501
|
+
return `${value.slice(0, keep)}${marker}`;
|
|
65502
|
+
}
|
|
65503
|
+
var SEVERITY, DATA_JSON_MAX = 2048, TRUNCATION_MARKER = "...[truncated]";
|
|
65504
|
+
var init_logs = __esm(() => {
|
|
65505
|
+
init_otlp();
|
|
65506
|
+
SEVERITY = {
|
|
65507
|
+
silent: { number: 0, text: "SILENT" },
|
|
65508
|
+
error: { number: 17, text: "ERROR" },
|
|
65509
|
+
warn: { number: 13, text: "WARN" },
|
|
65510
|
+
info: { number: 9, text: "INFO" },
|
|
65511
|
+
debug: { number: 5, text: "DEBUG" }
|
|
65512
|
+
};
|
|
65513
|
+
});
|
|
65514
|
+
|
|
65284
65515
|
// src/plugins/builtin/otel-reporter/span-tree.ts
|
|
65285
65516
|
function createSpanTree(traceId, runSpanId) {
|
|
65286
65517
|
const storySpanIds = new Map;
|
|
@@ -65364,7 +65595,8 @@ function createPhaseMetricsAggregator() {
|
|
|
65364
65595
|
function recordEscalation(toTier, count) {
|
|
65365
65596
|
bumpCounter(escalations, [attr("to_tier", toTier)], count);
|
|
65366
65597
|
}
|
|
65367
|
-
function buildMetricsPayload2(
|
|
65598
|
+
function buildMetricsPayload2(input) {
|
|
65599
|
+
const { serviceName, runId, timeUnixNano, feature, project, gitBranch, gitSha } = input;
|
|
65368
65600
|
const groups = [...phaseGroups.values()];
|
|
65369
65601
|
const counterMetric = (name, source) => ({
|
|
65370
65602
|
name,
|
|
@@ -65400,7 +65632,15 @@ function createPhaseMetricsAggregator() {
|
|
|
65400
65632
|
return {
|
|
65401
65633
|
resourceMetrics: [
|
|
65402
65634
|
{
|
|
65403
|
-
resource: {
|
|
65635
|
+
resource: {
|
|
65636
|
+
attributes: buildResourceAttributes({
|
|
65637
|
+
serviceName,
|
|
65638
|
+
runId,
|
|
65639
|
+
feature,
|
|
65640
|
+
project,
|
|
65641
|
+
git: { branch: gitBranch, sha: gitSha }
|
|
65642
|
+
})
|
|
65643
|
+
},
|
|
65404
65644
|
scopeMetrics: [{ scope: { name: "nax" }, metrics }]
|
|
65405
65645
|
}
|
|
65406
65646
|
]
|
|
@@ -65410,6 +65650,7 @@ function createPhaseMetricsAggregator() {
|
|
|
65410
65650
|
}
|
|
65411
65651
|
var PHASE_DURATION_BOUNDS, PHASE_COST_BOUNDS;
|
|
65412
65652
|
var init_span_tree = __esm(() => {
|
|
65653
|
+
init_otlp();
|
|
65413
65654
|
PHASE_DURATION_BOUNDS = [100, 500, 1000, 5000, 15000, 60000, 300000, 900000];
|
|
65414
65655
|
PHASE_COST_BOUNDS = [0.001, 0.01, 0.05, 0.1, 0.5, 1, 5];
|
|
65415
65656
|
});
|
|
@@ -65484,11 +65725,11 @@ function reviewSpanEvents(details, timeUnixNano, verbose) {
|
|
|
65484
65725
|
return { timeUnixNano, name: "review.finding", attributes };
|
|
65485
65726
|
});
|
|
65486
65727
|
}
|
|
65487
|
-
function createOtelReporterPlugin(cfg, deps) {
|
|
65728
|
+
function createOtelReporterPlugin(cfg, deps, workdir) {
|
|
65488
65729
|
const states = new Map;
|
|
65489
65730
|
const base = cfg.endpoint?.replace(/\/$/, "");
|
|
65490
65731
|
let tornDown = false;
|
|
65491
|
-
const
|
|
65732
|
+
const makeSendSpanBatch = (resourceAttrs) => async (batch) => {
|
|
65492
65733
|
if (!base || batch.length === 0)
|
|
65493
65734
|
return true;
|
|
65494
65735
|
const { resolved, missing } = interpolateHeaders(cfg.headers);
|
|
@@ -65499,21 +65740,55 @@ function createOtelReporterPlugin(cfg, deps) {
|
|
|
65499
65740
|
const payload = {
|
|
65500
65741
|
resourceSpans: [
|
|
65501
65742
|
{
|
|
65502
|
-
resource: { attributes:
|
|
65743
|
+
resource: { attributes: resourceAttrs },
|
|
65503
65744
|
scopeSpans: [{ scope: { name: "nax" }, spans: batch }]
|
|
65504
65745
|
}
|
|
65505
65746
|
]
|
|
65506
65747
|
};
|
|
65507
|
-
return postJson(`${base}/v1/traces`, payload, {
|
|
65748
|
+
return postJson(`${base}/v1/traces`, payload, {
|
|
65749
|
+
headers: resolved,
|
|
65750
|
+
timeoutMs: cfg.timeoutMs,
|
|
65751
|
+
stage: STAGE3,
|
|
65752
|
+
deps
|
|
65753
|
+
});
|
|
65754
|
+
};
|
|
65755
|
+
const makeSpanQueue = (resourceAttrs) => createBatchQueue({
|
|
65756
|
+
maxBatchSize: cfg.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE,
|
|
65757
|
+
flushIntervalMs: cfg.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
65758
|
+
maxQueueSize: cfg.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
65759
|
+
send: makeSendSpanBatch(resourceAttrs)
|
|
65760
|
+
});
|
|
65761
|
+
const makeSendLogsBatch = (resource) => async (batch) => {
|
|
65762
|
+
if (!base || batch.length === 0)
|
|
65763
|
+
return true;
|
|
65764
|
+
const { resolved, missing } = interpolateHeaders(cfg.headers);
|
|
65765
|
+
if (missing.length > 0) {
|
|
65766
|
+
getSafeLogger()?.warn(STAGE3, "Skipping OTLP export \u2014 unresolved env vars", { missing });
|
|
65767
|
+
return true;
|
|
65768
|
+
}
|
|
65769
|
+
const payload = buildLogsPayload(batch, {
|
|
65770
|
+
serviceName: resource.serviceName,
|
|
65771
|
+
runId: resource.runId,
|
|
65772
|
+
feature: resource.feature,
|
|
65773
|
+
project: resource.project,
|
|
65774
|
+
git: { branch: resource.gitBranch, sha: resource.gitSha }
|
|
65775
|
+
});
|
|
65776
|
+
return postJson(`${base}/v1/logs`, payload, {
|
|
65777
|
+
headers: resolved,
|
|
65778
|
+
timeoutMs: cfg.timeoutMs,
|
|
65779
|
+
stage: STAGE3,
|
|
65780
|
+
deps
|
|
65781
|
+
});
|
|
65508
65782
|
};
|
|
65509
|
-
const
|
|
65783
|
+
const makeLogsQueue = (resource) => createBatchQueue({
|
|
65510
65784
|
maxBatchSize: cfg.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE,
|
|
65511
65785
|
flushIntervalMs: cfg.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
65512
65786
|
maxQueueSize: cfg.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
65513
|
-
send:
|
|
65787
|
+
send: makeSendLogsBatch(resource)
|
|
65514
65788
|
});
|
|
65515
65789
|
const buildOrphanState = (startMs) => {
|
|
65516
65790
|
const identity = rootSpanIdentity();
|
|
65791
|
+
const orphanAttrs = buildResourceAttributes({ serviceName: cfg.serviceName, runId: "orphan" });
|
|
65517
65792
|
return {
|
|
65518
65793
|
...identity,
|
|
65519
65794
|
startMs,
|
|
@@ -65521,7 +65796,7 @@ function createOtelReporterPlugin(cfg, deps) {
|
|
|
65521
65796
|
project: "",
|
|
65522
65797
|
events: [],
|
|
65523
65798
|
spanTree: createSpanTree(identity.traceId, identity.spanId),
|
|
65524
|
-
spanQueue: makeSpanQueue(),
|
|
65799
|
+
spanQueue: makeSpanQueue(orphanAttrs),
|
|
65525
65800
|
metrics: createPhaseMetricsAggregator(),
|
|
65526
65801
|
storyBounds: new Map,
|
|
65527
65802
|
costUsd: 0,
|
|
@@ -65561,6 +65836,9 @@ function createOtelReporterPlugin(cfg, deps) {
|
|
|
65561
65836
|
startUnixNano,
|
|
65562
65837
|
endUnixNano,
|
|
65563
65838
|
feature: st.feature,
|
|
65839
|
+
project: st.project,
|
|
65840
|
+
gitBranch: st.gitBranch,
|
|
65841
|
+
gitSha: st.gitSha,
|
|
65564
65842
|
runId: e.runId,
|
|
65565
65843
|
storySummary: e.storySummary,
|
|
65566
65844
|
totalCost: e.totalCost,
|
|
@@ -65570,11 +65848,23 @@ function createOtelReporterPlugin(cfg, deps) {
|
|
|
65570
65848
|
serviceName: cfg.serviceName,
|
|
65571
65849
|
runId: e.runId,
|
|
65572
65850
|
timeUnixNano: endUnixNano,
|
|
65851
|
+
feature: st.feature,
|
|
65852
|
+
project: st.project,
|
|
65853
|
+
gitBranch: st.gitBranch,
|
|
65854
|
+
gitSha: st.gitSha,
|
|
65573
65855
|
storySummary: e.storySummary,
|
|
65574
65856
|
totalCost: e.totalCost,
|
|
65575
65857
|
totalDurationMs: e.totalDurationMs
|
|
65576
65858
|
});
|
|
65577
|
-
const aggMetrics = st.metrics.buildMetricsPayload(
|
|
65859
|
+
const aggMetrics = st.metrics.buildMetricsPayload({
|
|
65860
|
+
serviceName: cfg.serviceName,
|
|
65861
|
+
runId: e.runId,
|
|
65862
|
+
timeUnixNano: endUnixNano,
|
|
65863
|
+
feature: st.feature,
|
|
65864
|
+
project: st.project,
|
|
65865
|
+
gitBranch: st.gitBranch,
|
|
65866
|
+
gitSha: st.gitSha
|
|
65867
|
+
});
|
|
65578
65868
|
metrics.resourceMetrics[0].scopeMetrics[0].metrics.push(...aggMetrics.resourceMetrics[0].scopeMetrics[0].metrics);
|
|
65579
65869
|
const opts = { headers: resolved, timeoutMs: cfg.timeoutMs, stage: STAGE3, deps };
|
|
65580
65870
|
await postJson(`${base}/v1/traces`, traces, opts);
|
|
@@ -65585,14 +65875,41 @@ function createOtelReporterPlugin(cfg, deps) {
|
|
|
65585
65875
|
async onRunStart(event) {
|
|
65586
65876
|
const identity = rootSpanIdentity();
|
|
65587
65877
|
const runId = event.runId;
|
|
65878
|
+
let gitBranch;
|
|
65879
|
+
let gitSha;
|
|
65880
|
+
if (base && workdir) {
|
|
65881
|
+
const [branchResult, shaResult] = await Promise.all([
|
|
65882
|
+
gitWithTimeout(["rev-parse", "--abbrev-ref", "HEAD"], workdir).catch(() => null),
|
|
65883
|
+
gitWithTimeout(["rev-parse", "HEAD"], workdir).catch(() => null)
|
|
65884
|
+
]);
|
|
65885
|
+
if (branchResult?.exitCode === 0) {
|
|
65886
|
+
const branch = branchResult.stdout.trim();
|
|
65887
|
+
if (branch && branch !== "HEAD")
|
|
65888
|
+
gitBranch = branch;
|
|
65889
|
+
}
|
|
65890
|
+
if (shaResult?.exitCode === 0) {
|
|
65891
|
+
const sha = shaResult.stdout.trim();
|
|
65892
|
+
if (sha)
|
|
65893
|
+
gitSha = sha;
|
|
65894
|
+
}
|
|
65895
|
+
}
|
|
65896
|
+
const resourceAttrs = buildResourceAttributes({
|
|
65897
|
+
serviceName: cfg.serviceName,
|
|
65898
|
+
runId,
|
|
65899
|
+
feature: event.feature,
|
|
65900
|
+
project: event.project,
|
|
65901
|
+
git: { branch: gitBranch, sha: gitSha }
|
|
65902
|
+
});
|
|
65588
65903
|
const state = {
|
|
65589
65904
|
...identity,
|
|
65590
65905
|
startMs: Date.parse(event.startTime),
|
|
65591
65906
|
feature: event.feature,
|
|
65592
65907
|
project: event.project ?? "",
|
|
65908
|
+
gitBranch,
|
|
65909
|
+
gitSha,
|
|
65593
65910
|
events: [],
|
|
65594
65911
|
spanTree: createSpanTree(identity.traceId, identity.spanId),
|
|
65595
|
-
spanQueue: makeSpanQueue(),
|
|
65912
|
+
spanQueue: makeSpanQueue(resourceAttrs),
|
|
65596
65913
|
metrics: createPhaseMetricsAggregator(),
|
|
65597
65914
|
storyBounds: new Map,
|
|
65598
65915
|
costUsd: 0,
|
|
@@ -65603,6 +65920,27 @@ function createOtelReporterPlugin(cfg, deps) {
|
|
|
65603
65920
|
})
|
|
65604
65921
|
};
|
|
65605
65922
|
states.set(runId, state);
|
|
65923
|
+
if (cfg.logs?.enabled) {
|
|
65924
|
+
const logsQueue = makeLogsQueue({
|
|
65925
|
+
serviceName: cfg.serviceName,
|
|
65926
|
+
runId,
|
|
65927
|
+
feature: event.feature,
|
|
65928
|
+
project: event.project ?? "",
|
|
65929
|
+
gitBranch,
|
|
65930
|
+
gitSha
|
|
65931
|
+
});
|
|
65932
|
+
const floorKey = cfg.logs.level;
|
|
65933
|
+
const sank = (entry) => {
|
|
65934
|
+
if (REENTRY_STAGES.has(entry.stage))
|
|
65935
|
+
return;
|
|
65936
|
+
if (LOG_PRIORITY[entry.level] > LOG_PRIORITY[floorKey])
|
|
65937
|
+
return;
|
|
65938
|
+
logsQueue.enqueue(entry);
|
|
65939
|
+
};
|
|
65940
|
+
const addSinkFn = deps?.addSink ?? addSink;
|
|
65941
|
+
state.logsQueue = logsQueue;
|
|
65942
|
+
state.logUnsubscribe = addSinkFn(sank);
|
|
65943
|
+
}
|
|
65606
65944
|
},
|
|
65607
65945
|
async onStoryComplete(event) {
|
|
65608
65946
|
const st = states.get(event.runId);
|
|
@@ -65674,6 +66012,11 @@ function createOtelReporterPlugin(cfg, deps) {
|
|
|
65674
66012
|
states.delete(event.runId);
|
|
65675
66013
|
await st.spanQueue.flushNow();
|
|
65676
66014
|
st.spanQueue.teardown();
|
|
66015
|
+
if (st.logsQueue) {
|
|
66016
|
+
await st.logsQueue.flushNow();
|
|
66017
|
+
st.logsQueue.teardown();
|
|
66018
|
+
st.logUnsubscribe?.();
|
|
66019
|
+
}
|
|
65677
66020
|
await flush(st, startMs + event.totalDurationMs, event);
|
|
65678
66021
|
}
|
|
65679
66022
|
};
|
|
@@ -65691,6 +66034,11 @@ function createOtelReporterPlugin(cfg, deps) {
|
|
|
65691
66034
|
st.heartbeat.stop();
|
|
65692
66035
|
await st.spanQueue.flushNow();
|
|
65693
66036
|
st.spanQueue.teardown();
|
|
66037
|
+
if (st.logsQueue) {
|
|
66038
|
+
await st.logsQueue.flushNow();
|
|
66039
|
+
st.logsQueue.teardown();
|
|
66040
|
+
st.logUnsubscribe?.();
|
|
66041
|
+
}
|
|
65694
66042
|
const endMs = Date.now();
|
|
65695
66043
|
await flush(st, endMs, {
|
|
65696
66044
|
runId,
|
|
@@ -65703,14 +66051,25 @@ function createOtelReporterPlugin(cfg, deps) {
|
|
|
65703
66051
|
extensions: { reporter }
|
|
65704
66052
|
};
|
|
65705
66053
|
}
|
|
65706
|
-
var STAGE3 = "otel-reporter", DEFAULT_MAX_BATCH_SIZE = 64, DEFAULT_FLUSH_INTERVAL_MS = 5000, DEFAULT_MAX_QUEUE_SIZE = 2048;
|
|
66054
|
+
var STAGE3 = "otel-reporter", REENTRY_STAGE = "otel-batch-queue", REENTRY_STAGES, DEFAULT_MAX_BATCH_SIZE = 64, DEFAULT_FLUSH_INTERVAL_MS = 5000, DEFAULT_MAX_QUEUE_SIZE = 2048, LOG_PRIORITY;
|
|
65707
66055
|
var init_otel_reporter = __esm(() => {
|
|
65708
66056
|
init_logger2();
|
|
66057
|
+
init_git();
|
|
65709
66058
|
init_reporter_shared();
|
|
65710
66059
|
init_batch_queue();
|
|
65711
66060
|
init_heartbeat();
|
|
66061
|
+
init_logs();
|
|
66062
|
+
init_otlp();
|
|
65712
66063
|
init_span_tree();
|
|
65713
66064
|
init_traceparent();
|
|
66065
|
+
REENTRY_STAGES = new Set([STAGE3, REENTRY_STAGE]);
|
|
66066
|
+
LOG_PRIORITY = {
|
|
66067
|
+
silent: -1,
|
|
66068
|
+
error: 0,
|
|
66069
|
+
warn: 1,
|
|
66070
|
+
info: 2,
|
|
66071
|
+
debug: 3
|
|
66072
|
+
};
|
|
65714
66073
|
});
|
|
65715
66074
|
|
|
65716
66075
|
// src/plugins/builtin/webhook-reporter/index.ts
|
|
@@ -66166,7 +66525,7 @@ async function loadPlugins(globalDir, projectDir, configPlugins, projectRoot, di
|
|
|
66166
66525
|
{
|
|
66167
66526
|
name: "otel-reporter",
|
|
66168
66527
|
enabled: reporters.otel.enabled,
|
|
66169
|
-
make: () => createOtelReporterPlugin(reporters.otel)
|
|
66528
|
+
make: () => createOtelReporterPlugin(reporters.otel, undefined, effectiveProjectRoot)
|
|
66170
66529
|
}
|
|
66171
66530
|
] : [];
|
|
66172
66531
|
for (const { name, enabled: reporterEnabled, make } of reporterFactories) {
|
|
@@ -17,11 +17,20 @@
|
|
|
17
17
|
* - `load_ctx` is an `action`, not a `compute`: it shells git + `nax features
|
|
18
18
|
* resolve` once, and its output feeds both the review prompts (specPath) and
|
|
19
19
|
* the acceptance gate (groups), so nothing resolves the feature twice.
|
|
20
|
-
* - Review fixes loop: `review_* → route_* → fix_* → (re-run
|
|
21
|
-
* re-review) → review_*` until the reviewer comes back clean or
|
|
22
|
-
* trips. A single-shot fix left the fixed diff unverified.
|
|
20
|
+
* - Review fixes loop: `review_* → route_* → fix_* → commit_* → (re-run
|
|
21
|
+
* acceptance | re-review) → review_*` until the reviewer comes back clean or
|
|
22
|
+
* the fix cap trips. A single-shot fix left the fixed diff unverified.
|
|
23
|
+
* - Every `fix_*` node is followed by a `commit_*` node. The reviewers read
|
|
24
|
+
* `git diff <base>...HEAD`, so an uncommitted fix is invisible to the
|
|
25
|
+
* re-review: the loop re-reported findings that were already fixed and always
|
|
26
|
+
* escalated at the cap (issue #1397).
|
|
23
27
|
* - `route_*` compute nodes hold the escalate/clean/fix decision so the cap is
|
|
24
28
|
* enforced deterministically rather than trusting the model's own route.
|
|
29
|
+
* - `quality_gates` re-runs the feature's acceptance tests before the repo's
|
|
30
|
+
* own commands. The quality-review and gate fix loops both edit code after
|
|
31
|
+
* the `acceptance` node last passed, and the repo-root `test` command does
|
|
32
|
+
* not cover per-feature acceptance tests — so without this a fix could break
|
|
33
|
+
* the contract the first gate proved and still ship.
|
|
25
34
|
*/
|
|
26
35
|
import { defineFlow, extractJsonObject } from "acpx/flows";
|
|
27
36
|
import { buildReviewPrompt, fixPrompt } from "./review-prompts";
|
|
@@ -29,6 +38,7 @@ import {
|
|
|
29
38
|
_contextDeps,
|
|
30
39
|
buildEscalationComment,
|
|
31
40
|
commitAndPush,
|
|
41
|
+
commitFixes,
|
|
32
42
|
detectBaseBranch,
|
|
33
43
|
loadQualityCommands,
|
|
34
44
|
openOrPromotePr,
|
|
@@ -39,7 +49,7 @@ import {
|
|
|
39
49
|
runQualityGates,
|
|
40
50
|
writeResult,
|
|
41
51
|
} from "./steps";
|
|
42
|
-
import type { AcceptanceGroup, FinishInput, ReviewVerdict } from "./types";
|
|
52
|
+
import type { AcceptanceGroup, FinishInput, FinishResult, ReviewVerdict } from "./types";
|
|
43
53
|
|
|
44
54
|
const inputOf = (ctx: { input: unknown }) => ctx.input as FinishInput;
|
|
45
55
|
|
|
@@ -55,6 +65,8 @@ interface LoadCtxOutput {
|
|
|
55
65
|
base?: string;
|
|
56
66
|
specPath?: string;
|
|
57
67
|
groups?: AcceptanceGroup[];
|
|
68
|
+
/** `nax features resolve`'s acceptance status: "ok" | "disabled" | "no-prd". */
|
|
69
|
+
acceptanceStatus?: string;
|
|
58
70
|
route?: string;
|
|
59
71
|
}
|
|
60
72
|
|
|
@@ -66,16 +78,48 @@ function loadCtxOf(ctx: { outputs: unknown }): LoadCtxOutput {
|
|
|
66
78
|
return ((ctx.outputs as Record<string, LoadCtxOutput | undefined>).load_ctx ?? {}) as LoadCtxOutput;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Re-run the acceptance gate, routing on the shared fix-cap rules.
|
|
83
|
+
*
|
|
84
|
+
* "Nothing ran" is not a pass — the same rule `quality_gates` applies to an
|
|
85
|
+
* unconfigured repo. `nax features resolve` reports `groups: []` for BOTH
|
|
86
|
+
* `no-prd` and `disabled`, and reports `exists: false` for a group whose test
|
|
87
|
+
* was expected at its canonical path but never generated. Treating all of those
|
|
88
|
+
* as green let the flow open a ready PR having verified nothing about the
|
|
89
|
+
* feature's own contract (#1398). Only `disabled` — the repo's explicit opt-out
|
|
90
|
+
* — skips cleanly.
|
|
91
|
+
*/
|
|
70
92
|
async function acceptanceGateNode(ctx: {
|
|
71
93
|
input: unknown;
|
|
72
94
|
outputs: unknown;
|
|
73
95
|
state: { steps: { nodeId: string }[] };
|
|
74
96
|
}): Promise<{ route: string; reason?: string; output: string }> {
|
|
75
97
|
const i = inputOf(ctx);
|
|
76
|
-
const groups = loadCtxOf(ctx)
|
|
98
|
+
const { groups = [], acceptanceStatus } = loadCtxOf(ctx);
|
|
99
|
+
if (acceptanceStatus === "disabled") {
|
|
100
|
+
return { route: "proceed", output: "[acceptance] disabled in .nax/config.json — skipping" };
|
|
101
|
+
}
|
|
102
|
+
if (acceptanceStatus === "no-prd") {
|
|
103
|
+
return {
|
|
104
|
+
route: "escalate",
|
|
105
|
+
reason: `Acceptance targets could not be computed (status: no-prd) — nothing was verified for "${i.feature}".`,
|
|
106
|
+
output: "[acceptance] no prd.json resolved — acceptance targets unknown",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
77
110
|
const r = await runAcceptanceGate(i.workdir, groups, { timeoutMs: i.timeouts?.acceptanceMs });
|
|
78
|
-
if (r.passed)
|
|
111
|
+
if (r.passed) {
|
|
112
|
+
// A real failure below routes to the fix loop, which is more actionable;
|
|
113
|
+
// the coverage hole is only reported once the runnable groups are green.
|
|
114
|
+
if (r.missing.length > 0) {
|
|
115
|
+
return {
|
|
116
|
+
route: "escalate",
|
|
117
|
+
reason: `Acceptance test never generated for: ${r.missing.join(", ")} — that package's contract is unverified.`,
|
|
118
|
+
output: r.output,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { route: "proceed", output: r.output };
|
|
122
|
+
}
|
|
79
123
|
const attempts = fixAttemptCount(ctx, "fix_acceptance");
|
|
80
124
|
if (attempts >= MAX_FIX_ATTEMPTS) {
|
|
81
125
|
return {
|
|
@@ -119,6 +163,26 @@ function routeReview(
|
|
|
119
163
|
return { route: "fix", findings };
|
|
120
164
|
}
|
|
121
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Build the `commit_<phase>` node that follows `fix_<phase>`.
|
|
168
|
+
*
|
|
169
|
+
* One node per phase rather than a single shared one because each returns to a
|
|
170
|
+
* different successor, and acpx routes on the node id — a shared node would
|
|
171
|
+
* need a switch reconstructing which fix ran from the step history.
|
|
172
|
+
*/
|
|
173
|
+
function commitFixNode(phase: "acceptance" | "spec" | "quality" | "gate") {
|
|
174
|
+
return {
|
|
175
|
+
nodeType: "action" as const,
|
|
176
|
+
async run(ctx: { input: unknown }): Promise<{ committed: boolean }> {
|
|
177
|
+
const i = inputOf(ctx);
|
|
178
|
+
// skipHooks: an intermediate checkpoint must not be rejected by a repo's
|
|
179
|
+
// pre-commit hook — quality_gates runs the repo's real gates before any
|
|
180
|
+
// PR opens, and a hook failure here would kill the flow mid-loop.
|
|
181
|
+
return commitFixes(i.workdir, `fix(${i.feature}): nax-finish ${phase} fixes`, { skipHooks: true });
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
122
186
|
/** Normalise a reviewer's JSON, rewriting a findings-free `proceed` to `clean`. */
|
|
123
187
|
function parseVerdict(text: string): ReviewVerdict {
|
|
124
188
|
const raw = extractJsonObject(text) as Partial<ReviewVerdict>;
|
|
@@ -162,6 +226,7 @@ export default defineFlow({
|
|
|
162
226
|
prompt: (ctx) => fixPrompt("acceptance", ctx),
|
|
163
227
|
parse: parseVerdict,
|
|
164
228
|
},
|
|
229
|
+
commit_acceptance: commitFixNode("acceptance"),
|
|
165
230
|
review_spec: {
|
|
166
231
|
nodeType: "acp",
|
|
167
232
|
session: { isolated: true },
|
|
@@ -181,6 +246,7 @@ export default defineFlow({
|
|
|
181
246
|
prompt: (ctx) => fixPrompt("spec", ctx),
|
|
182
247
|
parse: parseVerdict,
|
|
183
248
|
},
|
|
249
|
+
commit_spec: commitFixNode("spec"),
|
|
184
250
|
review_quality: {
|
|
185
251
|
nodeType: "acp",
|
|
186
252
|
session: { isolated: true },
|
|
@@ -200,15 +266,57 @@ export default defineFlow({
|
|
|
200
266
|
prompt: (ctx) => fixPrompt("quality", ctx),
|
|
201
267
|
parse: parseVerdict,
|
|
202
268
|
},
|
|
269
|
+
commit_quality: commitFixNode("quality"),
|
|
203
270
|
fix_gate: {
|
|
204
271
|
nodeType: "acp",
|
|
205
272
|
prompt: (ctx) => fixPrompt("gate", ctx),
|
|
206
273
|
parse: parseVerdict,
|
|
207
274
|
},
|
|
275
|
+
commit_gate: commitFixNode("gate"),
|
|
208
276
|
quality_gates: {
|
|
209
277
|
nodeType: "action",
|
|
210
278
|
async run(ctx) {
|
|
211
279
|
const i = inputOf(ctx);
|
|
280
|
+
|
|
281
|
+
// Acceptance is gate zero here, not just at the `acceptance` node.
|
|
282
|
+
// Both fix loops that run after it — quality review and this gate —
|
|
283
|
+
// edit code, and the repo-root `test` command does not cover the
|
|
284
|
+
// feature's acceptance tests: they live under `<pkg>/.nax/features/<f>/`
|
|
285
|
+
// and usually need their own runner config. Re-running them here is what
|
|
286
|
+
// makes "nothing reaches open_pr without the feature's own contract
|
|
287
|
+
// passing against the tree as it will ship" true on every path (#1398).
|
|
288
|
+
//
|
|
289
|
+
// Unconditional, though the common green path re-runs a gate that
|
|
290
|
+
// already passed: acceptance is the cheapest gate in the pipeline, and a
|
|
291
|
+
// conditional skip derived from step history would be a check that can
|
|
292
|
+
// be *wrong* — a silent false green, the failure mode this exists to
|
|
293
|
+
// prevent.
|
|
294
|
+
//
|
|
295
|
+
// `missing` is deliberately ignored: groups are resolved once at
|
|
296
|
+
// load_ctx, so a coverage hole was already escalated by the acceptance
|
|
297
|
+
// node and cannot appear here.
|
|
298
|
+
const acc = await runAcceptanceGate(i.workdir, loadCtxOf(ctx).groups ?? [], {
|
|
299
|
+
timeoutMs: i.timeouts?.acceptanceMs,
|
|
300
|
+
});
|
|
301
|
+
if (!acc.passed) {
|
|
302
|
+
// Short-circuit: the repo gates are re-run next round anyway, and
|
|
303
|
+
// skipping them keeps this out of the "nothing configured" branch
|
|
304
|
+
// below, which would otherwise misreport configured-but-skipped
|
|
305
|
+
// commands as absent.
|
|
306
|
+
const accAttempts = fixAttemptCount(ctx, "fix_gate");
|
|
307
|
+
const failing = ["acceptance"];
|
|
308
|
+
if (accAttempts >= MAX_FIX_ATTEMPTS) {
|
|
309
|
+
return {
|
|
310
|
+
route: "escalate",
|
|
311
|
+
reason: `A later fix broke the feature's own contract: acceptance still failing after ${accAttempts} fix attempts.`,
|
|
312
|
+
ran: [],
|
|
313
|
+
failing,
|
|
314
|
+
output: acc.output,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return { route: "fix", ran: [], failing, output: acc.output };
|
|
318
|
+
}
|
|
319
|
+
|
|
212
320
|
const cmds = await loadQualityCommands(i.workdir);
|
|
213
321
|
const r = await runQualityGates(i.workdir, cmds, { timeoutMs: i.timeouts?.gateMs });
|
|
214
322
|
if (r.passed) return { route: "green", ran: r.ran, failing: r.failing, output: r.output };
|
|
@@ -289,12 +397,36 @@ export default defineFlow({
|
|
|
289
397
|
syncNote = `\n\n> Note: nax-finish could not push its partial fixes — ${String(err)}`;
|
|
290
398
|
}
|
|
291
399
|
|
|
400
|
+
// Write the result BEFORE attempting delivery. Delivery touches the
|
|
401
|
+
// network and the forge — a rate limit, an expired token, a locked PR
|
|
402
|
+
// or an unrecognised remote used to throw here, killing the node before
|
|
403
|
+
// any result existed. The plugin then had nothing to report and, on the
|
|
404
|
+
// Telegram channel, nothing to notify from: the one path whose job is
|
|
405
|
+
// to say "a human is needed" was the one path with no fallback (#1399).
|
|
406
|
+
const result: FinishResult = {
|
|
407
|
+
feature: i.feature,
|
|
408
|
+
status: "escalated",
|
|
409
|
+
escalationReason: reason,
|
|
410
|
+
findings: verdict?.findings ?? [],
|
|
411
|
+
};
|
|
412
|
+
await writeResult(i.workdir, result);
|
|
413
|
+
|
|
292
414
|
const comment = buildEscalationComment(i.feature, reason, verdict?.findings ?? []) + syncNote;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
415
|
+
let url: string | undefined;
|
|
416
|
+
let channel: string | undefined;
|
|
417
|
+
let deliveryError: string | undefined;
|
|
418
|
+
try {
|
|
419
|
+
const posted = await postEscalation(i.workdir, i.branch, comment, {
|
|
420
|
+
preferTelegram: i.escalateTelegram,
|
|
421
|
+
});
|
|
422
|
+
url = posted.url;
|
|
423
|
+
channel = posted.channel;
|
|
424
|
+
} catch (err) {
|
|
425
|
+
deliveryError = String(err);
|
|
426
|
+
}
|
|
427
|
+
await writeResult(i.workdir, { ...result, url, deliveryError });
|
|
428
|
+
|
|
429
|
+
return { route: "done", url, channel, deliveryError, escalationReason: reason };
|
|
298
430
|
},
|
|
299
431
|
},
|
|
300
432
|
},
|
|
@@ -304,7 +436,11 @@ export default defineFlow({
|
|
|
304
436
|
from: "acceptance",
|
|
305
437
|
switch: { on: "$.route", cases: { proceed: "review_spec", fix: "fix_acceptance", escalate: "escalate" } },
|
|
306
438
|
},
|
|
307
|
-
|
|
439
|
+
// Each fix commits before anything re-reads the diff: the reviewers see
|
|
440
|
+
// `git diff <base>...HEAD` only, so an uncommitted fix would be re-reported
|
|
441
|
+
// verbatim until the cap escalated it (#1397).
|
|
442
|
+
{ from: "fix_acceptance", to: "commit_acceptance" },
|
|
443
|
+
{ from: "commit_acceptance", to: "acceptance" },
|
|
308
444
|
{ from: "review_spec", to: "route_spec" },
|
|
309
445
|
{
|
|
310
446
|
from: "route_spec",
|
|
@@ -312,7 +448,8 @@ export default defineFlow({
|
|
|
312
448
|
},
|
|
313
449
|
// Spec fixes re-run the acceptance gate first (they can break it), and the
|
|
314
450
|
// acceptance node's `proceed` edge leads back into review_spec for re-review.
|
|
315
|
-
{ from: "fix_spec", to: "
|
|
451
|
+
{ from: "fix_spec", to: "commit_spec" },
|
|
452
|
+
{ from: "commit_spec", to: "acceptance" },
|
|
316
453
|
{ from: "review_quality", to: "route_quality" },
|
|
317
454
|
{
|
|
318
455
|
from: "route_quality",
|
|
@@ -320,11 +457,13 @@ export default defineFlow({
|
|
|
320
457
|
},
|
|
321
458
|
// Quality fixes are re-reviewed by the same lens; the repo-root gates that
|
|
322
459
|
// follow catch anything the fix broke mechanically.
|
|
323
|
-
{ from: "fix_quality", to: "
|
|
460
|
+
{ from: "fix_quality", to: "commit_quality" },
|
|
461
|
+
{ from: "commit_quality", to: "review_quality" },
|
|
324
462
|
{
|
|
325
463
|
from: "quality_gates",
|
|
326
464
|
switch: { on: "$.route", cases: { green: "open_pr", fix: "fix_gate", escalate: "escalate" } },
|
|
327
465
|
},
|
|
328
|
-
{ from: "fix_gate", to: "
|
|
466
|
+
{ from: "fix_gate", to: "commit_gate" },
|
|
467
|
+
{ from: "commit_gate", to: "quality_gates" },
|
|
329
468
|
],
|
|
330
469
|
});
|
|
@@ -21,24 +21,47 @@ export function buildAcceptanceCommand(repoRoot: string, group: AcceptanceGroup)
|
|
|
21
21
|
return template.replace(/\{\{FILE\}\}|\{\{file\}\}|\{\{files\}\}/g, absFile);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface AcceptanceGateOutcome {
|
|
25
|
+
/** Every group that ran exited 0. Says nothing about groups that could not run. */
|
|
26
|
+
passed: boolean;
|
|
27
|
+
ran: number;
|
|
28
|
+
/**
|
|
29
|
+
* Package names whose acceptance test the resolver expected at its canonical
|
|
30
|
+
* path but which is absent on disk — never generated, or generation failed.
|
|
31
|
+
* The caller must treat a non-empty list as a coverage hole rather than a
|
|
32
|
+
* pass: skipping these silently is how a feature reached a "ready" PR with
|
|
33
|
+
* nothing verified.
|
|
34
|
+
*/
|
|
35
|
+
missing: string[];
|
|
36
|
+
output: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
export async function runAcceptanceGate(
|
|
25
40
|
repoRoot: string,
|
|
26
41
|
groups: AcceptanceGroup[],
|
|
27
42
|
opts: { timeoutMs?: number } = {},
|
|
28
|
-
): Promise<
|
|
43
|
+
): Promise<AcceptanceGateOutcome> {
|
|
29
44
|
const chunks: string[] = [];
|
|
30
45
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_ACCEPTANCE_TIMEOUT_MS;
|
|
46
|
+
const missing: string[] = [];
|
|
31
47
|
let ran = 0;
|
|
32
48
|
for (const g of groups) {
|
|
33
|
-
|
|
49
|
+
const name = g.packageDir || "root";
|
|
50
|
+
if (!g.exists) {
|
|
51
|
+
missing.push(name);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
34
54
|
const cwd = g.packageDir ? `${repoRoot}/${g.packageDir}` : repoRoot;
|
|
35
55
|
ran += 1;
|
|
36
56
|
const res = await _acceptanceDeps.runShell(buildAcceptanceCommand(repoRoot, g), { cwd, timeoutMs });
|
|
37
|
-
chunks.push(`[${
|
|
38
|
-
if (res.exitCode !== 0) return { passed: false, ran, output: chunks.join("\n\n") };
|
|
57
|
+
chunks.push(`[${name}] exit=${res.exitCode}\n${res.stdout}\n${res.stderr}`);
|
|
58
|
+
if (res.exitCode !== 0) return { passed: false, ran, missing, output: chunks.join("\n\n") };
|
|
59
|
+
}
|
|
60
|
+
if (missing.length > 0) {
|
|
61
|
+
chunks.push(`[acceptance] no acceptance test file on disk for: ${missing.join(", ")}`);
|
|
39
62
|
}
|
|
40
63
|
if (ran === 0) chunks.push("[acceptance] no acceptance test files present — nothing to run");
|
|
41
|
-
return { passed: true, ran, output: chunks.join("\n\n") };
|
|
64
|
+
return { passed: true, ran, missing, output: chunks.join("\n\n") };
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
function languageRunner(language: string): string {
|
|
@@ -12,15 +12,64 @@ const URL_REGEX = /https?:\/\/\S+/;
|
|
|
12
12
|
|
|
13
13
|
export type Forge = "github" | "gitlab";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Host of a git remote, for both URL forms git accepts:
|
|
17
|
+
* `git@host:path` (scp-like) and `scheme://[user@]host[:port]/path`.
|
|
18
|
+
*/
|
|
19
|
+
export function remoteHost(remoteUrl: string): string {
|
|
20
|
+
const scp = remoteUrl.match(/^[^/]*@([^:/]+):/);
|
|
21
|
+
if (scp?.[1]) return scp[1].toLowerCase();
|
|
22
|
+
const url = remoteUrl.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]*@)?([^:/]+)/i);
|
|
23
|
+
return url?.[1]?.toLowerCase() ?? "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Classify by host name.
|
|
28
|
+
*
|
|
29
|
+
* Matching the host (not a substring of the whole URL) is what makes
|
|
30
|
+
* self-hosted instances work: `"gitlab.mycorp.com".includes("gitlab.com")` is
|
|
31
|
+
* false, so the previous check rejected every self-hosted forge. GitHub is
|
|
32
|
+
* tested first purely for determinism on an absurd host naming both.
|
|
33
|
+
*/
|
|
34
|
+
function forgeFromHost(host: string): Forge | null {
|
|
35
|
+
if (host.includes("github")) return "github";
|
|
36
|
+
if (host.includes("gitlab")) return "gitlab";
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Last resort for an enterprise host that names neither forge (`git.corp.com`):
|
|
42
|
+
* ask which CLI is installed. Only decisive when exactly one is — with both or
|
|
43
|
+
* neither present a guess would send `gh` at a GitLab remote.
|
|
44
|
+
*/
|
|
45
|
+
async function forgeFromCli(run: RunFn, repoRoot: string): Promise<Forge | null> {
|
|
46
|
+
const [gh, glab] = await Promise.all([
|
|
47
|
+
run(["gh", "--version"], { cwd: repoRoot }),
|
|
48
|
+
run(["glab", "--version"], { cwd: repoRoot }),
|
|
49
|
+
]);
|
|
50
|
+
const hasGh = gh.exitCode === 0;
|
|
51
|
+
const hasGlab = glab.exitCode === 0;
|
|
52
|
+
if (hasGh && !hasGlab) return "github";
|
|
53
|
+
if (hasGlab && !hasGh) return "gitlab";
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
15
57
|
export async function detectForge(run: RunFn, repoRoot: string, stage: string): Promise<Forge> {
|
|
16
58
|
const remote = await run(["git", "remote", "get-url", "origin"], { cwd: repoRoot });
|
|
17
59
|
const remoteUrl = remote.stdout.trim();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
60
|
+
const host = remoteHost(remoteUrl);
|
|
61
|
+
|
|
62
|
+
const byHost = forgeFromHost(host);
|
|
63
|
+
if (byHost) return byHost;
|
|
64
|
+
|
|
65
|
+
const byCli = await forgeFromCli(run, repoRoot);
|
|
66
|
+
if (byCli) return byCli;
|
|
67
|
+
|
|
68
|
+
throw new FinishError(
|
|
69
|
+
`Unable to determine forge for remote host "${host || remoteUrl}" — its name matches neither github nor gitlab, and the gh/glab probe was inconclusive`,
|
|
70
|
+
"FINISH_UNKNOWN_FORGE",
|
|
71
|
+
{ stage, remoteUrl, host },
|
|
72
|
+
);
|
|
24
73
|
}
|
|
25
74
|
|
|
26
75
|
/** Best-effort URL extraction: try `{url}` JSON first, fall back to a raw URL regex. */
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Branch synchronisation for the nax-finish flow.
|
|
3
3
|
*
|
|
4
|
-
* Every fix node edits the working tree in place
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Every fix node edits the working tree in place, which two different consumers
|
|
5
|
+
* would otherwise miss:
|
|
6
|
+
*
|
|
7
|
+
* - The **reviewers** read `git diff <base>...HEAD` — committed history only.
|
|
8
|
+
* With the fixes uncommitted, every re-review re-read the pre-fix code and
|
|
9
|
+
* re-reported findings the fix node had already resolved, so the loop could
|
|
10
|
+
* never converge and always escalated at the fix cap (issue #1397). Each
|
|
11
|
+
* `commit_*` node calls `commitFixes` for this reason.
|
|
12
|
+
* - The **forge**: `gh pr create --head <branch>` opens a PR from the *remote*
|
|
13
|
+
* branch, and an escalation comment would describe state nobody else can see.
|
|
14
|
+
* Both terminal nodes call `commitAndPush` before touching the forge.
|
|
9
15
|
*/
|
|
10
16
|
import { FinishError } from "../errors";
|
|
11
17
|
import { runArgv } from "../exec";
|
|
@@ -32,34 +38,62 @@ async function isDirty(repoRoot: string): Promise<boolean> {
|
|
|
32
38
|
return status.stdout.trim().length > 0;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Commit the working tree, if it has anything in it, without pushing.
|
|
43
|
+
*
|
|
44
|
+
* Called by the `commit_*` nodes after every fix node so the next reviewer's
|
|
45
|
+
* `git diff <base>...HEAD` contains the fix. `git add -A` (not `-u`) because a
|
|
46
|
+
* fix routinely adds a *new* test file, and an untracked file is invisible to
|
|
47
|
+
* that diff — which is also why committing beats widening the reviewer's diff
|
|
48
|
+
* to include the working tree.
|
|
49
|
+
*
|
|
50
|
+
* `skipHooks` (used by every mid-loop `commit_*` node) adds `--no-verify`. Those
|
|
51
|
+
* commits are internal checkpoints, not shipped history: a repo whose
|
|
52
|
+
* pre-commit hook runs lint or typecheck would otherwise reject an intermediate
|
|
53
|
+
* state — a lint error the gate loop was about to fix — and take the whole flow
|
|
54
|
+
* down with it, with no result file. Nothing is lost by skipping them, because
|
|
55
|
+
* `quality_gates` runs the repo's own build/typecheck/lint/test and no PR opens
|
|
56
|
+
* unless they are green. The terminal `commitAndPush` leaves hooks enabled.
|
|
57
|
+
*
|
|
58
|
+
* A failing commit still throws: the fix is then unreviewable, and continuing
|
|
59
|
+
* would silently reproduce the stale-diff bug this exists to fix.
|
|
60
|
+
*/
|
|
61
|
+
export async function commitFixes(
|
|
62
|
+
repoRoot: string,
|
|
63
|
+
message: string,
|
|
64
|
+
opts: { skipHooks?: boolean } = {},
|
|
65
|
+
): Promise<{ committed: boolean }> {
|
|
66
|
+
if (!(await isDirty(repoRoot))) return { committed: false };
|
|
67
|
+
|
|
68
|
+
const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
|
|
69
|
+
if (add.exitCode !== 0) {
|
|
70
|
+
throw new FinishError(
|
|
71
|
+
`git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
|
|
72
|
+
"FINISH_GIT_ADD_FAILED",
|
|
73
|
+
{ stage: "finish-git", repoRoot },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const commitArgv = ["git", "commit", "-m", message, ...(opts.skipHooks ? ["--no-verify"] : [])];
|
|
77
|
+
const commit = await _gitDeps.run(commitArgv, { cwd: repoRoot });
|
|
78
|
+
if (commit.exitCode !== 0) {
|
|
79
|
+
throw new FinishError(
|
|
80
|
+
`git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
|
|
81
|
+
"FINISH_GIT_COMMIT_FAILED",
|
|
82
|
+
{ stage: "finish-git", repoRoot },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return { committed: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
35
88
|
/**
|
|
36
89
|
* Commit any outstanding fixes and push the branch to `origin`.
|
|
37
90
|
*
|
|
38
91
|
* The push is unconditional — even with nothing new to commit the local branch
|
|
39
|
-
* may be ahead of its remote (nax's own run commits, or
|
|
40
|
-
*
|
|
92
|
+
* may be ahead of its remote (nax's own run commits, or the `commit_*` nodes'
|
|
93
|
+
* per-round commits), and the PR must reflect HEAD.
|
|
41
94
|
*/
|
|
42
95
|
export async function commitAndPush(repoRoot: string, branch: string, message: string): Promise<SyncOutcome> {
|
|
43
|
-
|
|
44
|
-
if (await isDirty(repoRoot)) {
|
|
45
|
-
const add = await _gitDeps.run(["git", "add", "-A"], { cwd: repoRoot });
|
|
46
|
-
if (add.exitCode !== 0) {
|
|
47
|
-
throw new FinishError(
|
|
48
|
-
`git add failed in "${repoRoot}": ${add.stderr.trim() || `exit ${add.exitCode}`}`,
|
|
49
|
-
"FINISH_GIT_ADD_FAILED",
|
|
50
|
-
{ stage: "finish-git", repoRoot },
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
const commit = await _gitDeps.run(["git", "commit", "-m", message], { cwd: repoRoot });
|
|
54
|
-
if (commit.exitCode !== 0) {
|
|
55
|
-
throw new FinishError(
|
|
56
|
-
`git commit failed in "${repoRoot}": ${commit.stderr.trim() || commit.stdout.trim() || `exit ${commit.exitCode}`}`,
|
|
57
|
-
"FINISH_GIT_COMMIT_FAILED",
|
|
58
|
-
{ stage: "finish-git", repoRoot, branch },
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
committed = true;
|
|
62
|
-
}
|
|
96
|
+
const { committed } = await commitFixes(repoRoot, message);
|
|
63
97
|
|
|
64
98
|
const push = await _gitDeps.run(["git", "push", "--set-upstream", "origin", branch], { cwd: repoRoot });
|
|
65
99
|
if (push.exitCode !== 0) {
|
|
@@ -48,6 +48,20 @@ export interface FinishResult {
|
|
|
48
48
|
status: "opened" | "promoted" | "already-ready" | "escalated" | "nothing-to-finish";
|
|
49
49
|
url?: string;
|
|
50
50
|
escalationReason?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The findings behind an escalation. Persisted because the reason alone is a
|
|
53
|
+
* bare count ("3 finding(s) after 3 fix attempts"), and on the Telegram
|
|
54
|
+
* channel the composed PR comment — the only other thing carrying them — is
|
|
55
|
+
* never posted. Without this the findings survived only in acpx's run bundle.
|
|
56
|
+
*/
|
|
57
|
+
findings?: Finding[];
|
|
58
|
+
/**
|
|
59
|
+
* Set when the escalation could not be delivered to its channel (forge
|
|
60
|
+
* comment failed, remote unrecognised). The result file is written before
|
|
61
|
+
* delivery is attempted, so an undelivered escalation is still reported
|
|
62
|
+
* rather than lost.
|
|
63
|
+
*/
|
|
64
|
+
deliveryError?: string;
|
|
51
65
|
}
|
|
52
66
|
export interface RunResult {
|
|
53
67
|
exitCode: number;
|