ccqa 1.31.4 → 1.33.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 +884 -55
- package/dist/hub-client/index.d.mts +134 -1
- package/dist/hub-client/index.mjs +40 -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,97 @@ 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
|
+
/**
|
|
7521
|
+
* A person's answer to one audit finding: the spec describes the code fine,
|
|
7522
|
+
* and the finding is wrong. Where an attestation speaks about the product,
|
|
7523
|
+
* this speaks about the *audit* — so it settles the audit axis rather than
|
|
7524
|
+
* the verdict, and the spec goes back to being run like any other.
|
|
7525
|
+
*
|
|
7526
|
+
* Pinned to the audit run whose finding it answers. A later audit is a new
|
|
7527
|
+
* observation of newer code, so it produces a new run and this stops
|
|
7528
|
+
* applying: the machine gets to raise the finding again, and the record of
|
|
7529
|
+
* the last dismissal is shown beside it rather than silently suppressing it.
|
|
7530
|
+
* No profile — an audit finding is about the repository, not an environment
|
|
7531
|
+
* (ADR-0013), which is also why this is scoped per project alone.
|
|
7532
|
+
*/
|
|
7533
|
+
const AuditDismissalSchema = z.object({
|
|
7534
|
+
by: z.string().min(1),
|
|
7535
|
+
at: z.string(),
|
|
7536
|
+
note: z.string().min(1),
|
|
7537
|
+
auditRunId: z.string(),
|
|
7538
|
+
label: DriftLabelSchema,
|
|
7539
|
+
headline: z.string()
|
|
7540
|
+
});
|
|
7541
|
+
/** The per-project dismissal document: "feature/spec" → the last dismissal. */
|
|
7542
|
+
const AuditDismissalsSchema = z.object({ specs: z.record(z.string(), AuditDismissalSchema).default({}) });
|
|
7543
|
+
/** Body of `PUT /projects/:project/audit-dismissals`. */
|
|
7544
|
+
const PutAuditDismissalRequestSchema = z.object({
|
|
7545
|
+
spec: z.string().min(1).max(512),
|
|
7546
|
+
by: z.string().min(1).max(256),
|
|
7547
|
+
note: z.string().min(1).max(4e3)
|
|
7548
|
+
});
|
|
7549
|
+
/** Body of `DELETE /projects/:project/audit-dismissals`. */
|
|
7550
|
+
const DeleteAuditDismissalRequestSchema = z.object({ spec: z.string().min(1).max(512) });
|
|
7551
|
+
z.object({
|
|
7552
|
+
project: z.string(),
|
|
7553
|
+
spec: z.string(),
|
|
7554
|
+
dismissal: AuditDismissalSchema
|
|
7555
|
+
});
|
|
7556
|
+
z.object({
|
|
7557
|
+
project: z.string(),
|
|
7558
|
+
specs: z.record(z.string(), AuditDismissalSchema)
|
|
7559
|
+
});
|
|
7560
|
+
/**
|
|
7455
7561
|
* One spec's verdict, the two axes it was derived from, and the three ledger
|
|
7456
7562
|
* coordinates the view shows alongside them. The coordinates are always
|
|
7457
7563
|
* present (null when the spec has no such entry); the optional fields appear
|
|
@@ -7467,8 +7573,14 @@ const SpecRerunSchema = z.object({
|
|
|
7467
7573
|
execution: ExecutionStateSchema,
|
|
7468
7574
|
driftLabel: DriftLabelSchema.exclude(["UNKNOWN"]).optional(),
|
|
7469
7575
|
auditAssumedReached: RerunUnknownReasonSchema.optional(),
|
|
7576
|
+
auditDismissed: AuditDismissalSchema.optional(),
|
|
7577
|
+
auditDismissalApplied: z.boolean().optional(),
|
|
7470
7578
|
executionAssumedReached: RerunUnknownReasonSchema.optional(),
|
|
7471
7579
|
specChangedSince: z.string().optional(),
|
|
7580
|
+
manual: AttestationSchema.optional(),
|
|
7581
|
+
manualLapsed: AttestationSchema.extend({ because: AttestationLapseSchema }).optional(),
|
|
7582
|
+
manualLapsedByDeploy: DeployRefSchema.nullable().optional(),
|
|
7583
|
+
manualLapsedReason: RerunUnknownReasonSchema.optional(),
|
|
7472
7584
|
heldBy: SpecLockSchema.nullable(),
|
|
7473
7585
|
lastRun: SpecLedgerEntrySchema.nullable(),
|
|
7474
7586
|
lastGreen: SpecLedgerEntrySchema.nullable(),
|
|
@@ -7711,7 +7823,7 @@ async function fetchRerunReport(hubCtx, profile) {
|
|
|
7711
7823
|
throw new RunUsageError(`--only-hub-rerun-needed: could not ask the hub which specs need a re-run: ${errMessage(err)}`);
|
|
7712
7824
|
}
|
|
7713
7825
|
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
|
|
7826
|
+
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
7827
|
report = parsed.data;
|
|
7716
7828
|
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
7829
|
return {
|
|
@@ -7723,7 +7835,8 @@ const SUMMARY_ORDER$1 = rankedOrder({
|
|
|
7723
7835
|
needsRepair: 0,
|
|
7724
7836
|
rerunNeeded: 1,
|
|
7725
7837
|
inProgress: 2,
|
|
7726
|
-
|
|
7838
|
+
manuallyVerified: 3,
|
|
7839
|
+
verified: 4
|
|
7727
7840
|
});
|
|
7728
7841
|
/**
|
|
7729
7842
|
* Narrow `specs` to the ones the hub says are worth running.
|
|
@@ -7733,7 +7846,9 @@ const SUMMARY_ORDER$1 = rankedOrder({
|
|
|
7733
7846
|
* cannot vouch for, are both as uncovered as one a deploy demonstrably
|
|
7734
7847
|
* invalidated (ADR-0014). `needsRepair`, `inProgress` and `verified` are never
|
|
7735
7848
|
* selected: running them repairs nothing, races something already in flight,
|
|
7736
|
-
* or repeats work that is still current.
|
|
7849
|
+
* or repeats work that is still current. `manuallyVerified` is never selected
|
|
7850
|
+
* either — the test is still the broken one the attestation stands in for,
|
|
7851
|
+
* and running it would only relabel a person's answer with a machine failure.
|
|
7737
7852
|
*/
|
|
7738
7853
|
function selectSpecsNeedingRerun(specs, report) {
|
|
7739
7854
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -9224,6 +9339,20 @@ function connect(opts) {
|
|
|
9224
9339
|
error("hub token is required (--hub-token or CCQA_HUB_TOKEN)");
|
|
9225
9340
|
process.exit(2);
|
|
9226
9341
|
}
|
|
9342
|
+
/**
|
|
9343
|
+
* The canonical "feature/spec" key for a CLI argument. Hub records are stored
|
|
9344
|
+
* and looked up under exactly this form, so an alias accepted here but kept
|
|
9345
|
+
* verbatim would silently never match.
|
|
9346
|
+
*/
|
|
9347
|
+
function requireSpecId(rawSpecId) {
|
|
9348
|
+
try {
|
|
9349
|
+
const parsed = parseSpecPath(rawSpecId);
|
|
9350
|
+
return `${parsed.featureName}/${parsed.specName}`;
|
|
9351
|
+
} catch (err) {
|
|
9352
|
+
error(errMessage(err));
|
|
9353
|
+
process.exit(2);
|
|
9354
|
+
}
|
|
9355
|
+
}
|
|
9227
9356
|
function validateSessionName(name) {
|
|
9228
9357
|
const parsed = SessionNameSchema.safeParse(name);
|
|
9229
9358
|
if (!parsed.success) {
|
|
@@ -9518,7 +9647,53 @@ const pushCommand = new Command("push").description("Upload the report directory
|
|
|
9518
9647
|
meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
|
|
9519
9648
|
info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
|
|
9520
9649
|
}));
|
|
9521
|
-
const
|
|
9650
|
+
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) => {
|
|
9651
|
+
const project = resolveProject(opts);
|
|
9652
|
+
const hub = connect(opts);
|
|
9653
|
+
const specId = requireSpecId(rawSpecId);
|
|
9654
|
+
if (opts.revoke) {
|
|
9655
|
+
await hub.deleteAttestation(project, { profile: opts.profile }, specId);
|
|
9656
|
+
header("hub attest", `${specId} revoked`);
|
|
9657
|
+
return;
|
|
9658
|
+
}
|
|
9659
|
+
if (!opts.by) {
|
|
9660
|
+
error("--by <name> is required: an attestation is a person's word, and it needs the person");
|
|
9661
|
+
process.exit(2);
|
|
9662
|
+
}
|
|
9663
|
+
const res = await hub.putAttestation(project, { profile: opts.profile }, {
|
|
9664
|
+
spec: specId,
|
|
9665
|
+
by: opts.by,
|
|
9666
|
+
...opts.note !== void 0 ? { note: opts.note } : {}
|
|
9667
|
+
});
|
|
9668
|
+
header("hub attest", specId);
|
|
9669
|
+
meta("by", res.attestation.by);
|
|
9670
|
+
meta("anchored to deploy", res.attestation.deployedSha ?? "(no deploy log)");
|
|
9671
|
+
info("the verdict answers manuallyVerified until a deploy reaches this spec or the spec is edited");
|
|
9672
|
+
}));
|
|
9673
|
+
const dismissCommand = new Command("dismiss").argument("<feature/spec>", "Spec id, e.g. checkout/happy-path").description("Record that a person judged the spec's current audit finding wrong: the spec describes the code fine. This settles the audit axis rather than the verdict — the spec goes back to being run like any other, and the next run says whether the person was right. The dismissal is pinned to the audit run that raised the finding, so a later audit can raise it again. No --profile: a finding is about the repository, not an environment.").option("--by <name>", "Who judged it wrong. Required unless --revoke.").option("--reason <text>", "Why the finding is wrong. Required unless --revoke — this is what a mis-firing audit learns from.").option("--revoke", "Withdraw the dismissal, putting the finding back in force.").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) => {
|
|
9674
|
+
const project = resolveProject(opts);
|
|
9675
|
+
const hub = connect(opts);
|
|
9676
|
+
const specId = requireSpecId(rawSpecId);
|
|
9677
|
+
if (opts.revoke) {
|
|
9678
|
+
await hub.deleteAuditDismissal(project, specId);
|
|
9679
|
+
header("hub dismiss", `${specId} revoked`);
|
|
9680
|
+
return;
|
|
9681
|
+
}
|
|
9682
|
+
if (!opts.by || !opts.reason) {
|
|
9683
|
+
error("--by <name> and --reason <text> are both required: a dismissal is a person's correction, and it needs the person and the correction");
|
|
9684
|
+
process.exit(2);
|
|
9685
|
+
}
|
|
9686
|
+
const res = await hub.putAuditDismissal(project, {
|
|
9687
|
+
spec: specId,
|
|
9688
|
+
by: opts.by,
|
|
9689
|
+
note: opts.reason
|
|
9690
|
+
});
|
|
9691
|
+
header("hub dismiss", specId);
|
|
9692
|
+
meta("by", res.dismissal.by);
|
|
9693
|
+
meta("dismissed", `${res.dismissal.label} — ${res.dismissal.headline || "(no headline)"}`);
|
|
9694
|
+
info("this finding no longer holds the spec back; a later audit can raise one of its own");
|
|
9695
|
+
}));
|
|
9696
|
+
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).addCommand(dismissCommand);
|
|
9522
9697
|
/** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
|
|
9523
9698
|
function isStorageStateShape(state) {
|
|
9524
9699
|
return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
|
|
@@ -17154,6 +17329,7 @@ async function buildSkeleton(tree) {
|
|
|
17154
17329
|
specName: s.specName,
|
|
17155
17330
|
title: meta.title,
|
|
17156
17331
|
summary: "",
|
|
17332
|
+
...meta.steps.length > 0 ? { steps: meta.steps } : {},
|
|
17157
17333
|
status,
|
|
17158
17334
|
...lastEdit ? { changedAt: lastEdit } : {}
|
|
17159
17335
|
};
|
|
@@ -17227,7 +17403,8 @@ function noteKey(featureName, specName) {
|
|
|
17227
17403
|
function readSpecMeta(specName, specYaml) {
|
|
17228
17404
|
if (specYaml === null) return {
|
|
17229
17405
|
title: specName,
|
|
17230
|
-
mode: DEFAULT_SPEC_MODE
|
|
17406
|
+
mode: DEFAULT_SPEC_MODE,
|
|
17407
|
+
steps: []
|
|
17231
17408
|
};
|
|
17232
17409
|
try {
|
|
17233
17410
|
const parsed = parse(specYaml);
|
|
@@ -17235,16 +17412,39 @@ function readSpecMeta(specName, specYaml) {
|
|
|
17235
17412
|
const modeResult = SpecModeSchema.safeParse(parsed.mode);
|
|
17236
17413
|
return {
|
|
17237
17414
|
title,
|
|
17238
|
-
mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE
|
|
17415
|
+
mode: modeResult.success ? modeResult.data : DEFAULT_SPEC_MODE,
|
|
17416
|
+
steps: transcribeSteps(parsed.steps)
|
|
17239
17417
|
};
|
|
17240
17418
|
} catch {
|
|
17241
17419
|
return {
|
|
17242
17420
|
title: specName,
|
|
17243
|
-
mode: DEFAULT_SPEC_MODE
|
|
17421
|
+
mode: DEFAULT_SPEC_MODE,
|
|
17422
|
+
steps: []
|
|
17244
17423
|
};
|
|
17245
17424
|
}
|
|
17246
17425
|
}
|
|
17247
17426
|
/**
|
|
17427
|
+
* The spec's procedure, copied verbatim for the inventory: an include step
|
|
17428
|
+
* keeps only the block name (its params are wiring, not procedure), an
|
|
17429
|
+
* action step keeps its instruction/expected text. Anything malformed is
|
|
17430
|
+
* skipped — the inventory never fails over one bad step, matching how the
|
|
17431
|
+
* rest of this sweep treats a broken spec.
|
|
17432
|
+
*/
|
|
17433
|
+
function transcribeSteps(raw) {
|
|
17434
|
+
if (!Array.isArray(raw)) return [];
|
|
17435
|
+
const steps = [];
|
|
17436
|
+
for (const step of raw) {
|
|
17437
|
+
if (typeof step !== "object" || step === null) continue;
|
|
17438
|
+
const s = step;
|
|
17439
|
+
if (typeof s.include === "string" && s.include.length > 0) steps.push({ include: s.include });
|
|
17440
|
+
else if (typeof s.instruction === "string" && s.instruction.length > 0) steps.push({
|
|
17441
|
+
instruction: s.instruction,
|
|
17442
|
+
...typeof s.expected === "string" && s.expected.length > 0 ? { expected: s.expected } : {}
|
|
17443
|
+
});
|
|
17444
|
+
}
|
|
17445
|
+
return steps;
|
|
17446
|
+
}
|
|
17447
|
+
/**
|
|
17248
17448
|
* Resolve a spec's generation target for coverage derivation, from its
|
|
17249
17449
|
* already-read spec.yaml. Best-effort: an unparseable spec or a target that
|
|
17250
17450
|
* can't be resolved (unknown id, agent-browser-only field misuse) falls back
|
|
@@ -18346,7 +18546,7 @@ function requireKey(config) {
|
|
|
18346
18546
|
return config.encryptionKey;
|
|
18347
18547
|
}
|
|
18348
18548
|
/** Validate the `:project`/`:profile` route params into a store scope. */
|
|
18349
|
-
function requireScope(ctx) {
|
|
18549
|
+
function requireScope$1(ctx) {
|
|
18350
18550
|
return {
|
|
18351
18551
|
project: requireSafeSegment(ctx.params.project, "project"),
|
|
18352
18552
|
profile: requireSafeSegment(ctx.params.profile, "profile")
|
|
@@ -18356,7 +18556,7 @@ function requireScope(ctx) {
|
|
|
18356
18556
|
function createPutSessionHandler(config) {
|
|
18357
18557
|
return async (ctx) => {
|
|
18358
18558
|
const key = requireKey(config);
|
|
18359
|
-
const scope = requireScope(ctx);
|
|
18559
|
+
const scope = requireScope$1(ctx);
|
|
18360
18560
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18361
18561
|
const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
|
|
18362
18562
|
const blob = encodeEncryptedBlob(encrypt(new Uint8Array(body), key));
|
|
@@ -18368,7 +18568,7 @@ function createPutSessionHandler(config) {
|
|
|
18368
18568
|
/** GET /api/v1/projects/:project/sessions/:profile — metadata only (names + timestamps). */
|
|
18369
18569
|
function createListSessionsHandler(config) {
|
|
18370
18570
|
return async (ctx) => {
|
|
18371
|
-
const scope = requireScope(ctx);
|
|
18571
|
+
const scope = requireScope$1(ctx);
|
|
18372
18572
|
const entries = await config.store.list(scope);
|
|
18373
18573
|
sendJson(ctx.res, 200, { sessions: entries.map((e) => ({
|
|
18374
18574
|
name: e.name,
|
|
@@ -18385,7 +18585,7 @@ function createListSessionsHandler(config) {
|
|
|
18385
18585
|
function createGetSessionHandler(config) {
|
|
18386
18586
|
return async (ctx) => {
|
|
18387
18587
|
const key = requireKey(config);
|
|
18388
|
-
const scope = requireScope(ctx);
|
|
18588
|
+
const scope = requireScope$1(ctx);
|
|
18389
18589
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18390
18590
|
const stored = await config.store.get(scope, name);
|
|
18391
18591
|
if (!stored) throw new HttpError(404, "not_found", `session "${name}" not found for ${scope.project}/${scope.profile}`);
|
|
@@ -18396,7 +18596,7 @@ function createGetSessionHandler(config) {
|
|
|
18396
18596
|
/** DELETE /api/v1/projects/:project/sessions/:profile/:name */
|
|
18397
18597
|
function createDeleteSessionHandler(config) {
|
|
18398
18598
|
return async (ctx) => {
|
|
18399
|
-
const scope = requireScope(ctx);
|
|
18599
|
+
const scope = requireScope$1(ctx);
|
|
18400
18600
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18401
18601
|
await config.store.delete(scope, name);
|
|
18402
18602
|
ctx.res.statusCode = 204;
|
|
@@ -18407,7 +18607,7 @@ function createDeleteSessionHandler(config) {
|
|
|
18407
18607
|
function createPutVariableHandler(config) {
|
|
18408
18608
|
return async (ctx) => {
|
|
18409
18609
|
const key = requireKey(config);
|
|
18410
|
-
const scope = requireScope(ctx);
|
|
18610
|
+
const scope = requireScope$1(ctx);
|
|
18411
18611
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18412
18612
|
const body = await readBody(ctx.req, MAX_SECRET_BODY_BYTES);
|
|
18413
18613
|
const parsed = PutVariableRequestSchema.safeParse(JSON.parse(body.toString("utf8") || "{}"));
|
|
@@ -18427,7 +18627,7 @@ function createPutVariableHandler(config) {
|
|
|
18427
18627
|
*/
|
|
18428
18628
|
function createListVariablesHandler(config) {
|
|
18429
18629
|
return async (ctx) => {
|
|
18430
|
-
const scope = requireScope(ctx);
|
|
18630
|
+
const scope = requireScope$1(ctx);
|
|
18431
18631
|
const includeValues = ctx.url.searchParams.get("include") === "values";
|
|
18432
18632
|
const key = includeValues ? requireKey(config) : config.encryptionKey;
|
|
18433
18633
|
const entries = await config.store.list(scope);
|
|
@@ -18462,7 +18662,7 @@ function createListVariablesHandler(config) {
|
|
|
18462
18662
|
/** DELETE /api/v1/projects/:project/variables/:profile/:name */
|
|
18463
18663
|
function createDeleteVariableHandler(config) {
|
|
18464
18664
|
return async (ctx) => {
|
|
18465
|
-
const scope = requireScope(ctx);
|
|
18665
|
+
const scope = requireScope$1(ctx);
|
|
18466
18666
|
const name = requireSafeSegment(ctx.params.name, "name");
|
|
18467
18667
|
await config.store.delete(scope, name);
|
|
18468
18668
|
ctx.res.statusCode = 204;
|
|
@@ -18801,7 +19001,7 @@ function createGetAuditNeedHandler(storage) {
|
|
|
18801
19001
|
//#endregion
|
|
18802
19002
|
//#region src/hub/api/handlers/locks.ts
|
|
18803
19003
|
/** A spec-key list and three short strings; nothing here should approach this. */
|
|
18804
|
-
const MAX_BODY_BYTES$
|
|
19004
|
+
const MAX_BODY_BYTES$5 = 1024 * 1024;
|
|
18805
19005
|
/**
|
|
18806
19006
|
* POST /api/v1/projects/:project/locks?profile=
|
|
18807
19007
|
*
|
|
@@ -18815,7 +19015,7 @@ function createAcquireLocksHandler(storage) {
|
|
|
18815
19015
|
return async (ctx) => {
|
|
18816
19016
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18817
19017
|
const profile = requireProfileParam(ctx.url);
|
|
18818
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19018
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, AcquireLocksRequestSchema, "lock request");
|
|
18819
19019
|
let result = {
|
|
18820
19020
|
granted: [],
|
|
18821
19021
|
denied: []
|
|
@@ -18844,7 +19044,7 @@ function createReleaseLocksHandler(storage) {
|
|
|
18844
19044
|
return async (ctx) => {
|
|
18845
19045
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18846
19046
|
const profile = requireProfileParam(ctx.url);
|
|
18847
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19047
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$5, ReleaseLocksRequestSchema, "release request");
|
|
18848
19048
|
await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
|
|
18849
19049
|
ctx.res.writeHead(204).end();
|
|
18850
19050
|
};
|
|
@@ -18857,7 +19057,7 @@ function createReleaseLocksHandler(storage) {
|
|
|
18857
19057
|
* 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
|
|
18858
19058
|
* never answered 413 by a limit the documented bounds don't mention.
|
|
18859
19059
|
*/
|
|
18860
|
-
const MAX_BODY_BYTES$
|
|
19060
|
+
const MAX_BODY_BYTES$4 = 8 * 1024 * 1024;
|
|
18861
19061
|
function requireAckKey(ctx) {
|
|
18862
19062
|
return {
|
|
18863
19063
|
project: requireSafeSegment(ctx.params.project, "project"),
|
|
@@ -18884,7 +19084,7 @@ function createGetAckHandler(storage) {
|
|
|
18884
19084
|
function createPutAckHandler(storage) {
|
|
18885
19085
|
return async (ctx) => {
|
|
18886
19086
|
const key = requireAckKey(ctx);
|
|
18887
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
19087
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$4, PutAckRequestSchema, "ack body");
|
|
18888
19088
|
const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
|
|
18889
19089
|
sendJson(ctx.res, 200, {
|
|
18890
19090
|
...key,
|
|
@@ -18893,6 +19093,157 @@ function createPutAckHandler(storage) {
|
|
|
18893
19093
|
};
|
|
18894
19094
|
}
|
|
18895
19095
|
//#endregion
|
|
19096
|
+
//#region src/hub/api/handlers/attestations.ts
|
|
19097
|
+
/** Far above the largest body `PutAttestationRequestSchema`'s bounds admit. */
|
|
19098
|
+
const MAX_BODY_BYTES$3 = 64 * 1024;
|
|
19099
|
+
function requireScope(ctx) {
|
|
19100
|
+
return {
|
|
19101
|
+
project: requireSafeSegment(ctx.params.project, "project"),
|
|
19102
|
+
profile: requireProfileParam(ctx.url)
|
|
19103
|
+
};
|
|
19104
|
+
}
|
|
19105
|
+
/**
|
|
19106
|
+
* GET /api/v1/projects/:project/attestations?profile= — the raw document,
|
|
19107
|
+
* standing and lapsed alike. Whether one still covers its spec is `/rerun`'s
|
|
19108
|
+
* answer; this exists so a lapsed attestation can still be found and revoked.
|
|
19109
|
+
*/
|
|
19110
|
+
function createGetAttestationsHandler(storage) {
|
|
19111
|
+
return async (ctx) => {
|
|
19112
|
+
const scope = requireScope(ctx);
|
|
19113
|
+
const doc = await storage.attestations.get(scope.project, scope.profile);
|
|
19114
|
+
sendJson(ctx.res, 200, {
|
|
19115
|
+
...scope,
|
|
19116
|
+
specs: doc.specs
|
|
19117
|
+
});
|
|
19118
|
+
};
|
|
19119
|
+
}
|
|
19120
|
+
/**
|
|
19121
|
+
* PUT /api/v1/projects/:project/attestations?profile= — record that a person
|
|
19122
|
+
* checked a spec by hand. The hub stamps the time and the profile's deploy
|
|
19123
|
+
* head: the anchor must be what the hub knows was deployed at this moment,
|
|
19124
|
+
* not what the caller believes. Replaces any previous attestation for the
|
|
19125
|
+
* spec.
|
|
19126
|
+
*/
|
|
19127
|
+
function createPutAttestationHandler(storage) {
|
|
19128
|
+
return async (ctx) => {
|
|
19129
|
+
const scope = requireScope(ctx);
|
|
19130
|
+
const [body, head, targets] = await Promise.all([
|
|
19131
|
+
readJsonBody(ctx.req, MAX_BODY_BYTES$3, PutAttestationRequestSchema, "attestation body"),
|
|
19132
|
+
storage.deploys.head(scope.project, scope.profile),
|
|
19133
|
+
requireSpecTargets(storage.perspectives, scope.project, "what can be attested")
|
|
19134
|
+
]);
|
|
19135
|
+
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`);
|
|
19136
|
+
const attestation = {
|
|
19137
|
+
by: body.by,
|
|
19138
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
19139
|
+
...body.note !== void 0 ? { note: body.note } : {},
|
|
19140
|
+
deployedSha: head?.sha ?? null
|
|
19141
|
+
};
|
|
19142
|
+
await storage.attestations.update(scope.project, scope.profile, (current) => ({ specs: {
|
|
19143
|
+
...current.specs,
|
|
19144
|
+
[body.spec]: attestation
|
|
19145
|
+
} }));
|
|
19146
|
+
sendJson(ctx.res, 200, {
|
|
19147
|
+
...scope,
|
|
19148
|
+
spec: body.spec,
|
|
19149
|
+
attestation
|
|
19150
|
+
});
|
|
19151
|
+
};
|
|
19152
|
+
}
|
|
19153
|
+
/**
|
|
19154
|
+
* DELETE /api/v1/projects/:project/attestations?profile= — revoke a spec's
|
|
19155
|
+
* attestation. Deleting one that does not exist is 200 like deleting one that
|
|
19156
|
+
* does: the caller asked for its absence, and it is absent.
|
|
19157
|
+
*/
|
|
19158
|
+
function createDeleteAttestationHandler(storage) {
|
|
19159
|
+
return async (ctx) => {
|
|
19160
|
+
const scope = requireScope(ctx);
|
|
19161
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, DeleteAttestationRequestSchema, "attestation body");
|
|
19162
|
+
await storage.attestations.update(scope.project, scope.profile, (current) => {
|
|
19163
|
+
const { [body.spec]: _, ...rest } = current.specs;
|
|
19164
|
+
return { specs: rest };
|
|
19165
|
+
});
|
|
19166
|
+
sendJson(ctx.res, 200, { removed: body.spec });
|
|
19167
|
+
};
|
|
19168
|
+
}
|
|
19169
|
+
//#endregion
|
|
19170
|
+
//#region src/hub/api/handlers/audit-dismissals.ts
|
|
19171
|
+
/** Far above the largest body `PutAuditDismissalRequestSchema`'s bounds admit. */
|
|
19172
|
+
const MAX_BODY_BYTES$2 = 64 * 1024;
|
|
19173
|
+
/**
|
|
19174
|
+
* GET /api/v1/projects/:project/audit-dismissals — the raw document,
|
|
19175
|
+
* whether or not each entry still answers the spec's current finding. No
|
|
19176
|
+
* `?profile=`: a finding is about the repository (see `AuditDismissalSchema`).
|
|
19177
|
+
*/
|
|
19178
|
+
function createGetAuditDismissalsHandler(storage) {
|
|
19179
|
+
return async (ctx) => {
|
|
19180
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19181
|
+
const doc = await storage.auditDismissals.get(project);
|
|
19182
|
+
sendJson(ctx.res, 200, {
|
|
19183
|
+
project,
|
|
19184
|
+
specs: doc.specs
|
|
19185
|
+
});
|
|
19186
|
+
};
|
|
19187
|
+
}
|
|
19188
|
+
/**
|
|
19189
|
+
* PUT /api/v1/projects/:project/audit-dismissals — record that a person
|
|
19190
|
+
* judged the spec's current audit finding wrong.
|
|
19191
|
+
*
|
|
19192
|
+
* The finding being answered is read from the ledger rather than taken from
|
|
19193
|
+
* the caller: a dismissal must name the run and the label it answers, and
|
|
19194
|
+
* only the hub knows which finding is current. A spec with no open finding is
|
|
19195
|
+
* a 400 — there is nothing to dismiss, and accepting it would write a record
|
|
19196
|
+
* that never applies to anything.
|
|
19197
|
+
*
|
|
19198
|
+
* The guard stops there on purpose. `/rerun` applies a dismissal only while
|
|
19199
|
+
* the audit is also *current* for the profile being asked about, and that is
|
|
19200
|
+
* a per-profile question this endpoint has no profile to ask it of (a finding
|
|
19201
|
+
* is about the repository, so the dismissal is project-scoped). A dismissal
|
|
19202
|
+
* written while a deploy has overtaken the audit is harmless: the next audit
|
|
19203
|
+
* supersedes the finding, and the record with it.
|
|
19204
|
+
*/
|
|
19205
|
+
function createPutAuditDismissalHandler(storage) {
|
|
19206
|
+
return async (ctx) => {
|
|
19207
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19208
|
+
const [body, ledger] = await Promise.all([readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAuditDismissalRequestSchema, "dismissal body"), storage.driftLedger.getMerged(project)]);
|
|
19209
|
+
const entry = ledger.specs[body.spec];
|
|
19210
|
+
if (!entry || entry.label === null) throw new HttpError(400, "no_open_finding", `spec "${body.spec}" has no open audit finding in project "${project}" — there is nothing to dismiss`);
|
|
19211
|
+
const dismissal = {
|
|
19212
|
+
by: body.by,
|
|
19213
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
19214
|
+
note: body.note,
|
|
19215
|
+
auditRunId: entry.runId,
|
|
19216
|
+
label: entry.label,
|
|
19217
|
+
headline: entry.headline ?? ""
|
|
19218
|
+
};
|
|
19219
|
+
await storage.auditDismissals.update(project, (current) => ({ specs: {
|
|
19220
|
+
...current.specs,
|
|
19221
|
+
[body.spec]: dismissal
|
|
19222
|
+
} }));
|
|
19223
|
+
sendJson(ctx.res, 200, {
|
|
19224
|
+
project,
|
|
19225
|
+
spec: body.spec,
|
|
19226
|
+
dismissal
|
|
19227
|
+
});
|
|
19228
|
+
};
|
|
19229
|
+
}
|
|
19230
|
+
/**
|
|
19231
|
+
* DELETE /api/v1/projects/:project/audit-dismissals — withdraw a dismissal,
|
|
19232
|
+
* putting the audit's finding back in force. Deleting one that does not exist
|
|
19233
|
+
* is 200: the caller asked for its absence, and it is absent.
|
|
19234
|
+
*/
|
|
19235
|
+
function createDeleteAuditDismissalHandler(storage) {
|
|
19236
|
+
return async (ctx) => {
|
|
19237
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19238
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, DeleteAuditDismissalRequestSchema, "dismissal body");
|
|
19239
|
+
await storage.auditDismissals.update(project, (current) => {
|
|
19240
|
+
const { [body.spec]: _, ...rest } = current.specs;
|
|
19241
|
+
return { specs: rest };
|
|
19242
|
+
});
|
|
19243
|
+
sendJson(ctx.res, 200, { removed: body.spec });
|
|
19244
|
+
};
|
|
19245
|
+
}
|
|
19246
|
+
//#endregion
|
|
18896
19247
|
//#region src/hub/api/handlers/spend.ts
|
|
18897
19248
|
/** One entry is a handful of short fields; anything larger is a malformed client. */
|
|
18898
19249
|
const MAX_BODY_BYTES$1 = 4 * 1024;
|
|
@@ -18965,7 +19316,7 @@ function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
|
|
|
18965
19316
|
return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
|
|
18966
19317
|
}
|
|
18967
19318
|
function computeRerun(input) {
|
|
18968
|
-
const { specs, ledger, log, touchIndex, drift, locks, now } = input;
|
|
19319
|
+
const { specs, ledger, log, touchIndex, drift, locks, attestations, dismissals, now } = input;
|
|
18969
19320
|
const range = buildRange(log, touchIndex);
|
|
18970
19321
|
const deployTimes = deployedAt(log);
|
|
18971
19322
|
const out = {};
|
|
@@ -18978,16 +19329,35 @@ function computeRerun(input) {
|
|
|
18978
19329
|
let audit = auditState(drift, spec.key, range);
|
|
18979
19330
|
let execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
|
|
18980
19331
|
const driftEntry = drift.specs[spec.key];
|
|
19332
|
+
const dismissal = dismissals.specs[spec.key];
|
|
19333
|
+
const dismissed = dismissal !== void 0 && driftEntry !== void 0 && dismissal.auditRunId === driftEntry.runId && dismissal.label === driftEntry.label && (audit.audit === "drifted" || audit.audit === "undecided");
|
|
19334
|
+
if (dismissed) audit = { audit: "clean" };
|
|
18981
19335
|
const auditMoved = specMovedSince(spec.changedAt, driftEntry?.gitHead ?? null, driftEntry?.at ?? "", deployTimes);
|
|
18982
19336
|
const runMoved = specMovedSince(spec.changedAt, coords.lastRun?.deployedSha ?? null, coords.lastRun?.at ?? "", deployTimes);
|
|
18983
19337
|
if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
|
|
18984
19338
|
if (runMoved && execution.execution === "passed") execution = { execution: "stale" };
|
|
18985
19339
|
const held = heldBy(locks, spec.key, now);
|
|
19340
|
+
let verdict = decide(audit.audit, execution.execution, held);
|
|
19341
|
+
const manualState = readAttestation(attestations.specs[spec.key], spec, coords.lastRed, range, log, deployTimes);
|
|
19342
|
+
if (manualState?.kind === "covers" && !held && verdict !== "verified") verdict = "manuallyVerified";
|
|
18986
19343
|
out[spec.key] = {
|
|
18987
|
-
verdict
|
|
19344
|
+
verdict,
|
|
18988
19345
|
...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
|
|
18989
19346
|
...audit,
|
|
18990
19347
|
...execution,
|
|
19348
|
+
...dismissal ? {
|
|
19349
|
+
auditDismissed: dismissal,
|
|
19350
|
+
auditDismissalApplied: dismissed
|
|
19351
|
+
} : {},
|
|
19352
|
+
...manualState?.kind === "covers" ? { manual: manualState.attest } : {},
|
|
19353
|
+
...manualState?.kind === "lapsed" ? {
|
|
19354
|
+
manualLapsed: {
|
|
19355
|
+
...manualState.attest,
|
|
19356
|
+
because: manualState.because
|
|
19357
|
+
},
|
|
19358
|
+
...manualState.because === "deployReached" ? { manualLapsedByDeploy: manualState.byDeploy } : {},
|
|
19359
|
+
...manualState.reason ? { manualLapsedReason: manualState.reason } : {}
|
|
19360
|
+
} : {},
|
|
18991
19361
|
heldBy: held,
|
|
18992
19362
|
...coords
|
|
18993
19363
|
};
|
|
@@ -18995,6 +19365,50 @@ function computeRerun(input) {
|
|
|
18995
19365
|
return out;
|
|
18996
19366
|
}
|
|
18997
19367
|
/**
|
|
19368
|
+
* Does the attestation still speak for what is deployed? Checked in the order
|
|
19369
|
+
* the lapse enum documents: deploy coverage first (a sha the log cannot place
|
|
19370
|
+
* reads as reached, ADR-0014, with the hole kept as an annotation), then the
|
|
19371
|
+
* spec's own edits — compared against when the person looked, because they
|
|
19372
|
+
* read the spec as it stood that moment, which `specMovedSince` covers via a
|
|
19373
|
+
* null baseline sha — then a red run recorded after them, which is newer
|
|
19374
|
+
* information than their word. The null-sha case is the profile that had no
|
|
19375
|
+
* deploy log when they checked: their word covers exactly as long as that
|
|
19376
|
+
* stays true.
|
|
19377
|
+
*/
|
|
19378
|
+
function readAttestation(attest, spec, lastRed, range, log, deployTimes) {
|
|
19379
|
+
if (!attest) return null;
|
|
19380
|
+
const coverage = attest.deployedSha === null ? log.entries.length === 0 ? { kind: "current" } : {
|
|
19381
|
+
kind: "unanswerable",
|
|
19382
|
+
reason: "unknownDeployedSha"
|
|
19383
|
+
} : freshness(attest.deployedSha, spec.key, range);
|
|
19384
|
+
if (coverage.kind === "touched") return {
|
|
19385
|
+
kind: "lapsed",
|
|
19386
|
+
attest,
|
|
19387
|
+
because: "deployReached",
|
|
19388
|
+
byDeploy: coverage.touchedByDeploy
|
|
19389
|
+
};
|
|
19390
|
+
if (coverage.kind === "unanswerable") return {
|
|
19391
|
+
kind: "lapsed",
|
|
19392
|
+
attest,
|
|
19393
|
+
because: "cannotPlace",
|
|
19394
|
+
reason: coverage.reason
|
|
19395
|
+
};
|
|
19396
|
+
if (specMovedSince(spec.changedAt, null, attest.at, deployTimes)) return {
|
|
19397
|
+
kind: "lapsed",
|
|
19398
|
+
attest,
|
|
19399
|
+
because: "specEdited"
|
|
19400
|
+
};
|
|
19401
|
+
if (lastRed !== null && lastRed.at > attest.at) return {
|
|
19402
|
+
kind: "lapsed",
|
|
19403
|
+
attest,
|
|
19404
|
+
because: "newerRed"
|
|
19405
|
+
};
|
|
19406
|
+
return {
|
|
19407
|
+
kind: "covers",
|
|
19408
|
+
attest
|
|
19409
|
+
};
|
|
19410
|
+
}
|
|
19411
|
+
/**
|
|
18998
19412
|
* Axis 1, derived from the same freshness answer `--only-hub-audit-needed`
|
|
18999
19413
|
* reads. The label only speaks once the audit is known to be current: a
|
|
19000
19414
|
* verdict about an older commit says nothing about the one running now.
|
|
@@ -19098,13 +19512,15 @@ function createGetRerunHandler(storage) {
|
|
|
19098
19512
|
return async (ctx) => {
|
|
19099
19513
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
19100
19514
|
const profile = requireProfileParam(ctx.url);
|
|
19101
|
-
const [specs, ledger, log, touchIndex, drift, locks] = await Promise.all([
|
|
19515
|
+
const [specs, ledger, log, touchIndex, drift, locks, attestations, dismissals] = await Promise.all([
|
|
19102
19516
|
requireSpecTargets(storage.perspectives, project, "which specs need a re-run"),
|
|
19103
19517
|
storage.ledger.getMerged(project, profile),
|
|
19104
19518
|
storage.deploys.getLog(project, profile),
|
|
19105
19519
|
storage.deploys.getTouchIndex(project, profile),
|
|
19106
19520
|
storage.driftLedger.getMerged(project),
|
|
19107
|
-
storage.locks.get(project, profile)
|
|
19521
|
+
storage.locks.get(project, profile),
|
|
19522
|
+
storage.attestations.get(project, profile),
|
|
19523
|
+
storage.auditDismissals.get(project)
|
|
19108
19524
|
]);
|
|
19109
19525
|
const head = log.entries[log.entries.length - 1];
|
|
19110
19526
|
sendJson(ctx.res, 200, {
|
|
@@ -19118,6 +19534,8 @@ function createGetRerunHandler(storage) {
|
|
|
19118
19534
|
touchIndex,
|
|
19119
19535
|
drift,
|
|
19120
19536
|
locks,
|
|
19537
|
+
attestations,
|
|
19538
|
+
dismissals,
|
|
19121
19539
|
now: /* @__PURE__ */ new Date()
|
|
19122
19540
|
})
|
|
19123
19541
|
});
|
|
@@ -19842,6 +20260,7 @@ const HTML_BODY = `
|
|
|
19842
20260
|
<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
20261
|
<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
20262
|
<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>
|
|
20263
|
+
<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
20264
|
<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
20265
|
</div>
|
|
19847
20266
|
<div class="spacer"></div>
|
|
@@ -20567,6 +20986,9 @@ const CSS = `
|
|
|
20567
20986
|
.sg-audit-undecided { background: var(--info); }
|
|
20568
20987
|
.sg-verified, .sg-audit-clean, .sg-exec-passed { background: var(--pass); }
|
|
20569
20988
|
.sg-inprogress, .sg-audit-due, .sg-exec-never { background: var(--muted-2); }
|
|
20989
|
+
/* A person's word, not the machine's — kept off the pass/fail palette both
|
|
20990
|
+
axes share so it never reads as either one. */
|
|
20991
|
+
.sg-manual { background: var(--violet); }
|
|
20570
20992
|
|
|
20571
20993
|
.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
20994
|
.search svg { width: 15px; height: 15px; flex: none; color: var(--muted-2); }
|
|
@@ -20605,6 +21027,11 @@ const CSS = `
|
|
|
20605
21027
|
.badge.rr-unknown .d { background: var(--info); }
|
|
20606
21028
|
.badge.rr-none { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
20607
21029
|
.badge.rr-none .d { background: var(--muted); }
|
|
21030
|
+
/* A person's attestation, not the audit/run pipeline's own verdict — kept
|
|
21031
|
+
off that palette (rr-repair/rr-needed/rr-none/passed) the same way
|
|
21032
|
+
sg-manual is kept off the summary bar's. */
|
|
21033
|
+
.badge.rr-manual { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
|
|
21034
|
+
.badge.rr-manual .d { background: var(--violet); }
|
|
20608
21035
|
.cellsub { display: block; margin-top: 3px; max-width: 260px; color: var(--muted); font-size: 11.5px; line-height: 1.45; }
|
|
20609
21036
|
.graded-mark { color: var(--fg-dim); font-weight: 600; }
|
|
20610
21037
|
.cellsub a { color: var(--muted); text-decoration: none; border-bottom: 1px dotted var(--border-strong); }
|
|
@@ -20660,6 +21087,11 @@ const CSS = `
|
|
|
20660
21087
|
.d-paths { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
20661
21088
|
.d-paths code { white-space: nowrap; }
|
|
20662
21089
|
.d-prose + .d-paths { margin-top: 6px; }
|
|
21090
|
+
.manual-attest { margin-top: 14px; }
|
|
21091
|
+
.steps-box { margin-top: 14px; max-width: 900px; }
|
|
21092
|
+
.steps-box .slabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
21093
|
+
.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; }
|
|
21094
|
+
.d-steps .step-expected { display: block; margin-top: 2px; font-size: 12.5px; }
|
|
20663
21095
|
.notebox { margin-top: 14px; max-width: 900px; }
|
|
20664
21096
|
.notebox .nlabel { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
20665
21097
|
.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; }
|
|
@@ -20667,6 +21099,12 @@ const CSS = `
|
|
|
20667
21099
|
.notebox .nstatus { font-size: 12px; color: var(--muted); }
|
|
20668
21100
|
.notebox .nstatus.ok { color: var(--pass); }
|
|
20669
21101
|
.notebox .nstatus.err { color: var(--fail); }
|
|
21102
|
+
/* The inline form an audit-dismissal or environment-attestation button
|
|
21103
|
+
expands into, in place of the two window.prompt() calls this replaces. */
|
|
21104
|
+
.override-form { margin-top: 10px; max-width: 480px; display: flex; flex-direction: column; gap: 10px; }
|
|
21105
|
+
.override-form 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; }
|
|
21106
|
+
.override-form .of-act { display: flex; align-items: center; gap: 8px; }
|
|
21107
|
+
.override-form .of-hint { font-size: 12px; color: var(--muted); max-width: 62ch; line-height: 1.5; }
|
|
20670
21108
|
|
|
20671
21109
|
@media (max-width: 900px) { .app { grid-template-columns: 1fr; } .sidebar { display: none; } .logo .wm { display: none; } .split { grid-template-columns: 1fr; } .rd-head .meta { margin-left: 0; } }
|
|
20672
21110
|
@media (max-width: 700px) { .prompt-grid { grid-template-columns: 1fr; } .prompt-diff { grid-template-columns: 1fr; } }
|
|
@@ -20700,6 +21138,7 @@ const CLIENT_JS = `
|
|
|
20700
21138
|
var THEME_KEY = "ccqa-hub-theme";
|
|
20701
21139
|
var PROJECT_KEY = "ccqa-hub-project";
|
|
20702
21140
|
var PROFILES_KEY = "ccqa-hub-profiles";
|
|
21141
|
+
var ATTEST_BY_KEY = "ccqa-attest-by";
|
|
20703
21142
|
|
|
20704
21143
|
// ── i18n ──────────────────────────────────────────────────────────────
|
|
20705
21144
|
// Chrome + labels only. Model output (headline/recommendation/reasoning) is
|
|
@@ -20788,6 +21227,8 @@ const CLIENT_JS = `
|
|
|
20788
21227
|
"perspectives.ov.cases": "cases", "perspectives.ov.features": "features",
|
|
20789
21228
|
"perspectives.d.preconditions": "Preconditions", "perspectives.d.startScreen": "Start screen",
|
|
20790
21229
|
"perspectives.d.testCondition": "Condition", "perspectives.d.spec": "spec",
|
|
21230
|
+
"perspectives.d.steps": "Steps", "perspectives.d.stepInclude": "Include: {name}",
|
|
21231
|
+
"perspectives.d.stepExpected": "Expected:",
|
|
20791
21232
|
"perspectives.note.label": "Note",
|
|
20792
21233
|
"perspectives.note.placeholder": "Notes about this case…",
|
|
20793
21234
|
"perspectives.note.saved": "Saved",
|
|
@@ -20800,6 +21241,7 @@ const CLIENT_JS = `
|
|
|
20800
21241
|
"perspectives.rerun.state.needsRepair": "Needs repair",
|
|
20801
21242
|
"perspectives.rerun.state.rerunNeeded": "Re-run needed",
|
|
20802
21243
|
"perspectives.rerun.state.inProgress": "In progress",
|
|
21244
|
+
"perspectives.rerun.state.manuallyVerified": "Manually verified",
|
|
20803
21245
|
"perspectives.rerun.state.verified": "Verified",
|
|
20804
21246
|
"perspectives.rerun.vsDeploy": "judged against deploy",
|
|
20805
21247
|
"perspectives.rerun.noDeployHead": "no deploy recorded for this profile",
|
|
@@ -20835,6 +21277,29 @@ const CLIENT_JS = `
|
|
|
20835
21277
|
"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
21278
|
"perspectives.rerun.deployHead": "deploy head",
|
|
20837
21279
|
"perspectives.drift.graded": "confirmed",
|
|
21280
|
+
"perspectives.manual.revokeButton": "Revoke manual verification",
|
|
21281
|
+
"perspectives.manual.envButton": "If the environment issue is resolved, use this",
|
|
21282
|
+
"perspectives.manual.confirmRevoke": "Revoke the manual verification for this case?",
|
|
21283
|
+
"perspectives.manual.error": "Could not save — retry",
|
|
21284
|
+
"perspectives.manual.verifiedBy": "{by} manually verified this ({at})",
|
|
21285
|
+
"perspectives.manual.lapsed.deployReached": "the manual verification lapsed when a deploy reached this case",
|
|
21286
|
+
"perspectives.manual.lapsed.deployReachedNamed": "the manual verification lapsed when deploy {sha} reached this case ({at})",
|
|
21287
|
+
"perspectives.manual.lapsed.cannotPlace": "the manual verification lapsed — its baseline deploy could not be placed in the log",
|
|
21288
|
+
"perspectives.manual.lapsed.specEdited": "the manual verification lapsed when the spec was edited",
|
|
21289
|
+
"perspectives.manual.lapsed.newerRed": "the manual verification lapsed after a later run failed",
|
|
21290
|
+
"perspectives.manual.lapsed.unrecognized": "the manual verification lapsed for a reason this UI does not recognise",
|
|
21291
|
+
"perspectives.dismiss.offerButton": "If the test spec was fine, use this",
|
|
21292
|
+
"perspectives.dismiss.revokeButton": "Undo the dismissal",
|
|
21293
|
+
"perspectives.dismiss.confirmRevoke": "Undo the dismissal for this case?",
|
|
21294
|
+
"perspectives.dismiss.activeNote": "Audit finding “{headline}” was dismissed by {by} as a false positive ({at}) — “{note}”. The next run will settle it.",
|
|
21295
|
+
"perspectives.dismiss.priorNote": "This finding was previously dismissed by {by} ({at}) — “{note}”.",
|
|
21296
|
+
"perspectives.override.byLabel": "Verified by",
|
|
21297
|
+
"perspectives.override.reasonLabel": "Reason (required)",
|
|
21298
|
+
"perspectives.override.noteLabel": "What was resolved, and how you checked (required)",
|
|
21299
|
+
"perspectives.override.submit": "Record",
|
|
21300
|
+
"perspectives.override.cancel": "Never mind",
|
|
21301
|
+
"perspectives.override.dismissHint": "Overrides the audit finding and closes the case. The verdict moves to “re-run needed”, and the next run settles it.",
|
|
21302
|
+
"perspectives.override.envHint": "The failure stays on record, but the verdict becomes “manually verified” without waiting for a re-run. It lapses once a deploy reaches this spec.",
|
|
20838
21303
|
"prompt.card.record": "Recording browser actions",
|
|
20839
21304
|
"prompt.card.live": "Live run (AI-driven)",
|
|
20840
21305
|
"prompt.card.playwright": "Playwright test generation",
|
|
@@ -20952,6 +21417,8 @@ const CLIENT_JS = `
|
|
|
20952
21417
|
"perspectives.ov.cases": "ケース", "perspectives.ov.features": "機能",
|
|
20953
21418
|
"perspectives.d.preconditions": "前提条件", "perspectives.d.startScreen": "開始画面",
|
|
20954
21419
|
"perspectives.d.testCondition": "実行条件", "perspectives.d.spec": "spec",
|
|
21420
|
+
"perspectives.d.steps": "手順", "perspectives.d.stepInclude": "ブロック: {name}",
|
|
21421
|
+
"perspectives.d.stepExpected": "期待結果:",
|
|
20955
21422
|
"perspectives.note.label": "note",
|
|
20956
21423
|
"perspectives.note.placeholder": "このケースについてのメモ…",
|
|
20957
21424
|
"perspectives.note.saved": "保存しました",
|
|
@@ -20964,6 +21431,7 @@ const CLIENT_JS = `
|
|
|
20964
21431
|
"perspectives.rerun.state.needsRepair": "修正待ち",
|
|
20965
21432
|
"perspectives.rerun.state.rerunNeeded": "要再実行",
|
|
20966
21433
|
"perspectives.rerun.state.inProgress": "進行中",
|
|
21434
|
+
"perspectives.rerun.state.manuallyVerified": "手動確認済み",
|
|
20967
21435
|
"perspectives.rerun.state.verified": "検証済み",
|
|
20968
21436
|
"perspectives.rerun.vsDeploy": "判定基準: デプロイ",
|
|
20969
21437
|
"perspectives.rerun.noDeployHead": "このプロファイルにはデプロイの記録がありません",
|
|
@@ -20999,6 +21467,29 @@ const CLIENT_JS = `
|
|
|
20999
21467
|
"perspectives.rerun.noDeployLogBanner": "プロファイル {profile} にデプロイの記録がないため、どのケースも判定できません。この環境のデプロイジョブに ccqa hub deploy record を組み込んでください。",
|
|
21000
21468
|
"perspectives.rerun.deployHead": "最新デプロイ",
|
|
21001
21469
|
"perspectives.drift.graded": "人が確認",
|
|
21470
|
+
"perspectives.manual.revokeButton": "手動確認を取り消す",
|
|
21471
|
+
"perspectives.manual.envButton": "環境要因が解消した場合はこちら",
|
|
21472
|
+
"perspectives.manual.confirmRevoke": "このケースの手動確認を取り消しますか?",
|
|
21473
|
+
"perspectives.manual.error": "保存に失敗しました — 再試行してください",
|
|
21474
|
+
"perspectives.manual.verifiedBy": "{by}さんが手動確認({at})",
|
|
21475
|
+
"perspectives.manual.lapsed.deployReached": "手動確認はデプロイの到達により失効",
|
|
21476
|
+
"perspectives.manual.lapsed.deployReachedNamed": "手動確認はデプロイ {sha}({at})の到達により失効",
|
|
21477
|
+
"perspectives.manual.lapsed.cannotPlace": "手動確認は基準デプロイをログで特定できず失効",
|
|
21478
|
+
"perspectives.manual.lapsed.specEdited": "手動確認はspecの編集により失効",
|
|
21479
|
+
"perspectives.manual.lapsed.newerRed": "手動確認は直後の実行失敗により失効",
|
|
21480
|
+
"perspectives.manual.lapsed.unrecognized": "このUIが認識できない理由により手動確認が失効",
|
|
21481
|
+
"perspectives.dismiss.offerButton": "テスト仕様に問題がなかった場合はこちら",
|
|
21482
|
+
"perspectives.dismiss.revokeButton": "棄却を取り消す",
|
|
21483
|
+
"perspectives.dismiss.confirmRevoke": "このケースの棄却を取り消しますか?",
|
|
21484
|
+
"perspectives.dismiss.activeNote": "監査指摘「{headline}」は{by}が誤検知として棄却({at})—「{note}」。次の実行が裁定します。",
|
|
21485
|
+
"perspectives.dismiss.priorNote": "前回この指摘は{by}が棄却しています({at})—「{note}」",
|
|
21486
|
+
"perspectives.override.byLabel": "確認した人",
|
|
21487
|
+
"perspectives.override.reasonLabel": "理由(必須)",
|
|
21488
|
+
"perspectives.override.noteLabel": "解消と確認の内容(必須)",
|
|
21489
|
+
"perspectives.override.submit": "記録する",
|
|
21490
|
+
"perspectives.override.cancel": "やめる",
|
|
21491
|
+
"perspectives.override.dismissHint": "監査の指摘を上書きして台帳を閉じます。判定は「要再実行」に移り、次の実行が正否を裁定します。",
|
|
21492
|
+
"perspectives.override.envHint": "失敗の記録は残したまま、判定は再実行を待たず「手動確認済み」になります。次のデプロイがこの spec に届くと失効します。",
|
|
21002
21493
|
"prompt.card.record": "ブラウザ操作の記録",
|
|
21003
21494
|
"prompt.card.live": "ライブ実行(AI操作)",
|
|
21004
21495
|
"prompt.card.playwright": "Playwrightテスト生成",
|
|
@@ -21132,6 +21623,17 @@ const CLIENT_JS = `
|
|
|
21132
21623
|
try { window.localStorage.removeItem(PROJECT_KEY); }
|
|
21133
21624
|
catch (e) { console.warn("ccqa hub: could not clear stored project:", e); }
|
|
21134
21625
|
}
|
|
21626
|
+
|
|
21627
|
+
// Remembered across attestations so the prompt's default is whoever last
|
|
21628
|
+
// used this browser, not a blank field every time.
|
|
21629
|
+
function loadAttestBy() {
|
|
21630
|
+
try { return window.localStorage.getItem(ATTEST_BY_KEY) || ""; }
|
|
21631
|
+
catch (e) { return ""; }
|
|
21632
|
+
}
|
|
21633
|
+
function storeAttestBy(by) {
|
|
21634
|
+
try { window.localStorage.setItem(ATTEST_BY_KEY, by); }
|
|
21635
|
+
catch (e) { /* non-fatal: next prompt just starts blank again */ }
|
|
21636
|
+
}
|
|
21135
21637
|
function loadProfileMap() {
|
|
21136
21638
|
try {
|
|
21137
21639
|
var raw = window.localStorage.getItem(PROFILES_KEY);
|
|
@@ -23080,6 +23582,18 @@ const CLIENT_JS = `
|
|
|
23080
23582
|
"/rerun?profile=" + encodeURIComponent(state.profile);
|
|
23081
23583
|
}
|
|
23082
23584
|
|
|
23585
|
+
function attestationsPath() {
|
|
23586
|
+
return "/api/v1/projects/" + encodeURIComponent(state.project) +
|
|
23587
|
+
"/attestations?profile=" + encodeURIComponent(state.profile);
|
|
23588
|
+
}
|
|
23589
|
+
|
|
23590
|
+
// A person's answer to an audit finding, not an environment: the finding is
|
|
23591
|
+
// about the repository, not a deployed profile, so this carries no
|
|
23592
|
+
// ?profile= (unlike attestationsPath above).
|
|
23593
|
+
function auditDismissalsPath() {
|
|
23594
|
+
return "/api/v1/projects/" + encodeURIComponent(state.project) + "/audit-dismissals";
|
|
23595
|
+
}
|
|
23596
|
+
|
|
23083
23597
|
// Resolves { report } or { note } and never rejects: a hub that predates
|
|
23084
23598
|
// the endpoint costs only the columns it feeds, not the whole tab. A 404
|
|
23085
23599
|
// here can only mean "no such route" — the endpoint's own 404 is "the
|
|
@@ -23158,6 +23672,37 @@ const CLIENT_JS = `
|
|
|
23158
23672
|
return text === prefix + reason ? t(prefix + "unrecognized") : text;
|
|
23159
23673
|
}
|
|
23160
23674
|
|
|
23675
|
+
// Who attested and when, plus their note if they left one — the whole
|
|
23676
|
+
// reason a "manuallyVerified" verdict has no evidence row of its own.
|
|
23677
|
+
function manualAttestationText(manual) {
|
|
23678
|
+
var text = t("perspectives.manual.verifiedBy").replace("{by}", manual.by).replace("{at}", relTime(manual.at));
|
|
23679
|
+
return manual.note ? text + " — " + manual.note : text;
|
|
23680
|
+
}
|
|
23681
|
+
|
|
23682
|
+
// An attestation that no longer covers what is deployed, named alongside
|
|
23683
|
+
// whatever the current verdict's own reason is (rerunCellWhy appends this)
|
|
23684
|
+
// rather than replacing it — the person deciding whether to attest again
|
|
23685
|
+
// needs both: what the axes say now, and that a person's word used to
|
|
23686
|
+
// stand in for it. deployReached is the only reason with a named cause
|
|
23687
|
+
// (manualLapsedByDeploy) to add when the log could confirm one.
|
|
23688
|
+
function rerunManualLapseText(rr) {
|
|
23689
|
+
var lapsed = rr && rr.manualLapsed;
|
|
23690
|
+
if (!lapsed) return null;
|
|
23691
|
+
if (lapsed.because === "deployReached") {
|
|
23692
|
+
var deploy = rr.manualLapsedByDeploy;
|
|
23693
|
+
return deploy
|
|
23694
|
+
? t("perspectives.manual.lapsed.deployReachedNamed").replace("{sha}", shortSha(deploy.sha)).replace("{at}", relTime(deploy.at))
|
|
23695
|
+
: t("perspectives.manual.lapsed.deployReached");
|
|
23696
|
+
}
|
|
23697
|
+
if (lapsed.because === "cannotPlace" && rr.manualLapsedReason) {
|
|
23698
|
+
// The same per-hole vocabulary the axes' assumed-reached annotations
|
|
23699
|
+
// use — an empty deploy log and an unjudged range call for different
|
|
23700
|
+
// next actions, so the shrug names which hole it is.
|
|
23701
|
+
return t("perspectives.manual.lapsed.cannotPlace") + " (" + rerunReasonText("perspectives.rerun.why.", rr.manualLapsedReason) + ")";
|
|
23702
|
+
}
|
|
23703
|
+
return rerunReasonText("perspectives.manual.lapsed.", lapsed.because);
|
|
23704
|
+
}
|
|
23705
|
+
|
|
23161
23706
|
// Every verdict that carries no evidence row explains itself here, in the
|
|
23162
23707
|
// actionable phrasing the detail panel wants. decide() checks heldBy before
|
|
23163
23708
|
// the audit axis, so a spec another job already holds must be explained by
|
|
@@ -23177,6 +23722,7 @@ const CLIENT_JS = `
|
|
|
23177
23722
|
? rerunReasonText("perspectives.rerun.fix.", rr.auditAssumedReached)
|
|
23178
23723
|
: t("perspectives.rerun.inProgressHint");
|
|
23179
23724
|
}
|
|
23725
|
+
if (rr.verdict === "manuallyVerified" && rr.manual) return manualAttestationText(rr.manual);
|
|
23180
23726
|
return rerunReasonText("perspectives.rerun.fix.", rr.verdict);
|
|
23181
23727
|
}
|
|
23182
23728
|
|
|
@@ -23195,16 +23741,23 @@ const CLIENT_JS = `
|
|
|
23195
23741
|
// rather than claiming a deploy matched it.
|
|
23196
23742
|
function rerunCellWhy(rr) {
|
|
23197
23743
|
var head = perspState.rerun && perspState.rerun.deployHead;
|
|
23744
|
+
var why;
|
|
23198
23745
|
if (rr.verdict === "rerunNeeded") {
|
|
23199
|
-
if (rr.executionAssumedReached)
|
|
23200
|
-
if (!rr.touchedBy || !rr.touchedBy.length)
|
|
23201
|
-
|
|
23202
|
-
}
|
|
23203
|
-
|
|
23204
|
-
|
|
23205
|
-
|
|
23746
|
+
if (rr.executionAssumedReached) why = rerunReasonText("perspectives.rerun.why.", rr.executionAssumedReached);
|
|
23747
|
+
else if (!rr.touchedBy || !rr.touchedBy.length) why = t("perspectives.rerun.touchedUnknown");
|
|
23748
|
+
else why = t("perspectives.rerun.touchedCount").replace("{n}", String(rr.touchedBy.length));
|
|
23749
|
+
} else if (rr.verdict === "verified") {
|
|
23750
|
+
why = !head
|
|
23751
|
+
? t("perspectives.rerun.noDeployHead")
|
|
23752
|
+
: t("perspectives.rerun.vsDeploy") + " " + shortSha(head.sha) + " · " + relTime(head.at);
|
|
23753
|
+
} else {
|
|
23754
|
+
why = rerunWhyVerdict(rr);
|
|
23206
23755
|
}
|
|
23207
|
-
|
|
23756
|
+
// A lapsed attestation is worth saying no matter which verdict the axes
|
|
23757
|
+
// landed on afterwards — it is not that verdict's reason, so it is
|
|
23758
|
+
// appended rather than replacing it.
|
|
23759
|
+
var lapse = rerunManualLapseText(rr);
|
|
23760
|
+
return lapse ? why + " · " + lapse : why;
|
|
23208
23761
|
}
|
|
23209
23762
|
|
|
23210
23763
|
|
|
@@ -23218,10 +23771,10 @@ const CLIENT_JS = `
|
|
|
23218
23771
|
// the pipeline still owes, then what needs nothing. "needsRepair" leads
|
|
23219
23772
|
// because it is the only verdict a run cannot clear — someone has to repair
|
|
23220
23773
|
// the spec or the product.
|
|
23221
|
-
var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "verified"];
|
|
23774
|
+
var RERUN_ORDER = ["needsRepair", "rerunNeeded", "inProgress", "manuallyVerified", "verified"];
|
|
23222
23775
|
var RERUN_SEG_CLASS = {
|
|
23223
23776
|
needsRepair: "sg-needsrepair", rerunNeeded: "sg-rerunneeded",
|
|
23224
|
-
inProgress: "sg-inprogress", verified: "sg-verified"
|
|
23777
|
+
inProgress: "sg-inprogress", manuallyVerified: "sg-manual", verified: "sg-verified"
|
|
23225
23778
|
};
|
|
23226
23779
|
|
|
23227
23780
|
// The one rule the summary bar and the verdict filter chips both answer
|
|
@@ -23235,7 +23788,7 @@ const CLIENT_JS = `
|
|
|
23235
23788
|
|
|
23236
23789
|
// One verdict per case, bucketed, via rerunVerdictOf above.
|
|
23237
23790
|
function rerunComposition(verdicts) {
|
|
23238
|
-
var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, verified: 0 };
|
|
23791
|
+
var counts = { needsRepair: 0, rerunNeeded: 0, inProgress: 0, manuallyVerified: 0, verified: 0 };
|
|
23239
23792
|
verdicts.forEach(function (rr) { counts[rerunVerdictOf(rr)] += 1; });
|
|
23240
23793
|
return counts;
|
|
23241
23794
|
}
|
|
@@ -23403,7 +23956,7 @@ const CLIENT_JS = `
|
|
|
23403
23956
|
// takes the attention colour and re-running — machine work — does not.
|
|
23404
23957
|
var VERDICT_BADGE = {
|
|
23405
23958
|
needsRepair: "rr-repair", rerunNeeded: "rr-needed",
|
|
23406
|
-
inProgress: "rr-none", verified: "passed"
|
|
23959
|
+
inProgress: "rr-none", manuallyVerified: "rr-manual", verified: "passed"
|
|
23407
23960
|
};
|
|
23408
23961
|
|
|
23409
23962
|
function perspVerdictCell(rr) {
|
|
@@ -23694,28 +24247,80 @@ const CLIENT_JS = `
|
|
|
23694
24247
|
}
|
|
23695
24248
|
// --- end pure: rerun detail labels ----------------------------------------
|
|
23696
24249
|
|
|
24250
|
+
// --- pure: audit dismissal reading ----------------------------------------
|
|
24251
|
+
// Self-contained (no DOM, no closures) for the same reason as the regions
|
|
24252
|
+
// above: read rr.auditDismissed against rr.audit, per the schema's own
|
|
24253
|
+
// comment on the field. "clean" means the dismissal is what is holding the
|
|
24254
|
+
// axis there; "drifted"/"undecided" means a later audit re-raised what it
|
|
24255
|
+
// answered, so the old dismissal no longer covers it.
|
|
24256
|
+
// The audit has something outstanding on this spec. Both values mean the
|
|
24257
|
+
// same thing to a reader deciding whether to answer it: the audit read the
|
|
24258
|
+
// code and did not clear the spec (ADR-0019).
|
|
24259
|
+
function auditOpen(rr) {
|
|
24260
|
+
return !!rr && (rr.audit === "drifted" || rr.audit === "undecided");
|
|
24261
|
+
}
|
|
24262
|
+
function auditDismissalActive(rr) {
|
|
24263
|
+
// The hub says whether the dismissal settled the axis. Inferring it from
|
|
24264
|
+
// "clean" would credit the person for a later audit clearing the spec on
|
|
24265
|
+
// its own, which reads identically here.
|
|
24266
|
+
return !!(rr && rr.auditDismissed && rr.auditDismissalApplied);
|
|
24267
|
+
}
|
|
24268
|
+
function auditDismissalReflagged(rr) {
|
|
24269
|
+
return !!(rr && rr.auditDismissed && auditOpen(rr));
|
|
24270
|
+
}
|
|
24271
|
+
// --- end pure: audit dismissal reading -------------------------------------
|
|
24272
|
+
|
|
24273
|
+
// The dismissal's own words, read against the current audit state: active,
|
|
24274
|
+
// it explains why the axis reads clean; re-flagged, it is a fact worth
|
|
24275
|
+
// keeping visible beside the finding that reopened it.
|
|
24276
|
+
function rerunDismissalLine(rr) {
|
|
24277
|
+
if (!rr || !rr.auditDismissed) return null;
|
|
24278
|
+
var d = rr.auditDismissed;
|
|
24279
|
+
if (auditDismissalActive(rr)) {
|
|
24280
|
+
return {
|
|
24281
|
+
muted: false,
|
|
24282
|
+
text: t("perspectives.dismiss.activeNote")
|
|
24283
|
+
.replace("{headline}", d.headline).replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
|
|
24284
|
+
};
|
|
24285
|
+
}
|
|
24286
|
+
if (auditDismissalReflagged(rr)) {
|
|
24287
|
+
return {
|
|
24288
|
+
muted: true,
|
|
24289
|
+
text: t("perspectives.dismiss.priorNote")
|
|
24290
|
+
.replace("{by}", d.by).replace("{at}", relTime(d.at)).replace("{note}", d.note),
|
|
24291
|
+
};
|
|
24292
|
+
}
|
|
24293
|
+
return null;
|
|
24294
|
+
}
|
|
24295
|
+
|
|
23697
24296
|
// The evidence behind the verdict, as the value of whichever row
|
|
23698
24297
|
// rerunEvidenceLabelKey chose. For needed/notNeeded that is what the deploy
|
|
23699
24298
|
// log holds since this case last ran, named by rerunChangeLine.
|
|
23700
24299
|
// The label already states the timeframe, so the value never repeats it.
|
|
24300
|
+
// A dismissal (active or superseded by a later finding) is appended below
|
|
24301
|
+
// whichever of those this case has, rather than replacing it — see
|
|
24302
|
+
// rerunDismissalLine.
|
|
23701
24303
|
function rerunEvidenceValue(rr) {
|
|
23702
24304
|
var wrap = el("div");
|
|
23703
24305
|
if (!rerunHasEvidence(rr)) {
|
|
23704
24306
|
wrap.appendChild(el("div", "d-prose", rerunWhyVerdict(rr)));
|
|
23705
|
-
|
|
23706
|
-
|
|
23707
|
-
|
|
23708
|
-
|
|
23709
|
-
|
|
23710
|
-
|
|
23711
|
-
|
|
23712
|
-
|
|
23713
|
-
|
|
23714
|
-
|
|
23715
|
-
|
|
23716
|
-
|
|
23717
|
-
|
|
24307
|
+
} else {
|
|
24308
|
+
// Both states require a non-empty deploy log, so a head-less report
|
|
24309
|
+
// contradicts itself; rerunChangeLine then names what is missing rather
|
|
24310
|
+
// than inventing a baseline.
|
|
24311
|
+
var line = rerunChangeLine(rr, perspState.rerun && perspState.rerun.deployHead);
|
|
24312
|
+
var text = t(line.key).replace("{sha}", shortSha(line.sha));
|
|
24313
|
+
if (line.at) text += " · " + relTime(line.at);
|
|
24314
|
+
wrap.appendChild(el("div", "d-prose", text));
|
|
24315
|
+
// A touch the index proved but cannot enumerate leaves no paths to
|
|
24316
|
+
// list; the line above still says a change landed, which is all that
|
|
24317
|
+
// is known.
|
|
24318
|
+
if (rr.verdict === "rerunNeeded" && rr.touchedBy && rr.touchedBy.length) {
|
|
24319
|
+
wrap.appendChild(pathCodes(rr.touchedBy));
|
|
24320
|
+
}
|
|
23718
24321
|
}
|
|
24322
|
+
var dismissLine = rerunDismissalLine(rr);
|
|
24323
|
+
if (dismissLine) wrap.appendChild(el("div", "d-prose" + (dismissLine.muted ? " muted" : ""), dismissLine.text));
|
|
23719
24324
|
return wrap;
|
|
23720
24325
|
}
|
|
23721
24326
|
|
|
@@ -23734,6 +24339,154 @@ const CLIENT_JS = `
|
|
|
23734
24339
|
return wrap;
|
|
23735
24340
|
}
|
|
23736
24341
|
|
|
24342
|
+
// Lets a person's own check stand in for the machine's verdict. reloadRerun()
|
|
24343
|
+
// is the same profile-scoped refresh a profile switch uses — it re-renders
|
|
24344
|
+
// the whole table, so an open detail panel closes along with it.
|
|
24345
|
+
function submitAttestation(method, body) {
|
|
24346
|
+
apiFetch(attestationsPath(), {
|
|
24347
|
+
method: method,
|
|
24348
|
+
headers: { "Content-Type": "application/json" },
|
|
24349
|
+
body: JSON.stringify(body),
|
|
24350
|
+
}).then(function () { reloadRerun(); })
|
|
24351
|
+
.catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
|
|
24352
|
+
}
|
|
24353
|
+
|
|
24354
|
+
// Lets a person say an audit finding was wrong. Same reload contract as
|
|
24355
|
+
// submitAttestation above; a different endpoint (no ?profile=, ADR: a
|
|
24356
|
+
// finding is about the repository).
|
|
24357
|
+
function submitAuditDismissal(method, body) {
|
|
24358
|
+
apiFetch(auditDismissalsPath(), {
|
|
24359
|
+
method: method,
|
|
24360
|
+
headers: { "Content-Type": "application/json" },
|
|
24361
|
+
body: JSON.stringify(body),
|
|
24362
|
+
}).then(function () { reloadRerun(); })
|
|
24363
|
+
.catch(function (err) { window.alert(t("perspectives.manual.error") + ": " + err.message); });
|
|
24364
|
+
}
|
|
24365
|
+
|
|
24366
|
+
function manualRevokeButton(feature, spec) {
|
|
24367
|
+
var btn = el("button", "btn ghost sm del", t("perspectives.manual.revokeButton"));
|
|
24368
|
+
btn.type = "button";
|
|
24369
|
+
btn.addEventListener("click", function () {
|
|
24370
|
+
if (!window.confirm(t("perspectives.manual.confirmRevoke"))) return;
|
|
24371
|
+
submitAttestation("DELETE", { spec: perspSpecKey(feature, spec) });
|
|
24372
|
+
});
|
|
24373
|
+
return btn;
|
|
24374
|
+
}
|
|
24375
|
+
|
|
24376
|
+
function auditDismissalRevokeButton(feature, spec) {
|
|
24377
|
+
var btn = el("button", "btn ghost sm del", t("perspectives.dismiss.revokeButton"));
|
|
24378
|
+
btn.type = "button";
|
|
24379
|
+
btn.addEventListener("click", function () {
|
|
24380
|
+
if (!window.confirm(t("perspectives.dismiss.confirmRevoke"))) return;
|
|
24381
|
+
submitAuditDismissal("DELETE", { spec: perspSpecKey(feature, spec) });
|
|
24382
|
+
});
|
|
24383
|
+
return btn;
|
|
24384
|
+
}
|
|
24385
|
+
|
|
24386
|
+
// The inline form a "dismiss" or "environment" offer button expands into,
|
|
24387
|
+
// in place of the two window.prompt() calls this replaces. Both kinds ask
|
|
24388
|
+
// for the same two things — who, and why — and differ only in wording and
|
|
24389
|
+
// which endpoint the answer goes to.
|
|
24390
|
+
function buildOverrideForm(kind, feature, spec, onCancel) {
|
|
24391
|
+
var wrap = el("div", "override-form");
|
|
24392
|
+
|
|
24393
|
+
var byRow = el("div", "form-row");
|
|
24394
|
+
byRow.appendChild(el("label", null, t("perspectives.override.byLabel")));
|
|
24395
|
+
var byInput = el("input", "input");
|
|
24396
|
+
byInput.type = "text";
|
|
24397
|
+
byInput.value = loadAttestBy();
|
|
24398
|
+
byRow.appendChild(byInput);
|
|
24399
|
+
wrap.appendChild(byRow);
|
|
24400
|
+
|
|
24401
|
+
var noteRow = el("div", "form-row");
|
|
24402
|
+
noteRow.appendChild(el("label", null, t(kind === "dismiss" ? "perspectives.override.reasonLabel" : "perspectives.override.noteLabel")));
|
|
24403
|
+
var noteInput = el("textarea");
|
|
24404
|
+
noteRow.appendChild(noteInput);
|
|
24405
|
+
wrap.appendChild(noteRow);
|
|
24406
|
+
|
|
24407
|
+
var act = el("div", "of-act");
|
|
24408
|
+
var submitBtn = el("button", "btn sm primary", t("perspectives.override.submit"));
|
|
24409
|
+
submitBtn.type = "button";
|
|
24410
|
+
submitBtn.disabled = true;
|
|
24411
|
+
var cancelBtn = el("button", "btn ghost sm", t("perspectives.override.cancel"));
|
|
24412
|
+
cancelBtn.type = "button";
|
|
24413
|
+
act.appendChild(submitBtn);
|
|
24414
|
+
act.appendChild(cancelBtn);
|
|
24415
|
+
wrap.appendChild(act);
|
|
24416
|
+
|
|
24417
|
+
wrap.appendChild(el("div", "of-hint", t(kind === "dismiss" ? "perspectives.override.dismissHint" : "perspectives.override.envHint")));
|
|
24418
|
+
|
|
24419
|
+
function syncEnabled() {
|
|
24420
|
+
submitBtn.disabled = !(byInput.value.trim() && noteInput.value.trim());
|
|
24421
|
+
}
|
|
24422
|
+
byInput.addEventListener("input", syncEnabled);
|
|
24423
|
+
noteInput.addEventListener("input", syncEnabled);
|
|
24424
|
+
cancelBtn.addEventListener("click", onCancel);
|
|
24425
|
+
|
|
24426
|
+
submitBtn.addEventListener("click", function () {
|
|
24427
|
+
var by = byInput.value.trim();
|
|
24428
|
+
var note = noteInput.value.trim();
|
|
24429
|
+
if (!by || !note) return;
|
|
24430
|
+
storeAttestBy(by);
|
|
24431
|
+
if (kind === "dismiss") submitAuditDismissal("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
|
|
24432
|
+
else submitAttestation("PUT", { spec: perspSpecKey(feature, spec), by: by, note: note });
|
|
24433
|
+
});
|
|
24434
|
+
|
|
24435
|
+
return wrap;
|
|
24436
|
+
}
|
|
24437
|
+
|
|
24438
|
+
// A button that expands into buildOverrideForm above in place, rather than
|
|
24439
|
+
// a modal — the action is rare enough that swapping the button for its own
|
|
24440
|
+
// form reads fine without one.
|
|
24441
|
+
function buildOverrideOffer(kind, feature, spec) {
|
|
24442
|
+
var box = el("div", "manual-attest");
|
|
24443
|
+
var openBtn = el("button", "btn sm primary", t(kind === "dismiss" ? "perspectives.dismiss.offerButton" : "perspectives.manual.envButton"));
|
|
24444
|
+
openBtn.type = "button";
|
|
24445
|
+
box.appendChild(openBtn);
|
|
24446
|
+
openBtn.addEventListener("click", function () {
|
|
24447
|
+
box.removeChild(openBtn);
|
|
24448
|
+
var form = buildOverrideForm(kind, feature, spec, function () {
|
|
24449
|
+
box.removeChild(form);
|
|
24450
|
+
box.appendChild(openBtn);
|
|
24451
|
+
});
|
|
24452
|
+
box.appendChild(form);
|
|
24453
|
+
});
|
|
24454
|
+
return box;
|
|
24455
|
+
}
|
|
24456
|
+
|
|
24457
|
+
// The audit-axis override slot: dismiss an open finding, or revoke a
|
|
24458
|
+
// dismissal that is currently the reason the axis reads clean. At most one
|
|
24459
|
+
// of the two ever shows — a finding the axis
|
|
24460
|
+
// itself has cleared, dismissed or not, offers nothing here.
|
|
24461
|
+
function auditOverrideBox(feature, spec, rr) {
|
|
24462
|
+
if (auditDismissalActive(rr)) {
|
|
24463
|
+
var box = el("div", "manual-attest");
|
|
24464
|
+
box.appendChild(auditDismissalRevokeButton(feature, spec));
|
|
24465
|
+
return box;
|
|
24466
|
+
}
|
|
24467
|
+
if (auditOpen(rr)) return buildOverrideOffer("dismiss", feature, spec);
|
|
24468
|
+
return null;
|
|
24469
|
+
}
|
|
24470
|
+
|
|
24471
|
+
// The execution-axis override slot: revoke a standing attestation, or offer
|
|
24472
|
+
// one for an environment-caused failure — but only when the audit axis has
|
|
24473
|
+
// no open finding of its own, which is auditOverrideBox's problem to answer,
|
|
24474
|
+
// not this one's.
|
|
24475
|
+
function executionOverrideBox(feature, spec, rr) {
|
|
24476
|
+
if (rr.manual) {
|
|
24477
|
+
var box = el("div", "manual-attest");
|
|
24478
|
+
if (rr.verdict !== "manuallyVerified") box.appendChild(el("div", "d-prose", manualAttestationText(rr.manual)));
|
|
24479
|
+
box.appendChild(manualRevokeButton(feature, spec));
|
|
24480
|
+
return box;
|
|
24481
|
+
}
|
|
24482
|
+
// Only once the audit has actually cleared the spec: while it is still
|
|
24483
|
+
// due, nobody knows yet whether the environment was the only thing wrong.
|
|
24484
|
+
if (rr.audit === "clean" && rr.execution === "failed" && rr.lastRed && rr.lastRed.label === "ENVIRONMENT") {
|
|
24485
|
+
return buildOverrideOffer("environment", feature, spec);
|
|
24486
|
+
}
|
|
24487
|
+
return null;
|
|
24488
|
+
}
|
|
24489
|
+
|
|
23737
24490
|
// Detail row: a definition list of the case's fields plus the note editor.
|
|
23738
24491
|
// Built with createElement/textContent throughout — every field here is
|
|
23739
24492
|
// API-derived, so none of it may go through innerHTML.
|
|
@@ -23771,6 +24524,36 @@ const CLIENT_JS = `
|
|
|
23771
24524
|
}
|
|
23772
24525
|
frag.appendChild(dl);
|
|
23773
24526
|
|
|
24527
|
+
if (spec.steps && spec.steps.length) {
|
|
24528
|
+
var stepsBox = el("div", "steps-box");
|
|
24529
|
+
stepsBox.appendChild(el("div", "slabel", t("perspectives.d.steps")));
|
|
24530
|
+
var stepsList = el("ol", "d-steps");
|
|
24531
|
+
spec.steps.forEach(function (step) {
|
|
24532
|
+
var li = el("li");
|
|
24533
|
+
if (step.include) {
|
|
24534
|
+
li.textContent = t("perspectives.d.stepInclude").replace("{name}", step.include);
|
|
24535
|
+
} else {
|
|
24536
|
+
li.appendChild(document.createTextNode(step.instruction || ""));
|
|
24537
|
+
if (step.expected) {
|
|
24538
|
+
li.appendChild(el("div", "muted step-expected", t("perspectives.d.stepExpected") + " " + step.expected));
|
|
24539
|
+
}
|
|
24540
|
+
}
|
|
24541
|
+
stepsList.appendChild(li);
|
|
24542
|
+
});
|
|
24543
|
+
stepsBox.appendChild(stepsList);
|
|
24544
|
+
frag.appendChild(stepsBox);
|
|
24545
|
+
}
|
|
24546
|
+
|
|
24547
|
+
// A person's override, always at most one control per axis: which finding
|
|
24548
|
+
// is open decides whether that slot offers a new override or revokes a
|
|
24549
|
+
// standing one (auditOverrideBox / executionOverrideBox).
|
|
24550
|
+
if (rr) {
|
|
24551
|
+
var auditBox = auditOverrideBox(feature, spec, rr);
|
|
24552
|
+
if (auditBox) frag.appendChild(auditBox);
|
|
24553
|
+
var execBox = executionOverrideBox(feature, spec, rr);
|
|
24554
|
+
if (execBox) frag.appendChild(execBox);
|
|
24555
|
+
}
|
|
24556
|
+
|
|
23774
24557
|
var notebox = el("div", "notebox");
|
|
23775
24558
|
notebox.appendChild(el("div", "nlabel", t("perspectives.note.label")));
|
|
23776
24559
|
var ta = el("textarea");
|
|
@@ -25062,6 +25845,12 @@ function registerRoutes(router, config, queue) {
|
|
|
25062
25845
|
router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
|
|
25063
25846
|
router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
|
|
25064
25847
|
router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
|
|
25848
|
+
router.get("/api/v1/projects/:project/attestations", createGetAttestationsHandler(storage));
|
|
25849
|
+
router.put("/api/v1/projects/:project/attestations", createPutAttestationHandler(storage));
|
|
25850
|
+
router.delete("/api/v1/projects/:project/attestations", createDeleteAttestationHandler(storage));
|
|
25851
|
+
router.get("/api/v1/projects/:project/audit-dismissals", createGetAuditDismissalsHandler(storage));
|
|
25852
|
+
router.put("/api/v1/projects/:project/audit-dismissals", createPutAuditDismissalHandler(storage));
|
|
25853
|
+
router.delete("/api/v1/projects/:project/audit-dismissals", createDeleteAuditDismissalHandler(storage));
|
|
25065
25854
|
router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
|
|
25066
25855
|
router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
|
|
25067
25856
|
router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
|
|
@@ -25351,6 +26140,12 @@ function deployTouchIndexPath(root, project, profile) {
|
|
|
25351
26140
|
function specLocksPath(root, project, profile) {
|
|
25352
26141
|
return join(root, "locks", project, profile, "locks.json");
|
|
25353
26142
|
}
|
|
26143
|
+
function attestationsPath(root, project, profile) {
|
|
26144
|
+
return join(root, "attestations", project, profile, "attestations.json");
|
|
26145
|
+
}
|
|
26146
|
+
function auditDismissalsPath(root, project) {
|
|
26147
|
+
return join(root, "audit-dismissals", `${project}.json`);
|
|
26148
|
+
}
|
|
25354
26149
|
function ackPath(root, project, profile, name) {
|
|
25355
26150
|
return join(root, "acks", project, profile, `${name}.json`);
|
|
25356
26151
|
}
|
|
@@ -25392,6 +26187,38 @@ function createFileAckStore(root) {
|
|
|
25392
26187
|
};
|
|
25393
26188
|
}
|
|
25394
26189
|
//#endregion
|
|
26190
|
+
//#region src/hub/core/storage/file/attestation-store.ts
|
|
26191
|
+
function toAttestations(doc) {
|
|
26192
|
+
const parsed = AttestationsSchema.safeParse(doc);
|
|
26193
|
+
return parsed.success ? parsed.data : { specs: {} };
|
|
26194
|
+
}
|
|
26195
|
+
function createFileAttestationStore(root) {
|
|
26196
|
+
return {
|
|
26197
|
+
async get(project, profile) {
|
|
26198
|
+
return toAttestations(await readJson(attestationsPath(root, project, profile)));
|
|
26199
|
+
},
|
|
26200
|
+
async update(project, profile, mutate) {
|
|
26201
|
+
return updateJson(attestationsPath(root, project, profile), (current) => mutate(toAttestations(current)));
|
|
26202
|
+
}
|
|
26203
|
+
};
|
|
26204
|
+
}
|
|
26205
|
+
//#endregion
|
|
26206
|
+
//#region src/hub/core/storage/file/audit-dismissal-store.ts
|
|
26207
|
+
function toDismissals(doc) {
|
|
26208
|
+
const parsed = AuditDismissalsSchema.safeParse(doc);
|
|
26209
|
+
return parsed.success ? parsed.data : { specs: {} };
|
|
26210
|
+
}
|
|
26211
|
+
function createFileAuditDismissalStore(root) {
|
|
26212
|
+
return {
|
|
26213
|
+
async get(project) {
|
|
26214
|
+
return toDismissals(await readJson(auditDismissalsPath(root, project)));
|
|
26215
|
+
},
|
|
26216
|
+
async update(project, mutate) {
|
|
26217
|
+
return updateJson(auditDismissalsPath(root, project), (current) => mutate(toDismissals(current)));
|
|
26218
|
+
}
|
|
26219
|
+
};
|
|
26220
|
+
}
|
|
26221
|
+
//#endregion
|
|
25395
26222
|
//#region src/hub/core/storage/file/artifact-store.ts
|
|
25396
26223
|
/**
|
|
25397
26224
|
* Defense-in-depth: `relPath` is expected to already be validated by the
|
|
@@ -25875,7 +26702,9 @@ function createFileHubStorage(dataDir) {
|
|
|
25875
26702
|
deploys: createFileDeployStore(dataDir),
|
|
25876
26703
|
locks: createFileLockStore(dataDir),
|
|
25877
26704
|
acks: createFileAckStore(dataDir),
|
|
25878
|
-
spend: createFileSpendStore(dataDir)
|
|
26705
|
+
spend: createFileSpendStore(dataDir),
|
|
26706
|
+
attestations: createFileAttestationStore(dataDir),
|
|
26707
|
+
auditDismissals: createFileAuditDismissalStore(dataDir)
|
|
25879
26708
|
};
|
|
25880
26709
|
}
|
|
25881
26710
|
//#endregion
|