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 +1170 -195
- package/dist/hub-client/index.d.mts +10 -1
- package/dist/hub-client/index.mjs +48 -37
- package/dist/package.json +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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/
|
|
5663
|
+
//#region src/coverage/resolver.ts
|
|
5659
5664
|
/**
|
|
5660
|
-
*
|
|
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
|
-
*
|
|
5664
|
-
*
|
|
5665
|
-
*
|
|
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
|
-
|
|
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(
|
|
5713
|
-
this.server = server;
|
|
5714
|
+
constructor(issued, tagToKey) {
|
|
5714
5715
|
this.issued = issued;
|
|
5715
5716
|
this.tagToKey = tagToKey;
|
|
5716
5717
|
}
|
|
5717
|
-
|
|
5718
|
-
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
5725
|
-
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
5731
|
-
|
|
5732
|
-
|
|
5733
|
-
|
|
5734
|
-
|
|
5735
|
-
|
|
5736
|
-
|
|
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
|
-
|
|
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`,
|
|
5959
|
-
* stamped the bounds — so each bound gives a little. It cannot reach
|
|
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
|
-
/**
|
|
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
|
-
|
|
6965
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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)
|
|
7018
|
-
const matched =
|
|
7019
|
-
const backend =
|
|
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:
|
|
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:
|
|
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:
|
|
7041
|
-
uninstrumentedProcesses:
|
|
7042
|
-
droppedPushes:
|
|
7043
|
-
unmappedActorEvents:
|
|
7044
|
-
outsideWindowEvents: owned.reduce((sum, window) => sum + (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
7257
|
+
return this.sink?.malformedPushes() ?? 0;
|
|
7075
7258
|
}
|
|
7076
7259
|
/** Application processes that instrumented nothing at all. */
|
|
7077
7260
|
uninstrumentedProcesses() {
|
|
7078
|
-
return this.sink
|
|
7261
|
+
return this.sink?.uninstrumentedProcesses() ?? 0;
|
|
7079
7262
|
}
|
|
7080
7263
|
async close() {
|
|
7081
|
-
await this.sink
|
|
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
|
-
|
|
7307
|
+
const sink = this.sink;
|
|
7308
|
+
if (!sink.heardFromApplication()) return;
|
|
7093
7309
|
const deadline = Date.now() + SETTLE_CAP_MS;
|
|
7094
|
-
let previous =
|
|
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 =
|
|
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", `
|
|
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.").
|
|
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.
|
|
24603
|
-
//
|
|
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
|
-
|
|
24620
|
-
|
|
24621
|
-
|
|
24622
|
-
|
|
24623
|
-
|
|
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 (!
|
|
24629
|
-
covState.model =
|
|
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
|
-
|
|
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
|
|
24875
|
-
|
|
24876
|
-
|
|
24877
|
-
|
|
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
|
|
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)`);
|