ccqa 1.43.0 → 1.44.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
@@ -4,7 +4,7 @@ import { t as EVIDENCE_DIR_ENV } from "../evidence-constants-C425F7ZG.mjs";
4
4
  import { a as formatAgentBrowserUnavailableMessage, i as assertAgentBrowserAvailable, n as spawnAB, o as pathWithAgentBrowserShim, r as AgentBrowserUnavailableError, s as resolveAgentBrowserBin$1, t as sleepSync } from "../spawn-ab-Bm34WBui.mjs";
5
5
  import { createRequire } from "node:module";
6
6
  import { Command } from "commander";
7
- import { accessSync, appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { accessSync, appendFileSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
10
10
  import { access, appendFile, chmod, cp, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
@@ -11986,6 +11986,14 @@ async function resolveCoverageRoots(changed, cwd) {
11986
11986
  */
11987
11987
  const MAX_REPORT_RUNS = 20;
11988
11988
  /**
11989
+ * How many reads one source keeps in flight. The two sources run together, so
11990
+ * the hub sees twice this. All at once is what it cannot take: it serves one
11991
+ * process, a resolve walks the whole event stream, and a report carries its
11992
+ * screenshots inline — forty of those together is what took it down, rather
11993
+ * than any single one of them.
11994
+ */
11995
+ const HUB_READ_CONCURRENCY = 4;
11996
+ /**
11989
11997
  * Read every spec's most recent measured reach from the hub. Never throws: a
11990
11998
  * source that cannot be read warns, and `degraded` flips when the failure
11991
11999
  * leaves absence ambiguous (the ledger itself, or the legacy sources while
@@ -12053,8 +12061,14 @@ async function collectStreamEdges(input, merge) {
12053
12061
  const { hub, project } = input;
12054
12062
  const latest = await hub.getCoverage(project);
12055
12063
  ingestResolved(latest.resolved, merge);
12056
- const older = latest.runIds.filter((runId) => runId !== latest.resolved?.runId);
12057
- return (await Promise.allSettled(older.map(async (runId) => ingestResolved((await hub.getCoverage(project, { runId })).resolved, merge)))).filter((r) => r.status === "rejected").length;
12064
+ return (await runPool(latest.runIds.filter((runId) => runId !== latest.resolved?.runId), HUB_READ_CONCURRENCY, async (runId) => {
12065
+ try {
12066
+ ingestResolved((await hub.getCoverage(project, { runId })).resolved, merge);
12067
+ return true;
12068
+ } catch {
12069
+ return false;
12070
+ }
12071
+ })).filter((ok) => !ok).length;
12058
12072
  }
12059
12073
  function ingestResolved(resolved, merge) {
12060
12074
  if (!resolved) return;
@@ -12086,7 +12100,7 @@ const ReportCoverageRowsSchema = z.object({ results: z.array(z.object({
12086
12100
  */
12087
12101
  async function collectReportEdges(input, merge) {
12088
12102
  const { hub, project } = input;
12089
- const eligible = (await hub.listRuns({
12103
+ return (await runPool((await hub.listRuns({
12090
12104
  project,
12091
12105
  kind: "run",
12092
12106
  limit: MAX_REPORT_RUNS
@@ -12098,18 +12112,22 @@ async function collectReportEdges(input, merge) {
12098
12112
  id: run.id,
12099
12113
  measuredAt
12100
12114
  }];
12101
- });
12102
- return (await Promise.allSettled(eligible.map(async ({ id, measuredAt }) => {
12103
- const parsed = ReportCoverageRowsSchema.safeParse(await hub.getReport(id));
12104
- if (!parsed.success) return;
12105
- for (const row of parsed.data.results) {
12106
- if (!row.coverage || row.coverage.files.length === 0) continue;
12107
- merge(`${row.feature}/${row.spec}`, {
12108
- files: row.coverage.files,
12109
- measuredAt
12110
- });
12115
+ }), HUB_READ_CONCURRENCY, async ({ id, measuredAt }) => {
12116
+ try {
12117
+ const parsed = ReportCoverageRowsSchema.safeParse(await hub.getReport(id));
12118
+ if (!parsed.success) return true;
12119
+ for (const row of parsed.data.results) {
12120
+ if (!row.coverage || row.coverage.files.length === 0) continue;
12121
+ merge(`${row.feature}/${row.spec}`, {
12122
+ files: row.coverage.files,
12123
+ measuredAt
12124
+ });
12125
+ }
12126
+ return true;
12127
+ } catch {
12128
+ return false;
12111
12129
  }
12112
- }))).filter((r) => r.status === "rejected").length;
12130
+ })).filter((ok) => !ok).length;
12113
12131
  }
12114
12132
  //#endregion
12115
12133
  //#region src/select/inventory.ts
@@ -12224,6 +12242,27 @@ function foldTouchIndex(current, entry, selection) {
12224
12242
  }
12225
12243
  return out;
12226
12244
  }
12245
+ //#endregion
12246
+ //#region src/coverage/resolve-stream.ts
12247
+ /**
12248
+ * One run's answer out of a project's stored event stream (ADR-0022).
12249
+ *
12250
+ * The stream interleaves many runs and two producers; this is the single
12251
+ * shared interpretation over it — the same gate, join and loss accounting the
12252
+ * run-local sink applies, executed at read time by whichever host asks (the
12253
+ * hub's API, or the CLI). Everything here is a pure fold over the stamps the
12254
+ * events carry: no clock is ever read, so the same stream resolves to the
12255
+ * same answer on any host, at any time.
12256
+ */
12257
+ /**
12258
+ * How far past a run's last marker an application push still counts as its
12259
+ * audience (ms). The run-local sink keeps listening while a spec settles, so
12260
+ * the work a spec's last action triggered still lands; a stored stream has no
12261
+ * listener to wait, so the resolve re-creates that patience as a fixed bound.
12262
+ * Pushes past it were heard by nobody bound to this run — most likely they
12263
+ * belong to whatever run came next.
12264
+ */
12265
+ const GRACE_MS = 3e4;
12227
12266
  z.object({
12228
12267
  runId: z.string(),
12229
12268
  hubRunId: z.string().optional(),
@@ -12261,88 +12300,109 @@ function formatResolvedSpec(spec) {
12261
12300
  return `${spec.specId}: ${spec.files.length} file(s)${actors ? ` (${actors})` : ""}`;
12262
12301
  }
12263
12302
  /**
12264
- * Interprets `runId`'s view of the stream.
12303
+ * `runId`'s view of the stream, built one event at a time.
12265
12304
  *
12266
- * Two passes, because the resolver needs its context up front: the first
12305
+ * Two passes, because the resolver needs its context up front. The first
12267
12306
  * collects what the run's own markers establish — which ids it issued, which
12268
12307
  * identity tags were its to hand out, its universe, and when its first and
12269
- * last marker arrived. The second replays the stream through the shared
12270
- * resolver: this run's window markers as they came, and every application
12271
- * push as-is when its stamp falls inside the span the run's sink would
12272
- * have been listening (first marker to last marker plus `GRACE_MS`),
12273
- * stripped of its spec and actor attribution when it does not. A push
12274
- * outside the span was another run's audience, so its attribution is not
12275
- * this run's to claim — but the collector never re-sends what an earlier
12276
- * run acked, so on an always-on hub the boot set and each process's health
12277
- * figures arrived long before this run began, and only survive here.
12278
- */
12279
- function resolveStream(events, runId) {
12280
- const issued = /* @__PURE__ */ new Set();
12281
- const specOrder = [];
12282
- const tagToKey = /* @__PURE__ */ new Map();
12283
- const browserFiles = /* @__PURE__ */ new Map();
12284
- let universe;
12285
- let hubRunId;
12286
- let firstMarkerAt;
12287
- let lastMarkerAt = 0;
12288
- for (const event of events) {
12289
- const body = event.body;
12290
- if (!("kind" in body) || body.runId !== runId) continue;
12291
- if (firstMarkerAt === void 0) firstMarkerAt = event.at;
12292
- lastMarkerAt = event.at;
12293
- switch (body.kind) {
12294
- case "spec-open":
12295
- if (!issued.has(body.specId)) {
12296
- issued.add(body.specId);
12297
- specOrder.push(body.specId);
12308
+ * last marker arrived; markers are a small part of a stream, so a caller can
12309
+ * hold them. The second replays the stream through the shared resolver: this
12310
+ * run's window markers as they came, and every application push as-is when
12311
+ * its stamp falls inside the span the run's sink would have been listening
12312
+ * (first marker to last marker plus `GRACE_MS`), stripped of its spec and
12313
+ * actor attribution when it does not. A push outside the span was another
12314
+ * run's audience, so its attribution is not this run's to claim — but the
12315
+ * collector never re-sends what an earlier run acked, so on an always-on hub
12316
+ * the boot set and each process's health figures arrived long before this run
12317
+ * began, and only survive here.
12318
+ *
12319
+ * The constructor takes the markers and `accept` takes one event at a time, so
12320
+ * the second pass can be fed from a stream: it keeps nothing per push, which
12321
+ * is what lets a resolve outlive the point where the stream stops fitting in
12322
+ * memory.
12323
+ */
12324
+ var StreamResolution = class {
12325
+ runId;
12326
+ resolver;
12327
+ /** The ids this run opened, in that order — a `Set` iterates by insertion. */
12328
+ specIds;
12329
+ browserFiles = /* @__PURE__ */ new Map();
12330
+ /**
12331
+ * The stamps in which the run's sink would have been listening. Undefined
12332
+ * when the markers held no event of this run — then no push is ever inside.
12333
+ */
12334
+ span;
12335
+ universe;
12336
+ hubRunId;
12337
+ asOf = 0;
12338
+ lastSeq = 0;
12339
+ pushesDuringRun = 0;
12340
+ /** `markers` needs to hold every marker event of the stream; pushes are ignored here. */
12341
+ constructor(markers, runId) {
12342
+ this.runId = runId;
12343
+ const specIds = /* @__PURE__ */ new Set();
12344
+ const tagToKey = /* @__PURE__ */ new Map();
12345
+ let firstAt;
12346
+ let lastAt = 0;
12347
+ for (const event of markers) {
12348
+ const body = event.body;
12349
+ if (!("kind" in body) || body.runId !== runId) continue;
12350
+ if (firstAt === void 0) firstAt = event.at;
12351
+ lastAt = event.at;
12352
+ switch (body.kind) {
12353
+ case "spec-open":
12354
+ specIds.add(body.specId);
12355
+ break;
12356
+ case "window-open":
12357
+ tagToKey.set(body.tag, body.key);
12358
+ break;
12359
+ case "universe":
12360
+ this.universe = {
12361
+ include: body.include,
12362
+ files: body.files
12363
+ };
12364
+ break;
12365
+ case "run-link":
12366
+ this.hubRunId = body.hubRunId;
12367
+ break;
12368
+ case "browser": {
12369
+ const files = this.browserFiles.get(body.specId) ?? /* @__PURE__ */ new Set();
12370
+ for (const file of body.files) files.add(file);
12371
+ this.browserFiles.set(body.specId, files);
12372
+ break;
12298
12373
  }
12299
- break;
12300
- case "window-open":
12301
- tagToKey.set(body.tag, body.key);
12302
- break;
12303
- case "universe":
12304
- universe = {
12305
- include: body.include,
12306
- files: body.files
12307
- };
12308
- break;
12309
- case "run-link":
12310
- hubRunId = body.hubRunId;
12311
- break;
12312
- case "browser": {
12313
- const files = browserFiles.get(body.specId) ?? /* @__PURE__ */ new Set();
12314
- for (const file of body.files) files.add(file);
12315
- browserFiles.set(body.specId, files);
12316
- break;
12317
12374
  }
12318
12375
  }
12376
+ this.specIds = specIds;
12377
+ this.span = firstAt === void 0 ? void 0 : {
12378
+ from: firstAt,
12379
+ until: lastAt + GRACE_MS
12380
+ };
12381
+ this.resolver = new CoverageResolver(specIds, tagToKey);
12319
12382
  }
12320
- const resolver = new CoverageResolver(issued, tagToKey);
12321
- let asOf = 0;
12322
- let lastSeq = 0;
12323
- let pushesDuringRun = 0;
12324
- for (const event of events) {
12325
- if (event.seq > lastSeq) lastSeq = event.seq;
12383
+ /** One event of the second pass. Every event of the stream, in stamp order. */
12384
+ accept(event) {
12385
+ if (event.seq > this.lastSeq) this.lastSeq = event.seq;
12326
12386
  const body = event.body;
12327
12387
  if ("kind" in body) {
12328
- if (body.runId !== runId) continue;
12329
- asOf = event.at;
12330
- if (body.kind === "window-open") resolver.apply({
12388
+ if (body.runId !== this.runId) return;
12389
+ this.asOf = event.at;
12390
+ if (body.kind === "window-open") this.resolver.apply({
12331
12391
  kind: "window-open",
12332
12392
  at: event.at,
12333
12393
  tag: body.tag,
12334
12394
  key: body.key,
12335
12395
  specId: body.specId
12336
12396
  });
12337
- else if (body.kind === "window-close") resolver.apply({
12397
+ else if (body.kind === "window-close") this.resolver.apply({
12338
12398
  kind: "window-close",
12339
12399
  at: event.at,
12340
12400
  tag: body.tag
12341
12401
  });
12342
- continue;
12402
+ return;
12343
12403
  }
12344
- if (firstMarkerAt === void 0 || event.at < firstMarkerAt || event.at > lastMarkerAt + 3e4) {
12345
- resolver.apply({
12404
+ if (this.span === void 0 || event.at < this.span.from || event.at > this.span.until) {
12405
+ this.resolver.apply({
12346
12406
  kind: "push",
12347
12407
  at: event.at,
12348
12408
  push: {
@@ -12351,49 +12411,52 @@ function resolveStream(events, runId) {
12351
12411
  actors: []
12352
12412
  }
12353
12413
  });
12354
- continue;
12414
+ return;
12355
12415
  }
12356
- asOf = event.at;
12357
- pushesDuringRun++;
12358
- resolver.apply({
12416
+ this.asOf = event.at;
12417
+ this.pushesDuringRun++;
12418
+ this.resolver.apply({
12359
12419
  kind: "push",
12360
12420
  at: event.at,
12361
12421
  push: body
12362
12422
  });
12363
12423
  }
12364
- const specs = specOrder.map((specId) => {
12365
- const actorEvents = {};
12366
- for (const [key, count] of resolver.actorEventsFor(specId)) actorEvents[key] = count;
12424
+ /** The answer as of every event accepted so far. */
12425
+ finish() {
12426
+ const specs = [...this.specIds].map((specId) => {
12427
+ const actorEvents = {};
12428
+ for (const [key, count] of this.resolver.actorEventsFor(specId)) actorEvents[key] = count;
12429
+ return {
12430
+ specId,
12431
+ files: [...new Set([...this.resolver.filesFor(specId) ?? [], ...this.browserFiles.get(specId) ?? []])].sort(),
12432
+ actorEvents
12433
+ };
12434
+ });
12435
+ const outsideWindowEvents = {};
12436
+ for (const [key, count] of this.resolver.outsideWindowEvents()) outsideWindowEvents[key] = count;
12367
12437
  return {
12368
- specId,
12369
- files: [...new Set([...resolver.filesFor(specId) ?? [], ...browserFiles.get(specId) ?? []])].sort(),
12370
- actorEvents
12438
+ runId: this.runId,
12439
+ ...this.hubRunId !== void 0 ? { hubRunId: this.hubRunId } : {},
12440
+ asOf: this.asOf,
12441
+ lastSeq: this.lastSeq,
12442
+ ...this.universe !== void 0 ? { universe: this.universe } : {},
12443
+ specs,
12444
+ boot: [...this.resolver.boot()].sort(),
12445
+ health: {
12446
+ heardFromApplication: this.resolver.heardFromApplication(),
12447
+ pushesDuringRun: this.pushesDuringRun,
12448
+ attributedSpecs: this.resolver.attributedSpecs(),
12449
+ rejectedPushes: this.resolver.rejectedPushes(),
12450
+ uninstrumentedFiles: this.resolver.uninstrumentedFiles(),
12451
+ uninstrumentedProcesses: this.resolver.uninstrumentedProcesses(),
12452
+ droppedPushes: this.resolver.droppedPushes(),
12453
+ unmappedActorEvents: this.resolver.unmappedActorEvents(),
12454
+ outsideWindowEvents,
12455
+ specsMeasured: specs.length
12456
+ }
12371
12457
  };
12372
- });
12373
- const outsideWindowEvents = {};
12374
- for (const [key, count] of resolver.outsideWindowEvents()) outsideWindowEvents[key] = count;
12375
- return {
12376
- runId,
12377
- ...hubRunId !== void 0 ? { hubRunId } : {},
12378
- asOf,
12379
- lastSeq,
12380
- ...universe !== void 0 ? { universe } : {},
12381
- specs,
12382
- boot: [...resolver.boot()].sort(),
12383
- health: {
12384
- heardFromApplication: resolver.heardFromApplication(),
12385
- pushesDuringRun,
12386
- attributedSpecs: resolver.attributedSpecs(),
12387
- rejectedPushes: resolver.rejectedPushes(),
12388
- uninstrumentedFiles: resolver.uninstrumentedFiles(),
12389
- uninstrumentedProcesses: resolver.uninstrumentedProcesses(),
12390
- droppedPushes: resolver.droppedPushes(),
12391
- unmappedActorEvents: resolver.unmappedActorEvents(),
12392
- outsideWindowEvents,
12393
- specsMeasured: specs.length
12394
- }
12395
- };
12396
- }
12458
+ }
12459
+ };
12397
12460
  /**
12398
12461
  * Every run that opened a spec in this stream, most recently heard-from
12399
12462
  * first — recency by the arrival position of each run's latest spec-open,
@@ -23432,7 +23495,9 @@ function createAppendCoverageEventHandler(config) {
23432
23495
  }
23433
23496
  /**
23434
23497
  * GET /api/v1/coverage/events?project=&sinceSeq= — the stream after `sinceSeq`
23435
- * (exclusive, so a consumer passes back the `lastSeq` it saw), decrypted.
23498
+ * (exclusive), decrypted, at most `MAX_EVENTS_PER_READ` of them. A consumer
23499
+ * passes back the `seq` of the last event it received; `lastSeq` is the
23500
+ * stream's head, which `truncated` says the body stopped short of.
23436
23501
  * Hub bearer token only (the server's central check): what the append-only
23437
23502
  * credential wrote, it must not be able to read back.
23438
23503
  */
@@ -23441,33 +23506,51 @@ function createGetCoverageEventsHandler(config) {
23441
23506
  const key = requireKey(config);
23442
23507
  const project = requireProjectParam(ctx);
23443
23508
  const sinceSeq = requireSinceSeqParam(ctx.url);
23444
- sendJson(ctx.res, 200, await readStream(config, key, project, sinceSeq));
23509
+ const events = [];
23510
+ let truncated = false;
23511
+ const { lastSeq, skipped } = await scanStream(config, key, project, sinceSeq, (event) => {
23512
+ if (events.length < MAX_EVENTS_PER_READ) events.push(event);
23513
+ else truncated = true;
23514
+ });
23515
+ sendJson(ctx.res, 200, {
23516
+ events,
23517
+ lastSeq,
23518
+ skipped,
23519
+ truncated
23520
+ });
23445
23521
  };
23446
23522
  }
23447
- /** The stored stream after `sinceSeq` (exclusive), decrypted and parsed. */
23448
- async function readStream(config, key, project, sinceSeq) {
23449
- const { entries, lastSeq, skipped } = await config.store.read(project, sinceSeq);
23450
- const events = [];
23523
+ /**
23524
+ * Hand each event after `sinceSeq` (exclusive) to `visit`, decrypted and
23525
+ * parsed. Nothing is retained here: what a caller keeps is its own choice,
23526
+ * which is what lets a whole-stream read stay bounded.
23527
+ */
23528
+ async function scanStream(config, key, project, sinceSeq, visit) {
23451
23529
  let unreadable = 0;
23452
- for (const entry of entries) try {
23453
- const plain = decrypt(decodeEncryptedBlob(entry.payload), key);
23454
- const body = InboxBodySchema.parse(JSON.parse(new TextDecoder().decode(plain)));
23455
- events.push({
23456
- seq: entry.seq,
23457
- at: entry.at,
23458
- body
23459
- });
23460
- } catch {
23461
- unreadable += 1;
23462
- }
23530
+ const { lastSeq, skipped } = await config.store.scan(project, sinceSeq, (entry) => {
23531
+ let event;
23532
+ try {
23533
+ const plain = decrypt(decodeEncryptedBlob(entry.payload), key);
23534
+ event = {
23535
+ seq: entry.seq,
23536
+ at: entry.at,
23537
+ body: InboxBodySchema.parse(JSON.parse(new TextDecoder().decode(plain)))
23538
+ };
23539
+ } catch {
23540
+ unreadable += 1;
23541
+ return;
23542
+ }
23543
+ visit(event);
23544
+ });
23463
23545
  return {
23464
- events,
23465
23546
  lastSeq,
23466
23547
  skipped: skipped + unreadable
23467
23548
  };
23468
23549
  }
23469
23550
  /** Newest runs the resolve read offers; past twenty the answer is history nobody pages through. */
23470
23551
  const RUN_IDS_LIMIT = 20;
23552
+ /** How many events one `GET /events` body carries; past it the answer is `truncated`. */
23553
+ const MAX_EVENTS_PER_READ = 5e3;
23471
23554
  /** Resolved answers kept per handler; one page polls one key, so a handful covers the readers. */
23472
23555
  const RESOLVE_CACHE_LIMIT = 8;
23473
23556
  /**
@@ -23524,14 +23607,27 @@ function createResolveCoverageHandler(config) {
23524
23607
  sendJson(ctx.res, 200, hit);
23525
23608
  return;
23526
23609
  }
23527
- const { events, lastSeq } = await readStream(config, key, project, 0);
23528
- const runIds = listRunIds(events).slice(0, RUN_IDS_LIMIT);
23610
+ const markers = [];
23611
+ let seen = 0;
23612
+ const first = await scanStream(config, key, project, 0, (event) => {
23613
+ seen += 1;
23614
+ if ("kind" in event.body) markers.push(event);
23615
+ });
23616
+ const runIds = listRunIds(markers).slice(0, RUN_IDS_LIMIT);
23529
23617
  const runId = runKey !== "" ? runKey : runIds[0];
23618
+ let resolved = null;
23619
+ if (runId !== void 0 && seen > 0) {
23620
+ const resolution = new StreamResolution(markers, runId);
23621
+ await scanStream(config, key, project, 0, (event) => {
23622
+ if (event.seq <= first.lastSeq) resolution.accept(event);
23623
+ });
23624
+ resolved = resolution.finish();
23625
+ }
23530
23626
  const answer = {
23531
- resolved: runId === void 0 || events.length === 0 ? null : resolveStream(events, runId),
23627
+ resolved,
23532
23628
  runIds
23533
23629
  };
23534
- memo.put(project, runKey, lastSeq, answer);
23630
+ memo.put(project, runKey, first.lastSeq, answer);
23535
23631
  sendJson(ctx.res, 200, answer);
23536
23632
  };
23537
23633
  }
@@ -31168,7 +31264,7 @@ function createFileCoverageEventStore(root, caps) {
31168
31264
  oldestAt: null,
31169
31265
  endsWithNewline: raw === "" || raw.endsWith("\n")
31170
31266
  };
31171
- for (const rawLine of nonEmptyLines(raw)) {
31267
+ for (const rawLine of raw.split("\n")) {
31172
31268
  const line = parseLine(rawLine);
31173
31269
  if (line === null) continue;
31174
31270
  if (line.seq >= state.nextSeq) state.nextSeq = line.seq + 1;
@@ -31224,19 +31320,17 @@ function createFileCoverageEventStore(root, caps) {
31224
31320
  return stamp;
31225
31321
  });
31226
31322
  },
31227
- async read(project, sinceSeq) {
31323
+ async scan(project, sinceSeq, visit) {
31228
31324
  assertSafeName(project, "project");
31229
31325
  const path = coverageEventsPath(root, project);
31230
31326
  const now = Date.now();
31231
31327
  await serialize(path, async () => {
31232
31328
  await pruneIfDue(project, path, await loadState(project, path), now);
31233
31329
  });
31234
- const raw = await readRaw(path);
31235
31330
  const cutoff = now - retentionMs;
31236
- const entries = [];
31237
31331
  let lastSeq = 0;
31238
31332
  let skipped = 0;
31239
- for (const rawLine of nonEmptyLines(raw)) {
31333
+ for await (const rawLine of streamLines(path)) {
31240
31334
  const line = parseLine(rawLine);
31241
31335
  if (line === null) {
31242
31336
  skipped += 1;
@@ -31245,15 +31339,13 @@ function createFileCoverageEventStore(root, caps) {
31245
31339
  if (line.seq > lastSeq) lastSeq = line.seq;
31246
31340
  if (line.seq <= sinceSeq) continue;
31247
31341
  if (line.at < cutoff) continue;
31248
- entries.push({
31342
+ visit({
31249
31343
  seq: line.seq,
31250
31344
  at: line.at,
31251
31345
  payload: new Uint8Array(Buffer.from(line.payload, "base64"))
31252
31346
  });
31253
31347
  }
31254
- entries.sort((a, b) => a.seq - b.seq);
31255
31348
  return {
31256
- entries,
31257
31349
  lastSeq,
31258
31350
  skipped
31259
31351
  };
@@ -31284,13 +31376,31 @@ async function readRaw(path) {
31284
31376
  throw err;
31285
31377
  }
31286
31378
  }
31287
- function nonEmptyLines(raw) {
31288
- return raw.split("\n").filter((l) => l !== "");
31379
+ /**
31380
+ * The stream's non-empty lines, one at a time. Streamed rather than read as one
31381
+ * string because a project's stream is capped in the hundreds of megabytes, far
31382
+ * past what a reader can hold. Order is the file's, which is append order,
31383
+ * which is seq order — the prune rewrites a suffix and never reorders.
31384
+ */
31385
+ async function* streamLines(path) {
31386
+ const input = createReadStream(path, { encoding: "utf8" });
31387
+ const lines = createInterface$1({
31388
+ input,
31389
+ crlfDelay: Infinity
31390
+ });
31391
+ try {
31392
+ for await (const line of lines) if (line !== "") yield line;
31393
+ } catch (err) {
31394
+ if (!(err instanceof Error && "code" in err && err.code === "ENOENT")) throw err;
31395
+ } finally {
31396
+ lines.close();
31397
+ input.destroy();
31398
+ }
31289
31399
  }
31290
31400
  /** Every parseable line of the log; partial or corrupt lines are silently omitted (the read side counts them). */
31291
31401
  async function readLines(path) {
31292
31402
  const lines = [];
31293
- for (const rawLine of nonEmptyLines(await readRaw(path))) {
31403
+ for await (const rawLine of streamLines(path)) {
31294
31404
  const line = parseLine(rawLine);
31295
31405
  if (line !== null) lines.push(line);
31296
31406
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.43.0",
3
+ "version": "1.44.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.43.0",
3
+ "version": "1.44.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {