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 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
- report: await readReport$1(reportFile),
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 entry = {
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 the atomic path.
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 raw = await readRaw(path);
29500
+ const tail = await statTail(path);
29419
29501
  const state = {
29420
29502
  nextSeq: 1,
29421
29503
  count: 0,
29422
- bytes: Buffer.byteLength(raw),
29504
+ bytes: tail?.size ?? 0,
29423
29505
  oldestAt: null,
29424
- endsWithNewline: raw === "" || raw.endsWith("\n")
29506
+ endsWithNewline: tail?.endsWithNewline ?? true
29425
29507
  };
29426
- for (const rawLine of raw.split("\n")) {
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 fresh = lines.filter((l) => l.at >= cutoff);
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 kept = fresh.length > keep ? fresh.slice(fresh.length - keep) : fresh;
29446
- if (overBytes) kept = newestWithinBytes(kept, pruneBytesTarget);
29447
- const encoded = new TextEncoder().encode(kept.map((l) => JSON.stringify(l)).join("\n") + (kept.length > 0 ? "\n" : ""));
29448
- await writeBytes(path, encoded);
29449
- const dropped = state.count - kept.length;
29450
- state.count = kept.length;
29451
- state.bytes = encoded.byteLength;
29452
- state.oldestAt = kept[0]?.at ?? null;
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
- /** The longest tail of `lines` whose serialized size (newlines included) fits in `budget`. */
29522
- function newestWithinBytes(lines, budget) {
29523
- let total = 0;
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
- return await readFile(path, "utf8");
29628
+ fh = await open(path, "r");
29533
29629
  } catch (err) {
29534
- if (err instanceof Error && "code" in err && err.code === "ENOENT") return "";
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 instanceof Error && "code" in err && err.code === "ENOENT")) throw 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.48.2",
3
+ "version": "1.50.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.48.2",
3
+ "version": "1.50.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {