ccqa 1.31.3 → 1.32.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 +517 -42
- package/dist/hub-client/index.d.mts +74 -1
- package/dist/hub-client/index.mjs +20 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -4775,6 +4775,18 @@ const PerspectiveStatusSchema = z.object({
|
|
|
4775
4775
|
target: z.string().min(1).optional()
|
|
4776
4776
|
}).strip();
|
|
4777
4777
|
/**
|
|
4778
|
+
* One step of a spec, transcribed verbatim for display. Exactly one shape per
|
|
4779
|
+
* step: an include step carries the block name it invokes (its params stay in
|
|
4780
|
+
* the spec — they are wiring, not procedure), an action step carries the
|
|
4781
|
+
* instruction and what it expects. Never authored or reworded here: like
|
|
4782
|
+
* `title`, this is a mechanical copy the next `ccqa perspectives` rewrites.
|
|
4783
|
+
*/
|
|
4784
|
+
const PerspectiveStepSchema = z.object({
|
|
4785
|
+
include: z.string().min(1).optional(),
|
|
4786
|
+
instruction: z.string().optional(),
|
|
4787
|
+
expected: z.string().optional()
|
|
4788
|
+
}).strip();
|
|
4789
|
+
/**
|
|
4778
4790
|
* One test case in the inventory.
|
|
4779
4791
|
*
|
|
4780
4792
|
* - `title` is transcribed verbatim from the spec.yaml.
|
|
@@ -4786,11 +4798,12 @@ const PerspectiveStatusSchema = z.object({
|
|
|
4786
4798
|
* steps (the opening screen, the state the test assumes, and the setup
|
|
4787
4799
|
* prerequisites such as which role logs in). Optional: a spec may not
|
|
4788
4800
|
* express all of them.
|
|
4801
|
+
* - `steps` is the procedure itself, transcribed verbatim (never authored —
|
|
4802
|
+
* see PerspectiveStepSchema) so the hub UI can show a case in full without
|
|
4803
|
+
* a repo checkout. The spec.yaml stays the single source of truth: this
|
|
4804
|
+
* copy is rewritten wholesale every time the inventory is regenerated, the
|
|
4805
|
+
* same way `title` is.
|
|
4789
4806
|
* - `note` is a human-only field. Regenerating perspectives preserves it.
|
|
4790
|
-
*
|
|
4791
|
-
* The detailed test procedure and expected results are deliberately NOT
|
|
4792
|
-
* duplicated here — the spec.yaml steps are the single source of truth for
|
|
4793
|
-
* those. The Markdown view links back to the spec instead of restating them.
|
|
4794
4807
|
*/
|
|
4795
4808
|
const PerspectiveSpecSchema = z.object({
|
|
4796
4809
|
specName: z.string().min(1),
|
|
@@ -4799,6 +4812,7 @@ const PerspectiveSpecSchema = z.object({
|
|
|
4799
4812
|
startScreen: z.string().optional(),
|
|
4800
4813
|
testCondition: z.string().optional(),
|
|
4801
4814
|
preconditions: z.array(z.string().min(1)).optional(),
|
|
4815
|
+
steps: z.array(PerspectiveStepSchema).optional(),
|
|
4802
4816
|
status: PerspectiveStatusSchema,
|
|
4803
4817
|
changedAt: z.string().optional(),
|
|
4804
4818
|
note: z.string().optional()
|
|
@@ -7396,7 +7410,8 @@ const SpecVerdictSchema = z.enum([
|
|
|
7396
7410
|
"inProgress",
|
|
7397
7411
|
"needsRepair",
|
|
7398
7412
|
"rerunNeeded",
|
|
7399
|
-
"verified"
|
|
7413
|
+
"verified",
|
|
7414
|
+
"manuallyVerified"
|
|
7400
7415
|
]);
|
|
7401
7416
|
/**
|
|
7402
7417
|
* Which hole in the deploy log made the hub assume a deploy reached this spec
|
|
@@ -7452,6 +7467,57 @@ z.object({
|
|
|
7452
7467
|
/** Body of `DELETE /projects/:project/locks?profile=`. */
|
|
7453
7468
|
const ReleaseLocksRequestSchema = z.object({ holder: z.string().min(1) });
|
|
7454
7469
|
/**
|
|
7470
|
+
* A person's word that they checked a spec's behaviour by hand against the
|
|
7471
|
+
* deployed environment. It overrides the verdict, never the ledgers: the
|
|
7472
|
+
* drift entry that parked the spec stays open, so the repair loop keeps its
|
|
7473
|
+
* reason to fix the test, while the verdict stops asking a person for what a
|
|
7474
|
+
* person already did.
|
|
7475
|
+
*
|
|
7476
|
+
* Anchored to the deploy head at the moment it was recorded, so it lapses on
|
|
7477
|
+
* its own — a deploy reaching the spec, or the spec being edited, ends its
|
|
7478
|
+
* coverage the same way those end a run's (ADR-0010). One per spec: a new
|
|
7479
|
+
* attestation replaces the previous one.
|
|
7480
|
+
*/
|
|
7481
|
+
const AttestationSchema = z.object({
|
|
7482
|
+
by: z.string().min(1),
|
|
7483
|
+
at: z.string(),
|
|
7484
|
+
note: z.string().optional(),
|
|
7485
|
+
deployedSha: z.string().nullable()
|
|
7486
|
+
});
|
|
7487
|
+
/** The per-(project, profile) attestation document: "feature/spec" → the standing attestation. */
|
|
7488
|
+
const AttestationsSchema = z.object({ specs: z.record(z.string(), AttestationSchema).default({}) });
|
|
7489
|
+
/**
|
|
7490
|
+
* Why an attestation stopped covering the spec. One reason is named even when
|
|
7491
|
+
* several hold, in the order the checks run (coverage, then the spec's own
|
|
7492
|
+
* edits, then a later red run) — enough for a reader to see what to verify
|
|
7493
|
+
* before attesting again.
|
|
7494
|
+
*/
|
|
7495
|
+
const AttestationLapseSchema = z.enum([
|
|
7496
|
+
"deployReached",
|
|
7497
|
+
"cannotPlace",
|
|
7498
|
+
"specEdited",
|
|
7499
|
+
"newerRed"
|
|
7500
|
+
]);
|
|
7501
|
+
/** Body of `PUT /projects/:project/attestations?profile=`. */
|
|
7502
|
+
const PutAttestationRequestSchema = z.object({
|
|
7503
|
+
spec: z.string().min(1).max(512),
|
|
7504
|
+
by: z.string().min(1).max(256),
|
|
7505
|
+
note: z.string().max(4e3).optional()
|
|
7506
|
+
});
|
|
7507
|
+
/** Body of `DELETE /projects/:project/attestations?profile=`. */
|
|
7508
|
+
const DeleteAttestationRequestSchema = z.object({ spec: z.string().min(1).max(512) });
|
|
7509
|
+
z.object({
|
|
7510
|
+
project: z.string(),
|
|
7511
|
+
profile: z.string(),
|
|
7512
|
+
spec: z.string(),
|
|
7513
|
+
attestation: AttestationSchema
|
|
7514
|
+
});
|
|
7515
|
+
z.object({
|
|
7516
|
+
project: z.string(),
|
|
7517
|
+
profile: z.string(),
|
|
7518
|
+
specs: z.record(z.string(), AttestationSchema)
|
|
7519
|
+
});
|
|
7520
|
+
/**
|
|
7455
7521
|
* One spec's verdict, the two axes it was derived from, and the three ledger
|
|
7456
7522
|
* coordinates the view shows alongside them. The coordinates are always
|
|
7457
7523
|
* present (null when the spec has no such entry); the optional fields appear
|
|
@@ -7469,6 +7535,10 @@ const SpecRerunSchema = z.object({
|
|
|
7469
7535
|
auditAssumedReached: RerunUnknownReasonSchema.optional(),
|
|
7470
7536
|
executionAssumedReached: RerunUnknownReasonSchema.optional(),
|
|
7471
7537
|
specChangedSince: z.string().optional(),
|
|
7538
|
+
manual: AttestationSchema.optional(),
|
|
7539
|
+
manualLapsed: AttestationSchema.extend({ because: AttestationLapseSchema }).optional(),
|
|
7540
|
+
manualLapsedByDeploy: DeployRefSchema.nullable().optional(),
|
|
7541
|
+
manualLapsedReason: RerunUnknownReasonSchema.optional(),
|
|
7472
7542
|
heldBy: SpecLockSchema.nullable(),
|
|
7473
7543
|
lastRun: SpecLedgerEntrySchema.nullable(),
|
|
7474
7544
|
lastGreen: SpecLedgerEntrySchema.nullable(),
|
|
@@ -7711,7 +7781,7 @@ async function fetchRerunReport(hubCtx, profile) {
|
|
|
7711
7781
|
throw new RunUsageError(`--only-hub-rerun-needed: could not ask the hub which specs need a re-run: ${errMessage(err)}`);
|
|
7712
7782
|
}
|
|
7713
7783
|
const parsed = RerunReportSchema.safeParse(report);
|
|
7714
|
-
if (!parsed.success) throw new RunUsageError(`${FLAG$1}: this hub's re-run answer is not in a shape this ccqa understands — it
|
|
7784
|
+
if (!parsed.success) throw new RunUsageError(`${FLAG$1}: this hub's re-run answer is not in a shape this ccqa understands — the hub and this CLI are on different versions, and either side being newer can cause it (a newer hub may answer with verdicts this CLI does not know). Align the two, or select with --only-affected-by <ref> instead.`);
|
|
7715
7785
|
report = parsed.data;
|
|
7716
7786
|
if (report.deployHead === null) throw new RunUsageError(`--only-hub-rerun-needed: 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 select with --only-affected-by <ref> instead.`);
|
|
7717
7787
|
return {
|
|
@@ -7723,7 +7793,8 @@ const SUMMARY_ORDER$1 = rankedOrder({
|
|
|
7723
7793
|
needsRepair: 0,
|
|
7724
7794
|
rerunNeeded: 1,
|
|
7725
7795
|
inProgress: 2,
|
|
7726
|
-
|
|
7796
|
+
manuallyVerified: 3,
|
|
7797
|
+
verified: 4
|
|
7727
7798
|
});
|
|
7728
7799
|
/**
|
|
7729
7800
|
* Narrow `specs` to the ones the hub says are worth running.
|
|
@@ -7733,7 +7804,9 @@ const SUMMARY_ORDER$1 = rankedOrder({
|
|
|
7733
7804
|
* cannot vouch for, are both as uncovered as one a deploy demonstrably
|
|
7734
7805
|
* invalidated (ADR-0014). `needsRepair`, `inProgress` and `verified` are never
|
|
7735
7806
|
* selected: running them repairs nothing, races something already in flight,
|
|
7736
|
-
* or repeats work that is still current.
|
|
7807
|
+
* or repeats work that is still current. `manuallyVerified` is never selected
|
|
7808
|
+
* either — the test is still the broken one the attestation stands in for,
|
|
7809
|
+
* and running it would only relabel a person's answer with a machine failure.
|
|
7737
7810
|
*/
|
|
7738
7811
|
function selectSpecsNeedingRerun(specs, report) {
|
|
7739
7812
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -9518,7 +9591,37 @@ const pushCommand = new Command("push").description("Upload the report directory
|
|
|
9518
9591
|
meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
|
|
9519
9592
|
info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
|
|
9520
9593
|
}));
|
|
9521
|
-
const
|
|
9594
|
+
const attestCommand = new Command("attest").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person checked a spec's behaviour by hand against the deployed environment. The verdict answers manuallyVerified instead of asking a person for what a person already did — the drift ledger is untouched, so the repair loop keeps its reason to fix the test. The attestation lapses on its own when a deploy reaches the spec or the spec is edited.").requiredOption("--profile <name>", "Environment that was checked (e.g. 'stg'). The attestation is anchored to its current deploy head.").option("--by <name>", "Who checked. Required unless --revoke.").option("--note <text>", "What was checked and how — the reader deciding whether to trust it sees this.").option("--revoke", "Withdraw the spec's attestation instead of recording one.").option(...hubUrlOption).option(...hubTokenOption).option("--project <name>", "Hub project. Defaults to the current directory's name.").option("--cwd <path>", "Directory the default --project name is resolved against.").action(withHubErrors(async (rawSpecId, opts) => {
|
|
9595
|
+
const project = resolveProject(opts);
|
|
9596
|
+
const hub = connect(opts);
|
|
9597
|
+
let specId;
|
|
9598
|
+
try {
|
|
9599
|
+
const parsed = parseSpecPath(rawSpecId);
|
|
9600
|
+
specId = `${parsed.featureName}/${parsed.specName}`;
|
|
9601
|
+
} catch (err) {
|
|
9602
|
+
error(errMessage(err));
|
|
9603
|
+
process.exit(2);
|
|
9604
|
+
}
|
|
9605
|
+
if (opts.revoke) {
|
|
9606
|
+
await hub.deleteAttestation(project, { profile: opts.profile }, specId);
|
|
9607
|
+
header("hub attest", `${specId} revoked`);
|
|
9608
|
+
return;
|
|
9609
|
+
}
|
|
9610
|
+
if (!opts.by) {
|
|
9611
|
+
error("--by <name> is required: an attestation is a person's word, and it needs the person");
|
|
9612
|
+
process.exit(2);
|
|
9613
|
+
}
|
|
9614
|
+
const res = await hub.putAttestation(project, { profile: opts.profile }, {
|
|
9615
|
+
spec: specId,
|
|
9616
|
+
by: opts.by,
|
|
9617
|
+
...opts.note !== void 0 ? { note: opts.note } : {}
|
|
9618
|
+
});
|
|
9619
|
+
header("hub attest", specId);
|
|
9620
|
+
meta("by", res.attestation.by);
|
|
9621
|
+
meta("anchored to deploy", res.attestation.deployedSha ?? "(no deploy log)");
|
|
9622
|
+
info("the verdict answers manuallyVerified until a deploy reaches this spec or the spec is edited");
|
|
9623
|
+
}));
|
|
9624
|
+
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(costCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand).addCommand(attestCommand);
|
|
9522
9625
|
/** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
|
|
9523
9626
|
function isStorageStateShape(state) {
|
|
9524
9627
|
return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
|
|
@@ -15903,6 +16006,21 @@ For a \`mode: live\` spec there is no generated surface, so always \`spec\`.
|
|
|
15903
16006
|
|
|
15904
16007
|
${surfaceAxisAside("`TEST_DRIFT`")}
|
|
15905
16008
|
|
|
16009
|
+
## What the \`replay-unstable\` comments are
|
|
16010
|
+
|
|
16011
|
+
Generated code may carry \`// [warn] replay-unstable: ...\` comments. These are
|
|
16012
|
+
observations from the one validation replay run right after recording — a
|
|
16013
|
+
selector that did not appear within its timeout *in that run*, on that day's
|
|
16014
|
+
data and load. They are diagnostic breadcrumbs, not part of the test, and a
|
|
16015
|
+
slow environment produces them on selectors that are perfectly correct.
|
|
16016
|
+
|
|
16017
|
+
Judge the selector the comment sits on like any other: find its string in the
|
|
16018
|
+
source. If it is there, the comment alone is **not** drift evidence — do not
|
|
16019
|
+
cite a \`replay-unstable\` comment as your evidence for TEST_DRIFT. If the
|
|
16020
|
+
string is genuinely absent from the source, the finding stands on that
|
|
16021
|
+
absence, with the source as the citation, whether or not a comment happens to
|
|
16022
|
+
sit nearby.
|
|
16023
|
+
|
|
15906
16024
|
## Earning each answer
|
|
15907
16025
|
|
|
15908
16026
|
- **No drift is a claim, not a default.** Make it after picking the concrete strings from *every* surface you were given — the spec's \`expected\` and the generated code's selectors alike — and finding each of them in the source. Clearing the test case because one surface checked out is the most common way to miss a real finding. If you never looked, the honest answer is UNKNOWN.
|
|
@@ -17139,6 +17257,7 @@ async function buildSkeleton(tree) {
|
|
|
17139
17257
|
specName: s.specName,
|
|
17140
17258
|
title: meta.title,
|
|
17141
17259
|
summary: "",
|
|
17260
|
+
...meta.steps.length > 0 ? { steps: meta.steps } : {},
|
|
17142
17261
|
status,
|
|
17143
17262
|
...lastEdit ? { changedAt: lastEdit } : {}
|
|
17144
17263
|
};
|
|
@@ -17212,7 +17331,8 @@ function noteKey(featureName, specName) {
|
|
|
17212
17331
|
function readSpecMeta(specName, specYaml) {
|
|
17213
17332
|
if (specYaml === null) return {
|
|
17214
17333
|
title: specName,
|
|
17215
|
-
mode: DEFAULT_SPEC_MODE
|
|
17334
|
+
mode: DEFAULT_SPEC_MODE,
|
|
17335
|
+
steps: []
|
|
17216
17336
|
};
|
|
17217
17337
|
try {
|
|
17218
17338
|
const parsed = parse(specYaml);
|
|
@@ -17220,16 +17340,39 @@ function readSpecMeta(specName, specYaml) {
|
|
|
17220
17340
|
const modeResult = SpecModeSchema.safeParse(parsed.mode);
|
|
17221
17341
|
return {
|
|
17222
17342
|
title,
|
|
17223
|
-
mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
|
|
17343
|
+
mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE,
|
|
17344
|
+
steps: transcribeSteps(parsed.steps)
|
|
17224
17345
|
};
|
|
17225
17346
|
} catch {
|
|
17226
17347
|
return {
|
|
17227
17348
|
title: specName,
|
|
17228
|
-
mode: DEFAULT_SPEC_MODE
|
|
17349
|
+
mode: DEFAULT_SPEC_MODE,
|
|
17350
|
+
steps: []
|
|
17229
17351
|
};
|
|
17230
17352
|
}
|
|
17231
17353
|
}
|
|
17232
17354
|
/**
|
|
17355
|
+
* The spec's procedure, copied verbatim for the inventory: an include step
|
|
17356
|
+
* keeps only the block name (its params are wiring, not procedure), an
|
|
17357
|
+
* action step keeps its instruction/expected text. Anything malformed is
|
|
17358
|
+
* skipped — the inventory never fails over one bad step, matching how the
|
|
17359
|
+
* rest of this sweep treats a broken spec.
|
|
17360
|
+
*/
|
|
17361
|
+
function transcribeSteps(raw) {
|
|
17362
|
+
if (!Array.isArray(raw)) return [];
|
|
17363
|
+
const steps = [];
|
|
17364
|
+
for (const step of raw) {
|
|
17365
|
+
if (typeof step !== "object" || step === null) continue;
|
|
17366
|
+
const s = step;
|
|
17367
|
+
if (typeof s.include === "string" && s.include.length > 0) steps.push({ include: s.include });
|
|
17368
|
+
else if (typeof s.instruction === "string" && s.instruction.length > 0) steps.push({
|
|
17369
|
+
instruction: s.instruction,
|
|
17370
|
+
...typeof s.expected === "string" && s.expected.length > 0 ? { expected: s.expected } : {}
|
|
17371
|
+
});
|
|
17372
|
+
}
|
|
17373
|
+
return steps;
|
|
17374
|
+
}
|
|
17375
|
+
/**
|
|
17233
17376
|
* Resolve a spec's generation target for coverage derivation, from its
|
|
17234
17377
|
* already-read spec.yaml. Best-effort: an unparseable spec or a target that
|
|
17235
17378
|
* can't be resolved (unknown id, agent-browser-only field misuse) falls back
|
|
@@ -18331,7 +18474,7 @@ function requireKey(config) {
|
|
|
18331
18474
|
return config.encryptionKey;
|
|
18332
18475
|
}
|
|
18333
18476
|
/** Validate the `:project`/`:profile` route params into a store scope. */
|
|
18334
|
-
function requireScope(ctx) {
|
|
18477
|
+
function requireScope$1(ctx) {
|
|
18335
18478
|
return {
|
|
18336
18479
|
project: requireSafeSegment(ctx.params.project, "project"),
|
|
18337
18480
|
profile: requireSafeSegment(ctx.params.profile, "profile")
|
|
@@ -18341,7 +18484,7 @@ function requireScope(ctx) {
|
|
|
18341
18484
|
function createPutSessionHandler(config) {
|
|
18342
18485
|
return async (ctx) => {
|
|
18343
18486
|
const key = requireKey(config);
|
|
18344
|
-
const scope = requireScope(ctx);
|
|
18487
|
+
const scope = requireScope$1(ctx);
|
|
18345
18488
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18346
18489
|
const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
|
|
18347
18490
|
const blob = encodeEncryptedBlob(encrypt(new Uint8Array(body), key));
|
|
@@ -18353,7 +18496,7 @@ function createPutSessionHandler(config) {
|
|
|
18353
18496
|
/** GET /api/v1/projects/:project/sessions/:profile — metadata only (names + timestamps). */
|
|
18354
18497
|
function createListSessionsHandler(config) {
|
|
18355
18498
|
return async (ctx) => {
|
|
18356
|
-
const scope = requireScope(ctx);
|
|
18499
|
+
const scope = requireScope$1(ctx);
|
|
18357
18500
|
const entries = await config.store.list(scope);
|
|
18358
18501
|
sendJson(ctx.res, 200, { sessions: entries.map((e) => ({
|
|
18359
18502
|
name: e.name,
|
|
@@ -18370,7 +18513,7 @@ function createListSessionsHandler(config) {
|
|
|
18370
18513
|
function createGetSessionHandler(config) {
|
|
18371
18514
|
return async (ctx) => {
|
|
18372
18515
|
const key = requireKey(config);
|
|
18373
|
-
const scope = requireScope(ctx);
|
|
18516
|
+
const scope = requireScope$1(ctx);
|
|
18374
18517
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18375
18518
|
const stored = await config.store.get(scope, name);
|
|
18376
18519
|
if (!stored) throw new HttpError(404, "not_found", `session "${name}" not found for ${scope.project}/${scope.profile}`);
|
|
@@ -18381,7 +18524,7 @@ function createGetSessionHandler(config) {
|
|
|
18381
18524
|
/** DELETE /api/v1/projects/:project/sessions/:profile/:name */
|
|
18382
18525
|
function createDeleteSessionHandler(config) {
|
|
18383
18526
|
return async (ctx) => {
|
|
18384
|
-
const scope = requireScope(ctx);
|
|
18527
|
+
const scope = requireScope$1(ctx);
|
|
18385
18528
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18386
18529
|
await config.store.delete(scope, name);
|
|
18387
18530
|
ctx.res.statusCode = 204;
|
|
@@ -18392,7 +18535,7 @@ function createDeleteSessionHandler(config) {
|
|
|
18392
18535
|
function createPutVariableHandler(config) {
|
|
18393
18536
|
return async (ctx) => {
|
|
18394
18537
|
const key = requireKey(config);
|
|
18395
|
-
const scope = requireScope(ctx);
|
|
18538
|
+
const scope = requireScope$1(ctx);
|
|
18396
18539
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18397
18540
|
const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
|
|
18398
18541
|
const parsed = PutVariableRequestSchema.safeParse(JSON.parse(body.toString("utf8") || "{}"));
|
|
@@ -18412,7 +18555,7 @@ function createPutVariableHandler(config) {
|
|
|
18412
18555
|
*/
|
|
18413
18556
|
function createListVariablesHandler(config) {
|
|
18414
18557
|
return async (ctx) => {
|
|
18415
|
-
const scope = requireScope(ctx);
|
|
18558
|
+
const scope = requireScope$1(ctx);
|
|
18416
18559
|
const includeValues = ctx.url.searchParams.get("include") === "values";
|
|
18417
18560
|
const key = includeValues ? requireKey(config) : config.encryptionKey;
|
|
18418
18561
|
const entries = await config.store.list(scope);
|
|
@@ -18447,7 +18590,7 @@ function createListVariablesHandler(config) {
|
|
|
18447
18590
|
/** DELETE /api/v1/projects/:project/variables/:profile/:name */
|
|
18448
18591
|
function createDeleteVariableHandler(config) {
|
|
18449
18592
|
return async (ctx) => {
|
|
18450
|
-
const scope = requireScope(ctx);
|
|
18593
|
+
const scope = requireScope$1(ctx);
|
|
18451
18594
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18452
18595
|
await config.store.delete(scope, name);
|
|
18453
18596
|
ctx.res.statusCode = 204;
|
|
@@ -18786,7 +18929,7 @@ function createGetAuditNeedHandler(storage) {
|
|
|
18786
18929
|
//#endregion
|
|
18787
18930
|
//#region src/hub/api/handlers/locks.ts
|
|
18788
18931
|
/** A spec-key list and three short strings; nothing here should approach this. */
|
|
18789
|
-
const MAX_BODY_BYTES$
|
|
18932
|
+
const MAX_BODY_BYTES$4 = 1024 * 1024;
|
|
18790
18933
|
/**
|
|
18791
18934
|
* POST /api/v1/projects/:project/locks?profile=
|
|
18792
18935
|
*
|
|
@@ -18800,7 +18943,7 @@ function createAcquireLocksHandler(storage) {
|
|
|
18800
18943
|
return async (ctx) => {
|
|
18801
18944
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18802
18945
|
const profile = requireProfileParam(ctx.url);
|
|
18803
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
18946
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, AcquireLocksRequestSchema, "lock request");
|
|
18804
18947
|
let result = {
|
|
18805
18948
|
granted: [],
|
|
18806
18949
|
denied: []
|
|
@@ -18829,7 +18972,7 @@ function createReleaseLocksHandler(storage) {
|
|
|
18829
18972
|
return async (ctx) => {
|
|
18830
18973
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18831
18974
|
const profile = requireProfileParam(ctx.url);
|
|
18832
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
18975
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, ReleaseLocksRequestSchema, "release request");
|
|
18833
18976
|
await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
|
|
18834
18977
|
ctx.res.writeHead(204).end();
|
|
18835
18978
|
};
|
|
@@ -18842,7 +18985,7 @@ function createReleaseLocksHandler(storage) {
|
|
|
18842
18985
|
* 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
|
|
18843
18986
|
* never answered 413 by a limit the documented bounds don't mention.
|
|
18844
18987
|
*/
|
|
18845
|
-
const MAX_BODY_BYTES$
|
|
18988
|
+
const MAX_BODY_BYTES$3 = 8 * 1024 * 1024;
|
|
18846
18989
|
function requireAckKey(ctx) {
|
|
18847
18990
|
return {
|
|
18848
18991
|
project: requireSafeSegment(ctx.params.project, "project"),
|
|
@@ -18869,7 +19012,7 @@ function createGetAckHandler(storage) {
|
|
|
18869
19012
|
function createPutAckHandler(storage) {
|
|
18870
19013
|
return async (ctx) => {
|
|
18871
19014
|
const key = requireAckKey(ctx);
|
|
18872
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19015
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAckRequestSchema, "ack body");
|
|
18873
19016
|
const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
|
|
18874
19017
|
sendJson(ctx.res, 200, {
|
|
18875
19018
|
...key,
|
|
@@ -18878,6 +19021,80 @@ function createPutAckHandler(storage) {
|
|
|
18878
19021
|
};
|
|
18879
19022
|
}
|
|
18880
19023
|
//#endregion
|
|
19024
|
+
//#region src/hub/api/handlers/attestations.ts
|
|
19025
|
+
/** Far above the largest body `PutAttestationRequestSchema`'s bounds admit. */
|
|
19026
|
+
const MAX_BODY_BYTES$2 = 64 * 1024;
|
|
19027
|
+
function requireScope(ctx) {
|
|
19028
|
+
return {
|
|
19029
|
+
project: requireSafeSegment(ctx.params.project, "project"),
|
|
19030
|
+
profile: requireProfileParam(ctx.url)
|
|
19031
|
+
};
|
|
19032
|
+
}
|
|
19033
|
+
/**
|
|
19034
|
+
* GET /api/v1/projects/:project/attestations?profile= — the raw document,
|
|
19035
|
+
* standing and lapsed alike. Whether one still covers its spec is `/rerun`'s
|
|
19036
|
+
* answer; this exists so a lapsed attestation can still be found and revoked.
|
|
19037
|
+
*/
|
|
19038
|
+
function createGetAttestationsHandler(storage) {
|
|
19039
|
+
return async (ctx) => {
|
|
19040
|
+
const scope = requireScope(ctx);
|
|
19041
|
+
const doc = await storage.attestations.get(scope.project, scope.profile);
|
|
19042
|
+
sendJson(ctx.res, 200, {
|
|
19043
|
+
...scope,
|
|
19044
|
+
specs: doc.specs
|
|
19045
|
+
});
|
|
19046
|
+
};
|
|
19047
|
+
}
|
|
19048
|
+
/**
|
|
19049
|
+
* PUT /api/v1/projects/:project/attestations?profile= — record that a person
|
|
19050
|
+
* checked a spec by hand. The hub stamps the time and the profile's deploy
|
|
19051
|
+
* head: the anchor must be what the hub knows was deployed at this moment,
|
|
19052
|
+
* not what the caller believes. Replaces any previous attestation for the
|
|
19053
|
+
* spec.
|
|
19054
|
+
*/
|
|
19055
|
+
function createPutAttestationHandler(storage) {
|
|
19056
|
+
return async (ctx) => {
|
|
19057
|
+
const scope = requireScope(ctx);
|
|
19058
|
+
const [body, head, targets] = await Promise.all([
|
|
19059
|
+
readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAttestationRequestSchema, "attestation body"),
|
|
19060
|
+
storage.deploys.head(scope.project, scope.profile),
|
|
19061
|
+
requireSpecTargets(storage.perspectives, scope.project, "what can be attested")
|
|
19062
|
+
]);
|
|
19063
|
+
if (!targets.some((target) => target.key === body.spec)) throw new HttpError(400, "unknown_spec", `spec "${body.spec}" is not in project "${scope.project}"'s perspectives document — attest with the canonical feature/spec key, or push \`ccqa perspectives\` first`);
|
|
19064
|
+
const attestation = {
|
|
19065
|
+
by: body.by,
|
|
19066
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
19067
|
+
...body.note !== void 0 ? { note: body.note } : {},
|
|
19068
|
+
deployedSha: head?.sha ?? null
|
|
19069
|
+
};
|
|
19070
|
+
await storage.attestations.update(scope.project, scope.profile, (current) => ({ specs: {
|
|
19071
|
+
...current.specs,
|
|
19072
|
+
[body.spec]: attestation
|
|
19073
|
+
} }));
|
|
19074
|
+
sendJson(ctx.res, 200, {
|
|
19075
|
+
...scope,
|
|
19076
|
+
spec: body.spec,
|
|
19077
|
+
attestation
|
|
19078
|
+
});
|
|
19079
|
+
};
|
|
19080
|
+
}
|
|
19081
|
+
/**
|
|
19082
|
+
* DELETE /api/v1/projects/:project/attestations?profile= — revoke a spec's
|
|
19083
|
+
* attestation. Deleting one that does not exist is 200 like deleting one that
|
|
19084
|
+
* does: the caller asked for its absence, and it is absent.
|
|
19085
|
+
*/
|
|
19086
|
+
function createDeleteAttestationHandler(storage) {
|
|
19087
|
+
return async (ctx) => {
|
|
19088
|
+
const scope = requireScope(ctx);
|
|
19089
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, DeleteAttestationRequestSchema, "attestation body");
|
|
19090
|
+
await storage.attestations.update(scope.project, scope.profile, (current) => {
|
|
19091
|
+
const { [body.spec]: _, ...rest } = current.specs;
|
|
19092
|
+
return { specs: rest };
|
|
19093
|
+
});
|
|
19094
|
+
sendJson(ctx.res, 200, { removed: body.spec });
|
|
19095
|
+
};
|
|
19096
|
+
}
|
|
19097
|
+
//#endregion
|
|
18881
19098
|
//#region src/hub/api/handlers/spend.ts
|
|
18882
19099
|
/** One entry is a handful of short fields; anything larger is a malformed client. */
|
|
18883
19100
|
const MAX_BODY_BYTES$1 = 4 * 1024;
|
|
@@ -18950,7 +19167,7 @@ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
|
|
|
18950
19167
|
return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
|
|
18951
19168
|
}
|
|
18952
19169
|
function computeRerun(input) {
|
|
18953
|
-
const { specs, ledger, log, touchIndex, drift, locks, now } = input;
|
|
19170
|
+
const { specs, ledger, log, touchIndex, drift, locks, attestations, now } = input;
|
|
18954
19171
|
const range = buildRange(log, touchIndex);
|
|
18955
19172
|
const deployTimes = deployedAt(log);
|
|
18956
19173
|
const out = {};
|
|
@@ -18968,11 +19185,23 @@ function computeRerun(input) {
|
|
|
18968
19185
|
if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
|
|
18969
19186
|
if (runMoved && execution.execution === "passed") execution = { execution: "stale" };
|
|
18970
19187
|
const held = heldBy(locks, spec.key, now);
|
|
19188
|
+
let verdict = decide(audit.audit, execution.execution, held);
|
|
19189
|
+
const manualState = readAttestation(attestations.specs[spec.key], spec, coords.lastRed, range, log, deployTimes);
|
|
19190
|
+
if (manualState?.kind === "covers" && !held && verdict !== "verified") verdict = "manuallyVerified";
|
|
18971
19191
|
out[spec.key] = {
|
|
18972
|
-
verdict
|
|
19192
|
+
verdict,
|
|
18973
19193
|
...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
|
|
18974
19194
|
...audit,
|
|
18975
19195
|
...execution,
|
|
19196
|
+
...manualState?.kind === "covers" ? { manual: manualState.attest } : {},
|
|
19197
|
+
...manualState?.kind === "lapsed" ? {
|
|
19198
|
+
manualLapsed: {
|
|
19199
|
+
...manualState.attest,
|
|
19200
|
+
because: manualState.because
|
|
19201
|
+
},
|
|
19202
|
+
...manualState.because === "deployReached" ? { manualLapsedByDeploy: manualState.byDeploy } : {},
|
|
19203
|
+
...manualState.reason ? { manualLapsedReason: manualState.reason } : {}
|
|
19204
|
+
} : {},
|
|
18976
19205
|
heldBy: held,
|
|
18977
19206
|
...coords
|
|
18978
19207
|
};
|
|
@@ -18980,6 +19209,50 @@ function computeRerun(input) {
|
|
|
18980
19209
|
return out;
|
|
18981
19210
|
}
|
|
18982
19211
|
/**
|
|
19212
|
+
* Does the attestation still speak for what is deployed? Checked in the order
|
|
19213
|
+
* the lapse enum documents: deploy coverage first (a sha the log cannot place
|
|
19214
|
+
* reads as reached, ADR-0014, with the hole kept as an annotation), then the
|
|
19215
|
+
* spec's own edits — compared against when the person looked, because they
|
|
19216
|
+
* read the spec as it stood that moment, which `specMovedSince` covers via a
|
|
19217
|
+
* null baseline sha — then a red run recorded after them, which is newer
|
|
19218
|
+
* information than their word. The null-sha case is the profile that had no
|
|
19219
|
+
* deploy log when they checked: their word covers exactly as long as that
|
|
19220
|
+
* stays true.
|
|
19221
|
+
*/
|
|
19222
|
+
function readAttestation(attest, spec, lastRed, range, log, deployTimes) {
|
|
19223
|
+
if (!attest) return null;
|
|
19224
|
+
const coverage = attest.deployedSha === null ? log.entries.length === 0 ? { kind: "current" } : {
|
|
19225
|
+
kind: "unanswerable",
|
|
19226
|
+
reason: "unknownDeployedSha"
|
|
19227
|
+
} : freshness(attest.deployedSha, spec.key, range);
|
|
19228
|
+
if (coverage.kind === "touched") return {
|
|
19229
|
+
kind: "lapsed",
|
|
19230
|
+
attest,
|
|
19231
|
+
because: "deployReached",
|
|
19232
|
+
byDeploy: coverage.touchedByDeploy
|
|
19233
|
+
};
|
|
19234
|
+
if (coverage.kind === "unanswerable") return {
|
|
19235
|
+
kind: "lapsed",
|
|
19236
|
+
attest,
|
|
19237
|
+
because: "cannotPlace",
|
|
19238
|
+
reason: coverage.reason
|
|
19239
|
+
};
|
|
19240
|
+
if (specMovedSince(spec.changedAt, null, attest.at, deployTimes)) return {
|
|
19241
|
+
kind: "lapsed",
|
|
19242
|
+
attest,
|
|
19243
|
+
because: "specEdited"
|
|
19244
|
+
};
|
|
19245
|
+
if (lastRed !== null && lastRed.at > attest.at) return {
|
|
19246
|
+
kind: "lapsed",
|
|
19247
|
+
attest,
|
|
19248
|
+
because: "newerRed"
|
|
19249
|
+
};
|
|
19250
|
+
return {
|
|
19251
|
+
kind: "covers",
|
|
19252
|
+
attest
|
|
19253
|
+
};
|
|
19254
|
+
}
|
|
19255
|
+
/**
|
|
18983
19256
|
* Axis 1, derived from the same freshness answer `--only-hub-audit-needed`
|
|
18984
19257
|
* reads. The label only speaks once the audit is known to be current: a
|
|
18985
19258
|
* verdict about an older commit says nothing about the one running now.
|
|
@@ -19083,13 +19356,14 @@ function createGetRerunHandler(storage) {
|
|
|
19083
19356
|
return async (ctx) => {
|
|
19084
19357
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19085
19358
|
const profile = requireProfileParam(ctx.url);
|
|
19086
|
-
const [specs, ledger, log, touchIndex, drift, locks] = await Promise.all([
|
|
19359
|
+
const [specs, ledger, log, touchIndex, drift, locks, attestations] = await Promise.all([
|
|
19087
19360
|
requireSpecTargets(storage.perspectives, project, "which specs need a re-run"),
|
|
19088
19361
|
storage.ledger.getMerged(project, profile),
|
|
19089
19362
|
storage.deploys.getLog(project, profile),
|
|
19090
19363
|
storage.deploys.getTouchIndex(project, profile),
|
|
19091
19364
|
storage.driftLedger.getMerged(project),
|
|
19092
|
-
storage.locks.get(project, profile)
|
|
19365
|
+
storage.locks.get(project, profile),
|
|
19366
|
+
storage.attestations.get(project, profile)
|
|
19093
19367
|
]);
|
|
19094
19368
|
const head = log.entries[log.entries.length - 1];
|
|
19095
19369
|
sendJson(ctx.res, 200, {
|
|
@@ -19103,6 +19377,7 @@ function createGetRerunHandler(storage) {
|
|
|
19103
19377
|
touchIndex,
|
|
19104
19378
|
drift,
|
|
19105
19379
|
locks,
|
|
19380
|
+
attestations,
|
|
19106
19381
|
now: /* @__PURE__ */ new Date()
|
|
19107
19382
|
})
|
|
19108
19383
|
});
|
|
@@ -19827,6 +20102,7 @@ const HTML_BODY = `
|
|
|
19827
20102
|
<button class="fchip" id="persp-chip-needsrepair" data-f="needsRepair" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.needsRepair">Needs repair</span><span class="fcount"></span></button>
|
|
19828
20103
|
<button class="fchip" id="persp-chip-rerunneeded" data-f="rerunNeeded" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.rerunNeeded">Re-run needed</span><span class="fcount"></span></button>
|
|
19829
20104
|
<button class="fchip" id="persp-chip-inprogress" data-f="inProgress" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.inProgress">In progress</span><span class="fcount"></span></button>
|
|
20105
|
+
<button class="fchip" id="persp-chip-manuallyverified" data-f="manuallyVerified" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.manuallyVerified">Manually verified</span><span class="fcount"></span></button>
|
|
19830
20106
|
<button class="fchip" id="persp-chip-verified" data-f="verified" aria-pressed="false" type="button"><span data-i18n="perspectives.rerun.state.verified">Verified</span><span class="fcount"></span></button>
|
|
19831
20107
|
</div>
|
|
19832
20108
|
<div class="spacer"></div>
|
|
@@ -20552,6 +20828,9 @@ const CSS = `
|
|
|
20552
20828
|
.sg-audit-undecided { background: var(--info); }
|
|
20553
20829
|
.sg-verified, .sg-audit-clean, .sg-exec-passed { background: var(--pass); }
|
|
20554
20830
|
.sg-inprogress, .sg-audit-due, .sg-exec-never { background: var(--muted-2); }
|
|
20831
|
+
/* A person's word, not the machine's — kept off the pass/fail palette both
|
|
20832
|
+
axes share so it never reads as either one. */
|
|
20833
|
+
.sg-manual { background: var(--violet); }
|
|
20555
20834
|
|
|
20556
20835
|
.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); }
|
|
20557
20836
|
.search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
|
|
@@ -20590,6 +20869,11 @@ const CSS = `
|
|
|
20590
20869
|
.badge.rr-unknown .d { background: var(--info); }
|
|
20591
20870
|
.badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
20592
20871
|
.badge.rr-none .d { background: var(--muted); }
|
|
20872
|
+
/* A person's attestation, not the audit/run pipeline's own verdict — kept
|
|
20873
|
+
off that palette (rr-repair/rr-needed/rr-none/passed) the same way
|
|
20874
|
+
sg-manual is kept off the summary bar's. */
|
|
20875
|
+
.badge.rr-manual { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
|
|
20876
|
+
.badge.rr-manual .d { background: var(--violet); }
|
|
20593
20877
|
.cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
|
|
20594
20878
|
.graded-mark { color: var(--fg-dim); font-weight: 600; }
|
|
20595
20879
|
.cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
|
|
@@ -20645,6 +20929,11 @@ const CSS = `
|
|
|
20645
20929
|
.d-paths { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
20646
20930
|
.d-paths code { white-space: nowrap; }
|
|
20647
20931
|
.d-prose + .d-paths { margin-top: 6px; }
|
|
20932
|
+
.manual-attest { margin-top: 14px; }
|
|
20933
|
+
.steps-box { margin-top: 14px; max-width: 900px; }
|
|
20934
|
+
.steps-box .slabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
20935
|
+
.d-steps { margin: 4px 0 0; padding-left: 18px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; color: var(--fg-dim); white-space: pre-line; }
|
|
20936
|
+
.d-steps .step-expected { display: block; margin-top: 2px; font-size: 12.5px; }
|
|
20648
20937
|
.notebox { margin-top: 14px; max-width: 900px; }
|
|
20649
20938
|
.notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
20650
20939
|
.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; }
|
|
@@ -20685,6 +20974,7 @@ const CLIENT_JS = `
|
|
|
20685
20974
|
var THEME_KEY = "ccqa-hub-theme";
|
|
20686
20975
|
var PROJECT_KEY = "ccqa-hub-project";
|
|
20687
20976
|
var PROFILES_KEY = "ccqa-hub-profiles";
|
|
20977
|
+
var ATTEST_BY_KEY = "ccqa-attest-by";
|
|
20688
20978
|
|
|
20689
20979
|
// ── i18n ──────────────────────────────────────────────────────────────
|
|
20690
20980
|
// Chrome + labels only. Model output (headline/recommendation/reasoning) is
|
|
@@ -20773,6 +21063,8 @@ const CLIENT_JS = `
|
|
|
20773
21063
|
"perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
|
|
20774
21064
|
"perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
|
|
20775
21065
|
"perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
|
|
21066
|
+
"perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
|
|
21067
|
+
"perspectives.d.stepExpected": "Expected:",
|
|
20776
21068
|
"perspectives.note.label": "Note",
|
|
20777
21069
|
"perspectives.note.placeholder": "Notes about this case…",
|
|
20778
21070
|
"perspectives.note.saved": "Saved",
|
|
@@ -20785,6 +21077,7 @@ const CLIENT_JS = `
|
|
|
20785
21077
|
"perspectives.rerun.state.needsRepair": "Needs repair",
|
|
20786
21078
|
"perspectives.rerun.state.rerunNeeded": "Re-run needed",
|
|
20787
21079
|
"perspectives.rerun.state.inProgress": "In progress",
|
|
21080
|
+
"perspectives.rerun.state.manuallyVerified": "Manually verified",
|
|
20788
21081
|
"perspectives.rerun.state.verified": "Verified",
|
|
20789
21082
|
"perspectives.rerun.vsDeploy": "judged against deploy",
|
|
20790
21083
|
"perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
|
|
@@ -20820,6 +21113,19 @@ const CLIENT_JS = `
|
|
|
20820
21113
|
"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.",
|
|
20821
21114
|
"perspectives.rerun.deployHead": "deploy head",
|
|
20822
21115
|
"perspectives.drift.graded": "confirmed",
|
|
21116
|
+
"perspectives.manual.attestButton": "Mark as manually verified",
|
|
21117
|
+
"perspectives.manual.revokeButton": "Revoke manual verification",
|
|
21118
|
+
"perspectives.manual.promptBy": "Who is verifying this?",
|
|
21119
|
+
"perspectives.manual.promptNote": "Note (optional)",
|
|
21120
|
+
"perspectives.manual.confirmRevoke": "Revoke the manual verification for this case?",
|
|
21121
|
+
"perspectives.manual.error": "Could not save — retry",
|
|
21122
|
+
"perspectives.manual.verifiedBy": "{by} manually verified this ({at})",
|
|
21123
|
+
"perspectives.manual.lapsed.deployReached": "the manual verification lapsed when a deploy reached this case",
|
|
21124
|
+
"perspectives.manual.lapsed.deployReachedNamed": "the manual verification lapsed when deploy {sha} reached this case ({at})",
|
|
21125
|
+
"perspectives.manual.lapsed.cannotPlace": "the manual verification lapsed — its baseline deploy could not be placed in the log",
|
|
21126
|
+
"perspectives.manual.lapsed.specEdited": "the manual verification lapsed when the spec was edited",
|
|
21127
|
+
"perspectives.manual.lapsed.newerRed": "the manual verification lapsed after a later run failed",
|
|
21128
|
+
"perspectives.manual.lapsed.unrecognized": "the manual verification lapsed for a reason this UI does not recognise",
|
|
20823
21129
|
"prompt.card.record": "Recording browser actions",
|
|
20824
21130
|
"prompt.card.live": "Live run (AI-driven)",
|
|
20825
21131
|
"prompt.card.playwright": "Playwright test generation",
|
|
@@ -20937,6 +21243,8 @@ const CLIENT_JS = `
|
|
|
20937
21243
|
"perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
|
|
20938
21244
|
"perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
|
|
20939
21245
|
"perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
|
|
21246
|
+
"perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
|
|
21247
|
+
"perspectives.d.stepExpected": "期待結果:",
|
|
20940
21248
|
"perspectives.note.label": "note",
|
|
20941
21249
|
"perspectives.note.placeholder": "このケースについてのメモ…",
|
|
20942
21250
|
"perspectives.note.saved": "保存しました",
|
|
@@ -20949,6 +21257,7 @@ const CLIENT_JS = `
|
|
|
20949
21257
|
"perspectives.rerun.state.needsRepair": "修正待ち",
|
|
20950
21258
|
"perspectives.rerun.state.rerunNeeded": "要再実行",
|
|
20951
21259
|
"perspectives.rerun.state.inProgress": "進行中",
|
|
21260
|
+
"perspectives.rerun.state.manuallyVerified": "手動確認済み",
|
|
20952
21261
|
"perspectives.rerun.state.verified": "検証済み",
|
|
20953
21262
|
"perspectives.rerun.vsDeploy": "判定基準: デプロイ",
|
|
20954
21263
|
"perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
|
|
@@ -20984,6 +21293,19 @@ const CLIENT_JS = `
|
|
|
20984
21293
|
"perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
|
|
20985
21294
|
"perspectives.rerun.deployHead": "最新デプロイ",
|
|
20986
21295
|
"perspectives.drift.graded": "人が確認",
|
|
21296
|
+
"perspectives.manual.attestButton": "手動で確認した",
|
|
21297
|
+
"perspectives.manual.revokeButton": "手動確認を取り消す",
|
|
21298
|
+
"perspectives.manual.promptBy": "確認者名を入力してください",
|
|
21299
|
+
"perspectives.manual.promptNote": "メモ(任意)",
|
|
21300
|
+
"perspectives.manual.confirmRevoke": "このケースの手動確認を取り消しますか?",
|
|
21301
|
+
"perspectives.manual.error": "保存に失敗しました — 再試行してください",
|
|
21302
|
+
"perspectives.manual.verifiedBy": "{by}さんが手動確認({at})",
|
|
21303
|
+
"perspectives.manual.lapsed.deployReached": "手動確認はデプロイの到達により失効",
|
|
21304
|
+
"perspectives.manual.lapsed.deployReachedNamed": "手動確認はデプロイ {sha}({at})の到達により失効",
|
|
21305
|
+
"perspectives.manual.lapsed.cannotPlace": "手動確認は基準デプロイをログで特定できず失効",
|
|
21306
|
+
"perspectives.manual.lapsed.specEdited": "手動確認はspecの編集により失効",
|
|
21307
|
+
"perspectives.manual.lapsed.newerRed": "手動確認は直後の実行失敗により失効",
|
|
21308
|
+
"perspectives.manual.lapsed.unrecognized": "このUIが認識できない理由により手動確認が失効",
|
|
20987
21309
|
"prompt.card.record": "ブラウザ操作の記録",
|
|
20988
21310
|
"prompt.card.live": "ライブ実行(AI操作)",
|
|
20989
21311
|
"prompt.card.playwright": "Playwrightテスト生成",
|
|
@@ -21117,6 +21439,17 @@ const CLIENT_JS = `
|
|
|
21117
21439
|
try { window.localStorage.removeItem(PROJECT_KEY); }
|
|
21118
21440
|
catch (e) { console.warn("ccqa hub: could not clear stored project:", e); }
|
|
21119
21441
|
}
|
|
21442
|
+
|
|
21443
|
+
// Remembered across attestations so the prompt's default is whoever last
|
|
21444
|
+
// used this browser, not a blank field every time.
|
|
21445
|
+
function loadAttestBy() {
|
|
21446
|
+
try { return window.localStorage.getItem(ATTEST_BY_KEY) || ""; }
|
|
21447
|
+
catch (e) { return ""; }
|
|
21448
|
+
}
|
|
21449
|
+
function storeAttestBy(by) {
|
|
21450
|
+
try { window.localStorage.setItem(ATTEST_BY_KEY, by); }
|
|
21451
|
+
catch (e) { /* non-fatal: next prompt just starts blank again */ }
|
|
21452
|
+
}
|
|
21120
21453
|
function loadProfileMap() {
|
|
21121
21454
|
try {
|
|
21122
21455
|
var raw = window.localStorage.getItem(PROFILES_KEY);
|
|
@@ -23065,6 +23398,11 @@ const CLIENT_JS = `
|
|
|
23065
23398
|
"/rerun?profile=" + encodeURIComponent(state.profile);
|
|
23066
23399
|
}
|
|
23067
23400
|
|
|
23401
|
+
function attestationsPath() {
|
|
23402
|
+
return "/api/v1/projects/" + encodeURIComponent(state.project) +
|
|
23403
|
+
"/attestations?profile=" + encodeURIComponent(state.profile);
|
|
23404
|
+
}
|
|
23405
|
+
|
|
23068
23406
|
// Resolves { report } or { note } and never rejects: a hub that predates
|
|
23069
23407
|
// the endpoint costs only the columns it feeds, not the whole tab. A 404
|
|
23070
23408
|
// here can only mean "no such route" — the endpoint's own 404 is "the
|
|
@@ -23143,6 +23481,37 @@ const CLIENT_JS = `
|
|
|
23143
23481
|
return text === prefix + reason ? t(prefix + "unrecognized") : text;
|
|
23144
23482
|
}
|
|
23145
23483
|
|
|
23484
|
+
// Who attested and when, plus their note if they left one — the whole
|
|
23485
|
+
// reason a "manuallyVerified" verdict has no evidence row of its own.
|
|
23486
|
+
function manualAttestationText(manual) {
|
|
23487
|
+
var text = t("perspectives.manual.verifiedBy").replace("{by}", manual.by).replace("{at}", relTime(manual.at));
|
|
23488
|
+
return manual.note ? text + " — " + manual.note : text;
|
|
23489
|
+
}
|
|
23490
|
+
|
|
23491
|
+
// An attestation that no longer covers what is deployed, named alongside
|
|
23492
|
+
// whatever the current verdict's own reason is (rerunCellWhy appends this)
|
|
23493
|
+
// rather than replacing it — the person deciding whether to attest again
|
|
23494
|
+
// needs both: what the axes say now, and that a person's word used to
|
|
23495
|
+
// stand in for it. deployReached is the only reason with a named cause
|
|
23496
|
+
// (manualLapsedByDeploy) to add when the log could confirm one.
|
|
23497
|
+
function rerunManualLapseText(rr) {
|
|
23498
|
+
var lapsed = rr && rr.manualLapsed;
|
|
23499
|
+
if (!lapsed) return null;
|
|
23500
|
+
if (lapsed.because === "deployReached") {
|
|
23501
|
+
var deploy = rr.manualLapsedByDeploy;
|
|
23502
|
+
return deploy
|
|
23503
|
+
? t("perspectives.manual.lapsed.deployReachedNamed").replace("{sha}", shortSha(deploy.sha)).replace("{at}", relTime(deploy.at))
|
|
23504
|
+
: t("perspectives.manual.lapsed.deployReached");
|
|
23505
|
+
}
|
|
23506
|
+
if (lapsed.because === "cannotPlace" && rr.manualLapsedReason) {
|
|
23507
|
+
// The same per-hole vocabulary the axes' assumed-reached annotations
|
|
23508
|
+
// use — an empty deploy log and an unjudged range call for different
|
|
23509
|
+
// next actions, so the shrug names which hole it is.
|
|
23510
|
+
return t("perspectives.manual.lapsed.cannotPlace") + " (" + rerunReasonText("perspectives.rerun.why.", rr.manualLapsedReason) + ")";
|
|
23511
|
+
}
|
|
23512
|
+
return rerunReasonText("perspectives.manual.lapsed.", lapsed.because);
|
|
23513
|
+
}
|
|
23514
|
+
|
|
23146
23515
|
// Every verdict that carries no evidence row explains itself here, in the
|
|
23147
23516
|
// actionable phrasing the detail panel wants. decide() checks heldBy before
|
|
23148
23517
|
// the audit axis, so a spec another job already holds must be explained by
|
|
@@ -23162,6 +23531,7 @@ const CLIENT_JS = `
|
|
|
23162
23531
|
? rerunReasonText("perspectives.rerun.fix.", rr.auditAssumedReached)
|
|
23163
23532
|
: t("perspectives.rerun.inProgressHint");
|
|
23164
23533
|
}
|
|
23534
|
+
if (rr.verdict === "manuallyVerified" && rr.manual) return manualAttestationText(rr.manual);
|
|
23165
23535
|
return rerunReasonText("perspectives.rerun.fix.", rr.verdict);
|
|
23166
23536
|
}
|
|
23167
23537
|
|
|
@@ -23180,16 +23550,23 @@ const CLIENT_JS = `
|
|
|
23180
23550
|
// rather than claiming a deploy matched it.
|
|
23181
23551
|
function rerunCellWhy(rr) {
|
|
23182
23552
|
var head = perspState.rerun && perspState.rerun.deployHead;
|
|
23553
|
+
var why;
|
|
23183
23554
|
if (rr.verdict === "rerunNeeded") {
|
|
23184
|
-
if (rr.executionAssumedReached)
|
|
23185
|
-
if (!rr.touchedBy || !rr.touchedBy.length)
|
|
23186
|
-
|
|
23187
|
-
}
|
|
23188
|
-
|
|
23189
|
-
|
|
23190
|
-
|
|
23555
|
+
if (rr.executionAssumedReached) why = rerunReasonText("perspectives.rerun.why.", rr.executionAssumedReached);
|
|
23556
|
+
else if (!rr.touchedBy || !rr.touchedBy.length) why = t("perspectives.rerun.touchedUnknown");
|
|
23557
|
+
else why = t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
|
|
23558
|
+
} else if (rr.verdict === "verified") {
|
|
23559
|
+
why = !head
|
|
23560
|
+
? t("perspectives.rerun.noDeployHead")
|
|
23561
|
+
: t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
|
|
23562
|
+
} else {
|
|
23563
|
+
why = rerunWhyVerdict(rr);
|
|
23191
23564
|
}
|
|
23192
|
-
|
|
23565
|
+
// A lapsed attestation is worth saying no matter which verdict the axes
|
|
23566
|
+
// landed on afterwards — it is not that verdict's reason, so it is
|
|
23567
|
+
// appended rather than replacing it.
|
|
23568
|
+
var lapse = rerunManualLapseText(rr);
|
|
23569
|
+
return lapse ? why + " · " + lapse : why;
|
|
23193
23570
|
}
|
|
23194
23571
|
|
|
23195
23572
|
|
|
@@ -23203,10 +23580,10 @@ const CLIENT_JS = `
|
|
|
23203
23580
|
// the pipeline still owes, then what needs nothing. "needsRepair" leads
|
|
23204
23581
|
// because it is the only verdict a run cannot clear — someone has to repair
|
|
23205
23582
|
// the spec or the product.
|
|
23206
|
-
var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "verified"];
|
|
23583
|
+
var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "manuallyVerified", "verified"];
|
|
23207
23584
|
var RERUN_SEG_CLASS = {
|
|
23208
23585
|
needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded",
|
|
23209
|
-
inProgress: "sg-inprogress", verified: "sg-verified"
|
|
23586
|
+
inProgress: "sg-inprogress", manuallyVerified: "sg-manual", verified: "sg-verified"
|
|
23210
23587
|
};
|
|
23211
23588
|
|
|
23212
23589
|
// The one rule the summary bar and the verdict filter chips both answer
|
|
@@ -23220,7 +23597,7 @@ const CLIENT_JS = `
|
|
|
23220
23597
|
|
|
23221
23598
|
// One verdict per case, bucketed, via rerunVerdictOf above.
|
|
23222
23599
|
function rerunComposition(verdicts) {
|
|
23223
|
-
var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, verified: 0 };
|
|
23600
|
+
var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, manuallyVerified: 0, verified: 0 };
|
|
23224
23601
|
verdicts.forEach(function (rr) { counts[rerunVerdictOf(rr)] += 1; });
|
|
23225
23602
|
return counts;
|
|
23226
23603
|
}
|
|
@@ -23388,7 +23765,7 @@ const CLIENT_JS = `
|
|
|
23388
23765
|
// takes the attention colour and re-running — machine work — does not.
|
|
23389
23766
|
var VERDICT_BADGE = {
|
|
23390
23767
|
needsRepair: "rr-repair", rerunNeeded: "rr-needed",
|
|
23391
|
-
inProgress: "rr-none", verified: "passed"
|
|
23768
|
+
inProgress: "rr-none", manuallyVerified: "rr-manual", verified: "passed"
|
|
23392
23769
|
};
|
|
23393
23770
|
|
|
23394
23771
|
function perspVerdictCell(rr) {
|
|
@@ -23719,6 +24096,43 @@ const CLIENT_JS = `
|
|
|
23719
24096
|
return wrap;
|
|
23720
24097
|
}
|
|
23721
24098
|
|
|
24099
|
+
// Lets a person's own check stand in for the machine's verdict. prompt()/
|
|
24100
|
+
// confirm() rather than a form: the action is rare and the two fields it
|
|
24101
|
+
// needs are short, so a modal would outweigh what it does. reloadRerun()
|
|
24102
|
+
// is the same profile-scoped refresh a profile switch uses — it re-renders
|
|
24103
|
+
// the whole table, so an open detail panel closes along with it.
|
|
24104
|
+
function submitAttestation(method, body) {
|
|
24105
|
+
apiFetch(attestationsPath(), {
|
|
24106
|
+
method: method,
|
|
24107
|
+
headers: { "Content-Type": "application/json" },
|
|
24108
|
+
body: JSON.stringify(body),
|
|
24109
|
+
}).then(function () { reloadRerun(); })
|
|
24110
|
+
.catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
|
|
24111
|
+
}
|
|
24112
|
+
|
|
24113
|
+
function manualAttestButton(feature, spec) {
|
|
24114
|
+
var btn = el("button", "btn sm primary", t("perspectives.manual.attestButton"));
|
|
24115
|
+
btn.type = "button";
|
|
24116
|
+
btn.addEventListener("click", function () {
|
|
24117
|
+
var by = window.prompt(t("perspectives.manual.promptBy"), loadAttestBy());
|
|
24118
|
+
if (!by) return;
|
|
24119
|
+
storeAttestBy(by);
|
|
24120
|
+
var note = window.prompt(t("perspectives.manual.promptNote"), "");
|
|
24121
|
+
submitAttestation("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note || undefined });
|
|
24122
|
+
});
|
|
24123
|
+
return btn;
|
|
24124
|
+
}
|
|
24125
|
+
|
|
24126
|
+
function manualRevokeButton(feature, spec) {
|
|
24127
|
+
var btn = el("button", "btn ghost sm del", t("perspectives.manual.revokeButton"));
|
|
24128
|
+
btn.type = "button";
|
|
24129
|
+
btn.addEventListener("click", function () {
|
|
24130
|
+
if (!window.confirm(t("perspectives.manual.confirmRevoke"))) return;
|
|
24131
|
+
submitAttestation("DELETE", { spec: perspSpecKey(feature, spec) });
|
|
24132
|
+
});
|
|
24133
|
+
return btn;
|
|
24134
|
+
}
|
|
24135
|
+
|
|
23722
24136
|
// Detail row: a definition list of the case's fields plus the note editor.
|
|
23723
24137
|
// Built with createElement/textContent throughout — every field here is
|
|
23724
24138
|
// API-derived, so none of it may go through innerHTML.
|
|
@@ -23756,6 +24170,44 @@ const CLIENT_JS = `
|
|
|
23756
24170
|
}
|
|
23757
24171
|
frag.appendChild(dl);
|
|
23758
24172
|
|
|
24173
|
+
if (spec.steps && spec.steps.length) {
|
|
24174
|
+
var stepsBox = el("div", "steps-box");
|
|
24175
|
+
stepsBox.appendChild(el("div", "slabel", t("perspectives.d.steps")));
|
|
24176
|
+
var stepsList = el("ol", "d-steps");
|
|
24177
|
+
spec.steps.forEach(function (step) {
|
|
24178
|
+
var li = el("li");
|
|
24179
|
+
if (step.include) {
|
|
24180
|
+
li.textContent = t("perspectives.d.stepInclude").replace("{name}", step.include);
|
|
24181
|
+
} else {
|
|
24182
|
+
li.appendChild(document.createTextNode(step.instruction || ""));
|
|
24183
|
+
if (step.expected) {
|
|
24184
|
+
li.appendChild(el("div", "muted step-expected", t("perspectives.d.stepExpected") + " " + step.expected));
|
|
24185
|
+
}
|
|
24186
|
+
}
|
|
24187
|
+
stepsList.appendChild(li);
|
|
24188
|
+
});
|
|
24189
|
+
stepsBox.appendChild(stepsList);
|
|
24190
|
+
frag.appendChild(stepsBox);
|
|
24191
|
+
}
|
|
24192
|
+
|
|
24193
|
+
// A standing attestation always offers revoke — even when it changed
|
|
24194
|
+
// nothing (a held or machine-verified spec), it exists and must stay
|
|
24195
|
+
// findable. A lapsed one is named right here, beside the attest button
|
|
24196
|
+
// the reader is deciding whether to press.
|
|
24197
|
+
var manualBtn = null;
|
|
24198
|
+
if (rr && rr.manual) manualBtn = manualRevokeButton(feature, spec);
|
|
24199
|
+
else if (rr && (rr.verdict === "needsRepair" || rr.verdict === "rerunNeeded")) manualBtn = manualAttestButton(feature, spec);
|
|
24200
|
+
if (manualBtn) {
|
|
24201
|
+
var manualBox = el("div", "manual-attest");
|
|
24202
|
+
if (rr.manual && rr.verdict !== "manuallyVerified") {
|
|
24203
|
+
manualBox.appendChild(el("div", "d-prose", manualAttestationText(rr.manual)));
|
|
24204
|
+
}
|
|
24205
|
+
var lapseLine = rerunManualLapseText(rr);
|
|
24206
|
+
if (lapseLine) manualBox.appendChild(el("div", "d-prose", lapseLine));
|
|
24207
|
+
manualBox.appendChild(manualBtn);
|
|
24208
|
+
frag.appendChild(manualBox);
|
|
24209
|
+
}
|
|
24210
|
+
|
|
23759
24211
|
var notebox = el("div", "notebox");
|
|
23760
24212
|
notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
|
|
23761
24213
|
var ta = el("textarea");
|
|
@@ -25047,6 +25499,9 @@ function registerRoutes(router, config, queue) {
|
|
|
25047
25499
|
router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
|
|
25048
25500
|
router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
|
|
25049
25501
|
router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
|
|
25502
|
+
router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
|
|
25503
|
+
router.put("/api/v1/projects/:project/attestations", createPutAttestationHandler(storage));
|
|
25504
|
+
router.delete("/api/v1/projects/:project/attestations", createDeleteAttestationHandler(storage));
|
|
25050
25505
|
router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
|
|
25051
25506
|
router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
|
|
25052
25507
|
router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
|
|
@@ -25336,6 +25791,9 @@ function deployTouchIndexPath(root, project, profile) {
|
|
|
25336
25791
|
function specLocksPath(root, project, profile) {
|
|
25337
25792
|
return join(root, "locks", project, profile, "locks.json");
|
|
25338
25793
|
}
|
|
25794
|
+
function attestationsPath(root, project, profile) {
|
|
25795
|
+
return join(root, "attestations", project, profile, "attestations.json");
|
|
25796
|
+
}
|
|
25339
25797
|
function ackPath(root, project, profile, name) {
|
|
25340
25798
|
return join(root, "acks", project, profile, `${name}.json`);
|
|
25341
25799
|
}
|
|
@@ -25377,6 +25835,22 @@ function createFileAckStore(root) {
|
|
|
25377
25835
|
};
|
|
25378
25836
|
}
|
|
25379
25837
|
//#endregion
|
|
25838
|
+
//#region src/hub/core/storage/file/attestation-store.ts
|
|
25839
|
+
function toAttestations(doc) {
|
|
25840
|
+
const parsed = AttestationsSchema.safeParse(doc);
|
|
25841
|
+
return parsed.success ? parsed.data : { specs: {} };
|
|
25842
|
+
}
|
|
25843
|
+
function createFileAttestationStore(root) {
|
|
25844
|
+
return {
|
|
25845
|
+
async get(project, profile) {
|
|
25846
|
+
return toAttestations(await readJson(attestationsPath(root, project, profile)));
|
|
25847
|
+
},
|
|
25848
|
+
async update(project, profile, mutate) {
|
|
25849
|
+
return updateJson(attestationsPath(root, project, profile), (current) => mutate(toAttestations(current)));
|
|
25850
|
+
}
|
|
25851
|
+
};
|
|
25852
|
+
}
|
|
25853
|
+
//#endregion
|
|
25380
25854
|
//#region src/hub/core/storage/file/artifact-store.ts
|
|
25381
25855
|
/**
|
|
25382
25856
|
* Defense-in-depth: `relPath` is expected to already be validated by the
|
|
@@ -25860,7 +26334,8 @@ function createFileHubStorage(dataDir) {
|
|
|
25860
26334
|
deploys: createFileDeployStore(dataDir),
|
|
25861
26335
|
locks: createFileLockStore(dataDir),
|
|
25862
26336
|
acks: createFileAckStore(dataDir),
|
|
25863
|
-
spend: createFileSpendStore(dataDir)
|
|
26337
|
+
spend: createFileSpendStore(dataDir),
|
|
26338
|
+
attestations: createFileAttestationStore(dataDir)
|
|
25864
26339
|
};
|
|
25865
26340
|
}
|
|
25866
26341
|
//#endregion
|
|
@@ -213,14 +213,38 @@ declare const AcquireLocksResponseSchema: z.ZodObject<{
|
|
|
213
213
|
denied: z.ZodArray<z.ZodString>;
|
|
214
214
|
}, z.core.$strip>;
|
|
215
215
|
type AcquireLocksResponse = z.infer<typeof AcquireLocksResponseSchema>;
|
|
216
|
+
/** Answer of PUT (the attestation as stamped) and GET (the whole document). */
|
|
217
|
+
declare const AttestationResponseSchema: z.ZodObject<{
|
|
218
|
+
project: z.ZodString;
|
|
219
|
+
profile: z.ZodString;
|
|
220
|
+
spec: z.ZodString;
|
|
221
|
+
attestation: z.ZodObject<{
|
|
222
|
+
by: z.ZodString;
|
|
223
|
+
at: z.ZodString;
|
|
224
|
+
note: z.ZodOptional<z.ZodString>;
|
|
225
|
+
deployedSha: z.ZodNullable<z.ZodString>;
|
|
226
|
+
}, z.core.$strip>;
|
|
227
|
+
}, z.core.$strip>;
|
|
228
|
+
type AttestationResponse = z.infer<typeof AttestationResponseSchema>;
|
|
229
|
+
declare const AttestationsResponseSchema: z.ZodObject<{
|
|
230
|
+
project: z.ZodString;
|
|
231
|
+
profile: z.ZodString;
|
|
232
|
+
specs: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
233
|
+
by: z.ZodString;
|
|
234
|
+
at: z.ZodString;
|
|
235
|
+
note: z.ZodOptional<z.ZodString>;
|
|
236
|
+
deployedSha: z.ZodNullable<z.ZodString>;
|
|
237
|
+
}, z.core.$strip>>;
|
|
238
|
+
}, z.core.$strip>;
|
|
239
|
+
type AttestationsResponse = z.infer<typeof AttestationsResponseSchema>;
|
|
216
240
|
/** Body of `GET /projects/:project/audit-needed?profile=`: one answer per spec. */
|
|
217
241
|
declare const AuditNeedReportSchema: z.ZodObject<{
|
|
218
242
|
project: z.ZodString;
|
|
219
243
|
profile: z.ZodString;
|
|
220
244
|
specs: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
221
245
|
because: z.ZodEnum<{
|
|
222
|
-
neverAudited: "neverAudited";
|
|
223
246
|
deployReached: "deployReached";
|
|
247
|
+
neverAudited: "neverAudited";
|
|
224
248
|
cannotTell: "cannotTell";
|
|
225
249
|
held: "held";
|
|
226
250
|
current: "current";
|
|
@@ -252,6 +276,7 @@ declare const RerunReportSchema: z.ZodObject<{
|
|
|
252
276
|
needsRepair: "needsRepair";
|
|
253
277
|
rerunNeeded: "rerunNeeded";
|
|
254
278
|
verified: "verified";
|
|
279
|
+
manuallyVerified: "manuallyVerified";
|
|
255
280
|
}>;
|
|
256
281
|
audit: z.ZodEnum<{
|
|
257
282
|
due: "due";
|
|
@@ -288,6 +313,38 @@ declare const RerunReportSchema: z.ZodObject<{
|
|
|
288
313
|
gapInRange: "gapInRange";
|
|
289
314
|
}>>;
|
|
290
315
|
specChangedSince: z.ZodOptional<z.ZodString>;
|
|
316
|
+
manual: z.ZodOptional<z.ZodObject<{
|
|
317
|
+
by: z.ZodString;
|
|
318
|
+
at: z.ZodString;
|
|
319
|
+
note: z.ZodOptional<z.ZodString>;
|
|
320
|
+
deployedSha: z.ZodNullable<z.ZodString>;
|
|
321
|
+
}, z.core.$strip>>;
|
|
322
|
+
manualLapsed: z.ZodOptional<z.ZodObject<{
|
|
323
|
+
by: z.ZodString;
|
|
324
|
+
at: z.ZodString;
|
|
325
|
+
note: z.ZodOptional<z.ZodString>;
|
|
326
|
+
deployedSha: z.ZodNullable<z.ZodString>;
|
|
327
|
+
because: z.ZodEnum<{
|
|
328
|
+
deployReached: "deployReached";
|
|
329
|
+
cannotPlace: "cannotPlace";
|
|
330
|
+
specEdited: "specEdited";
|
|
331
|
+
newerRed: "newerRed";
|
|
332
|
+
}>;
|
|
333
|
+
}, z.core.$strip>>;
|
|
334
|
+
manualLapsedByDeploy: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
335
|
+
index: z.ZodNumber;
|
|
336
|
+
sha: z.ZodString;
|
|
337
|
+
at: z.ZodString;
|
|
338
|
+
}, z.core.$strip>>>;
|
|
339
|
+
manualLapsedReason: z.ZodOptional<z.ZodEnum<{
|
|
340
|
+
noSelectionInRange: "noSelectionInRange";
|
|
341
|
+
selectionUnknown: "selectionUnknown";
|
|
342
|
+
noDeployLog: "noDeployLog";
|
|
343
|
+
unknownDeployedSha: "unknownDeployedSha";
|
|
344
|
+
ambiguousDeployedSha: "ambiguousDeployedSha";
|
|
345
|
+
deployedShaNotInLog: "deployedShaNotInLog";
|
|
346
|
+
gapInRange: "gapInRange";
|
|
347
|
+
}>>;
|
|
291
348
|
heldBy: z.ZodNullable<z.ZodObject<{
|
|
292
349
|
kind: z.ZodEnum<{
|
|
293
350
|
run: "run";
|
|
@@ -997,6 +1054,22 @@ interface HubClient {
|
|
|
997
1054
|
releaseLocks(project: string, q: {
|
|
998
1055
|
profile: string;
|
|
999
1056
|
}, holder: string): Promise<void>;
|
|
1057
|
+
/** Every attestation for the profile, standing and lapsed alike. */
|
|
1058
|
+
getAttestations(project: string, q: {
|
|
1059
|
+
profile: string;
|
|
1060
|
+
}): Promise<AttestationsResponse>;
|
|
1061
|
+
/** Record that a person checked `spec` by hand. The hub stamps the time and deploy head. */
|
|
1062
|
+
putAttestation(project: string, q: {
|
|
1063
|
+
profile: string;
|
|
1064
|
+
}, body: {
|
|
1065
|
+
spec: string;
|
|
1066
|
+
by: string;
|
|
1067
|
+
note?: string;
|
|
1068
|
+
}): Promise<AttestationResponse>;
|
|
1069
|
+
/** Revoke a spec's attestation. Revoking one that does not exist succeeds. */
|
|
1070
|
+
deleteAttestation(project: string, q: {
|
|
1071
|
+
profile: string;
|
|
1072
|
+
}, spec: string): Promise<void>;
|
|
1000
1073
|
/**
|
|
1001
1074
|
* Every spec's last `ccqa audit --report-to-hub` result, keyed by "feature/spec". No
|
|
1002
1075
|
* profile — drift asks whether a spec still describes the code, not
|
|
@@ -182,6 +182,23 @@ function createHubClient(opts) {
|
|
|
182
182
|
body: JSON.stringify({ holder })
|
|
183
183
|
});
|
|
184
184
|
},
|
|
185
|
+
getAttestations(project, q) {
|
|
186
|
+
return json(`${attestationsPath(project)}?${queryString({ profile: q.profile })}`);
|
|
187
|
+
},
|
|
188
|
+
putAttestation(project, q, body) {
|
|
189
|
+
return json(`${attestationsPath(project)}?${queryString({ profile: q.profile })}`, {
|
|
190
|
+
method: "PUT",
|
|
191
|
+
headers: { "Content-Type": "application/json" },
|
|
192
|
+
body: JSON.stringify(body)
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
async deleteAttestation(project, q, spec) {
|
|
196
|
+
await request(`${attestationsPath(project)}?${queryString({ profile: q.profile })}`, {
|
|
197
|
+
method: "DELETE",
|
|
198
|
+
headers: { "Content-Type": "application/json" },
|
|
199
|
+
body: JSON.stringify({ spec })
|
|
200
|
+
});
|
|
201
|
+
},
|
|
185
202
|
getAuditNeed(project, q) {
|
|
186
203
|
return json(`/api/v1/projects/${encodeURIComponent(project)}/audit-needed?${queryString({ profile: q.profile })}`);
|
|
187
204
|
},
|
|
@@ -305,6 +322,9 @@ function spendPath(project) {
|
|
|
305
322
|
function locksPath(project) {
|
|
306
323
|
return `/api/v1/projects/${encodeURIComponent(project)}/locks`;
|
|
307
324
|
}
|
|
325
|
+
function attestationsPath(project) {
|
|
326
|
+
return `/api/v1/projects/${encodeURIComponent(project)}/attestations`;
|
|
327
|
+
}
|
|
308
328
|
/** Perspectives are one document per project: `/api/v1/projects/<project>/perspectives`. */
|
|
309
329
|
function perspectivesPath(project) {
|
|
310
330
|
return `/api/v1/projects/${encodeURIComponent(project)}/perspectives`;
|
package/dist/package.json
CHANGED