ccqa 1.8.3 → 1.9.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 +1972 -286
- package/dist/hub-client/index.d.mts +131 -1
- package/dist/hub-client/index.mjs +22 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -2252,6 +2252,7 @@ const PerspectiveSpecSchema = z.object({
|
|
|
2252
2252
|
testCondition: z.string().optional(),
|
|
2253
2253
|
preconditions: z.array(z.string().min(1)).optional(),
|
|
2254
2254
|
relatedPaths: z.array(z.string().min(1)).optional(),
|
|
2255
|
+
relatedPathsUnmatched: z.number().int().nonnegative().optional(),
|
|
2255
2256
|
status: PerspectiveStatusSchema,
|
|
2256
2257
|
note: z.string().optional()
|
|
2257
2258
|
}).strip();
|
|
@@ -3080,6 +3081,7 @@ const RunReportDataSchema = z.object({
|
|
|
3080
3081
|
promptVersion: z.string(),
|
|
3081
3082
|
customPromptVersion: z.string().nullable().default(null),
|
|
3082
3083
|
triageUserPromptHash: z.string().optional(),
|
|
3084
|
+
deployedSha: z.string().optional(),
|
|
3083
3085
|
results: z.array(ReportSpecResultSchema)
|
|
3084
3086
|
});
|
|
3085
3087
|
/** Shape of the "export labels" download produced by the report's client-side JS. */
|
|
@@ -3594,7 +3596,7 @@ async function collectSpecArtifacts(args) {
|
|
|
3594
3596
|
};
|
|
3595
3597
|
const dir = specArtifactsDir(args.reportDir, args.feature, args.spec);
|
|
3596
3598
|
const relPrefix = posix.join(ARTIFACTS_SUBDIR, `${args.feature}__${args.spec}`);
|
|
3597
|
-
const relFiles = await walkFiles$
|
|
3599
|
+
const relFiles = await walkFiles$2(dir, "");
|
|
3598
3600
|
relFiles.sort((a, b) => a === "output.log" ? -1 : b === "output.log" ? 1 : a.localeCompare(b));
|
|
3599
3601
|
const kept = [];
|
|
3600
3602
|
const dropped = [];
|
|
@@ -3621,7 +3623,7 @@ async function collectSpecArtifacts(args) {
|
|
|
3621
3623
|
return kept;
|
|
3622
3624
|
}
|
|
3623
3625
|
/** All files under `dir` as posix paths relative to it; missing dir → []. */
|
|
3624
|
-
async function walkFiles$
|
|
3626
|
+
async function walkFiles$2(dir, relBase) {
|
|
3625
3627
|
let entries;
|
|
3626
3628
|
try {
|
|
3627
3629
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -3631,7 +3633,7 @@ async function walkFiles$1(dir, relBase) {
|
|
|
3631
3633
|
const out = [];
|
|
3632
3634
|
for (const entry of entries) {
|
|
3633
3635
|
const rel = relBase === "" ? entry.name : `${relBase}/${entry.name}`;
|
|
3634
|
-
if (entry.isDirectory()) out.push(...await walkFiles$
|
|
3636
|
+
if (entry.isDirectory()) out.push(...await walkFiles$2(join(dir, entry.name), rel));
|
|
3635
3637
|
else if (entry.isFile()) out.push(rel);
|
|
3636
3638
|
}
|
|
3637
3639
|
return out;
|
|
@@ -3876,6 +3878,22 @@ function parseGitDiffOutput(stdout) {
|
|
|
3876
3878
|
}
|
|
3877
3879
|
return out;
|
|
3878
3880
|
}
|
|
3881
|
+
/**
|
|
3882
|
+
* Returns true if `path` matches the glob `pattern`.
|
|
3883
|
+
*
|
|
3884
|
+
* Supports a deliberately small glob language sufficient for relatedPaths:
|
|
3885
|
+
* - `**` matches any number of path segments (including zero)
|
|
3886
|
+
* - `*` matches any run of characters that does NOT include `/`
|
|
3887
|
+
* - `?` matches exactly one character that is not `/`
|
|
3888
|
+
* - leading `./` is stripped from both sides
|
|
3889
|
+
*
|
|
3890
|
+
* Everything else is treated literally. This is intentional — relatedPaths
|
|
3891
|
+
* comes from Claude and we want predictable matching behavior, not full
|
|
3892
|
+
* minimatch semantics.
|
|
3893
|
+
*/
|
|
3894
|
+
function matchesGlob(path, pattern) {
|
|
3895
|
+
return compileGlob(pattern).test(stripLeadingDotSlash(path));
|
|
3896
|
+
}
|
|
3879
3897
|
/** Normalize a leading `./` away so diff paths and relatedPaths globs compare. */
|
|
3880
3898
|
function stripLeadingDotSlash(s) {
|
|
3881
3899
|
return s.startsWith("./") ? s.slice(2) : s;
|
|
@@ -3948,13 +3966,13 @@ function globBase(pattern) {
|
|
|
3948
3966
|
return staticSegments.join("/") || ".";
|
|
3949
3967
|
}
|
|
3950
3968
|
/** Recursively collect files under `dirAbs`, skipping node_modules/.git. */
|
|
3951
|
-
async function walkFiles(dirAbs) {
|
|
3969
|
+
async function walkFiles$1(dirAbs) {
|
|
3952
3970
|
const entries = await readdir(dirAbs, { withFileTypes: true });
|
|
3953
3971
|
const out = [];
|
|
3954
3972
|
for (const entry of entries) {
|
|
3955
3973
|
const abs = join(dirAbs, entry.name);
|
|
3956
3974
|
if (entry.isDirectory()) {
|
|
3957
|
-
if (!WALK_SKIP_DIRS.has(entry.name)) out.push(...await walkFiles(abs));
|
|
3975
|
+
if (!WALK_SKIP_DIRS.has(entry.name)) out.push(...await walkFiles$1(abs));
|
|
3958
3976
|
} else if (entry.isFile()) out.push(abs);
|
|
3959
3977
|
}
|
|
3960
3978
|
return out;
|
|
@@ -3969,13 +3987,13 @@ async function expandPatternToFiles(cwd, pattern) {
|
|
|
3969
3987
|
const abs = resolve(cwd, pattern);
|
|
3970
3988
|
const st = await stat(abs).catch(() => null);
|
|
3971
3989
|
if (!st) throw new Error(`"${pattern}" does not exist (resolved to ${abs})`);
|
|
3972
|
-
return (st.isDirectory() ? await walkFiles(abs) : [abs]).map((f) => relative(cwd, f)).sort();
|
|
3990
|
+
return (st.isDirectory() ? await walkFiles$1(abs) : [abs]).map((f) => relative(cwd, f)).sort();
|
|
3973
3991
|
}
|
|
3974
3992
|
const base = globBase(pattern);
|
|
3975
3993
|
const baseAbs = resolve(cwd, base);
|
|
3976
3994
|
if (!(await stat(baseAbs).catch(() => null))?.isDirectory()) throw new Error(`"${pattern}" matches nothing — its base directory ${base} does not exist`);
|
|
3977
3995
|
const matcher = compileGlob(pattern);
|
|
3978
|
-
const matched = (await walkFiles(baseAbs)).map((f) => relative(cwd, f)).filter((rel) => matcher.test(rel)).sort();
|
|
3996
|
+
const matched = (await walkFiles$1(baseAbs)).map((f) => relative(cwd, f)).filter((rel) => matcher.test(rel)).sort();
|
|
3979
3997
|
if (matched.length === 0) throw new Error(`"${pattern}" matches no files under ${base}`);
|
|
3980
3998
|
return matched;
|
|
3981
3999
|
}
|
|
@@ -6207,10 +6225,21 @@ var RunUsageError = class extends Error {
|
|
|
6207
6225
|
this.name = "RunUsageError";
|
|
6208
6226
|
}
|
|
6209
6227
|
};
|
|
6228
|
+
/** An error's message, whatever was thrown. Shared so the run modules report failures alike. */
|
|
6229
|
+
function errMessage(err) {
|
|
6230
|
+
return err instanceof Error ? err.message : String(err);
|
|
6231
|
+
}
|
|
6210
6232
|
//#endregion
|
|
6211
6233
|
//#region src/run/git-context.ts
|
|
6212
6234
|
/** The `--failure-analysis` value that selects per-spec hub-ledger baselines. */
|
|
6213
6235
|
const LAST_GREEN = "last-green";
|
|
6236
|
+
/**
|
|
6237
|
+
* The `--changed` value that selects specs from the hub's re-run verdicts
|
|
6238
|
+
* (`rerun-selection.ts` acts on it). Kept here beside `LAST_GREEN` so the two
|
|
6239
|
+
* "not a git ref" keywords, and the rules that reject each on the flag it does
|
|
6240
|
+
* not belong to, sit in one place.
|
|
6241
|
+
*/
|
|
6242
|
+
const LAST_RUN = "last-run";
|
|
6214
6243
|
/** Resolve `ref` to a full commit sha, or null when it does not exist locally. */
|
|
6215
6244
|
async function resolveCommitSha(ref, cwd) {
|
|
6216
6245
|
try {
|
|
@@ -6243,6 +6272,7 @@ async function resolveAnalysisBase(flagValue, flagName, cwd) {
|
|
|
6243
6272
|
let ref;
|
|
6244
6273
|
let source;
|
|
6245
6274
|
if (flagValue === "last-green") throw new RunUsageError(`${flagName}=${LAST_GREEN} is not supported — last-green baselines are per-spec and only apply to --failure-analysis`);
|
|
6275
|
+
if (flagValue === "last-run") throw new RunUsageError(`${flagName}=${LAST_RUN} is not supported — last-run selects which specs to run and only applies to --changed`);
|
|
6246
6276
|
if (typeof flagValue === "string") {
|
|
6247
6277
|
ref = flagValue;
|
|
6248
6278
|
source = "explicit";
|
|
@@ -6370,6 +6400,172 @@ function createLastGreenResolver(entries, cwd) {
|
|
|
6370
6400
|
};
|
|
6371
6401
|
}
|
|
6372
6402
|
//#endregion
|
|
6403
|
+
//#region src/run/deploy-head.ts
|
|
6404
|
+
/**
|
|
6405
|
+
* The commit a profile's environment is currently running, per the hub's
|
|
6406
|
+
* deploy log — its newest entry, or null when nothing has been recorded.
|
|
6407
|
+
*/
|
|
6408
|
+
async function deployHeadSha(hub, project, profile) {
|
|
6409
|
+
const { entries } = await hub.getDeployLog(project, {
|
|
6410
|
+
profile,
|
|
6411
|
+
limit: 1
|
|
6412
|
+
});
|
|
6413
|
+
return entries[entries.length - 1]?.sha ?? null;
|
|
6414
|
+
}
|
|
6415
|
+
/**
|
|
6416
|
+
* The commit the profile's environment was running when this run started.
|
|
6417
|
+
*
|
|
6418
|
+
* Captured before any spec executes and asserted on both push paths
|
|
6419
|
+
* (`?deployedSha=` on `POST /runs` via `ccqa hub push`, and on `POST
|
|
6420
|
+
* /runs/open` for `--push-report`). Left to itself the hub reads its own
|
|
6421
|
+
* deploy-log head when the call lands — after the whole run for a single-shot
|
|
6422
|
+
* push, after the deterministic phase for an incremental one — so a deploy
|
|
6423
|
+
* landing in that window would be recorded as the run's baseline and
|
|
6424
|
+
* under-report what needs re-running later. Asserting the earlier commit errs
|
|
6425
|
+
* the other way: a spec that straddled a deploy is simply selected again.
|
|
6426
|
+
*
|
|
6427
|
+
* Best-effort by design (hence `try`): no hub, no profile, no deploy log, or a
|
|
6428
|
+
* hub too old to serve one all leave the run unattributed, exactly as before.
|
|
6429
|
+
*/
|
|
6430
|
+
async function tryDeployHeadSha(hubCtx, profile) {
|
|
6431
|
+
try {
|
|
6432
|
+
return await deployHeadSha(hubCtx.hub, hubCtx.project, profile);
|
|
6433
|
+
} catch {
|
|
6434
|
+
return null;
|
|
6435
|
+
}
|
|
6436
|
+
}
|
|
6437
|
+
//#endregion
|
|
6438
|
+
//#region src/run/dry-run.ts
|
|
6439
|
+
/**
|
|
6440
|
+
* The lines `ccqa run --dry-run` prints: one per selected spec, tagged with
|
|
6441
|
+
* what would have executed it.
|
|
6442
|
+
*
|
|
6443
|
+
* This exists because a selection can be wrong in a way that costs money.
|
|
6444
|
+
* `relatedPaths` accuracy is not yet proven, and both `--changed <ref>` and
|
|
6445
|
+
* `--changed=last-run` decide from it, so a human has to be able to read the
|
|
6446
|
+
* selection back before a live spec spends a Claude budget on it.
|
|
6447
|
+
*
|
|
6448
|
+
* Rows that would not have executed (a generate-only target, an unresolvable
|
|
6449
|
+
* one) are listed too, with their reason: they are part of what the selection
|
|
6450
|
+
* produced, and leaving them out would make the list look shorter than the
|
|
6451
|
+
* run's report will be.
|
|
6452
|
+
*/
|
|
6453
|
+
function formatDryRunLines(agentBrowser, routed) {
|
|
6454
|
+
const tagged = [
|
|
6455
|
+
...agentBrowser.map((s) => ({
|
|
6456
|
+
key: specKey(s),
|
|
6457
|
+
tag: s.mode
|
|
6458
|
+
})),
|
|
6459
|
+
...routed.external.flatMap((g) => g.specs.map((s) => ({
|
|
6460
|
+
key: specKey(s),
|
|
6461
|
+
tag: g.targetId
|
|
6462
|
+
}))),
|
|
6463
|
+
...routed.skipped.map((s) => ({
|
|
6464
|
+
key: specKey(s),
|
|
6465
|
+
tag: `skipped — ${s.reason}`
|
|
6466
|
+
})),
|
|
6467
|
+
...routed.unresolved.map((s) => ({
|
|
6468
|
+
key: specKey(s),
|
|
6469
|
+
tag: `unresolved — ${s.reason}`
|
|
6470
|
+
}))
|
|
6471
|
+
];
|
|
6472
|
+
const width = Math.max(0, ...tagged.map((t) => t.key.length));
|
|
6473
|
+
return tagged.map((t) => ` ${t.key.padEnd(width)} ${t.tag}`);
|
|
6474
|
+
}
|
|
6475
|
+
//#endregion
|
|
6476
|
+
//#region src/run/rerun-selection.ts
|
|
6477
|
+
/**
|
|
6478
|
+
* `ccqa run --changed=last-run`: select specs from the hub's re-run verdicts
|
|
6479
|
+
* instead of from a git diff (ADR-0010). The baseline is not a ref at all —
|
|
6480
|
+
* it is each spec's own last run, positioned against the deploy log the
|
|
6481
|
+
* consuming deploy job feeds the hub — so this path does no git work.
|
|
6482
|
+
*
|
|
6483
|
+
* Every "I cannot answer" is an error here, never an empty selection: an
|
|
6484
|
+
* unanswerable question that silently runs nothing is the one failure mode
|
|
6485
|
+
* that makes the whole feature dangerous.
|
|
6486
|
+
*/
|
|
6487
|
+
/** First ccqa release whose hub serves `GET /projects/:project/rerun`. */
|
|
6488
|
+
const RERUN_MIN_HUB_VERSION = "1.9";
|
|
6489
|
+
/**
|
|
6490
|
+
* The profile `--changed=last-run` asks about. Mandatory: two environments sit
|
|
6491
|
+
* at different commits and the deploy log is per-profile, so "needs re-run"
|
|
6492
|
+
* has no profile-free answer.
|
|
6493
|
+
*/
|
|
6494
|
+
function requireRerunProfile(profile) {
|
|
6495
|
+
if (profile === void 0) throw new RunUsageError(`--changed=${LAST_RUN} requires --profile <name>: the deploy log it reads is per-profile, so which specs need a re-run has no answer without one`);
|
|
6496
|
+
return profile;
|
|
6497
|
+
}
|
|
6498
|
+
/**
|
|
6499
|
+
* Ask the hub which specs need a re-run, failing fast — before any spec runs —
|
|
6500
|
+
* on every condition that would otherwise degrade into "select nothing".
|
|
6501
|
+
*/
|
|
6502
|
+
async function fetchRerunReport(hubCtx, profile) {
|
|
6503
|
+
let report;
|
|
6504
|
+
try {
|
|
6505
|
+
report = await hubCtx.hub.getRerun(hubCtx.project, { profile });
|
|
6506
|
+
} catch (err) {
|
|
6507
|
+
if (err instanceof HubApiError && err.status === 404) throw new RunUsageError(explainNotFound(hubCtx, err));
|
|
6508
|
+
throw new RunUsageError(`--changed=${LAST_RUN}: could not ask the hub which specs need a re-run: ${errMessage(err)}`);
|
|
6509
|
+
}
|
|
6510
|
+
if (report.deployHead === null) throw new RunUsageError(`--changed=${LAST_RUN}: no deploy has been recorded for profile "${profile}" of project "${hubCtx.project}", so nothing can be compared against. Wire \`ccqa hub deploy record\` into the deploy job, or pass an explicit baseline (--changed=<ref>).`);
|
|
6511
|
+
return {
|
|
6512
|
+
...report,
|
|
6513
|
+
deployHead: report.deployHead
|
|
6514
|
+
};
|
|
6515
|
+
}
|
|
6516
|
+
/**
|
|
6517
|
+
* Which of the two 404s this was. The handler answers `no_perspectives` when
|
|
6518
|
+
* the route exists but the project has no document; any other code on a 404
|
|
6519
|
+
* means the hub does not serve this route at all.
|
|
6520
|
+
*/
|
|
6521
|
+
function explainNotFound(hubCtx, err) {
|
|
6522
|
+
if (err.code === "no_perspectives") return `--changed=${LAST_RUN}: project "${hubCtx.project}" has no perspectives document on the hub, so no spec has \`relatedPaths\` to match a deploy against. Run \`ccqa perspectives\` first.`;
|
|
6523
|
+
return `--changed=${LAST_RUN}: this hub does not serve re-run verdicts — it needs ccqa ${RERUN_MIN_HUB_VERSION} or newer. Upgrade the hub, or pass an explicit baseline (--changed=<ref>).`;
|
|
6524
|
+
}
|
|
6525
|
+
/** States the summary line reports, worst-known-first. */
|
|
6526
|
+
const SUMMARY_ORDER = [
|
|
6527
|
+
"needed",
|
|
6528
|
+
"unknown",
|
|
6529
|
+
"neverRun",
|
|
6530
|
+
"notNeeded",
|
|
6531
|
+
"notEvaluated"
|
|
6532
|
+
];
|
|
6533
|
+
/** States that mean "the hub has no verdict", as opposed to a verdict of "no". */
|
|
6534
|
+
const UNANSWERABLE = new Set([
|
|
6535
|
+
"unknown",
|
|
6536
|
+
"neverRun",
|
|
6537
|
+
"notEvaluated"
|
|
6538
|
+
]);
|
|
6539
|
+
/**
|
|
6540
|
+
* Narrow `specs` to the ones the hub says are worth running.
|
|
6541
|
+
*
|
|
6542
|
+
* `needed` is always selected. `unknown` and `neverRun` are "the question
|
|
6543
|
+
* cannot be answered", so they are excluded by default and opted into with
|
|
6544
|
+
* `--include-unknown` — fail-open on request, never silently. `notNeeded` and
|
|
6545
|
+
* `notEvaluated` are never selected.
|
|
6546
|
+
*/
|
|
6547
|
+
function selectSpecsNeedingRerun(specs, report, opts) {
|
|
6548
|
+
const selectable = new Set(opts.includeUnknown ? [
|
|
6549
|
+
"needed",
|
|
6550
|
+
"unknown",
|
|
6551
|
+
"neverRun"
|
|
6552
|
+
] : ["needed"]);
|
|
6553
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6554
|
+
const selected = [];
|
|
6555
|
+
let excludedUnanswerable = 0;
|
|
6556
|
+
for (const spec of specs) {
|
|
6557
|
+
const state = report.specs[specKey(spec)]?.state ?? "unknown";
|
|
6558
|
+
counts.set(state, (counts.get(state) ?? 0) + 1);
|
|
6559
|
+
if (selectable.has(state)) selected.push(spec);
|
|
6560
|
+
else if (UNANSWERABLE.has(state)) excludedUnanswerable++;
|
|
6561
|
+
}
|
|
6562
|
+
return {
|
|
6563
|
+
selected,
|
|
6564
|
+
summary: SUMMARY_ORDER.filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`).join(", "),
|
|
6565
|
+
excludedUnanswerable
|
|
6566
|
+
};
|
|
6567
|
+
}
|
|
6568
|
+
//#endregion
|
|
6373
6569
|
//#region src/report/github-format.ts
|
|
6374
6570
|
/**
|
|
6375
6571
|
* Build GitHub Actions `::error::` annotation lines for every failed spec in
|
|
@@ -7087,6 +7283,80 @@ function resolvePromptLocalPath(name, cwd) {
|
|
|
7087
7283
|
return join(cwd ?? process.cwd(), PROMPT_LOCAL_PATHS[name]);
|
|
7088
7284
|
}
|
|
7089
7285
|
//#endregion
|
|
7286
|
+
//#region src/run/github-run.ts
|
|
7287
|
+
/**
|
|
7288
|
+
* The GitHub Actions run URL for the current job, built from the standard
|
|
7289
|
+
* Actions environment variables. Returns null unless all three are present,
|
|
7290
|
+
* so nothing is ever invented for a local run — the same "only when in CI"
|
|
7291
|
+
* contract the report envelope's `runId` (GITHUB_RUN_ID) already follows.
|
|
7292
|
+
*/
|
|
7293
|
+
function githubRunUrl(env = process.env) {
|
|
7294
|
+
const server = env["GITHUB_SERVER_URL"];
|
|
7295
|
+
const repo = env["GITHUB_REPOSITORY"];
|
|
7296
|
+
const runId = githubRunId(env);
|
|
7297
|
+
if (!server || !repo || !runId) return null;
|
|
7298
|
+
return `${server}/${repo}/actions/runs/${runId}`;
|
|
7299
|
+
}
|
|
7300
|
+
/** The current GitHub Actions run id (GITHUB_RUN_ID); null outside Actions. */
|
|
7301
|
+
function githubRunId(env = process.env) {
|
|
7302
|
+
return env["GITHUB_RUN_ID"] ?? null;
|
|
7303
|
+
}
|
|
7304
|
+
//#endregion
|
|
7305
|
+
//#region src/cli/deploy-paths.ts
|
|
7306
|
+
/**
|
|
7307
|
+
* What `ccqa hub deploy record` reports as a deploy's changed paths.
|
|
7308
|
+
*
|
|
7309
|
+
* The hub has no checkout, so this is the one thing only the deploy job can
|
|
7310
|
+
* answer (ADR-0010). Everything here exists to keep that answer honest in the
|
|
7311
|
+
* direction that matters: over-reporting makes a spec re-run once too often,
|
|
7312
|
+
* under-reporting makes it silently skip a real regression.
|
|
7313
|
+
*/
|
|
7314
|
+
/**
|
|
7315
|
+
* How many paths one deploy sends. A monorepo-wide refactor can list tens of
|
|
7316
|
+
* thousands, and the hub bounds the request body, so the list is cut here.
|
|
7317
|
+
*
|
|
7318
|
+
* The cap is deliberately far above the hub's own retention bound: whenever it
|
|
7319
|
+
* bites, the hub still receives more paths than it retains and marks the entry
|
|
7320
|
+
* `truncated`, which reads as "touched everything". A cap at or below the
|
|
7321
|
+
* hub's would instead present a cut-down list as a complete one — a confident
|
|
7322
|
+
* "no re-run needed" built on paths that were never sent.
|
|
7323
|
+
*/
|
|
7324
|
+
const MAX_SENT_CHANGED_PATHS = 5e3;
|
|
7325
|
+
/** Cut `paths` to `MAX_SENT_CHANGED_PATHS`; see the constant for why the bound is where it is. */
|
|
7326
|
+
function capDeployPaths(paths) {
|
|
7327
|
+
return paths.slice(0, MAX_SENT_CHANGED_PATHS);
|
|
7328
|
+
}
|
|
7329
|
+
/**
|
|
7330
|
+
* The files that differ between two commits, as a deploy must report them.
|
|
7331
|
+
*
|
|
7332
|
+
* **Two-dot** (`git diff A B`), never three-dot: three-dot resolves the merge
|
|
7333
|
+
* base first, so redeploying an ancestor — a rollback — reports an empty diff
|
|
7334
|
+
* and the rollback becomes invisible. `getChangedFiles` in
|
|
7335
|
+
* `src/drift/affected.ts` is three-dot, which is right for the PR question it
|
|
7336
|
+
* answers and wrong for this one.
|
|
7337
|
+
*
|
|
7338
|
+
* `--no-renames` is likewise deliberate: with rename detection a rename is one
|
|
7339
|
+
* entry naming only the destination, so a file moved *out* of a spec's
|
|
7340
|
+
* `relatedPaths` would no longer match it. Off, the rename appears as a delete
|
|
7341
|
+
* plus an add and both paths are reported.
|
|
7342
|
+
*
|
|
7343
|
+
* Paths are re-rooted to `cwd` on the same rule as `--changed`, because
|
|
7344
|
+
* `relatedPaths` are written as the directory hosting `.ccqa/` sees them.
|
|
7345
|
+
*/
|
|
7346
|
+
async function changedPathsBetween(previous, sha, cwd) {
|
|
7347
|
+
const [{ stdout: rootOut }, { stdout: diffOut }] = await Promise.all([execFileP("git", ["rev-parse", "--show-toplevel"], { cwd }), execFileP("git", [
|
|
7348
|
+
"diff",
|
|
7349
|
+
"--name-status",
|
|
7350
|
+
"--no-renames",
|
|
7351
|
+
previous,
|
|
7352
|
+
sha
|
|
7353
|
+
], {
|
|
7354
|
+
cwd,
|
|
7355
|
+
maxBuffer: 32 * 1024 * 1024
|
|
7356
|
+
})]);
|
|
7357
|
+
return rerootChangedFiles(parseGitDiffOutput(diffOut), rootOut.trim(), cwd).map((f) => f.path);
|
|
7358
|
+
}
|
|
7359
|
+
//#endregion
|
|
7090
7360
|
//#region src/cli/hub.ts
|
|
7091
7361
|
/**
|
|
7092
7362
|
* `ccqa hub` — the client side of the ccqa hub (a results/secret control
|
|
@@ -7264,6 +7534,45 @@ const promptRm = new Command("rm").description("Delete a prompt from the hub.").
|
|
|
7264
7534
|
info(`deleted prompt "${name}" from the hub`);
|
|
7265
7535
|
}));
|
|
7266
7536
|
const promptCommand = new Command("prompt").description("Manage prompt assets (per-flow user/agent guidance, triage user guidance, analysis custom prompt) stored on the hub (fetched automatically by `ccqa run` at run time).").addCommand(promptPush).addCommand(promptLs).addCommand(promptRm);
|
|
7537
|
+
const deployRecord = new Command("record").description("Tell the hub what a deploy shipped, so it can answer which specs need a re-run (`ccqa run --changed=last-run`). Run this from the deploy job, after the deploy succeeds. The changed paths are computed locally with a two-dot `git diff <previous> <sha>`; a job that has only curl and git can POST the same body directly (see docs/hub.md).").requiredOption("--profile <name>", "Environment this deploy landed in (e.g. 'stg'). Required: dev and stg sit at different commits, so the deploy log is per-profile.").requiredOption("--sha <sha>", "Commit that was deployed.").option("--previous <sha>", "Commit this deploy replaced. Defaults to the profile's current deploy-log head on the hub. With neither, the deploy is recorded as touching everything.").option("--ref <ref>", "Ref that was deployed (branch or tag). Recorded for display only.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Project whose deploy log this entry joins. Defaults to the current directory's name.").option("--cwd <path>", "Directory the git diff and the default --project name are resolved against.").action(withHubErrors(async (opts) => {
|
|
7538
|
+
const cwd = resolveCwd(opts.cwd);
|
|
7539
|
+
const project = resolveProject(opts);
|
|
7540
|
+
const hub = connect(opts);
|
|
7541
|
+
const previous = opts.previous ?? await deployHeadSha(hub, project, opts.profile);
|
|
7542
|
+
const runUrl = githubRunUrl();
|
|
7543
|
+
const changedPaths = previous === null ? null : await diffOrTouchEverything(previous, opts.sha, cwd);
|
|
7544
|
+
const entry = await hub.recordDeploy(project, opts.profile, {
|
|
7545
|
+
sha: opts.sha,
|
|
7546
|
+
previousSha: previous,
|
|
7547
|
+
changedPaths,
|
|
7548
|
+
...opts.ref ? { ref: opts.ref } : {},
|
|
7549
|
+
...runUrl ? { runUrl } : {}
|
|
7550
|
+
});
|
|
7551
|
+
header("hub deploy record", entry.sha.slice(0, 12));
|
|
7552
|
+
meta("project", project);
|
|
7553
|
+
meta("profile", opts.profile);
|
|
7554
|
+
meta("previous", previous ? previous.slice(0, 12) : "(none — treated as touching everything)");
|
|
7555
|
+
meta("changed paths", changedPaths === null ? "(not reported)" : String(changedPaths.length));
|
|
7556
|
+
if (entry.truncated) meta("truncated", "yes — the hub treats this deploy as touching everything");
|
|
7557
|
+
if (entry.gapBefore) warn("this deploy does not chain onto the log head, so a gap is recorded — specs whose baseline sits behind it report 'unknown' rather than 'not needed'");
|
|
7558
|
+
info(`recorded deploy #${entry.index}`);
|
|
7559
|
+
}));
|
|
7560
|
+
/**
|
|
7561
|
+
* The two-dot diff, or `null` when git can't produce one (a shallow checkout
|
|
7562
|
+
* that never fetched `previous`, a rolled-back sha that isn't local). `null`
|
|
7563
|
+
* makes the hub treat the deploy as touching everything: fail-open and
|
|
7564
|
+
* self-limiting — everything re-runs once, then it settles — whereas silently
|
|
7565
|
+
* sending an empty list would claim the deploy changed nothing.
|
|
7566
|
+
*/
|
|
7567
|
+
async function diffOrTouchEverything(previous, sha, cwd) {
|
|
7568
|
+
try {
|
|
7569
|
+
return capDeployPaths(await changedPathsBetween(previous, sha, cwd));
|
|
7570
|
+
} catch (err) {
|
|
7571
|
+
warn(`could not diff ${previous.slice(0, 12)}..${sha.slice(0, 12)} (${errMessage(err)}); recording the deploy as touching everything`);
|
|
7572
|
+
return null;
|
|
7573
|
+
}
|
|
7574
|
+
}
|
|
7575
|
+
const deployCommand = new Command("deploy").description("Report deploys to the hub, the input behind `ccqa run --changed=last-run`.").addCommand(deployRecord);
|
|
7267
7576
|
const pushCommand = new Command("push").description("Upload the report directory of a finished `ccqa run --report` to the hub as a run. Run this after `ccqa run` (use `if: always()` in CI so failing runs are pushed too).").option("--report <dir>", `Report directory to push. Default: ${DEFAULT_REPORT_DIR}/`).option("--project <name>", "Logical project name for the run. Defaults to the current directory's name.").option("--branch <name>", "Branch label. Defaults to $GITHUB_HEAD_REF / $GITHUB_REF_NAME / current git branch.").option("--profile <name>", "Profile (environment) the run executed against. Recorded for display; runs are not scoped by profile.").option(...hubUrlOption).option(...hubTokenOption).option("--cwd <path>", "Directory the report dir is resolved against (defaults to the current directory).").action(withHubErrors(async (opts) => {
|
|
7268
7577
|
const cwd = resolveCwd(opts.cwd);
|
|
7269
7578
|
const reportDir = join(cwd, opts.report ?? "ccqa-report");
|
|
@@ -7276,16 +7585,19 @@ const pushCommand = new Command("push").description("Upload the report directory
|
|
|
7276
7585
|
hint("run `ccqa run --report` first, then push its report directory");
|
|
7277
7586
|
process.exit(2);
|
|
7278
7587
|
}
|
|
7279
|
-
|
|
7588
|
+
const parsed = RunReportDataSchema.safeParse(report);
|
|
7589
|
+
if (!parsed.success) {
|
|
7280
7590
|
error(`report.json in ${reportDir} is not a valid ccqa report`);
|
|
7281
7591
|
process.exit(2);
|
|
7282
7592
|
}
|
|
7593
|
+
const deployedSha = parsed.data.deployedSha;
|
|
7283
7594
|
const branch = opts.branch ?? await detectBranch(cwd);
|
|
7284
7595
|
const archive = await packDirToTarGz(reportDir);
|
|
7285
7596
|
const run = await connect(opts).pushRun(archive, {
|
|
7286
7597
|
project,
|
|
7287
7598
|
...branch ? { branch } : {},
|
|
7288
|
-
...opts.profile ? { profile: opts.profile } : {}
|
|
7599
|
+
...opts.profile ? { profile: opts.profile } : {},
|
|
7600
|
+
...deployedSha ? { deployedSha } : {}
|
|
7289
7601
|
});
|
|
7290
7602
|
header("hub push", run.id);
|
|
7291
7603
|
meta("project", run.project);
|
|
@@ -7295,7 +7607,7 @@ const pushCommand = new Command("push").description("Upload the report directory
|
|
|
7295
7607
|
meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
|
|
7296
7608
|
info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
|
|
7297
7609
|
}));
|
|
7298
|
-
const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(sessionCommand$1).addCommand(varCommand).addCommand(promptCommand);
|
|
7610
|
+
const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(sessionCommand$1).addCommand(varCommand).addCommand(promptCommand);
|
|
7299
7611
|
/** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
|
|
7300
7612
|
function isStorageStateShape(state) {
|
|
7301
7613
|
return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
|
|
@@ -10219,25 +10531,6 @@ function createIncrementalReport(reportDir, envelope, sink) {
|
|
|
10219
10531
|
};
|
|
10220
10532
|
}
|
|
10221
10533
|
//#endregion
|
|
10222
|
-
//#region src/run/github-run.ts
|
|
10223
|
-
/**
|
|
10224
|
-
* The GitHub Actions run URL for the current job, built from the standard
|
|
10225
|
-
* Actions environment variables. Returns null unless all three are present,
|
|
10226
|
-
* so nothing is ever invented for a local run — the same "only when in CI"
|
|
10227
|
-
* contract the report envelope's `runId` (GITHUB_RUN_ID) already follows.
|
|
10228
|
-
*/
|
|
10229
|
-
function githubRunUrl(env = process.env) {
|
|
10230
|
-
const server = env["GITHUB_SERVER_URL"];
|
|
10231
|
-
const repo = env["GITHUB_REPOSITORY"];
|
|
10232
|
-
const runId = githubRunId(env);
|
|
10233
|
-
if (!server || !repo || !runId) return null;
|
|
10234
|
-
return `${server}/${repo}/actions/runs/${runId}`;
|
|
10235
|
-
}
|
|
10236
|
-
/** The current GitHub Actions run id (GITHUB_RUN_ID); null outside Actions. */
|
|
10237
|
-
function githubRunId(env = process.env) {
|
|
10238
|
-
return env["GITHUB_RUN_ID"] ?? null;
|
|
10239
|
-
}
|
|
10240
|
-
//#endregion
|
|
10241
10534
|
//#region src/prompts/agent-update.ts
|
|
10242
10535
|
/**
|
|
10243
10536
|
* Build the prompts used by `--update-agent-prompt` to refresh
|
|
@@ -10633,9 +10926,12 @@ function dedupeSpecs(specs) {
|
|
|
10633
10926
|
*/
|
|
10634
10927
|
async function executeRun(targets, opts) {
|
|
10635
10928
|
if (opts.changed && targets.length > 0) throw new RunUsageError("--changed and an explicit spec target cannot be combined");
|
|
10929
|
+
const rerunProfile = opts.changed === "last-run" ? requireRerunProfile(opts.profile) : null;
|
|
10930
|
+
if (opts.includeUnknown && rerunProfile === null) warn(`--include-unknown is ignored: it only applies to --changed=${LAST_RUN}`);
|
|
10931
|
+
const forExecution = opts.dryRun !== true;
|
|
10636
10932
|
const cwd = opts.cwd ?? process.cwd();
|
|
10637
10933
|
const wantsLastGreen = opts.failureAnalysis === LAST_GREEN;
|
|
10638
|
-
const [head, fixedBase] = await Promise.all([getGitHead(cwd), opts.failureAnalysis && !wantsLastGreen ? resolveAnalysisBase(opts.failureAnalysis, "--failure-analysis", cwd) : null]);
|
|
10934
|
+
const [head, fixedBase] = await Promise.all([getGitHead(cwd), forExecution && opts.failureAnalysis && !wantsLastGreen ? resolveAnalysisBase(opts.failureAnalysis, "--failure-analysis", cwd) : null]);
|
|
10639
10935
|
const git = {
|
|
10640
10936
|
head,
|
|
10641
10937
|
base: wantsLastGreen ? {
|
|
@@ -10655,19 +10951,16 @@ async function executeRun(targets, opts) {
|
|
|
10655
10951
|
});
|
|
10656
10952
|
meta("analysis-base", `${fixedBase.ref} (${fixedBase.sha.slice(0, 12)}, ${fixedBase.source})`);
|
|
10657
10953
|
}
|
|
10658
|
-
|
|
10659
|
-
|
|
10660
|
-
|
|
10661
|
-
|
|
10662
|
-
|
|
10663
|
-
|
|
10664
|
-
|
|
10665
|
-
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
hubHeader: opts.hubHeader
|
|
10669
|
-
});
|
|
10670
|
-
} else await resolveProfileEnv({
|
|
10954
|
+
if (forExecution) try {
|
|
10955
|
+
if (opts.profile !== void 0) await resolveProfileEnv({
|
|
10956
|
+
profile: opts.profile,
|
|
10957
|
+
project: resolveProjectOrThrow(opts.project, cwd),
|
|
10958
|
+
cwd,
|
|
10959
|
+
hubUrl: opts.hubUrl,
|
|
10960
|
+
hubToken: opts.hubToken,
|
|
10961
|
+
hubHeader: opts.hubHeader
|
|
10962
|
+
});
|
|
10963
|
+
else await resolveProfileEnv({
|
|
10671
10964
|
profile: void 0,
|
|
10672
10965
|
project: "",
|
|
10673
10966
|
cwd
|
|
@@ -10676,7 +10969,7 @@ async function executeRun(targets, opts) {
|
|
|
10676
10969
|
if (err instanceof RunUsageError) throw err;
|
|
10677
10970
|
if (err instanceof ProjectNameError) throw new RunUsageError(err.message);
|
|
10678
10971
|
if (err instanceof HubConnectionError || err instanceof HubApiError) throw new RunUsageError(err.message);
|
|
10679
|
-
throw new RunUsageError(`failed to load profile "${opts.profile}": ${
|
|
10972
|
+
throw new RunUsageError(`failed to load profile "${opts.profile}": ${errMessage(err)}`);
|
|
10680
10973
|
}
|
|
10681
10974
|
let hubCtx = null;
|
|
10682
10975
|
try {
|
|
@@ -10692,11 +10985,15 @@ async function executeRun(targets, opts) {
|
|
|
10692
10985
|
}
|
|
10693
10986
|
if (wantsLastGreen && hubCtx == null) throw new RunUsageError(`--failure-analysis=${LAST_GREEN} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`);
|
|
10694
10987
|
const ledgerHub = wantsLastGreen ? hubCtx : null;
|
|
10695
|
-
|
|
10696
|
-
|
|
10697
|
-
|
|
10698
|
-
|
|
10988
|
+
if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(`--changed=${LAST_RUN} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`);
|
|
10989
|
+
const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead] = await Promise.all([
|
|
10990
|
+
forExecution ? fetchCustomPrompt(hubCtx) : null,
|
|
10991
|
+
forExecution ? fetchTriageUserPrompt(hubCtx) : null,
|
|
10992
|
+
forExecution && ledgerHub ? fetchLastGreenLedger(ledgerHub, opts.profile, cwd) : null,
|
|
10993
|
+
rerunProfile !== null && hubCtx ? fetchRerunReport(hubCtx, rerunProfile) : null,
|
|
10994
|
+
forExecution && hubCtx && opts.profile && rerunProfile === null ? tryDeployHeadSha(hubCtx, opts.profile) : null
|
|
10699
10995
|
]);
|
|
10996
|
+
const deployedSha = rerunReport?.deployHead.sha ?? fetchedDeployHead;
|
|
10700
10997
|
if (ledgerEntries) diffProvider = createDiffProvider({
|
|
10701
10998
|
resolveBase: createLastGreenResolver(ledgerEntries, cwd),
|
|
10702
10999
|
cwd
|
|
@@ -10720,11 +11017,19 @@ async function executeRun(targets, opts) {
|
|
|
10720
11017
|
let specs = dedupeSpecs((await Promise.all((targets.length ? targets : [void 0]).map((t) => resolveSpecTargets(t, enumerateAll, cwd)))).flat());
|
|
10721
11018
|
if (opts.changed) {
|
|
10722
11019
|
const before = specs.length;
|
|
10723
|
-
|
|
11020
|
+
let unanswerable = 0;
|
|
11021
|
+
if (rerunReport) {
|
|
11022
|
+
const selection = selectSpecsNeedingRerun(specs, rerunReport, { includeUnknown: opts.includeUnknown === true });
|
|
11023
|
+
specs = selection.selected;
|
|
11024
|
+
unanswerable = selection.excludedUnanswerable;
|
|
11025
|
+
meta("rerun-base", `deploy ${rerunReport.deployHead.sha.slice(0, 12)} (profile ${rerunReport.profile})`);
|
|
11026
|
+
meta("rerun-states", selection.summary);
|
|
11027
|
+
} else specs = await collectChangedSpecs(specs, {
|
|
10724
11028
|
cwd,
|
|
10725
11029
|
base: opts.changed
|
|
10726
11030
|
});
|
|
10727
11031
|
meta("changed-scoped", `${specs.length} of ${before} spec${before === 1 ? "" : "s"}`);
|
|
11032
|
+
if (specs.length === 0 && unanswerable > 0) hint(`${unanswerable} spec(s) were excluded because the hub could not tell whether they need a re-run; pass --include-unknown to run them anyway`);
|
|
10728
11033
|
}
|
|
10729
11034
|
if (specs.length === 0) {
|
|
10730
11035
|
warn("no specs to run");
|
|
@@ -10753,6 +11058,16 @@ async function executeRun(targets, opts) {
|
|
|
10753
11058
|
} else if (opts.out && liveSpecs.length > 1) warn("--out is ignored when running multiple live specs");
|
|
10754
11059
|
if (detSpecs.length === 0 && opts.evidence === false) warn("--no-evidence is ignored: it only applies to agent-browser 'mode: deterministic' specs, and this run has none");
|
|
10755
11060
|
blank();
|
|
11061
|
+
if (opts.dryRun) {
|
|
11062
|
+
for (const line of formatDryRunLines(withMode, dispatch)) emitRaw(line + "\n");
|
|
11063
|
+
blank();
|
|
11064
|
+
info("dry run: nothing was executed and no report was written");
|
|
11065
|
+
return {
|
|
11066
|
+
exitCode: 0,
|
|
11067
|
+
report: null,
|
|
11068
|
+
reportDir: null
|
|
11069
|
+
};
|
|
11070
|
+
}
|
|
10756
11071
|
const det = await runDeterministicSpecs(detSpecs, opts, cwd, reportDir);
|
|
10757
11072
|
if (opts.pushReport && hubCtx == null) warn("--push-report requires --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN); skipping push");
|
|
10758
11073
|
let hubRunId = null;
|
|
@@ -10766,6 +11081,7 @@ async function executeRun(targets, opts) {
|
|
|
10766
11081
|
...branch ? { branch } : {},
|
|
10767
11082
|
...opts.profile ? { profile: opts.profile } : {},
|
|
10768
11083
|
...git.head ? { gitHead: git.head } : {},
|
|
11084
|
+
...deployedSha ? { deployedSha } : {},
|
|
10769
11085
|
...ciRunId ? { ciRunId } : {},
|
|
10770
11086
|
...runUrl ? { runUrl } : {},
|
|
10771
11087
|
kind: "run"
|
|
@@ -10791,6 +11107,7 @@ async function executeRun(targets, opts) {
|
|
|
10791
11107
|
git,
|
|
10792
11108
|
customPromptVersion: customPrompt?.customPromptVersion ?? null,
|
|
10793
11109
|
triageUserPromptHash,
|
|
11110
|
+
deployedSha,
|
|
10794
11111
|
opts
|
|
10795
11112
|
}), hubSink);
|
|
10796
11113
|
let completedNormally = false;
|
|
@@ -10856,6 +11173,7 @@ async function executeRun(targets, opts) {
|
|
|
10856
11173
|
git,
|
|
10857
11174
|
customPromptVersion,
|
|
10858
11175
|
triageUserPromptHash,
|
|
11176
|
+
deployedSha,
|
|
10859
11177
|
opts
|
|
10860
11178
|
});
|
|
10861
11179
|
completedNormally = true;
|
|
@@ -10865,6 +11183,7 @@ async function executeRun(targets, opts) {
|
|
|
10865
11183
|
git,
|
|
10866
11184
|
customPromptVersion,
|
|
10867
11185
|
triageUserPromptHash,
|
|
11186
|
+
deployedSha,
|
|
10868
11187
|
opts
|
|
10869
11188
|
});
|
|
10870
11189
|
const streamedKeys = new Set(incrementalReport.rows().map((r) => `${r.feature}/${r.spec}`));
|
|
@@ -11140,7 +11459,7 @@ async function analyzeDeterministicSummaries(summaries, cwd, reportDir, { pass,
|
|
|
11140
11459
|
* final report.json stays byte-identical (existing e2e goldens compare it).
|
|
11141
11460
|
*/
|
|
11142
11461
|
function buildReportEnvelope(args) {
|
|
11143
|
-
const { git, customPromptVersion, triageUserPromptHash, opts } = args;
|
|
11462
|
+
const { git, customPromptVersion, triageUserPromptHash, deployedSha, opts } = args;
|
|
11144
11463
|
const runUrl = githubRunUrl();
|
|
11145
11464
|
return {
|
|
11146
11465
|
schemaVersion: 1,
|
|
@@ -11160,17 +11479,19 @@ function buildReportEnvelope(args) {
|
|
|
11160
11479
|
language: opts.language ?? null,
|
|
11161
11480
|
promptVersion: "8",
|
|
11162
11481
|
customPromptVersion,
|
|
11163
|
-
...triageUserPromptHash !== null ? { triageUserPromptHash } : {}
|
|
11482
|
+
...triageUserPromptHash !== null ? { triageUserPromptHash } : {},
|
|
11483
|
+
...deployedSha !== null ? { deployedSha } : {}
|
|
11164
11484
|
};
|
|
11165
11485
|
}
|
|
11166
11486
|
/** Write the unified JSON (+ optional GitHub-annotation) report for one run. Returns the report data. */
|
|
11167
11487
|
async function writeUnifiedReport(args) {
|
|
11168
|
-
const { reportDir, results, git, customPromptVersion, triageUserPromptHash, opts } = args;
|
|
11488
|
+
const { reportDir, results, git, customPromptVersion, triageUserPromptHash, deployedSha, opts } = args;
|
|
11169
11489
|
const data = {
|
|
11170
11490
|
...buildReportEnvelope({
|
|
11171
11491
|
git,
|
|
11172
11492
|
customPromptVersion,
|
|
11173
11493
|
triageUserPromptHash,
|
|
11494
|
+
deployedSha,
|
|
11174
11495
|
opts
|
|
11175
11496
|
}),
|
|
11176
11497
|
results
|
|
@@ -11182,9 +11503,6 @@ async function writeUnifiedReport(args) {
|
|
|
11182
11503
|
if (opts.format === "github") for (const line of emitGithubAnnotations(data)) emitRaw(line + "\n");
|
|
11183
11504
|
return data;
|
|
11184
11505
|
}
|
|
11185
|
-
function errMessage(err) {
|
|
11186
|
-
return err instanceof Error ? err.message : String(err);
|
|
11187
|
-
}
|
|
11188
11506
|
/**
|
|
11189
11507
|
* Raw-byte budget for the files inlined in one incremental `PATCH`. Base64
|
|
11190
11508
|
* inflates by ~4/3 and the rows ride in the same body, so this keeps the
|
|
@@ -11425,7 +11743,7 @@ function installTeardownSignalHandlers(teardown) {
|
|
|
11425
11743
|
}
|
|
11426
11744
|
//#endregion
|
|
11427
11745
|
//#region src/cli/run.ts
|
|
11428
|
-
const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --push-report to also stream it to a hub.").option("--report [dir]", `Directory for the structured run results (report.json + evidence PNGs) that are always written. Default: ${DEFAULT_REPORT_DIR}/. Pass this only to change the location.`).option("--push-report", "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("--changed [base]", "Restrict execution to specs whose relatedPaths intersect the git diff against [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --changed=origin/main). Cannot be combined with an explicit spec id.").option("--failure-analysis [base]", "Classify each failure (TEST_DRIFT / SPEC_CHANGE / PRODUCT_BUG) against the source diff since [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --failure-analysis=origin/main), or pass 'last-green' to diff each spec against the commit where it last passed (per-spec baselines from the hub; requires a hub connection). Off by default — no Claude calls without it.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--format <fmt>", "Additional output format alongside HTML when --report is set: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
|
|
11746
|
+
const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --push-report to also stream it to a hub.").option("--report [dir]", `Directory for the structured run results (report.json + evidence PNGs) that are always written. Default: ${DEFAULT_REPORT_DIR}/. Pass this only to change the location.`).option("--push-report", "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("--changed [base]", "Restrict execution to specs whose relatedPaths intersect the git diff against [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --changed=origin/main), or pass 'last-run' to run the specs the hub says need one — each spec's own last run compared against the deploy log (requires a hub connection and --profile; no git diff involved). Cannot be combined with an explicit spec id.").option("--include-unknown", "(--changed=last-run only) Also run specs whose re-run need the hub cannot answer ('unknown') and specs that have never run ('neverRun'). Off by default: an unanswerable question is reported, not guessed.").option("--dry-run", "Print the specs this invocation would run, then exit 0 without executing anything and without writing a report. Works with every selection mode.").option("--failure-analysis [base]", "Classify each failure (TEST_DRIFT / SPEC_CHANGE / PRODUCT_BUG) against the source diff since [base]. Without a value the base comes from $GITHUB_BASE_REF (pull_request CI); elsewhere pass it explicitly (e.g. --failure-analysis=origin/main), or pass 'last-green' to diff each spec against the commit where it last passed (per-spec baselines from the hub; requires a hub connection). Off by default — no Claude calls without it.").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--format <fmt>", "Additional output format alongside HTML when --report is set: 'text' (default), 'json' (writes report.json), 'github' (GitHub Actions annotations on stdout).", (raw) => {
|
|
11429
11747
|
if (REPORT_FORMATS.includes(raw)) return raw;
|
|
11430
11748
|
throw new Error(`--format must be one of ${REPORT_FORMATS.join(" | ")}`);
|
|
11431
11749
|
}, "text").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--no-evidence", `(deterministic only) Skip step-boundary evidence capture (PNG + meta JSON written to ${DEFAULT_REPORT_DIR}/${EVIDENCE_SUBDIR}/ by default).`).option("--retry <n>", "(live only) Retry each failed step up to N more times before recording failure. Default 0.", (raw) => {
|
|
@@ -11448,6 +11766,7 @@ function parseConcurrency$1(raw) {
|
|
|
11448
11766
|
function headerTarget(targets, opts) {
|
|
11449
11767
|
if (targets.length === 1) return targets[0];
|
|
11450
11768
|
if (targets.length > 1) return `${targets.length} targets`;
|
|
11769
|
+
if (opts.changed === "last-run") return "(needs re-run)";
|
|
11451
11770
|
return opts.changed ? "(changed)" : "(all specs)";
|
|
11452
11771
|
}
|
|
11453
11772
|
/**
|
|
@@ -12945,6 +13264,97 @@ For each test case above, write a 1–2 sentence factual \`summary\` of what it
|
|
|
12945
13264
|
`;
|
|
12946
13265
|
}
|
|
12947
13266
|
//#endregion
|
|
13267
|
+
//#region src/cli/related-paths-check.ts
|
|
13268
|
+
/**
|
|
13269
|
+
* Data-quality check on `relatedPaths`: how many of a spec's patterns match no
|
|
13270
|
+
* file at all.
|
|
13271
|
+
*
|
|
13272
|
+
* A pattern that matches nothing is the failure mode that makes re-run
|
|
13273
|
+
* selection lie in the *dangerous* direction — too narrow a list produces a
|
|
13274
|
+
* confident "no re-run needed" for a spec whose code did change (ADR-0010).
|
|
13275
|
+
* It costs one file listing and no model call, so `ccqa perspectives` records
|
|
13276
|
+
* the count and the hub UI can warn next to the verdict.
|
|
13277
|
+
*
|
|
13278
|
+
* Patterns are interpreted relative to the directory hosting `.ccqa/`, the
|
|
13279
|
+
* same rule `--changed` applies. A pattern deliberately written repo-root
|
|
13280
|
+
* relative to reach a monorepo sibling package therefore counts as unmatched;
|
|
13281
|
+
* that is a false alarm, which is the harmless direction here.
|
|
13282
|
+
*/
|
|
13283
|
+
/** Directories the fallback walk never descends into: enormous, and never what `relatedPaths` target. */
|
|
13284
|
+
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
|
13285
|
+
/**
|
|
13286
|
+
* The files a `relatedPaths` pattern could possibly match, as cwd-relative
|
|
13287
|
+
* posix paths, listed once and shared by every spec.
|
|
13288
|
+
*
|
|
13289
|
+
* Tracked files, via `git ls-files`. That is not just the cheap way (a walk of
|
|
13290
|
+
* a large monorepo takes an order of magnitude longer and drags in `dist/`,
|
|
13291
|
+
* `.next/`, `coverage/`, past report directories) — it is the *correct*
|
|
13292
|
+
* universe: `relatedPaths` are only ever matched against `git diff` output,
|
|
13293
|
+
* both by `--changed` and by a deploy's `changedPaths`, and that output only
|
|
13294
|
+
* ever names tracked files. A pattern whose sole match is an untracked build
|
|
13295
|
+
* artifact can never match a real change, so counting it as matched would be a
|
|
13296
|
+
* miss in precisely the direction this check exists to catch.
|
|
13297
|
+
*
|
|
13298
|
+
* Falls back to a directory walk outside a git checkout, where the question
|
|
13299
|
+
* still has a useful approximate answer.
|
|
13300
|
+
*/
|
|
13301
|
+
async function listCheckoutFiles(cwd) {
|
|
13302
|
+
try {
|
|
13303
|
+
const { stdout } = await execFileP("git", ["ls-files", "-z"], {
|
|
13304
|
+
cwd,
|
|
13305
|
+
maxBuffer: 64 * 1024 * 1024
|
|
13306
|
+
});
|
|
13307
|
+
return stdout.split("\0").filter((p) => p.length > 0);
|
|
13308
|
+
} catch {
|
|
13309
|
+
return walkFiles(cwd);
|
|
13310
|
+
}
|
|
13311
|
+
}
|
|
13312
|
+
/**
|
|
13313
|
+
* Every file under `cwd` as a cwd-relative posix path — the non-git fallback
|
|
13314
|
+
* for `listCheckoutFiles`. Symlinks are listed but not followed, so a link loop
|
|
13315
|
+
* cannot hang the walk. An unreadable directory is skipped rather than fatal:
|
|
13316
|
+
* the count is a warning signal, not a gate. Exported for tests, which cannot
|
|
13317
|
+
* otherwise reach this branch deterministically.
|
|
13318
|
+
*/
|
|
13319
|
+
async function walkFiles(cwd) {
|
|
13320
|
+
const out = [];
|
|
13321
|
+
const stack = [cwd];
|
|
13322
|
+
while (stack.length > 0) {
|
|
13323
|
+
const dir = stack.pop();
|
|
13324
|
+
let entries;
|
|
13325
|
+
try {
|
|
13326
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
13327
|
+
} catch {
|
|
13328
|
+
continue;
|
|
13329
|
+
}
|
|
13330
|
+
for (const entry of entries) {
|
|
13331
|
+
const abs = join(dir, entry.name);
|
|
13332
|
+
if (entry.isDirectory()) {
|
|
13333
|
+
if (!SKIP_DIRS.has(entry.name)) stack.push(abs);
|
|
13334
|
+
} else out.push(relative(cwd, abs).split(sep).join("/"));
|
|
13335
|
+
}
|
|
13336
|
+
}
|
|
13337
|
+
return out;
|
|
13338
|
+
}
|
|
13339
|
+
/** How many of `patterns` match none of `files`. */
|
|
13340
|
+
function countUnmatchedPatterns(patterns, files) {
|
|
13341
|
+
return patterns.filter((pattern) => !files.some((file) => matchesGlob(file, pattern))).length;
|
|
13342
|
+
}
|
|
13343
|
+
/**
|
|
13344
|
+
* A perspective entry's `relatedPaths` fields — the list and its zero-match
|
|
13345
|
+
* count — produced together, so the two writers of the document (the full
|
|
13346
|
+
* `ccqa perspectives` build and the per-spec update after `ccqa record`) cannot
|
|
13347
|
+
* record one without the other. Empty for a spec that declares no paths: there
|
|
13348
|
+
* is nothing to check, which is not the same as "checked, all matched".
|
|
13349
|
+
*/
|
|
13350
|
+
function relatedPathsFields(paths, files) {
|
|
13351
|
+
if (paths.length === 0) return {};
|
|
13352
|
+
return {
|
|
13353
|
+
relatedPaths: [...paths],
|
|
13354
|
+
relatedPathsUnmatched: countUnmatchedPatterns(paths, files)
|
|
13355
|
+
};
|
|
13356
|
+
}
|
|
13357
|
+
//#endregion
|
|
12948
13358
|
//#region src/cli/perspectives.ts
|
|
12949
13359
|
const perspectivesCommand = addHubOptions(addLanguageOption(new Command("perspectives").description("Generate/update the project's perspectives document on the hub — a factual inventory of existing test coverage (no severity, no gap analysis)").option("--instruction <text>", "Hint to steer how summaries are written").option("--apply", "Auto-apply without [y/N] confirmation", false).option("--check", "Verify the hub document still matches the local specs (mechanical fields only) and exit 1 when it is stale. No Claude calls — cheap enough for CI.", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID").option("--project <name>", "Hub project to store the document under (default: cwd directory name)"))).action(withHubErrors(async (opts) => {
|
|
12950
13360
|
if (opts.check) await runPerspectivesCheck(opts);
|
|
@@ -12962,7 +13372,8 @@ async function runPerspectivesCheck(opts) {
|
|
|
12962
13372
|
const hub = requireHubOrExit(opts);
|
|
12963
13373
|
const project = resolveProject(opts);
|
|
12964
13374
|
header("perspectives", `check (project: ${project})`);
|
|
12965
|
-
const
|
|
13375
|
+
const [tree, checkoutFiles] = await Promise.all([listFeatureTree(), listCheckoutFiles(process.cwd())]);
|
|
13376
|
+
const skeleton = await buildSkeleton(tree, checkoutFiles);
|
|
12966
13377
|
const localCount = skeleton.reduce((n, f) => n + f.specs.length, 0);
|
|
12967
13378
|
const existingDoc = await hub.getPerspectives(project);
|
|
12968
13379
|
if (existingDoc === null) {
|
|
@@ -13044,7 +13455,8 @@ async function runPerspectives(opts) {
|
|
|
13044
13455
|
const hub = requireHubOrExit(opts);
|
|
13045
13456
|
const project = resolveProject(opts);
|
|
13046
13457
|
header("perspectives", `project: ${project}`);
|
|
13047
|
-
const
|
|
13458
|
+
const [tree, checkoutFiles] = await Promise.all([listFeatureTree(), listCheckoutFiles(process.cwd())]);
|
|
13459
|
+
const skeleton = await buildSkeleton(tree, checkoutFiles);
|
|
13048
13460
|
const allSpecs = skeleton.flatMap((f) => f.specs);
|
|
13049
13461
|
if (allSpecs.length === 0) {
|
|
13050
13462
|
info("no test cases found under .ccqa/features — nothing to inventory.");
|
|
@@ -13100,8 +13512,11 @@ async function cleanupLegacyLocalFiles() {
|
|
|
13100
13512
|
* relatedPaths transcribed from each spec, status derived mechanically from
|
|
13101
13513
|
* on-disk artifacts. `summary` is left empty here; Claude fills it later.
|
|
13102
13514
|
* Specs whose spec.yaml is missing or unparsable are skipped.
|
|
13515
|
+
*
|
|
13516
|
+
* `checkoutFiles` is the one shared file listing every spec's `relatedPaths`
|
|
13517
|
+
* zero-match count is computed against.
|
|
13103
13518
|
*/
|
|
13104
|
-
async function buildSkeleton(tree) {
|
|
13519
|
+
async function buildSkeleton(tree, checkoutFiles) {
|
|
13105
13520
|
const config = await loadProjectConfig(process.cwd()).catch(() => null);
|
|
13106
13521
|
return (await Promise.all(tree.map(async (feature) => {
|
|
13107
13522
|
const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
|
|
@@ -13109,14 +13524,13 @@ async function buildSkeleton(tree) {
|
|
|
13109
13524
|
const meta = readSpecMeta(s.specName, specYaml);
|
|
13110
13525
|
const plugin = resolveSpecTarget(specYaml, config);
|
|
13111
13526
|
const status = await deriveStatus(feature.featureName, s.specName, meta.mode, plugin);
|
|
13112
|
-
|
|
13527
|
+
return {
|
|
13113
13528
|
specName: s.specName,
|
|
13114
13529
|
title: meta.title,
|
|
13115
13530
|
summary: "",
|
|
13531
|
+
...relatedPathsFields(s.relatedPaths ?? [], checkoutFiles),
|
|
13116
13532
|
status
|
|
13117
13533
|
};
|
|
13118
|
-
if (s.relatedPaths) entry.relatedPaths = s.relatedPaths;
|
|
13119
|
-
return entry;
|
|
13120
13534
|
}));
|
|
13121
13535
|
return {
|
|
13122
13536
|
featureName: feature.featureName,
|
|
@@ -13370,6 +13784,7 @@ async function doSync(ctx, opts) {
|
|
|
13370
13784
|
const status = await deriveStatus(featureName, specName, meta$1.mode, plugin);
|
|
13371
13785
|
const relatedPaths = extractRelatedPaths(specYaml);
|
|
13372
13786
|
const previous = findSpec(doc, featureName, specName);
|
|
13787
|
+
const checkoutFiles = listCheckoutFiles(process.cwd());
|
|
13373
13788
|
const written = (await requestSummaries([{
|
|
13374
13789
|
featureName,
|
|
13375
13790
|
specName,
|
|
@@ -13383,6 +13798,7 @@ async function doSync(ctx, opts) {
|
|
|
13383
13798
|
specName,
|
|
13384
13799
|
title: meta$1.title,
|
|
13385
13800
|
summary: written?.summary ?? previous?.summary ?? "",
|
|
13801
|
+
...relatedPathsFields(relatedPaths, await checkoutFiles),
|
|
13386
13802
|
status
|
|
13387
13803
|
};
|
|
13388
13804
|
const startScreen = written?.startScreen ?? previous?.startScreen;
|
|
@@ -13391,7 +13807,6 @@ async function doSync(ctx, opts) {
|
|
|
13391
13807
|
if (testCondition) entry.testCondition = testCondition;
|
|
13392
13808
|
const preconditions = written?.preconditions ?? previous?.preconditions;
|
|
13393
13809
|
if (preconditions && preconditions.length > 0) entry.preconditions = preconditions;
|
|
13394
|
-
if (relatedPaths.length > 0) entry.relatedPaths = relatedPaths;
|
|
13395
13810
|
if (previous?.note) entry.note = previous.note;
|
|
13396
13811
|
upsertSpec(doc, featureName, entry);
|
|
13397
13812
|
doc.generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -14508,6 +14923,10 @@ const bootstrapCommand = new Command("bootstrap").description("Open a headed bro
|
|
|
14508
14923
|
const sessionCommand = new Command("session").description("Manage saved browser sessions (cookies + localStorage) for `session:` specs. Use `ccqa hub session ls` to list sessions stored on the hub.").addCommand(bootstrapCommand);
|
|
14509
14924
|
//#endregion
|
|
14510
14925
|
//#region src/hub/api/respond.ts
|
|
14926
|
+
/** The message of an unknown throwable, for a log line or an error body. */
|
|
14927
|
+
function errMsg(err) {
|
|
14928
|
+
return err instanceof Error ? err.message : String(err);
|
|
14929
|
+
}
|
|
14511
14930
|
var HttpError = class extends Error {
|
|
14512
14931
|
status;
|
|
14513
14932
|
code;
|
|
@@ -14533,7 +14952,7 @@ function sendError(res, err) {
|
|
|
14533
14952
|
}
|
|
14534
14953
|
sendJson(res, 500, { error: {
|
|
14535
14954
|
code: "internal_error",
|
|
14536
|
-
message:
|
|
14955
|
+
message: errMsg(err)
|
|
14537
14956
|
} });
|
|
14538
14957
|
}
|
|
14539
14958
|
function sendBytes(res, status, bytes, contentType) {
|
|
@@ -14559,6 +14978,24 @@ function readBody(req, maxBytes) {
|
|
|
14559
14978
|
req.on("error", rejectPromise);
|
|
14560
14979
|
});
|
|
14561
14980
|
}
|
|
14981
|
+
/**
|
|
14982
|
+
* Read a JSON request body and validate it against `schema`. Both a body that
|
|
14983
|
+
* isn't JSON and one that doesn't fit the schema are the client's fault, so
|
|
14984
|
+
* both are 400 `invalid_body` — a bare `JSON.parse` would surface a malformed
|
|
14985
|
+
* body as a 500. `label` names the body in the message ("deploy body").
|
|
14986
|
+
*/
|
|
14987
|
+
async function readJsonBody(req, maxBytes, schema, label) {
|
|
14988
|
+
const raw = await readBody(req, maxBytes);
|
|
14989
|
+
let parsed;
|
|
14990
|
+
try {
|
|
14991
|
+
parsed = JSON.parse(raw.toString("utf8"));
|
|
14992
|
+
} catch {
|
|
14993
|
+
throw new HttpError(400, "invalid_body", `${label} must be valid JSON`);
|
|
14994
|
+
}
|
|
14995
|
+
const result = schema.safeParse(parsed);
|
|
14996
|
+
if (!result.success) throw new HttpError(400, "invalid_body", `${label} is invalid: ${result.error.issues[0]?.message ?? "schema mismatch"}`);
|
|
14997
|
+
return result.data;
|
|
14998
|
+
}
|
|
14562
14999
|
//#endregion
|
|
14563
15000
|
//#region src/hub/api/handlers/health.ts
|
|
14564
15001
|
/** GET /api/v1/health — unauthenticated liveness probe. `queueDepth` is the number of learning jobs waiting. */
|
|
@@ -14614,7 +15051,10 @@ z.object({
|
|
|
14614
15051
|
ciRunId: z.string().nullable(),
|
|
14615
15052
|
runUrl: z.string().nullable().optional(),
|
|
14616
15053
|
reportCreatedAt: z.string(),
|
|
14617
|
-
createdAt: z.string()
|
|
15054
|
+
createdAt: z.string(),
|
|
15055
|
+
deployedSha: z.string().nullable().optional(),
|
|
15056
|
+
deployedShaSource: z.enum(["hub-deploy-log", "client"]).nullable().optional(),
|
|
15057
|
+
deployedShaAmbiguous: z.boolean().optional()
|
|
14618
15058
|
});
|
|
14619
15059
|
/**
|
|
14620
15060
|
* One failing spec's triage: the AI's prediction (read-only, sourced from
|
|
@@ -14663,10 +15103,129 @@ z.object({ error: z.object({
|
|
|
14663
15103
|
code: z.string(),
|
|
14664
15104
|
message: z.string()
|
|
14665
15105
|
}) });
|
|
14666
|
-
|
|
15106
|
+
/**
|
|
15107
|
+
* One spec's record of a single run, as stored in a ledger bucket. Identical
|
|
15108
|
+
* to `LastGreenEntry` plus the commit the environment was running at the time
|
|
15109
|
+
* — without it a bucket entry can be ordered in wall-clock time but not
|
|
15110
|
+
* *positioned* against the deploy log, which is the only ordering re-run
|
|
15111
|
+
* selection may use (ADR-0010).
|
|
15112
|
+
*/
|
|
15113
|
+
const SpecLedgerEntrySchema = z.object({
|
|
14667
15114
|
gitHead: z.string(),
|
|
14668
15115
|
runId: z.string(),
|
|
14669
15116
|
at: z.string()
|
|
15117
|
+
}).extend({
|
|
15118
|
+
deployedSha: z.string().nullable().optional(),
|
|
15119
|
+
deployedShaAmbiguous: z.boolean().optional()
|
|
15120
|
+
});
|
|
15121
|
+
z.object({
|
|
15122
|
+
green: z.record(z.string(), SpecLedgerEntrySchema).default({}),
|
|
15123
|
+
run: z.record(z.string(), SpecLedgerEntrySchema).default({}),
|
|
15124
|
+
red: z.record(z.string(), SpecLedgerEntrySchema).default({})
|
|
15125
|
+
});
|
|
15126
|
+
/** One deploy, as the consumer's deploy job reported it (ADR-0010). */
|
|
15127
|
+
const DeployEntrySchema = z.object({
|
|
15128
|
+
index: z.number().int().nonnegative(),
|
|
15129
|
+
sha: z.string(),
|
|
15130
|
+
previousSha: z.string().nullable(),
|
|
15131
|
+
at: z.string(),
|
|
15132
|
+
ref: z.string().optional(),
|
|
15133
|
+
runUrl: z.string().optional(),
|
|
15134
|
+
changedPaths: z.array(z.string()).nullable(),
|
|
15135
|
+
truncated: z.boolean().default(false),
|
|
15136
|
+
gapBefore: z.boolean().default(false)
|
|
15137
|
+
});
|
|
15138
|
+
z.object({
|
|
15139
|
+
nextIndex: z.number().int().nonnegative().default(0),
|
|
15140
|
+
entries: z.array(DeployEntrySchema).default([])
|
|
15141
|
+
});
|
|
15142
|
+
/**
|
|
15143
|
+
* One spec's entry in the derived touch index: the newest deploy known to have
|
|
15144
|
+
* touched it, folded in at write time from the deploy's full `changedPaths` so
|
|
15145
|
+
* that list can be dropped afterwards rather than retained per deploy.
|
|
15146
|
+
*
|
|
15147
|
+
* Folded against the `relatedPaths` in force when the deploy landed, so it can
|
|
15148
|
+
* disagree with a match against a spec's current `relatedPaths`. It is
|
|
15149
|
+
* therefore only consulted where the retained log cannot answer (a truncated
|
|
15150
|
+
* entry), never in place of matching the current paths.
|
|
15151
|
+
*/
|
|
15152
|
+
const SpecTouchSchema = z.object({
|
|
15153
|
+
lastTouchedIndex: z.number().int().nonnegative(),
|
|
15154
|
+
lastTouchedSha: z.string(),
|
|
15155
|
+
lastTouchedAt: z.string(),
|
|
15156
|
+
matchedPaths: z.array(z.string())
|
|
15157
|
+
});
|
|
15158
|
+
z.record(z.string(), SpecTouchSchema);
|
|
15159
|
+
/**
|
|
15160
|
+
* Whether a spec's last result is still trustworthy. Deliberately named for
|
|
15161
|
+
* the action rather than for a freshness adjective: "needs re-run" (mechanical,
|
|
15162
|
+
* no model call) is a different question from drift (does the spec still
|
|
15163
|
+
* describe the product), and the two must not be conflated — see ADR-0010.
|
|
15164
|
+
*/
|
|
15165
|
+
const RerunStateSchema = z.enum([
|
|
15166
|
+
"needed",
|
|
15167
|
+
"notNeeded",
|
|
15168
|
+
"unknown",
|
|
15169
|
+
"neverRun",
|
|
15170
|
+
"notEvaluated"
|
|
15171
|
+
]);
|
|
15172
|
+
/**
|
|
15173
|
+
* Why a spec is `unknown`. Always carried, so the view can name the missing
|
|
15174
|
+
* input ("no deploy log for this profile") instead of shrugging. `unknown` is
|
|
15175
|
+
* never rendered as "not needed".
|
|
15176
|
+
*/
|
|
15177
|
+
const RerunUnknownReasonSchema = z.enum([
|
|
15178
|
+
"noRelatedPaths",
|
|
15179
|
+
"noDeployLog",
|
|
15180
|
+
"unknownDeployedSha",
|
|
15181
|
+
"ambiguousDeployedSha",
|
|
15182
|
+
"deployedShaNotInLog",
|
|
15183
|
+
"gapInRange",
|
|
15184
|
+
"truncatedInRange"
|
|
15185
|
+
]);
|
|
15186
|
+
/** Where a deploy sits in a profile's log, as the view names it. */
|
|
15187
|
+
const DeployRefSchema = z.object({
|
|
15188
|
+
index: z.number().int().nonnegative(),
|
|
15189
|
+
sha: z.string(),
|
|
15190
|
+
at: z.string()
|
|
15191
|
+
});
|
|
15192
|
+
/**
|
|
15193
|
+
* One spec's re-run verdict plus the three ledger coordinates the view shows
|
|
15194
|
+
* alongside it. The coordinates are always present (null when the spec has no
|
|
15195
|
+
* such entry); `reason`, `touchedBy` and `touchedByDeploy` appear only in the
|
|
15196
|
+
* states named below.
|
|
15197
|
+
*/
|
|
15198
|
+
const SpecRerunSchema = z.object({
|
|
15199
|
+
state: RerunStateSchema,
|
|
15200
|
+
reason: RerunUnknownReasonSchema.optional(),
|
|
15201
|
+
lastRun: SpecLedgerEntrySchema.nullable(),
|
|
15202
|
+
lastGreen: SpecLedgerEntrySchema.nullable(),
|
|
15203
|
+
lastRed: SpecLedgerEntrySchema.nullable(),
|
|
15204
|
+
touchedBy: z.array(z.string()).optional(),
|
|
15205
|
+
touchedByDeploy: DeployRefSchema.nullable().optional()
|
|
15206
|
+
});
|
|
15207
|
+
z.object({
|
|
15208
|
+
project: z.string(),
|
|
15209
|
+
profile: z.string(),
|
|
15210
|
+
deployHead: DeployRefSchema.nullable(),
|
|
15211
|
+
specs: z.record(z.string(), SpecRerunSchema)
|
|
15212
|
+
});
|
|
15213
|
+
/** Body of `POST /projects/:project/deploys?profile=` — what the deploy job shipped. */
|
|
15214
|
+
const RecordDeployRequestSchema = z.object({
|
|
15215
|
+
sha: z.string().min(1),
|
|
15216
|
+
previousSha: z.string().min(1).nullable().optional(),
|
|
15217
|
+
changedPaths: z.array(z.string()).nullable().optional(),
|
|
15218
|
+
ref: z.string().optional(),
|
|
15219
|
+
runUrl: z.string().optional()
|
|
15220
|
+
});
|
|
15221
|
+
z.object({
|
|
15222
|
+
entries: z.array(DeployEntrySchema),
|
|
15223
|
+
nextIndex: z.number().int().nonnegative()
|
|
15224
|
+
});
|
|
15225
|
+
z.object({
|
|
15226
|
+
entries: z.record(z.string(), SpecLedgerEntrySchema),
|
|
15227
|
+
lastRun: z.record(z.string(), SpecLedgerEntrySchema).default({}),
|
|
15228
|
+
lastRed: z.record(z.string(), SpecLedgerEntrySchema).default({})
|
|
14670
15229
|
});
|
|
14671
15230
|
/**
|
|
14672
15231
|
* A triage-learning job. Grading failing specs in the hub UI produces the
|
|
@@ -14706,6 +15265,49 @@ const CreateLearningJobRequestSchema = z.object({
|
|
|
14706
15265
|
runLimit: z.number().int().positive().max(1e3).optional()
|
|
14707
15266
|
});
|
|
14708
15267
|
//#endregion
|
|
15268
|
+
//#region src/hub/core/spec-ledger.ts
|
|
15269
|
+
const BUCKET_NAMES = [
|
|
15270
|
+
"green",
|
|
15271
|
+
"run",
|
|
15272
|
+
"red"
|
|
15273
|
+
];
|
|
15274
|
+
function emptyLedger() {
|
|
15275
|
+
return {
|
|
15276
|
+
green: {},
|
|
15277
|
+
run: {},
|
|
15278
|
+
red: {}
|
|
15279
|
+
};
|
|
15280
|
+
}
|
|
15281
|
+
/**
|
|
15282
|
+
* Documents written before the ledger grew `run` and `red` are a flat
|
|
15283
|
+
* `Record<specKey, entry>` of greens. A spec key is "feature/spec" and always
|
|
15284
|
+
* contains a '/', so it can never collide with a bucket name and the two
|
|
15285
|
+
* shapes are unambiguous. The first `merge` rewrites the file in the new
|
|
15286
|
+
* shape; nothing else migrates it.
|
|
15287
|
+
*/
|
|
15288
|
+
function toLedger(raw) {
|
|
15289
|
+
if (raw === null || typeof raw !== "object") return emptyLedger();
|
|
15290
|
+
const doc = raw;
|
|
15291
|
+
if (!BUCKET_NAMES.some((name) => name in doc)) return {
|
|
15292
|
+
green: doc,
|
|
15293
|
+
run: {},
|
|
15294
|
+
red: {}
|
|
15295
|
+
};
|
|
15296
|
+
return {
|
|
15297
|
+
green: doc["green"] ?? {},
|
|
15298
|
+
run: doc["run"] ?? {},
|
|
15299
|
+
red: doc["red"] ?? {}
|
|
15300
|
+
};
|
|
15301
|
+
}
|
|
15302
|
+
/** Fold `from` into `into` in place, per bucket and key, newest `at` winning. */
|
|
15303
|
+
function mergeLedgerInto(into, from) {
|
|
15304
|
+
for (const name of BUCKET_NAMES) for (const [key, entry] of Object.entries(from[name])) {
|
|
15305
|
+
const prev = into[name][key];
|
|
15306
|
+
if (!prev || prev.at <= entry.at) into[name][key] = entry;
|
|
15307
|
+
}
|
|
15308
|
+
return into;
|
|
15309
|
+
}
|
|
15310
|
+
//#endregion
|
|
14709
15311
|
//#region src/hub/api/validate.ts
|
|
14710
15312
|
/**
|
|
14711
15313
|
* Validators for URL path parameters that flow into the storage layer's file
|
|
@@ -14720,6 +15322,13 @@ function requireSafeSegment(value, paramName) {
|
|
|
14720
15322
|
if (value.length === 0 || value.length > 128 || !SAFE_SEGMENT.test(value) || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new HttpError(400, "invalid_param", `invalid ${paramName}: must be a bare name (letters, digits, '.', '_', '-'; no path separators or '..')`);
|
|
14721
15323
|
return value;
|
|
14722
15324
|
}
|
|
15325
|
+
/**
|
|
15326
|
+
* The `?profile=` query param, defaulting to "default". Shared by every
|
|
15327
|
+
* profile-scoped route so the default and the validation rule have one home.
|
|
15328
|
+
*/
|
|
15329
|
+
function requireProfileParam(url) {
|
|
15330
|
+
return requireSafeSegment(url.searchParams.get("profile") ?? "default", "profile");
|
|
15331
|
+
}
|
|
14723
15332
|
/** Validate a `*path`-captured relative path (multiple segments allowed) as safe to join under a root dir. Throws 400 if unsafe. */
|
|
14724
15333
|
function requireSafeRelPath(relPath, paramName) {
|
|
14725
15334
|
const segments = relPath.split("/");
|
|
@@ -14739,7 +15348,7 @@ const DEFAULT_MAX_PUSH_BYTES = 32 * 1024 * 1024;
|
|
|
14739
15348
|
function createPushRunHandler(config) {
|
|
14740
15349
|
const maxPushBytes = config.maxPushBytes ?? DEFAULT_MAX_PUSH_BYTES;
|
|
14741
15350
|
return async (ctx) => {
|
|
14742
|
-
const { project, branch, profile, kind } = parseRunScope(ctx);
|
|
15351
|
+
const { project, branch, profile, kind, deployedSha } = parseRunScope(ctx);
|
|
14743
15352
|
const body = await readBody(ctx.req, maxPushBytes);
|
|
14744
15353
|
const dir = await mkdtemp(join(tmpdir(), "ccqa-hub-push-"));
|
|
14745
15354
|
try {
|
|
@@ -14780,11 +15389,13 @@ function createPushRunHandler(config) {
|
|
|
14780
15389
|
ciRunId: report.runId,
|
|
14781
15390
|
runUrl: report.runUrl ?? null,
|
|
14782
15391
|
reportCreatedAt: report.createdAt,
|
|
14783
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
15392
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
15393
|
+
...await resolveDeployedSha(config.storage, project, profile, deployedSha),
|
|
15394
|
+
deployedShaAmbiguous: false
|
|
14784
15395
|
};
|
|
14785
15396
|
await config.storage.artifacts.putDir(run.id, dir);
|
|
14786
15397
|
await config.storage.runs.create(run);
|
|
14787
|
-
await
|
|
15398
|
+
await updateSpecLedger(config.storage, run, report.results);
|
|
14788
15399
|
sendJson(ctx.res, 201, run);
|
|
14789
15400
|
} finally {
|
|
14790
15401
|
await rm(dir, {
|
|
@@ -14804,7 +15415,7 @@ function createPushRunHandler(config) {
|
|
|
14804
15415
|
*/
|
|
14805
15416
|
function createOpenRunHandler(config) {
|
|
14806
15417
|
return async (ctx) => {
|
|
14807
|
-
const { project, branch, profile, kind } = parseRunScope(ctx);
|
|
15418
|
+
const { project, branch, profile, kind, deployedSha } = parseRunScope(ctx);
|
|
14808
15419
|
const gitHead = ctx.url.searchParams.get("gitHead");
|
|
14809
15420
|
const ciRunId = ctx.url.searchParams.get("ciRunId");
|
|
14810
15421
|
const runUrl = ctx.url.searchParams.get("runUrl");
|
|
@@ -14827,7 +15438,9 @@ function createOpenRunHandler(config) {
|
|
|
14827
15438
|
ciRunId: ciRunId || null,
|
|
14828
15439
|
runUrl: runUrl || null,
|
|
14829
15440
|
reportCreatedAt: now,
|
|
14830
|
-
createdAt: now
|
|
15441
|
+
createdAt: now,
|
|
15442
|
+
...await resolveDeployedSha(config.storage, project, profile, deployedSha),
|
|
15443
|
+
deployedShaAmbiguous: false
|
|
14831
15444
|
};
|
|
14832
15445
|
await config.storage.runs.create(run);
|
|
14833
15446
|
sendJson(ctx.res, 201, run);
|
|
@@ -14864,58 +15477,106 @@ function countSpecs(results) {
|
|
|
14864
15477
|
};
|
|
14865
15478
|
}
|
|
14866
15479
|
/**
|
|
14867
|
-
* Advance the
|
|
14868
|
-
*
|
|
14869
|
-
*
|
|
15480
|
+
* Advance the spec ledger's three buckets from a terminal `kind: "run"` run.
|
|
15481
|
+
* Spec-level, not run-level: a run with one chronically failing spec still
|
|
15482
|
+
* moves the baselines of every spec that did pass.
|
|
15483
|
+
*
|
|
15484
|
+
* A skipped row did not execute, so it advances nothing — not even `run`.
|
|
15485
|
+
* Everything else lands in `run` (the "needs re-run" baseline) and in `green`
|
|
15486
|
+
* or `red` (the last outcome), which are orthogonal axes.
|
|
15487
|
+
*
|
|
14870
15488
|
* Best-effort — a ledger failure must not fail the push; the ledger is an
|
|
14871
|
-
* accelerator for `--failure-analysis=last-green
|
|
14872
|
-
* record. Runs without a branch or gitHead can't be placed in
|
|
14873
|
-
* are skipped.
|
|
15489
|
+
* accelerator for `--failure-analysis=last-green` and re-run selection, not
|
|
15490
|
+
* part of the run record. Runs without a branch or gitHead can't be placed in
|
|
15491
|
+
* the ledger and are skipped.
|
|
14874
15492
|
*
|
|
14875
15493
|
* Ordering caveat (known approximation): `at` is the run's reportCreatedAt —
|
|
14876
15494
|
* open time for incremental runs, report time for immutable pushes. When two
|
|
14877
15495
|
* runs on the same branch+profile overlap, "newest at wins" can pick either
|
|
14878
15496
|
* of the two genuinely-green commits, since the hub has no git ancestry to
|
|
14879
15497
|
* order them properly. Accepted: CI serializes per branch in practice, and a
|
|
14880
|
-
* baseline can only ever point at a commit where the spec really
|
|
15498
|
+
* baseline can only ever point at a commit where the spec really ran.
|
|
14881
15499
|
*/
|
|
14882
|
-
async function
|
|
15500
|
+
async function updateSpecLedger(storage, run, results) {
|
|
14883
15501
|
const { gitHead, branch } = run;
|
|
14884
15502
|
if (run.kind !== "run" || !gitHead || !branch) return;
|
|
14885
|
-
const
|
|
14886
|
-
if (passed.length === 0) return;
|
|
14887
|
-
const entries = Object.fromEntries(passed.map((r) => [`${r.feature}/${r.spec}`, {
|
|
15503
|
+
const entry = {
|
|
14888
15504
|
gitHead,
|
|
14889
15505
|
runId: run.id,
|
|
14890
|
-
at: run.reportCreatedAt
|
|
14891
|
-
|
|
15506
|
+
at: run.reportCreatedAt,
|
|
15507
|
+
deployedSha: run.deployedSha ?? null,
|
|
15508
|
+
deployedShaAmbiguous: run.deployedShaAmbiguous ?? false
|
|
15509
|
+
};
|
|
15510
|
+
const ledger = emptyLedger();
|
|
15511
|
+
for (const row of results) {
|
|
15512
|
+
if (row.status === "skipped") continue;
|
|
15513
|
+
const key = `${row.feature}/${row.spec}`;
|
|
15514
|
+
ledger.run[key] = entry;
|
|
15515
|
+
if (row.status === "passed") ledger.green[key] = entry;
|
|
15516
|
+
else ledger.red[key] = entry;
|
|
15517
|
+
}
|
|
15518
|
+
if (Object.keys(ledger.run).length === 0) return;
|
|
14892
15519
|
try {
|
|
14893
|
-
await storage.
|
|
15520
|
+
await storage.ledger.merge(run.project, run.profile ?? "default", branch, ledger);
|
|
14894
15521
|
} catch (err) {
|
|
14895
|
-
console.error(`hub:
|
|
15522
|
+
console.error(`hub: spec ledger update failed for run "${run.id}": ${errMsg(err)}`);
|
|
14896
15523
|
}
|
|
14897
15524
|
}
|
|
14898
15525
|
/**
|
|
14899
|
-
*
|
|
14900
|
-
*
|
|
14901
|
-
*
|
|
15526
|
+
* What commit the environment was running for this run. An explicit
|
|
15527
|
+
* `?deployedSha=` wins — ccqa never guesses a baseline, and a caller that
|
|
15528
|
+
* knows what it deployed against is more authoritative than the log head.
|
|
15529
|
+
*
|
|
15530
|
+
* Best-effort: a deploy log the hub can't read leaves the run unattributed
|
|
15531
|
+
* (re-run selection then answers `unknown`) rather than rejecting the run.
|
|
14902
15532
|
*/
|
|
14903
|
-
function
|
|
14904
|
-
|
|
14905
|
-
|
|
15533
|
+
async function resolveDeployedSha(storage, project, profile, explicit) {
|
|
15534
|
+
if (explicit) return {
|
|
15535
|
+
deployedSha: explicit,
|
|
15536
|
+
deployedShaSource: "client"
|
|
15537
|
+
};
|
|
15538
|
+
try {
|
|
15539
|
+
const head = await storage.deploys.head(project, profile ?? "default");
|
|
15540
|
+
if (head) return {
|
|
15541
|
+
deployedSha: head.sha,
|
|
15542
|
+
deployedShaSource: "hub-deploy-log"
|
|
15543
|
+
};
|
|
15544
|
+
} catch (err) {
|
|
15545
|
+
console.error(`hub: could not read the deploy log for "${project}/${profile ?? "default"}": ${errMsg(err)}`);
|
|
15546
|
+
}
|
|
15547
|
+
return {
|
|
15548
|
+
deployedSha: null,
|
|
15549
|
+
deployedShaSource: null
|
|
15550
|
+
};
|
|
15551
|
+
}
|
|
15552
|
+
/**
|
|
15553
|
+
* True when the deploy-log head moved while the run was open: the run
|
|
15554
|
+
* straddled a deploy, so which commit it exercised is not knowable and re-run
|
|
15555
|
+
* selection must report `unknown` instead of picking one. Only meaningful for
|
|
15556
|
+
* a sha the hub stamped itself — a client-asserted one is the caller's claim
|
|
15557
|
+
* about its own run, not the hub's observation.
|
|
15558
|
+
*/
|
|
15559
|
+
async function deployHeadMovedDuringRun(storage, run) {
|
|
15560
|
+
if (run.deployedShaSource !== "hub-deploy-log" || !run.deployedSha) return false;
|
|
15561
|
+
try {
|
|
15562
|
+
const head = await storage.deploys.head(run.project, run.profile ?? "default");
|
|
15563
|
+
return head !== null && head.sha !== run.deployedSha;
|
|
15564
|
+
} catch {
|
|
15565
|
+
return false;
|
|
15566
|
+
}
|
|
15567
|
+
}
|
|
15568
|
+
/**
|
|
15569
|
+
* PATCH /api/v1/runs/:id — incrementally add spec results (and evidence) to a
|
|
15570
|
+
* "running" run. Once the run is terminal (`done: true` was sent, or it was
|
|
15571
|
+
* pushed immutably via `POST /runs`), further patches are rejected with 409.
|
|
15572
|
+
*/
|
|
15573
|
+
function createPatchRunHandler(config) {
|
|
15574
|
+
const maxPushBytes = config.maxPushBytes ?? DEFAULT_MAX_PUSH_BYTES;
|
|
15575
|
+
return async (ctx) => {
|
|
14906
15576
|
const id = ctx.params.id;
|
|
14907
15577
|
const run = await getRunOr404(config.storage, id);
|
|
14908
15578
|
if (run.status !== "running") throw new HttpError(409, "conflict", "run is not running (already terminal)");
|
|
14909
|
-
const
|
|
14910
|
-
let bodyJson;
|
|
14911
|
-
try {
|
|
14912
|
-
bodyJson = JSON.parse(raw.toString("utf8"));
|
|
14913
|
-
} catch {
|
|
14914
|
-
throw new HttpError(400, "invalid_body", "request body is not valid JSON");
|
|
14915
|
-
}
|
|
14916
|
-
const parsed = PatchRunRequestSchema.safeParse(bodyJson);
|
|
14917
|
-
if (!parsed.success) throw new HttpError(400, "invalid_body", `request body is invalid: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`);
|
|
14918
|
-
const { rows, evidence, done, finalStatus, reportMeta } = parsed.data;
|
|
15579
|
+
const { rows, evidence, done, finalStatus, reportMeta } = await readJsonBody(ctx.req, maxPushBytes, PatchRunRequestSchema, "request body");
|
|
14919
15580
|
let specs = run.specs;
|
|
14920
15581
|
let mergedResults = [];
|
|
14921
15582
|
await config.storage.artifacts.updateJsonFile(id, "report.json", (current) => {
|
|
@@ -14961,10 +15622,11 @@ function createPatchRunHandler(config) {
|
|
|
14961
15622
|
status: finalStatus ?? (specs.failed > 0 ? "failed" : "passed"),
|
|
14962
15623
|
specs,
|
|
14963
15624
|
...reportMeta?.git?.head ? { gitHead: reportMeta.git.head } : {},
|
|
14964
|
-
...reportMeta?.promptVersion ? { promptVersion: reportMeta.promptVersion } : {}
|
|
15625
|
+
...reportMeta?.promptVersion ? { promptVersion: reportMeta.promptVersion } : {},
|
|
15626
|
+
...await deployHeadMovedDuringRun(config.storage, run) ? { deployedShaAmbiguous: true } : {}
|
|
14965
15627
|
} : { specs };
|
|
14966
15628
|
const updated = await config.storage.runs.update(id, patch);
|
|
14967
|
-
if (done) await
|
|
15629
|
+
if (done) await updateSpecLedger(config.storage, updated, mergedResults);
|
|
14968
15630
|
sendJson(ctx.res, 200, updated);
|
|
14969
15631
|
};
|
|
14970
15632
|
}
|
|
@@ -15059,10 +15721,11 @@ function summarizeDrift(results) {
|
|
|
15059
15721
|
};
|
|
15060
15722
|
}
|
|
15061
15723
|
/**
|
|
15062
|
-
* Parse the `project`/`branch`/`profile`/`kind` query params
|
|
15063
|
-
* `POST /runs` (push) and `POST /runs/open`. `project` is required;
|
|
15064
|
-
* is optional and recorded for display only (runs are not scoped by
|
|
15065
|
-
* `kind` defaults to "run"
|
|
15724
|
+
* Parse the `project`/`branch`/`profile`/`kind`/`deployedSha` query params
|
|
15725
|
+
* shared by `POST /runs` (push) and `POST /runs/open`. `project` is required;
|
|
15726
|
+
* `profile` is optional and recorded for display only (runs are not scoped by
|
|
15727
|
+
* profile); `kind` defaults to "run"; `deployedSha` overrides what the hub
|
|
15728
|
+
* would read from the profile's deploy log.
|
|
15066
15729
|
*/
|
|
15067
15730
|
function parseRunScope(ctx) {
|
|
15068
15731
|
const projectRaw = ctx.url.searchParams.get("project");
|
|
@@ -15073,11 +15736,13 @@ function parseRunScope(ctx) {
|
|
|
15073
15736
|
const profile = profileRaw ? requireSafeSegment(profileRaw, "profile") : null;
|
|
15074
15737
|
const kindRaw = ctx.url.searchParams.get("kind");
|
|
15075
15738
|
if (kindRaw !== null && kindRaw !== "run" && kindRaw !== "drift") throw new HttpError(400, "invalid_param", `invalid kind: must be "run" or "drift"`);
|
|
15739
|
+
const deployedSha = boundedParam(ctx.url.searchParams.get("deployedSha"), "deployedSha", 64);
|
|
15076
15740
|
return {
|
|
15077
15741
|
project,
|
|
15078
15742
|
branch,
|
|
15079
15743
|
profile,
|
|
15080
|
-
kind: kindRaw ?? "run"
|
|
15744
|
+
kind: kindRaw ?? "run",
|
|
15745
|
+
deployedSha
|
|
15081
15746
|
};
|
|
15082
15747
|
}
|
|
15083
15748
|
/**
|
|
@@ -15088,13 +15753,14 @@ function parseRunScope(ctx) {
|
|
|
15088
15753
|
* send one. Exported for the last-green handler.
|
|
15089
15754
|
*/
|
|
15090
15755
|
function requireBranch(raw) {
|
|
15756
|
+
return boundedParam(raw, "branch", 256);
|
|
15757
|
+
}
|
|
15758
|
+
/** An opaque free-form query param: null when absent, otherwise length-capped as a sanity check. */
|
|
15759
|
+
function boundedParam(raw, name, max) {
|
|
15091
15760
|
if (raw === null || raw === "") return null;
|
|
15092
|
-
if (raw.length >
|
|
15761
|
+
if (raw.length > max) throw new HttpError(400, "invalid_param", `${name} is too long (max ${max} chars)`);
|
|
15093
15762
|
return raw;
|
|
15094
15763
|
}
|
|
15095
|
-
function errMsg(err) {
|
|
15096
|
-
return err instanceof Error ? err.message : String(err);
|
|
15097
|
-
}
|
|
15098
15764
|
//#endregion
|
|
15099
15765
|
//#region src/hub/core/crypto.ts
|
|
15100
15766
|
/**
|
|
@@ -15342,25 +16008,326 @@ function createListProfilesHandler(storage) {
|
|
|
15342
16008
|
/**
|
|
15343
16009
|
* GET /api/v1/projects/:project/last-green?profile=&branch=&fallbackBranch=
|
|
15344
16010
|
*
|
|
15345
|
-
* Returns the
|
|
15346
|
-
*
|
|
15347
|
-
*
|
|
15348
|
-
*
|
|
15349
|
-
*
|
|
15350
|
-
*
|
|
16011
|
+
* Returns the spec ledger for one project/profile, keyed by "feature/spec".
|
|
16012
|
+
* `branch` is the caller's current branch; `fallbackBranch` (optional,
|
|
16013
|
+
* typically the default branch) is overlaid *under* it, so a PR branch with no
|
|
16014
|
+
* entries of its own still inherits the default branch's baselines while its
|
|
16015
|
+
* own take precedence. One round trip serves the whole run.
|
|
16016
|
+
*
|
|
16017
|
+
* `entries` is the green bucket and keeps that exact meaning — older CLIs read
|
|
16018
|
+
* `entry.gitHead` from it as "the commit this spec last passed at". The
|
|
16019
|
+
* last-run and last-red buckets are siblings, not a redefinition.
|
|
15351
16020
|
*/
|
|
15352
16021
|
function createGetLastGreenHandler(storage) {
|
|
15353
16022
|
return async (ctx) => {
|
|
15354
16023
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
15355
|
-
const profile =
|
|
16024
|
+
const profile = requireProfileParam(ctx.url);
|
|
15356
16025
|
const branch = requireBranch(ctx.url.searchParams.get("branch"));
|
|
15357
16026
|
if (!branch) throw new HttpError(400, "missing_param", "branch query parameter is required");
|
|
15358
16027
|
const fallbackBranch = requireBranch(ctx.url.searchParams.get("fallbackBranch"));
|
|
15359
|
-
const [primary, fallback] = await Promise.all([storage.
|
|
15360
|
-
sendJson(ctx.res, 200, {
|
|
15361
|
-
|
|
15362
|
-
|
|
15363
|
-
|
|
16028
|
+
const [primary, fallback] = await Promise.all([storage.ledger.get(project, profile, branch), fallbackBranch && fallbackBranch !== branch ? storage.ledger.get(project, profile, fallbackBranch) : Promise.resolve(emptyLedger())]);
|
|
16029
|
+
sendJson(ctx.res, 200, {
|
|
16030
|
+
entries: {
|
|
16031
|
+
...fallback.green,
|
|
16032
|
+
...primary.green
|
|
16033
|
+
},
|
|
16034
|
+
lastRun: {
|
|
16035
|
+
...fallback.run,
|
|
16036
|
+
...primary.run
|
|
16037
|
+
},
|
|
16038
|
+
lastRed: {
|
|
16039
|
+
...fallback.red,
|
|
16040
|
+
...primary.red
|
|
16041
|
+
}
|
|
16042
|
+
});
|
|
16043
|
+
};
|
|
16044
|
+
}
|
|
16045
|
+
function emptyDeployLog() {
|
|
16046
|
+
return {
|
|
16047
|
+
nextIndex: 0,
|
|
16048
|
+
entries: []
|
|
16049
|
+
};
|
|
16050
|
+
}
|
|
16051
|
+
/** Append `input` to `current`; the appended entry is always the last of `entries`. */
|
|
16052
|
+
function appendDeploy(current, input) {
|
|
16053
|
+
const log = current ?? emptyDeployLog();
|
|
16054
|
+
const head = log.entries[log.entries.length - 1];
|
|
16055
|
+
const gapBefore = head ? head.sha !== input.previousSha : log.nextIndex > 0;
|
|
16056
|
+
const truncated = input.changedPaths !== null && input.changedPaths.length > 500;
|
|
16057
|
+
const entries = [...log.entries, {
|
|
16058
|
+
...input,
|
|
16059
|
+
index: log.nextIndex,
|
|
16060
|
+
changedPaths: truncated ? input.changedPaths.slice(0, 500) : input.changedPaths,
|
|
16061
|
+
truncated,
|
|
16062
|
+
gapBefore
|
|
16063
|
+
}];
|
|
16064
|
+
if (entries.length > 200) {
|
|
16065
|
+
entries.splice(0, entries.length - 200);
|
|
16066
|
+
entries[0] = {
|
|
16067
|
+
...entries[0],
|
|
16068
|
+
gapBefore: true
|
|
16069
|
+
};
|
|
16070
|
+
}
|
|
16071
|
+
return {
|
|
16072
|
+
nextIndex: log.nextIndex + 1,
|
|
16073
|
+
entries
|
|
16074
|
+
};
|
|
16075
|
+
}
|
|
16076
|
+
/**
|
|
16077
|
+
* Fold one deploy into the touch index, matched against `changedPaths` as the
|
|
16078
|
+
* deploy job reported them — the log's retained copy may be truncated, and
|
|
16079
|
+
* this is the only moment the full list exists.
|
|
16080
|
+
*
|
|
16081
|
+
* A deploy that reported no paths touches every spec: fail-open, and
|
|
16082
|
+
* self-limiting because it makes everything re-run once and then settles.
|
|
16083
|
+
*/
|
|
16084
|
+
function foldTouchIndex(current, entry, changedPaths, targets) {
|
|
16085
|
+
const out = { ...current };
|
|
16086
|
+
for (const target of targets) {
|
|
16087
|
+
if (target.relatedPaths.length === 0) continue;
|
|
16088
|
+
const matched = changedPaths === null ? [] : matchPaths(changedPaths, target.relatedPaths);
|
|
16089
|
+
if (changedPaths !== null && matched.length === 0) continue;
|
|
16090
|
+
out[target.key] = {
|
|
16091
|
+
lastTouchedIndex: entry.index,
|
|
16092
|
+
lastTouchedSha: entry.sha,
|
|
16093
|
+
lastTouchedAt: entry.at,
|
|
16094
|
+
matchedPaths: matched
|
|
16095
|
+
};
|
|
16096
|
+
}
|
|
16097
|
+
return out;
|
|
16098
|
+
}
|
|
16099
|
+
/**
|
|
16100
|
+
* The paths in `changedPaths` covered by `relatedPaths`, up to the sample
|
|
16101
|
+
* size. Uses `isPathAffectedBy`, the same matcher `ccqa drift --changed` and
|
|
16102
|
+
* `ccqa run --changed` use, so the hub's verdict and the CLI's cannot diverge.
|
|
16103
|
+
*/
|
|
16104
|
+
function matchPaths(changedPaths, relatedPaths) {
|
|
16105
|
+
const out = [];
|
|
16106
|
+
for (const path of changedPaths) {
|
|
16107
|
+
if (!isPathAffectedBy(path, relatedPaths)) continue;
|
|
16108
|
+
out.push(path);
|
|
16109
|
+
if (out.length >= 10) break;
|
|
16110
|
+
}
|
|
16111
|
+
return out;
|
|
16112
|
+
}
|
|
16113
|
+
//#endregion
|
|
16114
|
+
//#region src/hub/core/perspectives-specs.ts
|
|
16115
|
+
function prop(obj, key) {
|
|
16116
|
+
return obj?.[key];
|
|
16117
|
+
}
|
|
16118
|
+
/**
|
|
16119
|
+
* Pull the spec targets out of a stored perspectives document.
|
|
16120
|
+
*
|
|
16121
|
+
* Hand-parsed rather than run through `PerspectivesSchema`, because the
|
|
16122
|
+
* document on the hub was written by whatever CLI version the consumer runs:
|
|
16123
|
+
* a single malformed entry must cost that one spec, not fail the whole view.
|
|
16124
|
+
*/
|
|
16125
|
+
function readSpecTargets(doc) {
|
|
16126
|
+
const features = prop(doc, "features");
|
|
16127
|
+
if (!Array.isArray(features)) return [];
|
|
16128
|
+
const out = [];
|
|
16129
|
+
for (const feature of features) {
|
|
16130
|
+
const featureName = prop(feature, "featureName");
|
|
16131
|
+
const specs = prop(feature, "specs");
|
|
16132
|
+
if (typeof featureName !== "string" || !Array.isArray(specs)) continue;
|
|
16133
|
+
for (const spec of specs) {
|
|
16134
|
+
const specName = prop(spec, "specName");
|
|
16135
|
+
if (typeof specName !== "string") continue;
|
|
16136
|
+
const related = prop(spec, "relatedPaths");
|
|
16137
|
+
out.push({
|
|
16138
|
+
key: `${featureName}/${specName}`,
|
|
16139
|
+
relatedPaths: Array.isArray(related) ? related.filter((p) => typeof p === "string") : []
|
|
16140
|
+
});
|
|
16141
|
+
}
|
|
16142
|
+
}
|
|
16143
|
+
return out;
|
|
16144
|
+
}
|
|
16145
|
+
/**
|
|
16146
|
+
* The project's spec targets as stored on the hub, or null when it has no
|
|
16147
|
+
* perspectives document — the one condition `GET /rerun` answers with a 404,
|
|
16148
|
+
* and the one that leaves a deploy with nothing to fold against.
|
|
16149
|
+
*/
|
|
16150
|
+
async function loadSpecTargets(perspectives, project) {
|
|
16151
|
+
const stored = await perspectives.get(project);
|
|
16152
|
+
if (!stored) return null;
|
|
16153
|
+
try {
|
|
16154
|
+
return readSpecTargets(JSON.parse(Buffer.from(stored).toString("utf8")));
|
|
16155
|
+
} catch {
|
|
16156
|
+
return null;
|
|
16157
|
+
}
|
|
16158
|
+
}
|
|
16159
|
+
//#endregion
|
|
16160
|
+
//#region src/hub/api/handlers/deploys.ts
|
|
16161
|
+
/** `changedPaths` for a wide refactor can run to tens of thousands of entries. */
|
|
16162
|
+
const MAX_DEPLOY_BODY_BYTES = 8 * 1024 * 1024;
|
|
16163
|
+
/**
|
|
16164
|
+
* POST /api/v1/projects/:project/deploys?profile=
|
|
16165
|
+
*
|
|
16166
|
+
* The consuming deploy job tells the hub what it shipped. This is the input
|
|
16167
|
+
* that makes "needs re-run" answerable at all: the hub has no checkout and
|
|
16168
|
+
* never calls a git host, so it cannot work out what changed on its own
|
|
16169
|
+
* (ADR-0010). The log is per-profile because two environments sit at
|
|
16170
|
+
* different commits.
|
|
16171
|
+
*/
|
|
16172
|
+
function createRecordDeployHandler(storage) {
|
|
16173
|
+
return async (ctx) => {
|
|
16174
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
16175
|
+
const profile = requireProfileParam(ctx.url);
|
|
16176
|
+
const { sha, previousSha, changedPaths, ref, runUrl } = await readJsonBody(ctx.req, MAX_DEPLOY_BODY_BYTES, RecordDeployRequestSchema, "deploy body");
|
|
16177
|
+
const entry = await storage.deploys.append(project, profile, {
|
|
16178
|
+
sha,
|
|
16179
|
+
previousSha: previousSha ?? null,
|
|
16180
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16181
|
+
...ref ? { ref } : {},
|
|
16182
|
+
...runUrl ? { runUrl } : {},
|
|
16183
|
+
changedPaths: changedPaths ?? null
|
|
16184
|
+
});
|
|
16185
|
+
await foldIntoTouchIndex(storage, project, profile, entry, changedPaths ?? null);
|
|
16186
|
+
sendJson(ctx.res, 201, entry);
|
|
16187
|
+
};
|
|
16188
|
+
}
|
|
16189
|
+
/**
|
|
16190
|
+
* Record which specs this deploy touched, matched against the full
|
|
16191
|
+
* `changedPaths` before the stored entry's bounded copy is all that is left.
|
|
16192
|
+
*
|
|
16193
|
+
* Best-effort and deliberately after the append: the log is the record of what
|
|
16194
|
+
* shipped, the index is a derived accelerator, and losing the fold costs
|
|
16195
|
+
* precision on truncated entries — not correctness.
|
|
16196
|
+
*/
|
|
16197
|
+
async function foldIntoTouchIndex(storage, project, profile, entry, changedPaths) {
|
|
16198
|
+
try {
|
|
16199
|
+
const targets = await loadSpecTargets(storage.perspectives, project);
|
|
16200
|
+
if (!targets || targets.length === 0) return;
|
|
16201
|
+
await storage.deploys.updateTouchIndex(project, profile, (current) => foldTouchIndex(current, entry, changedPaths, targets));
|
|
16202
|
+
} catch (err) {
|
|
16203
|
+
console.error(`hub: touch-index fold failed for deploy "${entry.sha}" of "${project}/${profile}": ${errMsg(err)}`);
|
|
16204
|
+
}
|
|
16205
|
+
}
|
|
16206
|
+
/** GET /api/v1/projects/:project/deploys?profile=&limit= — the retained log, oldest first. */
|
|
16207
|
+
function createGetDeployLogHandler(storage) {
|
|
16208
|
+
return async (ctx) => {
|
|
16209
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
16210
|
+
const log = await storage.deploys.getLog(project, requireProfileParam(ctx.url));
|
|
16211
|
+
const limit = Number(ctx.url.searchParams.get("limit"));
|
|
16212
|
+
const entries = Number.isFinite(limit) && limit > 0 ? log.entries.slice(-Math.floor(limit)) : log.entries;
|
|
16213
|
+
sendJson(ctx.res, 200, {
|
|
16214
|
+
entries,
|
|
16215
|
+
nextIndex: log.nextIndex
|
|
16216
|
+
});
|
|
16217
|
+
};
|
|
16218
|
+
}
|
|
16219
|
+
//#endregion
|
|
16220
|
+
//#region src/hub/core/rerun.ts
|
|
16221
|
+
function computeRerun(input) {
|
|
16222
|
+
const { specs, ledger, log, touchIndex } = input;
|
|
16223
|
+
const notEvaluated = log.entries.length === 0 && Object.keys(ledger.run).length === 0 && Object.keys(ledger.green).length === 0;
|
|
16224
|
+
const positionBySha = /* @__PURE__ */ new Map();
|
|
16225
|
+
log.entries.forEach((entry, i) => {
|
|
16226
|
+
if (!positionBySha.has(entry.sha)) positionBySha.set(entry.sha, i);
|
|
16227
|
+
});
|
|
16228
|
+
const out = {};
|
|
16229
|
+
for (const spec of specs) {
|
|
16230
|
+
const coords = {
|
|
16231
|
+
lastRun: ledger.run[spec.key] ?? null,
|
|
16232
|
+
lastGreen: ledger.green[spec.key] ?? null,
|
|
16233
|
+
lastRed: ledger.red[spec.key] ?? null
|
|
16234
|
+
};
|
|
16235
|
+
out[spec.key] = notEvaluated ? {
|
|
16236
|
+
state: "notEvaluated",
|
|
16237
|
+
...coords
|
|
16238
|
+
} : {
|
|
16239
|
+
...verdict(spec, coords.lastRun, log, positionBySha, touchIndex),
|
|
16240
|
+
...coords
|
|
16241
|
+
};
|
|
16242
|
+
}
|
|
16243
|
+
return out;
|
|
16244
|
+
}
|
|
16245
|
+
function verdict(spec, lastRun, log, positionBySha, touchIndex) {
|
|
16246
|
+
if (!lastRun) return { state: "neverRun" };
|
|
16247
|
+
if (spec.relatedPaths.length === 0) return unknown("noRelatedPaths");
|
|
16248
|
+
if (log.entries.length === 0) return unknown("noDeployLog");
|
|
16249
|
+
if (lastRun.deployedShaAmbiguous) return unknown("ambiguousDeployedSha");
|
|
16250
|
+
const deployedSha = lastRun.deployedSha ?? null;
|
|
16251
|
+
if (!deployedSha) return unknown("unknownDeployedSha");
|
|
16252
|
+
const baselinePos = positionBySha.get(deployedSha);
|
|
16253
|
+
if (baselinePos === void 0) return unknown("deployedShaNotInLog");
|
|
16254
|
+
const baselineIndex = log.entries[baselinePos].index;
|
|
16255
|
+
let sawGap = false;
|
|
16256
|
+
let sawUnknownContents = false;
|
|
16257
|
+
for (let i = log.entries.length - 1; i > baselinePos; i--) {
|
|
16258
|
+
const entry = log.entries[i];
|
|
16259
|
+
if (entry.changedPaths !== null && !entry.truncated) {
|
|
16260
|
+
const matched = matchPaths(entry.changedPaths, spec.relatedPaths);
|
|
16261
|
+
if (matched.length > 0) return {
|
|
16262
|
+
state: "needed",
|
|
16263
|
+
touchedBy: matched,
|
|
16264
|
+
touchedByDeploy: deployRef(entry)
|
|
16265
|
+
};
|
|
16266
|
+
} else sawUnknownContents = true;
|
|
16267
|
+
if (entry.gapBefore) sawGap = true;
|
|
16268
|
+
}
|
|
16269
|
+
if (!sawGap && !sawUnknownContents) return { state: "notNeeded" };
|
|
16270
|
+
const touch = touchIndex[spec.key];
|
|
16271
|
+
if (touch && touch.lastTouchedIndex > baselineIndex) {
|
|
16272
|
+
const entry = log.entries.find((e) => e.index === touch.lastTouchedIndex);
|
|
16273
|
+
return {
|
|
16274
|
+
state: "needed",
|
|
16275
|
+
...touch.matchedPaths.length > 0 ? { touchedBy: touch.matchedPaths } : {},
|
|
16276
|
+
touchedByDeploy: entry ? deployRef(entry) : null
|
|
16277
|
+
};
|
|
16278
|
+
}
|
|
16279
|
+
return unknown(sawGap ? "gapInRange" : "truncatedInRange");
|
|
16280
|
+
}
|
|
16281
|
+
function deployRef(entry) {
|
|
16282
|
+
return {
|
|
16283
|
+
index: entry.index,
|
|
16284
|
+
sha: entry.sha,
|
|
16285
|
+
at: entry.at
|
|
16286
|
+
};
|
|
16287
|
+
}
|
|
16288
|
+
function unknown(reason) {
|
|
16289
|
+
return {
|
|
16290
|
+
state: "unknown",
|
|
16291
|
+
reason
|
|
16292
|
+
};
|
|
16293
|
+
}
|
|
16294
|
+
//#endregion
|
|
16295
|
+
//#region src/hub/api/handlers/rerun.ts
|
|
16296
|
+
/**
|
|
16297
|
+
* GET /api/v1/projects/:project/rerun?profile=
|
|
16298
|
+
*
|
|
16299
|
+
* Per spec: is its last result still trustworthy? Set arithmetic over the spec
|
|
16300
|
+
* ledger, the profile's deploy log and each spec's `relatedPaths` (ADR-0010).
|
|
16301
|
+
* The ledger is read across every branch: a run exercises the deployed
|
|
16302
|
+
* environment whatever branch its code came from.
|
|
16303
|
+
*/
|
|
16304
|
+
function createGetRerunHandler(storage) {
|
|
16305
|
+
return async (ctx) => {
|
|
16306
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
16307
|
+
const profile = requireProfileParam(ctx.url);
|
|
16308
|
+
const [specs, ledger, log, touchIndex] = await Promise.all([
|
|
16309
|
+
loadSpecTargets(storage.perspectives, project),
|
|
16310
|
+
storage.ledger.getMerged(project, profile),
|
|
16311
|
+
storage.deploys.getLog(project, profile),
|
|
16312
|
+
storage.deploys.getTouchIndex(project, profile)
|
|
16313
|
+
]);
|
|
16314
|
+
if (specs === null) throw new HttpError(404, "no_perspectives", `no perspectives stored for project "${project}" — push one with \`ccqa perspectives\` before asking which specs need a re-run`);
|
|
16315
|
+
const head = log.entries[log.entries.length - 1];
|
|
16316
|
+
sendJson(ctx.res, 200, {
|
|
16317
|
+
project,
|
|
16318
|
+
profile,
|
|
16319
|
+
deployHead: head ? {
|
|
16320
|
+
index: head.index,
|
|
16321
|
+
sha: head.sha,
|
|
16322
|
+
at: head.at
|
|
16323
|
+
} : null,
|
|
16324
|
+
specs: computeRerun({
|
|
16325
|
+
specs,
|
|
16326
|
+
ledger,
|
|
16327
|
+
log,
|
|
16328
|
+
touchIndex
|
|
16329
|
+
})
|
|
16330
|
+
});
|
|
15364
16331
|
};
|
|
15365
16332
|
}
|
|
15366
16333
|
//#endregion
|
|
@@ -15981,15 +16948,29 @@ const HTML_BODY = `
|
|
|
15981
16948
|
<p id="persp-status" class="empty-note" hidden></p>
|
|
15982
16949
|
<div id="persp-body" hidden>
|
|
15983
16950
|
<div class="ov" id="persp-ov"></div>
|
|
16951
|
+
<div class="note info persp-note" id="persp-rerun-note" hidden></div>
|
|
15984
16952
|
<div class="toolbar">
|
|
15985
16953
|
<label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg><input id="persp-q" type="search" data-i18n-ph="perspectives.search" aria-label="Search cases"></label>
|
|
15986
|
-
|
|
15987
|
-
|
|
15988
|
-
|
|
15989
|
-
<button class="fchip" data-f="
|
|
16954
|
+
<!-- Each chip carries the count of what it would leave behind, so
|
|
16955
|
+
data-i18n sits on the inner label span: applyStaticI18n swaps
|
|
16956
|
+
textContent, which on the button would delete the count. -->
|
|
16957
|
+
<button class="fchip" data-f="all" aria-pressed="true" type="button"><span data-i18n="perspectives.filter.all">All</span><span class="fcount"></span></button>
|
|
16958
|
+
<button class="fchip" data-f="deterministic" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.deterministic">Deterministic</span><span class="fcount"></span></button>
|
|
16959
|
+
<button class="fchip" data-f="live" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.live">Live</span><span class="fcount"></span></button>
|
|
16960
|
+
<button class="fchip" data-f="norec" aria-pressed="false" type="button"><span data-i18n="perspectives.filter.norec">Not recorded only</span><span class="fcount"></span></button>
|
|
16961
|
+
<button class="fchip" id="persp-chip-rerun" data-f="rerun" aria-pressed="false" type="button" hidden><span data-i18n="perspectives.filter.rerun">Needs re-run only</span><span class="fcount"></span></button>
|
|
16962
|
+
<div class="spacer"></div>
|
|
16963
|
+
<span class="muted persp-head" id="persp-deploy-head" hidden></span>
|
|
16964
|
+
<div class="sw-wrap" id="persp-profile-wrap">
|
|
16965
|
+
<button class="sw-btn" id="persp-profile-switch" type="button" aria-haspopup="menu" aria-expanded="false">
|
|
16966
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
16967
|
+
<span class="k" data-i18n="app.profile">profile</span> <span class="v" id="persp-profile-current">default</span> <span class="chev">▾</span>
|
|
16968
|
+
</button>
|
|
16969
|
+
<div class="proj-menu right" id="persp-profile-menu" role="menu" hidden></div>
|
|
16970
|
+
</div>
|
|
15990
16971
|
</div>
|
|
15991
16972
|
<div class="tblcard"><div class="table-wrap"><table>
|
|
15992
|
-
<thead><tr><th data-i18n="perspectives.col.case">Case</th><th data-i18n="perspectives.col.mode">Mode</th><th data-i18n="perspectives.col.status">Status</th><th></th></tr></thead>
|
|
16973
|
+
<thead><tr><th data-i18n="perspectives.col.case">Case</th><th data-i18n="perspectives.col.mode">Mode</th><th data-i18n="perspectives.col.status">Status</th><th id="persp-th-result" data-i18n="perspectives.col.lastResult" hidden>Last result</th><th id="persp-th-rerun" data-i18n="perspectives.col.rerun" hidden>Needs re-run</th><th></th></tr></thead>
|
|
15993
16974
|
<tbody id="persp-tbody"></tbody>
|
|
15994
16975
|
</table></div></div>
|
|
15995
16976
|
<p class="empty-note" id="persp-no-hit" hidden data-i18n="perspectives.noHit">No matching cases.</p>
|
|
@@ -16173,6 +17154,11 @@ const CSS = `
|
|
|
16173
17154
|
--fail: #dc2626; --fail-bg: #fef2f2; --fail-border: #fecaca;
|
|
16174
17155
|
--info: #2563eb; --info-bg: #eff6ff; --info-border: #bfdbfe;
|
|
16175
17156
|
--amber: #a16207; --amber-bg: #fefce8; --amber-border: #fde68a;
|
|
17157
|
+
/* Fill counterpart of --amber. The token above is tuned for *text* on
|
|
17158
|
+
--amber-bg, so at swatch size it reads brown; a filled area needs the
|
|
17159
|
+
actual yellow the badge is understood to mean. Same value in both
|
|
17160
|
+
themes, since a fill has no contrast-on-background constraint. */
|
|
17161
|
+
--amber-fill: #eab308;
|
|
16176
17162
|
--violet: #7c3aed; --violet-bg: #f5f3ff; --violet-border: #ddd6fe;
|
|
16177
17163
|
--radius: 10px; --radius-md: 8px; --radius-sm: 6px;
|
|
16178
17164
|
--shadow: 0 10px 38px -10px rgba(0,0,0,0.20), 0 4px 12px -4px rgba(0,0,0,0.10);
|
|
@@ -16190,6 +17176,7 @@ const CSS = `
|
|
|
16190
17176
|
--fail: #f87171; --fail-bg: rgba(248,113,113,0.10); --fail-border: rgba(248,113,113,0.25);
|
|
16191
17177
|
--info: #60a5fa; --info-bg: rgba(96,165,250,0.10); --info-border: rgba(96,165,250,0.25);
|
|
16192
17178
|
--amber: #eab308; --amber-bg: rgba(234,179,8,0.10); --amber-border: rgba(234,179,8,0.25);
|
|
17179
|
+
--amber-fill: #eab308;
|
|
16193
17180
|
--violet: #a78bfa; --violet-bg: rgba(167,139,250,0.10); --violet-border: rgba(167,139,250,0.25);
|
|
16194
17181
|
--shadow: 0 10px 38px -10px rgba(0,0,0,0.6), 0 4px 12px -4px rgba(0,0,0,0.4);
|
|
16195
17182
|
}
|
|
@@ -16633,30 +17620,39 @@ const CSS = `
|
|
|
16633
17620
|
.prompt-diff pre { margin: 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2);
|
|
16634
17621
|
font-family: var(--mono); font-size: 11px; line-height: 1.5; color: var(--fg-dim); white-space: pre-wrap; word-break: break-word; max-height: 480px; overflow-y: auto; }
|
|
16635
17622
|
|
|
16636
|
-
/* perspectives —
|
|
17623
|
+
/* perspectives — summary row, filter toolbar, and the one-table-per-project
|
|
16637
17624
|
view (feature section rows + expandable case detail rows). Reuses the
|
|
16638
|
-
existing badge/chip primitives above
|
|
16639
|
-
|
|
16640
|
-
|
|
16641
|
-
|
|
16642
|
-
|
|
16643
|
-
|
|
16644
|
-
.ov
|
|
16645
|
-
.ov
|
|
16646
|
-
.
|
|
16647
|
-
.
|
|
16648
|
-
.
|
|
16649
|
-
.
|
|
16650
|
-
.
|
|
16651
|
-
.
|
|
16652
|
-
|
|
16653
|
-
|
|
17625
|
+
existing badge/chip primitives above, so no new tokens are needed.
|
|
17626
|
+
|
|
17627
|
+
The summary row answers the question this tab exists for — which cases
|
|
17628
|
+
need re-running — as one inventory line plus one bar segmented by re-run
|
|
17629
|
+
state. The mode and recorded-ness counts moved onto the filter chips,
|
|
17630
|
+
which is where a count says something actionable. */
|
|
17631
|
+
.ov { display: flex; flex-direction: column; gap: 10px; padding: 14px 18px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-bottom: 16px; }
|
|
17632
|
+
.ov-inv { font-size: 13px; color: var(--muted); }
|
|
17633
|
+
.ov-inv b { color: var(--fg); font-size: 15px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
17634
|
+
.rrbar { height: 8px; border-radius: 999px; overflow: hidden; display: flex; background: var(--surface-3); }
|
|
17635
|
+
.rrleg { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 12px; color: var(--muted); }
|
|
17636
|
+
.rrleg span { display: inline-flex; align-items: center; gap: 6px; }
|
|
17637
|
+
.rrleg i { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
|
17638
|
+
.rrleg b { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
17639
|
+
/* One class per re-run state, worn by both the bar segment and its legend
|
|
17640
|
+
dot. "unknown" takes the info hue: it must never be mistaken for a pass,
|
|
17641
|
+
and the two neutral states are two different greys so adjacent segments
|
|
17642
|
+
stay separable. */
|
|
17643
|
+
.sg-needed { background: var(--amber-fill); }
|
|
17644
|
+
.sg-unknown { background: var(--info); }
|
|
17645
|
+
.sg-notneeded { background: var(--pass); }
|
|
17646
|
+
.sg-neverrun { background: var(--muted-2); }
|
|
17647
|
+
.sg-noteval { background: var(--muted); }
|
|
16654
17648
|
|
|
16655
17649
|
.search { flex: 1; min-width: 200px; max-width: 340px; display: flex; align-items: center; gap: 7px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 0 10px; height: 32px; background: var(--surface); }
|
|
16656
17650
|
.search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
|
|
16657
17651
|
.search input { border: none; outline: none; font: inherit; font-size: 13px; width: 100%; background: transparent; color: var(--fg); }
|
|
16658
17652
|
.fchip { border: 1px solid var(--border-strong); background: var(--surface); border-radius: 999px; padding: 5px 12px; font-size: 12.5px; color: var(--muted); }
|
|
16659
17653
|
.fchip[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
|
17654
|
+
.fchip .fcount { margin-left: 6px; font-variant-numeric: tabular-nums; color: var(--muted-2); }
|
|
17655
|
+
.fchip[aria-pressed="true"] .fcount { color: var(--accent-fg); opacity: 0.7; }
|
|
16660
17656
|
|
|
16661
17657
|
.chip.live { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
|
|
16662
17658
|
.badge.ok { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
|
|
@@ -16664,27 +17660,73 @@ const CSS = `
|
|
|
16664
17660
|
.badge.norec { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
16665
17661
|
.badge.norec .d { background: var(--amber); }
|
|
16666
17662
|
|
|
17663
|
+
/* "Needs re-run" (ADR-0010). Four distinct looks on purpose: rr-unknown must
|
|
17664
|
+
never be mistaken for rr-notneeded, so it takes the info hue rather than a
|
|
17665
|
+
dimmed green, and every badge is paired with a .cellsub saying what the
|
|
17666
|
+
verdict rests on. */
|
|
17667
|
+
.badge.rr-needed { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
17668
|
+
.badge.rr-needed .d { background: var(--amber); }
|
|
17669
|
+
.badge.rr-notneeded { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
|
|
17670
|
+
.badge.rr-notneeded .d { background: var(--pass); }
|
|
17671
|
+
.badge.rr-unknown { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
|
|
17672
|
+
.badge.rr-unknown .d { background: var(--info); }
|
|
17673
|
+
.badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
17674
|
+
.badge.rr-none .d { background: var(--muted); }
|
|
17675
|
+
.cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
|
|
17676
|
+
.cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
|
|
17677
|
+
.cellsub a:hover { color: var(--fg); }
|
|
17678
|
+
.persp-note { margin-bottom: 12px; }
|
|
17679
|
+
.persp-head { font-size: 12px; white-space: nowrap; }
|
|
17680
|
+
/* The Perspectives toolbar carries search + five chips + the profile
|
|
17681
|
+
selector, so it wraps instead of overflowing on a narrow window. */
|
|
17682
|
+
#view-perspectives .toolbar { flex-wrap: wrap; }
|
|
17683
|
+
.proj-menu.right { left: auto; right: 0; }
|
|
17684
|
+
.d-note { margin-top: 12px; max-width: 900px; }
|
|
17685
|
+
|
|
16667
17686
|
.tblcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
17687
|
+
/* Badges across a case row must land on one line. Some of these cells carry
|
|
17688
|
+
only a badge, others a badge plus a sub-line (sha · when) or a two-line
|
|
17689
|
+
explanation, so middle-aligning the cells put each badge at a different
|
|
17690
|
+
height. Top-aligning them fixes where the first line starts; a 24px first
|
|
17691
|
+
line in the cell and a 24px badge box, both centred, fix where the text
|
|
17692
|
+
inside it sits — so a badge's Y offset no longer depends on what follows
|
|
17693
|
+
it. Sub-lines re-declare their own tighter leading. */
|
|
17694
|
+
#persp-tbody td { vertical-align: top; }
|
|
17695
|
+
#persp-tbody tr.row > td { line-height: 24px; }
|
|
17696
|
+
#persp-tbody tr.row > td .chip, #persp-tbody tr.row > td .badge { vertical-align: top; min-height: 24px; line-height: 18px; }
|
|
16668
17697
|
/* Feature section rows must read as headings, not as just another data row —
|
|
16669
17698
|
larger, darker, extra padding, and a strong top rule marking the break. */
|
|
16670
17699
|
tr.grp td { background: var(--surface-2); border-top: 2px solid var(--border-strong); border-bottom: 1px solid var(--border); padding: 12px 12px 10px; font-family: var(--mono); font-size: 15px; font-weight: 700; color: var(--fg); }
|
|
16671
17700
|
tr.grp td .gcount { color: var(--muted); font-weight: 500; font-size: 12px; font-family: var(--font); margin-left: 10px; }
|
|
16672
17701
|
td.c-title { font-weight: 500; max-width: 460px; }
|
|
16673
|
-
td.c-title .csum { display: block; font-weight: 400; color: var(--muted); font-size: 12.5px; margin-top: 1px; }
|
|
17702
|
+
td.c-title .csum { display: block; font-weight: 400; color: var(--muted); font-size: 12.5px; line-height: 1.45; margin-top: 1px; }
|
|
16674
17703
|
td.c-chev { width: 28px; color: var(--muted-2); text-align: right; }
|
|
16675
17704
|
.chev-i { display: inline-block; transition: transform 0.15s; font-size: 11px; }
|
|
16676
17705
|
tr.row[aria-expanded="true"] .chev-i { transform: rotate(90deg); }
|
|
16677
17706
|
tr.detail { display: none; }
|
|
16678
17707
|
tr.detail.open { display: table-row; }
|
|
16679
|
-
|
|
16680
|
-
|
|
16681
|
-
|
|
17708
|
+
/* The panel is the row continuing, not a card under it: same surface, and no
|
|
17709
|
+
rule between a case and its own panel. The rule below the panel stays —
|
|
17710
|
+
that one separates this case from the next. The hover tint is dropped
|
|
17711
|
+
while open for the same reason; tinting only the top half would split the
|
|
17712
|
+
two apart again. */
|
|
17713
|
+
tr.detail > td { background: var(--surface); padding: 2px 16px 18px; }
|
|
17714
|
+
#persp-tbody tr.row[aria-expanded="true"] > td { border-bottom: 0; }
|
|
17715
|
+
#persp-tbody tr.row[aria-expanded="true"]:hover { background: var(--surface); }
|
|
17716
|
+
|
|
17717
|
+
.d-grid { display: grid; grid-template-columns: 156px 1fr; gap: 9px 14px; font-size: 13px; max-width: 900px; }
|
|
16682
17718
|
.d-grid dt { color: var(--muted); font-size: 12px; padding-top: 1px; }
|
|
16683
17719
|
.d-grid dd { color: var(--fg-dim); }
|
|
16684
17720
|
.d-grid dd ul { list-style: none; display: flex; flex-direction: column; gap: 3px; margin: 0; padding: 0; }
|
|
16685
17721
|
.d-grid dd li::before { content: "\\2022 "; color: var(--muted-2); }
|
|
16686
|
-
.d-grid code { font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
|
|
16687
|
-
|
|
17722
|
+
.d-grid code { font-size: 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; }
|
|
17723
|
+
/* Prose gets a measure so it stops wrapping mid-phrase in a narrow column;
|
|
17724
|
+
paths wrap as whole chips, never inside a path. */
|
|
17725
|
+
.d-prose { max-width: 62ch; line-height: 1.5; }
|
|
17726
|
+
.d-paths { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
17727
|
+
.d-paths code { white-space: nowrap; }
|
|
17728
|
+
.d-prose + .d-paths { margin-top: 6px; }
|
|
17729
|
+
.notebox { margin-top: 14px; max-width: 900px; }
|
|
16688
17730
|
.notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
16689
17731
|
.notebox textarea { width: 100%; min-height: 54px; resize: vertical; font: inherit; font-size: 13px; color: var(--fg-dim); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 8px 10px; }
|
|
16690
17732
|
.notebox .nact { margin-top: 6px; display: flex; align-items: center; gap: 8px; }
|
|
@@ -16766,6 +17808,8 @@ const CLIENT_JS = `
|
|
|
16766
17808
|
"perspectives.search": "Search cases…",
|
|
16767
17809
|
"perspectives.filter.all": "All", "perspectives.filter.deterministic": "Deterministic",
|
|
16768
17810
|
"perspectives.filter.live": "Live", "perspectives.filter.norec": "Not recorded only",
|
|
17811
|
+
"perspectives.filter.rerun": "Needs re-run only",
|
|
17812
|
+
"perspectives.col.lastResult": "Last result", "perspectives.col.rerun": "Needs re-run",
|
|
16769
17813
|
"perspectives.col.case": "Case", "perspectives.col.mode": "Mode", "perspectives.col.status": "Status",
|
|
16770
17814
|
"perspectives.noHit": "No matching cases.",
|
|
16771
17815
|
"perspectives.updated": "Last updated:",
|
|
@@ -16773,9 +17817,7 @@ const CLIENT_JS = `
|
|
|
16773
17817
|
"perspectives.loadFailed": "Loading perspectives failed",
|
|
16774
17818
|
"perspectives.mode.deterministic": "deterministic", "perspectives.mode.live": "live",
|
|
16775
17819
|
"perspectives.status.runnable": "runnable", "perspectives.status.notRecorded": "not recorded",
|
|
16776
|
-
"perspectives.
|
|
16777
|
-
"perspectives.metric.deterministic": "Deterministic", "perspectives.metric.live": "Live",
|
|
16778
|
-
"perspectives.cov.runnable": "runnable", "perspectives.cov.notRecorded": "not recorded",
|
|
17820
|
+
"perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
|
|
16779
17821
|
"perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
|
|
16780
17822
|
"perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
|
|
16781
17823
|
"perspectives.d.relatedPaths": "Related code",
|
|
@@ -16783,6 +17825,47 @@ const CLIENT_JS = `
|
|
|
16783
17825
|
"perspectives.note.placeholder": "Notes about this case…",
|
|
16784
17826
|
"perspectives.note.saved": "Saved",
|
|
16785
17827
|
"perspectives.note.error": "Could not save — retry",
|
|
17828
|
+
"perspectives.d.lastRed": "Most recent failure",
|
|
17829
|
+
"perspectives.d.changedSince": "Changes since the last run",
|
|
17830
|
+
"perspectives.d.cannotJudge": "Why this cannot be judged",
|
|
17831
|
+
"perspectives.result.never": "never run",
|
|
17832
|
+
"perspectives.result.openRun": "Open this run in the hub",
|
|
17833
|
+
"perspectives.result.ci": "CI",
|
|
17834
|
+
"perspectives.rerun.state.needed": "Re-run needed",
|
|
17835
|
+
"perspectives.rerun.state.notNeeded": "Not needed",
|
|
17836
|
+
"perspectives.rerun.state.unknown": "Unknown",
|
|
17837
|
+
"perspectives.rerun.state.neverRun": "Never run",
|
|
17838
|
+
"perspectives.rerun.state.notEvaluated": "Not evaluated",
|
|
17839
|
+
"perspectives.rerun.vsDeploy": "judged against deploy",
|
|
17840
|
+
"perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
|
|
17841
|
+
"perspectives.rerun.changedByDeploy": "deploy {sha} changed its related code",
|
|
17842
|
+
"perspectives.rerun.changesSome": "yes (as of deploy {sha})",
|
|
17843
|
+
"perspectives.rerun.changesNone": "none (as of deploy {sha})",
|
|
17844
|
+
"perspectives.rerun.touchedCount": "{n} deployed path(s) matched its related code",
|
|
17845
|
+
"perspectives.rerun.touchedUnknown": "a deploy since the last run touched its related code",
|
|
17846
|
+
"perspectives.rerun.neverRunHint": "no result recorded for this profile yet",
|
|
17847
|
+
"perspectives.rerun.notEvaluatedHint": "no run and no deploy has ever been recorded for this profile",
|
|
17848
|
+
"perspectives.rerun.why.noRelatedPaths": "no related code declared",
|
|
17849
|
+
"perspectives.rerun.why.noDeployLog": "no deploy log for this profile",
|
|
17850
|
+
"perspectives.rerun.why.unknownDeployedSha": "the last run's deployed commit is unknown",
|
|
17851
|
+
"perspectives.rerun.why.ambiguousDeployedSha": "a deploy landed while the last run was executing",
|
|
17852
|
+
"perspectives.rerun.why.deployedShaNotInLog": "the last run's commit predates the retained deploy log",
|
|
17853
|
+
"perspectives.rerun.why.gapInRange": "deploys are missing from the range",
|
|
17854
|
+
"perspectives.rerun.why.truncatedInRange": "a deploy in range did not report what it changed",
|
|
17855
|
+
"perspectives.rerun.why.unrecognized": "this hub reported a reason this UI does not recognise",
|
|
17856
|
+
"perspectives.rerun.fix.noRelatedPaths": "This case declares no related code, so no deploy can be matched against it. Add relatedPaths to its spec.yaml.",
|
|
17857
|
+
"perspectives.rerun.fix.noDeployLog": "Nothing has been recorded in this profile's deploy log. Wire ccqa hub deploy record into the deploy job for this environment so ccqa knows what shipped.",
|
|
17858
|
+
"perspectives.rerun.fix.unknownDeployedSha": "The last run did not record which commit the environment was running, so it cannot be positioned in the deploy log. Runs record it once this profile has a deploy log.",
|
|
17859
|
+
"perspectives.rerun.fix.ambiguousDeployedSha": "A deploy landed while the last run was executing, so which commit it exercised is not knowable. Re-run this case to get a clean baseline.",
|
|
17860
|
+
"perspectives.rerun.fix.deployedShaNotInLog": "The last run's deployed commit is older than the retained deploy log, so its position is lost. Re-run this case to re-anchor it.",
|
|
17861
|
+
"perspectives.rerun.fix.gapInRange": "A deploy in range did not chain onto its predecessor, so deploys are missing from the range. Have the deploy job report the commit it replaced.",
|
|
17862
|
+
"perspectives.rerun.fix.truncatedInRange": "A deploy in range did not report what it changed, so its contents are not knowable. Have the deploy job send the changed paths of a two-dot diff.",
|
|
17863
|
+
"perspectives.rerun.fix.unrecognized": "This hub reported a reason this UI does not recognise. Upgrade the UI to see what it means.",
|
|
17864
|
+
"perspectives.rerun.unsupported": "This hub does not report which cases need a re-run. Upgrade the hub to enable it.",
|
|
17865
|
+
"perspectives.rerun.loadFailed": "Loading re-run data failed",
|
|
17866
|
+
"perspectives.rerun.noDeployLogBanner": "No deploy has been recorded for profile {profile}, so no case can be judged. Wire ccqa hub deploy record into the deploy job for this environment.",
|
|
17867
|
+
"perspectives.rerun.deployHead": "deploy head",
|
|
17868
|
+
"perspectives.dq.unmatched": "{n} of this case's related-code patterns matched no file when perspectives were generated — a not-needed verdict may rest on paths that no longer exist.",
|
|
16786
17869
|
"prompt.card.record": "Recording browser actions",
|
|
16787
17870
|
"prompt.card.live": "Live run (AI-driven)",
|
|
16788
17871
|
"prompt.card.playwright": "Playwright test generation",
|
|
@@ -16864,6 +17947,8 @@ const CLIENT_JS = `
|
|
|
16864
17947
|
"perspectives.search": "ケースを検索…",
|
|
16865
17948
|
"perspectives.filter.all": "すべて", "perspectives.filter.deterministic": "決定的",
|
|
16866
17949
|
"perspectives.filter.live": "ライブ", "perspectives.filter.norec": "未recordのみ",
|
|
17950
|
+
"perspectives.filter.rerun": "要再実行のみ",
|
|
17951
|
+
"perspectives.col.lastResult": "前回結果", "perspectives.col.rerun": "再実行の要否",
|
|
16867
17952
|
"perspectives.col.case": "ケース", "perspectives.col.mode": "モード", "perspectives.col.status": "状態",
|
|
16868
17953
|
"perspectives.noHit": "該当するケースがありません。",
|
|
16869
17954
|
"perspectives.updated": "最終更新:",
|
|
@@ -16871,9 +17956,7 @@ const CLIENT_JS = `
|
|
|
16871
17956
|
"perspectives.loadFailed": "テスト観点の読み込みに失敗しました",
|
|
16872
17957
|
"perspectives.mode.deterministic": "決定的", "perspectives.mode.live": "ライブ",
|
|
16873
17958
|
"perspectives.status.runnable": "実行可能", "perspectives.status.notRecorded": "未record",
|
|
16874
|
-
"perspectives.
|
|
16875
|
-
"perspectives.metric.deterministic": "決定的", "perspectives.metric.live": "ライブ",
|
|
16876
|
-
"perspectives.cov.runnable": "実行可能", "perspectives.cov.notRecorded": "未record",
|
|
17959
|
+
"perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
|
|
16877
17960
|
"perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
|
|
16878
17961
|
"perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
|
|
16879
17962
|
"perspectives.d.relatedPaths": "関連コード",
|
|
@@ -16881,6 +17964,47 @@ const CLIENT_JS = `
|
|
|
16881
17964
|
"perspectives.note.placeholder": "このケースについてのメモ…",
|
|
16882
17965
|
"perspectives.note.saved": "保存しました",
|
|
16883
17966
|
"perspectives.note.error": "保存に失敗しました — 再試行してください",
|
|
17967
|
+
"perspectives.d.lastRed": "直近の失敗",
|
|
17968
|
+
"perspectives.d.changedSince": "前回実行以降の変更",
|
|
17969
|
+
"perspectives.d.cannotJudge": "判定できない理由",
|
|
17970
|
+
"perspectives.result.never": "未実行",
|
|
17971
|
+
"perspectives.result.openRun": "ハブでこの実行を開く",
|
|
17972
|
+
"perspectives.result.ci": "CI",
|
|
17973
|
+
"perspectives.rerun.state.needed": "要再実行",
|
|
17974
|
+
"perspectives.rerun.state.notNeeded": "不要",
|
|
17975
|
+
"perspectives.rerun.state.unknown": "不明",
|
|
17976
|
+
"perspectives.rerun.state.neverRun": "未実行",
|
|
17977
|
+
"perspectives.rerun.state.notEvaluated": "未評価",
|
|
17978
|
+
"perspectives.rerun.vsDeploy": "判定基準: デプロイ",
|
|
17979
|
+
"perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
|
|
17980
|
+
"perspectives.rerun.changedByDeploy": "デプロイ {sha} が関連コードを変更",
|
|
17981
|
+
"perspectives.rerun.changesSome": "あり(デプロイ {sha} 時点)",
|
|
17982
|
+
"perspectives.rerun.changesNone": "なし(デプロイ {sha} 時点)",
|
|
17983
|
+
"perspectives.rerun.touchedCount": "関連コードに一致したデプロイ差分 {n} 件",
|
|
17984
|
+
"perspectives.rerun.touchedUnknown": "前回実行以降のデプロイが関連コードを変更しています",
|
|
17985
|
+
"perspectives.rerun.neverRunHint": "このプロファイルでの実行記録がまだありません",
|
|
17986
|
+
"perspectives.rerun.notEvaluatedHint": "このプロファイルには実行もデプロイも記録がありません",
|
|
17987
|
+
"perspectives.rerun.why.noRelatedPaths": "関連コードが未宣言です",
|
|
17988
|
+
"perspectives.rerun.why.noDeployLog": "このプロファイルのデプロイ記録がありません",
|
|
17989
|
+
"perspectives.rerun.why.unknownDeployedSha": "前回実行時にデプロイされていたcommitが不明です",
|
|
17990
|
+
"perspectives.rerun.why.ambiguousDeployedSha": "前回実行の途中でデプロイが発生しました",
|
|
17991
|
+
"perspectives.rerun.why.deployedShaNotInLog": "前回実行のcommitが保持中のデプロイログより古いです",
|
|
17992
|
+
"perspectives.rerun.why.gapInRange": "対象範囲のデプロイ記録が欠けています",
|
|
17993
|
+
"perspectives.rerun.why.truncatedInRange": "対象範囲に変更内容を報告していないデプロイがあります",
|
|
17994
|
+
"perspectives.rerun.why.unrecognized": "このUIが認識できない理由がハブから返されました",
|
|
17995
|
+
"perspectives.rerun.fix.noRelatedPaths": "このケースは関連コードを宣言していないため、デプロイと突き合わせられません。spec.yaml に relatedPaths を追加してください。",
|
|
17996
|
+
"perspectives.rerun.fix.noDeployLog": "このプロファイルのデプロイログに記録がありません。何がデプロイされたかをccqaに伝えるため、この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
|
|
17997
|
+
"perspectives.rerun.fix.unknownDeployedSha": "前回実行は環境で動いていたcommitを記録していないため、デプロイログ上の位置を決められません。このプロファイルにデプロイログができれば、以降の実行では記録されます。",
|
|
17998
|
+
"perspectives.rerun.fix.ambiguousDeployedSha": "前回実行の途中でデプロイが発生したため、どのcommitを検証したのか確定できません。基準を取り直すには再実行してください。",
|
|
17999
|
+
"perspectives.rerun.fix.deployedShaNotInLog": "前回実行のデプロイcommitが保持中のデプロイログより古く、位置を特定できません。再実行して基準を取り直してください。",
|
|
18000
|
+
"perspectives.rerun.fix.gapInRange": "対象範囲のデプロイが直前のデプロイと連結しておらず、記録が欠けています。デプロイジョブから置き換え前のcommitも送ってください。",
|
|
18001
|
+
"perspectives.rerun.fix.truncatedInRange": "対象範囲に変更内容を報告していないデプロイがあり、何が変わったのか確定できません。デプロイジョブから two-dot diff の変更パスを送ってください。",
|
|
18002
|
+
"perspectives.rerun.fix.unrecognized": "このUIが認識できない理由がハブから返されました。内容を表示するにはUIを更新してください。",
|
|
18003
|
+
"perspectives.rerun.unsupported": "このハブは再実行の要否を返しません。利用するにはハブを更新してください。",
|
|
18004
|
+
"perspectives.rerun.loadFailed": "再実行の要否の読み込みに失敗しました",
|
|
18005
|
+
"perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
|
|
18006
|
+
"perspectives.rerun.deployHead": "最新デプロイ",
|
|
18007
|
+
"perspectives.dq.unmatched": "このケースの関連コードのうち {n} 件のパターンが、テスト観点の生成時にどのファイルにも一致しませんでした。不要という判定が、すでに存在しないパスに基づいている可能性があります。",
|
|
16884
18008
|
"prompt.card.record": "ブラウザ操作の記録",
|
|
16885
18009
|
"prompt.card.live": "ライブ実行(AI操作)",
|
|
16886
18010
|
"prompt.card.playwright": "Playwrightテスト生成",
|
|
@@ -17097,6 +18221,10 @@ const CLIENT_JS = `
|
|
|
17097
18221
|
return days + "d ago";
|
|
17098
18222
|
}
|
|
17099
18223
|
|
|
18224
|
+
function shortSha(sha) {
|
|
18225
|
+
return sha ? String(sha).slice(0, 7) : "";
|
|
18226
|
+
}
|
|
18227
|
+
|
|
17100
18228
|
function statusBadge(status) {
|
|
17101
18229
|
var span = el("span", "badge " + status);
|
|
17102
18230
|
span.appendChild(el("span", "d"));
|
|
@@ -17153,6 +18281,41 @@ const CLIENT_JS = `
|
|
|
17153
18281
|
return svg;
|
|
17154
18282
|
}
|
|
17155
18283
|
|
|
18284
|
+
// Round caps so the "i"/"!" dot (a zero-length segment) actually paints as a
|
|
18285
|
+
// filled dot instead of vanishing under a butt cap at small sizes.
|
|
18286
|
+
function svgRounded() {
|
|
18287
|
+
var svg = svgIcon();
|
|
18288
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
18289
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
18290
|
+
return svg;
|
|
18291
|
+
}
|
|
18292
|
+
|
|
18293
|
+
// The two note glyphs, matching the inline SVGs the static .note markup uses.
|
|
18294
|
+
function svgInfo() {
|
|
18295
|
+
var svg = svgRounded();
|
|
18296
|
+
var c = document.createElementNS(SVG_NS, "circle");
|
|
18297
|
+
c.setAttribute("cx", "12"); c.setAttribute("cy", "12"); c.setAttribute("r", "10");
|
|
18298
|
+
svg.appendChild(c);
|
|
18299
|
+
svg.appendChild(svgPath("M12 16v-4M12 8h.01"));
|
|
18300
|
+
return svg;
|
|
18301
|
+
}
|
|
18302
|
+
function svgWarn() {
|
|
18303
|
+
var svg = svgRounded();
|
|
18304
|
+
svg.appendChild(svgPath("M12 9v4M12 17h.01"));
|
|
18305
|
+
svg.appendChild(svgPath("M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"));
|
|
18306
|
+
return svg;
|
|
18307
|
+
}
|
|
18308
|
+
|
|
18309
|
+
// Fill an element as a .note box (the static ones live in HTML_BODY). kind is
|
|
18310
|
+
// "info" or "warn"; the text is always textContent, never innerHTML.
|
|
18311
|
+
function fillNote(box, kind, text, extraClass) {
|
|
18312
|
+
clear(box);
|
|
18313
|
+
box.className = "note " + kind + (extraClass ? " " + extraClass : "");
|
|
18314
|
+
box.appendChild(kind === "warn" ? svgWarn() : svgInfo());
|
|
18315
|
+
box.appendChild(el("span", null, text));
|
|
18316
|
+
return box;
|
|
18317
|
+
}
|
|
18318
|
+
|
|
17156
18319
|
// ── view routing ────────────────────────────────────────────────────
|
|
17157
18320
|
|
|
17158
18321
|
var VIEWS = ["projects", "runs", "detail", "perspectives", "secrets", "prompts", "jobs"];
|
|
@@ -18466,16 +19629,7 @@ const CLIENT_JS = `
|
|
|
18466
19629
|
var span = el("span", "info");
|
|
18467
19630
|
span.tabIndex = 0;
|
|
18468
19631
|
span.setAttribute("role", "note");
|
|
18469
|
-
|
|
18470
|
-
// Round caps so the "i" dot (a zero-length segment) actually paints as a
|
|
18471
|
-
// filled dot instead of vanishing under a butt cap at small sizes.
|
|
18472
|
-
svg.setAttribute("stroke-linecap", "round");
|
|
18473
|
-
svg.setAttribute("stroke-linejoin", "round");
|
|
18474
|
-
var c = document.createElementNS(SVG_NS, "circle");
|
|
18475
|
-
c.setAttribute("cx", "12"); c.setAttribute("cy", "12"); c.setAttribute("r", "10");
|
|
18476
|
-
svg.appendChild(c);
|
|
18477
|
-
svg.appendChild(svgPath("M12 16v-4M12 8h.01"));
|
|
18478
|
-
span.appendChild(svg);
|
|
19632
|
+
span.appendChild(svgInfo());
|
|
18479
19633
|
span.appendChild(el("span", "tip", hintText));
|
|
18480
19634
|
return span;
|
|
18481
19635
|
}
|
|
@@ -18549,7 +19703,16 @@ const CLIENT_JS = `
|
|
|
18549
19703
|
// whole document is fetched once per view-open and filtered/rendered
|
|
18550
19704
|
// client-side — small enough that there is no pagination.
|
|
18551
19705
|
|
|
18552
|
-
|
|
19706
|
+
// "rerun" is the RerunReport for the currently selected profile, or null when
|
|
19707
|
+
// this hub can't answer (older hub, or the fetch failed) — in which case the
|
|
19708
|
+
// two re-run columns are dropped rather than filled with blanks.
|
|
19709
|
+
// "rerunSupported" is tri-state: null until the first answer, then whether
|
|
19710
|
+
// this hub answers at all. Chip visibility follows it rather than the report,
|
|
19711
|
+
// so switching profile doesn't drop the filter while the next one loads.
|
|
19712
|
+
var perspState = {
|
|
19713
|
+
doc: null, q: "", f: "all",
|
|
19714
|
+
rerun: null, rerunSupported: null, runUrls: {}, rerunProfiles: []
|
|
19715
|
+
};
|
|
18553
19716
|
|
|
18554
19717
|
function perspectivesPath() {
|
|
18555
19718
|
return "/api/v1/projects/" + encodeURIComponent(state.project) + "/perspectives";
|
|
@@ -18566,6 +19729,272 @@ const CLIENT_JS = `
|
|
|
18566
19729
|
}, function () { throw new Error("Network unreachable — check the hub URL and your connection"); });
|
|
18567
19730
|
}
|
|
18568
19731
|
|
|
19732
|
+
// ── perspectives: needs re-run (ADR-0010) ─────────────────────────────
|
|
19733
|
+
// "Is this case's last result still trustworthy?" — mechanical, no model
|
|
19734
|
+
// call, and a different question from drift ("does the case still describe
|
|
19735
|
+
// the product"). The two vocabularies stay apart on purpose: this column
|
|
19736
|
+
// never says stale or fresh, and never borrows drift's wording.
|
|
19737
|
+
//
|
|
19738
|
+
// The verdict is per (project, profile): two environments sit at different
|
|
19739
|
+
// commits, so it has no profile-free answer — hence the profile selector in
|
|
19740
|
+
// this tab's toolbar.
|
|
19741
|
+
|
|
19742
|
+
function rerunPath() {
|
|
19743
|
+
return "/api/v1/projects/" + encodeURIComponent(state.project) +
|
|
19744
|
+
"/rerun?profile=" + encodeURIComponent(state.profile);
|
|
19745
|
+
}
|
|
19746
|
+
|
|
19747
|
+
// Resolves { report } or { note } and never rejects: a hub that predates
|
|
19748
|
+
// this endpoint costs the two extra columns, not the whole tab. A 404 here
|
|
19749
|
+
// can only mean "no such route" — the endpoint's own 404 is "the project has
|
|
19750
|
+
// no perspectives document", and this runs only after that document loaded.
|
|
19751
|
+
function fetchRerun() {
|
|
19752
|
+
return fetch(rerunPath(), { headers: { Authorization: "Bearer " + state.token } }).then(function (res) {
|
|
19753
|
+
if (res.status === 404) return { note: t("perspectives.rerun.unsupported"), kind: "info" };
|
|
19754
|
+
if (!res.ok) return { note: t("perspectives.rerun.loadFailed") + ": " + res.status + " " + res.statusText, kind: "warn" };
|
|
19755
|
+
return res.json().then(function (report) { return { report: report }; }, function () {
|
|
19756
|
+
return { note: t("perspectives.rerun.loadFailed"), kind: "warn" };
|
|
19757
|
+
});
|
|
19758
|
+
}, function () {
|
|
19759
|
+
return { note: t("perspectives.rerun.loadFailed"), kind: "warn" };
|
|
19760
|
+
});
|
|
19761
|
+
}
|
|
19762
|
+
|
|
19763
|
+
// The ledger records a runId but no link, and the profile list the Secrets
|
|
19764
|
+
// tab keeps is the wrong set here (a profile that only has variables has no
|
|
19765
|
+
// runs and no deploys to judge). One runs page answers both: runId -> CI URL,
|
|
19766
|
+
// and the profiles a run was actually recorded under. A run pushed without a
|
|
19767
|
+
// profile lands in "default", exactly as the ledger stores it.
|
|
19768
|
+
function fetchRunIndex() {
|
|
19769
|
+
return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&limit=200")
|
|
19770
|
+
.then(function (data) {
|
|
19771
|
+
var urls = {};
|
|
19772
|
+
var profiles = [];
|
|
19773
|
+
((data && data.runs) || []).forEach(function (r) {
|
|
19774
|
+
if (r.runUrl) urls[r.id] = r.runUrl;
|
|
19775
|
+
var p = r.profile || "default";
|
|
19776
|
+
if (profiles.indexOf(p) === -1) profiles.push(p);
|
|
19777
|
+
});
|
|
19778
|
+
return { urls: urls, profiles: profiles.sort() };
|
|
19779
|
+
})
|
|
19780
|
+
.catch(function () { return { urls: {}, profiles: [] }; });
|
|
19781
|
+
}
|
|
19782
|
+
|
|
19783
|
+
// Profiles worth offering in this tab's selector: only those a run has been
|
|
19784
|
+
// recorded under. The current one is always included so the menu can show
|
|
19785
|
+
// what is selected even before any run exists.
|
|
19786
|
+
function perspProfileNames() {
|
|
19787
|
+
var names = perspState.rerunProfiles.slice();
|
|
19788
|
+
if (names.indexOf(state.profile) === -1) names.push(state.profile);
|
|
19789
|
+
return names.sort();
|
|
19790
|
+
}
|
|
19791
|
+
|
|
19792
|
+
function perspSpecKey(feature, spec) {
|
|
19793
|
+
return feature.featureName + "/" + spec.specName;
|
|
19794
|
+
}
|
|
19795
|
+
|
|
19796
|
+
function rerunFor(feature, spec) {
|
|
19797
|
+
var report = perspState.rerun;
|
|
19798
|
+
return report && report.specs ? (report.specs[perspSpecKey(feature, spec)] || null) : null;
|
|
19799
|
+
}
|
|
19800
|
+
|
|
19801
|
+
// A reason a newer hub added but this UI has no wording for must still say
|
|
19802
|
+
// something honest instead of printing a raw i18n key (t() returns the key
|
|
19803
|
+
// when it has no entry).
|
|
19804
|
+
function rerunReasonText(prefix, reason) {
|
|
19805
|
+
var text = t(prefix + reason);
|
|
19806
|
+
return text === prefix + reason ? t(prefix + "unrecognized") : text;
|
|
19807
|
+
}
|
|
19808
|
+
|
|
19809
|
+
// Why the question cannot be answered, in the actionable phrasing the detail
|
|
19810
|
+
// panel wants: name the missing input and how to supply it. Only "unknown"
|
|
19811
|
+
// carries a machine-readable reason (ADR-0010); the other two are states of
|
|
19812
|
+
// the ledger, not gaps in the inputs.
|
|
19813
|
+
function rerunCannotJudge(rr) {
|
|
19814
|
+
if (rr.state === "unknown") return rerunReasonText("perspectives.rerun.fix.", rr.reason || "");
|
|
19815
|
+
if (rr.state === "neverRun") return t("perspectives.rerun.neverRunHint");
|
|
19816
|
+
if (rr.state === "notEvaluated") return t("perspectives.rerun.notEvaluatedHint");
|
|
19817
|
+
// A state a newer hub invented: say the UI cannot read it rather than leave
|
|
19818
|
+
// the row blank, which would look like missing data.
|
|
19819
|
+
return rerunReasonText("perspectives.rerun.fix.", rr.state);
|
|
19820
|
+
}
|
|
19821
|
+
|
|
19822
|
+
// The short justification a table cell carries under its badge. Nothing here
|
|
19823
|
+
// may collapse to a bare "up to date" — notNeeded names the deploy it was
|
|
19824
|
+
// judged against, and unknown names the missing input.
|
|
19825
|
+
function rerunCellWhy(rr) {
|
|
19826
|
+
var head = perspState.rerun && perspState.rerun.deployHead;
|
|
19827
|
+
if (rr.state === "needed") {
|
|
19828
|
+
if (!rr.touchedBy || !rr.touchedBy.length) return t("perspectives.rerun.touchedUnknown");
|
|
19829
|
+
return t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
|
|
19830
|
+
}
|
|
19831
|
+
if (rr.state === "notNeeded") {
|
|
19832
|
+
if (!head) return t("perspectives.rerun.noDeployHead");
|
|
19833
|
+
return t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
|
|
19834
|
+
}
|
|
19835
|
+
if (rr.state === "unknown") return rerunReasonText("perspectives.rerun.why.", rr.reason || "");
|
|
19836
|
+
return rerunCannotJudge(rr);
|
|
19837
|
+
}
|
|
19838
|
+
|
|
19839
|
+
var RERUN_BADGE_CLASS = {
|
|
19840
|
+
needed: "rr-needed", notNeeded: "rr-notneeded", unknown: "rr-unknown",
|
|
19841
|
+
neverRun: "rr-none", notEvaluated: "rr-none"
|
|
19842
|
+
};
|
|
19843
|
+
|
|
19844
|
+
// --- pure: rerun composition ---------------------------------------------
|
|
19845
|
+
// Self-contained on purpose: no DOM, no closures. rerun-view.test.ts lifts
|
|
19846
|
+
// this region out of the rendered page and runs it, because the summary bar
|
|
19847
|
+
// is where an overstatement would do the most damage and the suite has no
|
|
19848
|
+
// browser to click through.
|
|
19849
|
+
|
|
19850
|
+
// Bar segments in drawing order: what to act on first, then what needs no
|
|
19851
|
+
// action, then what was never measured. "unknown" keeps its own place and
|
|
19852
|
+
// its own colour — folding it into "notNeeded" would turn "we cannot say"
|
|
19853
|
+
// into "all clear", which is the one thing ADR-0010 forbids.
|
|
19854
|
+
var RERUN_ORDER = ["needed", "unknown", "notNeeded", "neverRun", "notEvaluated"];
|
|
19855
|
+
var RERUN_SEG_CLASS = {
|
|
19856
|
+
needed: "sg-needed", unknown: "sg-unknown", notNeeded: "sg-notneeded",
|
|
19857
|
+
neverRun: "sg-neverrun", notEvaluated: "sg-noteval"
|
|
19858
|
+
};
|
|
19859
|
+
|
|
19860
|
+
// One verdict per case, bucketed. A case with no verdict at all — this hub
|
|
19861
|
+
// does not answer the question, the fetch failed, or the case was added
|
|
19862
|
+
// after the report was computed — is not evaluated, never "not needed". A
|
|
19863
|
+
// state this UI does not know reads as unknown for the same reason: an
|
|
19864
|
+
// answer we cannot interpret is not evidence that nothing is needed.
|
|
19865
|
+
function rerunComposition(verdicts) {
|
|
19866
|
+
var counts = { needed: 0, unknown: 0, notNeeded: 0, neverRun: 0, notEvaluated: 0 };
|
|
19867
|
+
verdicts.forEach(function (rr) {
|
|
19868
|
+
if (!rr || !rr.state) { counts.notEvaluated += 1; return; }
|
|
19869
|
+
var known = Object.prototype.hasOwnProperty.call(counts, rr.state);
|
|
19870
|
+
counts[known ? rr.state : "unknown"] += 1;
|
|
19871
|
+
});
|
|
19872
|
+
return counts;
|
|
19873
|
+
}
|
|
19874
|
+
|
|
19875
|
+
// Only states with cases in them get drawn, which keeps the legend short and
|
|
19876
|
+
// stops an empty state from reading as a verdict: a "needs re-run 0" printed
|
|
19877
|
+
// for a profile nothing has been evaluated on is exactly that misreading.
|
|
19878
|
+
function rerunSegments(counts) {
|
|
19879
|
+
var out = [];
|
|
19880
|
+
RERUN_ORDER.forEach(function (key) {
|
|
19881
|
+
if (counts[key] > 0) out.push({ state: key, count: counts[key], cls: RERUN_SEG_CLASS[key] });
|
|
19882
|
+
});
|
|
19883
|
+
return out;
|
|
19884
|
+
}
|
|
19885
|
+
// --- end pure: rerun composition -----------------------------------------
|
|
19886
|
+
|
|
19887
|
+
// NB: the parameter is not named "state" — that would shadow the app-wide
|
|
19888
|
+
// state object this scope closes over.
|
|
19889
|
+
function rerunBadge(rerunState) {
|
|
19890
|
+
var span = el("span", "badge " + (RERUN_BADGE_CLASS[rerunState] || "rr-none"));
|
|
19891
|
+
span.appendChild(el("span", "d"));
|
|
19892
|
+
span.appendChild(document.createTextNode(" " + t("perspectives.rerun.state." + rerunState)));
|
|
19893
|
+
return span;
|
|
19894
|
+
}
|
|
19895
|
+
|
|
19896
|
+
// One ledger entry as "<short sha> · <when>", linking to the hub's run detail
|
|
19897
|
+
// and, when that run recorded one, to the CI run. Clicks must not bubble: the
|
|
19898
|
+
// table row itself toggles the detail panel.
|
|
19899
|
+
function ledgerLine(entry) {
|
|
19900
|
+
var wrap = el("span");
|
|
19901
|
+
var link = el("a", null, shortSha(entry.gitHead) || String(entry.runId).slice(0, 8));
|
|
19902
|
+
link.href = "#/runs/" + encodeURIComponent(entry.runId);
|
|
19903
|
+
link.title = t("perspectives.result.openRun");
|
|
19904
|
+
link.addEventListener("click", function (e) { e.stopPropagation(); });
|
|
19905
|
+
wrap.appendChild(link);
|
|
19906
|
+
wrap.appendChild(document.createTextNode(" · " + relTime(entry.at)));
|
|
19907
|
+
var ciUrl = perspState.runUrls[entry.runId];
|
|
19908
|
+
if (ciUrl) {
|
|
19909
|
+
wrap.appendChild(document.createTextNode(" · "));
|
|
19910
|
+
var ci = el("a", null, t("perspectives.result.ci"));
|
|
19911
|
+
ci.href = ciUrl;
|
|
19912
|
+
ci.target = "_blank";
|
|
19913
|
+
ci.rel = "noopener";
|
|
19914
|
+
ci.addEventListener("click", function (e) { e.stopPropagation(); });
|
|
19915
|
+
wrap.appendChild(ci);
|
|
19916
|
+
}
|
|
19917
|
+
return wrap;
|
|
19918
|
+
}
|
|
19919
|
+
|
|
19920
|
+
// The last recorded outcome, as { status, entry }. The ledger advances "run"
|
|
19921
|
+
// on every non-skipped result and "green"/"red" on the matching one, so the
|
|
19922
|
+
// last run is whichever of those two carries the same runId.
|
|
19923
|
+
//
|
|
19924
|
+
// A ledger written before the "run" bucket existed carries greens only, and
|
|
19925
|
+
// migrates that way — so with no "run" entry, fall back to the newer of
|
|
19926
|
+
// green/red. Both are real results; ignoring them would print "never run"
|
|
19927
|
+
// for a case whose last-passed coordinate is right there in the detail row.
|
|
19928
|
+
function lastResult(rr) {
|
|
19929
|
+
if (!rr) return null;
|
|
19930
|
+
if (rr.lastRun) {
|
|
19931
|
+
// "" when neither bucket carries this runId: the run is recorded but its
|
|
19932
|
+
// outcome is not, so the cell shows the coordinate without a verdict.
|
|
19933
|
+
var status = "";
|
|
19934
|
+
if (rr.lastGreen && rr.lastGreen.runId === rr.lastRun.runId) status = "passed";
|
|
19935
|
+
else if (rr.lastRed && rr.lastRed.runId === rr.lastRun.runId) status = "failed";
|
|
19936
|
+
return { status: status, entry: rr.lastRun };
|
|
19937
|
+
}
|
|
19938
|
+
if (rr.lastGreen && rr.lastRed) {
|
|
19939
|
+
return rr.lastGreen.at >= rr.lastRed.at
|
|
19940
|
+
? { status: "passed", entry: rr.lastGreen }
|
|
19941
|
+
: { status: "failed", entry: rr.lastRed };
|
|
19942
|
+
}
|
|
19943
|
+
if (rr.lastGreen) return { status: "passed", entry: rr.lastGreen };
|
|
19944
|
+
if (rr.lastRed) return { status: "failed", entry: rr.lastRed };
|
|
19945
|
+
return null;
|
|
19946
|
+
}
|
|
19947
|
+
|
|
19948
|
+
function perspResultCell(rr) {
|
|
19949
|
+
var td = el("td");
|
|
19950
|
+
// No verdict at all: this case is in the document but not in the report
|
|
19951
|
+
// (added since it was computed). Not the same statement as "never run".
|
|
19952
|
+
if (!rr) {
|
|
19953
|
+
td.appendChild(el("span", "muted", "—"));
|
|
19954
|
+
return td;
|
|
19955
|
+
}
|
|
19956
|
+
var last = lastResult(rr);
|
|
19957
|
+
if (!last) {
|
|
19958
|
+
td.appendChild(el("span", "muted", t("perspectives.result.never")));
|
|
19959
|
+
return td;
|
|
19960
|
+
}
|
|
19961
|
+
td.appendChild(last.status ? statusBadge(last.status) : el("span", "muted", "—"));
|
|
19962
|
+
var sub = el("span", "cellsub");
|
|
19963
|
+
sub.appendChild(ledgerLine(last.entry));
|
|
19964
|
+
td.appendChild(sub);
|
|
19965
|
+
return td;
|
|
19966
|
+
}
|
|
19967
|
+
|
|
19968
|
+
function perspRerunCell(rr) {
|
|
19969
|
+
var td = el("td");
|
|
19970
|
+
if (!rr) {
|
|
19971
|
+
td.appendChild(el("span", "muted", "—"));
|
|
19972
|
+
return td;
|
|
19973
|
+
}
|
|
19974
|
+
td.appendChild(rerunBadge(rr.state));
|
|
19975
|
+
var why = rerunCellWhy(rr);
|
|
19976
|
+
if (why) td.appendChild(el("span", "cellsub", why));
|
|
19977
|
+
return td;
|
|
19978
|
+
}
|
|
19979
|
+
|
|
19980
|
+
// A list of paths/globs as <code> chips. A flex row, so a list that does not
|
|
19981
|
+
// fit wraps between chips instead of breaking inside a path.
|
|
19982
|
+
function pathCodes(paths) {
|
|
19983
|
+
var wrap = el("span", "d-paths");
|
|
19984
|
+
paths.forEach(function (p) { wrap.appendChild(el("code", null, p)); });
|
|
19985
|
+
return wrap;
|
|
19986
|
+
}
|
|
19987
|
+
|
|
19988
|
+
// How many of this case's related-code patterns matched no file when the
|
|
19989
|
+
// document was written. ADDITIVE field: a document from a CLI older than
|
|
19990
|
+
// ADR-0010 simply has no measurement, which is not the same as a measured
|
|
19991
|
+
// zero — so anything that is not a real count reads as "not measured" and
|
|
19992
|
+
// shows nothing, rather than as a clean bill of health.
|
|
19993
|
+
function unmatchedRelatedPathCount(spec) {
|
|
19994
|
+
var raw = spec.relatedPathsUnmatched;
|
|
19995
|
+
return typeof raw === "number" && isFinite(raw) && raw >= 0 ? raw : null;
|
|
19996
|
+
}
|
|
19997
|
+
|
|
18569
19998
|
// The execution mode lives inside the mechanically-derived status object
|
|
18570
19999
|
// (spec.status.mode), not at the top level of a spec entry.
|
|
18571
20000
|
function perspMode(spec) {
|
|
@@ -18596,64 +20025,61 @@ const CLIENT_JS = `
|
|
|
18596
20025
|
return span;
|
|
18597
20026
|
}
|
|
18598
20027
|
|
|
18599
|
-
//
|
|
18600
|
-
//
|
|
20028
|
+
// Summary row: the inventory as one line, then one bar segmented by re-run
|
|
20029
|
+
// state — the question this tab is opened to answer. The mode and
|
|
20030
|
+
// recorded-ness counts are not lost; they moved onto the filter chips, where
|
|
20031
|
+
// a count states what that filter would leave behind.
|
|
20032
|
+
//
|
|
20033
|
+
// With no re-run data (an older hub, a failed fetch, or a profile nothing
|
|
20034
|
+
// has been recorded on) every case is "not evaluated" and the bar says so in
|
|
20035
|
+
// one neutral segment, rather than showing a composition that reads as
|
|
20036
|
+
// "nothing to do".
|
|
18601
20037
|
function renderPerspOverview(doc) {
|
|
18602
20038
|
var host = document.getElementById("persp-ov");
|
|
18603
20039
|
clear(host);
|
|
18604
|
-
var
|
|
18605
|
-
|
|
18606
|
-
|
|
18607
|
-
|
|
18608
|
-
var det = allSpecs.filter(function (s) { return perspMode(s) === "deterministic"; }).length;
|
|
18609
|
-
var live = total - det;
|
|
18610
|
-
|
|
18611
|
-
function numBlock(value, labelKey) {
|
|
18612
|
-
var box = el("div", "num");
|
|
18613
|
-
box.appendChild(el("b", null, String(value)));
|
|
18614
|
-
box.appendChild(el("span", null, t(labelKey)));
|
|
18615
|
-
return box;
|
|
18616
|
-
}
|
|
20040
|
+
var verdicts = [];
|
|
20041
|
+
doc.features.forEach(function (feature) {
|
|
20042
|
+
feature.specs.forEach(function (spec) { verdicts.push(rerunFor(feature, spec)); });
|
|
20043
|
+
});
|
|
18617
20044
|
|
|
18618
|
-
|
|
18619
|
-
|
|
18620
|
-
|
|
18621
|
-
|
|
18622
|
-
|
|
18623
|
-
|
|
18624
|
-
|
|
18625
|
-
|
|
18626
|
-
|
|
18627
|
-
var
|
|
18628
|
-
|
|
18629
|
-
|
|
18630
|
-
|
|
18631
|
-
|
|
18632
|
-
var
|
|
18633
|
-
|
|
18634
|
-
|
|
18635
|
-
|
|
18636
|
-
|
|
18637
|
-
|
|
18638
|
-
|
|
18639
|
-
|
|
18640
|
-
legOk.appendChild(el("i"));
|
|
18641
|
-
legOk.appendChild(document.createTextNode(t("perspectives.cov.runnable") + " " + ok));
|
|
18642
|
-
covleg.appendChild(legOk);
|
|
18643
|
-
if (no > 0) {
|
|
18644
|
-
var legNo = el("span", "lg-no");
|
|
18645
|
-
legNo.appendChild(el("i"));
|
|
18646
|
-
legNo.appendChild(document.createTextNode(t("perspectives.cov.notRecorded") + " " + no));
|
|
18647
|
-
covleg.appendChild(legNo);
|
|
18648
|
-
}
|
|
18649
|
-
covwrap.appendChild(covleg);
|
|
18650
|
-
host.appendChild(covwrap);
|
|
20045
|
+
var inv = el("div", "ov-inv");
|
|
20046
|
+
inv.appendChild(el("b", null, String(verdicts.length)));
|
|
20047
|
+
inv.appendChild(document.createTextNode(" " + t("perspectives.ov.cases") + " / "));
|
|
20048
|
+
inv.appendChild(el("b", null, String(doc.features.length)));
|
|
20049
|
+
inv.appendChild(document.createTextNode(" " + t("perspectives.ov.features")));
|
|
20050
|
+
host.appendChild(inv);
|
|
20051
|
+
if (!verdicts.length) return;
|
|
20052
|
+
|
|
20053
|
+
var bar = el("div", "rrbar");
|
|
20054
|
+
var leg = el("div", "rrleg");
|
|
20055
|
+
rerunSegments(rerunComposition(verdicts)).forEach(function (seg) {
|
|
20056
|
+
var fill = el("div", seg.cls);
|
|
20057
|
+
fill.style.width = (seg.count / verdicts.length) * 100 + "%";
|
|
20058
|
+
bar.appendChild(fill);
|
|
20059
|
+
var item = el("span");
|
|
20060
|
+
item.appendChild(el("i", seg.cls));
|
|
20061
|
+
item.appendChild(document.createTextNode(t("perspectives.rerun.state." + seg.state)));
|
|
20062
|
+
item.appendChild(el("b", null, String(seg.count)));
|
|
20063
|
+
leg.appendChild(item);
|
|
20064
|
+
});
|
|
20065
|
+
host.appendChild(bar);
|
|
20066
|
+
host.appendChild(leg);
|
|
18651
20067
|
}
|
|
18652
20068
|
|
|
18653
|
-
|
|
18654
|
-
|
|
18655
|
-
|
|
18656
|
-
|
|
20069
|
+
// The filter is passed in rather than read from perspState so the same
|
|
20070
|
+
// predicate can answer "what would this chip yield?" for every chip. The
|
|
20071
|
+
// search text always applies: a chip's count has to be the number of rows
|
|
20072
|
+
// clicking it actually leaves.
|
|
20073
|
+
function perspMatches(feature, spec, f) {
|
|
20074
|
+
if (f === "deterministic" && perspMode(spec) !== "deterministic") return false;
|
|
20075
|
+
if (f === "live" && perspMode(spec) !== "live") return false;
|
|
20076
|
+
if (f === "norec" && perspRunnable(spec)) return false;
|
|
20077
|
+
if (f === "rerun") {
|
|
20078
|
+
var rr = rerunFor(feature, spec);
|
|
20079
|
+
// Only "needed": "unknown" is not a weaker "probably needed", and
|
|
20080
|
+
// folding it in here would be exactly the overstatement ADR-0010 forbids.
|
|
20081
|
+
if (!rr || rr.state !== "needed") return false;
|
|
20082
|
+
}
|
|
18657
20083
|
if (perspState.q) {
|
|
18658
20084
|
var hay = (spec.title + " " + (spec.summary || "") + " " + spec.specName).toLowerCase();
|
|
18659
20085
|
if (hay.indexOf(perspState.q) === -1) return false;
|
|
@@ -18661,16 +20087,99 @@ const CLIENT_JS = `
|
|
|
18661
20087
|
return true;
|
|
18662
20088
|
}
|
|
18663
20089
|
|
|
20090
|
+
function perspFilterCount(f) {
|
|
20091
|
+
var doc = perspState.doc;
|
|
20092
|
+
if (!doc) return 0;
|
|
20093
|
+
var n = 0;
|
|
20094
|
+
doc.features.forEach(function (feature) {
|
|
20095
|
+
feature.specs.forEach(function (spec) { if (perspMatches(feature, spec, f)) n += 1; });
|
|
20096
|
+
});
|
|
20097
|
+
return n;
|
|
20098
|
+
}
|
|
20099
|
+
|
|
20100
|
+
// --- pure: rerun detail labels -------------------------------------------
|
|
20101
|
+
// Self-contained on purpose (no DOM, no closures) so rerun-view.test.ts can
|
|
20102
|
+
// lift this region out of the rendered page and run it: which label the
|
|
20103
|
+
// panel's evidence row wears, and whether it has a failure row at all.
|
|
20104
|
+
|
|
20105
|
+
// needed/notNeeded put evidence in that row, so it is labelled by the
|
|
20106
|
+
// timeframe the evidence covers. The other three have no evidence to show,
|
|
20107
|
+
// only a missing input to name — a different kind of content, and forcing one
|
|
20108
|
+
// label over both would make one of the two read as a lie.
|
|
20109
|
+
function rerunEvidenceLabelKey(rerunState) {
|
|
20110
|
+
return rerunState === "needed" || rerunState === "notNeeded"
|
|
20111
|
+
? "perspectives.d.changedSince"
|
|
20112
|
+
: "perspectives.d.cannotJudge";
|
|
20113
|
+
}
|
|
20114
|
+
|
|
20115
|
+
// The failure row points at a run. With no failure there is nothing to point
|
|
20116
|
+
// at, so the row is omitted rather than filled with "never failed" — the row
|
|
20117
|
+
// above already carries the last result.
|
|
20118
|
+
function rerunHasFailure(rr) {
|
|
20119
|
+
return !!(rr && rr.lastRed);
|
|
20120
|
+
}
|
|
20121
|
+
|
|
20122
|
+
// Which deploy the evidence line names, and how. A "needed" verdict carries
|
|
20123
|
+
// the deploy that caused it (touchedByDeploy) when the hub could confirm one,
|
|
20124
|
+
// and that is the deploy a reader wants — so it is named, with when it
|
|
20125
|
+
// landed. Without it (an older hub, or an entry the log no longer retains)
|
|
20126
|
+
// the only deploy coordinate on hand is the report's head, which is the point
|
|
20127
|
+
// the judgement was made at and not a cause: it keeps the weaker wording.
|
|
20128
|
+
// "at" is set only when the line names a cause, so the caller knows whether a
|
|
20129
|
+
// timestamp belongs on it.
|
|
20130
|
+
function rerunChangeLine(rr, deployHead) {
|
|
20131
|
+
var cause = rr.state === "needed" && rr.touchedByDeploy ? rr.touchedByDeploy : null;
|
|
20132
|
+
if (cause && cause.sha) return { key: "perspectives.rerun.changedByDeploy", sha: cause.sha, at: cause.at };
|
|
20133
|
+
if (!deployHead) return { key: "perspectives.rerun.noDeployHead", sha: null, at: null };
|
|
20134
|
+
return {
|
|
20135
|
+
key: rr.state === "needed" ? "perspectives.rerun.changesSome" : "perspectives.rerun.changesNone",
|
|
20136
|
+
sha: deployHead.sha,
|
|
20137
|
+
at: null,
|
|
20138
|
+
};
|
|
20139
|
+
}
|
|
20140
|
+
// --- end pure: rerun detail labels ----------------------------------------
|
|
20141
|
+
|
|
20142
|
+
// The evidence behind the verdict, as the value of whichever row
|
|
20143
|
+
// rerunEvidenceLabelKey chose. For needed/notNeeded that is what the deploy
|
|
20144
|
+
// log holds since this case last ran, named by rerunChangeLine.
|
|
20145
|
+
// The label already states the timeframe, so the value never repeats it.
|
|
20146
|
+
function rerunEvidenceValue(rr) {
|
|
20147
|
+
var wrap = el("div");
|
|
20148
|
+
if (rr.state !== "needed" && rr.state !== "notNeeded") {
|
|
20149
|
+
wrap.appendChild(el("div", "d-prose", rerunCannotJudge(rr)));
|
|
20150
|
+
return wrap;
|
|
20151
|
+
}
|
|
20152
|
+
// Both states require a non-empty deploy log, so a head-less report
|
|
20153
|
+
// contradicts itself; rerunChangeLine then names what is missing rather
|
|
20154
|
+
// than inventing a baseline.
|
|
20155
|
+
var line = rerunChangeLine(rr, perspState.rerun && perspState.rerun.deployHead);
|
|
20156
|
+
var text = t(line.key).replace("{sha}", shortSha(line.sha));
|
|
20157
|
+
if (line.at) text += " · " + relTime(line.at);
|
|
20158
|
+
wrap.appendChild(el("div", "d-prose", text));
|
|
20159
|
+
// A touch the index proved but cannot enumerate leaves no paths to list;
|
|
20160
|
+
// the line above still says a change landed, which is all that is known.
|
|
20161
|
+
if (rr.state === "needed" && rr.touchedBy && rr.touchedBy.length) {
|
|
20162
|
+
wrap.appendChild(pathCodes(rr.touchedBy));
|
|
20163
|
+
}
|
|
20164
|
+
return wrap;
|
|
20165
|
+
}
|
|
20166
|
+
|
|
18664
20167
|
// Detail row: a definition list of the case's fields plus the note editor.
|
|
18665
20168
|
// Built with createElement/textContent throughout — every field here is
|
|
18666
20169
|
// API-derived, so none of it may go through innerHTML.
|
|
20170
|
+
//
|
|
20171
|
+
// The panel shows only what the table row cannot. The row already carries the
|
|
20172
|
+
// title, mode, recorded state, last result and the re-run verdict, so none of
|
|
20173
|
+
// those is repeated: what is left is the case's definition, the evidence the
|
|
20174
|
+
// verdict rests on, its related code, and the note.
|
|
18667
20175
|
function perspDetailContent(feature, spec) {
|
|
18668
20176
|
var frag = document.createDocumentFragment();
|
|
18669
20177
|
var dl = el("dl", "d-grid");
|
|
18670
20178
|
function row(labelKey, valueNode) {
|
|
18671
20179
|
dl.appendChild(el("dt", null, t(labelKey)));
|
|
18672
20180
|
var dd = el("dd");
|
|
18673
|
-
|
|
20181
|
+
// Prose gets a measure; a node brings its own layout.
|
|
20182
|
+
if (typeof valueNode === "string") dd.appendChild(el("div", "d-prose", valueNode));
|
|
18674
20183
|
else dd.appendChild(valueNode);
|
|
18675
20184
|
dl.appendChild(dd);
|
|
18676
20185
|
}
|
|
@@ -18681,18 +20190,28 @@ const CLIENT_JS = `
|
|
|
18681
20190
|
}
|
|
18682
20191
|
if (spec.startScreen) row("perspectives.d.startScreen", spec.startScreen);
|
|
18683
20192
|
if (spec.testCondition) row("perspectives.d.testCondition", spec.testCondition);
|
|
18684
|
-
|
|
18685
|
-
|
|
20193
|
+
// The spec id stays: it is what a user types to re-run this case, and the
|
|
20194
|
+
// table shows the title, never the id.
|
|
20195
|
+
row("perspectives.d.spec", el("code", null, spec.specName));
|
|
20196
|
+
|
|
20197
|
+
var rr = rerunFor(feature, spec);
|
|
20198
|
+
if (rr) {
|
|
20199
|
+
row(rerunEvidenceLabelKey(rr.state), rerunEvidenceValue(rr));
|
|
20200
|
+
if (rerunHasFailure(rr)) row("perspectives.d.lastRed", ledgerLine(rr.lastRed));
|
|
20201
|
+
}
|
|
18686
20202
|
if (spec.relatedPaths && spec.relatedPaths.length) {
|
|
18687
|
-
|
|
18688
|
-
spec.relatedPaths.forEach(function (p, i) {
|
|
18689
|
-
if (i > 0) pathsWrap.appendChild(document.createTextNode(" "));
|
|
18690
|
-
pathsWrap.appendChild(el("code", null, p));
|
|
18691
|
-
});
|
|
18692
|
-
row("perspectives.d.relatedPaths", pathsWrap);
|
|
20203
|
+
row("perspectives.d.relatedPaths", pathCodes(spec.relatedPaths));
|
|
18693
20204
|
}
|
|
18694
20205
|
frag.appendChild(dl);
|
|
18695
20206
|
|
|
20207
|
+
// Too-narrow relatedPaths produce a confident "not needed" — the dangerous
|
|
20208
|
+
// direction — so an unmatched pattern is flagged under the paths it is about.
|
|
20209
|
+
var unmatched = unmatchedRelatedPathCount(spec);
|
|
20210
|
+
if (unmatched) {
|
|
20211
|
+
frag.appendChild(fillNote(el("div"), "warn",
|
|
20212
|
+
t("perspectives.dq.unmatched").replace("{n}", String(unmatched)), "d-note"));
|
|
20213
|
+
}
|
|
20214
|
+
|
|
18696
20215
|
var notebox = el("div", "notebox");
|
|
18697
20216
|
notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
|
|
18698
20217
|
var ta = el("textarea");
|
|
@@ -18732,17 +20251,23 @@ const CLIENT_JS = `
|
|
|
18732
20251
|
function renderPerspTable(doc) {
|
|
18733
20252
|
var tbody = document.getElementById("persp-tbody");
|
|
18734
20253
|
clear(tbody);
|
|
20254
|
+
// Hiding the two <th>s (rather than emitting empty cells) leaves the table
|
|
20255
|
+
// exactly as it was on a hub that cannot answer the re-run question.
|
|
20256
|
+
var showRerun = perspState.rerun != null;
|
|
20257
|
+
document.getElementById("persp-th-result").hidden = !showRerun;
|
|
20258
|
+
document.getElementById("persp-th-rerun").hidden = !showRerun;
|
|
20259
|
+
var cols = showRerun ? 6 : 4;
|
|
18735
20260
|
var hits = 0;
|
|
18736
20261
|
doc.features.forEach(function (feature) {
|
|
18737
|
-
var specs = feature.specs.filter(perspMatches);
|
|
20262
|
+
var specs = feature.specs.filter(function (s) { return perspMatches(feature, s, perspState.f); });
|
|
18738
20263
|
if (!specs.length) return;
|
|
18739
20264
|
hits += specs.length;
|
|
18740
20265
|
|
|
18741
20266
|
var grpRow = el("tr", "grp");
|
|
18742
20267
|
var grpTd = el("td");
|
|
18743
|
-
grpTd.colSpan =
|
|
20268
|
+
grpTd.colSpan = cols;
|
|
18744
20269
|
grpTd.appendChild(document.createTextNode(feature.featureName));
|
|
18745
|
-
grpTd.appendChild(el("span", "gcount", specs.length + " " + t("perspectives.
|
|
20270
|
+
grpTd.appendChild(el("span", "gcount", specs.length + " " + t("perspectives.ov.cases")));
|
|
18746
20271
|
grpRow.appendChild(grpTd);
|
|
18747
20272
|
tbody.appendChild(grpRow);
|
|
18748
20273
|
|
|
@@ -18764,13 +20289,19 @@ const CLIENT_JS = `
|
|
|
18764
20289
|
statusTd.appendChild(perspStatusBadge(spec));
|
|
18765
20290
|
row.appendChild(statusTd);
|
|
18766
20291
|
|
|
20292
|
+
if (showRerun) {
|
|
20293
|
+
var rr = rerunFor(feature, spec);
|
|
20294
|
+
row.appendChild(perspResultCell(rr));
|
|
20295
|
+
row.appendChild(perspRerunCell(rr));
|
|
20296
|
+
}
|
|
20297
|
+
|
|
18767
20298
|
var chevTd = el("td", "c-chev");
|
|
18768
20299
|
chevTd.appendChild(el("span", "chev-i", "\\u25b6"));
|
|
18769
20300
|
row.appendChild(chevTd);
|
|
18770
20301
|
|
|
18771
20302
|
var detailRow = el("tr", "detail");
|
|
18772
20303
|
var detailTd = el("td");
|
|
18773
|
-
detailTd.colSpan =
|
|
20304
|
+
detailTd.colSpan = cols;
|
|
18774
20305
|
detailRow.appendChild(detailTd);
|
|
18775
20306
|
var built = false;
|
|
18776
20307
|
|
|
@@ -18800,10 +20331,44 @@ const CLIENT_JS = `
|
|
|
18800
20331
|
function renderPerspectives() {
|
|
18801
20332
|
var doc = perspState.doc;
|
|
18802
20333
|
if (!doc) return;
|
|
20334
|
+
syncPerspChips();
|
|
18803
20335
|
renderPerspOverview(doc);
|
|
18804
20336
|
renderPerspTable(doc);
|
|
18805
20337
|
}
|
|
18806
20338
|
|
|
20339
|
+
// The needs-re-run chip only exists while the hub answers the question;
|
|
20340
|
+
// otherwise it would filter everything away. Drop back to "all" if it was
|
|
20341
|
+
// the active filter when the answer came back "not supported".
|
|
20342
|
+
//
|
|
20343
|
+
// Each chip also carries what it would yield — the mode breakdown the
|
|
20344
|
+
// summary row used to spend four tiles on.
|
|
20345
|
+
function syncPerspChips() {
|
|
20346
|
+
var chip = document.getElementById("persp-chip-rerun");
|
|
20347
|
+
chip.hidden = perspState.rerunSupported !== true;
|
|
20348
|
+
if (perspState.rerunSupported === false && perspState.f === "rerun") perspState.f = "all";
|
|
20349
|
+
document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
|
|
20350
|
+
var f = b.getAttribute("data-f");
|
|
20351
|
+
b.setAttribute("aria-pressed", String(f === perspState.f));
|
|
20352
|
+
b.querySelector(".fcount").textContent = perspState.doc ? String(perspFilterCount(f)) : "";
|
|
20353
|
+
});
|
|
20354
|
+
}
|
|
20355
|
+
|
|
20356
|
+
function setPerspRerunNote(text, kind) {
|
|
20357
|
+
var box = document.getElementById("persp-rerun-note");
|
|
20358
|
+
box.hidden = !text;
|
|
20359
|
+
if (!text) { clear(box); return; }
|
|
20360
|
+
fillNote(box, kind || "info", text, "persp-note");
|
|
20361
|
+
}
|
|
20362
|
+
|
|
20363
|
+
function setPerspDeployHead(report) {
|
|
20364
|
+
var span = document.getElementById("persp-deploy-head");
|
|
20365
|
+
var head = report && report.deployHead;
|
|
20366
|
+
span.hidden = !head;
|
|
20367
|
+
span.textContent = head
|
|
20368
|
+
? t("perspectives.rerun.deployHead") + " " + shortSha(head.sha) + " · " + relTime(head.at)
|
|
20369
|
+
: "";
|
|
20370
|
+
}
|
|
20371
|
+
|
|
18807
20372
|
function setPerspUpdated(doc) {
|
|
18808
20373
|
var span = document.getElementById("persp-updated");
|
|
18809
20374
|
if (doc && doc.generatedAt) {
|
|
@@ -18819,13 +20384,26 @@ const CLIENT_JS = `
|
|
|
18819
20384
|
setPerspStatus("");
|
|
18820
20385
|
document.getElementById("persp-body").hidden = true;
|
|
18821
20386
|
setPerspUpdated(null);
|
|
20387
|
+
setPerspRerunNote("");
|
|
20388
|
+
setPerspDeployHead(null);
|
|
20389
|
+
perspState.rerun = null;
|
|
20390
|
+
perspState.rerunSupported = null;
|
|
20391
|
+
syncPerspChips();
|
|
18822
20392
|
fetchPerspectives()
|
|
18823
20393
|
.then(function (doc) {
|
|
18824
20394
|
perspState.doc = doc;
|
|
18825
20395
|
if (!doc) { setPerspStatus(t("perspectives.empty")); return; }
|
|
18826
20396
|
setPerspUpdated(doc);
|
|
18827
20397
|
document.getElementById("persp-body").hidden = false;
|
|
20398
|
+
// The inventory renders first: re-run data is additive to it, and a
|
|
20399
|
+
// slow or absent /rerun must never hold up the table.
|
|
18828
20400
|
renderPerspectives();
|
|
20401
|
+
// Scoped to its own failure message: a fault here costs the two extra
|
|
20402
|
+
// columns, and reporting it as "loading perspectives failed" would
|
|
20403
|
+
// point at the inventory that in fact loaded fine.
|
|
20404
|
+
return loadRerun().catch(function (err) {
|
|
20405
|
+
setPerspRerunNote(t("perspectives.rerun.loadFailed") + ": " + err.message, "warn");
|
|
20406
|
+
});
|
|
18829
20407
|
})
|
|
18830
20408
|
.catch(function (err) {
|
|
18831
20409
|
perspState.doc = null;
|
|
@@ -18833,12 +20411,48 @@ const CLIENT_JS = `
|
|
|
18833
20411
|
});
|
|
18834
20412
|
}
|
|
18835
20413
|
|
|
20414
|
+
function rerunScope() {
|
|
20415
|
+
return state.project + "/" + state.profile;
|
|
20416
|
+
}
|
|
20417
|
+
|
|
20418
|
+
function loadRerun() {
|
|
20419
|
+
var scope = rerunScope();
|
|
20420
|
+
return Promise.all([fetchRerun(), fetchRunIndex()]).then(function (results) {
|
|
20421
|
+
// A second profile pick can land while this one is still in flight; the
|
|
20422
|
+
// older response must not overwrite the newer scope's verdicts.
|
|
20423
|
+
if (scope !== rerunScope()) return;
|
|
20424
|
+
var rerun = results[0];
|
|
20425
|
+
perspState.runUrls = results[1].urls;
|
|
20426
|
+
perspState.rerunProfiles = results[1].profiles;
|
|
20427
|
+
perspState.rerun = rerun.report || null;
|
|
20428
|
+
perspState.rerunSupported = perspState.rerun != null;
|
|
20429
|
+
setPerspDeployHead(perspState.rerun);
|
|
20430
|
+
if (rerun.note) {
|
|
20431
|
+
setPerspRerunNote(rerun.note, rerun.kind);
|
|
20432
|
+
} else if (perspState.rerun && !perspState.rerun.deployHead) {
|
|
20433
|
+
// Every case is "unknown" in this state, so say once, at the top, what
|
|
20434
|
+
// is missing and how to supply it, rather than only per row.
|
|
20435
|
+
setPerspRerunNote(t("perspectives.rerun.noDeployLogBanner").replace("{profile}", state.profile), "warn");
|
|
20436
|
+
} else {
|
|
20437
|
+
setPerspRerunNote("");
|
|
20438
|
+
}
|
|
20439
|
+
renderPerspectives();
|
|
20440
|
+
});
|
|
20441
|
+
}
|
|
20442
|
+
|
|
20443
|
+
// Switching profile re-asks only the profile-scoped question: the
|
|
20444
|
+
// perspectives document itself is project-scoped and does not change.
|
|
20445
|
+
function reloadRerun() {
|
|
20446
|
+
perspState.rerun = null;
|
|
20447
|
+
setPerspRerunNote("");
|
|
20448
|
+
setPerspDeployHead(null);
|
|
20449
|
+
renderPerspectives();
|
|
20450
|
+
return loadRerun();
|
|
20451
|
+
}
|
|
20452
|
+
|
|
18836
20453
|
function openPerspectives() {
|
|
18837
20454
|
showView("perspectives");
|
|
18838
20455
|
document.getElementById("persp-q").value = perspState.q;
|
|
18839
|
-
document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
|
|
18840
|
-
b.setAttribute("aria-pressed", String(b.getAttribute("data-f") === perspState.f));
|
|
18841
|
-
});
|
|
18842
20456
|
loadPerspectives();
|
|
18843
20457
|
}
|
|
18844
20458
|
|
|
@@ -19026,22 +20640,41 @@ const CLIENT_JS = `
|
|
|
19026
20640
|
document.getElementById("project-menu").hidden ? openProjectMenu() : closeProjectMenu();
|
|
19027
20641
|
}
|
|
19028
20642
|
|
|
19029
|
-
// ── profile switching (
|
|
19030
|
-
// Profiles scope
|
|
19031
|
-
//
|
|
19032
|
-
//
|
|
20643
|
+
// ── profile switching (per-tab dropdowns) ──────────────────────────────
|
|
20644
|
+
// Profiles scope variables + sessions (a profile is a set of env vars, like
|
|
20645
|
+
// .ccqa/profiles/<name>.env) and, since ADR-0010, the needs-re-run verdict:
|
|
20646
|
+
// two environments sit at different commits, so that question has no
|
|
20647
|
+
// profile-free answer. Prompts are project-wide and runs are cross-profile,
|
|
20648
|
+
// so there is still no header-level selector — Secrets and Perspectives each
|
|
20649
|
+
// carry their own, sharing state.profile and differing only in which names
|
|
20650
|
+
// they offer and what a pick reloads.
|
|
20651
|
+
|
|
20652
|
+
var PROFILE_MENUS = {
|
|
20653
|
+
secrets: {
|
|
20654
|
+
switchId: "sec-profile-switch", menuId: "sec-profile-menu", withNew: true,
|
|
20655
|
+
names: function () { return knownProfiles; },
|
|
20656
|
+
pick: function (p) { chooseProfile(p, loadSecrets); }
|
|
20657
|
+
},
|
|
20658
|
+
perspectives: {
|
|
20659
|
+
switchId: "persp-profile-switch", menuId: "persp-profile-menu", withNew: false,
|
|
20660
|
+
names: perspProfileNames,
|
|
20661
|
+
pick: function (p) { chooseProfile(p, reloadRerun); }
|
|
20662
|
+
}
|
|
20663
|
+
};
|
|
19033
20664
|
|
|
19034
20665
|
function setProfile(p) {
|
|
19035
20666
|
state.profile = p || "default";
|
|
19036
|
-
|
|
19037
|
-
|
|
20667
|
+
["sec-profile-current", "persp-profile-current"].forEach(function (id) {
|
|
20668
|
+
var cur = document.getElementById(id);
|
|
20669
|
+
if (cur) cur.textContent = state.profile;
|
|
20670
|
+
});
|
|
19038
20671
|
}
|
|
19039
20672
|
|
|
19040
|
-
// Switch profile and reload the
|
|
19041
|
-
function chooseProfile(p) {
|
|
20673
|
+
// Switch profile and reload the tab that asked, under the new scope.
|
|
20674
|
+
function chooseProfile(p, reload) {
|
|
19042
20675
|
setProfile(p);
|
|
19043
20676
|
storeProfileForProject(state.project, state.profile);
|
|
19044
|
-
|
|
20677
|
+
reload();
|
|
19045
20678
|
}
|
|
19046
20679
|
|
|
19047
20680
|
// Fetch the profiles for the current project. "default" is always available
|
|
@@ -19055,17 +20688,20 @@ const CLIENT_JS = `
|
|
|
19055
20688
|
}).catch(function () { knownProfiles = ["default"]; setProfile("default"); });
|
|
19056
20689
|
}
|
|
19057
20690
|
|
|
19058
|
-
function buildProfileMenu() {
|
|
19059
|
-
var menu = document.getElementById(
|
|
20691
|
+
function buildProfileMenu(menuSpec) {
|
|
20692
|
+
var menu = document.getElementById(menuSpec.menuId);
|
|
19060
20693
|
clear(menu);
|
|
19061
|
-
|
|
20694
|
+
menuSpec.names().forEach(function (p) {
|
|
19062
20695
|
var mi = el("button", "mi" + (p === state.profile ? " current" : ""));
|
|
19063
20696
|
mi.type = "button";
|
|
19064
20697
|
mi.setAttribute("role", "menuitem");
|
|
19065
20698
|
mi.appendChild(el("span", "name", p));
|
|
19066
|
-
mi.addEventListener("click", function () { closeProfileMenu();
|
|
20699
|
+
mi.addEventListener("click", function () { closeProfileMenu(); menuSpec.pick(p); });
|
|
19067
20700
|
menu.appendChild(mi);
|
|
19068
20701
|
});
|
|
20702
|
+
// Only the Secrets menu can create: a profile with no secrets is a usable
|
|
20703
|
+
// secrets scope, but a profile with no runs has nothing to judge.
|
|
20704
|
+
if (!menuSpec.withNew) return;
|
|
19069
20705
|
menu.appendChild(el("div", "sep"));
|
|
19070
20706
|
var newItem = el("button", "mi action");
|
|
19071
20707
|
newItem.type = "button";
|
|
@@ -19076,20 +20712,26 @@ const CLIENT_JS = `
|
|
|
19076
20712
|
menu.appendChild(newItem);
|
|
19077
20713
|
}
|
|
19078
20714
|
|
|
19079
|
-
function openProfileMenu() {
|
|
20715
|
+
function openProfileMenu(which) {
|
|
19080
20716
|
if (!state.token || !state.project) return;
|
|
19081
|
-
|
|
19082
|
-
|
|
19083
|
-
document.getElementById(
|
|
20717
|
+
var menuSpec = PROFILE_MENUS[which];
|
|
20718
|
+
buildProfileMenu(menuSpec);
|
|
20719
|
+
document.getElementById(menuSpec.menuId).hidden = false;
|
|
20720
|
+
document.getElementById(menuSpec.switchId).setAttribute("aria-expanded", "true");
|
|
19084
20721
|
}
|
|
20722
|
+
// Closes both, so an outside click or Escape needs no idea which is open.
|
|
19085
20723
|
function closeProfileMenu() {
|
|
19086
|
-
|
|
19087
|
-
|
|
19088
|
-
|
|
19089
|
-
|
|
20724
|
+
Object.keys(PROFILE_MENUS).forEach(function (which) {
|
|
20725
|
+
var menuSpec = PROFILE_MENUS[which];
|
|
20726
|
+
var menu = document.getElementById(menuSpec.menuId);
|
|
20727
|
+
if (!menu) return;
|
|
20728
|
+
menu.hidden = true;
|
|
20729
|
+
document.getElementById(menuSpec.switchId).setAttribute("aria-expanded", "false");
|
|
20730
|
+
});
|
|
19090
20731
|
}
|
|
19091
|
-
function toggleProfileMenu() {
|
|
19092
|
-
document.getElementById(
|
|
20732
|
+
function toggleProfileMenu(which) {
|
|
20733
|
+
var menu = document.getElementById(PROFILE_MENUS[which].menuId);
|
|
20734
|
+
if (menu.hidden) openProfileMenu(which); else closeProfileMenu();
|
|
19093
20735
|
}
|
|
19094
20736
|
|
|
19095
20737
|
// ── new-project dialog (centered modal; shares #scrim with the sheet) ──
|
|
@@ -19133,7 +20775,9 @@ const CLIENT_JS = `
|
|
|
19133
20775
|
// Profiles are implicit like projects — created for real on the first
|
|
19134
20776
|
// secret/prompt stored under them. Just add to the list and select it.
|
|
19135
20777
|
if (knownProfiles.indexOf(name) === -1) { knownProfiles.push(name); knownProfiles.sort(); }
|
|
19136
|
-
|
|
20778
|
+
// The "new profile" item only exists in the Secrets menu, so that is the
|
|
20779
|
+
// tab to reload.
|
|
20780
|
+
chooseProfile(name, loadSecrets);
|
|
19137
20781
|
} else {
|
|
19138
20782
|
if (knownProjects.indexOf(name) === -1) { knownProjects.push(name); knownProjects.sort(); }
|
|
19139
20783
|
chooseProject(name);
|
|
@@ -19220,13 +20864,16 @@ const CLIENT_JS = `
|
|
|
19220
20864
|
});
|
|
19221
20865
|
// Keep clicks inside the menu from bubbling to the document close-handler.
|
|
19222
20866
|
document.getElementById("project-menu").addEventListener("click", function (e) { e.stopPropagation(); });
|
|
19223
|
-
//
|
|
19224
|
-
|
|
19225
|
-
|
|
19226
|
-
|
|
19227
|
-
|
|
20867
|
+
// Per-tab profile dropdowns (Secrets, Perspectives)
|
|
20868
|
+
Object.keys(PROFILE_MENUS).forEach(function (which) {
|
|
20869
|
+
var menuSpec = PROFILE_MENUS[which];
|
|
20870
|
+
document.getElementById(menuSpec.switchId).addEventListener("click", function (e) {
|
|
20871
|
+
e.stopPropagation();
|
|
20872
|
+
closeProjectMenu();
|
|
20873
|
+
toggleProfileMenu(which);
|
|
20874
|
+
});
|
|
20875
|
+
document.getElementById(menuSpec.menuId).addEventListener("click", function (e) { e.stopPropagation(); });
|
|
19228
20876
|
});
|
|
19229
|
-
document.getElementById("sec-profile-menu").addEventListener("click", function (e) { e.stopPropagation(); });
|
|
19230
20877
|
// Outside click closes both menus.
|
|
19231
20878
|
document.addEventListener("click", function () { closeProjectMenu(); closeProfileMenu(); });
|
|
19232
20879
|
|
|
@@ -19257,10 +20904,9 @@ const CLIENT_JS = `
|
|
|
19257
20904
|
});
|
|
19258
20905
|
document.querySelectorAll("#view-perspectives .fchip").forEach(function (b) {
|
|
19259
20906
|
b.addEventListener("click", function () {
|
|
20907
|
+
// renderPerspectives -> syncPerspChips repaints aria-pressed from
|
|
20908
|
+
// perspState.f, so the handler only has to record the choice.
|
|
19260
20909
|
perspState.f = b.getAttribute("data-f");
|
|
19261
|
-
document.querySelectorAll("#view-perspectives .fchip").forEach(function (x) {
|
|
19262
|
-
x.setAttribute("aria-pressed", x === b ? "true" : "false");
|
|
19263
|
-
});
|
|
19264
20910
|
renderPerspectives();
|
|
19265
20911
|
});
|
|
19266
20912
|
});
|
|
@@ -19695,6 +21341,9 @@ function registerRoutes(router, config, queue) {
|
|
|
19695
21341
|
router.get("/api/v1/projects", createListProjectsHandler(storage));
|
|
19696
21342
|
router.get("/api/v1/projects/:project/profiles", createListProfilesHandler(storage));
|
|
19697
21343
|
router.get("/api/v1/projects/:project/last-green", createGetLastGreenHandler(storage));
|
|
21344
|
+
router.post("/api/v1/projects/:project/deploys", createRecordDeployHandler(storage));
|
|
21345
|
+
router.get("/api/v1/projects/:project/deploys", createGetDeployLogHandler(storage));
|
|
21346
|
+
router.get("/api/v1/projects/:project/rerun", createGetRerunHandler(storage));
|
|
19698
21347
|
const sessionConfig = {
|
|
19699
21348
|
store: storage.sessions,
|
|
19700
21349
|
encryptionKey: config.encryptionKey
|
|
@@ -19864,6 +21513,9 @@ function isNotFound(err) {
|
|
|
19864
21513
|
* variables/<project>/<profile>/<name>.meta.json
|
|
19865
21514
|
* triage/<runId>.json (TriageRecord[])
|
|
19866
21515
|
* jobs/<id>/job.json (LearningJob record, mutated as it runs)
|
|
21516
|
+
* last-green/<project>/<profile>/<branch>.json (SpecLedger: green/run/red buckets)
|
|
21517
|
+
* deploys/<project>/<profile>/log.json (DeployLog, ring-buffered)
|
|
21518
|
+
* deploys/<project>/<profile>/touch.json (SpecTouchIndex derived from the log)
|
|
19867
21519
|
*
|
|
19868
21520
|
* IDs and names are validated by their callers (run ids are server-minted
|
|
19869
21521
|
* UUIDs; project/profile/name come from validated request params) before
|
|
@@ -19927,9 +21579,22 @@ function perspectivesKindDir(root) {
|
|
|
19927
21579
|
function perspectivesPath(root, project) {
|
|
19928
21580
|
return join(perspectivesKindDir(root), `${project}.json`);
|
|
19929
21581
|
}
|
|
19930
|
-
function
|
|
21582
|
+
function ledgerProfileDir(root, project, profile) {
|
|
21583
|
+
return join(root, "last-green", project, profile);
|
|
21584
|
+
}
|
|
21585
|
+
function ledgerPath(root, project, profile, branch) {
|
|
19931
21586
|
const encoded = encodeURIComponent(branch);
|
|
19932
|
-
|
|
21587
|
+
const name = encoded.length <= 200 ? encoded : `${encoded.slice(0, 64)}-${createHash("sha256").update(branch).digest("hex").slice(0, 32)}`;
|
|
21588
|
+
return join(ledgerProfileDir(root, project, profile), `${name}.json`);
|
|
21589
|
+
}
|
|
21590
|
+
function deployScopeDir(root, project, profile) {
|
|
21591
|
+
return join(root, "deploys", project, profile);
|
|
21592
|
+
}
|
|
21593
|
+
function deployLogPath(root, project, profile) {
|
|
21594
|
+
return join(deployScopeDir(root, project, profile), "log.json");
|
|
21595
|
+
}
|
|
21596
|
+
function deployTouchIndexPath(root, project, profile) {
|
|
21597
|
+
return join(deployScopeDir(root, project, profile), "touch.json");
|
|
19933
21598
|
}
|
|
19934
21599
|
//#endregion
|
|
19935
21600
|
//#region src/hub/core/storage/file/artifact-store.ts
|
|
@@ -19971,6 +21636,28 @@ function createFileArtifactStore(root) {
|
|
|
19971
21636
|
};
|
|
19972
21637
|
}
|
|
19973
21638
|
//#endregion
|
|
21639
|
+
//#region src/hub/core/storage/file/deploy-store.ts
|
|
21640
|
+
function createFileDeployStore(root) {
|
|
21641
|
+
const readLog = async (project, profile) => await readJson(deployLogPath(root, project, profile)) ?? emptyDeployLog();
|
|
21642
|
+
return {
|
|
21643
|
+
async append(project, profile, input) {
|
|
21644
|
+
const log = await updateJson(deployLogPath(root, project, profile), (current) => appendDeploy(current, input));
|
|
21645
|
+
return log.entries[log.entries.length - 1];
|
|
21646
|
+
},
|
|
21647
|
+
getLog: readLog,
|
|
21648
|
+
async head(project, profile) {
|
|
21649
|
+
const { entries } = await readLog(project, profile);
|
|
21650
|
+
return entries[entries.length - 1] ?? null;
|
|
21651
|
+
},
|
|
21652
|
+
async getTouchIndex(project, profile) {
|
|
21653
|
+
return await readJson(deployTouchIndexPath(root, project, profile)) ?? {};
|
|
21654
|
+
},
|
|
21655
|
+
async updateTouchIndex(project, profile, mutate) {
|
|
21656
|
+
await updateJson(deployTouchIndexPath(root, project, profile), (current) => mutate(current ?? {}));
|
|
21657
|
+
}
|
|
21658
|
+
};
|
|
21659
|
+
}
|
|
21660
|
+
//#endregion
|
|
19974
21661
|
//#region src/hub/core/storage/file/job-store.ts
|
|
19975
21662
|
/**
|
|
19976
21663
|
* Read one job record for a list scan, tolerating a bad entry: a missing
|
|
@@ -20018,21 +21705,19 @@ function createFileJobStore(root) {
|
|
|
20018
21705
|
};
|
|
20019
21706
|
}
|
|
20020
21707
|
//#endregion
|
|
20021
|
-
//#region src/hub/core/storage/file/
|
|
20022
|
-
function
|
|
21708
|
+
//#region src/hub/core/storage/file/ledger-store.ts
|
|
21709
|
+
function createFileSpecLedgerStore(root) {
|
|
20023
21710
|
return {
|
|
20024
21711
|
async get(project, profile, branch) {
|
|
20025
|
-
return await readJson(
|
|
21712
|
+
return toLedger(await readJson(ledgerPath(root, project, profile, branch)));
|
|
20026
21713
|
},
|
|
20027
|
-
async
|
|
20028
|
-
|
|
20029
|
-
|
|
20030
|
-
|
|
20031
|
-
|
|
20032
|
-
|
|
20033
|
-
|
|
20034
|
-
return out;
|
|
20035
|
-
});
|
|
21714
|
+
async getMerged(project, profile) {
|
|
21715
|
+
const dir = ledgerProfileDir(root, project, profile);
|
|
21716
|
+
const files = (await listDirOrEmpty(dir)).filter((name) => name.endsWith(".json"));
|
|
21717
|
+
return (await Promise.all(files.map((name) => readJson(join(dir, name))))).reduce((acc, doc) => mergeLedgerInto(acc, toLedger(doc)), emptyLedger());
|
|
21718
|
+
},
|
|
21719
|
+
async merge(project, profile, branch, ledger) {
|
|
21720
|
+
await updateJson(ledgerPath(root, project, profile, branch), (current) => mergeLedgerInto(toLedger(current), ledger));
|
|
20036
21721
|
}
|
|
20037
21722
|
};
|
|
20038
21723
|
}
|
|
@@ -20291,7 +21976,8 @@ function createFileHubStorage(dataDir) {
|
|
|
20291
21976
|
prompts: createFilePromptStore(dataDir),
|
|
20292
21977
|
perspectives: createFilePerspectivesStore(dataDir),
|
|
20293
21978
|
jobs: createFileJobStore(dataDir),
|
|
20294
|
-
|
|
21979
|
+
ledger: createFileSpecLedgerStore(dataDir),
|
|
21980
|
+
deploys: createFileDeployStore(dataDir)
|
|
20295
21981
|
};
|
|
20296
21982
|
}
|
|
20297
21983
|
//#endregion
|