ccqa 1.24.0 → 1.26.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 +912 -249
- package/dist/hub-client/index.d.mts +69 -2
- package/dist/hub-client/index.mjs +16 -0
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -3115,12 +3115,29 @@ const NO_DRIFT_CAUSE = "NO_DRIFT";
|
|
|
3115
3115
|
const DRIFT_ACTUAL_CAUSES = [...DRIFT_FAILURE_CAUSES, NO_DRIFT_CAUSE];
|
|
3116
3116
|
const ACTUAL_CAUSES = [...FAILURE_CAUSES, NO_DRIFT_CAUSE];
|
|
3117
3117
|
const ActualCauseSchema = z.enum(ACTUAL_CAUSES);
|
|
3118
|
+
/**
|
|
3119
|
+
* Report rows come in kinds, and each answers fewer questions than the last.
|
|
3120
|
+
* A "record" row answers none: it says a recording happened and what it cost,
|
|
3121
|
+
* and nothing was judged, so both vocabularies below are empty for it.
|
|
3122
|
+
*
|
|
3123
|
+
* The one source for the vocabulary: the report envelope, the hub's
|
|
3124
|
+
* `Run.kind` and the `?kind=` query params all derive from this enum, so a
|
|
3125
|
+
* fourth kind cannot be half-added. What a kind that judges nothing may and
|
|
3126
|
+
* may not advance is ADR-0017.
|
|
3127
|
+
*/
|
|
3128
|
+
const ReportKindSchema = z.enum([
|
|
3129
|
+
"run",
|
|
3130
|
+
"drift",
|
|
3131
|
+
"record"
|
|
3132
|
+
]);
|
|
3118
3133
|
/** What a person may record on a row of this kind. */
|
|
3119
3134
|
function causesForKind(kind) {
|
|
3135
|
+
if (kind === "record") return [];
|
|
3120
3136
|
return kind === "drift" ? DRIFT_ACTUAL_CAUSES : FAILURE_CAUSES;
|
|
3121
3137
|
}
|
|
3122
3138
|
/** What the model may answer on a row of this kind. */
|
|
3123
3139
|
function predictedForKind(kind) {
|
|
3140
|
+
if (kind === "record") return [];
|
|
3124
3141
|
return kind === "drift" ? [...DRIFT_FAILURE_CAUSES, "UNKNOWN"] : PREDICTED_LABELS;
|
|
3125
3142
|
}
|
|
3126
3143
|
const SUB_DIAGNOSES = [...FIXABLE_DIAGNOSIS_TYPES, "NONE"];
|
|
@@ -3146,6 +3163,20 @@ const FailureEvidenceSchema = z.object({
|
|
|
3146
3163
|
*/
|
|
3147
3164
|
const DriftSurfaceSchema = z.enum(["spec", "generated"]);
|
|
3148
3165
|
/**
|
|
3166
|
+
* Which repair a `SPEC_CHANGE` needs: `FEATURE_REMOVED` means the behaviour
|
|
3167
|
+
* the spec checks is gone from the code, so the spec goes with it;
|
|
3168
|
+
* `BEHAVIOUR_CHANGED` means it still exists but works differently, so the spec
|
|
3169
|
+
* is rewritten and re-recorded.
|
|
3170
|
+
*
|
|
3171
|
+
* A fourth axis rather than a `subDiagnosis` value, because that vocabulary is
|
|
3172
|
+
* `[...FIXABLE_DIAGNOSIS_TYPES, "NONE"]` — the shapes a machine knows how to
|
|
3173
|
+
* repair — so a spec change always lands on `NONE` there.
|
|
3174
|
+
*
|
|
3175
|
+
* Deliberately without an "unknown" member: absence carries it, and every
|
|
3176
|
+
* reader must treat an absent value as a call to leave to a human.
|
|
3177
|
+
*/
|
|
3178
|
+
const SpecChangeKindSchema = z.enum(["FEATURE_REMOVED", "BEHAVIOUR_CHANGED"]);
|
|
3179
|
+
/**
|
|
3149
3180
|
* LLM output shape. Deliberately NOT .strict(): the model occasionally adds
|
|
3150
3181
|
* keys, and rejecting the whole analysis over an extra field would collapse
|
|
3151
3182
|
* a usable prediction into UNKNOWN. Zod's default strips unknown keys.
|
|
@@ -3162,7 +3193,8 @@ const FailureAnalysisSchema = z.object({
|
|
|
3162
3193
|
recommendation: z.string().default(""),
|
|
3163
3194
|
evidence: z.array(FailureEvidenceSchema),
|
|
3164
3195
|
reasoning: z.string(),
|
|
3165
|
-
surface: DriftSurfaceSchema.optional()
|
|
3196
|
+
surface: DriftSurfaceSchema.optional(),
|
|
3197
|
+
specChangeKind: SpecChangeKindSchema.optional()
|
|
3166
3198
|
});
|
|
3167
3199
|
/**
|
|
3168
3200
|
* What a drift audit may conclude, in the same vocabulary `ccqa run
|
|
@@ -3201,11 +3233,33 @@ const DriftDiagnosisSchema = z.object({
|
|
|
3201
3233
|
confidence: z.number().min(0).max(1),
|
|
3202
3234
|
surface: DriftSurfaceSchema.default("spec"),
|
|
3203
3235
|
subDiagnosis: DriftSubDiagnosisSchema.default("NONE"),
|
|
3236
|
+
specChangeKind: SpecChangeKindSchema.optional(),
|
|
3204
3237
|
headline: z.string(),
|
|
3205
3238
|
recommendation: z.string().default(""),
|
|
3206
3239
|
evidence: z.array(FailureEvidenceSchema),
|
|
3207
3240
|
reasoning: z.string().default("")
|
|
3208
3241
|
});
|
|
3242
|
+
/**
|
|
3243
|
+
* `specChangeKind` names which repair a `SPEC_CHANGE` needs, so under any other
|
|
3244
|
+
* label it is dropped. Dropped rather than rejected — a stray value must not
|
|
3245
|
+
* cost an otherwise usable verdict.
|
|
3246
|
+
*
|
|
3247
|
+
* Two producers apply it. `ccqa audit` normalizes at the parse boundary
|
|
3248
|
+
* (`src/drift/analyze.ts`), so every consumer of a verdict it produced — the
|
|
3249
|
+
* JSON output, the report rows, the hub push — is clean by construction. The
|
|
3250
|
+
* hub normalizes rows on the way into the drift ledger, which is what a
|
|
3251
|
+
* foreign client's push passes through.
|
|
3252
|
+
*
|
|
3253
|
+
* What does NOT hold: a pushed report's stored archive is kept verbatim and
|
|
3254
|
+
* served back unchanged, so a row a foreign client wrote can still carry a
|
|
3255
|
+
* stray field. That is why the UI re-checks the label before rendering the
|
|
3256
|
+
* chip rather than trusting the stored row.
|
|
3257
|
+
*/
|
|
3258
|
+
function normalizeDiagnosis(diagnosis) {
|
|
3259
|
+
if (diagnosis.label === "SPEC_CHANGE" || diagnosis.specChangeKind === void 0) return diagnosis;
|
|
3260
|
+
const { specChangeKind: _dropped, ...rest } = diagnosis;
|
|
3261
|
+
return rest;
|
|
3262
|
+
}
|
|
3209
3263
|
const ReportAssertionSchema = z.object({
|
|
3210
3264
|
name: z.string(),
|
|
3211
3265
|
status: z.enum([
|
|
@@ -3359,7 +3413,7 @@ const GitEnvelopeSchema = z.object({
|
|
|
3359
3413
|
});
|
|
3360
3414
|
const RunReportDataSchema = z.object({
|
|
3361
3415
|
schemaVersion: z.literal(1),
|
|
3362
|
-
kind:
|
|
3416
|
+
kind: ReportKindSchema.default("run"),
|
|
3363
3417
|
createdAt: z.string(),
|
|
3364
3418
|
runId: z.string().nullable(),
|
|
3365
3419
|
runUrl: z.string().nullable().optional(),
|
|
@@ -4726,6 +4780,42 @@ function appendCostRecord(command, cost) {
|
|
|
4726
4780
|
appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
|
|
4727
4781
|
} catch {}
|
|
4728
4782
|
}
|
|
4783
|
+
/**
|
|
4784
|
+
* Read back what `appendCostRecord` wrote. `null` when the file isn't there:
|
|
4785
|
+
* ccqa never ran, which is not the answer "ran and was billed nothing". An
|
|
4786
|
+
* unbilled line (`totalCostUsd: null`) adds nothing to the total but is still
|
|
4787
|
+
* an invocation — a killed job leaves a half-written last line, which is
|
|
4788
|
+
* neither.
|
|
4789
|
+
*/
|
|
4790
|
+
async function readCostFileTotal(path) {
|
|
4791
|
+
let raw;
|
|
4792
|
+
try {
|
|
4793
|
+
raw = await readFile(path, "utf8");
|
|
4794
|
+
} catch (err) {
|
|
4795
|
+
if (err instanceof Error && err.code === "ENOENT") return null;
|
|
4796
|
+
throw err;
|
|
4797
|
+
}
|
|
4798
|
+
let totalUsd = 0;
|
|
4799
|
+
let invocations = 0;
|
|
4800
|
+
let unreadable = 0;
|
|
4801
|
+
for (const line of raw.split("\n")) {
|
|
4802
|
+
if (line.trim().length === 0) continue;
|
|
4803
|
+
let record;
|
|
4804
|
+
try {
|
|
4805
|
+
record = JSON.parse(line);
|
|
4806
|
+
} catch {
|
|
4807
|
+
unreadable++;
|
|
4808
|
+
continue;
|
|
4809
|
+
}
|
|
4810
|
+
invocations++;
|
|
4811
|
+
if (typeof record.totalCostUsd === "number") totalUsd += record.totalCostUsd;
|
|
4812
|
+
}
|
|
4813
|
+
return {
|
|
4814
|
+
totalUsd,
|
|
4815
|
+
invocations,
|
|
4816
|
+
unreadable
|
|
4817
|
+
};
|
|
4818
|
+
}
|
|
4729
4819
|
//#endregion
|
|
4730
4820
|
//#region src/cli/draft.ts
|
|
4731
4821
|
const CATEGORY_LABEL = DRAFT_CATEGORY_LABEL;
|
|
@@ -6738,7 +6828,7 @@ z.object({
|
|
|
6738
6828
|
profile: z.string().nullable(),
|
|
6739
6829
|
branch: z.string().nullable(),
|
|
6740
6830
|
status: RunStatusSchema,
|
|
6741
|
-
kind:
|
|
6831
|
+
kind: ReportKindSchema.default("run"),
|
|
6742
6832
|
drift: z.object({
|
|
6743
6833
|
specs: z.number(),
|
|
6744
6834
|
testDrift: z.number(),
|
|
@@ -7108,6 +7198,7 @@ z.object({
|
|
|
7108
7198
|
const SpecDriftEntrySchema = z.object({
|
|
7109
7199
|
label: DriftLabelSchema.nullable(),
|
|
7110
7200
|
surface: DriftSurfaceSchema.optional(),
|
|
7201
|
+
specChangeKind: SpecChangeKindSchema.optional(),
|
|
7111
7202
|
confidence: z.number().optional(),
|
|
7112
7203
|
headline: z.string().optional(),
|
|
7113
7204
|
gitHead: z.string(),
|
|
@@ -7158,6 +7249,53 @@ const CreateLearningJobRequestSchema = z.object({
|
|
|
7158
7249
|
profile: z.string(),
|
|
7159
7250
|
runLimit: z.number().int().positive().max(1e3).optional()
|
|
7160
7251
|
});
|
|
7252
|
+
const MAX_ACK_KEYS = 5e3;
|
|
7253
|
+
const MAX_ACK_KEY_LENGTH = 256;
|
|
7254
|
+
/**
|
|
7255
|
+
* A named set of opaque keys a consumer has already acted on, as stored (see
|
|
7256
|
+
* `AckStore`). `at` is null only for a set that was never written — an ack
|
|
7257
|
+
* nobody has recorded yet reads as empty rather than missing.
|
|
7258
|
+
*/
|
|
7259
|
+
const AckSchema = z.object({
|
|
7260
|
+
keys: z.array(z.string()),
|
|
7261
|
+
at: z.string().nullable()
|
|
7262
|
+
});
|
|
7263
|
+
/** Body of `PUT /projects/:project/acks/:name?profile=` — the whole set, not a delta. */
|
|
7264
|
+
const PutAckRequestSchema = z.object({ keys: z.array(z.string().min(1).max(MAX_ACK_KEY_LENGTH)).max(MAX_ACK_KEYS) });
|
|
7265
|
+
AckSchema.extend({
|
|
7266
|
+
project: z.string(),
|
|
7267
|
+
profile: z.string(),
|
|
7268
|
+
name: z.string()
|
|
7269
|
+
});
|
|
7270
|
+
/**
|
|
7271
|
+
* What one batch of ccqa invocations spent on Claude, as the job that ran them
|
|
7272
|
+
* reported it (see `SpendStore`). `label` is the consumer's name for the batch
|
|
7273
|
+
* — its job name — and the only thing that says where the money went.
|
|
7274
|
+
*/
|
|
7275
|
+
const SpendEntrySchema = z.object({
|
|
7276
|
+
id: z.string(),
|
|
7277
|
+
at: z.string(),
|
|
7278
|
+
costUsd: z.number(),
|
|
7279
|
+
label: z.string(),
|
|
7280
|
+
ciRunId: z.string().optional(),
|
|
7281
|
+
runUrl: z.string().optional()
|
|
7282
|
+
});
|
|
7283
|
+
z.object({ entries: z.array(SpendEntrySchema).default([]) });
|
|
7284
|
+
/** Body of `POST /projects/:project/spend` — one batch's total. `at` defaults to now. */
|
|
7285
|
+
const RecordSpendRequestSchema = z.object({
|
|
7286
|
+
costUsd: z.number().nonnegative(),
|
|
7287
|
+
label: z.string().min(1).max(200),
|
|
7288
|
+
at: z.string().refine((v) => !Number.isNaN(Date.parse(v)), "at must be an ISO-8601 instant").optional(),
|
|
7289
|
+
ciRunId: z.string().optional(),
|
|
7290
|
+
runUrl: z.string().optional()
|
|
7291
|
+
});
|
|
7292
|
+
z.object({
|
|
7293
|
+
project: z.string(),
|
|
7294
|
+
since: z.string().nullable(),
|
|
7295
|
+
until: z.string().nullable(),
|
|
7296
|
+
totalUsd: z.number(),
|
|
7297
|
+
entries: z.array(SpendEntrySchema)
|
|
7298
|
+
});
|
|
7161
7299
|
//#endregion
|
|
7162
7300
|
//#region src/run/hub-selection.ts
|
|
7163
7301
|
/**
|
|
@@ -8172,6 +8310,19 @@ function githubRunUrl(env = process.env) {
|
|
|
8172
8310
|
function githubRunId(env = process.env) {
|
|
8173
8311
|
return env["GITHUB_RUN_ID"] ?? null;
|
|
8174
8312
|
}
|
|
8313
|
+
/**
|
|
8314
|
+
* The CI provenance every hub record carries, ready to spread into a request
|
|
8315
|
+
* body. Empty outside Actions, so a local invocation sends neither field
|
|
8316
|
+
* rather than a null one.
|
|
8317
|
+
*/
|
|
8318
|
+
function ciProvenance(env = process.env) {
|
|
8319
|
+
const ciRunId = githubRunId(env);
|
|
8320
|
+
const runUrl = githubRunUrl(env);
|
|
8321
|
+
return {
|
|
8322
|
+
...ciRunId ? { ciRunId } : {},
|
|
8323
|
+
...runUrl ? { runUrl } : {}
|
|
8324
|
+
};
|
|
8325
|
+
}
|
|
8175
8326
|
//#endregion
|
|
8176
8327
|
//#region src/cli/deploy-paths.ts
|
|
8177
8328
|
/**
|
|
@@ -8963,6 +9114,36 @@ function describeSelection(selection, diffAvailable) {
|
|
|
8963
9114
|
return `${values.filter((s) => s.verdict === "needed").length} needed / ${values.filter((s) => s.verdict === "unknown").length} unknown / ${values.length} specs`;
|
|
8964
9115
|
}
|
|
8965
9116
|
const deployCommand = new Command("deploy").description("Report deploys to the hub, the input behind `ccqa run --only-hub-rerun-needed`.").addCommand(deployRecord);
|
|
9117
|
+
const costPush = new Command("push").description("Record what this job spent on Claude: sum the cost file every ccqa invocation appended to ($CCQA_COST_FILE) and push the total to the hub as one spend entry. Run it last, once. A budget reads these totals INSTEAD OF summing the hub's runs: most commands that call Claude leave no run behind, and a batch already includes the ones that do, so adding both double-counts.").requiredOption("--label <name>", "What this batch was — typically the CI job's name. Required: an unlabelled entry tells a reader nothing about where the money went.").option("--from <path>", "Cost file to read. Defaults to $CCQA_COST_FILE.").option("--project <name>", "Project the spend is recorded against. Defaults to the current directory's name.").option(...cwdOption).option(...hubUrlOption).option(...hubTokenOption).action(withHubErrors(runCostPush));
|
|
9118
|
+
async function runCostPush(opts) {
|
|
9119
|
+
const path = opts.from ?? process.env.CCQA_COST_FILE;
|
|
9120
|
+
if (!path) {
|
|
9121
|
+
error("no cost file to read (--from <path> or CCQA_COST_FILE)");
|
|
9122
|
+
process.exit(2);
|
|
9123
|
+
}
|
|
9124
|
+
const project = resolveProject(opts);
|
|
9125
|
+
const hub = connect(opts);
|
|
9126
|
+
header("hub cost push", opts.label);
|
|
9127
|
+
meta("project", project);
|
|
9128
|
+
const total = await readCostFileTotal(path);
|
|
9129
|
+
if (total === null) {
|
|
9130
|
+
warn(`no cost file at ${path}; recorded nothing — ccqa never ran here, which is not a spend of zero`);
|
|
9131
|
+
return;
|
|
9132
|
+
}
|
|
9133
|
+
if (total.invocations === 0) {
|
|
9134
|
+
warn(`${path} holds no invocations; recorded nothing`);
|
|
9135
|
+
return;
|
|
9136
|
+
}
|
|
9137
|
+
await hub.recordSpend(project, {
|
|
9138
|
+
costUsd: total.totalUsd,
|
|
9139
|
+
label: opts.label,
|
|
9140
|
+
...ciProvenance()
|
|
9141
|
+
});
|
|
9142
|
+
meta("invocations", String(total.invocations));
|
|
9143
|
+
if (total.unreadable > 0) warn(`${total.unreadable} line(s) of ${path} could not be read, so the total below is a floor: what they cost is not in it, and the hub now holds the short number`);
|
|
9144
|
+
info(`recorded $${total.totalUsd.toFixed(4)} of spend on the hub`);
|
|
9145
|
+
}
|
|
9146
|
+
const costCommand = new Command("cost").description("Report what a CI job spent on Claude to the hub — the number a budget reads.").addCommand(costPush);
|
|
8966
9147
|
const pushCommand = new Command("push").description("Upload the report directory of a finished `ccqa run --report` to the hub as a run. Run this after `ccqa run` (use `if: always()` in CI so failing runs are pushed too).").option("--report-dir <dir>", `Report directory to push. Default: ${DEFAULT_REPORT_DIR}/`).option("--project <name>", "Logical project name for the run. Defaults to the current directory's name.").option("--branch <name>", "Branch label. Defaults to $GITHUB_HEAD_REF / $GITHUB_REF_NAME / current git branch.").option("--profile <name>", "Profile (environment) the run executed against. Recorded for display; runs are not scoped by profile.").option(...hubUrlOption).option(...hubTokenOption).option("--cwd <path>", "Directory the report dir is resolved against (defaults to the current directory).").action(withHubErrors(async (opts) => {
|
|
8967
9148
|
const cwd = resolveCwd(opts.cwd);
|
|
8968
9149
|
const reportDir = join(cwd, opts.reportDir ?? "ccqa-report");
|
|
@@ -8997,7 +9178,7 @@ const pushCommand = new Command("push").description("Upload the report directory
|
|
|
8997
9178
|
meta("specs", `${run.specs.passed}/${run.specs.total} passed`);
|
|
8998
9179
|
info(`${resolveBaseUrl(opts)}/#/runs/${run.id}`);
|
|
8999
9180
|
}));
|
|
9000
|
-
const hubCommand = new Command("hub").description("Client for a ccqa hub: push run results and manage sessions/variables/prompts used by `ccqa run`. See docs/hub.md.").addCommand(pushCommand).addCommand(deployCommand).addCommand(sessionCommand).addCommand(varCommand).addCommand(promptCommand);
|
|
9181
|
+
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);
|
|
9001
9182
|
/** Loose check that a fetched session is agent-browser storage-state, mirroring loadStorageState. */
|
|
9002
9183
|
function isStorageStateShape(state) {
|
|
9003
9184
|
return typeof state === "object" && state !== null && Array.isArray(state.cookies) && Array.isArray(state.origins);
|
|
@@ -10993,14 +11174,9 @@ async function generateAgentBrowserTest(ctx) {
|
|
|
10993
11174
|
blank();
|
|
10994
11175
|
const agentBrowserSession = fix.useSnapshot ? `ccqa-generate-${Date.now()}` : void 0;
|
|
10995
11176
|
const runVitestForSession = (path) => runVitest(path, agentBrowserSession);
|
|
10996
|
-
let signalHandler = null;
|
|
10997
11177
|
if (agentBrowserSession) {
|
|
10998
11178
|
await closeSession(agentBrowserSession);
|
|
10999
|
-
|
|
11000
|
-
closeSession(agentBrowserSession).finally(() => process.exit(130));
|
|
11001
|
-
};
|
|
11002
|
-
process.once("SIGINT", signalHandler);
|
|
11003
|
-
process.once("SIGTERM", signalHandler);
|
|
11179
|
+
ctx.teardown?.trackSession(agentBrowserSession);
|
|
11004
11180
|
}
|
|
11005
11181
|
try {
|
|
11006
11182
|
const initialRun = await timedPhase("vitest run #1", () => runVitestForSession(scriptPath), "run");
|
|
@@ -11027,11 +11203,10 @@ async function generateAgentBrowserTest(ctx) {
|
|
|
11027
11203
|
passed
|
|
11028
11204
|
};
|
|
11029
11205
|
} finally {
|
|
11030
|
-
if (
|
|
11031
|
-
|
|
11032
|
-
|
|
11206
|
+
if (agentBrowserSession) {
|
|
11207
|
+
ctx.teardown?.untrackSession(agentBrowserSession);
|
|
11208
|
+
await closeSession(agentBrowserSession);
|
|
11033
11209
|
}
|
|
11034
|
-
if (agentBrowserSession) await closeSession(agentBrowserSession);
|
|
11035
11210
|
}
|
|
11036
11211
|
}
|
|
11037
11212
|
/**
|
|
@@ -11836,6 +12011,72 @@ function createIncrementalReport(reportDir, envelope, sink, costNow) {
|
|
|
11836
12011
|
};
|
|
11837
12012
|
}
|
|
11838
12013
|
//#endregion
|
|
12014
|
+
//#region src/cli/open-hub-run.ts
|
|
12015
|
+
/** The one wording for "this flag needs a hub, and none is configured". */
|
|
12016
|
+
function needsHubConnection(flag) {
|
|
12017
|
+
return `${flag} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`;
|
|
12018
|
+
}
|
|
12019
|
+
const REPORT_TO_HUB_NEEDS_CONNECTION = needsHubConnection("--report-to-hub");
|
|
12020
|
+
/**
|
|
12021
|
+
* The connection a `--report-to-hub` command publishes through. Both CLI
|
|
12022
|
+
* callers check this before the expensive part — the audit's sweep, the
|
|
12023
|
+
* recording's spec lock and browser — so a job that cannot publish spends
|
|
12024
|
+
* nothing finding out.
|
|
12025
|
+
*/
|
|
12026
|
+
function requireReportToHubConnection(conn) {
|
|
12027
|
+
if (conn) return conn;
|
|
12028
|
+
error(REPORT_TO_HUB_NEEDS_CONNECTION);
|
|
12029
|
+
process.exit(2);
|
|
12030
|
+
}
|
|
12031
|
+
/**
|
|
12032
|
+
* Open the run a `--report-to-hub` command patches into. Failure is fatal: a
|
|
12033
|
+
* job that asked to publish and cannot reach the hub has not done what it was
|
|
12034
|
+
* told. Thrown rather than exited, so a caller's `finally` still runs (the
|
|
12035
|
+
* audit releases its spec claims there). Not retried: a dropped response after
|
|
12036
|
+
* the hub committed would leave a second orphan running run.
|
|
12037
|
+
*/
|
|
12038
|
+
async function openHubRun(kind, conn, cwd, profile) {
|
|
12039
|
+
const [branch, gitHead] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
|
|
12040
|
+
try {
|
|
12041
|
+
const run = await conn.hub.openRun({
|
|
12042
|
+
project: conn.project,
|
|
12043
|
+
kind,
|
|
12044
|
+
...branch ? { branch } : {},
|
|
12045
|
+
...profile ? { profile } : {},
|
|
12046
|
+
...gitHead ? { gitHead } : {},
|
|
12047
|
+
...ciProvenance()
|
|
12048
|
+
});
|
|
12049
|
+
return {
|
|
12050
|
+
hub: conn.hub,
|
|
12051
|
+
kind,
|
|
12052
|
+
runId: run.id,
|
|
12053
|
+
gitHead
|
|
12054
|
+
};
|
|
12055
|
+
} catch (err) {
|
|
12056
|
+
throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
|
|
12057
|
+
}
|
|
12058
|
+
}
|
|
12059
|
+
/**
|
|
12060
|
+
* Close an open run with its final rows and envelope, answering whether it
|
|
12061
|
+
* closed. A failed seal leaves the run `running` with whatever rows landed — a
|
|
12062
|
+
* wrong record, not merely a missing one — so a CLI caller must not exit clean.
|
|
12063
|
+
* The exit is the caller's to make rather than taken here: `ccqa record` seals
|
|
12064
|
+
* from inside a teardown finalizer, and exiting there would skip the
|
|
12065
|
+
* browser-session reap queued behind it.
|
|
12066
|
+
*/
|
|
12067
|
+
async function sealHubRun(push, body) {
|
|
12068
|
+
try {
|
|
12069
|
+
await push.hub.patchRun(push.runId, {
|
|
12070
|
+
...body,
|
|
12071
|
+
done: true
|
|
12072
|
+
});
|
|
12073
|
+
return true;
|
|
12074
|
+
} catch (err) {
|
|
12075
|
+
error(`hub: could not close the ${push.kind} run ${push.runId}: ${errMessage(err)}`);
|
|
12076
|
+
return false;
|
|
12077
|
+
}
|
|
12078
|
+
}
|
|
12079
|
+
//#endregion
|
|
11839
12080
|
//#region src/prompts/agent-update.ts
|
|
11840
12081
|
/**
|
|
11841
12082
|
* Build the prompts used by the `--learn-*-prompt` flags to refresh
|
|
@@ -12373,9 +12614,9 @@ async function executeRun(targets, opts) {
|
|
|
12373
12614
|
});
|
|
12374
12615
|
if (wantsLastGreen && hubCtx == null) throw new RunUsageError("--on-fail-explain needs a hub connection for the per-spec last-green baselines (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN), or an explicit --on-fail-explain-base <ref>");
|
|
12375
12616
|
const ledgerHub = wantsLastGreen ? hubCtx : null;
|
|
12376
|
-
if (rerunProfile !== null && hubCtx == null) throw new RunUsageError("--only-hub-rerun-needed
|
|
12377
|
-
if (opts.reportToHub && hubCtx == null) throw new RunUsageError(
|
|
12378
|
-
if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError("--learn-hub-live-prompt
|
|
12617
|
+
if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
|
|
12618
|
+
if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
|
|
12619
|
+
if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError(needsHubConnection("--learn-hub-live-prompt"));
|
|
12379
12620
|
const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead] = await Promise.all([
|
|
12380
12621
|
forExecution ? fetchCustomPrompt(hubCtx) : null,
|
|
12381
12622
|
forExecution ? fetchTriageUserPrompt(hubCtx) : null,
|
|
@@ -12513,16 +12754,13 @@ async function executeRun(targets, opts) {
|
|
|
12513
12754
|
let hubPublishBroken = false;
|
|
12514
12755
|
if (hubCtx != null && opts.reportToHub) try {
|
|
12515
12756
|
const branch = await detectBranch(cwd);
|
|
12516
|
-
const ciRunId = githubRunId();
|
|
12517
|
-
const runUrl = githubRunUrl();
|
|
12518
12757
|
const opened = await hubCtx.hub.openRun({
|
|
12519
12758
|
project: hubCtx.project,
|
|
12520
12759
|
...branch ? { branch } : {},
|
|
12521
12760
|
...opts.hubProfile ? { profile: opts.hubProfile } : {},
|
|
12522
12761
|
...git.head ? { gitHead: git.head } : {},
|
|
12523
12762
|
...deployedSha ? { deployedSha } : {},
|
|
12524
|
-
...
|
|
12525
|
-
...runUrl ? { runUrl } : {},
|
|
12763
|
+
...ciProvenance(),
|
|
12526
12764
|
kind: "run"
|
|
12527
12765
|
});
|
|
12528
12766
|
hubRunId = opened.id;
|
|
@@ -13137,7 +13375,16 @@ async function streamFiltered(source, sink, capture) {
|
|
|
13137
13375
|
function createRunTeardown() {
|
|
13138
13376
|
const sessions = /* @__PURE__ */ new Set();
|
|
13139
13377
|
const finalizers = [];
|
|
13140
|
-
let
|
|
13378
|
+
let running = null;
|
|
13379
|
+
const tearDown = async () => {
|
|
13380
|
+
for (const fn of finalizers) try {
|
|
13381
|
+
await fn();
|
|
13382
|
+
} catch (err) {
|
|
13383
|
+
warn(`teardown finalizer failed (partial report may be incomplete): ${err instanceof Error ? err.message : String(err)}`);
|
|
13384
|
+
}
|
|
13385
|
+
await Promise.all([...sessions].map((name) => closeSession(name)));
|
|
13386
|
+
sessions.clear();
|
|
13387
|
+
};
|
|
13141
13388
|
return {
|
|
13142
13389
|
trackSession(name) {
|
|
13143
13390
|
sessions.add(name);
|
|
@@ -13148,24 +13395,18 @@ function createRunTeardown() {
|
|
|
13148
13395
|
onFinalize(fn) {
|
|
13149
13396
|
finalizers.push(fn);
|
|
13150
13397
|
},
|
|
13151
|
-
|
|
13152
|
-
|
|
13153
|
-
|
|
13154
|
-
for (const fn of finalizers) try {
|
|
13155
|
-
await fn();
|
|
13156
|
-
} catch (err) {
|
|
13157
|
-
warn(`teardown finalizer failed (partial report may be incomplete): ${err instanceof Error ? err.message : String(err)}`);
|
|
13158
|
-
}
|
|
13159
|
-
await Promise.all([...sessions].map((name) => closeSession(name)));
|
|
13160
|
-
sessions.clear();
|
|
13398
|
+
run() {
|
|
13399
|
+
running ??= tearDown();
|
|
13400
|
+
return running;
|
|
13161
13401
|
}
|
|
13162
13402
|
};
|
|
13163
13403
|
}
|
|
13164
13404
|
/**
|
|
13165
13405
|
* Install SIGINT/SIGTERM handlers that run {@link RunTeardown.run} then exit
|
|
13166
13406
|
* with the conventional signal code, and return a disposer that removes them.
|
|
13167
|
-
*
|
|
13168
|
-
*
|
|
13407
|
+
* A command must install at most one: a second handler that also exits would
|
|
13408
|
+
* race this one and could terminate mid-finalizer. A second signal while
|
|
13409
|
+
* tearing down hard-exits immediately rather than waiting.
|
|
13169
13410
|
*/
|
|
13170
13411
|
function installTeardownSignalHandlers(teardown) {
|
|
13171
13412
|
let handling = false;
|
|
@@ -14691,8 +14932,11 @@ function parseAutoFixFlag(raw) {
|
|
|
14691
14932
|
* The `generate` flow shared by `ccqa generate` and the codegen half of
|
|
14692
14933
|
* `ccqa record`: resolve the spec's target plugin, load its input (the
|
|
14693
14934
|
* recording, for input:"recording" targets), and dispatch to the plugin.
|
|
14694
|
-
* This layer owns the CLI concerns — overwrite confirmation, logging
|
|
14695
|
-
*
|
|
14935
|
+
* This layer owns the CLI concerns — overwrite confirmation, logging — while
|
|
14936
|
+
* the plugin owns the generation pipeline. A generation whose output still
|
|
14937
|
+
* fails is reported as `{ passed: false }` rather than thrown or exited: both
|
|
14938
|
+
* callers have work left that `process.exit` would skip — `ccqa record
|
|
14939
|
+
* --report-to-hub` still has to seal the run holding what the retries cost.
|
|
14696
14940
|
*/
|
|
14697
14941
|
async function runGenerate(featureName, specName, opts) {
|
|
14698
14942
|
header("generate", `${featureName}/${specName}`);
|
|
@@ -14700,7 +14944,7 @@ async function runGenerate(featureName, specName, opts) {
|
|
|
14700
14944
|
await ensureCcqaDir(cwd);
|
|
14701
14945
|
const releaseLock = await acquireSpecLock(featureName, specName, "generate", cwd);
|
|
14702
14946
|
try {
|
|
14703
|
-
await runGenerateLocked(featureName, specName, opts, cwd);
|
|
14947
|
+
return await runGenerateLocked(featureName, specName, opts, cwd);
|
|
14704
14948
|
} finally {
|
|
14705
14949
|
await releaseLock();
|
|
14706
14950
|
}
|
|
@@ -14731,7 +14975,7 @@ async function runGenerateLocked(featureName, specName, opts, cwd) {
|
|
|
14731
14975
|
if (existingOutput && !opts.force) {
|
|
14732
14976
|
if (!await confirmOverwrite(existingOutput)) {
|
|
14733
14977
|
info("aborted; pass --overwrite to replace it without prompting");
|
|
14734
|
-
return;
|
|
14978
|
+
return { passed: true };
|
|
14735
14979
|
}
|
|
14736
14980
|
}
|
|
14737
14981
|
let recording;
|
|
@@ -14760,15 +15004,14 @@ async function runGenerateLocked(featureName, specName, opts, cwd) {
|
|
|
14760
15004
|
maxRetries: opts.maxRetries,
|
|
14761
15005
|
mode: opts.fixMode,
|
|
14762
15006
|
useSnapshot: opts.useSnapshot
|
|
14763
|
-
}
|
|
15007
|
+
},
|
|
15008
|
+
teardown: opts.teardown
|
|
14764
15009
|
};
|
|
14765
15010
|
const result = await target.generate(ctx);
|
|
14766
15011
|
if (opts.updateAgentPrompt) await runGenerateAgentPromptUpdate(target, featureName, specName, result, opts, cwd);
|
|
14767
|
-
if (!result.passed)
|
|
14768
|
-
|
|
14769
|
-
|
|
14770
|
-
}
|
|
14771
|
-
hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
|
|
15012
|
+
if (!result.passed) warn("auto-fix exhausted; test still failing");
|
|
15013
|
+
else hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
|
|
15014
|
+
return { passed: result.passed };
|
|
14772
15015
|
}
|
|
14773
15016
|
/**
|
|
14774
15017
|
* `ccqa generate --learn-hub-codegen-prompt`: refresh the target's learned
|
|
@@ -14838,15 +15081,18 @@ async function runGenerateCli(specPath, opts) {
|
|
|
14838
15081
|
cwd
|
|
14839
15082
|
});
|
|
14840
15083
|
if (opts.learnHubCodegenPrompt && hubClient === null) {
|
|
14841
|
-
error("--learn-hub-codegen-prompt
|
|
15084
|
+
error(needsHubConnection("--learn-hub-codegen-prompt"));
|
|
14842
15085
|
process.exit(2);
|
|
14843
15086
|
}
|
|
14844
15087
|
const hubContext = hubClient && project ? {
|
|
14845
15088
|
hub: hubClient,
|
|
14846
15089
|
project
|
|
14847
15090
|
} : null;
|
|
15091
|
+
const teardown = createRunTeardown();
|
|
15092
|
+
const disposeSignalHandlers = installTeardownSignalHandlers(teardown);
|
|
15093
|
+
let passed;
|
|
14848
15094
|
try {
|
|
14849
|
-
await runGenerate(featureName, specName, {
|
|
15095
|
+
({passed} = await runGenerate(featureName, specName, {
|
|
14850
15096
|
maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
|
|
14851
15097
|
fixMode: toFixMode(opts.autoFix ?? "interactive"),
|
|
14852
15098
|
force: opts.overwrite ?? false,
|
|
@@ -14856,15 +15102,20 @@ async function runGenerateCli(specPath, opts) {
|
|
|
14856
15102
|
targetOverride: opts.target,
|
|
14857
15103
|
cwd,
|
|
14858
15104
|
hubContext,
|
|
14859
|
-
updateAgentPrompt: opts.learnHubCodegenPrompt ?? false
|
|
14860
|
-
|
|
15105
|
+
updateAgentPrompt: opts.learnHubCodegenPrompt ?? false,
|
|
15106
|
+
teardown
|
|
15107
|
+
}));
|
|
14861
15108
|
} catch (e) {
|
|
14862
15109
|
if (e instanceof SpecLockedError) {
|
|
14863
15110
|
error(e.message);
|
|
14864
15111
|
process.exit(2);
|
|
14865
15112
|
}
|
|
14866
15113
|
throw e;
|
|
15114
|
+
} finally {
|
|
15115
|
+
await teardown.run();
|
|
15116
|
+
disposeSignalHandlers();
|
|
14867
15117
|
}
|
|
15118
|
+
if (!passed) process.exit(1);
|
|
14868
15119
|
}
|
|
14869
15120
|
//#endregion
|
|
14870
15121
|
//#region src/cli/record.ts
|
|
@@ -14872,7 +15123,7 @@ const VALIDATION_MODES = ["lenient", "strict"];
|
|
|
14872
15123
|
const recordCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("record").argument("<feature/spec>", "Spec id in '<feature>/<spec>' form (resolves to .ccqa/features/<feature>/test-cases/<spec>/)").description("Record a test from a spec: run agent-browser to collect actions (trace), then compile them into runnable code via the spec's target (generate) — a vitest test.spec.ts for agent-browser, a @playwright/test spec for the playwright target. Recording-backed targets only; spec-input targets like runn have no trace step (use `ccqa generate`), and agent-browser live specs need no recording.").optionsGroup("How to record:").option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--trace-validation <mode>", "What to do with actions that fail post-trace validation: 'lenient' (default) tags them; 'strict' drops them.", (raw) => {
|
|
14873
15124
|
if (VALIDATION_MODES.includes(raw)) return raw;
|
|
14874
15125
|
throw new Error(`--trace-validation must be one of ${VALIDATION_MODES.join(" | ")}`);
|
|
14875
|
-
}, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--auto-fix-max-retries <n>", "Maximum number of auto-fix retries", "3").option("--trace-only", "Stop after the trace step; do not generate test code").option("--no-session-pin", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").optionsGroup("What to do with the result:").option("--overwrite", "Replace an existing test.spec.ts without warning").optionsGroup("Learning:").option("--learn-hub-trace-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
|
|
15126
|
+
}, "lenient").option("--auto-fix <mode>", "Auto-fix behaviour during script generation: 'interactive' (default, prompt y/N; declines on non-TTY), 'auto' (apply without prompt, for CI), 'skip' (agent-browser: apply only high-confidence fixes; external targets like playwright/runn: no fix pass at all).", parseAutoFixFlag, "interactive").option("--auto-fix-max-retries <n>", "Maximum number of auto-fix retries", "3").option("--trace-only", "Stop after the trace step; do not generate test code").option("--no-session-pin", "Don't pin AGENT_BROWSER_SESSION / capture page snapshots after a failure (debug toggle)").optionsGroup("What to do with the result:").option("--overwrite", "Replace an existing test.spec.ts without warning").option("--report-to-hub", "Leave a run (kind: record) on the hub saying this spec was recorded and what the recording spent on Claude, so a budget summed over the hub's runs sees it. It advances no ledger: a recording verifies nothing.").optionsGroup("Learning:").option("--learn-hub-trace-prompt", "After the trace finishes, ask Claude to refresh the \"record.agent\" prompt on the hub from a summary of the run. Requires a hub connection.").optionsGroup("Environment and connection:").option("--cwd <path>", "Working directory containing the .ccqa/ tree (monorepo support). Defaults to the current directory.").option("--project <name>", "Project name for the hub. Defaults to the current directory's name.")))).action(withUsageErrors(async (specPath, opts) => {
|
|
14876
15127
|
await withCostReporting("record", () => runRecord(specPath, opts));
|
|
14877
15128
|
}));
|
|
14878
15129
|
async function runRecord(specPath, opts) {
|
|
@@ -14911,9 +15162,10 @@ async function runRecord(specPath, opts) {
|
|
|
14911
15162
|
project: hubProject
|
|
14912
15163
|
} : null;
|
|
14913
15164
|
if (opts.learnHubTracePrompt && hubContext === null) {
|
|
14914
|
-
error("--learn-hub-trace-prompt
|
|
15165
|
+
error(needsHubConnection("--learn-hub-trace-prompt"));
|
|
14915
15166
|
process.exit(2);
|
|
14916
15167
|
}
|
|
15168
|
+
const pushConn = opts.reportToHub ? requireReportToHubConnection(hubContext) : null;
|
|
14917
15169
|
const releaseLock = await acquireSpecLock(featureName, specName, "record", cwdForProfile).catch((e) => {
|
|
14918
15170
|
if (e instanceof SpecLockedError) {
|
|
14919
15171
|
error(e.message);
|
|
@@ -14921,37 +15173,78 @@ async function runRecord(specPath, opts) {
|
|
|
14921
15173
|
}
|
|
14922
15174
|
throw e;
|
|
14923
15175
|
});
|
|
14924
|
-
|
|
15176
|
+
const push = pushConn ? await openHubRun("record", pushConn, cwdForProfile, opts.hubProfile) : null;
|
|
15177
|
+
if (push) info(`hub: record run opened (${push.runId})`);
|
|
15178
|
+
let recorded = false;
|
|
15179
|
+
let sealed = true;
|
|
15180
|
+
const teardown = createRunTeardown();
|
|
15181
|
+
teardown.onFinalize(async () => {
|
|
15182
|
+
if (push) sealed = await sealRecordPush(push, featureName, specName, recorded);
|
|
15183
|
+
});
|
|
15184
|
+
const disposeSignalHandlers = installTeardownSignalHandlers(teardown);
|
|
14925
15185
|
try {
|
|
14926
|
-
traceResult =
|
|
14927
|
-
|
|
14928
|
-
|
|
14929
|
-
|
|
14930
|
-
|
|
14931
|
-
|
|
14932
|
-
|
|
14933
|
-
|
|
14934
|
-
|
|
14935
|
-
|
|
14936
|
-
|
|
14937
|
-
|
|
14938
|
-
|
|
14939
|
-
|
|
14940
|
-
|
|
15186
|
+
let traceResult = null;
|
|
15187
|
+
let generated = true;
|
|
15188
|
+
try {
|
|
15189
|
+
traceResult = await runTrace(featureName, specName, opts.model, opts.traceValidation ?? "lenient", language, {
|
|
15190
|
+
cwd: cwdForProfile,
|
|
15191
|
+
hubContext
|
|
15192
|
+
});
|
|
15193
|
+
blank();
|
|
15194
|
+
if (!opts.traceOnly) generated = (await runGenerate(featureName, specName, {
|
|
15195
|
+
maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
|
|
15196
|
+
fixMode: toFixMode(opts.autoFix ?? "interactive"),
|
|
15197
|
+
force: opts.overwrite ?? false,
|
|
15198
|
+
useSnapshot: opts.sessionPin !== false,
|
|
15199
|
+
language,
|
|
15200
|
+
model: opts.model,
|
|
15201
|
+
cwd: cwdForProfile,
|
|
15202
|
+
hubContext,
|
|
15203
|
+
teardown
|
|
15204
|
+
})).passed;
|
|
15205
|
+
} finally {
|
|
15206
|
+
await releaseLock();
|
|
15207
|
+
}
|
|
15208
|
+
if (opts.learnHubTracePrompt && traceResult !== null) {
|
|
15209
|
+
blank();
|
|
15210
|
+
await updateAgentPrompt({
|
|
15211
|
+
kind: "record",
|
|
15212
|
+
flag: "--learn-hub-trace-prompt",
|
|
15213
|
+
runSummary: buildRecordRunSummary(featureName, specName, traceResult),
|
|
15214
|
+
hubContext,
|
|
15215
|
+
...opts.model ? { model: opts.model } : {},
|
|
15216
|
+
...language ? { language } : {}
|
|
15217
|
+
});
|
|
15218
|
+
}
|
|
15219
|
+
recorded = generated;
|
|
14941
15220
|
} finally {
|
|
14942
|
-
await
|
|
14943
|
-
|
|
14944
|
-
if (opts.learnHubTracePrompt && traceResult !== null) {
|
|
14945
|
-
blank();
|
|
14946
|
-
await updateAgentPrompt({
|
|
14947
|
-
kind: "record",
|
|
14948
|
-
flag: "--learn-hub-trace-prompt",
|
|
14949
|
-
runSummary: buildRecordRunSummary(featureName, specName, traceResult),
|
|
14950
|
-
hubContext,
|
|
14951
|
-
...opts.model ? { model: opts.model } : {},
|
|
14952
|
-
...language ? { language } : {}
|
|
14953
|
-
});
|
|
15221
|
+
await teardown.run();
|
|
15222
|
+
disposeSignalHandlers();
|
|
14954
15223
|
}
|
|
15224
|
+
if (!sealed) process.exit(2);
|
|
15225
|
+
if (!recorded) process.exit(1);
|
|
15226
|
+
}
|
|
15227
|
+
/**
|
|
15228
|
+
* Close the record run with the one row this command produced, answering
|
|
15229
|
+
* whether it closed. One spec is recorded per invocation, so one row is the
|
|
15230
|
+
* whole run — enough for the runs list to say what the money bought.
|
|
15231
|
+
*/
|
|
15232
|
+
async function sealRecordPush(push, featureName, specName, recorded) {
|
|
15233
|
+
return sealHubRun(push, {
|
|
15234
|
+
rows: [emptySpecRow({
|
|
15235
|
+
feature: featureName,
|
|
15236
|
+
spec: specName,
|
|
15237
|
+
title: null,
|
|
15238
|
+
status: recorded ? "passed" : "failed"
|
|
15239
|
+
})],
|
|
15240
|
+
reportMeta: {
|
|
15241
|
+
git: {
|
|
15242
|
+
head: push.gitHead,
|
|
15243
|
+
base: null
|
|
15244
|
+
},
|
|
15245
|
+
cost: currentReportCost()
|
|
15246
|
+
}
|
|
15247
|
+
});
|
|
14955
15248
|
}
|
|
14956
15249
|
/**
|
|
14957
15250
|
* Compact summary of the trace pass for the record agent-prompt refresh.
|
|
@@ -15175,6 +15468,7 @@ Drift found:
|
|
|
15175
15468
|
"confidence": 0.0,
|
|
15176
15469
|
"surface": "spec" | "generated",
|
|
15177
15470
|
"subDiagnosis": "SELECTOR_DRIFT" | "OVER_ASSERTION" | "NONE",
|
|
15471
|
+
"specChangeKind": "FEATURE_REMOVED" | "BEHAVIOUR_CHANGED",
|
|
15178
15472
|
"headline": "<one line: what is out of sync>",
|
|
15179
15473
|
"recommendation": "<what to change to bring them back in sync>",
|
|
15180
15474
|
"reasoning": "<how you reached this label: what you looked for, what you found, why it is this label and not the other>",
|
|
@@ -15186,6 +15480,13 @@ Drift found:
|
|
|
15186
15480
|
\`\`\`
|
|
15187
15481
|
|
|
15188
15482
|
\`subDiagnosis\`: \`SELECTOR_DRIFT\` when a selector or string was renamed, \`OVER_ASSERTION\` when the spec asserts something narrower than the product ever promised, \`NONE\` otherwise.
|
|
15483
|
+
|
|
15484
|
+
\`specChangeKind\`: set it only when the label is \`SPEC_CHANGE\`, and omit the field entirely otherwise. It says which repair the spec needs — deleting it, or rewriting and re-recording it:
|
|
15485
|
+
|
|
15486
|
+
- \`FEATURE_REMOVED\` — the code no longer implements the behaviour at all: removed, moved elsewhere, or deliberately disabled. This is the stronger claim, so earn it: your evidence must point at where the implementation would be if it still existed.
|
|
15487
|
+
- \`BEHAVIOUR_CHANGED\` — the behaviour is still there, but its wording, its route, or the conditions it runs under moved.
|
|
15488
|
+
|
|
15489
|
+
When the evidence does not support "gone", answer \`BEHAVIOUR_CHANGED\`. When neither reading is supported, omit the field — there is no value for "I cannot tell", and a human decides what you leave unsaid.
|
|
15189
15490
|
`;
|
|
15190
15491
|
}
|
|
15191
15492
|
function buildDriftUserPrompt(artifacts) {
|
|
@@ -15366,10 +15667,11 @@ async function checkSpec(target, opts) {
|
|
|
15366
15667
|
continue;
|
|
15367
15668
|
}
|
|
15368
15669
|
try {
|
|
15670
|
+
const reply = DriftReplySchema.parse(JSON.parse(json));
|
|
15369
15671
|
return {
|
|
15370
15672
|
target,
|
|
15371
15673
|
ok: true,
|
|
15372
|
-
drift:
|
|
15674
|
+
drift: reply.drift ? normalizeDiagnosis(reply.drift) : null,
|
|
15373
15675
|
live: artifacts.live,
|
|
15374
15676
|
title: artifacts.title
|
|
15375
15677
|
};
|
|
@@ -15503,7 +15805,7 @@ function determineExitCode(results, threshold) {
|
|
|
15503
15805
|
//#endregion
|
|
15504
15806
|
//#region src/drift/to-report.ts
|
|
15505
15807
|
/** Tracks the drift prompt's own version — the two must never drift apart. */
|
|
15506
|
-
const DRIFT_REPORT_PROMPT_VERSION = "
|
|
15808
|
+
const DRIFT_REPORT_PROMPT_VERSION = "6";
|
|
15507
15809
|
/**
|
|
15508
15810
|
* Spec-level status under the given threshold, mirroring determineExitCode's
|
|
15509
15811
|
* per-spec logic (exit-code.ts) but scoped to a single SpecResult.
|
|
@@ -15536,7 +15838,7 @@ function driftResultToRow(result, threshold) {
|
|
|
15536
15838
|
status: specStatus(result, threshold)
|
|
15537
15839
|
}),
|
|
15538
15840
|
...result.live === void 0 ? {} : { mode: result.live ? "live" : "deterministic" },
|
|
15539
|
-
analysis: result.drift
|
|
15841
|
+
analysis: result.drift
|
|
15540
15842
|
};
|
|
15541
15843
|
}
|
|
15542
15844
|
/**
|
|
@@ -15822,14 +16124,24 @@ async function runAudit(specPath, opts) {
|
|
|
15822
16124
|
if (targets.length === 0) exitWithNoSpecs(format, "noDiffIntersection", "no specs intersect the changed file set; nothing to check");
|
|
15823
16125
|
}
|
|
15824
16126
|
const blocks = await loadAvailableBlocks(cwd);
|
|
15825
|
-
|
|
16127
|
+
let pushConn = null;
|
|
16128
|
+
if (opts.reportToHub) {
|
|
16129
|
+
const pushHub = resolveHubClient(opts);
|
|
16130
|
+
pushConn = requireReportToHubConnection(pushHub && {
|
|
16131
|
+
hub: pushHub,
|
|
16132
|
+
project: resolveProject({
|
|
16133
|
+
project: opts.project,
|
|
16134
|
+
cwd
|
|
16135
|
+
})
|
|
16136
|
+
});
|
|
16137
|
+
}
|
|
15826
16138
|
let results;
|
|
15827
16139
|
let promptCtx;
|
|
15828
16140
|
let push = null;
|
|
15829
16141
|
try {
|
|
15830
16142
|
promptCtx = await resolveAuditPromptContext(opts, cwd);
|
|
15831
|
-
if (
|
|
15832
|
-
push = await
|
|
16143
|
+
if (pushConn) {
|
|
16144
|
+
push = await openHubRun("drift", pushConn, cwd, opts.hubProfile);
|
|
15833
16145
|
if (format === "text") info(`hub: incremental drift run opened (${push.runId})`);
|
|
15834
16146
|
}
|
|
15835
16147
|
results = await analyzeDrift({
|
|
@@ -15890,56 +16202,6 @@ async function releaseSpecs(hub, project, profile, holder) {
|
|
|
15890
16202
|
}
|
|
15891
16203
|
}
|
|
15892
16204
|
/**
|
|
15893
|
-
* Fail fast when `--report-to-hub` was requested but no hub connection is
|
|
15894
|
-
* available. Called at the top of `runAudit`, before the sweep spends any
|
|
15895
|
-
* model calls — `pushDriftResults` (below) keeps its own equivalent, injectable
|
|
15896
|
-
* check so it stays safe to call standalone (e.g. in tests) without this guard
|
|
15897
|
-
* having already run.
|
|
15898
|
-
*/
|
|
15899
|
-
function requireReportToHubConnection(opts) {
|
|
15900
|
-
if (!opts.reportToHub || resolveHubClient(opts)) return;
|
|
15901
|
-
error("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
|
|
15902
|
-
process.exit(2);
|
|
15903
|
-
}
|
|
15904
|
-
/**
|
|
15905
|
-
* Open the drift run this sweep patches into. A failure here is fatal, as it
|
|
15906
|
-
* is for `ccqa run`: a job that asked to publish and cannot reach the hub has
|
|
15907
|
-
* not done what it was told, and the audit has no local artifact to fall back
|
|
15908
|
-
* on — the hub is its only output. Raised before any spec is checked, so
|
|
15909
|
-
* nothing is wasted. Not retried: a dropped response after the hub committed
|
|
15910
|
-
* would leave a second orphan running run.
|
|
15911
|
-
*
|
|
15912
|
-
* `resolveHub` is injectable for tests.
|
|
15913
|
-
*/
|
|
15914
|
-
async function openDriftPush(opts, cwd, resolveHub = resolveHubClient) {
|
|
15915
|
-
const hub = resolveHub(opts);
|
|
15916
|
-
if (!hub) throw new RunUsageError("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
|
|
15917
|
-
const project = resolveProject({
|
|
15918
|
-
project: opts.project,
|
|
15919
|
-
cwd
|
|
15920
|
-
});
|
|
15921
|
-
const [branch, gitHead] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
|
|
15922
|
-
const ciRunId = githubRunId();
|
|
15923
|
-
const runUrl = githubRunUrl();
|
|
15924
|
-
try {
|
|
15925
|
-
return {
|
|
15926
|
-
hub,
|
|
15927
|
-
runId: (await hub.openRun({
|
|
15928
|
-
project,
|
|
15929
|
-
kind: "drift",
|
|
15930
|
-
...branch ? { branch } : {},
|
|
15931
|
-
...opts.hubProfile ? { profile: opts.hubProfile } : {},
|
|
15932
|
-
...gitHead ? { gitHead } : {},
|
|
15933
|
-
...ciRunId ? { ciRunId } : {},
|
|
15934
|
-
...runUrl ? { runUrl } : {}
|
|
15935
|
-
})).id,
|
|
15936
|
-
gitHead
|
|
15937
|
-
};
|
|
15938
|
-
} catch (err) {
|
|
15939
|
-
throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
|
|
15940
|
-
}
|
|
15941
|
-
}
|
|
15942
|
-
/**
|
|
15943
16205
|
* Send one finished spec. A failure is warned and swallowed rather than
|
|
15944
16206
|
* thrown: the seal resends every row, so a dropped patch costs freshness for
|
|
15945
16207
|
* the rest of the sweep, not the record.
|
|
@@ -15971,22 +16233,16 @@ async function sealDriftPush(push, args) {
|
|
|
15971
16233
|
customPromptVersion: promptCtx?.customPromptVersion ?? null,
|
|
15972
16234
|
triageUserPromptHash: promptCtx?.triageUserPromptHash ?? null
|
|
15973
16235
|
});
|
|
15974
|
-
|
|
15975
|
-
|
|
15976
|
-
|
|
15977
|
-
|
|
15978
|
-
|
|
15979
|
-
|
|
15980
|
-
|
|
15981
|
-
|
|
15982
|
-
|
|
15983
|
-
|
|
15984
|
-
}
|
|
15985
|
-
});
|
|
15986
|
-
} catch (err) {
|
|
15987
|
-
error(`hub: could not close the drift run ${push.runId}: ${errMessage(err)}`);
|
|
15988
|
-
process.exit(2);
|
|
15989
|
-
}
|
|
16236
|
+
if (!await sealHubRun(push, {
|
|
16237
|
+
rows: report.results,
|
|
16238
|
+
reportMeta: {
|
|
16239
|
+
git: report.git,
|
|
16240
|
+
promptVersion: report.promptVersion,
|
|
16241
|
+
customPromptVersion: report.customPromptVersion,
|
|
16242
|
+
...report.triageUserPromptHash ? { triageUserPromptHash: report.triageUserPromptHash } : {},
|
|
16243
|
+
cost: report.cost
|
|
16244
|
+
}
|
|
16245
|
+
})) process.exit(2);
|
|
15990
16246
|
if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${push.runId}`);
|
|
15991
16247
|
}
|
|
15992
16248
|
/**
|
|
@@ -16824,6 +17080,7 @@ function gradedDriftEntry(ledger, key, runId, label) {
|
|
|
16824
17080
|
delete graded.headline;
|
|
16825
17081
|
delete graded.confidence;
|
|
16826
17082
|
}
|
|
17083
|
+
if (label !== "SPEC_CHANGE") delete graded.specChangeKind;
|
|
16827
17084
|
return graded;
|
|
16828
17085
|
}
|
|
16829
17086
|
//#endregion
|
|
@@ -16877,11 +17134,15 @@ function mergeBucket(into, from) {
|
|
|
16877
17134
|
//#endregion
|
|
16878
17135
|
//#region src/hub/api/validate.ts
|
|
16879
17136
|
/**
|
|
16880
|
-
* Validators for
|
|
16881
|
-
* path
|
|
17137
|
+
* Validators for the request parameters handlers read straight off the wire:
|
|
17138
|
+
* the URL path parameters that flow into the storage layer's file path
|
|
17139
|
+
* construction (secret store scope/name, artifact relative paths), and the
|
|
17140
|
+
* query params bounding a listing's time window.
|
|
17141
|
+
*
|
|
16882
17142
|
* Router params come from `decodeURIComponent`-ed path segments, so a client
|
|
16883
|
-
* can put `..`, `/`, or `\` in them —
|
|
16884
|
-
* could escape the intended directory before it ever reaches
|
|
17143
|
+
* can put `..`, `/`, or `\` in them — the path validators below reject
|
|
17144
|
+
* anything that could escape the intended directory before it ever reaches
|
|
17145
|
+
* disk I/O.
|
|
16885
17146
|
*/
|
|
16886
17147
|
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
16887
17148
|
/** Validate a single URL path parameter (e.g. `:profile`, `:name`) as a bare name. Throws 400 if unsafe. */
|
|
@@ -16896,6 +17157,26 @@ function requireSafeSegment(value, paramName) {
|
|
|
16896
17157
|
function requireProfileParam(url) {
|
|
16897
17158
|
return requireSafeSegment(url.searchParams.get("profile") ?? "default", "profile");
|
|
16898
17159
|
}
|
|
17160
|
+
/**
|
|
17161
|
+
* The `?since=`/`?until=` window a listing takes, in the shape its store's
|
|
17162
|
+
* `list` takes it. Half-open on purpose: a caller asking for one day passes
|
|
17163
|
+
* that day's start and the next day's start, and no record is counted twice
|
|
17164
|
+
* at a boundary.
|
|
17165
|
+
*/
|
|
17166
|
+
function requireWindowParams(url) {
|
|
17167
|
+
const since = requireInstant(url.searchParams.get("since"), "since");
|
|
17168
|
+
const until = requireInstant(url.searchParams.get("until"), "until");
|
|
17169
|
+
return {
|
|
17170
|
+
...since ? { since } : {},
|
|
17171
|
+
...until ? { until } : {}
|
|
17172
|
+
};
|
|
17173
|
+
}
|
|
17174
|
+
/** Rejected rather than ignored: a typo would otherwise read as "nothing that day". */
|
|
17175
|
+
function requireInstant(raw, name) {
|
|
17176
|
+
if (raw === null || raw === "") return null;
|
|
17177
|
+
if (Number.isNaN(Date.parse(raw))) throw new HttpError(400, "invalid_param", `invalid ${name}: must be an ISO-8601 instant`);
|
|
17178
|
+
return raw;
|
|
17179
|
+
}
|
|
16899
17180
|
/** Validate a `*path`-captured relative path (multiple segments allowed) as safe to join under a root dir. Throws 400 if unsafe. */
|
|
16900
17181
|
function requireSafeRelPath(relPath, paramName) {
|
|
16901
17182
|
const segments = relPath.split("/");
|
|
@@ -16958,7 +17239,7 @@ function createPushRunHandler(config) {
|
|
|
16958
17239
|
runUrl: report.runUrl ?? null,
|
|
16959
17240
|
reportCreatedAt: report.createdAt,
|
|
16960
17241
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16961
|
-
...await resolveDeployedSha(config.storage, project, profile, deployedSha),
|
|
17242
|
+
...await resolveDeployedSha(config.storage, kind, project, profile, deployedSha),
|
|
16962
17243
|
deployedShaAmbiguous: false
|
|
16963
17244
|
};
|
|
16964
17245
|
await config.storage.artifacts.putDir(run.id, dir);
|
|
@@ -17009,7 +17290,7 @@ function createOpenRunHandler(config) {
|
|
|
17009
17290
|
runUrl: runUrl || null,
|
|
17010
17291
|
reportCreatedAt: now,
|
|
17011
17292
|
createdAt: now,
|
|
17012
|
-
...await resolveDeployedSha(config.storage, project, profile, deployedSha),
|
|
17293
|
+
...await resolveDeployedSha(config.storage, kind, project, profile, deployedSha),
|
|
17013
17294
|
deployedShaAmbiguous: false
|
|
17014
17295
|
};
|
|
17015
17296
|
await config.storage.runs.create(run);
|
|
@@ -17131,10 +17412,11 @@ async function updateDriftLedger(storage, run, results) {
|
|
|
17131
17412
|
for (const row of results) {
|
|
17132
17413
|
if (row.status === "skipped") continue;
|
|
17133
17414
|
const key = `${row.feature}/${row.spec}`;
|
|
17134
|
-
const diagnosis = row.analysis;
|
|
17415
|
+
const diagnosis = row.analysis ? normalizeDiagnosis(row.analysis) : null;
|
|
17135
17416
|
ledger.specs[key] = {
|
|
17136
17417
|
label: diagnosis ? diagnosis.label : null,
|
|
17137
17418
|
surface: diagnosis?.surface,
|
|
17419
|
+
specChangeKind: diagnosis?.specChangeKind,
|
|
17138
17420
|
confidence: diagnosis?.confidence,
|
|
17139
17421
|
headline: diagnosis?.headline,
|
|
17140
17422
|
gitHead,
|
|
@@ -17157,11 +17439,15 @@ async function updateDriftLedger(storage, run, results) {
|
|
|
17157
17439
|
* Best-effort: a deploy log the hub can't read leaves the run unattributed
|
|
17158
17440
|
* (re-run selection then answers `unknown`) rather than rejecting the run.
|
|
17159
17441
|
*/
|
|
17160
|
-
async function resolveDeployedSha(storage, project, profile, explicit) {
|
|
17442
|
+
async function resolveDeployedSha(storage, kind, project, profile, explicit) {
|
|
17161
17443
|
if (explicit) return {
|
|
17162
17444
|
deployedSha: explicit,
|
|
17163
17445
|
deployedShaSource: "client"
|
|
17164
17446
|
};
|
|
17447
|
+
if (kind === "record") return {
|
|
17448
|
+
deployedSha: null,
|
|
17449
|
+
deployedShaSource: null
|
|
17450
|
+
};
|
|
17165
17451
|
try {
|
|
17166
17452
|
const head = await storage.deploys.head(project, profile ?? "default");
|
|
17167
17453
|
if (head) return {
|
|
@@ -17266,17 +17552,20 @@ function createPatchRunHandler(config) {
|
|
|
17266
17552
|
sendJson(ctx.res, 200, updated);
|
|
17267
17553
|
};
|
|
17268
17554
|
}
|
|
17269
|
-
/** GET /api/v1/runs?project&branch&status&limit */
|
|
17555
|
+
/** GET /api/v1/runs?project&branch&status&kind&since&until&limit */
|
|
17270
17556
|
function createListRunsHandler(storage) {
|
|
17271
17557
|
return async (ctx) => {
|
|
17272
17558
|
const project = ctx.url.searchParams.get("project");
|
|
17273
17559
|
const branch = ctx.url.searchParams.get("branch");
|
|
17274
17560
|
const status = ctx.url.searchParams.get("status");
|
|
17275
17561
|
const limitRaw = ctx.url.searchParams.get("limit");
|
|
17562
|
+
const kindsRaw = ctx.url.searchParams.get("kind");
|
|
17276
17563
|
const runs = await storage.runs.list({
|
|
17277
17564
|
...project ? { project } : {},
|
|
17278
17565
|
...branch ? { branch } : {},
|
|
17279
17566
|
...status ? { status } : {},
|
|
17567
|
+
...kindsRaw ? { kinds: kindsRaw.split(",").map(requireKind) } : {},
|
|
17568
|
+
...requireWindowParams(ctx.url),
|
|
17280
17569
|
...limitRaw ? { limit: Number(limitRaw) } : {}
|
|
17281
17570
|
});
|
|
17282
17571
|
sendJson(ctx.res, 200, { runs: await Promise.all(runs.map((r) => withGradedDrift(storage, r))) });
|
|
@@ -17410,16 +17699,21 @@ function parseRunScope(ctx) {
|
|
|
17410
17699
|
const profileRaw = ctx.url.searchParams.get("profile");
|
|
17411
17700
|
const profile = profileRaw ? requireSafeSegment(profileRaw, "profile") : null;
|
|
17412
17701
|
const kindRaw = ctx.url.searchParams.get("kind");
|
|
17413
|
-
if (kindRaw !== null && kindRaw !== "run" && kindRaw !== "drift") throw new HttpError(400, "invalid_param", `invalid kind: must be "run" or "drift"`);
|
|
17414
17702
|
const deployedSha = boundedParam(ctx.url.searchParams.get("deployedSha"), "deployedSha", 64);
|
|
17415
17703
|
return {
|
|
17416
17704
|
project,
|
|
17417
17705
|
branch,
|
|
17418
17706
|
profile,
|
|
17419
|
-
kind: kindRaw
|
|
17707
|
+
kind: kindRaw === null ? "run" : requireKind(kindRaw),
|
|
17420
17708
|
deployedSha
|
|
17421
17709
|
};
|
|
17422
17710
|
}
|
|
17711
|
+
/** One `kind` value, validated against the enum so the message can't drift from it. */
|
|
17712
|
+
function requireKind(raw) {
|
|
17713
|
+
const parsed = ReportKindSchema.safeParse(raw);
|
|
17714
|
+
if (!parsed.success) throw new HttpError(400, "invalid_param", `invalid kind: must be one of ${ReportKindSchema.options.map((k) => `"${k}"`).join(", ")}`);
|
|
17715
|
+
return parsed.data;
|
|
17716
|
+
}
|
|
17423
17717
|
/**
|
|
17424
17718
|
* A branch is a free-form label (e.g. `feature/foo`), so `/` is allowed —
|
|
17425
17719
|
* only length is bounded (a sanity cap; the last-green ledger separately
|
|
@@ -17965,7 +18259,7 @@ function createGetAuditNeedHandler(storage) {
|
|
|
17965
18259
|
//#endregion
|
|
17966
18260
|
//#region src/hub/api/handlers/locks.ts
|
|
17967
18261
|
/** A spec-key list and three short strings; nothing here should approach this. */
|
|
17968
|
-
const MAX_BODY_BYTES$
|
|
18262
|
+
const MAX_BODY_BYTES$3 = 1024 * 1024;
|
|
17969
18263
|
/**
|
|
17970
18264
|
* POST /api/v1/projects/:project/locks?profile=
|
|
17971
18265
|
*
|
|
@@ -17979,7 +18273,7 @@ function createAcquireLocksHandler(storage) {
|
|
|
17979
18273
|
return async (ctx) => {
|
|
17980
18274
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
17981
18275
|
const profile = requireProfileParam(ctx.url);
|
|
17982
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
18276
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, AcquireLocksRequestSchema, "lock request");
|
|
17983
18277
|
let result = {
|
|
17984
18278
|
granted: [],
|
|
17985
18279
|
denied: []
|
|
@@ -18008,12 +18302,95 @@ function createReleaseLocksHandler(storage) {
|
|
|
18008
18302
|
return async (ctx) => {
|
|
18009
18303
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18010
18304
|
const profile = requireProfileParam(ctx.url);
|
|
18011
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
18305
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$3, ReleaseLocksRequestSchema, "release request");
|
|
18012
18306
|
await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
|
|
18013
18307
|
ctx.res.writeHead(204).end();
|
|
18014
18308
|
};
|
|
18015
18309
|
}
|
|
18016
18310
|
//#endregion
|
|
18311
|
+
//#region src/hub/api/handlers/acks.ts
|
|
18312
|
+
/**
|
|
18313
|
+
* Pre-parse guard only. `PutAckRequestSchema` holds the real bound (5000 keys
|
|
18314
|
+
* of 256 characters); this sits above the largest body that can satisfy it —
|
|
18315
|
+
* 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
|
|
18316
|
+
* never answered 413 by a limit the documented bounds don't mention.
|
|
18317
|
+
*/
|
|
18318
|
+
const MAX_BODY_BYTES$2 = 8 * 1024 * 1024;
|
|
18319
|
+
function requireAckKey(ctx) {
|
|
18320
|
+
return {
|
|
18321
|
+
project: requireSafeSegment(ctx.params.project, "project"),
|
|
18322
|
+
profile: requireProfileParam(ctx.url),
|
|
18323
|
+
name: requireSafeSegment(ctx.params.name, "name")
|
|
18324
|
+
};
|
|
18325
|
+
}
|
|
18326
|
+
/**
|
|
18327
|
+
* GET /api/v1/projects/:project/acks/:name?profile= — an unset ack answers 200
|
|
18328
|
+
* with an empty set, not 404: "nothing acted on yet" is a real answer, and a
|
|
18329
|
+
* 404 there is a special case consumers get wrong.
|
|
18330
|
+
*/
|
|
18331
|
+
function createGetAckHandler(storage) {
|
|
18332
|
+
return async (ctx) => {
|
|
18333
|
+
const key = requireAckKey(ctx);
|
|
18334
|
+
const ack = await storage.acks.get(key.project, key.profile, key.name);
|
|
18335
|
+
sendJson(ctx.res, 200, {
|
|
18336
|
+
...key,
|
|
18337
|
+
...ack
|
|
18338
|
+
});
|
|
18339
|
+
};
|
|
18340
|
+
}
|
|
18341
|
+
/** PUT /api/v1/projects/:project/acks/:name?profile= */
|
|
18342
|
+
function createPutAckHandler(storage) {
|
|
18343
|
+
return async (ctx) => {
|
|
18344
|
+
const key = requireAckKey(ctx);
|
|
18345
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, PutAckRequestSchema, "ack body");
|
|
18346
|
+
const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
|
|
18347
|
+
sendJson(ctx.res, 200, {
|
|
18348
|
+
...key,
|
|
18349
|
+
...ack
|
|
18350
|
+
});
|
|
18351
|
+
};
|
|
18352
|
+
}
|
|
18353
|
+
//#endregion
|
|
18354
|
+
//#region src/hub/api/handlers/spend.ts
|
|
18355
|
+
/** One entry is a handful of short fields; anything larger is a malformed client. */
|
|
18356
|
+
const MAX_BODY_BYTES$1 = 4 * 1024;
|
|
18357
|
+
/**
|
|
18358
|
+
* POST /api/v1/projects/:project/spend
|
|
18359
|
+
*
|
|
18360
|
+
* What a batch of ccqa invocations cost, as the job that ran them reported it —
|
|
18361
|
+
* the number a budget reads instead of summing runs (ADR-0017).
|
|
18362
|
+
*/
|
|
18363
|
+
function createRecordSpendHandler(storage) {
|
|
18364
|
+
return async (ctx) => {
|
|
18365
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18366
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$1, RecordSpendRequestSchema, "spend body");
|
|
18367
|
+
const entry = await storage.spend.append(project, {
|
|
18368
|
+
id: randomUUID(),
|
|
18369
|
+
at: new Date(body.at ?? Date.now()).toISOString(),
|
|
18370
|
+
costUsd: body.costUsd,
|
|
18371
|
+
label: body.label,
|
|
18372
|
+
...body.ciRunId ? { ciRunId: body.ciRunId } : {},
|
|
18373
|
+
...body.runUrl ? { runUrl: body.runUrl } : {}
|
|
18374
|
+
});
|
|
18375
|
+
sendJson(ctx.res, 201, entry);
|
|
18376
|
+
};
|
|
18377
|
+
}
|
|
18378
|
+
/** GET /api/v1/projects/:project/spend?since=&until= — newest first, plus the window's total. */
|
|
18379
|
+
function createGetSpendHandler(storage) {
|
|
18380
|
+
return async (ctx) => {
|
|
18381
|
+
const project = requireSafeSegment(ctx.params.project, "project");
|
|
18382
|
+
const window = requireWindowParams(ctx.url);
|
|
18383
|
+
const entries = await storage.spend.list(project, window);
|
|
18384
|
+
sendJson(ctx.res, 200, {
|
|
18385
|
+
project,
|
|
18386
|
+
since: window.since ?? null,
|
|
18387
|
+
until: window.until ?? null,
|
|
18388
|
+
totalUsd: entries.reduce((sum, e) => sum + e.costUsd, 0),
|
|
18389
|
+
entries
|
|
18390
|
+
});
|
|
18391
|
+
};
|
|
18392
|
+
}
|
|
18393
|
+
//#endregion
|
|
18017
18394
|
//#region src/hub/core/rerun.ts
|
|
18018
18395
|
/**
|
|
18019
18396
|
* When each deployed commit reached the environment. A baseline read at that
|
|
@@ -18850,10 +19227,32 @@ const HTML_BODY = `
|
|
|
18850
19227
|
<div class="page-bar">
|
|
18851
19228
|
<h1 data-i18n="runs.title">Runs</h1>
|
|
18852
19229
|
<span class="total" id="runs-total-cost" hidden></span>
|
|
19230
|
+
<span class="total" id="runs-capped" hidden></span>
|
|
19231
|
+
<!-- Ruled off from the two above because it counts something else: the
|
|
19232
|
+
project's whole spend, not what the listed runs cost. -->
|
|
19233
|
+
<span class="total apart" id="runs-spend-24h" hidden></span>
|
|
18853
19234
|
<div class="spacer"></div>
|
|
18854
19235
|
${refreshButton("runs-refresh")}
|
|
18855
19236
|
</div>
|
|
18856
19237
|
<div class="content">
|
|
19238
|
+
<!-- Deliberately selects and a native date input, not the .fchip
|
|
19239
|
+
toggles the rest of the page uses: these three refetch, and a chip
|
|
19240
|
+
group beside a date box would be the odd one out. Their options
|
|
19241
|
+
are built by syncRunsFilters. -->
|
|
19242
|
+
<div class="toolbar">
|
|
19243
|
+
<div class="fgroup">
|
|
19244
|
+
<label class="fgroup-label" for="runs-f-date" data-i18n="runs.filter.date">Date</label>
|
|
19245
|
+
<input class="fctl" type="date" id="runs-f-date">
|
|
19246
|
+
</div>
|
|
19247
|
+
<div class="fgroup">
|
|
19248
|
+
<label class="fgroup-label" for="runs-f-kind" data-i18n="runs.filter.kind">Kind</label>
|
|
19249
|
+
<select class="fctl" id="runs-f-kind"></select>
|
|
19250
|
+
</div>
|
|
19251
|
+
<div class="fgroup">
|
|
19252
|
+
<label class="fgroup-label" for="runs-f-status" data-i18n="runs.filter.status">Status</label>
|
|
19253
|
+
<select class="fctl" id="runs-f-status"></select>
|
|
19254
|
+
</div>
|
|
19255
|
+
</div>
|
|
18857
19256
|
<div class="card" id="runs-card">
|
|
18858
19257
|
<div class="table-wrap">
|
|
18859
19258
|
<table>
|
|
@@ -19214,6 +19613,7 @@ const CSS = `
|
|
|
19214
19613
|
.page-bar .back svg { width: 15px; height: 15px; }
|
|
19215
19614
|
.page-bar .filters { display: flex; gap: 8px; margin-left: 8px; }
|
|
19216
19615
|
.page-bar .total { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
19616
|
+
.page-bar .total.apart { padding-left: 12px; border-left: 1px solid var(--border); }
|
|
19217
19617
|
.page-bar .spacer { flex: 1; }
|
|
19218
19618
|
.content { padding: 18px 24px 48px; }
|
|
19219
19619
|
|
|
@@ -19263,7 +19663,12 @@ const CSS = `
|
|
|
19263
19663
|
.empty-note { color: var(--muted); font-size: 13px; padding: 16px 2px; }
|
|
19264
19664
|
|
|
19265
19665
|
.runid { font-family: var(--mono); font-size: 13px; font-weight: 600; }
|
|
19266
|
-
.
|
|
19666
|
+
/* Chips wrap when a run carries several labels. Laid out as inline content
|
|
19667
|
+
they wrapped to a line indented by the chips' own left margin, and the two
|
|
19668
|
+
lines sat at text leading — too tight to read as separate rows. Flex with a
|
|
19669
|
+
gap aligns every line at the same left edge and spaces them the same way
|
|
19670
|
+
horizontally and vertically. */
|
|
19671
|
+
.subline { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 4px; }
|
|
19267
19672
|
.ci-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-family: var(--mono); color: var(--muted); background: var(--surface-3); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; }
|
|
19268
19673
|
.ci-badge.local { color: var(--muted-2); }
|
|
19269
19674
|
a.ci-badge { text-decoration: none; }
|
|
@@ -19279,6 +19684,15 @@ const CSS = `
|
|
|
19279
19684
|
.badge.skipped .d { background: var(--muted); }
|
|
19280
19685
|
.badge.running { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
19281
19686
|
.badge.running .d { background: var(--amber); }
|
|
19687
|
+
/* A drift verdict is a diagnosis, not a broken test: amber, never fail-red.
|
|
19688
|
+
Without these the badge rendered as bare text next to the pill-shaped
|
|
19689
|
+
pass/fail ones, and read as a different kind of thing. */
|
|
19690
|
+
.badge.dr-found { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
19691
|
+
.badge.dr-found .d { background: var(--amber); }
|
|
19692
|
+
.badge.dr-clean { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
|
|
19693
|
+
.badge.dr-clean .d { background: var(--pass); }
|
|
19694
|
+
.badge.dr-unknown { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
19695
|
+
.badge.dr-unknown .d { background: var(--muted); }
|
|
19282
19696
|
.badge-live, .badge-det { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--radius-sm); font-size: 11px; font-weight: 600; border: 1px solid transparent; }
|
|
19283
19697
|
.badge-live { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
|
|
19284
19698
|
.badge-det { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
@@ -19290,8 +19704,11 @@ const CSS = `
|
|
|
19290
19704
|
order decides). One amber look for every drift label chip — a label chip
|
|
19291
19705
|
is a finding, not a severity, so it does not split into fail-red/amber
|
|
19292
19706
|
the way the old errors/warnings counts did. */
|
|
19293
|
-
.chip.kind-chip { color: var(--violet); background: var(--violet-bg); border-color: var(--violet-border); font-family: var(--font);
|
|
19294
|
-
.chip.drift-count-chip { color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border);
|
|
19707
|
+
.chip.kind-chip { color: var(--violet); background: var(--violet-bg); border-color: var(--violet-border); font-family: var(--font); }
|
|
19708
|
+
.chip.drift-count-chip { color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border); }
|
|
19709
|
+
/* Prose, not an identifier — and neutral, since it qualifies the label chip
|
|
19710
|
+
beside it rather than claiming a severity of its own. */
|
|
19711
|
+
.chip.spec-change-chip { font-family: var(--font); }
|
|
19295
19712
|
.drift-meta-box { display: flex; flex-direction: column; gap: 4px; }
|
|
19296
19713
|
/* The chips carry their own margin for the run list, where they sit inline
|
|
19297
19714
|
after other chips. Here the container owns the spacing, so the margin only
|
|
@@ -19625,6 +20042,13 @@ const CSS = `
|
|
|
19625
20042
|
.fchip[aria-pressed="true"] { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
|
19626
20043
|
.fchip .fcount { margin-left: 6px; font-variant-numeric: tabular-nums; color: var(--muted-2); }
|
|
19627
20044
|
.fchip[aria-pressed="true"] .fcount { color: var(--accent-fg); opacity: 0.7; }
|
|
20045
|
+
/* A filter control that picks one value out of many (the runs bar's date box
|
|
20046
|
+
and selects), sized to itself — .input is the full-width form field the
|
|
20047
|
+
sheets use, which in a toolbar row swallows the whole line. */
|
|
20048
|
+
.fctl { height: 32px; padding: 0 8px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--fg); font: inherit; font-size: 13px; }
|
|
20049
|
+
/* Native chrome (the date picker's glyph, the select's arrow) takes its
|
|
20050
|
+
colours from the color-scheme property, not from any class of ours. */
|
|
20051
|
+
.dark .fctl { color-scheme: dark; }
|
|
19628
20052
|
|
|
19629
20053
|
.chip.live { background: var(--info-bg); color: var(--info); border-color: var(--info-border); }
|
|
19630
20054
|
.badge.ok { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
|
|
@@ -19729,7 +20153,10 @@ const CLIENT_JS = `
|
|
|
19729
20153
|
var PREDICTED_LABELS = ${JSON.stringify(PREDICTED_LABELS)};
|
|
19730
20154
|
var AGENT_BROWSER_TARGET = ${JSON.stringify(AGENT_BROWSER_TARGET)};
|
|
19731
20155
|
var GUIDANCE_KINDS = ${JSON.stringify(GUIDANCE_KINDS)};
|
|
19732
|
-
|
|
20156
|
+
// Every status a run can be in, from the contract that defines them, so the
|
|
20157
|
+
// runs filter offers exactly what a row's badge can say.
|
|
20158
|
+
var RUN_STATUSES = ${JSON.stringify(RunStatusSchema.options)};
|
|
20159
|
+
var state = { token: "", project: "", profile: "default", detailRunId: "", jobPollToken: 0, runsLoadToken: 0, spendLoadToken: 0 };
|
|
19733
20160
|
var knownProfiles = [];
|
|
19734
20161
|
var TOKEN_KEY = "ccqa-hub-token";
|
|
19735
20162
|
var LANG_KEY = "ccqa-hub-lang";
|
|
@@ -19754,9 +20181,13 @@ const CLIENT_JS = `
|
|
|
19754
20181
|
"projects.title": "Projects", "projects.new": "New project",
|
|
19755
20182
|
"runs.title": "Runs", "runs.empty": "Select a project to see its runs.",
|
|
19756
20183
|
"runs.none": "No runs yet for this project.", "projects.none": "No projects yet. Create one to get started.", "projects.noneShort": "No projects yet",
|
|
20184
|
+
"runs.noMatch": "No runs match this filter.",
|
|
19757
20185
|
"runs.col.run": "Run", "runs.col.branch": "Branch", "runs.col.status": "Status",
|
|
19758
20186
|
"runs.col.specs": "Specs", "runs.col.cost": "Cost", "runs.col.created": "Created",
|
|
19759
|
-
"runs.totalCost": "Cost of these {n}:",
|
|
20187
|
+
"runs.totalCost": "Cost of these {n}:", "runs.capped": "showing the first {n}",
|
|
20188
|
+
"runs.spend24h": "All spend, last 24h:",
|
|
20189
|
+
"runs.filter.date": "Date", "runs.filter.kind": "Kind", "runs.filter.status": "Status",
|
|
20190
|
+
"runs.filter.all": "All",
|
|
19760
20191
|
"detail.back": "Runs", "detail.specs": "Specs",
|
|
19761
20192
|
"detail.download": "Download artifacts",
|
|
19762
20193
|
"detail.triage": "Triage",
|
|
@@ -19765,6 +20196,7 @@ const CLIENT_JS = `
|
|
|
19765
20196
|
"meta.drift": "Drift",
|
|
19766
20197
|
"diag.cause": "Cause", "diag.fix": "Fix",
|
|
19767
20198
|
"diag.surface": "Surface", "diag.surface.spec": "spec", "diag.surface.generated": "generated code",
|
|
20199
|
+
"diag.specChangeKind.FEATURE_REMOVED": "feature gone", "diag.specChangeKind.BEHAVIOUR_CHANGED": "behaviour changed",
|
|
19768
20200
|
"acc.reasoning": "Reasoning", "acc.evidence": "Evidence", "acc.steps": "Live run steps",
|
|
19769
20201
|
"acc.assertions": "Assertions",
|
|
19770
20202
|
"acc.artifacts": "Artifacts",
|
|
@@ -19773,7 +20205,7 @@ const CLIENT_JS = `
|
|
|
19773
20205
|
"spec.kind.live": "Live", "spec.kind.det": "Deterministic",
|
|
19774
20206
|
"det.steps": "Steps",
|
|
19775
20207
|
"det.noEvidence": "No step screenshots:",
|
|
19776
|
-
"kind.run": "Test run", "kind.drift": "Drift audit",
|
|
20208
|
+
"kind.run": "Test run", "kind.drift": "Drift audit", "kind.record": "Recording",
|
|
19777
20209
|
"drift.summary.ratio": "{found} of {total} specs",
|
|
19778
20210
|
"drift.clean": "No drift issues",
|
|
19779
20211
|
"status.passed": "passed", "status.failed": "failed", "status.skipped": "skipped", "status.running": "running",
|
|
@@ -19912,9 +20344,13 @@ const CLIENT_JS = `
|
|
|
19912
20344
|
"projects.title": "プロジェクト", "projects.new": "新規プロジェクト",
|
|
19913
20345
|
"runs.title": "実行", "runs.empty": "プロジェクトを選択すると実行一覧が表示されます。",
|
|
19914
20346
|
"runs.none": "このプロジェクトにはまだ実行がありません。", "projects.none": "まだプロジェクトがありません。作成して始めましょう。", "projects.noneShort": "プロジェクトなし",
|
|
20347
|
+
"runs.noMatch": "条件に一致する実行はありません。",
|
|
19915
20348
|
"runs.col.run": "実行", "runs.col.branch": "ブランチ", "runs.col.status": "ステータス",
|
|
19916
20349
|
"runs.col.specs": "スペック", "runs.col.cost": "コスト", "runs.col.created": "作成",
|
|
19917
|
-
"runs.totalCost": "この {n} 件のコスト:",
|
|
20350
|
+
"runs.totalCost": "この {n} 件のコスト:", "runs.capped": "先頭 {n} 件のみ表示",
|
|
20351
|
+
"runs.spend24h": "直近 24 時間の全支出:",
|
|
20352
|
+
"runs.filter.date": "日付", "runs.filter.kind": "種類", "runs.filter.status": "結果",
|
|
20353
|
+
"runs.filter.all": "すべて",
|
|
19918
20354
|
"detail.back": "実行", "detail.specs": "スペック",
|
|
19919
20355
|
"detail.download": "アーティファクトをダウンロード",
|
|
19920
20356
|
"detail.triage": "トリアージ",
|
|
@@ -19923,6 +20359,7 @@ const CLIENT_JS = `
|
|
|
19923
20359
|
"meta.drift": "ドリフト",
|
|
19924
20360
|
"diag.cause": "原因", "diag.fix": "対処",
|
|
19925
20361
|
"diag.surface": "対象", "diag.surface.spec": "spec", "diag.surface.generated": "生成コード",
|
|
20362
|
+
"diag.specChangeKind.FEATURE_REMOVED": "機能が無い", "diag.specChangeKind.BEHAVIOUR_CHANGED": "振る舞いが変わった",
|
|
19926
20363
|
"acc.reasoning": "推論", "acc.evidence": "根拠", "acc.steps": "実行ステップ",
|
|
19927
20364
|
"acc.assertions": "アサーション",
|
|
19928
20365
|
"acc.artifacts": "成果物",
|
|
@@ -19931,7 +20368,7 @@ const CLIENT_JS = `
|
|
|
19931
20368
|
"spec.kind.live": "ライブ", "spec.kind.det": "決定的",
|
|
19932
20369
|
"det.steps": "ステップ",
|
|
19933
20370
|
"det.noEvidence": "ステップのスクリーンショットなし:",
|
|
19934
|
-
"kind.run": "テスト実行", "kind.drift": "ドリフト監査",
|
|
20371
|
+
"kind.run": "テスト実行", "kind.drift": "ドリフト監査", "kind.record": "収録",
|
|
19935
20372
|
"drift.summary.ratio": "{found} / {total} スペック",
|
|
19936
20373
|
"drift.clean": "ドリフトの問題なし",
|
|
19937
20374
|
"status.passed": "合格", "status.failed": "失敗", "status.skipped": "スキップ", "status.running": "実行中",
|
|
@@ -20081,6 +20518,9 @@ const CLIENT_JS = `
|
|
|
20081
20518
|
for (var i = 0; i < nodes.length; i++) { nodes[i].textContent = t(nodes[i].getAttribute("data-i18n")); }
|
|
20082
20519
|
var phs = document.querySelectorAll("[data-i18n-ph]");
|
|
20083
20520
|
for (var j = 0; j < phs.length; j++) { phs[j].placeholder = t(phs[j].getAttribute("data-i18n-ph")); }
|
|
20521
|
+
// The runs filters' options are built rather than marked up, so they are
|
|
20522
|
+
// not reached by the two loops above.
|
|
20523
|
+
syncRunsFilters();
|
|
20084
20524
|
document.documentElement.lang = lang;
|
|
20085
20525
|
}
|
|
20086
20526
|
|
|
@@ -20310,6 +20750,21 @@ const CLIENT_JS = `
|
|
|
20310
20750
|
return run.kind === "drift" ? driftFoundBadge(driftRunState(run), "drift.run.") : statusBadge(run.status);
|
|
20311
20751
|
}
|
|
20312
20752
|
|
|
20753
|
+
// Which command left the run, and whether its spec counts are a tally of
|
|
20754
|
+
// what was verified. A recording's rows are the specs it wrote, not specs it
|
|
20755
|
+
// checked, so "1 / 1 passed" and a full meter would claim a test result.
|
|
20756
|
+
// A kind from a newer hub keeps the generic label but is read the same
|
|
20757
|
+
// cautious way, since nothing here knows what its counts mean.
|
|
20758
|
+
var KINDS = {
|
|
20759
|
+
run: { label: "kind.run", verifies: true },
|
|
20760
|
+
drift: { label: "kind.drift", verifies: true },
|
|
20761
|
+
record: { label: "kind.record", verifies: false },
|
|
20762
|
+
};
|
|
20763
|
+
function kindOf(kind) { return KINDS[kind] || { label: "kind.run", verifies: false }; }
|
|
20764
|
+
function kindChip(kind) {
|
|
20765
|
+
return el("span", "chip kind-chip", t(kindOf(kind).label));
|
|
20766
|
+
}
|
|
20767
|
+
|
|
20313
20768
|
// A run's Claude spend, in the same $x.xxxx form as the per-step badge.
|
|
20314
20769
|
// A run that billed nothing, and one stored before costs were recorded, both
|
|
20315
20770
|
// arrive as a non-number — printing $0.0000 would claim a measured zero.
|
|
@@ -20533,13 +20988,58 @@ const CLIENT_JS = `
|
|
|
20533
20988
|
|
|
20534
20989
|
// ── runs list ────────────────────────────────────────────────────────
|
|
20535
20990
|
|
|
20991
|
+
var RUNS_LIMIT = 50;
|
|
20992
|
+
// Outlives every render, so a refresh or a language switch comes back to the
|
|
20993
|
+
// list the operator was looking at.
|
|
20994
|
+
var runsFilter = { date: "", kind: "", status: "" };
|
|
20995
|
+
function runsFilterActive() { return !!(runsFilter.date || runsFilter.kind || runsFilter.status); }
|
|
20996
|
+
|
|
20997
|
+
function runsQuery() {
|
|
20998
|
+
var q = "/api/v1/runs?project=" + encodeURIComponent(state.project) + "&limit=" + RUNS_LIMIT;
|
|
20999
|
+
if (runsFilter.kind) q += "&kind=" + encodeURIComponent(runsFilter.kind);
|
|
21000
|
+
if (runsFilter.status) q += "&status=" + encodeURIComponent(runsFilter.status);
|
|
21001
|
+
if (runsFilter.date) {
|
|
21002
|
+
// The picked day becomes [local midnight, next local midnight). The API
|
|
21003
|
+
// takes instants and carries no timezone, so the day has to be resolved
|
|
21004
|
+
// here — against the clock of whoever picked it.
|
|
21005
|
+
var p = runsFilter.date.split("-");
|
|
21006
|
+
var start = new Date(+p[0], +p[1] - 1, +p[2]);
|
|
21007
|
+
var next = new Date(+p[0], +p[1] - 1, +p[2] + 1);
|
|
21008
|
+
q += "&since=" + encodeURIComponent(start.toISOString()) + "&until=" + encodeURIComponent(next.toISOString());
|
|
21009
|
+
}
|
|
21010
|
+
return q;
|
|
21011
|
+
}
|
|
21012
|
+
|
|
21013
|
+
// Both selects take their values from the tables that label the rows, so a
|
|
21014
|
+
// filter cannot name a kind or a status differently from the run it hides.
|
|
21015
|
+
// Called on boot and on a language switch — the only times the labels move.
|
|
21016
|
+
function syncRunsFilters() {
|
|
21017
|
+
document.getElementById("runs-f-date").value = runsFilter.date;
|
|
21018
|
+
fillRunsFilter("runs-f-kind", Object.keys(KINDS), function (k) { return t(kindOf(k).label); }, runsFilter.kind);
|
|
21019
|
+
fillRunsFilter("runs-f-status", RUN_STATUSES, function (s) { return t("status." + s); }, runsFilter.status);
|
|
21020
|
+
}
|
|
21021
|
+
|
|
21022
|
+
function fillRunsFilter(id, values, labelOf, selected) {
|
|
21023
|
+
var sel = document.getElementById(id);
|
|
21024
|
+
clear(sel);
|
|
21025
|
+
var any = el("option", null, t("runs.filter.all"));
|
|
21026
|
+
any.value = ""; // the empty value is what the query omits
|
|
21027
|
+
sel.appendChild(any);
|
|
21028
|
+
values.forEach(function (v) {
|
|
21029
|
+
var opt = el("option", null, labelOf(v));
|
|
21030
|
+
opt.value = v;
|
|
21031
|
+
sel.appendChild(opt);
|
|
21032
|
+
});
|
|
21033
|
+
sel.value = selected;
|
|
21034
|
+
}
|
|
21035
|
+
|
|
20536
21036
|
// What the listed runs cost together — the accumulating number an operator
|
|
20537
21037
|
// reads to decide how often CI should run, so it follows whatever filter
|
|
20538
21038
|
// produced the list. Hidden when no listed run carries a cost at all, since
|
|
20539
21039
|
// a "$0.0000" total would read as "CI is free" rather than "nothing measured".
|
|
20540
21040
|
//
|
|
20541
|
-
// The label names the run count on purpose. The list is capped (
|
|
20542
|
-
// an unqualified "total" would quietly under-report a project's spend the
|
|
21041
|
+
// The label names the run count on purpose. The list is capped (RUNS_LIMIT),
|
|
21042
|
+
// so an unqualified "total" would quietly under-report a project's spend the
|
|
20543
21043
|
// moment it has more runs than that — the one number this feature exists to
|
|
20544
21044
|
// get right.
|
|
20545
21045
|
function renderRunsTotalCost(runs) {
|
|
@@ -20561,14 +21061,38 @@ const CLIENT_JS = `
|
|
|
20561
21061
|
}
|
|
20562
21062
|
}
|
|
20563
21063
|
|
|
21064
|
+
// The project's whole spend over the last 24 hours — deliberately not the
|
|
21065
|
+
// list's window or filter: the two numbers differ by everything that calls
|
|
21066
|
+
// Claude without leaving a run behind, which is why both are here. Hidden
|
|
21067
|
+
// when nothing was reported, since "$0.0000" would claim a free day.
|
|
21068
|
+
function loadRunsSpend() {
|
|
21069
|
+
var span = document.getElementById("runs-spend-24h");
|
|
21070
|
+
span.hidden = true;
|
|
21071
|
+
var token = ++state.spendLoadToken;
|
|
21072
|
+
var since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
21073
|
+
apiFetch("/api/v1/projects/" + encodeURIComponent(state.project) + "/spend?since=" + encodeURIComponent(since))
|
|
21074
|
+
.then(function (data) {
|
|
21075
|
+
if (token !== state.spendLoadToken) return;
|
|
21076
|
+
if (!data.entries.length) return;
|
|
21077
|
+
span.hidden = false;
|
|
21078
|
+
span.textContent = t("runs.spend24h") + " " + costText(data.totalUsd);
|
|
21079
|
+
})
|
|
21080
|
+
.catch(function () { /* the runs list is the page; a missing total must not replace it with an error */ });
|
|
21081
|
+
}
|
|
21082
|
+
|
|
20564
21083
|
function renderRunsList(runs) {
|
|
20565
21084
|
var tbody = document.getElementById("runs-tbody");
|
|
20566
21085
|
clear(tbody);
|
|
20567
21086
|
renderRunsTotalCost(runs);
|
|
21087
|
+
// A full page is almost certainly a truncated one, and under a date filter
|
|
21088
|
+
// that turns the total beside it into a day's spend that stops at the cap.
|
|
21089
|
+
var capped = document.getElementById("runs-capped");
|
|
21090
|
+
capped.hidden = runs.length < RUNS_LIMIT;
|
|
21091
|
+
capped.textContent = t("runs.capped").replace("{n}", RUNS_LIMIT);
|
|
20568
21092
|
var empty = document.getElementById("runs-empty");
|
|
20569
21093
|
if (runs.length === 0) {
|
|
20570
21094
|
empty.hidden = false;
|
|
20571
|
-
empty.textContent = t("runs.none");
|
|
21095
|
+
empty.textContent = t(runsFilterActive() ? "runs.noMatch" : "runs.none");
|
|
20572
21096
|
return;
|
|
20573
21097
|
}
|
|
20574
21098
|
empty.hidden = true;
|
|
@@ -20580,12 +21104,10 @@ const CLIENT_JS = `
|
|
|
20580
21104
|
runCell.appendChild(el("div", "runid", r.id.slice(0, 8)));
|
|
20581
21105
|
var sub = el("div", "subline");
|
|
20582
21106
|
sub.appendChild(ciBadge(r));
|
|
21107
|
+
sub.appendChild(kindChip(r.kind));
|
|
20583
21108
|
if (r.kind === "drift") {
|
|
20584
|
-
sub.appendChild(el("span", "chip kind-chip", t("kind.drift")));
|
|
20585
21109
|
var rowDrift = driftSummary(r);
|
|
20586
21110
|
if (rowDrift) driftChips(rowDrift).forEach(function (chip) { sub.appendChild(chip); });
|
|
20587
|
-
} else {
|
|
20588
|
-
sub.appendChild(el("span", "chip kind-chip", t("kind.run")));
|
|
20589
21111
|
}
|
|
20590
21112
|
runCell.appendChild(sub);
|
|
20591
21113
|
tr.appendChild(runCell);
|
|
@@ -20602,29 +21124,33 @@ const CLIENT_JS = `
|
|
|
20602
21124
|
statusCell.appendChild(runStatusBadge(r));
|
|
20603
21125
|
tr.appendChild(statusCell);
|
|
20604
21126
|
|
|
20605
|
-
// A drift row counts what the audit found, not what "passed" — the same
|
|
20606
|
-
// ratio its detail page shows. Reading passed/total here printed a
|
|
20607
|
-
// different number for the same run in the two places you would compare.
|
|
20608
|
-
var rowDriftSummary = r.kind === "drift" ? driftSummary(r) : null;
|
|
20609
|
-
var found = rowDriftSummary
|
|
20610
|
-
? rowDriftSummary.testDrift + rowDriftSummary.specChange + rowDriftSummary.unknown
|
|
20611
|
-
: null;
|
|
20612
|
-
var num = rowDriftSummary
|
|
20613
|
-
? found + " / " + rowDriftSummary.specs
|
|
20614
|
-
: r.specs.passed + " / " + r.specs.total;
|
|
20615
|
-
var fillTotal = rowDriftSummary ? rowDriftSummary.specs : r.specs.total;
|
|
20616
|
-
var fillPart = rowDriftSummary ? found : r.specs.passed;
|
|
20617
|
-
|
|
20618
21127
|
var specsCell = document.createElement("td");
|
|
20619
|
-
|
|
20620
|
-
|
|
20621
|
-
|
|
20622
|
-
|
|
20623
|
-
|
|
20624
|
-
|
|
20625
|
-
|
|
20626
|
-
|
|
20627
|
-
|
|
21128
|
+
if (!kindOf(r.kind).verifies) {
|
|
21129
|
+
specsCell.appendChild(el("span", "muted", "—"));
|
|
21130
|
+
} else {
|
|
21131
|
+
// A drift row counts what the audit found, not what "passed" — the same
|
|
21132
|
+
// ratio its detail page shows. Reading passed/total here printed a
|
|
21133
|
+
// different number for the same run in the two places you would compare.
|
|
21134
|
+
var rowDriftSummary = r.kind === "drift" ? driftSummary(r) : null;
|
|
21135
|
+
var found = rowDriftSummary
|
|
21136
|
+
? rowDriftSummary.testDrift + rowDriftSummary.specChange + rowDriftSummary.unknown
|
|
21137
|
+
: null;
|
|
21138
|
+
var num = rowDriftSummary
|
|
21139
|
+
? found + " / " + rowDriftSummary.specs
|
|
21140
|
+
: r.specs.passed + " / " + r.specs.total;
|
|
21141
|
+
var fillTotal = rowDriftSummary ? rowDriftSummary.specs : r.specs.total;
|
|
21142
|
+
var fillPart = rowDriftSummary ? found : r.specs.passed;
|
|
21143
|
+
|
|
21144
|
+
var specsWrap = el("div", "specs");
|
|
21145
|
+
var meter = el("span", "meter" + (rowDriftSummary ? " drift" : ""));
|
|
21146
|
+
var pct = fillTotal > 0 ? Math.round((fillPart / fillTotal) * 100) : 0;
|
|
21147
|
+
var bar = el("i");
|
|
21148
|
+
bar.style.width = pct + "%";
|
|
21149
|
+
meter.appendChild(bar);
|
|
21150
|
+
specsWrap.appendChild(meter);
|
|
21151
|
+
specsWrap.appendChild(el("span", "num muted", num));
|
|
21152
|
+
specsCell.appendChild(specsWrap);
|
|
21153
|
+
}
|
|
20628
21154
|
tr.appendChild(specsCell);
|
|
20629
21155
|
|
|
20630
21156
|
tr.appendChild(el("td", "muted num", costText(r.costUsd)));
|
|
@@ -20633,19 +21159,33 @@ const CLIENT_JS = `
|
|
|
20633
21159
|
});
|
|
20634
21160
|
}
|
|
20635
21161
|
|
|
20636
|
-
|
|
20637
|
-
|
|
20638
|
-
|
|
20639
|
-
|
|
20640
|
-
|
|
21162
|
+
// Compared against the live token before painting, so a slower earlier
|
|
21163
|
+
// response cannot land last: holding an arrow key down in the date box fires
|
|
21164
|
+
// one request per day passed, and the table would end up on the wrong one.
|
|
21165
|
+
function loadRunsList() {
|
|
21166
|
+
var token = ++state.runsLoadToken;
|
|
21167
|
+
document.getElementById("runs-empty").hidden = true;
|
|
21168
|
+
apiFetch(runsQuery())
|
|
21169
|
+
.then(function (data) {
|
|
21170
|
+
if (token !== state.runsLoadToken) return;
|
|
21171
|
+
renderRunsList(data.runs);
|
|
21172
|
+
})
|
|
20641
21173
|
.catch(function (err) {
|
|
20642
|
-
|
|
20643
|
-
|
|
21174
|
+
if (token !== state.runsLoadToken) return;
|
|
21175
|
+
renderRunsList([]);
|
|
21176
|
+
var empty = document.getElementById("runs-empty");
|
|
20644
21177
|
empty.hidden = false;
|
|
20645
21178
|
empty.textContent = "Error loading runs: " + err.message;
|
|
20646
21179
|
});
|
|
20647
21180
|
}
|
|
20648
21181
|
|
|
21182
|
+
// Entering the view or refreshing it. The spend readout is a fixed window,
|
|
21183
|
+
// so a filter change reloads the list alone.
|
|
21184
|
+
function loadRuns() {
|
|
21185
|
+
loadRunsSpend();
|
|
21186
|
+
loadRunsList();
|
|
21187
|
+
}
|
|
21188
|
+
|
|
20649
21189
|
// ── run detail: header ──────────────────────────────────────────────
|
|
20650
21190
|
|
|
20651
21191
|
function renderRunHead(run) {
|
|
@@ -20663,7 +21203,7 @@ const CLIENT_JS = `
|
|
|
20663
21203
|
// What kind of run this is, said once. The spec cards below used to repeat
|
|
20664
21204
|
// it per row, which read as "this spec was drift-audited" — a property of
|
|
20665
21205
|
// the run described as if it varied spec to spec. Same chip as the run list.
|
|
20666
|
-
sub.appendChild(
|
|
21206
|
+
sub.appendChild(kindChip(run.kind));
|
|
20667
21207
|
idblock.appendChild(sub);
|
|
20668
21208
|
head.appendChild(idblock);
|
|
20669
21209
|
|
|
@@ -20697,7 +21237,7 @@ const CLIENT_JS = `
|
|
|
20697
21237
|
driftBox.appendChild(el("div", "muted", ratio));
|
|
20698
21238
|
metaItem(t("meta.drift"), driftBox);
|
|
20699
21239
|
}
|
|
20700
|
-
} else {
|
|
21240
|
+
} else if (kindOf(run.kind).verifies) {
|
|
20701
21241
|
metaItem(t("meta.specs"), run.specs.passed + " / " + run.specs.total + " " + t("meta.passed"));
|
|
20702
21242
|
}
|
|
20703
21243
|
// Everything this run spent on Claude — live browsing, triage, the audit a
|
|
@@ -20806,6 +21346,12 @@ const CLIENT_JS = `
|
|
|
20806
21346
|
var a = r.analysis;
|
|
20807
21347
|
var head = el("div", "analysis-head");
|
|
20808
21348
|
head.appendChild(labelChip(a.label));
|
|
21349
|
+
// Which repair a SPEC_CHANGE needs — delete the spec, or rewrite it. The
|
|
21350
|
+
// label is re-checked rather than trusted: this chip means nothing beside
|
|
21351
|
+
// any other one, however the row reached the browser.
|
|
21352
|
+
if (a.label === "SPEC_CHANGE" && a.specChangeKind) {
|
|
21353
|
+
head.appendChild(el("span", "chip spec-change-chip", t("diag.specChangeKind." + a.specChangeKind)));
|
|
21354
|
+
}
|
|
20809
21355
|
head.appendChild(el("span", "conf", Math.round(a.confidence * 100) + "%"));
|
|
20810
21356
|
wrap.appendChild(head);
|
|
20811
21357
|
var kv = el("div", "analysis-kv");
|
|
@@ -22000,8 +22546,12 @@ const CLIENT_JS = `
|
|
|
22000
22546
|
// runs and no deploys to judge). One runs page answers both: runId -> CI URL,
|
|
22001
22547
|
// and the profiles a run was actually recorded under. A run pushed without a
|
|
22002
22548
|
// profile lands in "default", exactly as the ledger stores it.
|
|
22549
|
+
//
|
|
22550
|
+
// Only the two kinds a ledger entry can point at, so recordings — which
|
|
22551
|
+
// advance no ledger and are never looked up here — cannot crowd them out of
|
|
22552
|
+
// the window.
|
|
22003
22553
|
function fetchRunIndex() {
|
|
22004
|
-
return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&limit=200")
|
|
22554
|
+
return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&kind=run,drift&limit=200")
|
|
22005
22555
|
.then(function (data) {
|
|
22006
22556
|
var urls = {};
|
|
22007
22557
|
var profiles = [];
|
|
@@ -23464,6 +24014,14 @@ const CLIENT_JS = `
|
|
|
23464
24014
|
|
|
23465
24015
|
document.getElementById("detail-back").addEventListener("click", function () { location.hash = "#/runs"; });
|
|
23466
24016
|
document.getElementById("runs-refresh").addEventListener("click", loadRuns);
|
|
24017
|
+
// Every control refetches: the window and the kinds are the server's to
|
|
24018
|
+
// apply, so filtering client-side would only narrow the same capped page.
|
|
24019
|
+
[["runs-f-date", "date"], ["runs-f-kind", "kind"], ["runs-f-status", "status"]].forEach(function (pair) {
|
|
24020
|
+
document.getElementById(pair[0]).addEventListener("change", function (e) {
|
|
24021
|
+
runsFilter[pair[1]] = e.target.value;
|
|
24022
|
+
loadRunsList();
|
|
24023
|
+
});
|
|
24024
|
+
});
|
|
23467
24025
|
document.getElementById("learn-run").addEventListener("click", startLearn);
|
|
23468
24026
|
document.getElementById("jobs-refresh").addEventListener("click", loadJobs);
|
|
23469
24027
|
// Wrap so the click PointerEvent isn't passed as loadSecrets' statusAfter
|
|
@@ -23734,7 +24292,8 @@ function createLearningWorker(deps) {
|
|
|
23734
24292
|
const runLimit = job.input.runLimit > 0 ? job.input.runLimit : DEFAULT_RUN_LIMIT;
|
|
23735
24293
|
const runs = await storage.runs.list({
|
|
23736
24294
|
project: job.project,
|
|
23737
|
-
limit: runLimit
|
|
24295
|
+
limit: runLimit,
|
|
24296
|
+
kinds: ["run"]
|
|
23738
24297
|
});
|
|
23739
24298
|
const cases = [];
|
|
23740
24299
|
let excluded = 0;
|
|
@@ -23933,6 +24492,10 @@ function registerRoutes(router, config, queue) {
|
|
|
23933
24492
|
router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
|
|
23934
24493
|
router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
|
|
23935
24494
|
router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
|
|
24495
|
+
router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
|
|
24496
|
+
router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
|
|
24497
|
+
router.post("/api/v1/projects/:project/spend", createRecordSpendHandler(storage));
|
|
24498
|
+
router.get("/api/v1/projects/:project/spend", createGetSpendHandler(storage));
|
|
23936
24499
|
const sessionConfig = {
|
|
23937
24500
|
store: storage.sessions,
|
|
23938
24501
|
encryptionKey: config.encryptionKey
|
|
@@ -23967,6 +24530,16 @@ function registerRoutes(router, config, queue) {
|
|
|
23967
24530
|
}
|
|
23968
24531
|
//#endregion
|
|
23969
24532
|
//#region src/hub/core/storage/file/fs-helpers.ts
|
|
24533
|
+
/**
|
|
24534
|
+
* Defense-in-depth for a name this layer joins into a file path. The API's
|
|
24535
|
+
* `SAFE_SEGMENT` (api/validate.ts) is deliberately stricter — it also fixes a
|
|
24536
|
+
* charset and a length — and stays the rule for what a client may name; this
|
|
24537
|
+
* only refuses traversal, so it also covers callers that never came through
|
|
24538
|
+
* HTTP (tests, a library embedding the hub).
|
|
24539
|
+
*/
|
|
24540
|
+
function assertSafeName(value, label) {
|
|
24541
|
+
if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
|
|
24542
|
+
}
|
|
23970
24543
|
/** Read and JSON-parse a file, returning `null` when it doesn't exist. Malformed JSON throws. */
|
|
23971
24544
|
async function readJson(path) {
|
|
23972
24545
|
let raw;
|
|
@@ -24106,6 +24679,8 @@ function isNotFound(err) {
|
|
|
24106
24679
|
* drift-ledger/<project>/<branch>.json (DriftLedger, no profile)
|
|
24107
24680
|
* deploys/<project>/<profile>/log.json (DeployLog, ring-buffered)
|
|
24108
24681
|
* deploys/<project>/<profile>/touch.json (SpecTouchIndex derived from the log)
|
|
24682
|
+
* acks/<project>/<profile>/<name>.json (Ack: a consumer's acted-on keys)
|
|
24683
|
+
* spend/<project>.json (SpendLog, pruned to its retention window)
|
|
24109
24684
|
*
|
|
24110
24685
|
* IDs and names are validated by their callers (run ids are server-minted
|
|
24111
24686
|
* UUIDs; project/profile/name come from validated request params) before
|
|
@@ -24197,6 +24772,46 @@ function deployTouchIndexPath(root, project, profile) {
|
|
|
24197
24772
|
function specLocksPath(root, project, profile) {
|
|
24198
24773
|
return join(root, "locks", project, profile, "locks.json");
|
|
24199
24774
|
}
|
|
24775
|
+
function ackPath(root, project, profile, name) {
|
|
24776
|
+
return join(root, "acks", project, profile, `${name}.json`);
|
|
24777
|
+
}
|
|
24778
|
+
function spendPath(root, project) {
|
|
24779
|
+
return join(root, "spend", `${project}.json`);
|
|
24780
|
+
}
|
|
24781
|
+
//#endregion
|
|
24782
|
+
//#region src/hub/core/storage/file/ack-store.ts
|
|
24783
|
+
function assertSafeKey(project, profile, name) {
|
|
24784
|
+
assertSafeName(project, "project");
|
|
24785
|
+
assertSafeName(profile, "profile");
|
|
24786
|
+
assertSafeName(name, "name");
|
|
24787
|
+
}
|
|
24788
|
+
/**
|
|
24789
|
+
* Ack storage: one JSON document per (project, profile, name). A write
|
|
24790
|
+
* replaces the document outright, so unlike the ledgers there is no
|
|
24791
|
+
* read-modify-write to serialize — but it still goes through `writeJson`'s
|
|
24792
|
+
* temp-then-rename, so a concurrent reader never sees a half-written set.
|
|
24793
|
+
*/
|
|
24794
|
+
function createFileAckStore(root) {
|
|
24795
|
+
return {
|
|
24796
|
+
async get(project, profile, name) {
|
|
24797
|
+
assertSafeKey(project, profile, name);
|
|
24798
|
+
const parsed = AckSchema.safeParse(await readJson(ackPath(root, project, profile, name)));
|
|
24799
|
+
return parsed.success ? parsed.data : {
|
|
24800
|
+
keys: [],
|
|
24801
|
+
at: null
|
|
24802
|
+
};
|
|
24803
|
+
},
|
|
24804
|
+
async put(project, profile, name, keys) {
|
|
24805
|
+
assertSafeKey(project, profile, name);
|
|
24806
|
+
const ack = {
|
|
24807
|
+
keys,
|
|
24808
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
24809
|
+
};
|
|
24810
|
+
await writeJson(ackPath(root, project, profile, name), ack);
|
|
24811
|
+
return ack;
|
|
24812
|
+
}
|
|
24813
|
+
};
|
|
24814
|
+
}
|
|
24200
24815
|
//#endregion
|
|
24201
24816
|
//#region src/hub/core/storage/file/artifact-store.ts
|
|
24202
24817
|
/**
|
|
@@ -24367,14 +24982,6 @@ function createFileSpecLedgerStore(root) {
|
|
|
24367
24982
|
//#endregion
|
|
24368
24983
|
//#region src/hub/core/storage/file/perspectives-store.ts
|
|
24369
24984
|
/**
|
|
24370
|
-
* Defense-in-depth path validation: the HTTP layer already checks the project
|
|
24371
|
-
* segment, but this builds a file path from it, so it re-checks rather than
|
|
24372
|
-
* trusting callers. Mirrors the sibling prompt/secret stores.
|
|
24373
|
-
*/
|
|
24374
|
-
function assertSafeName$2(value, label) {
|
|
24375
|
-
if (value.length === 0 || value.includes("/") || value.includes("\\") || value.split(/[\\/]/).includes("..") || value === ".") throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
|
|
24376
|
-
}
|
|
24377
|
-
/**
|
|
24378
24985
|
* Perspectives storage: one JSON document per project, plain UTF-8 with no
|
|
24379
24986
|
* encryption (an inventory of what is tested is not a secret). No meta file —
|
|
24380
24987
|
* the document's own `generatedAt` is its timestamp.
|
|
@@ -24382,19 +24989,19 @@ function assertSafeName$2(value, label) {
|
|
|
24382
24989
|
function createFilePerspectivesStore(root) {
|
|
24383
24990
|
return {
|
|
24384
24991
|
async put(project, blob) {
|
|
24385
|
-
assertSafeName
|
|
24992
|
+
assertSafeName(project, "project");
|
|
24386
24993
|
await writeBytes(perspectivesPath(root, project), blob);
|
|
24387
24994
|
},
|
|
24388
24995
|
async get(project) {
|
|
24389
|
-
assertSafeName
|
|
24996
|
+
assertSafeName(project, "project");
|
|
24390
24997
|
return readBytesOrNull(perspectivesPath(root, project));
|
|
24391
24998
|
},
|
|
24392
24999
|
async update(project, mutate) {
|
|
24393
|
-
assertSafeName
|
|
25000
|
+
assertSafeName(project, "project");
|
|
24394
25001
|
await updateJson(perspectivesPath(root, project), mutate);
|
|
24395
25002
|
},
|
|
24396
25003
|
async delete(project) {
|
|
24397
|
-
assertSafeName
|
|
25004
|
+
assertSafeName(project, "project");
|
|
24398
25005
|
await removePath(perspectivesPath(root, project));
|
|
24399
25006
|
}
|
|
24400
25007
|
};
|
|
@@ -24402,15 +25009,6 @@ function createFilePerspectivesStore(root) {
|
|
|
24402
25009
|
//#endregion
|
|
24403
25010
|
//#region src/hub/core/storage/file/prompt-store.ts
|
|
24404
25011
|
/**
|
|
24405
|
-
* Defense-in-depth path validation: the HTTP layer already checks project/name,
|
|
24406
|
-
* but this builds file paths from them, so it re-checks rather than trusting
|
|
24407
|
-
* callers. (Which names are allowed at all is the handler's job — this only
|
|
24408
|
-
* guards against path traversal.)
|
|
24409
|
-
*/
|
|
24410
|
-
function assertSafeName$1(value, label) {
|
|
24411
|
-
if (value.length === 0 || value.includes("/") || value.includes("\\") || value.split(/[\\/]/).includes("..") || value === ".") throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
|
|
24412
|
-
}
|
|
24413
|
-
/**
|
|
24414
25012
|
* Prompt storage, project-scoped (not per-profile — prompts are project-wide).
|
|
24415
25013
|
* The blob is plain UTF-8 text (Markdown or custom prompt JSON) with no encryption,
|
|
24416
25014
|
* so this works whether or not `CCQA_HUB_ENCRYPTION_KEY` is configured.
|
|
@@ -24418,8 +25016,8 @@ function assertSafeName$1(value, label) {
|
|
|
24418
25016
|
function createFilePromptStore(root) {
|
|
24419
25017
|
return {
|
|
24420
25018
|
async put(project, name, blob, meta = {}) {
|
|
24421
|
-
assertSafeName
|
|
24422
|
-
assertSafeName
|
|
25019
|
+
assertSafeName(project, "project");
|
|
25020
|
+
assertSafeName(name, "name");
|
|
24423
25021
|
await writeJson(promptMetaPath(root, project, name), {
|
|
24424
25022
|
meta,
|
|
24425
25023
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -24427,8 +25025,8 @@ function createFilePromptStore(root) {
|
|
|
24427
25025
|
await writeBytes(promptBlobPath(root, project, name), blob);
|
|
24428
25026
|
},
|
|
24429
25027
|
async get(project, name) {
|
|
24430
|
-
assertSafeName
|
|
24431
|
-
assertSafeName
|
|
25028
|
+
assertSafeName(project, "project");
|
|
25029
|
+
assertSafeName(name, "name");
|
|
24432
25030
|
const blob = await readBytesOrNull(promptBlobPath(root, project, name));
|
|
24433
25031
|
if (!blob) return null;
|
|
24434
25032
|
return {
|
|
@@ -24437,7 +25035,7 @@ function createFilePromptStore(root) {
|
|
|
24437
25035
|
};
|
|
24438
25036
|
},
|
|
24439
25037
|
async list(project) {
|
|
24440
|
-
assertSafeName
|
|
25038
|
+
assertSafeName(project, "project");
|
|
24441
25039
|
const names = (await listDirOrEmpty(promptProjectDir(root, project))).filter((f) => f.endsWith(".txt")).map((f) => f.slice(0, -4));
|
|
24442
25040
|
const out = [];
|
|
24443
25041
|
for (const name of names) {
|
|
@@ -24451,8 +25049,8 @@ function createFilePromptStore(root) {
|
|
|
24451
25049
|
return out;
|
|
24452
25050
|
},
|
|
24453
25051
|
async delete(project, name) {
|
|
24454
|
-
assertSafeName
|
|
24455
|
-
assertSafeName
|
|
25052
|
+
assertSafeName(project, "project");
|
|
25053
|
+
assertSafeName(name, "name");
|
|
24456
25054
|
await removePath(promptBlobPath(root, project, name));
|
|
24457
25055
|
await removePath(promptMetaPath(root, project, name));
|
|
24458
25056
|
},
|
|
@@ -24462,6 +25060,22 @@ function createFilePromptStore(root) {
|
|
|
24462
25060
|
};
|
|
24463
25061
|
}
|
|
24464
25062
|
//#endregion
|
|
25063
|
+
//#region src/hub/core/storage/file/time-window.ts
|
|
25064
|
+
/**
|
|
25065
|
+
* The half-open `[since, until)` window the run and spend listings both take,
|
|
25066
|
+
* as a predicate over a record's ISO-8601 timestamp. Compared as instants, not
|
|
25067
|
+
* as strings: the ends come off the wire in whatever offset the caller wrote
|
|
25068
|
+
* them in, while the stored field is always UTC.
|
|
25069
|
+
*/
|
|
25070
|
+
function windowFilter(q) {
|
|
25071
|
+
const from = q.since === void 0 ? null : Date.parse(q.since);
|
|
25072
|
+
const to = q.until === void 0 ? null : Date.parse(q.until);
|
|
25073
|
+
return (at) => {
|
|
25074
|
+
const instant = Date.parse(at);
|
|
25075
|
+
return (from === null || instant >= from) && (to === null || instant < to);
|
|
25076
|
+
};
|
|
25077
|
+
}
|
|
25078
|
+
//#endregion
|
|
24465
25079
|
//#region src/hub/core/storage/file/run-store.ts
|
|
24466
25080
|
/**
|
|
24467
25081
|
* Read one run record for an aggregate scan, tolerating a bad entry: a
|
|
@@ -24494,8 +25108,12 @@ function createFileRunStore(root) {
|
|
|
24494
25108
|
};
|
|
24495
25109
|
});
|
|
24496
25110
|
},
|
|
24497
|
-
async list({ project, branch, status, limit }) {
|
|
25111
|
+
async list({ project, branch, status, kinds, since, until, limit }) {
|
|
24498
25112
|
const ids = await listSubdirsOrEmpty(runsDir(root));
|
|
25113
|
+
const inWindow = windowFilter({
|
|
25114
|
+
since,
|
|
25115
|
+
until
|
|
25116
|
+
});
|
|
24499
25117
|
const runs = [];
|
|
24500
25118
|
for (const id of ids) {
|
|
24501
25119
|
const run = await readRunOrSkip(root, id);
|
|
@@ -24503,6 +25121,8 @@ function createFileRunStore(root) {
|
|
|
24503
25121
|
if (project !== void 0 && run.project !== project) continue;
|
|
24504
25122
|
if (branch !== void 0 && run.branch !== branch) continue;
|
|
24505
25123
|
if (status !== void 0 && run.status !== status) continue;
|
|
25124
|
+
if (kinds !== void 0 && !kinds.includes(run.kind)) continue;
|
|
25125
|
+
if (!inWindow(run.createdAt)) continue;
|
|
24506
25126
|
runs.push(run);
|
|
24507
25127
|
}
|
|
24508
25128
|
runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
@@ -24521,14 +25141,6 @@ function createFileRunStore(root) {
|
|
|
24521
25141
|
}
|
|
24522
25142
|
//#endregion
|
|
24523
25143
|
//#region src/hub/core/storage/file/secret-store.ts
|
|
24524
|
-
/**
|
|
24525
|
-
* Defense-in-depth: project/profile/name are expected to already be validated
|
|
24526
|
-
* by the HTTP layer (`requireSafeSegment`), but this store builds file paths
|
|
24527
|
-
* directly from them, so it re-checks rather than trusting callers blindly.
|
|
24528
|
-
*/
|
|
24529
|
-
function assertSafeName(value, label) {
|
|
24530
|
-
if (value.length === 0 || value.includes("/") || value.includes("\\") || value.split(/[\\/]/).includes("..") || value === ".") throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
|
|
24531
|
-
}
|
|
24532
25144
|
function assertSafeScope(scope) {
|
|
24533
25145
|
assertSafeName(scope.project, "project");
|
|
24534
25146
|
assertSafeName(scope.profile, "profile");
|
|
@@ -24583,6 +25195,55 @@ function createFileSecretStore(root, kind) {
|
|
|
24583
25195
|
}
|
|
24584
25196
|
};
|
|
24585
25197
|
}
|
|
25198
|
+
/** Spend storage: one JSON document per project, pruned as it is appended to. */
|
|
25199
|
+
function createFileSpendStore(root) {
|
|
25200
|
+
return {
|
|
25201
|
+
async append(project, entry) {
|
|
25202
|
+
assertSafeName(project, "project");
|
|
25203
|
+
const path = spendPath(root, project);
|
|
25204
|
+
const cutoff = Date.now() - 2160 * 60 * 60 * 1e3;
|
|
25205
|
+
await updateJson(path, (current) => {
|
|
25206
|
+
return { entries: [...readEntries(current, path).filter((e) => Date.parse(e.at) >= cutoff && !supersededBy(e, entry)), entry] };
|
|
25207
|
+
});
|
|
25208
|
+
return entry;
|
|
25209
|
+
},
|
|
25210
|
+
async list(project, window) {
|
|
25211
|
+
assertSafeName(project, "project");
|
|
25212
|
+
const path = spendPath(root, project);
|
|
25213
|
+
const inWindow = windowFilter(window);
|
|
25214
|
+
return readEntries(await readJson(path), path).filter((e) => inWindow(e.at)).sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
|
25215
|
+
}
|
|
25216
|
+
};
|
|
25217
|
+
}
|
|
25218
|
+
/**
|
|
25219
|
+
* A second push from the same CI run under the same label replaces the first
|
|
25220
|
+
* rather than adding to it: a retried job really does spend again, but it also
|
|
25221
|
+
* rewrites its cost file from scratch, so its new total is the whole of it.
|
|
25222
|
+
*/
|
|
25223
|
+
function supersededBy(stored, incoming) {
|
|
25224
|
+
return incoming.ciRunId !== void 0 && stored.ciRunId === incoming.ciRunId && stored.label === incoming.label;
|
|
25225
|
+
}
|
|
25226
|
+
/**
|
|
25227
|
+
* Entries parsed one at a time, keeping the survivors: a whole-document parse
|
|
25228
|
+
* would answer "this project spent nothing" for one bad entry, and a budget
|
|
25229
|
+
* reads that as zero rather than as an error. What is lost is logged, since
|
|
25230
|
+
* nothing else would ever say so.
|
|
25231
|
+
*/
|
|
25232
|
+
function readEntries(raw, path) {
|
|
25233
|
+
if (raw === null || raw === void 0) return [];
|
|
25234
|
+
const stored = raw.entries;
|
|
25235
|
+
if (!Array.isArray(stored)) {
|
|
25236
|
+
console.error(`hub: spend log at ${path} is not a spend document; ignoring what it holds`);
|
|
25237
|
+
return [];
|
|
25238
|
+
}
|
|
25239
|
+
const entries = [];
|
|
25240
|
+
for (const value of stored) {
|
|
25241
|
+
const parsed = SpendEntrySchema.safeParse(value);
|
|
25242
|
+
if (parsed.success) entries.push(parsed.data);
|
|
25243
|
+
}
|
|
25244
|
+
if (entries.length < stored.length) console.error(`hub: skipping ${stored.length - entries.length} unreadable spend entries in ${path}`);
|
|
25245
|
+
return entries;
|
|
25246
|
+
}
|
|
24586
25247
|
//#endregion
|
|
24587
25248
|
//#region src/hub/core/storage/file/triage-store.ts
|
|
24588
25249
|
function createFileTriageStore(root) {
|
|
@@ -24622,7 +25283,9 @@ function createFileHubStorage(dataDir) {
|
|
|
24622
25283
|
ledger: createFileSpecLedgerStore(dataDir),
|
|
24623
25284
|
driftLedger: createFileDriftLedgerStore(dataDir),
|
|
24624
25285
|
deploys: createFileDeployStore(dataDir),
|
|
24625
|
-
locks: createFileLockStore(dataDir)
|
|
25286
|
+
locks: createFileLockStore(dataDir),
|
|
25287
|
+
acks: createFileAckStore(dataDir),
|
|
25288
|
+
spend: createFileSpendStore(dataDir)
|
|
24626
25289
|
};
|
|
24627
25290
|
}
|
|
24628
25291
|
//#endregion
|