ccqa 1.48.2 → 1.50.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/dist/bin/ccqa.mjs +160 -42
- package/dist/hub-client/index.d.mts +4 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { connect as connect$1 } from "node:tls";
|
|
|
23
23
|
import { gunzipSync, gzipSync } from "node:zlib";
|
|
24
24
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
25
25
|
import { createInterface as createInterface$1 } from "node:readline";
|
|
26
|
+
import { pipeline } from "node:stream/promises";
|
|
26
27
|
//#region src/run/report-constants.ts
|
|
27
28
|
/**
|
|
28
29
|
* Pure report/run constants with no runtime dependencies. Kept separate from
|
|
@@ -1501,6 +1502,8 @@ const ReportSpecResultSchema = z.object({
|
|
|
1501
1502
|
failed: z.number()
|
|
1502
1503
|
}).nullable(),
|
|
1503
1504
|
durationMs: z.number().nullable(),
|
|
1505
|
+
startedAt: z.string().optional(),
|
|
1506
|
+
finishedAt: z.string().optional(),
|
|
1504
1507
|
assertions: z.array(ReportAssertionSchema).nullable(),
|
|
1505
1508
|
analysis: FailureAnalysisSchema.nullable(),
|
|
1506
1509
|
analysisSkipped: z.string().nullable(),
|
|
@@ -6197,6 +6200,7 @@ async function runOneSpec$1(ref, opts, blocks) {
|
|
|
6197
6200
|
meta("command", command);
|
|
6198
6201
|
blank();
|
|
6199
6202
|
const started = Date.now();
|
|
6203
|
+
const startedAt = new Date(started).toISOString();
|
|
6200
6204
|
let outcome;
|
|
6201
6205
|
let spawnFailure;
|
|
6202
6206
|
let measured;
|
|
@@ -6217,6 +6221,7 @@ async function runOneSpec$1(ref, opts, blocks) {
|
|
|
6217
6221
|
const coverageFields = coverageRowFields(opts, measured, attachError);
|
|
6218
6222
|
if (spawnFailure !== void 0 || outcome === void 0) return {
|
|
6219
6223
|
...didNotExecute(`could not spawn runCommand: ${spawnFailure ?? "unknown error"}`, "the runCommand could not be spawned"),
|
|
6224
|
+
startedAt,
|
|
6220
6225
|
...coverageFields
|
|
6221
6226
|
};
|
|
6222
6227
|
const durationMs = Date.now() - started;
|
|
@@ -6243,6 +6248,8 @@ async function runOneSpec$1(ref, opts, blocks) {
|
|
|
6243
6248
|
status: "passed"
|
|
6244
6249
|
}),
|
|
6245
6250
|
target: opts.targetId,
|
|
6251
|
+
startedAt,
|
|
6252
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6246
6253
|
durationMs,
|
|
6247
6254
|
...artifactFields,
|
|
6248
6255
|
...evidenceFields,
|
|
@@ -6250,6 +6257,8 @@ async function runOneSpec$1(ref, opts, blocks) {
|
|
|
6250
6257
|
};
|
|
6251
6258
|
return {
|
|
6252
6259
|
...failedRow([`command failed (exit ${outcome.exitCode}): ${command}`, outcome.tail.length > 0 ? `--- output (tail) ---\n${outcome.tail}` : null].filter((p) => p !== null).join("\n")),
|
|
6260
|
+
startedAt,
|
|
6261
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6253
6262
|
durationMs,
|
|
6254
6263
|
...artifactFields,
|
|
6255
6264
|
...evidenceFields,
|
|
@@ -10188,6 +10197,36 @@ function foldTouchIndex(current, entry, selection) {
|
|
|
10188
10197
|
}
|
|
10189
10198
|
return out;
|
|
10190
10199
|
}
|
|
10200
|
+
/**
|
|
10201
|
+
* How much clock skew between the runner (which stamps a row's window) and
|
|
10202
|
+
* the hub (which stamps a deploy) the placement tolerates. The window is
|
|
10203
|
+
* widened by this on both ends, so a skewed clock costs a row its credit
|
|
10204
|
+
* rather than crediting a row that straddled.
|
|
10205
|
+
*/
|
|
10206
|
+
const PLACEMENT_SKEW_MS = 1e4;
|
|
10207
|
+
/**
|
|
10208
|
+
* Place one row's execution window against the deploy log (ADR-0027).
|
|
10209
|
+
*
|
|
10210
|
+
* The window is widened by {@link PLACEMENT_SKEW_MS} and its start is
|
|
10211
|
+
* exclusive, so a deploy landing exactly as the spec began reads as a
|
|
10212
|
+
* straddle. Both choices err the same way: toward `ambiguous`, never toward
|
|
10213
|
+
* crediting a spec with a commit it may not have exercised.
|
|
10214
|
+
*
|
|
10215
|
+
* `entries` must be in append order, which the log guarantees and which is
|
|
10216
|
+
* time order.
|
|
10217
|
+
*/
|
|
10218
|
+
function placeRowInDeployLog(entries, window) {
|
|
10219
|
+
const before = (ms, inclusive) => entries.findLast((e) => {
|
|
10220
|
+
const at = Date.parse(e.at);
|
|
10221
|
+
return inclusive ? at <= ms : at < ms;
|
|
10222
|
+
});
|
|
10223
|
+
const opened = before(window.startMs - PLACEMENT_SKEW_MS, false);
|
|
10224
|
+
const closed = before(Math.max(window.startMs, window.endMs) + PLACEMENT_SKEW_MS, true);
|
|
10225
|
+
return {
|
|
10226
|
+
deployedSha: opened?.sha ?? null,
|
|
10227
|
+
deployedShaAmbiguous: opened?.sha !== closed?.sha
|
|
10228
|
+
};
|
|
10229
|
+
}
|
|
10191
10230
|
//#endregion
|
|
10192
10231
|
//#region src/coverage/resolve-stream.ts
|
|
10193
10232
|
/**
|
|
@@ -11773,6 +11812,8 @@ async function liveRunToReportResult(args) {
|
|
|
11773
11812
|
target: AGENT_BROWSER_TARGET,
|
|
11774
11813
|
status: result.status,
|
|
11775
11814
|
testCounts: null,
|
|
11815
|
+
startedAt: result.startedAt,
|
|
11816
|
+
finishedAt: new Date(Date.parse(result.startedAt) + result.durationMs).toISOString(),
|
|
11776
11817
|
durationMs: result.durationMs,
|
|
11777
11818
|
assertions: null,
|
|
11778
11819
|
analysis: null,
|
|
@@ -15728,6 +15769,7 @@ async function runOneDeterministicSpec(spec, index, ctx) {
|
|
|
15728
15769
|
CCQA_RUN_ID: runId
|
|
15729
15770
|
};
|
|
15730
15771
|
if (evidenceDir) specEnv[EVIDENCE_DIR_ENV] = evidenceDir;
|
|
15772
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
15731
15773
|
const proc = spawnVitestStreaming([
|
|
15732
15774
|
"run",
|
|
15733
15775
|
"--config",
|
|
@@ -15744,11 +15786,14 @@ async function runOneDeterministicSpec(spec, index, ctx) {
|
|
|
15744
15786
|
await Promise.all([streamFiltered(proc.stdout, sink, tail), streamFiltered(proc.stderr, sink, tail)]);
|
|
15745
15787
|
const specExitCode = await proc.exited;
|
|
15746
15788
|
blank();
|
|
15789
|
+
const report = await readReport$1(reportFile);
|
|
15747
15790
|
return {
|
|
15748
15791
|
featureName,
|
|
15749
15792
|
specName,
|
|
15750
15793
|
scriptFile,
|
|
15751
|
-
|
|
15794
|
+
startedAt,
|
|
15795
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
15796
|
+
report,
|
|
15752
15797
|
exitCode: specExitCode,
|
|
15753
15798
|
outputTail: tail ? tail.toString() : null,
|
|
15754
15799
|
evidenceDir
|
|
@@ -15777,6 +15822,8 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass,
|
|
|
15777
15822
|
spec: s.specName,
|
|
15778
15823
|
title: parsedSpec?.title ?? null,
|
|
15779
15824
|
target: AGENT_BROWSER_TARGET,
|
|
15825
|
+
startedAt: s.startedAt,
|
|
15826
|
+
finishedAt: s.finishedAt,
|
|
15780
15827
|
testCounts: s.report ? {
|
|
15781
15828
|
total: s.report.numTotalTests,
|
|
15782
15829
|
passed: s.report.numPassedTests,
|
|
@@ -20185,17 +20232,20 @@ function countSpecs(results) {
|
|
|
20185
20232
|
async function updateSpecLedger(storage, run, results) {
|
|
20186
20233
|
const { gitHead, branch } = run;
|
|
20187
20234
|
if (run.kind !== "run" || !gitHead || !branch) return;
|
|
20188
|
-
const
|
|
20235
|
+
const placeRow = await rowDeployPlacer(storage, run);
|
|
20236
|
+
const base = {
|
|
20189
20237
|
gitHead,
|
|
20190
20238
|
runId: run.id,
|
|
20191
|
-
at: run.reportCreatedAt
|
|
20192
|
-
deployedSha: run.deployedSha ?? null,
|
|
20193
|
-
deployedShaAmbiguous: run.deployedShaAmbiguous ?? false
|
|
20239
|
+
at: run.reportCreatedAt
|
|
20194
20240
|
};
|
|
20195
20241
|
const ledger = emptyLedger();
|
|
20196
20242
|
for (const row of results) {
|
|
20197
20243
|
if (row.status === "skipped") continue;
|
|
20198
20244
|
const key = `${row.feature}/${row.spec}`;
|
|
20245
|
+
const entry = {
|
|
20246
|
+
...base,
|
|
20247
|
+
...placeRow(row)
|
|
20248
|
+
};
|
|
20199
20249
|
ledger.run[key] = entry;
|
|
20200
20250
|
if (row.status === "passed") ledger.green[key] = entry;
|
|
20201
20251
|
else ledger.red[key] = redEntry(entry, row);
|
|
@@ -20298,6 +20348,38 @@ async function resolveDeployedSha(storage, kind, project, profile, explicit) {
|
|
|
20298
20348
|
};
|
|
20299
20349
|
}
|
|
20300
20350
|
/**
|
|
20351
|
+
* Place each row against the deploy log by its own execution window rather
|
|
20352
|
+
* than the run's (ADR-0027), so a run that outlives a deploy loses only the
|
|
20353
|
+
* specs that straddled it.
|
|
20354
|
+
*
|
|
20355
|
+
* Falls back to the run's placement where a finer answer isn't available: a
|
|
20356
|
+
* client-asserted sha is the caller's claim about its whole run, and a row
|
|
20357
|
+
* with no `startedAt` (an older client) has nothing finer to say. A row with
|
|
20358
|
+
* no end is measured to now, which only widens its window.
|
|
20359
|
+
*/
|
|
20360
|
+
async function rowDeployPlacer(storage, run) {
|
|
20361
|
+
const runPlacement = {
|
|
20362
|
+
deployedSha: run.deployedSha ?? null,
|
|
20363
|
+
deployedShaAmbiguous: run.deployedShaAmbiguous ?? false
|
|
20364
|
+
};
|
|
20365
|
+
if (run.deployedShaSource !== "hub-deploy-log") return () => runPlacement;
|
|
20366
|
+
const log = await storage.deploys.getLog(run.project, run.profile ?? "default").catch((err) => {
|
|
20367
|
+
console.error(`hub: could not read the deploy log to place rows of run "${run.id}": ${errMsg(err)}`);
|
|
20368
|
+
return null;
|
|
20369
|
+
});
|
|
20370
|
+
if (!log) return () => runPlacement;
|
|
20371
|
+
const sealedAt = Date.now();
|
|
20372
|
+
return (row) => {
|
|
20373
|
+
const startMs = row.startedAt ? Date.parse(row.startedAt) : NaN;
|
|
20374
|
+
if (Number.isNaN(startMs)) return runPlacement;
|
|
20375
|
+
const endMs = row.finishedAt ? Date.parse(row.finishedAt) : sealedAt;
|
|
20376
|
+
return placeRowInDeployLog(log.entries, {
|
|
20377
|
+
startMs,
|
|
20378
|
+
endMs: Number.isNaN(endMs) ? sealedAt : endMs
|
|
20379
|
+
});
|
|
20380
|
+
};
|
|
20381
|
+
}
|
|
20382
|
+
/**
|
|
20301
20383
|
* True when the deploy-log head moved while the run was open: the run
|
|
20302
20384
|
* straddled a deploy, so which commit it exercised is not knowable and re-run
|
|
20303
20385
|
* selection must report `unknown` instead of picking one. Only meaningful for
|
|
@@ -29403,7 +29485,7 @@ const PRUNE_AGE_SLACK_MS = 3600 * 1e3;
|
|
|
29403
29485
|
* event per line, appended in place (not atomic-rewritten — an append must
|
|
29404
29486
|
* not cost the whole stream). A reader can therefore observe a partial final
|
|
29405
29487
|
* line mid-append; the read side counts such lines as skipped rather than
|
|
29406
|
-
* failing, and the prune's full rewrite goes through
|
|
29488
|
+
* failing, and the prune's full rewrite goes through a temp file + rename.
|
|
29407
29489
|
*/
|
|
29408
29490
|
function createFileCoverageEventStore(root, caps) {
|
|
29409
29491
|
const maxEvents = caps?.maxEvents ?? 2e5;
|
|
@@ -29415,15 +29497,15 @@ function createFileCoverageEventStore(root, caps) {
|
|
|
29415
29497
|
async function loadState(project, path) {
|
|
29416
29498
|
const cached = states.get(project);
|
|
29417
29499
|
if (cached) return cached;
|
|
29418
|
-
const
|
|
29500
|
+
const tail = await statTail(path);
|
|
29419
29501
|
const state = {
|
|
29420
29502
|
nextSeq: 1,
|
|
29421
29503
|
count: 0,
|
|
29422
|
-
bytes:
|
|
29504
|
+
bytes: tail?.size ?? 0,
|
|
29423
29505
|
oldestAt: null,
|
|
29424
|
-
endsWithNewline:
|
|
29506
|
+
endsWithNewline: tail?.endsWithNewline ?? true
|
|
29425
29507
|
};
|
|
29426
|
-
for (const rawLine of
|
|
29508
|
+
for await (const rawLine of streamLines(path)) {
|
|
29427
29509
|
const line = parseLine(rawLine);
|
|
29428
29510
|
if (line === null) continue;
|
|
29429
29511
|
if (line.seq >= state.nextSeq) state.nextSeq = line.seq + 1;
|
|
@@ -29438,18 +29520,39 @@ function createFileCoverageEventStore(root, caps) {
|
|
|
29438
29520
|
const overBytes = state.bytes > maxBytes;
|
|
29439
29521
|
const overAge = state.oldestAt !== null && state.oldestAt < now - retentionMs - PRUNE_AGE_SLACK_MS;
|
|
29440
29522
|
if (!overCount && !overBytes && !overAge) return;
|
|
29441
|
-
const lines = await readLines(path);
|
|
29442
29523
|
const cutoff = now - retentionMs;
|
|
29443
|
-
const
|
|
29524
|
+
const freshSizes = [];
|
|
29525
|
+
for await (const line of streamFreshLines(path, cutoff)) freshSizes.push(Buffer.byteLength(JSON.stringify(line)) + 1);
|
|
29444
29526
|
const keep = overCount ? Math.max(0, maxEvents - pruneBatch) : maxEvents;
|
|
29445
|
-
let
|
|
29446
|
-
if (overBytes)
|
|
29447
|
-
|
|
29448
|
-
|
|
29449
|
-
|
|
29450
|
-
|
|
29451
|
-
|
|
29452
|
-
|
|
29527
|
+
let firstKept = freshSizes.length > keep ? freshSizes.length - keep : 0;
|
|
29528
|
+
if (overBytes) firstKept = firstWithinBytes(freshSizes, firstKept, pruneBytesTarget);
|
|
29529
|
+
await mkdir(dirname(path), { recursive: true });
|
|
29530
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
29531
|
+
let keptCount = 0;
|
|
29532
|
+
let keptBytes = 0;
|
|
29533
|
+
let oldestKeptAt = null;
|
|
29534
|
+
try {
|
|
29535
|
+
await pipeline(async function* () {
|
|
29536
|
+
let freshIdx = 0;
|
|
29537
|
+
for await (const line of streamFreshLines(path, cutoff)) {
|
|
29538
|
+
freshIdx += 1;
|
|
29539
|
+
if (freshIdx <= firstKept) continue;
|
|
29540
|
+
const text = JSON.stringify(line) + "\n";
|
|
29541
|
+
keptCount += 1;
|
|
29542
|
+
keptBytes += Buffer.byteLength(text);
|
|
29543
|
+
if (oldestKeptAt === null) oldestKeptAt = line.at;
|
|
29544
|
+
yield text;
|
|
29545
|
+
}
|
|
29546
|
+
}, createWriteStream(tmp, { encoding: "utf8" }));
|
|
29547
|
+
} catch (err) {
|
|
29548
|
+
await rm(tmp, { force: true });
|
|
29549
|
+
throw err;
|
|
29550
|
+
}
|
|
29551
|
+
await rename(tmp, path);
|
|
29552
|
+
const dropped = state.count - keptCount;
|
|
29553
|
+
state.count = keptCount;
|
|
29554
|
+
state.bytes = keptBytes;
|
|
29555
|
+
state.oldestAt = oldestKeptAt;
|
|
29453
29556
|
state.endsWithNewline = true;
|
|
29454
29557
|
if (dropped > 0) console.warn(`hub: coverage inbox for "${project}": dropped ${dropped} events past retention (${maxEvents} events / ${Math.round(maxBytes / 1048576)} MiB / ${Math.round(retentionMs / 864e5)} days)`);
|
|
29455
29558
|
}
|
|
@@ -29518,22 +29621,46 @@ function createFileCoverageEventStore(root, caps) {
|
|
|
29518
29621
|
}
|
|
29519
29622
|
};
|
|
29520
29623
|
}
|
|
29521
|
-
/**
|
|
29522
|
-
function
|
|
29523
|
-
let
|
|
29524
|
-
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
29525
|
-
total += Buffer.byteLength(JSON.stringify(lines[i])) + 1;
|
|
29526
|
-
if (total > budget) return lines.slice(i + 1);
|
|
29527
|
-
}
|
|
29528
|
-
return lines;
|
|
29529
|
-
}
|
|
29530
|
-
async function readRaw(path) {
|
|
29624
|
+
/** Size and trailing-newline state of the stream file, or null when it doesn't exist. */
|
|
29625
|
+
async function statTail(path) {
|
|
29626
|
+
let fh;
|
|
29531
29627
|
try {
|
|
29532
|
-
|
|
29628
|
+
fh = await open(path, "r");
|
|
29533
29629
|
} catch (err) {
|
|
29534
|
-
if (err
|
|
29630
|
+
if (isNotFound(err)) return null;
|
|
29535
29631
|
throw err;
|
|
29536
29632
|
}
|
|
29633
|
+
try {
|
|
29634
|
+
const { size } = await fh.stat();
|
|
29635
|
+
if (size === 0) return {
|
|
29636
|
+
size,
|
|
29637
|
+
endsWithNewline: true
|
|
29638
|
+
};
|
|
29639
|
+
const tail = Buffer.alloc(1);
|
|
29640
|
+
await fh.read(tail, 0, 1, size - 1);
|
|
29641
|
+
return {
|
|
29642
|
+
size,
|
|
29643
|
+
endsWithNewline: tail[0] === 10
|
|
29644
|
+
};
|
|
29645
|
+
} finally {
|
|
29646
|
+
await fh.close();
|
|
29647
|
+
}
|
|
29648
|
+
}
|
|
29649
|
+
/** Retention-window lines only, in file order (= append order = seq order). */
|
|
29650
|
+
async function* streamFreshLines(path, cutoff) {
|
|
29651
|
+
for await (const rawLine of streamLines(path)) {
|
|
29652
|
+
const line = parseLine(rawLine);
|
|
29653
|
+
if (line !== null && line.at >= cutoff) yield line;
|
|
29654
|
+
}
|
|
29655
|
+
}
|
|
29656
|
+
/** Start of the longest suffix of `sizes` that fits `budget` (never below `lower`). */
|
|
29657
|
+
function firstWithinBytes(sizes, lower, budget) {
|
|
29658
|
+
let total = 0;
|
|
29659
|
+
for (let i = sizes.length - 1; i >= lower; i -= 1) {
|
|
29660
|
+
total += sizes[i];
|
|
29661
|
+
if (total > budget) return i + 1;
|
|
29662
|
+
}
|
|
29663
|
+
return lower;
|
|
29537
29664
|
}
|
|
29538
29665
|
/**
|
|
29539
29666
|
* The stream's non-empty lines, one at a time. Streamed rather than read as one
|
|
@@ -29550,21 +29677,12 @@ async function* streamLines(path) {
|
|
|
29550
29677
|
try {
|
|
29551
29678
|
for await (const line of lines) if (line !== "") yield line;
|
|
29552
29679
|
} catch (err) {
|
|
29553
|
-
if (!(err
|
|
29680
|
+
if (!isNotFound(err)) throw err;
|
|
29554
29681
|
} finally {
|
|
29555
29682
|
lines.close();
|
|
29556
29683
|
input.destroy();
|
|
29557
29684
|
}
|
|
29558
29685
|
}
|
|
29559
|
-
/** Every parseable line of the log; partial or corrupt lines are silently omitted (the read side counts them). */
|
|
29560
|
-
async function readLines(path) {
|
|
29561
|
-
const lines = [];
|
|
29562
|
-
for await (const rawLine of streamLines(path)) {
|
|
29563
|
-
const line = parseLine(rawLine);
|
|
29564
|
-
if (line !== null) lines.push(line);
|
|
29565
|
-
}
|
|
29566
|
-
return lines;
|
|
29567
|
-
}
|
|
29568
29686
|
function parseLine(rawLine) {
|
|
29569
29687
|
let value;
|
|
29570
29688
|
try {
|
|
@@ -655,6 +655,8 @@ declare const ReportSpecResultSchema: z.ZodObject<{
|
|
|
655
655
|
failed: z.ZodNumber;
|
|
656
656
|
}, z.core.$strip>>;
|
|
657
657
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
|
658
|
+
startedAt: z.ZodOptional<z.ZodString>;
|
|
659
|
+
finishedAt: z.ZodOptional<z.ZodString>;
|
|
658
660
|
assertions: z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
659
661
|
name: z.ZodString;
|
|
660
662
|
status: z.ZodEnum<{
|
|
@@ -869,6 +871,8 @@ declare const RunReportDataSchema: z.ZodObject<{
|
|
|
869
871
|
failed: z.ZodNumber;
|
|
870
872
|
}, z.core.$strip>>;
|
|
871
873
|
durationMs: z.ZodNullable<z.ZodNumber>;
|
|
874
|
+
startedAt: z.ZodOptional<z.ZodString>;
|
|
875
|
+
finishedAt: z.ZodOptional<z.ZodString>;
|
|
872
876
|
assertions: z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
873
877
|
name: z.ZodString;
|
|
874
878
|
status: z.ZodEnum<{
|
package/dist/package.json
CHANGED