ccqa 1.31.4 → 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 +502 -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);
|
|
@@ -17154,6 +17257,7 @@ async function buildSkeleton(tree) {
|
|
|
17154
17257
|
specName: s.specName,
|
|
17155
17258
|
title: meta.title,
|
|
17156
17259
|
summary: "",
|
|
17260
|
+
...meta.steps.length > 0 ? { steps: meta.steps } : {},
|
|
17157
17261
|
status,
|
|
17158
17262
|
...lastEdit ? { changedAt: lastEdit } : {}
|
|
17159
17263
|
};
|
|
@@ -17227,7 +17331,8 @@ function noteKey(featureName, specName) {
|
|
|
17227
17331
|
function readSpecMeta(specName, specYaml) {
|
|
17228
17332
|
if (specYaml === null) return {
|
|
17229
17333
|
title: specName,
|
|
17230
|
-
mode: DEFAULT_SPEC_MODE
|
|
17334
|
+
mode: DEFAULT_SPEC_MODE,
|
|
17335
|
+
steps: []
|
|
17231
17336
|
};
|
|
17232
17337
|
try {
|
|
17233
17338
|
const parsed = parse(specYaml);
|
|
@@ -17235,16 +17340,39 @@ function readSpecMeta(specName, specYaml) {
|
|
|
17235
17340
|
const modeResult = SpecModeSchema.safeParse(parsed.mode);
|
|
17236
17341
|
return {
|
|
17237
17342
|
title,
|
|
17238
|
-
mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
|
|
17343
|
+
mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE,
|
|
17344
|
+
steps: transcribeSteps(parsed.steps)
|
|
17239
17345
|
};
|
|
17240
17346
|
} catch {
|
|
17241
17347
|
return {
|
|
17242
17348
|
title: specName,
|
|
17243
|
-
mode: DEFAULT_SPEC_MODE
|
|
17349
|
+
mode: DEFAULT_SPEC_MODE,
|
|
17350
|
+
steps: []
|
|
17244
17351
|
};
|
|
17245
17352
|
}
|
|
17246
17353
|
}
|
|
17247
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
|
+
/**
|
|
17248
17376
|
* Resolve a spec's generation target for coverage derivation, from its
|
|
17249
17377
|
* already-read spec.yaml. Best-effort: an unparseable spec or a target that
|
|
17250
17378
|
* can't be resolved (unknown id, agent-browser-only field misuse) falls back
|
|
@@ -18346,7 +18474,7 @@ function requireKey(config) {
|
|
|
18346
18474
|
return config.encryptionKey;
|
|
18347
18475
|
}
|
|
18348
18476
|
/** Validate the `:project`/`:profile` route params into a store scope. */
|
|
18349
|
-
function requireScope(ctx) {
|
|
18477
|
+
function requireScope$1(ctx) {
|
|
18350
18478
|
return {
|
|
18351
18479
|
project: requireSafeSegment(ctx.params.project, "project"),
|
|
18352
18480
|
profile: requireSafeSegment(ctx.params.profile, "profile")
|
|
@@ -18356,7 +18484,7 @@ function requireScope(ctx) {
|
|
|
18356
18484
|
function createPutSessionHandler(config) {
|
|
18357
18485
|
return async (ctx) => {
|
|
18358
18486
|
const key = requireKey(config);
|
|
18359
|
-
const scope = requireScope(ctx);
|
|
18487
|
+
const scope = requireScope$1(ctx);
|
|
18360
18488
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18361
18489
|
const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
|
|
18362
18490
|
const blob = encodeEncryptedBlob(encrypt(new Uint8Array(body), key));
|
|
@@ -18368,7 +18496,7 @@ function createPutSessionHandler(config) {
|
|
|
18368
18496
|
/** GET /api/v1/projects/:project/sessions/:profile — metadata only (names + timestamps). */
|
|
18369
18497
|
function createListSessionsHandler(config) {
|
|
18370
18498
|
return async (ctx) => {
|
|
18371
|
-
const scope = requireScope(ctx);
|
|
18499
|
+
const scope = requireScope$1(ctx);
|
|
18372
18500
|
const entries = await config.store.list(scope);
|
|
18373
18501
|
sendJson(ctx.res, 200, { sessions: entries.map((e) => ({
|
|
18374
18502
|
name: e.name,
|
|
@@ -18385,7 +18513,7 @@ function createListSessionsHandler(config) {
|
|
|
18385
18513
|
function createGetSessionHandler(config) {
|
|
18386
18514
|
return async (ctx) => {
|
|
18387
18515
|
const key = requireKey(config);
|
|
18388
|
-
const scope = requireScope(ctx);
|
|
18516
|
+
const scope = requireScope$1(ctx);
|
|
18389
18517
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18390
18518
|
const stored = await config.store.get(scope, name);
|
|
18391
18519
|
if (!stored) throw new HttpError(404, "not_found", `session "${name}" not found for ${scope.project}/${scope.profile}`);
|
|
@@ -18396,7 +18524,7 @@ function createGetSessionHandler(config) {
|
|
|
18396
18524
|
/** DELETE /api/v1/projects/:project/sessions/:profile/:name */
|
|
18397
18525
|
function createDeleteSessionHandler(config) {
|
|
18398
18526
|
return async (ctx) => {
|
|
18399
|
-
const scope = requireScope(ctx);
|
|
18527
|
+
const scope = requireScope$1(ctx);
|
|
18400
18528
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18401
18529
|
await config.store.delete(scope, name);
|
|
18402
18530
|
ctx.res.statusCode = 204;
|
|
@@ -18407,7 +18535,7 @@ function createDeleteSessionHandler(config) {
|
|
|
18407
18535
|
function createPutVariableHandler(config) {
|
|
18408
18536
|
return async (ctx) => {
|
|
18409
18537
|
const key = requireKey(config);
|
|
18410
|
-
const scope = requireScope(ctx);
|
|
18538
|
+
const scope = requireScope$1(ctx);
|
|
18411
18539
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18412
18540
|
const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
|
|
18413
18541
|
const parsed = PutVariableRequestSchema.safeParse(JSON.parse(body.toString("utf8") || "{}"));
|
|
@@ -18427,7 +18555,7 @@ function createPutVariableHandler(config) {
|
|
|
18427
18555
|
*/
|
|
18428
18556
|
function createListVariablesHandler(config) {
|
|
18429
18557
|
return async (ctx) => {
|
|
18430
|
-
const scope = requireScope(ctx);
|
|
18558
|
+
const scope = requireScope$1(ctx);
|
|
18431
18559
|
const includeValues = ctx.url.searchParams.get("include") === "values";
|
|
18432
18560
|
const key = includeValues ? requireKey(config) : config.encryptionKey;
|
|
18433
18561
|
const entries = await config.store.list(scope);
|
|
@@ -18462,7 +18590,7 @@ function createListVariablesHandler(config) {
|
|
|
18462
18590
|
/** DELETE /api/v1/projects/:project/variables/:profile/:name */
|
|
18463
18591
|
function createDeleteVariableHandler(config) {
|
|
18464
18592
|
return async (ctx) => {
|
|
18465
|
-
const scope = requireScope(ctx);
|
|
18593
|
+
const scope = requireScope$1(ctx);
|
|
18466
18594
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18467
18595
|
await config.store.delete(scope, name);
|
|
18468
18596
|
ctx.res.statusCode = 204;
|
|
@@ -18801,7 +18929,7 @@ function createGetAuditNeedHandler(storage) {
|
|
|
18801
18929
|
//#endregion
|
|
18802
18930
|
//#region src/hub/api/handlers/locks.ts
|
|
18803
18931
|
/** A spec-key list and three short strings; nothing here should approach this. */
|
|
18804
|
-
const MAX_BODY_BYTES$
|
|
18932
|
+
const MAX_BODY_BYTES$4 = 1024 * 1024;
|
|
18805
18933
|
/**
|
|
18806
18934
|
* POST /api/v1/projects/:project/locks?profile=
|
|
18807
18935
|
*
|
|
@@ -18815,7 +18943,7 @@ function createAcquireLocksHandler(storage) {
|
|
|
18815
18943
|
return async (ctx) => {
|
|
18816
18944
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18817
18945
|
const profile = requireProfileParam(ctx.url);
|
|
18818
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
18946
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, AcquireLocksRequestSchema, "lock request");
|
|
18819
18947
|
let result = {
|
|
18820
18948
|
granted: [],
|
|
18821
18949
|
denied: []
|
|
@@ -18844,7 +18972,7 @@ function createReleaseLocksHandler(storage) {
|
|
|
18844
18972
|
return async (ctx) => {
|
|
18845
18973
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18846
18974
|
const profile = requireProfileParam(ctx.url);
|
|
18847
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
18975
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, ReleaseLocksRequestSchema, "release request");
|
|
18848
18976
|
await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
|
|
18849
18977
|
ctx.res.writeHead(204).end();
|
|
18850
18978
|
};
|
|
@@ -18857,7 +18985,7 @@ function createReleaseLocksHandler(storage) {
|
|
|
18857
18985
|
* 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
|
|
18858
18986
|
* never answered 413 by a limit the documented bounds don't mention.
|
|
18859
18987
|
*/
|
|
18860
|
-
const MAX_BODY_BYTES$
|
|
18988
|
+
const MAX_BODY_BYTES$3 = 8 * 1024 * 1024;
|
|
18861
18989
|
function requireAckKey(ctx) {
|
|
18862
18990
|
return {
|
|
18863
18991
|
project: requireSafeSegment(ctx.params.project, "project"),
|
|
@@ -18884,7 +19012,7 @@ function createGetAckHandler(storage) {
|
|
|
18884
19012
|
function createPutAckHandler(storage) {
|
|
18885
19013
|
return async (ctx) => {
|
|
18886
19014
|
const key = requireAckKey(ctx);
|
|
18887
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19015
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAckRequestSchema, "ack body");
|
|
18888
19016
|
const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
|
|
18889
19017
|
sendJson(ctx.res, 200, {
|
|
18890
19018
|
...key,
|
|
@@ -18893,6 +19021,80 @@ function createPutAckHandler(storage) {
|
|
|
18893
19021
|
};
|
|
18894
19022
|
}
|
|
18895
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
|
|
18896
19098
|
//#region src/hub/api/handlers/spend.ts
|
|
18897
19099
|
/** One entry is a handful of short fields; anything larger is a malformed client. */
|
|
18898
19100
|
const MAX_BODY_BYTES$1 = 4 * 1024;
|
|
@@ -18965,7 +19167,7 @@ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
|
|
|
18965
19167
|
return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
|
|
18966
19168
|
}
|
|
18967
19169
|
function computeRerun(input) {
|
|
18968
|
-
const { specs, ledger, log, touchIndex, drift, locks, now } = input;
|
|
19170
|
+
const { specs, ledger, log, touchIndex, drift, locks, attestations, now } = input;
|
|
18969
19171
|
const range = buildRange(log, touchIndex);
|
|
18970
19172
|
const deployTimes = deployedAt(log);
|
|
18971
19173
|
const out = {};
|
|
@@ -18983,11 +19185,23 @@ function computeRerun(input) {
|
|
|
18983
19185
|
if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
|
|
18984
19186
|
if (runMoved && execution.execution === "passed") execution = { execution: "stale" };
|
|
18985
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";
|
|
18986
19191
|
out[spec.key] = {
|
|
18987
|
-
verdict
|
|
19192
|
+
verdict,
|
|
18988
19193
|
...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
|
|
18989
19194
|
...audit,
|
|
18990
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
|
+
} : {},
|
|
18991
19205
|
heldBy: held,
|
|
18992
19206
|
...coords
|
|
18993
19207
|
};
|
|
@@ -18995,6 +19209,50 @@ function computeRerun(input) {
|
|
|
18995
19209
|
return out;
|
|
18996
19210
|
}
|
|
18997
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
|
+
/**
|
|
18998
19256
|
* Axis 1, derived from the same freshness answer `--only-hub-audit-needed`
|
|
18999
19257
|
* reads. The label only speaks once the audit is known to be current: a
|
|
19000
19258
|
* verdict about an older commit says nothing about the one running now.
|
|
@@ -19098,13 +19356,14 @@ function createGetRerunHandler(storage) {
|
|
|
19098
19356
|
return async (ctx) => {
|
|
19099
19357
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19100
19358
|
const profile = requireProfileParam(ctx.url);
|
|
19101
|
-
const [specs, ledger, log, touchIndex, drift, locks] = await Promise.all([
|
|
19359
|
+
const [specs, ledger, log, touchIndex, drift, locks, attestations] = await Promise.all([
|
|
19102
19360
|
requireSpecTargets(storage.perspectives, project, "which specs need a re-run"),
|
|
19103
19361
|
storage.ledger.getMerged(project, profile),
|
|
19104
19362
|
storage.deploys.getLog(project, profile),
|
|
19105
19363
|
storage.deploys.getTouchIndex(project, profile),
|
|
19106
19364
|
storage.driftLedger.getMerged(project),
|
|
19107
|
-
storage.locks.get(project, profile)
|
|
19365
|
+
storage.locks.get(project, profile),
|
|
19366
|
+
storage.attestations.get(project, profile)
|
|
19108
19367
|
]);
|
|
19109
19368
|
const head = log.entries[log.entries.length - 1];
|
|
19110
19369
|
sendJson(ctx.res, 200, {
|
|
@@ -19118,6 +19377,7 @@ function createGetRerunHandler(storage) {
|
|
|
19118
19377
|
touchIndex,
|
|
19119
19378
|
drift,
|
|
19120
19379
|
locks,
|
|
19380
|
+
attestations,
|
|
19121
19381
|
now: /* @__PURE__ */ new Date()
|
|
19122
19382
|
})
|
|
19123
19383
|
});
|
|
@@ -19842,6 +20102,7 @@ const HTML_BODY = `
|
|
|
19842
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>
|
|
19843
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>
|
|
19844
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>
|
|
19845
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>
|
|
19846
20107
|
</div>
|
|
19847
20108
|
<div class="spacer"></div>
|
|
@@ -20567,6 +20828,9 @@ const CSS = `
|
|
|
20567
20828
|
.sg-audit-undecided { background: var(--info); }
|
|
20568
20829
|
.sg-verified, .sg-audit-clean, .sg-exec-passed { background: var(--pass); }
|
|
20569
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); }
|
|
20570
20834
|
|
|
20571
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); }
|
|
20572
20836
|
.search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
|
|
@@ -20605,6 +20869,11 @@ const CSS = `
|
|
|
20605
20869
|
.badge.rr-unknown .d { background: var(--info); }
|
|
20606
20870
|
.badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
20607
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); }
|
|
20608
20877
|
.cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
|
|
20609
20878
|
.graded-mark { color: var(--fg-dim); font-weight: 600; }
|
|
20610
20879
|
.cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
|
|
@@ -20660,6 +20929,11 @@ const CSS = `
|
|
|
20660
20929
|
.d-paths { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
20661
20930
|
.d-paths code { white-space: nowrap; }
|
|
20662
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; }
|
|
20663
20937
|
.notebox { margin-top: 14px; max-width: 900px; }
|
|
20664
20938
|
.notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
20665
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; }
|
|
@@ -20700,6 +20974,7 @@ const CLIENT_JS = `
|
|
|
20700
20974
|
var THEME_KEY = "ccqa-hub-theme";
|
|
20701
20975
|
var PROJECT_KEY = "ccqa-hub-project";
|
|
20702
20976
|
var PROFILES_KEY = "ccqa-hub-profiles";
|
|
20977
|
+
var ATTEST_BY_KEY = "ccqa-attest-by";
|
|
20703
20978
|
|
|
20704
20979
|
// ── i18n ──────────────────────────────────────────────────────────────
|
|
20705
20980
|
// Chrome + labels only. Model output (headline/recommendation/reasoning) is
|
|
@@ -20788,6 +21063,8 @@ const CLIENT_JS = `
|
|
|
20788
21063
|
"perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
|
|
20789
21064
|
"perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
|
|
20790
21065
|
"perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
|
|
21066
|
+
"perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
|
|
21067
|
+
"perspectives.d.stepExpected": "Expected:",
|
|
20791
21068
|
"perspectives.note.label": "Note",
|
|
20792
21069
|
"perspectives.note.placeholder": "Notes about this case…",
|
|
20793
21070
|
"perspectives.note.saved": "Saved",
|
|
@@ -20800,6 +21077,7 @@ const CLIENT_JS = `
|
|
|
20800
21077
|
"perspectives.rerun.state.needsRepair": "Needs repair",
|
|
20801
21078
|
"perspectives.rerun.state.rerunNeeded": "Re-run needed",
|
|
20802
21079
|
"perspectives.rerun.state.inProgress": "In progress",
|
|
21080
|
+
"perspectives.rerun.state.manuallyVerified": "Manually verified",
|
|
20803
21081
|
"perspectives.rerun.state.verified": "Verified",
|
|
20804
21082
|
"perspectives.rerun.vsDeploy": "judged against deploy",
|
|
20805
21083
|
"perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
|
|
@@ -20835,6 +21113,19 @@ const CLIENT_JS = `
|
|
|
20835
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.",
|
|
20836
21114
|
"perspectives.rerun.deployHead": "deploy head",
|
|
20837
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",
|
|
20838
21129
|
"prompt.card.record": "Recording browser actions",
|
|
20839
21130
|
"prompt.card.live": "Live run (AI-driven)",
|
|
20840
21131
|
"prompt.card.playwright": "Playwright test generation",
|
|
@@ -20952,6 +21243,8 @@ const CLIENT_JS = `
|
|
|
20952
21243
|
"perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
|
|
20953
21244
|
"perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
|
|
20954
21245
|
"perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
|
|
21246
|
+
"perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
|
|
21247
|
+
"perspectives.d.stepExpected": "期待結果:",
|
|
20955
21248
|
"perspectives.note.label": "note",
|
|
20956
21249
|
"perspectives.note.placeholder": "このケースについてのメモ…",
|
|
20957
21250
|
"perspectives.note.saved": "保存しました",
|
|
@@ -20964,6 +21257,7 @@ const CLIENT_JS = `
|
|
|
20964
21257
|
"perspectives.rerun.state.needsRepair": "修正待ち",
|
|
20965
21258
|
"perspectives.rerun.state.rerunNeeded": "要再実行",
|
|
20966
21259
|
"perspectives.rerun.state.inProgress": "進行中",
|
|
21260
|
+
"perspectives.rerun.state.manuallyVerified": "手動確認済み",
|
|
20967
21261
|
"perspectives.rerun.state.verified": "検証済み",
|
|
20968
21262
|
"perspectives.rerun.vsDeploy": "判定基準: デプロイ",
|
|
20969
21263
|
"perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
|
|
@@ -20999,6 +21293,19 @@ const CLIENT_JS = `
|
|
|
20999
21293
|
"perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
|
|
21000
21294
|
"perspectives.rerun.deployHead": "最新デプロイ",
|
|
21001
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が認識できない理由により手動確認が失効",
|
|
21002
21309
|
"prompt.card.record": "ブラウザ操作の記録",
|
|
21003
21310
|
"prompt.card.live": "ライブ実行(AI操作)",
|
|
21004
21311
|
"prompt.card.playwright": "Playwrightテスト生成",
|
|
@@ -21132,6 +21439,17 @@ const CLIENT_JS = `
|
|
|
21132
21439
|
try { window.localStorage.removeItem(PROJECT_KEY); }
|
|
21133
21440
|
catch (e) { console.warn("ccqa hub: could not clear stored project:", e); }
|
|
21134
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
|
+
}
|
|
21135
21453
|
function loadProfileMap() {
|
|
21136
21454
|
try {
|
|
21137
21455
|
var raw = window.localStorage.getItem(PROFILES_KEY);
|
|
@@ -23080,6 +23398,11 @@ const CLIENT_JS = `
|
|
|
23080
23398
|
"/rerun?profile=" + encodeURIComponent(state.profile);
|
|
23081
23399
|
}
|
|
23082
23400
|
|
|
23401
|
+
function attestationsPath() {
|
|
23402
|
+
return "/api/v1/projects/" + encodeURIComponent(state.project) +
|
|
23403
|
+
"/attestations?profile=" + encodeURIComponent(state.profile);
|
|
23404
|
+
}
|
|
23405
|
+
|
|
23083
23406
|
// Resolves { report } or { note } and never rejects: a hub that predates
|
|
23084
23407
|
// the endpoint costs only the columns it feeds, not the whole tab. A 404
|
|
23085
23408
|
// here can only mean "no such route" — the endpoint's own 404 is "the
|
|
@@ -23158,6 +23481,37 @@ const CLIENT_JS = `
|
|
|
23158
23481
|
return text === prefix + reason ? t(prefix + "unrecognized") : text;
|
|
23159
23482
|
}
|
|
23160
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
|
+
|
|
23161
23515
|
// Every verdict that carries no evidence row explains itself here, in the
|
|
23162
23516
|
// actionable phrasing the detail panel wants. decide() checks heldBy before
|
|
23163
23517
|
// the audit axis, so a spec another job already holds must be explained by
|
|
@@ -23177,6 +23531,7 @@ const CLIENT_JS = `
|
|
|
23177
23531
|
? rerunReasonText("perspectives.rerun.fix.", rr.auditAssumedReached)
|
|
23178
23532
|
: t("perspectives.rerun.inProgressHint");
|
|
23179
23533
|
}
|
|
23534
|
+
if (rr.verdict === "manuallyVerified" && rr.manual) return manualAttestationText(rr.manual);
|
|
23180
23535
|
return rerunReasonText("perspectives.rerun.fix.", rr.verdict);
|
|
23181
23536
|
}
|
|
23182
23537
|
|
|
@@ -23195,16 +23550,23 @@ const CLIENT_JS = `
|
|
|
23195
23550
|
// rather than claiming a deploy matched it.
|
|
23196
23551
|
function rerunCellWhy(rr) {
|
|
23197
23552
|
var head = perspState.rerun && perspState.rerun.deployHead;
|
|
23553
|
+
var why;
|
|
23198
23554
|
if (rr.verdict === "rerunNeeded") {
|
|
23199
|
-
if (rr.executionAssumedReached)
|
|
23200
|
-
if (!rr.touchedBy || !rr.touchedBy.length)
|
|
23201
|
-
|
|
23202
|
-
}
|
|
23203
|
-
|
|
23204
|
-
|
|
23205
|
-
|
|
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);
|
|
23206
23564
|
}
|
|
23207
|
-
|
|
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;
|
|
23208
23570
|
}
|
|
23209
23571
|
|
|
23210
23572
|
|
|
@@ -23218,10 +23580,10 @@ const CLIENT_JS = `
|
|
|
23218
23580
|
// the pipeline still owes, then what needs nothing. "needsRepair" leads
|
|
23219
23581
|
// because it is the only verdict a run cannot clear — someone has to repair
|
|
23220
23582
|
// the spec or the product.
|
|
23221
|
-
var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "verified"];
|
|
23583
|
+
var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "manuallyVerified", "verified"];
|
|
23222
23584
|
var RERUN_SEG_CLASS = {
|
|
23223
23585
|
needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded",
|
|
23224
|
-
inProgress: "sg-inprogress", verified: "sg-verified"
|
|
23586
|
+
inProgress: "sg-inprogress", manuallyVerified: "sg-manual", verified: "sg-verified"
|
|
23225
23587
|
};
|
|
23226
23588
|
|
|
23227
23589
|
// The one rule the summary bar and the verdict filter chips both answer
|
|
@@ -23235,7 +23597,7 @@ const CLIENT_JS = `
|
|
|
23235
23597
|
|
|
23236
23598
|
// One verdict per case, bucketed, via rerunVerdictOf above.
|
|
23237
23599
|
function rerunComposition(verdicts) {
|
|
23238
|
-
var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, verified: 0 };
|
|
23600
|
+
var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, manuallyVerified: 0, verified: 0 };
|
|
23239
23601
|
verdicts.forEach(function (rr) { counts[rerunVerdictOf(rr)] += 1; });
|
|
23240
23602
|
return counts;
|
|
23241
23603
|
}
|
|
@@ -23403,7 +23765,7 @@ const CLIENT_JS = `
|
|
|
23403
23765
|
// takes the attention colour and re-running — machine work — does not.
|
|
23404
23766
|
var VERDICT_BADGE = {
|
|
23405
23767
|
needsRepair: "rr-repair", rerunNeeded: "rr-needed",
|
|
23406
|
-
inProgress: "rr-none", verified: "passed"
|
|
23768
|
+
inProgress: "rr-none", manuallyVerified: "rr-manual", verified: "passed"
|
|
23407
23769
|
};
|
|
23408
23770
|
|
|
23409
23771
|
function perspVerdictCell(rr) {
|
|
@@ -23734,6 +24096,43 @@ const CLIENT_JS = `
|
|
|
23734
24096
|
return wrap;
|
|
23735
24097
|
}
|
|
23736
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
|
+
|
|
23737
24136
|
// Detail row: a definition list of the case's fields plus the note editor.
|
|
23738
24137
|
// Built with createElement/textContent throughout — every field here is
|
|
23739
24138
|
// API-derived, so none of it may go through innerHTML.
|
|
@@ -23771,6 +24170,44 @@ const CLIENT_JS = `
|
|
|
23771
24170
|
}
|
|
23772
24171
|
frag.appendChild(dl);
|
|
23773
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
|
+
|
|
23774
24211
|
var notebox = el("div", "notebox");
|
|
23775
24212
|
notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
|
|
23776
24213
|
var ta = el("textarea");
|
|
@@ -25062,6 +25499,9 @@ function registerRoutes(router, config, queue) {
|
|
|
25062
25499
|
router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
|
|
25063
25500
|
router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
|
|
25064
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));
|
|
25065
25505
|
router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
|
|
25066
25506
|
router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
|
|
25067
25507
|
router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
|
|
@@ -25351,6 +25791,9 @@ function deployTouchIndexPath(root, project, profile) {
|
|
|
25351
25791
|
function specLocksPath(root, project, profile) {
|
|
25352
25792
|
return join(root, "locks", project, profile, "locks.json");
|
|
25353
25793
|
}
|
|
25794
|
+
function attestationsPath(root, project, profile) {
|
|
25795
|
+
return join(root, "attestations", project, profile, "attestations.json");
|
|
25796
|
+
}
|
|
25354
25797
|
function ackPath(root, project, profile, name) {
|
|
25355
25798
|
return join(root, "acks", project, profile, `${name}.json`);
|
|
25356
25799
|
}
|
|
@@ -25392,6 +25835,22 @@ function createFileAckStore(root) {
|
|
|
25392
25835
|
};
|
|
25393
25836
|
}
|
|
25394
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
|
|
25395
25854
|
//#region src/hub/core/storage/file/artifact-store.ts
|
|
25396
25855
|
/**
|
|
25397
25856
|
* Defense-in-depth: `relPath` is expected to already be validated by the
|
|
@@ -25875,7 +26334,8 @@ function createFileHubStorage(dataDir) {
|
|
|
25875
26334
|
deploys: createFileDeployStore(dataDir),
|
|
25876
26335
|
locks: createFileLockStore(dataDir),
|
|
25877
26336
|
acks: createFileAckStore(dataDir),
|
|
25878
|
-
spend: createFileSpendStore(dataDir)
|
|
26337
|
+
spend: createFileSpendStore(dataDir),
|
|
26338
|
+
attestations: createFileAttestationStore(dataDir)
|
|
25879
26339
|
};
|
|
25880
26340
|
}
|
|
25881
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