ccqa 1.37.0 → 1.38.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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { HubApiError, createHubClient } from "../hub-client/index.mjs";
2
+ import { HubApiError, createHubClient, hubRequest } from "../hub-client/index.mjs";
3
3
  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-CRIVfWpw.mjs";
5
5
  import { createRequire } from "node:module";
@@ -7,7 +7,7 @@ import { Command } from "commander";
7
7
  import { accessSync, appendFileSync, 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
- import { access, chmod, cp, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
10
+ import { access, appendFile, chmod, cp, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
11
11
  import { homedir, tmpdir } from "node:os";
12
12
  import { basename, dirname, isAbsolute, join, normalize, posix, relative, resolve, sep } from "node:path";
13
13
  import { parse, stringify } from "yaml";
@@ -3726,9 +3726,19 @@ function scrubOutcome(outcome, scrubMap) {
3726
3726
  }
3727
3727
  };
3728
3728
  }
3729
+ /**
3730
+ * Pause before the single retry of an errored classification call. Long enough
3731
+ * to ride out a transient network/model hiccup, short enough not to stall the
3732
+ * report when the error is persistent.
3733
+ */
3734
+ const RETRY_DELAY_MS = 2e3;
3735
+ function sleep(ms) {
3736
+ return new Promise((resolve) => setTimeout(resolve, ms));
3737
+ }
3729
3738
  async function classifyFailure(input, options) {
3730
- const { result: raw, isError } = await invokeClaudeStreaming({
3731
- prompt: buildFailureAnalysisPrompt(input),
3739
+ const prompt = buildFailureAnalysisPrompt(input);
3740
+ const invoke = () => invokeClaudeStreaming({
3741
+ prompt,
3732
3742
  allowedTools: [
3733
3743
  "Read",
3734
3744
  "Grep",
@@ -3741,11 +3751,22 @@ async function classifyFailure(input, options) {
3741
3751
  ...options.model ? { model: options.model } : {},
3742
3752
  ...options.cwd ? { cwd: options.cwd } : {}
3743
3753
  }, () => {});
3744
- if (isError || !raw) return {
3745
- analysis: unknownAnalysis(isError ? "Claude returned an error result" : "Claude returned no output"),
3746
- raw: raw ?? "",
3747
- sdkError: isError
3748
- };
3754
+ let { result: raw, isError } = await invoke();
3755
+ let retried = false;
3756
+ if (isError) {
3757
+ warn("failure analysis: Claude invocation errored — retrying once");
3758
+ await sleep(RETRY_DELAY_MS);
3759
+ ({result: raw, isError} = await invoke());
3760
+ retried = true;
3761
+ }
3762
+ if (isError || !raw) {
3763
+ const cause = isError ? "Claude returned an error result" : "Claude returned no output";
3764
+ return {
3765
+ analysis: unknownAnalysis(retried ? `${cause} (after 1 retry)` : cause),
3766
+ raw: raw ?? "",
3767
+ sdkError: isError
3768
+ };
3769
+ }
3749
3770
  let sawParseableJson = false;
3750
3771
  for (const candidate of extractJsonCandidates(raw)) {
3751
3772
  let parsed;
@@ -4627,22 +4648,27 @@ var HubConnectionError = class extends Error {
4627
4648
  super(message);
4628
4649
  }
4629
4650
  };
4630
- /**
4631
- * Resolve a hub client from flags / env. Returns `null` (never throws/exits)
4632
- * when either the URL or the token is missing — callers that treat the hub
4633
- * as optional can fall back; callers that require it should use
4634
- * `requireHubClient` instead.
4635
- */
4636
- function resolveHubClient(opts) {
4651
+ /** `resolveHubClient`'s resolution half: `null` when the URL or token is missing. */
4652
+ function resolveHubTransport(opts) {
4637
4653
  const baseUrl = opts.hubUrl ?? process.env.CCQA_HUB_URL;
4638
4654
  const token = opts.hubToken ?? process.env.CCQA_HUB_TOKEN;
4639
4655
  if (!baseUrl || !token) return null;
4640
4656
  const headers = resolveHubHeaders(opts.hubHeader);
4641
- return createHubClient({
4657
+ return {
4642
4658
  baseUrl: baseUrl.replace(/\/+$/, ""),
4643
4659
  token,
4644
4660
  ...headers ? { headers } : {}
4645
- });
4661
+ };
4662
+ }
4663
+ /**
4664
+ * Resolve a hub client from flags / env. Returns `null` (never throws/exits)
4665
+ * when either the URL or the token is missing — callers that treat the hub
4666
+ * as optional can fall back; callers that require it should use
4667
+ * `requireHubClient` instead.
4668
+ */
4669
+ function resolveHubClient(opts) {
4670
+ const transport = resolveHubTransport(opts);
4671
+ return transport === null ? null : createHubClient(transport);
4646
4672
  }
4647
4673
  /** Same as `resolveHubClient`, but throws `HubConnectionError` instead of returning `null`. */
4648
4674
  function requireHubClient(opts) {
@@ -5634,14 +5660,16 @@ const FRONTEND_COVERAGE_FILE = "coverage-frontend.json";
5634
5660
  /** The only spec-id shape this run issues, and the only one the sink accepts. */
5635
5661
  const SPEC_ID_PATTERN = /^[A-Za-z0-9._\-/]{1,200}$/;
5636
5662
  //#endregion
5637
- //#region src/coverage/sink.ts
5663
+ //#region src/coverage/resolver.ts
5638
5664
  /**
5639
- * Where instrumented application processes push what they reached. Why they
5640
- * push rather than being scraped is ADR-0021.
5665
+ * The interpretation half of coverage: what a run's raw events mean.
5641
5666
  *
5642
- * It authenticates nothing. The gate is the set of spec ids this run issued —
5643
- * a token would have to be configured on both sides to add anything, and the
5644
- * sink binds to loopback by default.
5667
+ * Everything here is a deterministic fold over an ordered, stamped event
5668
+ * stream pushes from instrumented processes, and the turns the run opened
5669
+ * and closed on identities. The resolver never reads a clock: every judgement
5670
+ * uses the `at` its event carries, so replaying the same stream on another
5671
+ * host reaches the same answer. Stamping is the transport's job — the one
5672
+ * place a single clock exists.
5645
5673
  */
5646
5674
  /** What an instrumented process pushes, once a second. */
5647
5675
  const PushSchema = z.object({
@@ -5660,17 +5688,13 @@ const PushSchema = z.object({
5660
5688
  files: z.array(z.string())
5661
5689
  })).default([])
5662
5690
  });
5663
- const MAX_BODY_BYTES$6 = 8 * 1024 * 1024;
5664
- var CoverageSink = class CoverageSink {
5665
- /** Where instrumented processes push to. Known once the socket is bound. */
5666
- url = "";
5691
+ var CoverageResolver = class {
5667
5692
  specs = /* @__PURE__ */ new Map();
5668
5693
  bootFiles = /* @__PURE__ */ new Set();
5669
5694
  /** What each reporting process last said about itself, keyed so a restart is a new one. */
5670
5695
  processes = /* @__PURE__ */ new Map();
5671
5696
  pushesReceived = 0;
5672
5697
  rejected = 0;
5673
- malformed = 0;
5674
5698
  /** Every turn this run has handed out, oldest first. Kept for the whole run. */
5675
5699
  windows = [];
5676
5700
  /** Per spec, per window, the distinct events that landed in it. Drives the row's count. */
@@ -5683,60 +5707,40 @@ var CoverageSink = class CoverageSink {
5683
5707
  * on arrival, so there is nothing else left to tell two of them apart.
5684
5708
  */
5685
5709
  unmappedAt = /* @__PURE__ */ new Set();
5686
- server;
5687
5710
  /** Spec ids this run issued. A push naming anything else is dropped. */
5688
5711
  issued;
5689
5712
  /** Declared identities to their display key. A tag absent here is somebody else's. */
5690
5713
  tagToKey;
5691
- constructor(server, issued, tagToKey) {
5692
- this.server = server;
5714
+ constructor(issued, tagToKey) {
5693
5715
  this.issued = issued;
5694
5716
  this.tagToKey = tagToKey;
5695
5717
  }
5696
- /**
5697
- * Binds and starts accepting pushes. `issued` is fixed at start: the cookie
5698
- * is client-controlled, so an id this run never issued is refused here
5699
- * rather than trusted into a report.
5700
- */
5701
- static async start(host, port, issued, tagToKey = /* @__PURE__ */ new Map()) {
5702
- const sink = new CoverageSink(createServer(), issued, tagToKey);
5703
- sink.server.on("request", (request, response) => {
5704
- sink.handle(request, response);
5705
- });
5706
- await new Promise((resolve, reject) => {
5707
- sink.server.once("error", reject);
5708
- sink.server.listen(port, host, () => {
5709
- sink.server.removeListener("error", reject);
5710
- resolve();
5711
- });
5712
- });
5713
- const address = sink.server.address();
5714
- sink.url = `http://${formatHost(host)}:${address.port}`;
5715
- return sink;
5718
+ apply(event) {
5719
+ switch (event.kind) {
5720
+ case "push":
5721
+ this.acceptPush(event.push);
5722
+ return;
5723
+ case "window-open":
5724
+ this.windows.push({
5725
+ tag: event.tag,
5726
+ key: event.key,
5727
+ specId: event.specId,
5728
+ openedAt: event.at,
5729
+ closedAt: void 0
5730
+ });
5731
+ return;
5732
+ case "window-close": for (let i = this.windows.length - 1; i >= 0; i--) {
5733
+ const window = this.windows[i];
5734
+ if (window.tag !== event.tag || window.closedAt !== void 0) continue;
5735
+ window.closedAt = event.at;
5736
+ return;
5737
+ }
5738
+ }
5716
5739
  }
5717
5740
  /** What `specId` reached so far. Reads do not clear: late pushes still land. */
5718
5741
  filesFor(specId) {
5719
5742
  return this.specs.get(specId)?.files;
5720
5743
  }
5721
- /** Gives `specId` sole claim to `window`'s identity from now until it is closed. */
5722
- openWindow(window, specId) {
5723
- this.windows.push({
5724
- tag: window.tag,
5725
- key: window.key,
5726
- specId,
5727
- openedAt: Date.now(),
5728
- closedAt: void 0
5729
- });
5730
- }
5731
- /** Ends the open turn on `tag`. Later events from it belong to nobody. */
5732
- closeWindow(tag) {
5733
- for (let i = this.windows.length - 1; i >= 0; i--) {
5734
- const window = this.windows[i];
5735
- if (window.tag !== tag || window.closedAt !== void 0) continue;
5736
- window.closedAt = Date.now();
5737
- return;
5738
- }
5739
- }
5740
5744
  /** When the run may next open a turn on `tag`, given the drain it has to leave. */
5741
5745
  lastClosedAt(tag) {
5742
5746
  let latest;
@@ -5801,13 +5805,6 @@ var CoverageSink = class CoverageSink {
5801
5805
  return this.rejected;
5802
5806
  }
5803
5807
  /**
5804
- * Pushes the sink could not read. Counted because the failure is otherwise
5805
- * invisible from this side and shows up as "the spec reached no server code".
5806
- */
5807
- malformedPushes() {
5808
- return this.malformed;
5809
- }
5810
- /**
5811
5808
  * Files the applications could not instrument — they can never report reach.
5812
5809
  *
5813
5810
  * Not baselined, unlike `unattributed` and `droppedPushes`: a file that
@@ -5835,38 +5832,7 @@ var CoverageSink = class CoverageSink {
5835
5832
  for (const report of this.processes.values()) total += Math.max(0, report.droppedLatest - report.droppedBaseline);
5836
5833
  return total;
5837
5834
  }
5838
- async close() {
5839
- await new Promise((resolve) => {
5840
- this.server.close(() => {
5841
- resolve();
5842
- });
5843
- });
5844
- }
5845
- async handle(request, response) {
5846
- if (request.method !== "POST") {
5847
- response.writeHead(405).end();
5848
- return;
5849
- }
5850
- let body;
5851
- try {
5852
- body = await readBody$1(request);
5853
- } catch {
5854
- this.malformed++;
5855
- response.writeHead(413).end();
5856
- return;
5857
- }
5858
- let push;
5859
- try {
5860
- push = PushSchema.parse(JSON.parse(body));
5861
- } catch {
5862
- this.malformed++;
5863
- response.writeHead(400).end();
5864
- return;
5865
- }
5866
- this.accept(push);
5867
- response.writeHead(204).end();
5868
- }
5869
- accept(push) {
5835
+ acceptPush(push) {
5870
5836
  const process = `${push.pid}:${push.startedAt}`;
5871
5837
  const known = this.processes.get(process);
5872
5838
  const previous = known?.unattributed ?? push.unattributed;
@@ -5934,9 +5900,9 @@ var CoverageSink = class CoverageSink {
5934
5900
  /**
5935
5901
  * The turn on `tag` that `at` falls in, latest first.
5936
5902
  *
5937
- * Both clocks are involved — the application stamped `at`, this process
5938
- * stamped the bounds — so each bound gives a little. It cannot reach the
5939
- * neighbouring turn: the run leaves a full drain between two turns on one
5903
+ * Both clocks are involved — the application stamped `at`, the receiving
5904
+ * process stamped the bounds — so each bound gives a little. It cannot reach
5905
+ * the neighbouring turn: the run leaves a full drain between two turns on one
5940
5906
  * identity and this reaches half of it.
5941
5907
  */
5942
5908
  windowAt(tag, at) {
@@ -5950,6 +5916,170 @@ var CoverageSink = class CoverageSink {
5950
5916
  return found;
5951
5917
  }
5952
5918
  };
5919
+ //#endregion
5920
+ //#region src/coverage/sink.ts
5921
+ /**
5922
+ * Where instrumented application processes push what they reached. Why they
5923
+ * push rather than being scraped is ADR-0021.
5924
+ *
5925
+ * This is the transport half only: it reads bodies, stamps arrival with the
5926
+ * one clock this process has, and hands each event to the resolver — which
5927
+ * owns every judgement about what the events mean.
5928
+ *
5929
+ * It authenticates nothing. The gate is the set of spec ids this run issued —
5930
+ * a token would have to be configured on both sides to add anything, and the
5931
+ * sink binds to loopback by default.
5932
+ */
5933
+ const MAX_BODY_BYTES$6 = 8 * 1024 * 1024;
5934
+ var CoverageSink = class CoverageSink {
5935
+ /** Where instrumented processes push to. Known once the socket is bound. */
5936
+ url = "";
5937
+ server;
5938
+ resolver;
5939
+ /**
5940
+ * Pushes this side could not read. Counted here, not in the resolver: an
5941
+ * unreadable body never becomes an event, so only the transport that
5942
+ * dropped it can count it — a host replaying the stream would see nothing.
5943
+ */
5944
+ malformed = 0;
5945
+ constructor(server, resolver) {
5946
+ this.server = server;
5947
+ this.resolver = resolver;
5948
+ }
5949
+ /**
5950
+ * Binds and starts accepting pushes. `issued` is fixed at start: the cookie
5951
+ * is client-controlled, so an id this run never issued is refused by the
5952
+ * resolver rather than trusted into a report.
5953
+ */
5954
+ static async start(host, port, issued, tagToKey = /* @__PURE__ */ new Map()) {
5955
+ const sink = new CoverageSink(createServer(), new CoverageResolver(issued, tagToKey));
5956
+ sink.server.on("request", (request, response) => {
5957
+ sink.handle(request, response);
5958
+ });
5959
+ await new Promise((resolve, reject) => {
5960
+ sink.server.once("error", reject);
5961
+ sink.server.listen(port, host, () => {
5962
+ sink.server.removeListener("error", reject);
5963
+ resolve();
5964
+ });
5965
+ });
5966
+ const address = sink.server.address();
5967
+ sink.url = `http://${formatHost(host)}:${address.port}`;
5968
+ return sink;
5969
+ }
5970
+ /** What `specId` reached so far. Reads do not clear: late pushes still land. */
5971
+ filesFor(specId) {
5972
+ return this.resolver.filesFor(specId);
5973
+ }
5974
+ /** Gives `specId` sole claim to `window`'s identity from now until it is closed. */
5975
+ openWindow(window, specId) {
5976
+ this.resolver.apply({
5977
+ kind: "window-open",
5978
+ at: Date.now(),
5979
+ tag: window.tag,
5980
+ key: window.key,
5981
+ specId
5982
+ });
5983
+ }
5984
+ /** Ends the open turn on `tag`. Later events from it belong to nobody. */
5985
+ closeWindow(tag) {
5986
+ this.resolver.apply({
5987
+ kind: "window-close",
5988
+ at: Date.now(),
5989
+ tag
5990
+ });
5991
+ }
5992
+ /** When the run may next open a turn on `tag`, given the drain it has to leave. */
5993
+ lastClosedAt(tag) {
5994
+ return this.resolver.lastClosedAt(tag);
5995
+ }
5996
+ /** Per window key, how many distinct events this spec was credited with. */
5997
+ actorEventsFor(specId) {
5998
+ return this.resolver.actorEventsFor(specId);
5999
+ }
6000
+ /** Events from a declared identity that arrived outside its turns. */
6001
+ outsideWindowEvents() {
6002
+ return this.resolver.outsideWindowEvents();
6003
+ }
6004
+ /** Events from identities this project never declared. Their reach belongs to nobody. */
6005
+ unmappedActorEvents() {
6006
+ return this.resolver.unmappedActorEvents();
6007
+ }
6008
+ /** Executions that ran while `specId` was open but outside its context. */
6009
+ unattributedFor(specId) {
6010
+ return this.resolver.unattributedFor(specId);
6011
+ }
6012
+ /** Files reached at module top level, never folded into any spec. */
6013
+ boot() {
6014
+ return this.resolver.boot();
6015
+ }
6016
+ /** True once any instrumented process has reported — i.e. the server half is wired up. */
6017
+ heardFromApplication() {
6018
+ return this.resolver.heardFromApplication();
6019
+ }
6020
+ /** Specs some process attributed a file to. */
6021
+ attributedSpecs() {
6022
+ return this.resolver.attributedSpecs();
6023
+ }
6024
+ /** Pushes refused because they named a spec id this run never issued. */
6025
+ rejectedPushes() {
6026
+ return this.resolver.rejectedPushes();
6027
+ }
6028
+ /**
6029
+ * Pushes the sink could not read. Counted because the failure is otherwise
6030
+ * invisible from this side and shows up as "the spec reached no server code".
6031
+ */
6032
+ malformedPushes() {
6033
+ return this.malformed;
6034
+ }
6035
+ /** Files the applications could not instrument — they can never report reach. */
6036
+ uninstrumentedFiles() {
6037
+ return this.resolver.uninstrumentedFiles();
6038
+ }
6039
+ /** Application processes that instrumented nothing at all. */
6040
+ uninstrumentedProcesses() {
6041
+ return this.resolver.uninstrumentedProcesses();
6042
+ }
6043
+ /** Pushes the applications could not deliver during this run. Never seen here. */
6044
+ droppedPushes() {
6045
+ return this.resolver.droppedPushes();
6046
+ }
6047
+ async close() {
6048
+ await new Promise((resolve) => {
6049
+ this.server.close(() => {
6050
+ resolve();
6051
+ });
6052
+ });
6053
+ }
6054
+ async handle(request, response) {
6055
+ if (request.method !== "POST") {
6056
+ response.writeHead(405).end();
6057
+ return;
6058
+ }
6059
+ let body;
6060
+ try {
6061
+ body = await readBody$1(request);
6062
+ } catch {
6063
+ this.malformed++;
6064
+ response.writeHead(413).end();
6065
+ return;
6066
+ }
6067
+ let push;
6068
+ try {
6069
+ push = PushSchema.parse(JSON.parse(body));
6070
+ } catch {
6071
+ this.malformed++;
6072
+ response.writeHead(400).end();
6073
+ return;
6074
+ }
6075
+ this.resolver.apply({
6076
+ kind: "push",
6077
+ at: Date.now(),
6078
+ push
6079
+ });
6080
+ response.writeHead(204).end();
6081
+ }
6082
+ };
5953
6083
  function formatHost(host) {
5954
6084
  return host.includes(":") ? `[${host}]` : host;
5955
6085
  }
@@ -6915,7 +7045,19 @@ const SETTLE_QUIET_POLLS = 10;
6915
7045
  const SETTLE_CAP_MS = 1e4;
6916
7046
  var CoverageSession = class CoverageSession {
6917
7047
  existing = /* @__PURE__ */ new Map();
7048
+ /**
7049
+ * Undefined in hub mode: nothing binds on the runner, and every read-side
7050
+ * answer here stays empty — interpretation lives in the hub's resolve
7051
+ * (ADR-0022).
7052
+ */
6918
7053
  sink;
7054
+ inbox;
7055
+ /**
7056
+ * Hub mode's stand-in for the sink's window log: when each identity's turn
7057
+ * last closed, on this process's clock, which is all the drain scheduling
7058
+ * ever compared against.
7059
+ */
7060
+ windowClosedAt = /* @__PURE__ */ new Map();
6919
7061
  runId;
6920
7062
  /** What reported paths are relative to, and what they are checked against. */
6921
7063
  root;
@@ -6923,10 +7065,15 @@ var CoverageSession = class CoverageSession {
6923
7065
  cwd;
6924
7066
  actors;
6925
7067
  origins;
6926
- /** The denominator, enumerated once at start, or undefined when `coverage.include` is unset. */
7068
+ /**
7069
+ * The denominator, enumerated once at start, or undefined when
7070
+ * `coverage.include` is unset — and always in hub mode, where it travels as
7071
+ * a `universe` event instead of riding the report envelope.
7072
+ */
6927
7073
  universe;
6928
- constructor(sink, runId, root, cwd, actors, origins, universe) {
7074
+ constructor(sink, inbox, runId, root, cwd, actors, origins, universe) {
6929
7075
  this.sink = sink;
7076
+ this.inbox = inbox;
6930
7077
  this.runId = runId;
6931
7078
  this.root = root;
6932
7079
  this.cwd = cwd;
@@ -6938,16 +7085,48 @@ var CoverageSession = class CoverageSession {
6938
7085
  const origins = options.config.instrumentedOrigins.map((origin) => resolveEnvRefs(origin));
6939
7086
  const unresolved = origins.filter((origin) => !/^https?:\/\//i.test(origin));
6940
7087
  if (unresolved.length > 0) throw new Error(`coverage.instrumentedOrigins must be absolute http(s) URLs after variable substitution; got ${unresolved.join(", ")}`);
6941
- const bind = new URL(resolveEnvRefs(options.config.sink));
6942
7088
  const actors = options.actors ?? NO_ACTORS;
6943
- const issued = new Set(options.specs.map((spec) => specIdFor(options.runId, spec)));
6944
- const sink = await CoverageSink.start(bind.hostname, bind.port === "" ? 80 : Number(bind.port), issued, actors.tagToKey);
7089
+ let sink;
7090
+ if (options.inbox === void 0) {
7091
+ const bind = new URL(resolveEnvRefs(options.config.sink));
7092
+ const issued = new Set(options.specs.map((spec) => specIdFor(options.runId, spec)));
7093
+ sink = await CoverageSink.start(bind.hostname, bind.port === "" ? 80 : Number(bind.port), issued, actors.tagToKey);
7094
+ }
6945
7095
  const root = await resolveRoot(options.cwd, options.config.projectRoot) ?? options.cwd;
6946
7096
  const universe = options.config.include === void 0 ? void 0 : await enumerateUniverse(root, options.config.include, (text) => warn(text));
6947
- return new CoverageSession(sink, options.runId, root, options.cwd, actors, origins, universe);
7097
+ if (options.inbox !== void 0 && universe !== void 0) await options.inbox.append({
7098
+ kind: "universe",
7099
+ runId: options.runId,
7100
+ include: [...universe.include],
7101
+ files: [...universe.files]
7102
+ });
7103
+ return new CoverageSession(sink, options.inbox, options.runId, root, options.cwd, actors, origins, options.inbox === void 0 ? universe : void 0);
7104
+ }
7105
+ /**
7106
+ * Ties the stream's run id to the hub's run record. The session starts
7107
+ * before the hub assigns that id, so the link is appended once it exists;
7108
+ * local mode has no stream to link.
7109
+ */
7110
+ async linkHubRun(hubRunId) {
7111
+ if (this.inbox === void 0) return;
7112
+ await this.inbox.append({
7113
+ kind: "run-link",
7114
+ runId: this.runId,
7115
+ hubRunId
7116
+ });
6948
7117
  }
7118
+ /** Where the local sink listens. Hub mode binds nothing, so there is no URL. */
6949
7119
  get sinkUrl() {
6950
- return this.sink.url;
7120
+ return this.sink?.url ?? "";
7121
+ }
7122
+ /**
7123
+ * True in hub mode: the facts leave as a stream, rows carry no coverage,
7124
+ * and the run-side health read-outs below answer empty. The one mode flag
7125
+ * callers should consult, so the answer cannot drift from what `start`
7126
+ * actually wired.
7127
+ */
7128
+ get streamsToHub() {
7129
+ return this.inbox !== void 0;
6951
7130
  }
6952
7131
  /**
6953
7132
  * Opens the spec's measurement.
@@ -6959,14 +7138,26 @@ var CoverageSession = class CoverageSession {
6959
7138
  */
6960
7139
  async beginSpec(ref) {
6961
7140
  const specId = specIdFor(this.runId, ref);
7141
+ if (this.inbox !== void 0) await this.inbox.append({
7142
+ kind: "spec-open",
7143
+ runId: this.runId,
7144
+ specId
7145
+ });
6962
7146
  for (const window of this.actors.windowsForSpec.get(specKey(ref)) ?? []) {
6963
- const closedAt = this.sink.lastClosedAt(window.tag);
7147
+ const closedAt = this.inbox === void 0 ? this.sink.lastClosedAt(window.tag) : this.windowClosedAt.get(window.tag);
6964
7148
  const wait = closedAt === void 0 ? 0 : closedAt + ACTOR_DRAIN_MS - Date.now();
6965
7149
  if (wait > 0) {
6966
7150
  meta("coverage", `waiting ${Math.ceil(wait / 1e3)}s for ${window.key} to go quiet`);
6967
7151
  await new Promise((resolve) => setTimeout(resolve, wait));
6968
7152
  }
6969
- this.sink.openWindow(window, specId);
7153
+ if (this.inbox === void 0) this.sink.openWindow(window, specId);
7154
+ else await this.inbox.append({
7155
+ kind: "window-open",
7156
+ runId: this.runId,
7157
+ tag: window.tag,
7158
+ key: window.key,
7159
+ specId
7160
+ });
6970
7161
  }
6971
7162
  }
6972
7163
  /**
@@ -6988,21 +7179,34 @@ var CoverageSession = class CoverageSession {
6988
7179
  warn: (text) => warn(`coverage: ${text}`)
6989
7180
  });
6990
7181
  }
6991
- /** Merges both sides once the spec's pushes have stopped arriving. */
7182
+ /**
7183
+ * Merges both sides once the spec's pushes have stopped arriving.
7184
+ *
7185
+ * In hub mode there is nothing to merge: the run appends what it alone can
7186
+ * state — its markers and the browser half — and resolves nothing, so the
7187
+ * row gets no coverage. There is no settle either: settling existed to read
7188
+ * a complete file set before the row was written, and late pushes land on
7189
+ * the hub whenever they arrive, attributed by the spec id they carry.
7190
+ */
6992
7191
  async collect(ref, coverageDir) {
6993
7192
  const specId = specIdFor(this.runId, ref);
7193
+ if (this.inbox !== void 0) {
7194
+ await this.streamSpecClose(this.inbox, ref, specId, coverageDir);
7195
+ return;
7196
+ }
7197
+ const sink = this.sink;
6994
7198
  await this.settle(specId);
6995
7199
  const owned = this.actors.windowsForSpec.get(specKey(ref)) ?? [];
6996
- for (const window of owned) this.sink.closeWindow(window.tag);
6997
- const matched = this.sink.actorEventsFor(specId);
6998
- const backend = this.sink.filesFor(specId);
7200
+ for (const window of owned) sink.closeWindow(window.tag);
7201
+ const matched = sink.actorEventsFor(specId);
7202
+ const backend = sink.filesFor(specId);
6999
7203
  const frontend = await readFrontend(coverageDir, specId);
7000
7204
  const inProject = await this.keepExisting(frontend?.files ?? []);
7001
7205
  return {
7002
7206
  files: [...new Set([...backend ?? [], ...inProject])].sort(),
7003
7207
  frontendFiles: inProject.length,
7004
7208
  backendFiles: backend?.size ?? 0,
7005
- backendReported: this.sink.heardFromApplication(),
7209
+ backendReported: sink.heardFromApplication(),
7006
7210
  frontendReported: frontend !== void 0,
7007
7211
  frontendStopped: frontend?.stopped ?? false,
7008
7212
  actorWindows: owned.map((window) => ({
@@ -7011,53 +7215,84 @@ var CoverageSession = class CoverageSession {
7011
7215
  })),
7012
7216
  excludedDependencies: frontend?.excludedDependencies ?? 0,
7013
7217
  gaps: {
7014
- unattributed: this.sink.unattributedFor(specId),
7218
+ unattributed: sink.unattributedFor(specId),
7015
7219
  unmappedScripts: frontend?.unmappedScripts ?? 0,
7016
7220
  unmappedRanges: frontend?.unmappedRanges ?? 0,
7017
7221
  outsideProject: (frontend?.files.length ?? 0) - inProject.length,
7018
7222
  unresolvedSources: frontend?.unresolvedSources ?? 0,
7019
- uninstrumentedFiles: this.sink.uninstrumentedFiles(),
7020
- uninstrumentedProcesses: this.sink.uninstrumentedProcesses(),
7021
- droppedPushes: this.sink.droppedPushes(),
7022
- unmappedActorEvents: this.sink.unmappedActorEvents(),
7023
- outsideWindowEvents: owned.reduce((sum, window) => sum + (this.sink.outsideWindowEvents().get(window.key) ?? 0), 0)
7223
+ uninstrumentedFiles: sink.uninstrumentedFiles(),
7224
+ uninstrumentedProcesses: sink.uninstrumentedProcesses(),
7225
+ droppedPushes: sink.droppedPushes(),
7226
+ unmappedActorEvents: sink.unmappedActorEvents(),
7227
+ outsideWindowEvents: owned.reduce((sum, window) => sum + (sink.outsideWindowEvents().get(window.key) ?? 0), 0)
7024
7228
  }
7025
7229
  };
7026
7230
  }
7027
7231
  /** Files reached at module top level, across the whole run. */
7028
7232
  boot() {
7029
- return [...this.sink.boot()].sort();
7233
+ return this.sink === void 0 ? [] : [...this.sink.boot()].sort();
7030
7234
  }
7031
7235
  /** Whether any instrumented application process reported at all. */
7032
7236
  heardFromApplication() {
7033
- return this.sink.heardFromApplication();
7237
+ return this.sink?.heardFromApplication() ?? false;
7034
7238
  }
7035
7239
  /** Specs some application process attributed a file to. */
7036
7240
  attributedSpecs() {
7037
- return this.sink.attributedSpecs();
7241
+ return this.sink?.attributedSpecs() ?? 0;
7038
7242
  }
7039
7243
  /** Declared identities that acted outside the turns this run gave them. */
7040
7244
  outsideWindowEvents() {
7041
- return this.sink.outsideWindowEvents();
7245
+ return this.sink?.outsideWindowEvents() ?? /* @__PURE__ */ new Map();
7042
7246
  }
7043
7247
  /** Events from identities this project never declared. */
7044
7248
  unmappedActorEvents() {
7045
- return this.sink.unmappedActorEvents();
7249
+ return this.sink?.unmappedActorEvents() ?? 0;
7046
7250
  }
7047
7251
  /** Pushes naming a spec id this run never issued — a stale or forged cookie. */
7048
7252
  rejectedPushes() {
7049
- return this.sink.rejectedPushes();
7253
+ return this.sink?.rejectedPushes() ?? 0;
7050
7254
  }
7051
7255
  /** Pushes the sink could not read — the two halves' wire formats disagree. */
7052
7256
  malformedPushes() {
7053
- return this.sink.malformedPushes();
7257
+ return this.sink?.malformedPushes() ?? 0;
7054
7258
  }
7055
7259
  /** Application processes that instrumented nothing at all. */
7056
7260
  uninstrumentedProcesses() {
7057
- return this.sink.uninstrumentedProcesses();
7261
+ return this.sink?.uninstrumentedProcesses() ?? 0;
7058
7262
  }
7059
7263
  async close() {
7060
- await this.sink.close();
7264
+ await this.sink?.close();
7265
+ }
7266
+ /**
7267
+ * Hub-mode close: everything the run alone can state about this spec goes
7268
+ * to the inbox. Windows close now, on this process's stamps — actor events
7269
+ * match on the instant the work was asked for, so a spec's asynchronous
7270
+ * tail still lands inside the turn that caused it.
7271
+ */
7272
+ async streamSpecClose(inbox, ref, specId, coverageDir) {
7273
+ for (const window of this.actors.windowsForSpec.get(specKey(ref)) ?? []) {
7274
+ await inbox.append({
7275
+ kind: "window-close",
7276
+ runId: this.runId,
7277
+ tag: window.tag
7278
+ });
7279
+ this.windowClosedAt.set(window.tag, Date.now());
7280
+ }
7281
+ const frontend = await readFrontend(coverageDir, specId);
7282
+ if (frontend !== void 0) {
7283
+ const files = [...new Set(await this.keepExisting(frontend.files))].sort();
7284
+ await inbox.append({
7285
+ kind: "browser",
7286
+ runId: this.runId,
7287
+ specId,
7288
+ files
7289
+ });
7290
+ }
7291
+ await inbox.append({
7292
+ kind: "spec-close",
7293
+ runId: this.runId,
7294
+ specId
7295
+ });
7061
7296
  }
7062
7297
  /** Keeps the paths that name a file in the working tree, cached per session. */
7063
7298
  async keepExisting(paths) {
@@ -7067,14 +7302,16 @@ var CoverageSession = class CoverageSession {
7067
7302
  }));
7068
7303
  return paths.filter((path) => this.existing.get(path) === true);
7069
7304
  }
7305
+ /** Local mode only; hub mode never settles (see `collect`). */
7070
7306
  async settle(specId) {
7071
- if (!this.sink.heardFromApplication()) return;
7307
+ const sink = this.sink;
7308
+ if (!sink.heardFromApplication()) return;
7072
7309
  const deadline = Date.now() + SETTLE_CAP_MS;
7073
- let previous = this.sink.filesFor(specId)?.size ?? 0;
7310
+ let previous = sink.filesFor(specId)?.size ?? 0;
7074
7311
  let quiet = 0;
7075
7312
  while (Date.now() < deadline && quiet < SETTLE_QUIET_POLLS) {
7076
7313
  await new Promise((resolve) => setTimeout(resolve, SETTLE_POLL_MS));
7077
- const size = this.sink.filesFor(specId)?.size ?? 0;
7314
+ const size = sink.filesFor(specId)?.size ?? 0;
7078
7315
  quiet = size === previous ? quiet + 1 : 0;
7079
7316
  previous = size;
7080
7317
  }
@@ -8147,8 +8384,9 @@ const C$1 = {
8147
8384
  * diff — so report rows and CI logs look the same whichever target a
8148
8385
  * project's specs use.
8149
8386
  *
8150
- * One Claude call per failing spec, which reads the source itself rather than
8151
- * deferring to a drift audit run beforehand. It is still one *phase*:
8387
+ * One Claude call per failing spec (two when the first errors and is retried
8388
+ * once), which reads the source itself rather than deferring to a drift audit
8389
+ * run beforehand. It is still one *phase*:
8152
8390
  * `beginFailureAnalysis` hands back the state every path shares, so a mixed
8153
8391
  * run prints one `failure analysis` banner in one place rather than one per
8154
8392
  * execution path. It runs after every spec has executed, so no Claude turn is
@@ -9065,8 +9303,10 @@ z.object({ error: z.object({
9065
9303
  * One spec's record of a single run, as stored in a ledger bucket. Identical
9066
9304
  * to `LastGreenEntry` plus the commit the environment was running at the time
9067
9305
  * — without it a bucket entry can be ordered in wall-clock time but not
9068
- * *positioned* against the deploy log, which is the only ordering re-run
9069
- * selection may use (ADR-0010).
9306
+ * *positioned* against the deploy log, which is the only ordering the
9307
+ * staleness verdict may use (ADR-0010). Wall-clock order is fit only for
9308
+ * scheduling within an already-decided set, where a mis-ranking delays a
9309
+ * spec rather than excusing it.
9070
9310
  */
9071
9311
  const SpecLedgerEntrySchema = z.object({
9072
9312
  gitHead: z.string(),
@@ -9651,7 +9891,10 @@ function selectSpecsNeedingRerun(specs, report) {
9651
9891
  const entry = report.specs[specKey(spec)];
9652
9892
  const verdict = entry?.verdict ?? "inProgress";
9653
9893
  counts.set(verdict, (counts.get(verdict) ?? 0) + 1);
9654
- if (verdict === "rerunNeeded") selected.push(spec);
9894
+ if (verdict === "rerunNeeded") selected.push({
9895
+ spec,
9896
+ lastRunAt: entry?.lastRun?.at ?? ""
9897
+ });
9655
9898
  else if (verdict === "inProgress") {
9656
9899
  excludedInProgress++;
9657
9900
  if (!entry) excludedUnknownToHub++;
@@ -9661,8 +9904,9 @@ function selectSpecsNeedingRerun(specs, report) {
9661
9904
  }
9662
9905
  }
9663
9906
  }
9907
+ selected.sort((a, b) => a.lastRunAt.localeCompare(b.lastRunAt));
9664
9908
  return {
9665
- selected,
9909
+ selected: selected.map((s) => s.spec),
9666
9910
  summary: formatCounts(SUMMARY_ORDER$1, counts),
9667
9911
  excludedInProgress,
9668
9912
  excludedUnknownToHub,
@@ -11583,6 +11827,15 @@ ${stepsText}
11583
11827
  - Do not invent success when blocked: fail honestly with a short reason.
11584
11828
  - **Evidence discipline**: when the assertion target is a specific row / message / banner / URL, scroll it into view (or focus the relevant pane) before letting the step end. The "after" screenshot is captured for you automatically — your job is to make sure that screenshot shows the thing your STEP_RESULT line is talking about.
11585
11829
 
11830
+ ### Waiting for asynchronous responses
11831
+
11832
+ Some expected outcomes arrive asynchronously — an automated reply, a background job finishing, a list refreshing. Waiting for them is fine, but the wait has a budget:
11833
+
11834
+ - Prefer bounded probes (\`agent-browser wait --text "..."\`, or a short pause followed by a fresh \`snapshot\`) over long blind sleeps, and keep a rough running total of how long you have waited within this step.
11835
+ - **The total wait within one step must not exceed 3 minutes**, unless the step's own instruction explicitly names a longer wait. Do not keep adding "one more" sleep past the budget.
11836
+ - When the budget is spent and the expected outcome has still not appeared, STOP waiting and emit \`STEP_RESULT|<stepId>|fail|...\`. **Never end your turn without a STEP_RESULT because you were still waiting** — a silent timeout is recorded as a protocol failure and hides the real cause from failure analysis.
11837
+ - The fail reason must state what you waited for, roughly how long in total, and what you observed instead (e.g. "waited ~3 min for a reply to appear after submitting; none appeared, the view still shows only the submitted item").
11838
+
11586
11839
  ### Output contract (STRICT)
11587
11840
 
11588
11841
  Your final assistant message MUST contain exactly one line of the form:
@@ -12747,6 +13000,46 @@ function enrichZodError(error, source) {
12747
13000
  return new Error(lines.join("\n"));
12748
13001
  }
12749
13002
  //#endregion
13003
+ //#region src/coverage/inbox.ts
13004
+ /**
13005
+ * The run's side of the hub coverage inbox (ADR-0022). Under
13006
+ * `--coverage-inbox hub` nothing binds on the runner: the run appends its own
13007
+ * facts — spec lifecycle markers, actor-window markers, the browser half, the
13008
+ * universe — to the hub's durable stream, next to the pushes the instrumented
13009
+ * application sends there itself. The hub stamps arrival order and stores;
13010
+ * interpretation happens at read time, in the shared resolver.
13011
+ */
13012
+ /** `--coverage-inbox` values: where the measurement's two sides meet. */
13013
+ const COVERAGE_INBOX_MODES = ["local", "hub"];
13014
+ var CoverageInbox = class {
13015
+ transport;
13016
+ path;
13017
+ constructor(options) {
13018
+ const { project, ...transport } = options;
13019
+ this.transport = transport;
13020
+ this.path = `/api/v1/coverage/events?project=${encodeURIComponent(project)}`;
13021
+ }
13022
+ /**
13023
+ * Appends one event to the project's stream. Never throws: a marker that
13024
+ * could not be delivered degrades the resolved answer, and failing the run
13025
+ * over it would cost the test results the run exists for. The transport is
13026
+ * the hub client's, with its one opt-in: an append delivered twice resolves
13027
+ * the same as once, so unlike the client's own POSTs it retries once.
13028
+ */
13029
+ async append(event) {
13030
+ try {
13031
+ await hubRequest(this.transport, this.path, {
13032
+ method: "POST",
13033
+ headers: { "Content-Type": "application/json" },
13034
+ body: JSON.stringify(event)
13035
+ }, "post-once");
13036
+ } catch (err) {
13037
+ const reason = err instanceof HubApiError ? `status ${err.status}` : errMessage(err);
13038
+ warn(`coverage: could not append a ${event.kind} event to the hub inbox (${reason})`);
13039
+ }
13040
+ }
13041
+ };
13042
+ //#endregion
12750
13043
  //#region src/codegen/actions-to-script.ts
12751
13044
  function actionsToScript(input) {
12752
13045
  const { actions, testName, stepMarkers = [], emptySteps = [] } = input;
@@ -15228,6 +15521,22 @@ async function executeRun(targets, opts) {
15228
15521
  if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
15229
15522
  if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
15230
15523
  if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError(needsHubConnection("--learn-hub-live-prompt"));
15524
+ let coverageInbox;
15525
+ if (opts.coverageInbox === "hub") {
15526
+ if (opts.coverage !== true) throw new RunUsageError("--coverage-inbox hub does nothing without --coverage — there is no measurement to stream");
15527
+ const transport = resolveHubTransport(opts);
15528
+ if (transport === null) throw new RunUsageError(needsHubConnection("--coverage-inbox hub"));
15529
+ let project;
15530
+ try {
15531
+ project = resolveProjectOrThrow(opts.project, cwd);
15532
+ } catch (err) {
15533
+ throw new RunUsageError(errMessage(err));
15534
+ }
15535
+ coverageInbox = new CoverageInbox({
15536
+ ...transport,
15537
+ project
15538
+ });
15539
+ }
15231
15540
  const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead] = await Promise.all([
15232
15541
  forExecution ? fetchCustomPrompt(hubCtx) : null,
15233
15542
  forExecution ? fetchTriageUserPrompt(hubCtx) : null,
@@ -15344,7 +15653,7 @@ async function executeRun(targets, opts) {
15344
15653
  const liveSpecs = withMode.filter((s) => s.mode === "live");
15345
15654
  meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
15346
15655
  if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
15347
- const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown) : void 0;
15656
+ const coverage = opts.coverage && forExecution ? await startCoverage(cwd, projectConfig.coverage, actors, dispatch, opts.teardown, coverageInbox) : void 0;
15348
15657
  if (liveSpecs.length === 0) {
15349
15658
  const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
15350
15659
  if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
@@ -15381,6 +15690,7 @@ async function executeRun(targets, opts) {
15381
15690
  });
15382
15691
  hubRunId = opened.id;
15383
15692
  info(`hub: incremental run opened (${opened.id})`);
15693
+ await coverage?.linkHubRun(opened.id);
15384
15694
  const runId = opened.id;
15385
15695
  let hubPatchEverSucceeded = false;
15386
15696
  hubSink = { onUpsert: async (row) => {
@@ -15455,7 +15765,7 @@ async function executeRun(targets, opts) {
15455
15765
  report: incrementalReport
15456
15766
  };
15457
15767
  const live = await runLiveSpecs(liveSpecs, liveOpts);
15458
- if (coverage) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
15768
+ if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
15459
15769
  let overallExitCode = det.exitCode !== 0 ? 1 : 0;
15460
15770
  if (live.failedCount > 0) overallExitCode = 1;
15461
15771
  if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
@@ -15490,7 +15800,7 @@ async function executeRun(targets, opts) {
15490
15800
  });
15491
15801
  report = await writeUnifiedReport({
15492
15802
  reportDir,
15493
- results: coverage ? results.map(explainMissingCoverage) : results,
15803
+ results: coverage && !coverage.streamsToHub ? results.map(explainMissingCoverage) : results,
15494
15804
  git,
15495
15805
  customPromptVersion,
15496
15806
  triageUserPromptHash,
@@ -15548,9 +15858,10 @@ async function executeRun(targets, opts) {
15548
15858
  /**
15549
15859
  * Starts the run's coverage measurement before any spec runs: the sink has to
15550
15860
  * be listening before the first request reaches the application, and the set
15551
- * of spec ids it will accept is only known once dispatch has resolved.
15861
+ * of spec ids it will accept is only known once dispatch has resolved. With
15862
+ * an `inbox`, nothing binds — the run streams its events to the hub instead.
15552
15863
  */
15553
- async function startCoverage(cwd, config, actors, dispatch, teardown) {
15864
+ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
15554
15865
  if (config === void 0) throw new RunUsageError("--coverage needs a `coverage:` block in .ccqa/config.yaml whose `instrumentedOrigins` names the origins the spec cookie may be attached to");
15555
15866
  let session;
15556
15867
  try {
@@ -15559,12 +15870,14 @@ async function startCoverage(cwd, config, actors, dispatch, teardown) {
15559
15870
  cwd,
15560
15871
  config,
15561
15872
  actors,
15562
- specs: dispatch.external.flatMap((g) => g.specs)
15873
+ specs: dispatch.external.flatMap((g) => g.specs),
15874
+ ...inbox ? { inbox } : {}
15563
15875
  });
15564
15876
  } catch (err) {
15565
15877
  throw new RunUsageError(`could not start coverage collection: ${errMessage(err)}`);
15566
15878
  }
15567
- meta("coverage", `sink ${session.sinkUrl} → ${session.origins.join(", ")}`);
15879
+ if (inbox !== void 0) meta("coverage", `streaming to hub inbox → ${session.origins.join(", ")}`);
15880
+ else meta("coverage", `sink ${session.sinkUrl} → ${session.origins.join(", ")}`);
15568
15881
  const unmeasured = dispatch.external.filter((g) => g.browserCoverage.browser === "none").length;
15569
15882
  if (unmeasured > 0) warn(`${unmeasured} target(s) declare no browser to measure; their specs are reported as unmeasured rather than as reaching nothing`);
15570
15883
  teardown?.onFinalize(() => session.close());
@@ -16198,7 +16511,10 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
16198
16511
  }, "never").option("--on-fail-explain-rerun-max-specs <n>", "Rerun at most N specs, in report order; the rest are named in the run summary and keep the label they were first given. Default: no cap. The knob for an environment having a bad day, where the alternative is turning the reruns off entirely.", parseRerunMaxSpecs).optionsGroup("What to do with the results:").option("--report-dir <dir>", `Directory for the structured run results (report.json + evidence PNGs), which are always written. Default: ${DEFAULT_REPORT_DIR}/.`).option("--report-format <fmt>", "Additional output format alongside HTML: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
16199
16512
  if (REPORT_FORMATS.includes(raw)) return raw;
16200
16513
  throw new Error(`--report-format must be one of ${REPORT_FORMATS.join(" | ")}`);
16201
- }, "text").option("--report-to-hub", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--coverage", "Measure what each spec actually reached in the application under test, and record it on the spec's report row. Needs a `coverage:` block in .ccqa/config.yaml naming the instrumented origins the spec cookie may go to. The browser half attaches to the target's browser from outside (nothing is emitted into generated tests; needs node 22+); the server half needs the application running with ccqa-tools.").optionsGroup("Learning:").option("--learn-hub-live-prompt", "(live only) After the run finishes, ask Claude to refresh the \"live.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (targets, opts) => {
16514
+ }, "text").option("--report-to-hub", "Incrementally push the run report to the hub as the run progresses (open → patch per spec → finalize). Requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN). Without it, hub credentials are used only to fetch variables/sessions/prompts, not to push.").option("--coverage", "Measure what each spec actually reached in the application under test, and record it on the spec's report row. Needs a `coverage:` block in .ccqa/config.yaml naming the instrumented origins the spec cookie may go to. The browser half attaches to the target's browser from outside (nothing is emitted into generated tests; needs node 22+); the server half needs the application running with ccqa-tools.").option("--coverage-inbox <where>", "With --coverage: where the measurement's two sides meet. 'local' (default) binds a loopback inbox on this machine for the run's duration; 'hub' appends every event to the hub's durable coverage inbox instead nothing listens on the runner, report.json carries no coverage, and the hub resolves per-spec results on read (requires a hub connection).", (raw) => {
16515
+ if (COVERAGE_INBOX_MODES.includes(raw)) return raw;
16516
+ throw new Error(`--coverage-inbox must be one of ${COVERAGE_INBOX_MODES.join(" | ")}`);
16517
+ }, "local").optionsGroup("Learning:").option("--learn-hub-live-prompt", "(live only) After the run finishes, ask Claude to refresh the \"live.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(async (targets, opts) => {
16202
16518
  await runCliAction(targets, opts);
16203
16519
  });
16204
16520
  /** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
@@ -20718,7 +21034,7 @@ function decodeEncryptedBlob(bytes) {
20718
21034
  //#endregion
20719
21035
  //#region src/hub/api/handlers/secrets.ts
20720
21036
  const MAX_SECRET_BODY_BYTES = 4 * 1024 * 1024;
20721
- function requireKey(config) {
21037
+ function requireKey$1(config) {
20722
21038
  if (!config.encryptionKey) throw new HttpError(503, "encryption_not_configured", "CCQA_HUB_ENCRYPTION_KEY is not set on this hub");
20723
21039
  return config.encryptionKey;
20724
21040
  }
@@ -20732,7 +21048,7 @@ function requireScope$1(ctx) {
20732
21048
  /** PUT /api/v1/projects/:project/sessions/:profile/:name — body is the raw agent-browser storage-state JSON. */
20733
21049
  function createPutSessionHandler(config) {
20734
21050
  return async (ctx) => {
20735
- const key = requireKey(config);
21051
+ const key = requireKey$1(config);
20736
21052
  const scope = requireScope$1(ctx);
20737
21053
  const name = requireSafeSegment(ctx.params.name, "name");
20738
21054
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
@@ -20761,7 +21077,7 @@ function createListSessionsHandler(config) {
20761
21077
  */
20762
21078
  function createGetSessionHandler(config) {
20763
21079
  return async (ctx) => {
20764
- const key = requireKey(config);
21080
+ const key = requireKey$1(config);
20765
21081
  const scope = requireScope$1(ctx);
20766
21082
  const name = requireSafeSegment(ctx.params.name, "name");
20767
21083
  const stored = await config.store.get(scope, name);
@@ -20783,7 +21099,7 @@ function createDeleteSessionHandler(config) {
20783
21099
  /** PUT /api/v1/projects/:project/variables/:profile/:name */
20784
21100
  function createPutVariableHandler(config) {
20785
21101
  return async (ctx) => {
20786
- const key = requireKey(config);
21102
+ const key = requireKey$1(config);
20787
21103
  const scope = requireScope$1(ctx);
20788
21104
  const name = requireSafeSegment(ctx.params.name, "name");
20789
21105
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
@@ -20806,7 +21122,7 @@ function createListVariablesHandler(config) {
20806
21122
  return async (ctx) => {
20807
21123
  const scope = requireScope$1(ctx);
20808
21124
  const includeValues = ctx.url.searchParams.get("include") === "values";
20809
- const key = includeValues ? requireKey(config) : config.encryptionKey;
21125
+ const key = includeValues ? requireKey$1(config) : config.encryptionKey;
20810
21126
  const entries = await config.store.list(scope);
20811
21127
  const variables = await Promise.all(entries.map(async (e) => {
20812
21128
  const sensitive = e.meta.sensitive === true;
@@ -21461,6 +21777,428 @@ function createGetSpendHandler(storage) {
21461
21777
  };
21462
21778
  }
21463
21779
  //#endregion
21780
+ //#region src/coverage/events.ts
21781
+ /**
21782
+ * The wire and storage shapes of the coverage event stream (ADR-0022).
21783
+ *
21784
+ * Two producers write it. The instrumented application posts the same push
21785
+ * body it has always posted — the inbox recognises it by its `protocol`
21786
+ * field, so a collector already deployed keeps working unchanged. The run
21787
+ * posts explicit run events: its markers, its browser half, its universe.
21788
+ * The hub stores either under a stamp `{seq, at}` and never looks inside;
21789
+ * interpretation happens at read time (resolver.ts).
21790
+ *
21791
+ * Every run event carries `runId` because one project's stream interleaves
21792
+ * many runs: the markers are what bound one run's view of the stream, so
21793
+ * they must say whose they are.
21794
+ */
21795
+ const RunEventSchema = z.discriminatedUnion("kind", [
21796
+ z.object({
21797
+ kind: z.literal("run-link"),
21798
+ runId: z.string(),
21799
+ hubRunId: z.string()
21800
+ }),
21801
+ z.object({
21802
+ kind: z.literal("spec-open"),
21803
+ runId: z.string(),
21804
+ specId: z.string()
21805
+ }),
21806
+ z.object({
21807
+ kind: z.literal("spec-close"),
21808
+ runId: z.string(),
21809
+ specId: z.string()
21810
+ }),
21811
+ z.object({
21812
+ kind: z.literal("window-open"),
21813
+ runId: z.string(),
21814
+ tag: z.string(),
21815
+ key: z.string(),
21816
+ specId: z.string()
21817
+ }),
21818
+ z.object({
21819
+ kind: z.literal("window-close"),
21820
+ runId: z.string(),
21821
+ tag: z.string()
21822
+ }),
21823
+ z.object({
21824
+ kind: z.literal("browser"),
21825
+ runId: z.string(),
21826
+ specId: z.string(),
21827
+ files: z.array(z.string())
21828
+ }),
21829
+ z.object({
21830
+ kind: z.literal("universe"),
21831
+ runId: z.string(),
21832
+ include: z.array(z.string()),
21833
+ files: z.array(z.string())
21834
+ })
21835
+ ]);
21836
+ /**
21837
+ * What one POST to the inbox may carry: an application push (recognised by
21838
+ * `protocol`) or a run event (recognised by `kind`). Checked in this order —
21839
+ * the push shape has no `kind` and a run event has no `protocol`.
21840
+ */
21841
+ const InboxBodySchema = z.union([PushSchema, RunEventSchema]);
21842
+ z.object({
21843
+ seq: z.number(),
21844
+ at: z.number(),
21845
+ body: InboxBodySchema
21846
+ });
21847
+ z.object({
21848
+ runId: z.string(),
21849
+ hubRunId: z.string().optional(),
21850
+ asOf: z.number(),
21851
+ lastSeq: z.number(),
21852
+ universe: z.object({
21853
+ include: z.array(z.string()),
21854
+ files: z.array(z.string())
21855
+ }).optional(),
21856
+ specs: z.array(z.object({
21857
+ specId: z.string(),
21858
+ files: z.array(z.string()),
21859
+ actorEvents: z.record(z.string(), z.number())
21860
+ })),
21861
+ boot: z.array(z.string()),
21862
+ health: z.object({
21863
+ heardFromApplication: z.boolean(),
21864
+ pushesDuringRun: z.number(),
21865
+ attributedSpecs: z.number(),
21866
+ rejectedPushes: z.number(),
21867
+ uninstrumentedFiles: z.number(),
21868
+ uninstrumentedProcesses: z.number(),
21869
+ droppedPushes: z.number(),
21870
+ unmappedActorEvents: z.number(),
21871
+ outsideWindowEvents: z.record(z.string(), z.number()),
21872
+ specsMeasured: z.number()
21873
+ })
21874
+ });
21875
+ /**
21876
+ * Interprets `runId`'s view of the stream.
21877
+ *
21878
+ * Two passes, because the resolver needs its context up front: the first
21879
+ * collects what the run's own markers establish — which ids it issued, which
21880
+ * identity tags were its to hand out, its universe, and when its first and
21881
+ * last marker arrived. The second replays the stream through the shared
21882
+ * resolver: this run's window markers as they came, and every application
21883
+ * push — as-is when its stamp falls inside the span the run's sink would
21884
+ * have been listening (first marker to last marker plus `GRACE_MS`),
21885
+ * stripped of its spec and actor attribution when it does not. A push
21886
+ * outside the span was another run's audience, so its attribution is not
21887
+ * this run's to claim — but the collector never re-sends what an earlier
21888
+ * run acked, so on an always-on hub the boot set and each process's health
21889
+ * figures arrived long before this run began, and only survive here.
21890
+ */
21891
+ function resolveStream(events, runId) {
21892
+ const issued = /* @__PURE__ */ new Set();
21893
+ const specOrder = [];
21894
+ const tagToKey = /* @__PURE__ */ new Map();
21895
+ const browserFiles = /* @__PURE__ */ new Map();
21896
+ let universe;
21897
+ let hubRunId;
21898
+ let firstMarkerAt;
21899
+ let lastMarkerAt = 0;
21900
+ for (const event of events) {
21901
+ const body = event.body;
21902
+ if (!("kind" in body) || body.runId !== runId) continue;
21903
+ if (firstMarkerAt === void 0) firstMarkerAt = event.at;
21904
+ lastMarkerAt = event.at;
21905
+ switch (body.kind) {
21906
+ case "spec-open":
21907
+ if (!issued.has(body.specId)) {
21908
+ issued.add(body.specId);
21909
+ specOrder.push(body.specId);
21910
+ }
21911
+ break;
21912
+ case "window-open":
21913
+ tagToKey.set(body.tag, body.key);
21914
+ break;
21915
+ case "universe":
21916
+ universe = {
21917
+ include: body.include,
21918
+ files: body.files
21919
+ };
21920
+ break;
21921
+ case "run-link":
21922
+ hubRunId = body.hubRunId;
21923
+ break;
21924
+ case "browser": {
21925
+ const files = browserFiles.get(body.specId) ?? /* @__PURE__ */ new Set();
21926
+ for (const file of body.files) files.add(file);
21927
+ browserFiles.set(body.specId, files);
21928
+ break;
21929
+ }
21930
+ }
21931
+ }
21932
+ const resolver = new CoverageResolver(issued, tagToKey);
21933
+ let asOf = 0;
21934
+ let lastSeq = 0;
21935
+ let pushesDuringRun = 0;
21936
+ for (const event of events) {
21937
+ if (event.seq > lastSeq) lastSeq = event.seq;
21938
+ const body = event.body;
21939
+ if ("kind" in body) {
21940
+ if (body.runId !== runId) continue;
21941
+ asOf = event.at;
21942
+ if (body.kind === "window-open") resolver.apply({
21943
+ kind: "window-open",
21944
+ at: event.at,
21945
+ tag: body.tag,
21946
+ key: body.key,
21947
+ specId: body.specId
21948
+ });
21949
+ else if (body.kind === "window-close") resolver.apply({
21950
+ kind: "window-close",
21951
+ at: event.at,
21952
+ tag: body.tag
21953
+ });
21954
+ continue;
21955
+ }
21956
+ if (firstMarkerAt === void 0 || event.at < firstMarkerAt || event.at > lastMarkerAt + 3e4) {
21957
+ resolver.apply({
21958
+ kind: "push",
21959
+ at: event.at,
21960
+ push: {
21961
+ ...body,
21962
+ specs: {},
21963
+ actors: []
21964
+ }
21965
+ });
21966
+ continue;
21967
+ }
21968
+ asOf = event.at;
21969
+ pushesDuringRun++;
21970
+ resolver.apply({
21971
+ kind: "push",
21972
+ at: event.at,
21973
+ push: body
21974
+ });
21975
+ }
21976
+ const specs = specOrder.map((specId) => {
21977
+ const actorEvents = {};
21978
+ for (const [key, count] of resolver.actorEventsFor(specId)) actorEvents[key] = count;
21979
+ return {
21980
+ specId,
21981
+ files: [...new Set([...resolver.filesFor(specId) ?? [], ...browserFiles.get(specId) ?? []])].sort(),
21982
+ actorEvents
21983
+ };
21984
+ });
21985
+ const outsideWindowEvents = {};
21986
+ for (const [key, count] of resolver.outsideWindowEvents()) outsideWindowEvents[key] = count;
21987
+ return {
21988
+ runId,
21989
+ ...hubRunId !== void 0 ? { hubRunId } : {},
21990
+ asOf,
21991
+ lastSeq,
21992
+ ...universe !== void 0 ? { universe } : {},
21993
+ specs,
21994
+ boot: [...resolver.boot()].sort(),
21995
+ health: {
21996
+ heardFromApplication: resolver.heardFromApplication(),
21997
+ pushesDuringRun,
21998
+ attributedSpecs: resolver.attributedSpecs(),
21999
+ rejectedPushes: resolver.rejectedPushes(),
22000
+ uninstrumentedFiles: resolver.uninstrumentedFiles(),
22001
+ uninstrumentedProcesses: resolver.uninstrumentedProcesses(),
22002
+ droppedPushes: resolver.droppedPushes(),
22003
+ unmappedActorEvents: resolver.unmappedActorEvents(),
22004
+ outsideWindowEvents,
22005
+ specsMeasured: specs.length
22006
+ }
22007
+ };
22008
+ }
22009
+ /**
22010
+ * Every run that opened a spec in this stream, most recently heard-from
22011
+ * first — recency by the arrival position of each run's latest spec-open,
22012
+ * the one order the hub's stamps establish.
22013
+ */
22014
+ function listRunIds(events) {
22015
+ const lastOpenIndex = /* @__PURE__ */ new Map();
22016
+ events.forEach((event, index) => {
22017
+ const body = event.body;
22018
+ if ("kind" in body && body.kind === "spec-open") lastOpenIndex.set(body.runId, index);
22019
+ });
22020
+ return [...lastOpenIndex.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
22021
+ }
22022
+ //#endregion
22023
+ //#region src/hub/api/auth.ts
22024
+ /**
22025
+ * Constant-time comparison against the hub's bearer token, so response
22026
+ * timing can't be used to guess the token character-by-character. Accepts
22027
+ * the token either as an `Authorization: Bearer <token>` header or, for
22028
+ * read-only GET endpoints only (the artifacts download is a browser `<a>` that can't
22029
+ * carry a header), a `?token=` query parameter — see docs/hub-api.md for
22030
+ * the security tradeoff that accepts.
22031
+ */
22032
+ function extractToken(req, url) {
22033
+ const header = req.headers.authorization;
22034
+ if (header?.startsWith("Bearer ")) return header.slice(7);
22035
+ return url.searchParams.get("token");
22036
+ }
22037
+ function isValidToken(provided, expected) {
22038
+ if (provided === null) return false;
22039
+ const a = Buffer.from(provided);
22040
+ const b = Buffer.from(expected);
22041
+ if (a.length !== b.length) return false;
22042
+ return timingSafeEqual(a, b);
22043
+ }
22044
+ //#endregion
22045
+ //#region src/hub/api/handlers/coverage.ts
22046
+ /**
22047
+ * The coverage inbox (ADR-0022): the hub stamps, stores, serves and expires
22048
+ * coverage events — it never looks inside one, except the read-time resolve
22049
+ * below, the one bounded amendment ADR-0022 makes to the no-compute rule.
22050
+ * The append authenticates here rather than in the server's central token
22051
+ * check, because it accepts a second credential:
22052
+ * `CCQA_HUB_COVERAGE_TOKEN`, the append-only token the instrumented
22053
+ * application holds. That token may append pushes and nothing else — in
22054
+ * particular no run events, so a leaked application credential cannot forge
22055
+ * the markers that bound a run's view of the stream. The reads stay behind
22056
+ * the central check (see SELF_AUTHENTICATED_ROUTES in server.ts).
22057
+ */
22058
+ const MAX_COVERAGE_BODY_BYTES = 8 * 1024 * 1024;
22059
+ function requireKey(config) {
22060
+ if (!config.encryptionKey) throw new HttpError(503, "encryption_not_configured", "CCQA_HUB_ENCRYPTION_KEY is not set on this hub");
22061
+ return config.encryptionKey;
22062
+ }
22063
+ function requireProjectParam(ctx) {
22064
+ return requireSafeSegment(ctx.url.searchParams.get("project") ?? "", "project");
22065
+ }
22066
+ /** Which credential the request carries: the hub's own, or the application's append-only one. */
22067
+ function authenticate(ctx, config) {
22068
+ const token = extractToken(ctx.req, ctx.url);
22069
+ if (isValidToken(token, config.hubToken)) return "hub";
22070
+ if (config.coverageToken === void 0) throw new HttpError(503, "coverage_inbox_not_configured", "CCQA_HUB_COVERAGE_TOKEN is not set on this hub");
22071
+ if (isValidToken(token, config.coverageToken)) return "app";
22072
+ throw new HttpError(401, "unauthorized", "missing or invalid bearer token");
22073
+ }
22074
+ /** POST /api/v1/coverage/events?project= — stamp and append one event; 204 on receipt. */
22075
+ function createAppendCoverageEventHandler(config) {
22076
+ return async (ctx) => {
22077
+ const caller = authenticate(ctx, config);
22078
+ const key = requireKey(config);
22079
+ const project = requireProjectParam(ctx);
22080
+ const body = await readJsonBody(ctx.req, MAX_COVERAGE_BODY_BYTES, InboxBodySchema, "coverage event");
22081
+ if (caller === "app" && !("protocol" in body)) throw new HttpError(403, "forbidden", "the coverage token appends application pushes only; run events require the hub bearer token");
22082
+ const payload = encodeEncryptedBlob(encrypt(new TextEncoder().encode(JSON.stringify(body)), key));
22083
+ await config.store.append(project, payload);
22084
+ ctx.res.statusCode = 204;
22085
+ ctx.res.end();
22086
+ };
22087
+ }
22088
+ /**
22089
+ * GET /api/v1/coverage/events?project=&sinceSeq= — the stream after `sinceSeq`
22090
+ * (exclusive, so a consumer passes back the `lastSeq` it saw), decrypted.
22091
+ * Hub bearer token only (the server's central check): what the append-only
22092
+ * credential wrote, it must not be able to read back.
22093
+ */
22094
+ function createGetCoverageEventsHandler(config) {
22095
+ return async (ctx) => {
22096
+ const key = requireKey(config);
22097
+ const project = requireProjectParam(ctx);
22098
+ const sinceSeq = requireSinceSeqParam(ctx.url);
22099
+ sendJson(ctx.res, 200, await readStream(config, key, project, sinceSeq));
22100
+ };
22101
+ }
22102
+ /** The stored stream after `sinceSeq` (exclusive), decrypted and parsed. */
22103
+ async function readStream(config, key, project, sinceSeq) {
22104
+ const { entries, lastSeq, skipped } = await config.store.read(project, sinceSeq);
22105
+ const events = [];
22106
+ let unreadable = 0;
22107
+ for (const entry of entries) try {
22108
+ const plain = decrypt(decodeEncryptedBlob(entry.payload), key);
22109
+ const body = InboxBodySchema.parse(JSON.parse(new TextDecoder().decode(plain)));
22110
+ events.push({
22111
+ seq: entry.seq,
22112
+ at: entry.at,
22113
+ body
22114
+ });
22115
+ } catch {
22116
+ unreadable += 1;
22117
+ }
22118
+ return {
22119
+ events,
22120
+ lastSeq,
22121
+ skipped: skipped + unreadable
22122
+ };
22123
+ }
22124
+ /** Newest runs the resolve read offers; past twenty the answer is history nobody pages through. */
22125
+ const RUN_IDS_LIMIT = 20;
22126
+ /** Resolved answers kept per handler; one page polls one key, so a handful covers the readers. */
22127
+ const RESOLVE_CACHE_LIMIT = 8;
22128
+ /**
22129
+ * Memo of served answers keyed by stream position (ADR-0022: a resolved
22130
+ * answer may be cached keyed by stream position, but the cache is never the
22131
+ * record). Any new event moves the stream's seq, so a stored answer can only
22132
+ * ever be served for exactly the stream it was computed from — which is what
22133
+ * lets the handler answer a hit without reading the stream at all.
22134
+ * Least-recently-used beyond `limit`. `runKey` is the requested runId, or ""
22135
+ * for "the latest run". Unambiguous join: `project` is a safe segment (no
22136
+ * newline) and the trailing element is a number, so `runKey` cannot forge
22137
+ * another key.
22138
+ */
22139
+ function createResolveMemo(limit) {
22140
+ const cache = /* @__PURE__ */ new Map();
22141
+ const keyOf = (project, runKey, seq) => `${project}\n${runKey}\n${seq}`;
22142
+ return {
22143
+ get(project, runKey, seq) {
22144
+ const cacheKey = keyOf(project, runKey, seq);
22145
+ const hit = cache.get(cacheKey);
22146
+ if (hit !== void 0) {
22147
+ cache.delete(cacheKey);
22148
+ cache.set(cacheKey, hit);
22149
+ }
22150
+ return hit;
22151
+ },
22152
+ put(project, runKey, seq, answer) {
22153
+ cache.set(keyOf(project, runKey, seq), answer);
22154
+ if (cache.size > limit) {
22155
+ const oldest = cache.keys().next().value;
22156
+ if (oldest !== void 0) cache.delete(oldest);
22157
+ }
22158
+ }
22159
+ };
22160
+ }
22161
+ /**
22162
+ * GET /api/v1/coverage?project=[&runId=] — the stream, interpreted for one
22163
+ * run by the shared resolver (resolve-stream.ts); this handler only reads,
22164
+ * memoizes and serves. `runId` omitted means the run the stream most
22165
+ * recently heard a spec-open from — the page's default view; naming one
22166
+ * serves history. Hub bearer token only (the central check), like the raw
22167
+ * read.
22168
+ */
22169
+ function createResolveCoverageHandler(config) {
22170
+ const memo = createResolveMemo(RESOLVE_CACHE_LIMIT);
22171
+ return async (ctx) => {
22172
+ const key = requireKey(config);
22173
+ const project = requireProjectParam(ctx);
22174
+ const requested = ctx.url.searchParams.get("runId");
22175
+ const runKey = requested !== null && requested !== "" ? requested : "";
22176
+ const seq = await config.store.currentSeq(project);
22177
+ const hit = memo.get(project, runKey, seq);
22178
+ if (hit !== void 0) {
22179
+ sendJson(ctx.res, 200, hit);
22180
+ return;
22181
+ }
22182
+ const { events, lastSeq } = await readStream(config, key, project, 0);
22183
+ const runIds = listRunIds(events).slice(0, RUN_IDS_LIMIT);
22184
+ const runId = runKey !== "" ? runKey : runIds[0];
22185
+ const answer = {
22186
+ resolved: runId === void 0 || events.length === 0 ? null : resolveStream(events, runId),
22187
+ runIds
22188
+ };
22189
+ memo.put(project, runKey, lastSeq, answer);
22190
+ sendJson(ctx.res, 200, answer);
22191
+ };
22192
+ }
22193
+ /** Rejected rather than defaulted on garbage: a typo would otherwise read as "the whole stream". */
22194
+ function requireSinceSeqParam(url) {
22195
+ const raw = url.searchParams.get("sinceSeq");
22196
+ if (raw === null || raw === "") return 0;
22197
+ const value = Number(raw);
22198
+ if (!Number.isInteger(value) || value < 0) throw new HttpError(400, "invalid_param", "invalid sinceSeq: must be a non-negative integer");
22199
+ return value;
22200
+ }
22201
+ //#endregion
21464
22202
  //#region src/hub/core/rerun.ts
21465
22203
  /**
21466
22204
  * When each deployed commit reached the environment. A baseline read at that
@@ -22201,28 +22939,6 @@ var Router = class {
22201
22939
  }
22202
22940
  };
22203
22941
  //#endregion
22204
- //#region src/hub/api/auth.ts
22205
- /**
22206
- * Constant-time comparison against the hub's bearer token, so response
22207
- * timing can't be used to guess the token character-by-character. Accepts
22208
- * the token either as an `Authorization: Bearer <token>` header or, for
22209
- * read-only GET endpoints only (the artifacts download is a browser `<a>` that can't
22210
- * carry a header), a `?token=` query parameter — see docs/hub-api.md for
22211
- * the security tradeoff that accepts.
22212
- */
22213
- function extractToken(req, url) {
22214
- const header = req.headers.authorization;
22215
- if (header?.startsWith("Bearer ")) return header.slice(7);
22216
- return url.searchParams.get("token");
22217
- }
22218
- function isValidToken(provided, expected) {
22219
- if (provided === null) return false;
22220
- const a = Buffer.from(provided);
22221
- const b = Buffer.from(expected);
22222
- if (a.length !== b.length) return false;
22223
- return timingSafeEqual(a, b);
22224
- }
22225
- //#endregion
22226
22942
  //#region src/hub/api/cors.ts
22227
22943
  /**
22228
22944
  * Apply CORS headers when the request's Origin is in the configured
@@ -23411,6 +24127,7 @@ const CLIENT_JS = `
23411
24127
  "coverage.reached": "Reached", "coverage.uncovered": "Uncovered", "coverage.files": "files",
23412
24128
  "coverage.measured": "measured", "coverage.specsCombined": "specs combined",
23413
24129
  "coverage.noUniverse": "This measurement carried no file inventory, so only reached files are shown; nothing can be called uncovered.",
24130
+ "coverage.noServerDuringRun": "No instrumented server process reported during this run — check CCQA_COVERAGE_ENDPOINT on the application.",
23414
24131
  "coverage.placeholder": "Select a file to see the cases that reach it.",
23415
24132
  "coverage.fileUncovered": "No case reached this file in this measurement.",
23416
24133
  "coverage.casesReach": "case(s) reach this file",
@@ -23634,6 +24351,7 @@ const CLIENT_JS = `
23634
24351
  "coverage.reached": "到達", "coverage.uncovered": "未到達", "coverage.files": "ファイル",
23635
24352
  "coverage.measured": "計測", "coverage.specsCombined": "spec 合算",
23636
24353
  "coverage.noUniverse": "この計測にはファイル台帳が付いていないため、到達したファイルのみ表示しています。未到達は判定できません。",
24354
+ "coverage.noServerDuringRun": "この実行中、計装済みサーバプロセスからの報告がありませんでした。アプリケーション側の CCQA_COVERAGE_ENDPOINT を確認してください。",
23637
24355
  "coverage.placeholder": "ファイルを選択すると、到達しているケースが表示されます",
23638
24356
  "coverage.fileUncovered": "この計測では、どのケースもこのファイルに到達しませんでした。",
23639
24357
  "coverage.casesReach": "ケースが到達",
@@ -24562,8 +25280,8 @@ const CLIENT_JS = `
24562
25280
  }
24563
25281
 
24564
25282
  // == coverage: file tree =============================================
24565
- // One run's measurement drawn over the enumerated universe. Everything is
24566
- // display-side aggregation of report.json the hub computes nothing new.
25283
+ // One run's measurement drawn over the enumerated universe. The hub's
25284
+ // resolve endpoint answers first (ADR-0022); the page only colours it.
24567
25285
  var covState = { q: "", unc: false, model: null, selected: null, openDirs: null, loadToken: 0 };
24568
25286
 
24569
25287
  function openCoverage() {
@@ -24579,17 +25297,14 @@ const CLIENT_JS = `
24579
25297
  document.getElementById("cov-body").hidden = true;
24580
25298
  status.hidden = false;
24581
25299
  status.textContent = t("coverage.loading");
24582
- apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&kind=run&limit=25")
24583
- .then(function (data) {
24584
- // A run still in flight has a report, but a partial one: rows trickle
24585
- // in per spec and the universe only arrives with the seal.
24586
- var runs = (data.runs || []).filter(function (r) { return r.status !== "running"; });
24587
- return covFindReport(runs, 0, token);
24588
- })
24589
- .then(function (found) {
25300
+ covLoadResolved(token)
25301
+ // Runs that measured locally streamed nothing; their answer still rides
25302
+ // report.json, so the probe stays as the fallback for them.
25303
+ .then(function (model) { return model || covLoadFromReports(token); })
25304
+ .then(function (model) {
24590
25305
  if (token !== covState.loadToken) return;
24591
- if (!found) { status.textContent = t("coverage.none"); return; }
24592
- covState.model = covBuildModel(found.run, found.report);
25306
+ if (!model) { status.textContent = t("coverage.none"); return; }
25307
+ covState.model = model;
24593
25308
  covState.openDirs = null;
24594
25309
  covState.selected = null;
24595
25310
  status.hidden = true;
@@ -24602,6 +25317,89 @@ const CLIENT_JS = `
24602
25317
  });
24603
25318
  }
24604
25319
 
25320
+ // The stream's resolved answer, or null when nothing streamed. Not always
25321
+ // the freshest answer: a local run after a hub-mode one writes its coverage
25322
+ // into report.json, not the stream, so a settled run newer than the
25323
+ // stream's last word is probed first and wins when it measured.
25324
+ function covLoadResolved(token) {
25325
+ return apiFetch("/api/v1/coverage?project=" + encodeURIComponent(state.project))
25326
+ .then(function (data) {
25327
+ if (token !== covState.loadToken || !data || !data.resolved) return null;
25328
+ var resolved = data.resolved;
25329
+ return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&kind=run&limit=25")
25330
+ .catch(function () { return null; })
25331
+ .then(function (list) {
25332
+ // Newest first, settled only, and only runs created after the
25333
+ // stream's "as of" — older ones the stream already answers for.
25334
+ var newer = ((list && list.runs) || []).filter(function (r) {
25335
+ return r.status !== "running" && Date.parse(r.createdAt) > resolved.asOf;
25336
+ }).slice(0, 5);
25337
+ return covFindReport(newer, 0, token).then(function (found) {
25338
+ if (found) return covBuildModel(found.run, found.report);
25339
+ return covResolvedGitHead(resolved).then(function (head) {
25340
+ return covModelFromResolved(resolved, head);
25341
+ });
25342
+ });
25343
+ });
25344
+ })
25345
+ .catch(function () { return null; });
25346
+ }
25347
+
25348
+ // The stream carries no commit; only the linked run record does. One direct
25349
+ // read — a stream run id with no run record simply shows no sha.
25350
+ function covResolvedGitHead(resolved) {
25351
+ if (!resolved.hubRunId) return Promise.resolve(null);
25352
+ return apiFetch("/api/v1/runs/" + encodeURIComponent(resolved.hubRunId))
25353
+ .then(function (run) { return (run && run.gitHead) || null; })
25354
+ .catch(function () { return null; });
25355
+ }
25356
+
25357
+ function covLoadFromReports(token) {
25358
+ return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&kind=run&limit=25")
25359
+ .then(function (data) {
25360
+ // A run still in flight has a report, but a partial one: rows trickle
25361
+ // in per spec and the universe only arrives with the seal.
25362
+ var runs = (data.runs || []).filter(function (r) { return r.status !== "running"; });
25363
+ return covFindReport(runs, 0, token);
25364
+ })
25365
+ .then(function (found) {
25366
+ if (!found) return null;
25367
+ return covBuildModel(found.run, found.report);
25368
+ });
25369
+ }
25370
+
25371
+ // Reshapes the resolved answer into the {results, coverageUniverse} the
25372
+ // report-based model builder already consumes, so one tree renderer serves
25373
+ // both sources.
25374
+ function covModelFromResolved(resolved, gitHead) {
25375
+ var results = (resolved.specs || []).map(function (s) {
25376
+ // Spec ids are "<runId>.<feature>/<spec>" (the run id keeps a stale
25377
+ // cookie out); the page is already scoped to one run, so drop it.
25378
+ var key = s.specId;
25379
+ if (key.indexOf(resolved.runId + ".") === 0) key = key.slice(resolved.runId.length + 1);
25380
+ var slash = key.indexOf("/");
25381
+ return {
25382
+ feature: slash === -1 ? key : key.slice(0, slash),
25383
+ spec: slash === -1 ? "" : key.slice(slash + 1),
25384
+ coverage: { files: s.files || [] },
25385
+ };
25386
+ });
25387
+ var report = {
25388
+ results: results,
25389
+ createdAt: new Date(resolved.asOf).toISOString(),
25390
+ git: { head: gitHead },
25391
+ };
25392
+ if (resolved.universe) report.coverageUniverse = { files: resolved.universe.files };
25393
+ // The stream's own run id names no run page; only the linked record does.
25394
+ var model = covBuildModel({ id: resolved.hubRunId || null }, report);
25395
+ // The endpoint-mismatch detector (ADR-0022): specs measured, yet not one
25396
+ // application push arrived while the run listened — the application is
25397
+ // pushing somewhere else, or not at all.
25398
+ model.noServerDuringRun =
25399
+ !!(resolved.health && resolved.health.pushesDuringRun === 0 && results.length > 0);
25400
+ return model;
25401
+ }
25402
+
24605
25403
  // Newest first, stop at the first run whose report actually measured.
24606
25404
  // Capped: each probe is a full report fetch, and past ten stale runs the
24607
25405
  // answer is "none recent enough to trust" anyway.
@@ -24693,7 +25491,14 @@ const CLIENT_JS = `
24693
25491
  var segments = [{ cls: "sg-verified", state: "reached", count: model.root.covered }];
24694
25492
  if (model.hasUniverse) segments.push({ cls: "sg-rerunneeded", state: "uncovered", count: uncovered });
24695
25493
  if (model.root.total > 0) axis.appendChild(ovAxisRow("", segments, "coverage.", model.root.total));
24696
- document.getElementById("cov-note").hidden = model.hasUniverse;
25494
+ // One note slot, two conditions. The endpoint warning outranks the
25495
+ // no-universe note: it says the numbers themselves are short, not just
25496
+ // that nothing can be called uncovered.
25497
+ var note = document.getElementById("cov-note");
25498
+ var noteKey = model.noServerDuringRun ? "coverage.noServerDuringRun" : "coverage.noUniverse";
25499
+ note.setAttribute("data-i18n", noteKey);
25500
+ note.textContent = t(noteKey);
25501
+ note.hidden = model.noServerDuringRun ? false : model.hasUniverse;
24697
25502
  // Without a denominator every shown file is reached — the filter could
24698
25503
  // only ever produce an empty tree, so it is withdrawn, not just zeroed.
24699
25504
  var uncChip = document.getElementById("cov-unc");
@@ -24832,12 +25637,18 @@ const CLIENT_JS = `
24832
25637
  var list = el("div", "cov-caselist");
24833
25638
  var runId = covState.model.run.id;
24834
25639
  // No pass/fail here on purpose: this pane answers "what reaches this
24835
- // file", not "did it pass" — the run page holds the verdicts.
25640
+ // file", not "did it pass" — the run page holds the verdicts. A model
25641
+ // with no linked run record gets plain rows: no link beats a dead one.
24836
25642
  f.cases.forEach(function (key) {
24837
- var a = document.createElement("a");
24838
- a.href = "#/runs/" + encodeURIComponent(runId);
24839
- a.appendChild(el("span", "cs", key));
24840
- list.appendChild(a);
25643
+ var row;
25644
+ if (runId) {
25645
+ row = document.createElement("a");
25646
+ row.href = "#/runs/" + encodeURIComponent(runId);
25647
+ } else {
25648
+ row = el("div");
25649
+ }
25650
+ row.appendChild(el("span", "cs", key));
25651
+ list.appendChild(row);
24841
25652
  });
24842
25653
  host.appendChild(list);
24843
25654
  }
@@ -28562,6 +29373,15 @@ async function loadStoredCustomPrompt(storage, project) {
28562
29373
  //#region src/hub/api/server.ts
28563
29374
  /** Endpoints reachable without a token: the liveness probe and the bundled UI shell. */
28564
29375
  const PUBLIC_PATHS = new Set(["/api/v1/health", "/"]);
29376
+ /**
29377
+ * Requests ("METHOD pathname") that authenticate inside their handlers
29378
+ * instead of the central bearer check below: the coverage append accepts a
29379
+ * second, append-only credential (ADR-0022) that a single-token check cannot
29380
+ * express. Keyed by method so the sibling reads under the same path stay
29381
+ * behind the central check. Every handler registered here must enforce auth
29382
+ * itself.
29383
+ */
29384
+ const SELF_AUTHENTICATED_ROUTES = new Set(["POST /api/v1/coverage/events"]);
28565
29385
  function createHubServer(config) {
28566
29386
  const queue = new LearningQueue(config.storage.jobs, createLearningWorker({ storage: config.storage }));
28567
29387
  queue.recoverFromRestart().catch((err) => {
@@ -28585,12 +29405,13 @@ async function handleRequest(req, res, router, config) {
28585
29405
  try {
28586
29406
  if (applyCors(req, res, config.allowedOrigins)) return;
28587
29407
  const url = new URL(req.url ?? "/", "http://localhost");
28588
- const matched = router.match(req.method ?? "GET", url.pathname);
29408
+ const method = req.method ?? "GET";
29409
+ const matched = router.match(method, url.pathname);
28589
29410
  if (!matched) {
28590
29411
  sendError(res, new HttpError(404, "not_found", `no route for ${req.method} ${url.pathname}`));
28591
29412
  return;
28592
29413
  }
28593
- if (!PUBLIC_PATHS.has(url.pathname)) {
29414
+ if (!PUBLIC_PATHS.has(url.pathname) && !SELF_AUTHENTICATED_ROUTES.has(`${method} ${url.pathname}`)) {
28594
29415
  if (!isValidToken(extractToken(req, url), config.token)) {
28595
29416
  sendError(res, new HttpError(401, "unauthorized", "missing or invalid bearer token"));
28596
29417
  return;
@@ -28683,6 +29504,15 @@ function registerRoutes(router, config, queue) {
28683
29504
  router.get("/api/v1/projects/:project/perspectives", createGetPerspectivesHandler(perspectivesConfig));
28684
29505
  router.patch("/api/v1/projects/:project/perspectives", createPatchPerspectivesNoteHandler(perspectivesConfig));
28685
29506
  router.delete("/api/v1/projects/:project/perspectives", createDeletePerspectivesHandler(perspectivesConfig));
29507
+ const coverageConfig = {
29508
+ store: storage.coverageEvents,
29509
+ encryptionKey: config.encryptionKey,
29510
+ hubToken: config.token,
29511
+ coverageToken: config.coverageToken
29512
+ };
29513
+ router.post("/api/v1/coverage/events", createAppendCoverageEventHandler(coverageConfig));
29514
+ router.get("/api/v1/coverage/events", createGetCoverageEventsHandler(coverageConfig));
29515
+ router.get("/api/v1/coverage", createResolveCoverageHandler(coverageConfig));
28686
29516
  router.post("/api/v1/projects/:project/learning-jobs", createCreateLearningJobHandler({
28687
29517
  storage,
28688
29518
  queue
@@ -28852,6 +29682,7 @@ function isNotFound(err) {
28852
29682
  * deploys/<project>/<profile>/touch.json (SpecTouchIndex derived from the log)
28853
29683
  * acks/<project>/<profile>/<name>.json (Ack: a consumer's acted-on keys)
28854
29684
  * spend/<project>.json (SpendLog, pruned to its retention window)
29685
+ * coverage/<project>/events.jsonl (coverage inbox: stamped encrypted events)
28855
29686
  *
28856
29687
  * IDs and names are validated by their callers (run ids are server-minted
28857
29688
  * UUIDs; project/profile/name come from validated request params) before
@@ -28955,6 +29786,9 @@ function ackPath(root, project, profile, name) {
28955
29786
  function spendPath(root, project) {
28956
29787
  return join(root, "spend", `${project}.json`);
28957
29788
  }
29789
+ function coverageEventsPath(root, project) {
29790
+ return join(root, "coverage", project, "events.jsonl");
29791
+ }
28958
29792
  //#endregion
28959
29793
  //#region src/hub/core/storage/file/ack-store.ts
28960
29794
  function assertSafeKey(project, profile, name) {
@@ -29064,6 +29898,176 @@ function createFileArtifactStore(root) {
29064
29898
  }
29065
29899
  };
29066
29900
  }
29901
+ const PRUNE_COUNT_BATCH = 1e3;
29902
+ const PRUNE_AGE_SLACK_MS = 3600 * 1e3;
29903
+ /**
29904
+ * Coverage-inbox storage: `coverage/<project>/events.jsonl`, one stamped
29905
+ * event per line, appended in place (not atomic-rewritten — an append must
29906
+ * not cost the whole stream). A reader can therefore observe a partial final
29907
+ * line mid-append; the read side counts such lines as skipped rather than
29908
+ * failing, and the prune's full rewrite goes through the atomic path.
29909
+ */
29910
+ function createFileCoverageEventStore(root, caps) {
29911
+ const maxEvents = caps?.maxEvents ?? 2e5;
29912
+ const maxBytes = caps?.maxBytes ?? 268435456;
29913
+ const retentionMs = caps?.retentionMs ?? 336 * 60 * 60 * 1e3;
29914
+ const pruneBatch = Math.min(PRUNE_COUNT_BATCH, Math.max(1, Math.floor(maxEvents / 10)));
29915
+ const pruneBytesTarget = maxBytes - Math.max(1, Math.floor(maxBytes / 10));
29916
+ const states = /* @__PURE__ */ new Map();
29917
+ async function loadState(project, path) {
29918
+ const cached = states.get(project);
29919
+ if (cached) return cached;
29920
+ const raw = await readRaw(path);
29921
+ const state = {
29922
+ nextSeq: 1,
29923
+ count: 0,
29924
+ bytes: Buffer.byteLength(raw),
29925
+ oldestAt: null,
29926
+ endsWithNewline: raw === "" || raw.endsWith("\n")
29927
+ };
29928
+ for (const rawLine of nonEmptyLines(raw)) {
29929
+ const line = parseLine(rawLine);
29930
+ if (line === null) continue;
29931
+ if (line.seq >= state.nextSeq) state.nextSeq = line.seq + 1;
29932
+ state.count += 1;
29933
+ if (state.oldestAt === null || line.at < state.oldestAt) state.oldestAt = line.at;
29934
+ }
29935
+ states.set(project, state);
29936
+ return state;
29937
+ }
29938
+ async function pruneIfDue(project, path, state, now) {
29939
+ const overCount = state.count > maxEvents;
29940
+ const overBytes = state.bytes > maxBytes;
29941
+ const overAge = state.oldestAt !== null && state.oldestAt < now - retentionMs - PRUNE_AGE_SLACK_MS;
29942
+ if (!overCount && !overBytes && !overAge) return;
29943
+ const lines = await readLines(path);
29944
+ const cutoff = now - retentionMs;
29945
+ const fresh = lines.filter((l) => l.at >= cutoff);
29946
+ const keep = overCount ? Math.max(0, maxEvents - pruneBatch) : maxEvents;
29947
+ let kept = fresh.length > keep ? fresh.slice(fresh.length - keep) : fresh;
29948
+ if (overBytes) kept = newestWithinBytes(kept, pruneBytesTarget);
29949
+ const encoded = new TextEncoder().encode(kept.map((l) => JSON.stringify(l)).join("\n") + (kept.length > 0 ? "\n" : ""));
29950
+ await writeBytes(path, encoded);
29951
+ const dropped = state.count - kept.length;
29952
+ state.count = kept.length;
29953
+ state.bytes = encoded.byteLength;
29954
+ state.oldestAt = kept[0]?.at ?? null;
29955
+ state.endsWithNewline = true;
29956
+ 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)`);
29957
+ }
29958
+ return {
29959
+ async append(project, payload) {
29960
+ assertSafeName(project, "project");
29961
+ const path = coverageEventsPath(root, project);
29962
+ return await serialize(path, async () => {
29963
+ const state = await loadState(project, path);
29964
+ const stamp = {
29965
+ seq: state.nextSeq,
29966
+ at: Date.now()
29967
+ };
29968
+ const line = {
29969
+ ...stamp,
29970
+ payload: Buffer.from(payload).toString("base64")
29971
+ };
29972
+ await mkdir(dirname(path), { recursive: true });
29973
+ const text = (state.endsWithNewline ? "" : "\n") + JSON.stringify(line) + "\n";
29974
+ await appendFile(path, text);
29975
+ state.nextSeq += 1;
29976
+ state.count += 1;
29977
+ state.bytes += Buffer.byteLength(text);
29978
+ state.endsWithNewline = true;
29979
+ if (state.oldestAt === null) state.oldestAt = stamp.at;
29980
+ await pruneIfDue(project, path, state, stamp.at);
29981
+ return stamp;
29982
+ });
29983
+ },
29984
+ async read(project, sinceSeq) {
29985
+ assertSafeName(project, "project");
29986
+ const path = coverageEventsPath(root, project);
29987
+ const now = Date.now();
29988
+ await serialize(path, async () => {
29989
+ await pruneIfDue(project, path, await loadState(project, path), now);
29990
+ });
29991
+ const raw = await readRaw(path);
29992
+ const cutoff = now - retentionMs;
29993
+ const entries = [];
29994
+ let lastSeq = 0;
29995
+ let skipped = 0;
29996
+ for (const rawLine of nonEmptyLines(raw)) {
29997
+ const line = parseLine(rawLine);
29998
+ if (line === null) {
29999
+ skipped += 1;
30000
+ continue;
30001
+ }
30002
+ if (line.seq > lastSeq) lastSeq = line.seq;
30003
+ if (line.seq <= sinceSeq) continue;
30004
+ if (line.at < cutoff) continue;
30005
+ entries.push({
30006
+ seq: line.seq,
30007
+ at: line.at,
30008
+ payload: new Uint8Array(Buffer.from(line.payload, "base64"))
30009
+ });
30010
+ }
30011
+ entries.sort((a, b) => a.seq - b.seq);
30012
+ return {
30013
+ entries,
30014
+ lastSeq,
30015
+ skipped
30016
+ };
30017
+ },
30018
+ async currentSeq(project) {
30019
+ assertSafeName(project, "project");
30020
+ const cached = states.get(project);
30021
+ if (cached) return cached.nextSeq - 1;
30022
+ const path = coverageEventsPath(root, project);
30023
+ return (await serialize(path, () => loadState(project, path))).nextSeq - 1;
30024
+ }
30025
+ };
30026
+ }
30027
+ /** The longest tail of `lines` whose serialized size (newlines included) fits in `budget`. */
30028
+ function newestWithinBytes(lines, budget) {
30029
+ let total = 0;
30030
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
30031
+ total += Buffer.byteLength(JSON.stringify(lines[i])) + 1;
30032
+ if (total > budget) return lines.slice(i + 1);
30033
+ }
30034
+ return lines;
30035
+ }
30036
+ async function readRaw(path) {
30037
+ try {
30038
+ return await readFile(path, "utf8");
30039
+ } catch (err) {
30040
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") return "";
30041
+ throw err;
30042
+ }
30043
+ }
30044
+ function nonEmptyLines(raw) {
30045
+ return raw.split("\n").filter((l) => l !== "");
30046
+ }
30047
+ /** Every parseable line of the log; partial or corrupt lines are silently omitted (the read side counts them). */
30048
+ async function readLines(path) {
30049
+ const lines = [];
30050
+ for (const rawLine of nonEmptyLines(await readRaw(path))) {
30051
+ const line = parseLine(rawLine);
30052
+ if (line !== null) lines.push(line);
30053
+ }
30054
+ return lines;
30055
+ }
30056
+ function parseLine(rawLine) {
30057
+ let value;
30058
+ try {
30059
+ value = JSON.parse(rawLine);
30060
+ } catch {
30061
+ return null;
30062
+ }
30063
+ const line = value;
30064
+ if (typeof line.seq !== "number" || typeof line.at !== "number" || typeof line.payload !== "string") return null;
30065
+ return {
30066
+ seq: line.seq,
30067
+ at: line.at,
30068
+ payload: line.payload
30069
+ };
30070
+ }
29067
30071
  //#endregion
29068
30072
  //#region src/hub/core/storage/file/deploy-store.ts
29069
30073
  function createFileDeployStore(root) {
@@ -29507,7 +30511,8 @@ function createFileHubStorage(dataDir) {
29507
30511
  acks: createFileAckStore(dataDir),
29508
30512
  spend: createFileSpendStore(dataDir),
29509
30513
  attestations: createFileAttestationStore(dataDir),
29510
- auditDismissals: createFileAuditDismissalStore(dataDir)
30514
+ auditDismissals: createFileAuditDismissalStore(dataDir),
30515
+ coverageEvents: createFileCoverageEventStore(dataDir)
29511
30516
  };
29512
30517
  }
29513
30518
  //#endregion
@@ -29551,6 +30556,11 @@ async function runServe(opts) {
29551
30556
  process.exit(2);
29552
30557
  }
29553
30558
  else warn("CCQA_HUB_ENCRYPTION_KEY is not set — sessions and variables cannot be stored (PUT returns 503)");
30559
+ const coverageToken = process.env.CCQA_HUB_COVERAGE_TOKEN;
30560
+ if (coverageToken !== void 0 && coverageToken === token) {
30561
+ error("CCQA_HUB_COVERAGE_TOKEN must differ from CCQA_HUB_TOKEN — the same value would make the append-only credential a full hub token");
30562
+ process.exit(2);
30563
+ }
29554
30564
  const dataDir = resolveCwd(opts.dataDir);
29555
30565
  const server = createHubServer({
29556
30566
  storage: createHubStorage({
@@ -29561,7 +30571,8 @@ async function runServe(opts) {
29561
30571
  encryptionKey,
29562
30572
  allowedOrigins: opts.allowOrigin ?? [],
29563
30573
  ...opts.maxPushMb ? { maxPushBytes: opts.maxPushMb * 1024 * 1024 } : {},
29564
- ...opts.maxRunsPerBranch ? { maxRunsPerBranch: opts.maxRunsPerBranch } : {}
30574
+ ...opts.maxRunsPerBranch ? { maxRunsPerBranch: opts.maxRunsPerBranch } : {},
30575
+ ...coverageToken ? { coverageToken } : {}
29565
30576
  });
29566
30577
  const requestedPort = Number(opts.port);
29567
30578
  if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
@@ -29578,6 +30589,7 @@ async function runServe(opts) {
29578
30589
  header("serve", `port ${boundPort}`);
29579
30590
  meta("data-dir", dataDir);
29580
30591
  meta("encryption", encryptionKey ? "enabled" : "disabled (no CCQA_HUB_ENCRYPTION_KEY)");
30592
+ meta("coverage inbox", coverageToken && encryptionKey ? "enabled" : coverageToken ? "disabled (no CCQA_HUB_ENCRYPTION_KEY)" : "disabled (no CCQA_HUB_COVERAGE_TOKEN)");
29581
30593
  meta("run retention", `${opts.maxRunsPerBranch ?? 200} per project/branch`);
29582
30594
  const auth = driftAuthAvailable();
29583
30595
  meta("triage learning", auth.ok ? "available" : `unavailable (${auth.reason} — learning jobs will fail)`);