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