ccqa 1.48.2 → 1.49.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
@@ -29403,7 +29404,7 @@ const PRUNE_AGE_SLACK_MS = 3600 * 1e3;
29403
29404
  * event per line, appended in place (not atomic-rewritten — an append must
29404
29405
  * not cost the whole stream). A reader can therefore observe a partial final
29405
29406
  * 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.
29407
+ * failing, and the prune's full rewrite goes through a temp file + rename.
29407
29408
  */
29408
29409
  function createFileCoverageEventStore(root, caps) {
29409
29410
  const maxEvents = caps?.maxEvents ?? 2e5;
@@ -29415,15 +29416,15 @@ function createFileCoverageEventStore(root, caps) {
29415
29416
  async function loadState(project, path) {
29416
29417
  const cached = states.get(project);
29417
29418
  if (cached) return cached;
29418
- const raw = await readRaw(path);
29419
+ const tail = await statTail(path);
29419
29420
  const state = {
29420
29421
  nextSeq: 1,
29421
29422
  count: 0,
29422
- bytes: Buffer.byteLength(raw),
29423
+ bytes: tail?.size ?? 0,
29423
29424
  oldestAt: null,
29424
- endsWithNewline: raw === "" || raw.endsWith("\n")
29425
+ endsWithNewline: tail?.endsWithNewline ?? true
29425
29426
  };
29426
- for (const rawLine of raw.split("\n")) {
29427
+ for await (const rawLine of streamLines(path)) {
29427
29428
  const line = parseLine(rawLine);
29428
29429
  if (line === null) continue;
29429
29430
  if (line.seq >= state.nextSeq) state.nextSeq = line.seq + 1;
@@ -29438,18 +29439,39 @@ function createFileCoverageEventStore(root, caps) {
29438
29439
  const overBytes = state.bytes > maxBytes;
29439
29440
  const overAge = state.oldestAt !== null && state.oldestAt < now - retentionMs - PRUNE_AGE_SLACK_MS;
29440
29441
  if (!overCount && !overBytes && !overAge) return;
29441
- const lines = await readLines(path);
29442
29442
  const cutoff = now - retentionMs;
29443
- const fresh = lines.filter((l) => l.at >= cutoff);
29443
+ const freshSizes = [];
29444
+ for await (const line of streamFreshLines(path, cutoff)) freshSizes.push(Buffer.byteLength(JSON.stringify(line)) + 1);
29444
29445
  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;
29446
+ let firstKept = freshSizes.length > keep ? freshSizes.length - keep : 0;
29447
+ if (overBytes) firstKept = firstWithinBytes(freshSizes, firstKept, pruneBytesTarget);
29448
+ await mkdir(dirname(path), { recursive: true });
29449
+ const tmp = `${path}.${randomUUID()}.tmp`;
29450
+ let keptCount = 0;
29451
+ let keptBytes = 0;
29452
+ let oldestKeptAt = null;
29453
+ try {
29454
+ await pipeline(async function* () {
29455
+ let freshIdx = 0;
29456
+ for await (const line of streamFreshLines(path, cutoff)) {
29457
+ freshIdx += 1;
29458
+ if (freshIdx <= firstKept) continue;
29459
+ const text = JSON.stringify(line) + "\n";
29460
+ keptCount += 1;
29461
+ keptBytes += Buffer.byteLength(text);
29462
+ if (oldestKeptAt === null) oldestKeptAt = line.at;
29463
+ yield text;
29464
+ }
29465
+ }, createWriteStream(tmp, { encoding: "utf8" }));
29466
+ } catch (err) {
29467
+ await rm(tmp, { force: true });
29468
+ throw err;
29469
+ }
29470
+ await rename(tmp, path);
29471
+ const dropped = state.count - keptCount;
29472
+ state.count = keptCount;
29473
+ state.bytes = keptBytes;
29474
+ state.oldestAt = oldestKeptAt;
29453
29475
  state.endsWithNewline = true;
29454
29476
  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
29477
  }
@@ -29518,22 +29540,46 @@ function createFileCoverageEventStore(root, caps) {
29518
29540
  }
29519
29541
  };
29520
29542
  }
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) {
29543
+ /** Size and trailing-newline state of the stream file, or null when it doesn't exist. */
29544
+ async function statTail(path) {
29545
+ let fh;
29531
29546
  try {
29532
- return await readFile(path, "utf8");
29547
+ fh = await open(path, "r");
29533
29548
  } catch (err) {
29534
- if (err instanceof Error && "code" in err && err.code === "ENOENT") return "";
29549
+ if (isNotFound(err)) return null;
29535
29550
  throw err;
29536
29551
  }
29552
+ try {
29553
+ const { size } = await fh.stat();
29554
+ if (size === 0) return {
29555
+ size,
29556
+ endsWithNewline: true
29557
+ };
29558
+ const tail = Buffer.alloc(1);
29559
+ await fh.read(tail, 0, 1, size - 1);
29560
+ return {
29561
+ size,
29562
+ endsWithNewline: tail[0] === 10
29563
+ };
29564
+ } finally {
29565
+ await fh.close();
29566
+ }
29567
+ }
29568
+ /** Retention-window lines only, in file order (= append order = seq order). */
29569
+ async function* streamFreshLines(path, cutoff) {
29570
+ for await (const rawLine of streamLines(path)) {
29571
+ const line = parseLine(rawLine);
29572
+ if (line !== null && line.at >= cutoff) yield line;
29573
+ }
29574
+ }
29575
+ /** Start of the longest suffix of `sizes` that fits `budget` (never below `lower`). */
29576
+ function firstWithinBytes(sizes, lower, budget) {
29577
+ let total = 0;
29578
+ for (let i = sizes.length - 1; i >= lower; i -= 1) {
29579
+ total += sizes[i];
29580
+ if (total > budget) return i + 1;
29581
+ }
29582
+ return lower;
29537
29583
  }
29538
29584
  /**
29539
29585
  * The stream's non-empty lines, one at a time. Streamed rather than read as one
@@ -29550,21 +29596,12 @@ async function* streamLines(path) {
29550
29596
  try {
29551
29597
  for await (const line of lines) if (line !== "") yield line;
29552
29598
  } catch (err) {
29553
- if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) throw err;
29599
+ if (!isNotFound(err)) throw err;
29554
29600
  } finally {
29555
29601
  lines.close();
29556
29602
  input.destroy();
29557
29603
  }
29558
29604
  }
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
29605
  function parseLine(rawLine) {
29569
29606
  let value;
29570
29607
  try {
@@ -35,9 +35,9 @@ declare const RunSchema: z.ZodObject<{
35
35
  running: "running";
36
36
  }>;
37
37
  kind: z.ZodDefault<z.ZodEnum<{
38
+ record: "record";
38
39
  run: "run";
39
40
  drift: "drift";
40
- record: "record";
41
41
  }>>;
42
42
  drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
43
43
  specs: z.ZodNumber;
@@ -640,8 +640,8 @@ declare const ReportSpecResultSchema: z.ZodObject<{
640
640
  title: z.ZodNullable<z.ZodString>;
641
641
  target: z.ZodOptional<z.ZodString>;
642
642
  mode: z.ZodOptional<z.ZodEnum<{
643
- live: "live";
644
643
  deterministic: "deterministic";
644
+ live: "live";
645
645
  }>>;
646
646
  status: z.ZodEnum<{
647
647
  passed: "passed";
@@ -811,9 +811,9 @@ type ReportSpecResult = z.infer<typeof ReportSpecResultSchema>;
811
811
  declare const RunReportDataSchema: z.ZodObject<{
812
812
  schemaVersion: z.ZodLiteral<1>;
813
813
  kind: z.ZodDefault<z.ZodEnum<{
814
+ record: "record";
814
815
  run: "run";
815
816
  drift: "drift";
816
- record: "record";
817
817
  }>>;
818
818
  createdAt: z.ZodString;
819
819
  runId: z.ZodNullable<z.ZodString>;
@@ -854,8 +854,8 @@ declare const RunReportDataSchema: z.ZodObject<{
854
854
  title: z.ZodNullable<z.ZodString>;
855
855
  target: z.ZodOptional<z.ZodString>;
856
856
  mode: z.ZodOptional<z.ZodEnum<{
857
- live: "live";
858
857
  deterministic: "deterministic";
858
+ live: "live";
859
859
  }>>;
860
860
  status: z.ZodEnum<{
861
861
  passed: "passed";
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.48.2",
3
+ "version": "1.49.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.49.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {