ccqa 1.37.1 → 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";
@@ -4648,22 +4648,27 @@ var HubConnectionError = class extends Error {
4648
4648
  super(message);
4649
4649
  }
4650
4650
  };
4651
- /**
4652
- * Resolve a hub client from flags / env. Returns `null` (never throws/exits)
4653
- * when either the URL or the token is missing — callers that treat the hub
4654
- * as optional can fall back; callers that require it should use
4655
- * `requireHubClient` instead.
4656
- */
4657
- function resolveHubClient(opts) {
4651
+ /** `resolveHubClient`'s resolution half: `null` when the URL or token is missing. */
4652
+ function resolveHubTransport(opts) {
4658
4653
  const baseUrl = opts.hubUrl ?? process.env.CCQA_HUB_URL;
4659
4654
  const token = opts.hubToken ?? process.env.CCQA_HUB_TOKEN;
4660
4655
  if (!baseUrl || !token) return null;
4661
4656
  const headers = resolveHubHeaders(opts.hubHeader);
4662
- return createHubClient({
4657
+ return {
4663
4658
  baseUrl: baseUrl.replace(/\/+$/, ""),
4664
4659
  token,
4665
4660
  ...headers ? { headers } : {}
4666
- });
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);
4667
4672
  }
4668
4673
  /** Same as `resolveHubClient`, but throws `HubConnectionError` instead of returning `null`. */
4669
4674
  function requireHubClient(opts) {
@@ -5655,14 +5660,16 @@ const FRONTEND_COVERAGE_FILE = "coverage-frontend.json";
5655
5660
  /** The only spec-id shape this run issues, and the only one the sink accepts. */
5656
5661
  const SPEC_ID_PATTERN = /^[A-Za-z0-9._\-/]{1,200}$/;
5657
5662
  //#endregion
5658
- //#region src/coverage/sink.ts
5663
+ //#region src/coverage/resolver.ts
5659
5664
  /**
5660
- * Where instrumented application processes push what they reached. Why they
5661
- * push rather than being scraped is ADR-0021.
5665
+ * The interpretation half of coverage: what a run's raw events mean.
5662
5666
  *
5663
- * It authenticates nothing. The gate is the set of spec ids this run issued —
5664
- * a token would have to be configured on both sides to add anything, and the
5665
- * 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.
5666
5673
  */
5667
5674
  /** What an instrumented process pushes, once a second. */
5668
5675
  const PushSchema = z.object({
@@ -5681,17 +5688,13 @@ const PushSchema = z.object({
5681
5688
  files: z.array(z.string())
5682
5689
  })).default([])
5683
5690
  });
5684
- const MAX_BODY_BYTES$6 = 8 * 1024 * 1024;
5685
- var CoverageSink = class CoverageSink {
5686
- /** Where instrumented processes push to. Known once the socket is bound. */
5687
- url = "";
5691
+ var CoverageResolver = class {
5688
5692
  specs = /* @__PURE__ */ new Map();
5689
5693
  bootFiles = /* @__PURE__ */ new Set();
5690
5694
  /** What each reporting process last said about itself, keyed so a restart is a new one. */
5691
5695
  processes = /* @__PURE__ */ new Map();
5692
5696
  pushesReceived = 0;
5693
5697
  rejected = 0;
5694
- malformed = 0;
5695
5698
  /** Every turn this run has handed out, oldest first. Kept for the whole run. */
5696
5699
  windows = [];
5697
5700
  /** Per spec, per window, the distinct events that landed in it. Drives the row's count. */
@@ -5704,60 +5707,40 @@ var CoverageSink = class CoverageSink {
5704
5707
  * on arrival, so there is nothing else left to tell two of them apart.
5705
5708
  */
5706
5709
  unmappedAt = /* @__PURE__ */ new Set();
5707
- server;
5708
5710
  /** Spec ids this run issued. A push naming anything else is dropped. */
5709
5711
  issued;
5710
5712
  /** Declared identities to their display key. A tag absent here is somebody else's. */
5711
5713
  tagToKey;
5712
- constructor(server, issued, tagToKey) {
5713
- this.server = server;
5714
+ constructor(issued, tagToKey) {
5714
5715
  this.issued = issued;
5715
5716
  this.tagToKey = tagToKey;
5716
5717
  }
5717
- /**
5718
- * Binds and starts accepting pushes. `issued` is fixed at start: the cookie
5719
- * is client-controlled, so an id this run never issued is refused here
5720
- * rather than trusted into a report.
5721
- */
5722
- static async start(host, port, issued, tagToKey = /* @__PURE__ */ new Map()) {
5723
- const sink = new CoverageSink(createServer(), issued, tagToKey);
5724
- sink.server.on("request", (request, response) => {
5725
- sink.handle(request, response);
5726
- });
5727
- await new Promise((resolve, reject) => {
5728
- sink.server.once("error", reject);
5729
- sink.server.listen(port, host, () => {
5730
- sink.server.removeListener("error", reject);
5731
- resolve();
5732
- });
5733
- });
5734
- const address = sink.server.address();
5735
- sink.url = `http://${formatHost(host)}:${address.port}`;
5736
- 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
+ }
5737
5739
  }
5738
5740
  /** What `specId` reached so far. Reads do not clear: late pushes still land. */
5739
5741
  filesFor(specId) {
5740
5742
  return this.specs.get(specId)?.files;
5741
5743
  }
5742
- /** Gives `specId` sole claim to `window`'s identity from now until it is closed. */
5743
- openWindow(window, specId) {
5744
- this.windows.push({
5745
- tag: window.tag,
5746
- key: window.key,
5747
- specId,
5748
- openedAt: Date.now(),
5749
- closedAt: void 0
5750
- });
5751
- }
5752
- /** Ends the open turn on `tag`. Later events from it belong to nobody. */
5753
- closeWindow(tag) {
5754
- for (let i = this.windows.length - 1; i >= 0; i--) {
5755
- const window = this.windows[i];
5756
- if (window.tag !== tag || window.closedAt !== void 0) continue;
5757
- window.closedAt = Date.now();
5758
- return;
5759
- }
5760
- }
5761
5744
  /** When the run may next open a turn on `tag`, given the drain it has to leave. */
5762
5745
  lastClosedAt(tag) {
5763
5746
  let latest;
@@ -5822,13 +5805,6 @@ var CoverageSink = class CoverageSink {
5822
5805
  return this.rejected;
5823
5806
  }
5824
5807
  /**
5825
- * Pushes the sink could not read. Counted because the failure is otherwise
5826
- * invisible from this side and shows up as "the spec reached no server code".
5827
- */
5828
- malformedPushes() {
5829
- return this.malformed;
5830
- }
5831
- /**
5832
5808
  * Files the applications could not instrument — they can never report reach.
5833
5809
  *
5834
5810
  * Not baselined, unlike `unattributed` and `droppedPushes`: a file that
@@ -5856,38 +5832,7 @@ var CoverageSink = class CoverageSink {
5856
5832
  for (const report of this.processes.values()) total += Math.max(0, report.droppedLatest - report.droppedBaseline);
5857
5833
  return total;
5858
5834
  }
5859
- async close() {
5860
- await new Promise((resolve) => {
5861
- this.server.close(() => {
5862
- resolve();
5863
- });
5864
- });
5865
- }
5866
- async handle(request, response) {
5867
- if (request.method !== "POST") {
5868
- response.writeHead(405).end();
5869
- return;
5870
- }
5871
- let body;
5872
- try {
5873
- body = await readBody$1(request);
5874
- } catch {
5875
- this.malformed++;
5876
- response.writeHead(413).end();
5877
- return;
5878
- }
5879
- let push;
5880
- try {
5881
- push = PushSchema.parse(JSON.parse(body));
5882
- } catch {
5883
- this.malformed++;
5884
- response.writeHead(400).end();
5885
- return;
5886
- }
5887
- this.accept(push);
5888
- response.writeHead(204).end();
5889
- }
5890
- accept(push) {
5835
+ acceptPush(push) {
5891
5836
  const process = `${push.pid}:${push.startedAt}`;
5892
5837
  const known = this.processes.get(process);
5893
5838
  const previous = known?.unattributed ?? push.unattributed;
@@ -5955,9 +5900,9 @@ var CoverageSink = class CoverageSink {
5955
5900
  /**
5956
5901
  * The turn on `tag` that `at` falls in, latest first.
5957
5902
  *
5958
- * Both clocks are involved — the application stamped `at`, this process
5959
- * stamped the bounds — so each bound gives a little. It cannot reach the
5960
- * 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
5961
5906
  * identity and this reaches half of it.
5962
5907
  */
5963
5908
  windowAt(tag, at) {
@@ -5971,6 +5916,170 @@ var CoverageSink = class CoverageSink {
5971
5916
  return found;
5972
5917
  }
5973
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
+ };
5974
6083
  function formatHost(host) {
5975
6084
  return host.includes(":") ? `[${host}]` : host;
5976
6085
  }
@@ -6936,7 +7045,19 @@ const SETTLE_QUIET_POLLS = 10;
6936
7045
  const SETTLE_CAP_MS = 1e4;
6937
7046
  var CoverageSession = class CoverageSession {
6938
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
+ */
6939
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();
6940
7061
  runId;
6941
7062
  /** What reported paths are relative to, and what they are checked against. */
6942
7063
  root;
@@ -6944,10 +7065,15 @@ var CoverageSession = class CoverageSession {
6944
7065
  cwd;
6945
7066
  actors;
6946
7067
  origins;
6947
- /** 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
+ */
6948
7073
  universe;
6949
- constructor(sink, runId, root, cwd, actors, origins, universe) {
7074
+ constructor(sink, inbox, runId, root, cwd, actors, origins, universe) {
6950
7075
  this.sink = sink;
7076
+ this.inbox = inbox;
6951
7077
  this.runId = runId;
6952
7078
  this.root = root;
6953
7079
  this.cwd = cwd;
@@ -6959,16 +7085,48 @@ var CoverageSession = class CoverageSession {
6959
7085
  const origins = options.config.instrumentedOrigins.map((origin) => resolveEnvRefs(origin));
6960
7086
  const unresolved = origins.filter((origin) => !/^https?:\/\//i.test(origin));
6961
7087
  if (unresolved.length > 0) throw new Error(`coverage.instrumentedOrigins must be absolute http(s) URLs after variable substitution; got ${unresolved.join(", ")}`);
6962
- const bind = new URL(resolveEnvRefs(options.config.sink));
6963
7088
  const actors = options.actors ?? NO_ACTORS;
6964
- const issued = new Set(options.specs.map((spec) => specIdFor(options.runId, spec)));
6965
- 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
+ }
6966
7095
  const root = await resolveRoot(options.cwd, options.config.projectRoot) ?? options.cwd;
6967
7096
  const universe = options.config.include === void 0 ? void 0 : await enumerateUniverse(root, options.config.include, (text) => warn(text));
6968
- 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
+ });
6969
7117
  }
7118
+ /** Where the local sink listens. Hub mode binds nothing, so there is no URL. */
6970
7119
  get sinkUrl() {
6971
- 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;
6972
7130
  }
6973
7131
  /**
6974
7132
  * Opens the spec's measurement.
@@ -6980,14 +7138,26 @@ var CoverageSession = class CoverageSession {
6980
7138
  */
6981
7139
  async beginSpec(ref) {
6982
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
+ });
6983
7146
  for (const window of this.actors.windowsForSpec.get(specKey(ref)) ?? []) {
6984
- const closedAt = this.sink.lastClosedAt(window.tag);
7147
+ const closedAt = this.inbox === void 0 ? this.sink.lastClosedAt(window.tag) : this.windowClosedAt.get(window.tag);
6985
7148
  const wait = closedAt === void 0 ? 0 : closedAt + ACTOR_DRAIN_MS - Date.now();
6986
7149
  if (wait > 0) {
6987
7150
  meta("coverage", `waiting ${Math.ceil(wait / 1e3)}s for ${window.key} to go quiet`);
6988
7151
  await new Promise((resolve) => setTimeout(resolve, wait));
6989
7152
  }
6990
- 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
+ });
6991
7161
  }
6992
7162
  }
6993
7163
  /**
@@ -7009,21 +7179,34 @@ var CoverageSession = class CoverageSession {
7009
7179
  warn: (text) => warn(`coverage: ${text}`)
7010
7180
  });
7011
7181
  }
7012
- /** 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
+ */
7013
7191
  async collect(ref, coverageDir) {
7014
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;
7015
7198
  await this.settle(specId);
7016
7199
  const owned = this.actors.windowsForSpec.get(specKey(ref)) ?? [];
7017
- for (const window of owned) this.sink.closeWindow(window.tag);
7018
- const matched = this.sink.actorEventsFor(specId);
7019
- 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);
7020
7203
  const frontend = await readFrontend(coverageDir, specId);
7021
7204
  const inProject = await this.keepExisting(frontend?.files ?? []);
7022
7205
  return {
7023
7206
  files: [...new Set([...backend ?? [], ...inProject])].sort(),
7024
7207
  frontendFiles: inProject.length,
7025
7208
  backendFiles: backend?.size ?? 0,
7026
- backendReported: this.sink.heardFromApplication(),
7209
+ backendReported: sink.heardFromApplication(),
7027
7210
  frontendReported: frontend !== void 0,
7028
7211
  frontendStopped: frontend?.stopped ?? false,
7029
7212
  actorWindows: owned.map((window) => ({
@@ -7032,53 +7215,84 @@ var CoverageSession = class CoverageSession {
7032
7215
  })),
7033
7216
  excludedDependencies: frontend?.excludedDependencies ?? 0,
7034
7217
  gaps: {
7035
- unattributed: this.sink.unattributedFor(specId),
7218
+ unattributed: sink.unattributedFor(specId),
7036
7219
  unmappedScripts: frontend?.unmappedScripts ?? 0,
7037
7220
  unmappedRanges: frontend?.unmappedRanges ?? 0,
7038
7221
  outsideProject: (frontend?.files.length ?? 0) - inProject.length,
7039
7222
  unresolvedSources: frontend?.unresolvedSources ?? 0,
7040
- uninstrumentedFiles: this.sink.uninstrumentedFiles(),
7041
- uninstrumentedProcesses: this.sink.uninstrumentedProcesses(),
7042
- droppedPushes: this.sink.droppedPushes(),
7043
- unmappedActorEvents: this.sink.unmappedActorEvents(),
7044
- 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)
7045
7228
  }
7046
7229
  };
7047
7230
  }
7048
7231
  /** Files reached at module top level, across the whole run. */
7049
7232
  boot() {
7050
- return [...this.sink.boot()].sort();
7233
+ return this.sink === void 0 ? [] : [...this.sink.boot()].sort();
7051
7234
  }
7052
7235
  /** Whether any instrumented application process reported at all. */
7053
7236
  heardFromApplication() {
7054
- return this.sink.heardFromApplication();
7237
+ return this.sink?.heardFromApplication() ?? false;
7055
7238
  }
7056
7239
  /** Specs some application process attributed a file to. */
7057
7240
  attributedSpecs() {
7058
- return this.sink.attributedSpecs();
7241
+ return this.sink?.attributedSpecs() ?? 0;
7059
7242
  }
7060
7243
  /** Declared identities that acted outside the turns this run gave them. */
7061
7244
  outsideWindowEvents() {
7062
- return this.sink.outsideWindowEvents();
7245
+ return this.sink?.outsideWindowEvents() ?? /* @__PURE__ */ new Map();
7063
7246
  }
7064
7247
  /** Events from identities this project never declared. */
7065
7248
  unmappedActorEvents() {
7066
- return this.sink.unmappedActorEvents();
7249
+ return this.sink?.unmappedActorEvents() ?? 0;
7067
7250
  }
7068
7251
  /** Pushes naming a spec id this run never issued — a stale or forged cookie. */
7069
7252
  rejectedPushes() {
7070
- return this.sink.rejectedPushes();
7253
+ return this.sink?.rejectedPushes() ?? 0;
7071
7254
  }
7072
7255
  /** Pushes the sink could not read — the two halves' wire formats disagree. */
7073
7256
  malformedPushes() {
7074
- return this.sink.malformedPushes();
7257
+ return this.sink?.malformedPushes() ?? 0;
7075
7258
  }
7076
7259
  /** Application processes that instrumented nothing at all. */
7077
7260
  uninstrumentedProcesses() {
7078
- return this.sink.uninstrumentedProcesses();
7261
+ return this.sink?.uninstrumentedProcesses() ?? 0;
7079
7262
  }
7080
7263
  async close() {
7081
- 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
+ });
7082
7296
  }
7083
7297
  /** Keeps the paths that name a file in the working tree, cached per session. */
7084
7298
  async keepExisting(paths) {
@@ -7088,14 +7302,16 @@ var CoverageSession = class CoverageSession {
7088
7302
  }));
7089
7303
  return paths.filter((path) => this.existing.get(path) === true);
7090
7304
  }
7305
+ /** Local mode only; hub mode never settles (see `collect`). */
7091
7306
  async settle(specId) {
7092
- if (!this.sink.heardFromApplication()) return;
7307
+ const sink = this.sink;
7308
+ if (!sink.heardFromApplication()) return;
7093
7309
  const deadline = Date.now() + SETTLE_CAP_MS;
7094
- let previous = this.sink.filesFor(specId)?.size ?? 0;
7310
+ let previous = sink.filesFor(specId)?.size ?? 0;
7095
7311
  let quiet = 0;
7096
7312
  while (Date.now() < deadline && quiet < SETTLE_QUIET_POLLS) {
7097
7313
  await new Promise((resolve) => setTimeout(resolve, SETTLE_POLL_MS));
7098
- const size = this.sink.filesFor(specId)?.size ?? 0;
7314
+ const size = sink.filesFor(specId)?.size ?? 0;
7099
7315
  quiet = size === previous ? quiet + 1 : 0;
7100
7316
  previous = size;
7101
7317
  }
@@ -12784,6 +13000,46 @@ function enrichZodError(error, source) {
12784
13000
  return new Error(lines.join("\n"));
12785
13001
  }
12786
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
12787
13043
  //#region src/codegen/actions-to-script.ts
12788
13044
  function actionsToScript(input) {
12789
13045
  const { actions, testName, stepMarkers = [], emptySteps = [] } = input;
@@ -15265,6 +15521,22 @@ async function executeRun(targets, opts) {
15265
15521
  if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
15266
15522
  if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
15267
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
+ }
15268
15540
  const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead] = await Promise.all([
15269
15541
  forExecution ? fetchCustomPrompt(hubCtx) : null,
15270
15542
  forExecution ? fetchTriageUserPrompt(hubCtx) : null,
@@ -15381,7 +15653,7 @@ async function executeRun(targets, opts) {
15381
15653
  const liveSpecs = withMode.filter((s) => s.mode === "live");
15382
15654
  meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
15383
15655
  if (dispatch.external.length > 0) meta("external", dispatch.external.map((g) => `${g.targetId} ${g.specs.length}`).join(" / "));
15384
- 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;
15385
15657
  if (liveSpecs.length === 0) {
15386
15658
  const why = "it only applies to agent-browser 'mode: live' specs, and this run has none";
15387
15659
  if (typeof opts.liveStepRetry === "number" && opts.liveStepRetry > 0) warn(`--live-step-retry is ignored: ${why}`);
@@ -15418,6 +15690,7 @@ async function executeRun(targets, opts) {
15418
15690
  });
15419
15691
  hubRunId = opened.id;
15420
15692
  info(`hub: incremental run opened (${opened.id})`);
15693
+ await coverage?.linkHubRun(opened.id);
15421
15694
  const runId = opened.id;
15422
15695
  let hubPatchEverSucceeded = false;
15423
15696
  hubSink = { onUpsert: async (row) => {
@@ -15492,7 +15765,7 @@ async function executeRun(targets, opts) {
15492
15765
  report: incrementalReport
15493
15766
  };
15494
15767
  const live = await runLiveSpecs(liveSpecs, liveOpts);
15495
- if (coverage) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
15768
+ if (coverage && !coverage.streamsToHub) reportCoverageHealth(coverage, [...externalRows, ...live.reportResults]);
15496
15769
  let overallExitCode = det.exitCode !== 0 ? 1 : 0;
15497
15770
  if (live.failedCount > 0) overallExitCode = 1;
15498
15771
  if (externalRows.some((r) => r.status === "failed")) overallExitCode = 1;
@@ -15527,7 +15800,7 @@ async function executeRun(targets, opts) {
15527
15800
  });
15528
15801
  report = await writeUnifiedReport({
15529
15802
  reportDir,
15530
- results: coverage ? results.map(explainMissingCoverage) : results,
15803
+ results: coverage && !coverage.streamsToHub ? results.map(explainMissingCoverage) : results,
15531
15804
  git,
15532
15805
  customPromptVersion,
15533
15806
  triageUserPromptHash,
@@ -15585,9 +15858,10 @@ async function executeRun(targets, opts) {
15585
15858
  /**
15586
15859
  * Starts the run's coverage measurement before any spec runs: the sink has to
15587
15860
  * be listening before the first request reaches the application, and the set
15588
- * 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.
15589
15863
  */
15590
- async function startCoverage(cwd, config, actors, dispatch, teardown) {
15864
+ async function startCoverage(cwd, config, actors, dispatch, teardown, inbox) {
15591
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");
15592
15866
  let session;
15593
15867
  try {
@@ -15596,12 +15870,14 @@ async function startCoverage(cwd, config, actors, dispatch, teardown) {
15596
15870
  cwd,
15597
15871
  config,
15598
15872
  actors,
15599
- specs: dispatch.external.flatMap((g) => g.specs)
15873
+ specs: dispatch.external.flatMap((g) => g.specs),
15874
+ ...inbox ? { inbox } : {}
15600
15875
  });
15601
15876
  } catch (err) {
15602
15877
  throw new RunUsageError(`could not start coverage collection: ${errMessage(err)}`);
15603
15878
  }
15604
- 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(", ")}`);
15605
15881
  const unmeasured = dispatch.external.filter((g) => g.browserCoverage.browser === "none").length;
15606
15882
  if (unmeasured > 0) warn(`${unmeasured} target(s) declare no browser to measure; their specs are reported as unmeasured rather than as reaching nothing`);
15607
15883
  teardown?.onFinalize(() => session.close());
@@ -16235,7 +16511,10 @@ const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command(
16235
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) => {
16236
16512
  if (REPORT_FORMATS.includes(raw)) return raw;
16237
16513
  throw new Error(`--report-format must be one of ${REPORT_FORMATS.join(" | ")}`);
16238
- }, "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) => {
16239
16518
  await runCliAction(targets, opts);
16240
16519
  });
16241
16520
  /** Parse --concurrency: a positive integer. Rejects 0, negatives, non-integers. */
@@ -20755,7 +21034,7 @@ function decodeEncryptedBlob(bytes) {
20755
21034
  //#endregion
20756
21035
  //#region src/hub/api/handlers/secrets.ts
20757
21036
  const MAX_SECRET_BODY_BYTES = 4 * 1024 * 1024;
20758
- function requireKey(config) {
21037
+ function requireKey$1(config) {
20759
21038
  if (!config.encryptionKey) throw new HttpError(503, "encryption_not_configured", "CCQA_HUB_ENCRYPTION_KEY is not set on this hub");
20760
21039
  return config.encryptionKey;
20761
21040
  }
@@ -20769,7 +21048,7 @@ function requireScope$1(ctx) {
20769
21048
  /** PUT /api/v1/projects/:project/sessions/:profile/:name — body is the raw agent-browser storage-state JSON. */
20770
21049
  function createPutSessionHandler(config) {
20771
21050
  return async (ctx) => {
20772
- const key = requireKey(config);
21051
+ const key = requireKey$1(config);
20773
21052
  const scope = requireScope$1(ctx);
20774
21053
  const name = requireSafeSegment(ctx.params.name, "name");
20775
21054
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
@@ -20798,7 +21077,7 @@ function createListSessionsHandler(config) {
20798
21077
  */
20799
21078
  function createGetSessionHandler(config) {
20800
21079
  return async (ctx) => {
20801
- const key = requireKey(config);
21080
+ const key = requireKey$1(config);
20802
21081
  const scope = requireScope$1(ctx);
20803
21082
  const name = requireSafeSegment(ctx.params.name, "name");
20804
21083
  const stored = await config.store.get(scope, name);
@@ -20820,7 +21099,7 @@ function createDeleteSessionHandler(config) {
20820
21099
  /** PUT /api/v1/projects/:project/variables/:profile/:name */
20821
21100
  function createPutVariableHandler(config) {
20822
21101
  return async (ctx) => {
20823
- const key = requireKey(config);
21102
+ const key = requireKey$1(config);
20824
21103
  const scope = requireScope$1(ctx);
20825
21104
  const name = requireSafeSegment(ctx.params.name, "name");
20826
21105
  const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
@@ -20843,7 +21122,7 @@ function createListVariablesHandler(config) {
20843
21122
  return async (ctx) => {
20844
21123
  const scope = requireScope$1(ctx);
20845
21124
  const includeValues = ctx.url.searchParams.get("include") === "values";
20846
- const key = includeValues ? requireKey(config) : config.encryptionKey;
21125
+ const key = includeValues ? requireKey$1(config) : config.encryptionKey;
20847
21126
  const entries = await config.store.list(scope);
20848
21127
  const variables = await Promise.all(entries.map(async (e) => {
20849
21128
  const sensitive = e.meta.sensitive === true;
@@ -21498,6 +21777,428 @@ function createGetSpendHandler(storage) {
21498
21777
  };
21499
21778
  }
21500
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
21501
22202
  //#region src/hub/core/rerun.ts
21502
22203
  /**
21503
22204
  * When each deployed commit reached the environment. A baseline read at that
@@ -22238,28 +22939,6 @@ var Router = class {
22238
22939
  }
22239
22940
  };
22240
22941
  //#endregion
22241
- //#region src/hub/api/auth.ts
22242
- /**
22243
- * Constant-time comparison against the hub's bearer token, so response
22244
- * timing can't be used to guess the token character-by-character. Accepts
22245
- * the token either as an `Authorization: Bearer <token>` header or, for
22246
- * read-only GET endpoints only (the artifacts download is a browser `<a>` that can't
22247
- * carry a header), a `?token=` query parameter — see docs/hub-api.md for
22248
- * the security tradeoff that accepts.
22249
- */
22250
- function extractToken(req, url) {
22251
- const header = req.headers.authorization;
22252
- if (header?.startsWith("Bearer ")) return header.slice(7);
22253
- return url.searchParams.get("token");
22254
- }
22255
- function isValidToken(provided, expected) {
22256
- if (provided === null) return false;
22257
- const a = Buffer.from(provided);
22258
- const b = Buffer.from(expected);
22259
- if (a.length !== b.length) return false;
22260
- return timingSafeEqual(a, b);
22261
- }
22262
- //#endregion
22263
22942
  //#region src/hub/api/cors.ts
22264
22943
  /**
22265
22944
  * Apply CORS headers when the request's Origin is in the configured
@@ -23448,6 +24127,7 @@ const CLIENT_JS = `
23448
24127
  "coverage.reached": "Reached", "coverage.uncovered": "Uncovered", "coverage.files": "files",
23449
24128
  "coverage.measured": "measured", "coverage.specsCombined": "specs combined",
23450
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.",
23451
24131
  "coverage.placeholder": "Select a file to see the cases that reach it.",
23452
24132
  "coverage.fileUncovered": "No case reached this file in this measurement.",
23453
24133
  "coverage.casesReach": "case(s) reach this file",
@@ -23671,6 +24351,7 @@ const CLIENT_JS = `
23671
24351
  "coverage.reached": "到達", "coverage.uncovered": "未到達", "coverage.files": "ファイル",
23672
24352
  "coverage.measured": "計測", "coverage.specsCombined": "spec 合算",
23673
24353
  "coverage.noUniverse": "この計測にはファイル台帳が付いていないため、到達したファイルのみ表示しています。未到達は判定できません。",
24354
+ "coverage.noServerDuringRun": "この実行中、計装済みサーバプロセスからの報告がありませんでした。アプリケーション側の CCQA_COVERAGE_ENDPOINT を確認してください。",
23674
24355
  "coverage.placeholder": "ファイルを選択すると、到達しているケースが表示されます",
23675
24356
  "coverage.fileUncovered": "この計測では、どのケースもこのファイルに到達しませんでした。",
23676
24357
  "coverage.casesReach": "ケースが到達",
@@ -24599,8 +25280,8 @@ const CLIENT_JS = `
24599
25280
  }
24600
25281
 
24601
25282
  // == coverage: file tree =============================================
24602
- // One run's measurement drawn over the enumerated universe. Everything is
24603
- // 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.
24604
25285
  var covState = { q: "", unc: false, model: null, selected: null, openDirs: null, loadToken: 0 };
24605
25286
 
24606
25287
  function openCoverage() {
@@ -24616,17 +25297,14 @@ const CLIENT_JS = `
24616
25297
  document.getElementById("cov-body").hidden = true;
24617
25298
  status.hidden = false;
24618
25299
  status.textContent = t("coverage.loading");
24619
- apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&kind=run&limit=25")
24620
- .then(function (data) {
24621
- // A run still in flight has a report, but a partial one: rows trickle
24622
- // in per spec and the universe only arrives with the seal.
24623
- var runs = (data.runs || []).filter(function (r) { return r.status !== "running"; });
24624
- return covFindReport(runs, 0, token);
24625
- })
24626
- .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) {
24627
25305
  if (token !== covState.loadToken) return;
24628
- if (!found) { status.textContent = t("coverage.none"); return; }
24629
- covState.model = covBuildModel(found.run, found.report);
25306
+ if (!model) { status.textContent = t("coverage.none"); return; }
25307
+ covState.model = model;
24630
25308
  covState.openDirs = null;
24631
25309
  covState.selected = null;
24632
25310
  status.hidden = true;
@@ -24639,6 +25317,89 @@ const CLIENT_JS = `
24639
25317
  });
24640
25318
  }
24641
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
+
24642
25403
  // Newest first, stop at the first run whose report actually measured.
24643
25404
  // Capped: each probe is a full report fetch, and past ten stale runs the
24644
25405
  // answer is "none recent enough to trust" anyway.
@@ -24730,7 +25491,14 @@ const CLIENT_JS = `
24730
25491
  var segments = [{ cls: "sg-verified", state: "reached", count: model.root.covered }];
24731
25492
  if (model.hasUniverse) segments.push({ cls: "sg-rerunneeded", state: "uncovered", count: uncovered });
24732
25493
  if (model.root.total > 0) axis.appendChild(ovAxisRow("", segments, "coverage.", model.root.total));
24733
- 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;
24734
25502
  // Without a denominator every shown file is reached — the filter could
24735
25503
  // only ever produce an empty tree, so it is withdrawn, not just zeroed.
24736
25504
  var uncChip = document.getElementById("cov-unc");
@@ -24869,12 +25637,18 @@ const CLIENT_JS = `
24869
25637
  var list = el("div", "cov-caselist");
24870
25638
  var runId = covState.model.run.id;
24871
25639
  // No pass/fail here on purpose: this pane answers "what reaches this
24872
- // 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.
24873
25642
  f.cases.forEach(function (key) {
24874
- var a = document.createElement("a");
24875
- a.href = "#/runs/" + encodeURIComponent(runId);
24876
- a.appendChild(el("span", "cs", key));
24877
- 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);
24878
25652
  });
24879
25653
  host.appendChild(list);
24880
25654
  }
@@ -28599,6 +29373,15 @@ async function loadStoredCustomPrompt(storage, project) {
28599
29373
  //#region src/hub/api/server.ts
28600
29374
  /** Endpoints reachable without a token: the liveness probe and the bundled UI shell. */
28601
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"]);
28602
29385
  function createHubServer(config) {
28603
29386
  const queue = new LearningQueue(config.storage.jobs, createLearningWorker({ storage: config.storage }));
28604
29387
  queue.recoverFromRestart().catch((err) => {
@@ -28622,12 +29405,13 @@ async function handleRequest(req, res, router, config) {
28622
29405
  try {
28623
29406
  if (applyCors(req, res, config.allowedOrigins)) return;
28624
29407
  const url = new URL(req.url ?? "/", "http://localhost");
28625
- const matched = router.match(req.method ?? "GET", url.pathname);
29408
+ const method = req.method ?? "GET";
29409
+ const matched = router.match(method, url.pathname);
28626
29410
  if (!matched) {
28627
29411
  sendError(res, new HttpError(404, "not_found", `no route for ${req.method} ${url.pathname}`));
28628
29412
  return;
28629
29413
  }
28630
- if (!PUBLIC_PATHS.has(url.pathname)) {
29414
+ if (!PUBLIC_PATHS.has(url.pathname) && !SELF_AUTHENTICATED_ROUTES.has(`${method} ${url.pathname}`)) {
28631
29415
  if (!isValidToken(extractToken(req, url), config.token)) {
28632
29416
  sendError(res, new HttpError(401, "unauthorized", "missing or invalid bearer token"));
28633
29417
  return;
@@ -28720,6 +29504,15 @@ function registerRoutes(router, config, queue) {
28720
29504
  router.get("/api/v1/projects/:project/perspectives", createGetPerspectivesHandler(perspectivesConfig));
28721
29505
  router.patch("/api/v1/projects/:project/perspectives", createPatchPerspectivesNoteHandler(perspectivesConfig));
28722
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));
28723
29516
  router.post("/api/v1/projects/:project/learning-jobs", createCreateLearningJobHandler({
28724
29517
  storage,
28725
29518
  queue
@@ -28889,6 +29682,7 @@ function isNotFound(err) {
28889
29682
  * deploys/<project>/<profile>/touch.json (SpecTouchIndex derived from the log)
28890
29683
  * acks/<project>/<profile>/<name>.json (Ack: a consumer's acted-on keys)
28891
29684
  * spend/<project>.json (SpendLog, pruned to its retention window)
29685
+ * coverage/<project>/events.jsonl (coverage inbox: stamped encrypted events)
28892
29686
  *
28893
29687
  * IDs and names are validated by their callers (run ids are server-minted
28894
29688
  * UUIDs; project/profile/name come from validated request params) before
@@ -28992,6 +29786,9 @@ function ackPath(root, project, profile, name) {
28992
29786
  function spendPath(root, project) {
28993
29787
  return join(root, "spend", `${project}.json`);
28994
29788
  }
29789
+ function coverageEventsPath(root, project) {
29790
+ return join(root, "coverage", project, "events.jsonl");
29791
+ }
28995
29792
  //#endregion
28996
29793
  //#region src/hub/core/storage/file/ack-store.ts
28997
29794
  function assertSafeKey(project, profile, name) {
@@ -29101,6 +29898,176 @@ function createFileArtifactStore(root) {
29101
29898
  }
29102
29899
  };
29103
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
+ }
29104
30071
  //#endregion
29105
30072
  //#region src/hub/core/storage/file/deploy-store.ts
29106
30073
  function createFileDeployStore(root) {
@@ -29544,7 +30511,8 @@ function createFileHubStorage(dataDir) {
29544
30511
  acks: createFileAckStore(dataDir),
29545
30512
  spend: createFileSpendStore(dataDir),
29546
30513
  attestations: createFileAttestationStore(dataDir),
29547
- auditDismissals: createFileAuditDismissalStore(dataDir)
30514
+ auditDismissals: createFileAuditDismissalStore(dataDir),
30515
+ coverageEvents: createFileCoverageEventStore(dataDir)
29548
30516
  };
29549
30517
  }
29550
30518
  //#endregion
@@ -29588,6 +30556,11 @@ async function runServe(opts) {
29588
30556
  process.exit(2);
29589
30557
  }
29590
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
+ }
29591
30564
  const dataDir = resolveCwd(opts.dataDir);
29592
30565
  const server = createHubServer({
29593
30566
  storage: createHubStorage({
@@ -29598,7 +30571,8 @@ async function runServe(opts) {
29598
30571
  encryptionKey,
29599
30572
  allowedOrigins: opts.allowOrigin ?? [],
29600
30573
  ...opts.maxPushMb ? { maxPushBytes: opts.maxPushMb * 1024 * 1024 } : {},
29601
- ...opts.maxRunsPerBranch ? { maxRunsPerBranch: opts.maxRunsPerBranch } : {}
30574
+ ...opts.maxRunsPerBranch ? { maxRunsPerBranch: opts.maxRunsPerBranch } : {},
30575
+ ...coverageToken ? { coverageToken } : {}
29602
30576
  });
29603
30577
  const requestedPort = Number(opts.port);
29604
30578
  if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
@@ -29615,6 +30589,7 @@ async function runServe(opts) {
29615
30589
  header("serve", `port ${boundPort}`);
29616
30590
  meta("data-dir", dataDir);
29617
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)");
29618
30593
  meta("run retention", `${opts.maxRunsPerBranch ?? 200} per project/branch`);
29619
30594
  const auth = driftAuthAvailable();
29620
30595
  meta("triage learning", auth.ok ? "available" : `unavailable (${auth.reason} — learning jobs will fail)`);