ccqa 1.23.0 → 1.25.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 +608 -231
- package/dist/hub-client/index.d.mts +17 -2
- 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(),
|
|
@@ -4556,6 +4610,7 @@ const PerspectiveSpecSchema = z.object({
|
|
|
4556
4610
|
testCondition: z.string().optional(),
|
|
4557
4611
|
preconditions: z.array(z.string().min(1)).optional(),
|
|
4558
4612
|
status: PerspectiveStatusSchema,
|
|
4613
|
+
changedAt: z.string().optional(),
|
|
4559
4614
|
note: z.string().optional()
|
|
4560
4615
|
}).strip();
|
|
4561
4616
|
const PerspectiveFeatureSchema = z.object({
|
|
@@ -6737,7 +6792,7 @@ z.object({
|
|
|
6737
6792
|
profile: z.string().nullable(),
|
|
6738
6793
|
branch: z.string().nullable(),
|
|
6739
6794
|
status: RunStatusSchema,
|
|
6740
|
-
kind:
|
|
6795
|
+
kind: ReportKindSchema.default("run"),
|
|
6741
6796
|
drift: z.object({
|
|
6742
6797
|
specs: z.number(),
|
|
6743
6798
|
testDrift: z.number(),
|
|
@@ -7038,6 +7093,7 @@ const SpecRerunSchema = z.object({
|
|
|
7038
7093
|
driftLabel: DriftLabelSchema.exclude(["UNKNOWN"]).optional(),
|
|
7039
7094
|
auditAssumedReached: RerunUnknownReasonSchema.optional(),
|
|
7040
7095
|
executionAssumedReached: RerunUnknownReasonSchema.optional(),
|
|
7096
|
+
specChangedSince: z.string().optional(),
|
|
7041
7097
|
heldBy: SpecLockSchema.nullable(),
|
|
7042
7098
|
lastRun: SpecLedgerEntrySchema.nullable(),
|
|
7043
7099
|
lastGreen: SpecLedgerEntrySchema.nullable(),
|
|
@@ -7106,6 +7162,7 @@ z.object({
|
|
|
7106
7162
|
const SpecDriftEntrySchema = z.object({
|
|
7107
7163
|
label: DriftLabelSchema.nullable(),
|
|
7108
7164
|
surface: DriftSurfaceSchema.optional(),
|
|
7165
|
+
specChangeKind: SpecChangeKindSchema.optional(),
|
|
7109
7166
|
confidence: z.number().optional(),
|
|
7110
7167
|
headline: z.string().optional(),
|
|
7111
7168
|
gitHead: z.string(),
|
|
@@ -7156,6 +7213,24 @@ const CreateLearningJobRequestSchema = z.object({
|
|
|
7156
7213
|
profile: z.string(),
|
|
7157
7214
|
runLimit: z.number().int().positive().max(1e3).optional()
|
|
7158
7215
|
});
|
|
7216
|
+
const MAX_ACK_KEYS = 5e3;
|
|
7217
|
+
const MAX_ACK_KEY_LENGTH = 256;
|
|
7218
|
+
/**
|
|
7219
|
+
* A named set of opaque keys a consumer has already acted on, as stored (see
|
|
7220
|
+
* `AckStore`). `at` is null only for a set that was never written — an ack
|
|
7221
|
+
* nobody has recorded yet reads as empty rather than missing.
|
|
7222
|
+
*/
|
|
7223
|
+
const AckSchema = z.object({
|
|
7224
|
+
keys: z.array(z.string()),
|
|
7225
|
+
at: z.string().nullable()
|
|
7226
|
+
});
|
|
7227
|
+
/** Body of `PUT /projects/:project/acks/:name?profile=` — the whole set, not a delta. */
|
|
7228
|
+
const PutAckRequestSchema = z.object({ keys: z.array(z.string().min(1).max(MAX_ACK_KEY_LENGTH)).max(MAX_ACK_KEYS) });
|
|
7229
|
+
AckSchema.extend({
|
|
7230
|
+
project: z.string(),
|
|
7231
|
+
profile: z.string(),
|
|
7232
|
+
name: z.string()
|
|
7233
|
+
});
|
|
7159
7234
|
//#endregion
|
|
7160
7235
|
//#region src/run/hub-selection.ts
|
|
7161
7236
|
/**
|
|
@@ -10991,14 +11066,9 @@ async function generateAgentBrowserTest(ctx) {
|
|
|
10991
11066
|
blank();
|
|
10992
11067
|
const agentBrowserSession = fix.useSnapshot ? `ccqa-generate-${Date.now()}` : void 0;
|
|
10993
11068
|
const runVitestForSession = (path) => runVitest(path, agentBrowserSession);
|
|
10994
|
-
let signalHandler = null;
|
|
10995
11069
|
if (agentBrowserSession) {
|
|
10996
11070
|
await closeSession(agentBrowserSession);
|
|
10997
|
-
|
|
10998
|
-
closeSession(agentBrowserSession).finally(() => process.exit(130));
|
|
10999
|
-
};
|
|
11000
|
-
process.once("SIGINT", signalHandler);
|
|
11001
|
-
process.once("SIGTERM", signalHandler);
|
|
11071
|
+
ctx.teardown?.trackSession(agentBrowserSession);
|
|
11002
11072
|
}
|
|
11003
11073
|
try {
|
|
11004
11074
|
const initialRun = await timedPhase("vitest run #1", () => runVitestForSession(scriptPath), "run");
|
|
@@ -11025,11 +11095,10 @@ async function generateAgentBrowserTest(ctx) {
|
|
|
11025
11095
|
passed
|
|
11026
11096
|
};
|
|
11027
11097
|
} finally {
|
|
11028
|
-
if (
|
|
11029
|
-
|
|
11030
|
-
|
|
11098
|
+
if (agentBrowserSession) {
|
|
11099
|
+
ctx.teardown?.untrackSession(agentBrowserSession);
|
|
11100
|
+
await closeSession(agentBrowserSession);
|
|
11031
11101
|
}
|
|
11032
|
-
if (agentBrowserSession) await closeSession(agentBrowserSession);
|
|
11033
11102
|
}
|
|
11034
11103
|
}
|
|
11035
11104
|
/**
|
|
@@ -11834,6 +11903,75 @@ function createIncrementalReport(reportDir, envelope, sink, costNow) {
|
|
|
11834
11903
|
};
|
|
11835
11904
|
}
|
|
11836
11905
|
//#endregion
|
|
11906
|
+
//#region src/cli/open-hub-run.ts
|
|
11907
|
+
/** The one wording for "this flag needs a hub, and none is configured". */
|
|
11908
|
+
function needsHubConnection(flag) {
|
|
11909
|
+
return `${flag} requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)`;
|
|
11910
|
+
}
|
|
11911
|
+
const REPORT_TO_HUB_NEEDS_CONNECTION = needsHubConnection("--report-to-hub");
|
|
11912
|
+
/**
|
|
11913
|
+
* The connection a `--report-to-hub` command publishes through. Both CLI
|
|
11914
|
+
* callers check this before the expensive part — the audit's sweep, the
|
|
11915
|
+
* recording's spec lock and browser — so a job that cannot publish spends
|
|
11916
|
+
* nothing finding out.
|
|
11917
|
+
*/
|
|
11918
|
+
function requireReportToHubConnection(conn) {
|
|
11919
|
+
if (conn) return conn;
|
|
11920
|
+
error(REPORT_TO_HUB_NEEDS_CONNECTION);
|
|
11921
|
+
process.exit(2);
|
|
11922
|
+
}
|
|
11923
|
+
/**
|
|
11924
|
+
* Open the run a `--report-to-hub` command patches into. Failure is fatal: a
|
|
11925
|
+
* job that asked to publish and cannot reach the hub has not done what it was
|
|
11926
|
+
* told. Thrown rather than exited, so a caller's `finally` still runs (the
|
|
11927
|
+
* audit releases its spec claims there). Not retried: a dropped response after
|
|
11928
|
+
* the hub committed would leave a second orphan running run.
|
|
11929
|
+
*/
|
|
11930
|
+
async function openHubRun(kind, conn, cwd, profile) {
|
|
11931
|
+
const [branch, gitHead] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
|
|
11932
|
+
const ciRunId = githubRunId();
|
|
11933
|
+
const runUrl = githubRunUrl();
|
|
11934
|
+
try {
|
|
11935
|
+
const run = await conn.hub.openRun({
|
|
11936
|
+
project: conn.project,
|
|
11937
|
+
kind,
|
|
11938
|
+
...branch ? { branch } : {},
|
|
11939
|
+
...profile ? { profile } : {},
|
|
11940
|
+
...gitHead ? { gitHead } : {},
|
|
11941
|
+
...ciRunId ? { ciRunId } : {},
|
|
11942
|
+
...runUrl ? { runUrl } : {}
|
|
11943
|
+
});
|
|
11944
|
+
return {
|
|
11945
|
+
hub: conn.hub,
|
|
11946
|
+
kind,
|
|
11947
|
+
runId: run.id,
|
|
11948
|
+
gitHead
|
|
11949
|
+
};
|
|
11950
|
+
} catch (err) {
|
|
11951
|
+
throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
|
|
11952
|
+
}
|
|
11953
|
+
}
|
|
11954
|
+
/**
|
|
11955
|
+
* Close an open run with its final rows and envelope, answering whether it
|
|
11956
|
+
* closed. A failed seal leaves the run `running` with whatever rows landed — a
|
|
11957
|
+
* wrong record, not merely a missing one — so a CLI caller must not exit clean.
|
|
11958
|
+
* The exit is the caller's to make rather than taken here: `ccqa record` seals
|
|
11959
|
+
* from inside a teardown finalizer, and exiting there would skip the
|
|
11960
|
+
* browser-session reap queued behind it.
|
|
11961
|
+
*/
|
|
11962
|
+
async function sealHubRun(push, body) {
|
|
11963
|
+
try {
|
|
11964
|
+
await push.hub.patchRun(push.runId, {
|
|
11965
|
+
...body,
|
|
11966
|
+
done: true
|
|
11967
|
+
});
|
|
11968
|
+
return true;
|
|
11969
|
+
} catch (err) {
|
|
11970
|
+
error(`hub: could not close the ${push.kind} run ${push.runId}: ${errMessage(err)}`);
|
|
11971
|
+
return false;
|
|
11972
|
+
}
|
|
11973
|
+
}
|
|
11974
|
+
//#endregion
|
|
11837
11975
|
//#region src/prompts/agent-update.ts
|
|
11838
11976
|
/**
|
|
11839
11977
|
* Build the prompts used by the `--learn-*-prompt` flags to refresh
|
|
@@ -12371,9 +12509,9 @@ async function executeRun(targets, opts) {
|
|
|
12371
12509
|
});
|
|
12372
12510
|
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>");
|
|
12373
12511
|
const ledgerHub = wantsLastGreen ? hubCtx : null;
|
|
12374
|
-
if (rerunProfile !== null && hubCtx == null) throw new RunUsageError("--only-hub-rerun-needed
|
|
12375
|
-
if (opts.reportToHub && hubCtx == null) throw new RunUsageError(
|
|
12376
|
-
if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError("--learn-hub-live-prompt
|
|
12512
|
+
if (rerunProfile !== null && hubCtx == null) throw new RunUsageError(needsHubConnection("--only-hub-rerun-needed"));
|
|
12513
|
+
if (opts.reportToHub && hubCtx == null) throw new RunUsageError(REPORT_TO_HUB_NEEDS_CONNECTION);
|
|
12514
|
+
if (opts.learnHubLivePrompt && hubCtx == null) throw new RunUsageError(needsHubConnection("--learn-hub-live-prompt"));
|
|
12377
12515
|
const [customPrompt, triageUserPrompt, ledgerEntries, rerunReport, fetchedDeployHead] = await Promise.all([
|
|
12378
12516
|
forExecution ? fetchCustomPrompt(hubCtx) : null,
|
|
12379
12517
|
forExecution ? fetchTriageUserPrompt(hubCtx) : null,
|
|
@@ -13135,7 +13273,16 @@ async function streamFiltered(source, sink, capture) {
|
|
|
13135
13273
|
function createRunTeardown() {
|
|
13136
13274
|
const sessions = /* @__PURE__ */ new Set();
|
|
13137
13275
|
const finalizers = [];
|
|
13138
|
-
let
|
|
13276
|
+
let running = null;
|
|
13277
|
+
const tearDown = async () => {
|
|
13278
|
+
for (const fn of finalizers) try {
|
|
13279
|
+
await fn();
|
|
13280
|
+
} catch (err) {
|
|
13281
|
+
warn(`teardown finalizer failed (partial report may be incomplete): ${err instanceof Error ? err.message : String(err)}`);
|
|
13282
|
+
}
|
|
13283
|
+
await Promise.all([...sessions].map((name) => closeSession(name)));
|
|
13284
|
+
sessions.clear();
|
|
13285
|
+
};
|
|
13139
13286
|
return {
|
|
13140
13287
|
trackSession(name) {
|
|
13141
13288
|
sessions.add(name);
|
|
@@ -13146,24 +13293,18 @@ function createRunTeardown() {
|
|
|
13146
13293
|
onFinalize(fn) {
|
|
13147
13294
|
finalizers.push(fn);
|
|
13148
13295
|
},
|
|
13149
|
-
|
|
13150
|
-
|
|
13151
|
-
|
|
13152
|
-
for (const fn of finalizers) try {
|
|
13153
|
-
await fn();
|
|
13154
|
-
} catch (err) {
|
|
13155
|
-
warn(`teardown finalizer failed (partial report may be incomplete): ${err instanceof Error ? err.message : String(err)}`);
|
|
13156
|
-
}
|
|
13157
|
-
await Promise.all([...sessions].map((name) => closeSession(name)));
|
|
13158
|
-
sessions.clear();
|
|
13296
|
+
run() {
|
|
13297
|
+
running ??= tearDown();
|
|
13298
|
+
return running;
|
|
13159
13299
|
}
|
|
13160
13300
|
};
|
|
13161
13301
|
}
|
|
13162
13302
|
/**
|
|
13163
13303
|
* Install SIGINT/SIGTERM handlers that run {@link RunTeardown.run} then exit
|
|
13164
13304
|
* with the conventional signal code, and return a disposer that removes them.
|
|
13165
|
-
*
|
|
13166
|
-
*
|
|
13305
|
+
* A command must install at most one: a second handler that also exits would
|
|
13306
|
+
* race this one and could terminate mid-finalizer. A second signal while
|
|
13307
|
+
* tearing down hard-exits immediately rather than waiting.
|
|
13167
13308
|
*/
|
|
13168
13309
|
function installTeardownSignalHandlers(teardown) {
|
|
13169
13310
|
let handling = false;
|
|
@@ -14689,8 +14830,11 @@ function parseAutoFixFlag(raw) {
|
|
|
14689
14830
|
* The `generate` flow shared by `ccqa generate` and the codegen half of
|
|
14690
14831
|
* `ccqa record`: resolve the spec's target plugin, load its input (the
|
|
14691
14832
|
* recording, for input:"recording" targets), and dispatch to the plugin.
|
|
14692
|
-
* This layer owns the CLI concerns — overwrite confirmation, logging
|
|
14693
|
-
*
|
|
14833
|
+
* This layer owns the CLI concerns — overwrite confirmation, logging — while
|
|
14834
|
+
* the plugin owns the generation pipeline. A generation whose output still
|
|
14835
|
+
* fails is reported as `{ passed: false }` rather than thrown or exited: both
|
|
14836
|
+
* callers have work left that `process.exit` would skip — `ccqa record
|
|
14837
|
+
* --report-to-hub` still has to seal the run holding what the retries cost.
|
|
14694
14838
|
*/
|
|
14695
14839
|
async function runGenerate(featureName, specName, opts) {
|
|
14696
14840
|
header("generate", `${featureName}/${specName}`);
|
|
@@ -14698,7 +14842,7 @@ async function runGenerate(featureName, specName, opts) {
|
|
|
14698
14842
|
await ensureCcqaDir(cwd);
|
|
14699
14843
|
const releaseLock = await acquireSpecLock(featureName, specName, "generate", cwd);
|
|
14700
14844
|
try {
|
|
14701
|
-
await runGenerateLocked(featureName, specName, opts, cwd);
|
|
14845
|
+
return await runGenerateLocked(featureName, specName, opts, cwd);
|
|
14702
14846
|
} finally {
|
|
14703
14847
|
await releaseLock();
|
|
14704
14848
|
}
|
|
@@ -14729,7 +14873,7 @@ async function runGenerateLocked(featureName, specName, opts, cwd) {
|
|
|
14729
14873
|
if (existingOutput && !opts.force) {
|
|
14730
14874
|
if (!await confirmOverwrite(existingOutput)) {
|
|
14731
14875
|
info("aborted; pass --overwrite to replace it without prompting");
|
|
14732
|
-
return;
|
|
14876
|
+
return { passed: true };
|
|
14733
14877
|
}
|
|
14734
14878
|
}
|
|
14735
14879
|
let recording;
|
|
@@ -14758,15 +14902,14 @@ async function runGenerateLocked(featureName, specName, opts, cwd) {
|
|
|
14758
14902
|
maxRetries: opts.maxRetries,
|
|
14759
14903
|
mode: opts.fixMode,
|
|
14760
14904
|
useSnapshot: opts.useSnapshot
|
|
14761
|
-
}
|
|
14905
|
+
},
|
|
14906
|
+
teardown: opts.teardown
|
|
14762
14907
|
};
|
|
14763
14908
|
const result = await target.generate(ctx);
|
|
14764
14909
|
if (opts.updateAgentPrompt) await runGenerateAgentPromptUpdate(target, featureName, specName, result, opts, cwd);
|
|
14765
|
-
if (!result.passed)
|
|
14766
|
-
|
|
14767
|
-
|
|
14768
|
-
}
|
|
14769
|
-
hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
|
|
14910
|
+
if (!result.passed) warn("auto-fix exhausted; test still failing");
|
|
14911
|
+
else hint(`run 'ccqa run ${featureName}/${specName}' to execute the test`);
|
|
14912
|
+
return { passed: result.passed };
|
|
14770
14913
|
}
|
|
14771
14914
|
/**
|
|
14772
14915
|
* `ccqa generate --learn-hub-codegen-prompt`: refresh the target's learned
|
|
@@ -14836,15 +14979,18 @@ async function runGenerateCli(specPath, opts) {
|
|
|
14836
14979
|
cwd
|
|
14837
14980
|
});
|
|
14838
14981
|
if (opts.learnHubCodegenPrompt && hubClient === null) {
|
|
14839
|
-
error("--learn-hub-codegen-prompt
|
|
14982
|
+
error(needsHubConnection("--learn-hub-codegen-prompt"));
|
|
14840
14983
|
process.exit(2);
|
|
14841
14984
|
}
|
|
14842
14985
|
const hubContext = hubClient && project ? {
|
|
14843
14986
|
hub: hubClient,
|
|
14844
14987
|
project
|
|
14845
14988
|
} : null;
|
|
14989
|
+
const teardown = createRunTeardown();
|
|
14990
|
+
const disposeSignalHandlers = installTeardownSignalHandlers(teardown);
|
|
14991
|
+
let passed;
|
|
14846
14992
|
try {
|
|
14847
|
-
await runGenerate(featureName, specName, {
|
|
14993
|
+
({passed} = await runGenerate(featureName, specName, {
|
|
14848
14994
|
maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
|
|
14849
14995
|
fixMode: toFixMode(opts.autoFix ?? "interactive"),
|
|
14850
14996
|
force: opts.overwrite ?? false,
|
|
@@ -14854,15 +15000,20 @@ async function runGenerateCli(specPath, opts) {
|
|
|
14854
15000
|
targetOverride: opts.target,
|
|
14855
15001
|
cwd,
|
|
14856
15002
|
hubContext,
|
|
14857
|
-
updateAgentPrompt: opts.learnHubCodegenPrompt ?? false
|
|
14858
|
-
|
|
15003
|
+
updateAgentPrompt: opts.learnHubCodegenPrompt ?? false,
|
|
15004
|
+
teardown
|
|
15005
|
+
}));
|
|
14859
15006
|
} catch (e) {
|
|
14860
15007
|
if (e instanceof SpecLockedError) {
|
|
14861
15008
|
error(e.message);
|
|
14862
15009
|
process.exit(2);
|
|
14863
15010
|
}
|
|
14864
15011
|
throw e;
|
|
15012
|
+
} finally {
|
|
15013
|
+
await teardown.run();
|
|
15014
|
+
disposeSignalHandlers();
|
|
14865
15015
|
}
|
|
15016
|
+
if (!passed) process.exit(1);
|
|
14866
15017
|
}
|
|
14867
15018
|
//#endregion
|
|
14868
15019
|
//#region src/cli/record.ts
|
|
@@ -14870,7 +15021,7 @@ const VALIDATION_MODES = ["lenient", "strict"];
|
|
|
14870
15021
|
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) => {
|
|
14871
15022
|
if (VALIDATION_MODES.includes(raw)) return raw;
|
|
14872
15023
|
throw new Error(`--trace-validation must be one of ${VALIDATION_MODES.join(" | ")}`);
|
|
14873
|
-
}, "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) => {
|
|
15024
|
+
}, "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) => {
|
|
14874
15025
|
await withCostReporting("record", () => runRecord(specPath, opts));
|
|
14875
15026
|
}));
|
|
14876
15027
|
async function runRecord(specPath, opts) {
|
|
@@ -14909,9 +15060,10 @@ async function runRecord(specPath, opts) {
|
|
|
14909
15060
|
project: hubProject
|
|
14910
15061
|
} : null;
|
|
14911
15062
|
if (opts.learnHubTracePrompt && hubContext === null) {
|
|
14912
|
-
error("--learn-hub-trace-prompt
|
|
15063
|
+
error(needsHubConnection("--learn-hub-trace-prompt"));
|
|
14913
15064
|
process.exit(2);
|
|
14914
15065
|
}
|
|
15066
|
+
const pushConn = opts.reportToHub ? requireReportToHubConnection(hubContext) : null;
|
|
14915
15067
|
const releaseLock = await acquireSpecLock(featureName, specName, "record", cwdForProfile).catch((e) => {
|
|
14916
15068
|
if (e instanceof SpecLockedError) {
|
|
14917
15069
|
error(e.message);
|
|
@@ -14919,37 +15071,78 @@ async function runRecord(specPath, opts) {
|
|
|
14919
15071
|
}
|
|
14920
15072
|
throw e;
|
|
14921
15073
|
});
|
|
14922
|
-
|
|
15074
|
+
const push = pushConn ? await openHubRun("record", pushConn, cwdForProfile, opts.hubProfile) : null;
|
|
15075
|
+
if (push) info(`hub: record run opened (${push.runId})`);
|
|
15076
|
+
let recorded = false;
|
|
15077
|
+
let sealed = true;
|
|
15078
|
+
const teardown = createRunTeardown();
|
|
15079
|
+
teardown.onFinalize(async () => {
|
|
15080
|
+
if (push) sealed = await sealRecordPush(push, featureName, specName, recorded);
|
|
15081
|
+
});
|
|
15082
|
+
const disposeSignalHandlers = installTeardownSignalHandlers(teardown);
|
|
14923
15083
|
try {
|
|
14924
|
-
traceResult =
|
|
14925
|
-
|
|
14926
|
-
|
|
14927
|
-
|
|
14928
|
-
|
|
14929
|
-
|
|
14930
|
-
|
|
14931
|
-
|
|
14932
|
-
|
|
14933
|
-
|
|
14934
|
-
|
|
14935
|
-
|
|
14936
|
-
|
|
14937
|
-
|
|
14938
|
-
|
|
15084
|
+
let traceResult = null;
|
|
15085
|
+
let generated = true;
|
|
15086
|
+
try {
|
|
15087
|
+
traceResult = await runTrace(featureName, specName, opts.model, opts.traceValidation ?? "lenient", language, {
|
|
15088
|
+
cwd: cwdForProfile,
|
|
15089
|
+
hubContext
|
|
15090
|
+
});
|
|
15091
|
+
blank();
|
|
15092
|
+
if (!opts.traceOnly) generated = (await runGenerate(featureName, specName, {
|
|
15093
|
+
maxRetries: parseInt(opts.autoFixMaxRetries ?? "3", 10),
|
|
15094
|
+
fixMode: toFixMode(opts.autoFix ?? "interactive"),
|
|
15095
|
+
force: opts.overwrite ?? false,
|
|
15096
|
+
useSnapshot: opts.sessionPin !== false,
|
|
15097
|
+
language,
|
|
15098
|
+
model: opts.model,
|
|
15099
|
+
cwd: cwdForProfile,
|
|
15100
|
+
hubContext,
|
|
15101
|
+
teardown
|
|
15102
|
+
})).passed;
|
|
15103
|
+
} finally {
|
|
15104
|
+
await releaseLock();
|
|
15105
|
+
}
|
|
15106
|
+
if (opts.learnHubTracePrompt && traceResult !== null) {
|
|
15107
|
+
blank();
|
|
15108
|
+
await updateAgentPrompt({
|
|
15109
|
+
kind: "record",
|
|
15110
|
+
flag: "--learn-hub-trace-prompt",
|
|
15111
|
+
runSummary: buildRecordRunSummary(featureName, specName, traceResult),
|
|
15112
|
+
hubContext,
|
|
15113
|
+
...opts.model ? { model: opts.model } : {},
|
|
15114
|
+
...language ? { language } : {}
|
|
15115
|
+
});
|
|
15116
|
+
}
|
|
15117
|
+
recorded = generated;
|
|
14939
15118
|
} finally {
|
|
14940
|
-
await
|
|
14941
|
-
|
|
14942
|
-
if (opts.learnHubTracePrompt && traceResult !== null) {
|
|
14943
|
-
blank();
|
|
14944
|
-
await updateAgentPrompt({
|
|
14945
|
-
kind: "record",
|
|
14946
|
-
flag: "--learn-hub-trace-prompt",
|
|
14947
|
-
runSummary: buildRecordRunSummary(featureName, specName, traceResult),
|
|
14948
|
-
hubContext,
|
|
14949
|
-
...opts.model ? { model: opts.model } : {},
|
|
14950
|
-
...language ? { language } : {}
|
|
14951
|
-
});
|
|
15119
|
+
await teardown.run();
|
|
15120
|
+
disposeSignalHandlers();
|
|
14952
15121
|
}
|
|
15122
|
+
if (!sealed) process.exit(2);
|
|
15123
|
+
if (!recorded) process.exit(1);
|
|
15124
|
+
}
|
|
15125
|
+
/**
|
|
15126
|
+
* Close the record run with the one row this command produced, answering
|
|
15127
|
+
* whether it closed. One spec is recorded per invocation, so one row is the
|
|
15128
|
+
* whole run — enough for the runs list to say what the money bought.
|
|
15129
|
+
*/
|
|
15130
|
+
async function sealRecordPush(push, featureName, specName, recorded) {
|
|
15131
|
+
return sealHubRun(push, {
|
|
15132
|
+
rows: [emptySpecRow({
|
|
15133
|
+
feature: featureName,
|
|
15134
|
+
spec: specName,
|
|
15135
|
+
title: null,
|
|
15136
|
+
status: recorded ? "passed" : "failed"
|
|
15137
|
+
})],
|
|
15138
|
+
reportMeta: {
|
|
15139
|
+
git: {
|
|
15140
|
+
head: push.gitHead,
|
|
15141
|
+
base: null
|
|
15142
|
+
},
|
|
15143
|
+
cost: currentReportCost()
|
|
15144
|
+
}
|
|
15145
|
+
});
|
|
14953
15146
|
}
|
|
14954
15147
|
/**
|
|
14955
15148
|
* Compact summary of the trace pass for the record agent-prompt refresh.
|
|
@@ -15173,6 +15366,7 @@ Drift found:
|
|
|
15173
15366
|
"confidence": 0.0,
|
|
15174
15367
|
"surface": "spec" | "generated",
|
|
15175
15368
|
"subDiagnosis": "SELECTOR_DRIFT" | "OVER_ASSERTION" | "NONE",
|
|
15369
|
+
"specChangeKind": "FEATURE_REMOVED" | "BEHAVIOUR_CHANGED",
|
|
15176
15370
|
"headline": "<one line: what is out of sync>",
|
|
15177
15371
|
"recommendation": "<what to change to bring them back in sync>",
|
|
15178
15372
|
"reasoning": "<how you reached this label: what you looked for, what you found, why it is this label and not the other>",
|
|
@@ -15184,6 +15378,13 @@ Drift found:
|
|
|
15184
15378
|
\`\`\`
|
|
15185
15379
|
|
|
15186
15380
|
\`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.
|
|
15381
|
+
|
|
15382
|
+
\`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:
|
|
15383
|
+
|
|
15384
|
+
- \`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.
|
|
15385
|
+
- \`BEHAVIOUR_CHANGED\` — the behaviour is still there, but its wording, its route, or the conditions it runs under moved.
|
|
15386
|
+
|
|
15387
|
+
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.
|
|
15187
15388
|
`;
|
|
15188
15389
|
}
|
|
15189
15390
|
function buildDriftUserPrompt(artifacts) {
|
|
@@ -15364,10 +15565,11 @@ async function checkSpec(target, opts) {
|
|
|
15364
15565
|
continue;
|
|
15365
15566
|
}
|
|
15366
15567
|
try {
|
|
15568
|
+
const reply = DriftReplySchema.parse(JSON.parse(json));
|
|
15367
15569
|
return {
|
|
15368
15570
|
target,
|
|
15369
15571
|
ok: true,
|
|
15370
|
-
drift:
|
|
15572
|
+
drift: reply.drift ? normalizeDiagnosis(reply.drift) : null,
|
|
15371
15573
|
live: artifacts.live,
|
|
15372
15574
|
title: artifacts.title
|
|
15373
15575
|
};
|
|
@@ -15501,7 +15703,7 @@ function determineExitCode(results, threshold) {
|
|
|
15501
15703
|
//#endregion
|
|
15502
15704
|
//#region src/drift/to-report.ts
|
|
15503
15705
|
/** Tracks the drift prompt's own version — the two must never drift apart. */
|
|
15504
|
-
const DRIFT_REPORT_PROMPT_VERSION = "
|
|
15706
|
+
const DRIFT_REPORT_PROMPT_VERSION = "6";
|
|
15505
15707
|
/**
|
|
15506
15708
|
* Spec-level status under the given threshold, mirroring determineExitCode's
|
|
15507
15709
|
* per-spec logic (exit-code.ts) but scoped to a single SpecResult.
|
|
@@ -15534,7 +15736,7 @@ function driftResultToRow(result, threshold) {
|
|
|
15534
15736
|
status: specStatus(result, threshold)
|
|
15535
15737
|
}),
|
|
15536
15738
|
...result.live === void 0 ? {} : { mode: result.live ? "live" : "deterministic" },
|
|
15537
|
-
analysis: result.drift
|
|
15739
|
+
analysis: result.drift
|
|
15538
15740
|
};
|
|
15539
15741
|
}
|
|
15540
15742
|
/**
|
|
@@ -15820,14 +16022,24 @@ async function runAudit(specPath, opts) {
|
|
|
15820
16022
|
if (targets.length === 0) exitWithNoSpecs(format, "noDiffIntersection", "no specs intersect the changed file set; nothing to check");
|
|
15821
16023
|
}
|
|
15822
16024
|
const blocks = await loadAvailableBlocks(cwd);
|
|
15823
|
-
|
|
16025
|
+
let pushConn = null;
|
|
16026
|
+
if (opts.reportToHub) {
|
|
16027
|
+
const pushHub = resolveHubClient(opts);
|
|
16028
|
+
pushConn = requireReportToHubConnection(pushHub && {
|
|
16029
|
+
hub: pushHub,
|
|
16030
|
+
project: resolveProject({
|
|
16031
|
+
project: opts.project,
|
|
16032
|
+
cwd
|
|
16033
|
+
})
|
|
16034
|
+
});
|
|
16035
|
+
}
|
|
15824
16036
|
let results;
|
|
15825
16037
|
let promptCtx;
|
|
15826
16038
|
let push = null;
|
|
15827
16039
|
try {
|
|
15828
16040
|
promptCtx = await resolveAuditPromptContext(opts, cwd);
|
|
15829
|
-
if (
|
|
15830
|
-
push = await
|
|
16041
|
+
if (pushConn) {
|
|
16042
|
+
push = await openHubRun("drift", pushConn, cwd, opts.hubProfile);
|
|
15831
16043
|
if (format === "text") info(`hub: incremental drift run opened (${push.runId})`);
|
|
15832
16044
|
}
|
|
15833
16045
|
results = await analyzeDrift({
|
|
@@ -15888,56 +16100,6 @@ async function releaseSpecs(hub, project, profile, holder) {
|
|
|
15888
16100
|
}
|
|
15889
16101
|
}
|
|
15890
16102
|
/**
|
|
15891
|
-
* Fail fast when `--report-to-hub` was requested but no hub connection is
|
|
15892
|
-
* available. Called at the top of `runAudit`, before the sweep spends any
|
|
15893
|
-
* model calls — `pushDriftResults` (below) keeps its own equivalent, injectable
|
|
15894
|
-
* check so it stays safe to call standalone (e.g. in tests) without this guard
|
|
15895
|
-
* having already run.
|
|
15896
|
-
*/
|
|
15897
|
-
function requireReportToHubConnection(opts) {
|
|
15898
|
-
if (!opts.reportToHub || resolveHubClient(opts)) return;
|
|
15899
|
-
error("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
|
|
15900
|
-
process.exit(2);
|
|
15901
|
-
}
|
|
15902
|
-
/**
|
|
15903
|
-
* Open the drift run this sweep patches into. A failure here is fatal, as it
|
|
15904
|
-
* is for `ccqa run`: a job that asked to publish and cannot reach the hub has
|
|
15905
|
-
* not done what it was told, and the audit has no local artifact to fall back
|
|
15906
|
-
* on — the hub is its only output. Raised before any spec is checked, so
|
|
15907
|
-
* nothing is wasted. Not retried: a dropped response after the hub committed
|
|
15908
|
-
* would leave a second orphan running run.
|
|
15909
|
-
*
|
|
15910
|
-
* `resolveHub` is injectable for tests.
|
|
15911
|
-
*/
|
|
15912
|
-
async function openDriftPush(opts, cwd, resolveHub = resolveHubClient) {
|
|
15913
|
-
const hub = resolveHub(opts);
|
|
15914
|
-
if (!hub) throw new RunUsageError("--report-to-hub requires a hub connection (--hub-url/--hub-token or CCQA_HUB_URL/CCQA_HUB_TOKEN)");
|
|
15915
|
-
const project = resolveProject({
|
|
15916
|
-
project: opts.project,
|
|
15917
|
-
cwd
|
|
15918
|
-
});
|
|
15919
|
-
const [branch, gitHead] = await Promise.all([detectBranch(cwd), getGitHead(cwd)]);
|
|
15920
|
-
const ciRunId = githubRunId();
|
|
15921
|
-
const runUrl = githubRunUrl();
|
|
15922
|
-
try {
|
|
15923
|
-
return {
|
|
15924
|
-
hub,
|
|
15925
|
-
runId: (await hub.openRun({
|
|
15926
|
-
project,
|
|
15927
|
-
kind: "drift",
|
|
15928
|
-
...branch ? { branch } : {},
|
|
15929
|
-
...opts.hubProfile ? { profile: opts.hubProfile } : {},
|
|
15930
|
-
...gitHead ? { gitHead } : {},
|
|
15931
|
-
...ciRunId ? { ciRunId } : {},
|
|
15932
|
-
...runUrl ? { runUrl } : {}
|
|
15933
|
-
})).id,
|
|
15934
|
-
gitHead
|
|
15935
|
-
};
|
|
15936
|
-
} catch (err) {
|
|
15937
|
-
throw new RunUsageError(`--report-to-hub: could not open a run on the hub (${errMessage(err)})`);
|
|
15938
|
-
}
|
|
15939
|
-
}
|
|
15940
|
-
/**
|
|
15941
16103
|
* Send one finished spec. A failure is warned and swallowed rather than
|
|
15942
16104
|
* thrown: the seal resends every row, so a dropped patch costs freshness for
|
|
15943
16105
|
* the rest of the sweep, not the record.
|
|
@@ -15969,22 +16131,16 @@ async function sealDriftPush(push, args) {
|
|
|
15969
16131
|
customPromptVersion: promptCtx?.customPromptVersion ?? null,
|
|
15970
16132
|
triageUserPromptHash: promptCtx?.triageUserPromptHash ?? null
|
|
15971
16133
|
});
|
|
15972
|
-
|
|
15973
|
-
|
|
15974
|
-
|
|
15975
|
-
|
|
15976
|
-
|
|
15977
|
-
|
|
15978
|
-
|
|
15979
|
-
|
|
15980
|
-
|
|
15981
|
-
|
|
15982
|
-
}
|
|
15983
|
-
});
|
|
15984
|
-
} catch (err) {
|
|
15985
|
-
error(`hub: could not close the drift run ${push.runId}: ${errMessage(err)}`);
|
|
15986
|
-
process.exit(2);
|
|
15987
|
-
}
|
|
16134
|
+
if (!await sealHubRun(push, {
|
|
16135
|
+
rows: report.results,
|
|
16136
|
+
reportMeta: {
|
|
16137
|
+
git: report.git,
|
|
16138
|
+
promptVersion: report.promptVersion,
|
|
16139
|
+
customPromptVersion: report.customPromptVersion,
|
|
16140
|
+
...report.triageUserPromptHash ? { triageUserPromptHash: report.triageUserPromptHash } : {},
|
|
16141
|
+
cost: report.cost
|
|
16142
|
+
}
|
|
16143
|
+
})) process.exit(2);
|
|
15988
16144
|
if (format === "text") info(`pushed drift result to hub: ${(opts.hubUrl ?? process.env.CCQA_HUB_URL ?? "").replace(/\/+$/, "")}/#/runs/${push.runId}`);
|
|
15989
16145
|
}
|
|
15990
16146
|
/**
|
|
@@ -16180,6 +16336,53 @@ For each test case above, write a 1–2 sentence factual \`summary\` of what it
|
|
|
16180
16336
|
`;
|
|
16181
16337
|
}
|
|
16182
16338
|
//#endregion
|
|
16339
|
+
//#region src/spec/spec-changed-at.ts
|
|
16340
|
+
/**
|
|
16341
|
+
* One `git log` over the spec tree, walked newest-first. The first time a spec's
|
|
16342
|
+
* directory appears is that spec's last edit.
|
|
16343
|
+
*
|
|
16344
|
+
* Best-effort: outside a repository (or with no history) this returns an empty
|
|
16345
|
+
* map and every caller falls back to what it did before. A missing timestamp
|
|
16346
|
+
* must never make a spec look fresher than it is.
|
|
16347
|
+
*/
|
|
16348
|
+
async function readSpecChangedAt(cwd) {
|
|
16349
|
+
const out = /* @__PURE__ */ new Map();
|
|
16350
|
+
let stdout;
|
|
16351
|
+
try {
|
|
16352
|
+
({stdout} = await execFileP("git", [
|
|
16353
|
+
"log",
|
|
16354
|
+
"--pretty=format:%x00%cI",
|
|
16355
|
+
"--name-only",
|
|
16356
|
+
"--",
|
|
16357
|
+
".ccqa/features"
|
|
16358
|
+
], {
|
|
16359
|
+
cwd,
|
|
16360
|
+
maxBuffer: 64 * 1024 * 1024
|
|
16361
|
+
}));
|
|
16362
|
+
} catch {
|
|
16363
|
+
return out;
|
|
16364
|
+
}
|
|
16365
|
+
let when = "";
|
|
16366
|
+
for (const line of stdout.split("\n")) {
|
|
16367
|
+
if (line.startsWith("\0")) {
|
|
16368
|
+
when = line.slice(1).trim();
|
|
16369
|
+
continue;
|
|
16370
|
+
}
|
|
16371
|
+
const key = specKeyOf(line.trim());
|
|
16372
|
+
if (key && when && !out.has(key)) out.set(key, when);
|
|
16373
|
+
}
|
|
16374
|
+
return out;
|
|
16375
|
+
}
|
|
16376
|
+
/**
|
|
16377
|
+
* "feature/spec" for a path under the spec tree, or null for anything else.
|
|
16378
|
+
* Every file in a case's directory counts: the generated code moving is as
|
|
16379
|
+
* much a change to the test as the spec.yaml moving.
|
|
16380
|
+
*/
|
|
16381
|
+
function specKeyOf(path) {
|
|
16382
|
+
const m = /^\.ccqa\/features\/([^/]+)\/test-cases\/([^/]+)\//.exec(path);
|
|
16383
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
16384
|
+
}
|
|
16385
|
+
//#endregion
|
|
16183
16386
|
//#region src/cli/perspectives.ts
|
|
16184
16387
|
const perspectivesCommand = addHubOptions(addLanguageOption(new Command("perspectives").description("Generate/update the project's perspectives document on the hub — a factual inventory of existing test coverage (no severity, no gap analysis)").option("--instruction <text>", "Hint to steer how summaries are written").option("-y, --yes", "Apply without asking [y/N]", false).option("--verify", "Check the hub document against the local specs (mechanical fields only) and exit 1 when it is stale. No Claude calls — cheap enough for CI.", false).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID").option("--project <name>", "Hub project to store the document under (default: cwd directory name)"))).action(withHubErrors(async (opts) => {
|
|
16185
16388
|
await withCostReporting("perspectives", () => opts.verify ? runPerspectivesCheck(opts) : runPerspectives(opts));
|
|
@@ -16330,17 +16533,20 @@ async function cleanupLegacyLocalFiles() {
|
|
|
16330
16533
|
*/
|
|
16331
16534
|
async function buildSkeleton(tree) {
|
|
16332
16535
|
const config = await loadProjectConfig(process.cwd()).catch(() => null);
|
|
16536
|
+
const changedAt = await readSpecChangedAt(process.cwd());
|
|
16333
16537
|
return (await Promise.all(tree.map(async (feature) => {
|
|
16334
16538
|
const specs = await Promise.all(feature.specs.filter((s) => s.hasSpecFile).map(async (s) => {
|
|
16335
16539
|
const specYaml = await tryReadSpecFile(feature.featureName, s.specName);
|
|
16336
16540
|
const meta = readSpecMeta(s.specName, specYaml);
|
|
16337
16541
|
const plugin = resolveSpecTarget(specYaml, config);
|
|
16338
16542
|
const status = await deriveStatus(feature.featureName, s.specName, meta.mode, plugin);
|
|
16543
|
+
const lastEdit = changedAt.get(`${feature.featureName}/${s.specName}`);
|
|
16339
16544
|
return {
|
|
16340
16545
|
specName: s.specName,
|
|
16341
16546
|
title: meta.title,
|
|
16342
16547
|
summary: "",
|
|
16343
|
-
status
|
|
16548
|
+
status,
|
|
16549
|
+
...lastEdit ? { changedAt: lastEdit } : {}
|
|
16344
16550
|
};
|
|
16345
16551
|
}));
|
|
16346
16552
|
return {
|
|
@@ -16772,6 +16978,7 @@ function gradedDriftEntry(ledger, key, runId, label) {
|
|
|
16772
16978
|
delete graded.headline;
|
|
16773
16979
|
delete graded.confidence;
|
|
16774
16980
|
}
|
|
16981
|
+
if (label !== "SPEC_CHANGE") delete graded.specChangeKind;
|
|
16775
16982
|
return graded;
|
|
16776
16983
|
}
|
|
16777
16984
|
//#endregion
|
|
@@ -16906,7 +17113,7 @@ function createPushRunHandler(config) {
|
|
|
16906
17113
|
runUrl: report.runUrl ?? null,
|
|
16907
17114
|
reportCreatedAt: report.createdAt,
|
|
16908
17115
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
16909
|
-
...await resolveDeployedSha(config.storage, project, profile, deployedSha),
|
|
17116
|
+
...await resolveDeployedSha(config.storage, kind, project, profile, deployedSha),
|
|
16910
17117
|
deployedShaAmbiguous: false
|
|
16911
17118
|
};
|
|
16912
17119
|
await config.storage.artifacts.putDir(run.id, dir);
|
|
@@ -16957,7 +17164,7 @@ function createOpenRunHandler(config) {
|
|
|
16957
17164
|
runUrl: runUrl || null,
|
|
16958
17165
|
reportCreatedAt: now,
|
|
16959
17166
|
createdAt: now,
|
|
16960
|
-
...await resolveDeployedSha(config.storage, project, profile, deployedSha),
|
|
17167
|
+
...await resolveDeployedSha(config.storage, kind, project, profile, deployedSha),
|
|
16961
17168
|
deployedShaAmbiguous: false
|
|
16962
17169
|
};
|
|
16963
17170
|
await config.storage.runs.create(run);
|
|
@@ -17079,10 +17286,11 @@ async function updateDriftLedger(storage, run, results) {
|
|
|
17079
17286
|
for (const row of results) {
|
|
17080
17287
|
if (row.status === "skipped") continue;
|
|
17081
17288
|
const key = `${row.feature}/${row.spec}`;
|
|
17082
|
-
const diagnosis = row.analysis;
|
|
17289
|
+
const diagnosis = row.analysis ? normalizeDiagnosis(row.analysis) : null;
|
|
17083
17290
|
ledger.specs[key] = {
|
|
17084
17291
|
label: diagnosis ? diagnosis.label : null,
|
|
17085
17292
|
surface: diagnosis?.surface,
|
|
17293
|
+
specChangeKind: diagnosis?.specChangeKind,
|
|
17086
17294
|
confidence: diagnosis?.confidence,
|
|
17087
17295
|
headline: diagnosis?.headline,
|
|
17088
17296
|
gitHead,
|
|
@@ -17105,11 +17313,15 @@ async function updateDriftLedger(storage, run, results) {
|
|
|
17105
17313
|
* Best-effort: a deploy log the hub can't read leaves the run unattributed
|
|
17106
17314
|
* (re-run selection then answers `unknown`) rather than rejecting the run.
|
|
17107
17315
|
*/
|
|
17108
|
-
async function resolveDeployedSha(storage, project, profile, explicit) {
|
|
17316
|
+
async function resolveDeployedSha(storage, kind, project, profile, explicit) {
|
|
17109
17317
|
if (explicit) return {
|
|
17110
17318
|
deployedSha: explicit,
|
|
17111
17319
|
deployedShaSource: "client"
|
|
17112
17320
|
};
|
|
17321
|
+
if (kind === "record") return {
|
|
17322
|
+
deployedSha: null,
|
|
17323
|
+
deployedShaSource: null
|
|
17324
|
+
};
|
|
17113
17325
|
try {
|
|
17114
17326
|
const head = await storage.deploys.head(project, profile ?? "default");
|
|
17115
17327
|
if (head) return {
|
|
@@ -17214,17 +17426,19 @@ function createPatchRunHandler(config) {
|
|
|
17214
17426
|
sendJson(ctx.res, 200, updated);
|
|
17215
17427
|
};
|
|
17216
17428
|
}
|
|
17217
|
-
/** GET /api/v1/runs?project&branch&status&limit */
|
|
17429
|
+
/** GET /api/v1/runs?project&branch&status&kind&limit */
|
|
17218
17430
|
function createListRunsHandler(storage) {
|
|
17219
17431
|
return async (ctx) => {
|
|
17220
17432
|
const project = ctx.url.searchParams.get("project");
|
|
17221
17433
|
const branch = ctx.url.searchParams.get("branch");
|
|
17222
17434
|
const status = ctx.url.searchParams.get("status");
|
|
17223
17435
|
const limitRaw = ctx.url.searchParams.get("limit");
|
|
17436
|
+
const kindsRaw = ctx.url.searchParams.get("kind");
|
|
17224
17437
|
const runs = await storage.runs.list({
|
|
17225
17438
|
...project ? { project } : {},
|
|
17226
17439
|
...branch ? { branch } : {},
|
|
17227
17440
|
...status ? { status } : {},
|
|
17441
|
+
...kindsRaw ? { kinds: kindsRaw.split(",").map(requireKind) } : {},
|
|
17228
17442
|
...limitRaw ? { limit: Number(limitRaw) } : {}
|
|
17229
17443
|
});
|
|
17230
17444
|
sendJson(ctx.res, 200, { runs: await Promise.all(runs.map((r) => withGradedDrift(storage, r))) });
|
|
@@ -17358,16 +17572,21 @@ function parseRunScope(ctx) {
|
|
|
17358
17572
|
const profileRaw = ctx.url.searchParams.get("profile");
|
|
17359
17573
|
const profile = profileRaw ? requireSafeSegment(profileRaw, "profile") : null;
|
|
17360
17574
|
const kindRaw = ctx.url.searchParams.get("kind");
|
|
17361
|
-
if (kindRaw !== null && kindRaw !== "run" && kindRaw !== "drift") throw new HttpError(400, "invalid_param", `invalid kind: must be "run" or "drift"`);
|
|
17362
17575
|
const deployedSha = boundedParam(ctx.url.searchParams.get("deployedSha"), "deployedSha", 64);
|
|
17363
17576
|
return {
|
|
17364
17577
|
project,
|
|
17365
17578
|
branch,
|
|
17366
17579
|
profile,
|
|
17367
|
-
kind: kindRaw
|
|
17580
|
+
kind: kindRaw === null ? "run" : requireKind(kindRaw),
|
|
17368
17581
|
deployedSha
|
|
17369
17582
|
};
|
|
17370
17583
|
}
|
|
17584
|
+
/** One `kind` value, validated against the enum so the message can't drift from it. */
|
|
17585
|
+
function requireKind(raw) {
|
|
17586
|
+
const parsed = ReportKindSchema.safeParse(raw);
|
|
17587
|
+
if (!parsed.success) throw new HttpError(400, "invalid_param", `invalid kind: must be one of ${ReportKindSchema.options.map((k) => `"${k}"`).join(", ")}`);
|
|
17588
|
+
return parsed.data;
|
|
17589
|
+
}
|
|
17371
17590
|
/**
|
|
17372
17591
|
* A branch is a free-form label (e.g. `feature/foo`), so `/` is allowed —
|
|
17373
17592
|
* only length is bounded (a sanity cap; the last-green ledger separately
|
|
@@ -17838,7 +18057,11 @@ function readSpecTargets(doc) {
|
|
|
17838
18057
|
for (const spec of specs) {
|
|
17839
18058
|
const specName = prop(spec, "specName");
|
|
17840
18059
|
if (typeof specName !== "string") continue;
|
|
17841
|
-
|
|
18060
|
+
const changedAt = prop(spec, "changedAt");
|
|
18061
|
+
out.push({
|
|
18062
|
+
key: `${featureName}/${specName}`,
|
|
18063
|
+
...typeof changedAt === "string" && changedAt ? { changedAt } : {}
|
|
18064
|
+
});
|
|
17842
18065
|
}
|
|
17843
18066
|
}
|
|
17844
18067
|
return out;
|
|
@@ -17909,7 +18132,7 @@ function createGetAuditNeedHandler(storage) {
|
|
|
17909
18132
|
//#endregion
|
|
17910
18133
|
//#region src/hub/api/handlers/locks.ts
|
|
17911
18134
|
/** A spec-key list and three short strings; nothing here should approach this. */
|
|
17912
|
-
const MAX_BODY_BYTES$
|
|
18135
|
+
const MAX_BODY_BYTES$2 = 1024 * 1024;
|
|
17913
18136
|
/**
|
|
17914
18137
|
* POST /api/v1/projects/:project/locks?profile=
|
|
17915
18138
|
*
|
|
@@ -17923,7 +18146,7 @@ function createAcquireLocksHandler(storage) {
|
|
|
17923
18146
|
return async (ctx) => {
|
|
17924
18147
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
17925
18148
|
const profile = requireProfileParam(ctx.url);
|
|
17926
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
18149
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, AcquireLocksRequestSchema, "lock request");
|
|
17927
18150
|
let result = {
|
|
17928
18151
|
granted: [],
|
|
17929
18152
|
denied: []
|
|
@@ -17952,16 +18175,90 @@ function createReleaseLocksHandler(storage) {
|
|
|
17952
18175
|
return async (ctx) => {
|
|
17953
18176
|
const project = requireSafeSegment(ctx.params.project, "project");
|
|
17954
18177
|
const profile = requireProfileParam(ctx.url);
|
|
17955
|
-
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$
|
|
18178
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$2, ReleaseLocksRequestSchema, "release request");
|
|
17956
18179
|
await storage.locks.update(project, profile, (current) => releaseAll(current, body.holder));
|
|
17957
18180
|
ctx.res.writeHead(204).end();
|
|
17958
18181
|
};
|
|
17959
18182
|
}
|
|
17960
18183
|
//#endregion
|
|
18184
|
+
//#region src/hub/api/handlers/acks.ts
|
|
18185
|
+
/**
|
|
18186
|
+
* Pre-parse guard only. `PutAckRequestSchema` holds the real bound (5000 keys
|
|
18187
|
+
* of 256 characters); this sits above the largest body that can satisfy it —
|
|
18188
|
+
* 5000 keys of 256 `\uXXXX`-escaped characters — so a conforming client is
|
|
18189
|
+
* never answered 413 by a limit the documented bounds don't mention.
|
|
18190
|
+
*/
|
|
18191
|
+
const MAX_BODY_BYTES$1 = 8 * 1024 * 1024;
|
|
18192
|
+
function requireAckKey(ctx) {
|
|
18193
|
+
return {
|
|
18194
|
+
project: requireSafeSegment(ctx.params.project, "project"),
|
|
18195
|
+
profile: requireProfileParam(ctx.url),
|
|
18196
|
+
name: requireSafeSegment(ctx.params.name, "name")
|
|
18197
|
+
};
|
|
18198
|
+
}
|
|
18199
|
+
/**
|
|
18200
|
+
* GET /api/v1/projects/:project/acks/:name?profile= — an unset ack answers 200
|
|
18201
|
+
* with an empty set, not 404: "nothing acted on yet" is a real answer, and a
|
|
18202
|
+
* 404 there is a special case consumers get wrong.
|
|
18203
|
+
*/
|
|
18204
|
+
function createGetAckHandler(storage) {
|
|
18205
|
+
return async (ctx) => {
|
|
18206
|
+
const key = requireAckKey(ctx);
|
|
18207
|
+
const ack = await storage.acks.get(key.project, key.profile, key.name);
|
|
18208
|
+
sendJson(ctx.res, 200, {
|
|
18209
|
+
...key,
|
|
18210
|
+
...ack
|
|
18211
|
+
});
|
|
18212
|
+
};
|
|
18213
|
+
}
|
|
18214
|
+
/** PUT /api/v1/projects/:project/acks/:name?profile= */
|
|
18215
|
+
function createPutAckHandler(storage) {
|
|
18216
|
+
return async (ctx) => {
|
|
18217
|
+
const key = requireAckKey(ctx);
|
|
18218
|
+
const body = await readJsonBody(ctx.req, MAX_BODY_BYTES$1, PutAckRequestSchema, "ack body");
|
|
18219
|
+
const ack = await storage.acks.put(key.project, key.profile, key.name, body.keys);
|
|
18220
|
+
sendJson(ctx.res, 200, {
|
|
18221
|
+
...key,
|
|
18222
|
+
...ack
|
|
18223
|
+
});
|
|
18224
|
+
};
|
|
18225
|
+
}
|
|
18226
|
+
//#endregion
|
|
17961
18227
|
//#region src/hub/core/rerun.ts
|
|
18228
|
+
/**
|
|
18229
|
+
* When each deployed commit reached the environment. A baseline read at that
|
|
18230
|
+
* commit cannot have seen anything committed after it was deployed.
|
|
18231
|
+
*/
|
|
18232
|
+
function deployedAt(log) {
|
|
18233
|
+
const out = /* @__PURE__ */ new Map();
|
|
18234
|
+
for (const entry of log.entries) out.set(entry.sha, entry.at);
|
|
18235
|
+
return out;
|
|
18236
|
+
}
|
|
18237
|
+
/**
|
|
18238
|
+
* Has the spec moved since the baseline was taken?
|
|
18239
|
+
*
|
|
18240
|
+
* A verdict is a claim about a (spec, product) pair, so either side moving
|
|
18241
|
+
* invalidates it. The deploy log covers the product side; this covers the
|
|
18242
|
+
* other one. Without it a spec repaired and merged stays `needsRepair` until
|
|
18243
|
+
* a deploy happens to reach it, and a run that passed against the previous
|
|
18244
|
+
* spec keeps answering `verified` for the new one.
|
|
18245
|
+
*
|
|
18246
|
+
* Compared against when the baseline commit was *deployed*, not when the audit
|
|
18247
|
+
* or run happened: the tree read at that commit predates its deployment, so an
|
|
18248
|
+
* edit after it is definitely not in it. Falls back to the baseline's own
|
|
18249
|
+
* timestamp when the log cannot place the commit.
|
|
18250
|
+
*
|
|
18251
|
+
* One-directional. A later edit time proves the baseline is stale; an earlier
|
|
18252
|
+
* one proves nothing, and this answers false rather than guessing.
|
|
18253
|
+
*/
|
|
18254
|
+
function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
|
|
18255
|
+
if (!changedAt) return null;
|
|
18256
|
+
return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
|
|
18257
|
+
}
|
|
17962
18258
|
function computeRerun(input) {
|
|
17963
18259
|
const { specs, ledger, log, touchIndex, drift, locks, now } = input;
|
|
17964
18260
|
const range = buildRange(log, touchIndex);
|
|
18261
|
+
const deployTimes = deployedAt(log);
|
|
17965
18262
|
const out = {};
|
|
17966
18263
|
for (const spec of specs) {
|
|
17967
18264
|
const coords = {
|
|
@@ -17969,11 +18266,17 @@ function computeRerun(input) {
|
|
|
17969
18266
|
lastGreen: ledger.green[spec.key] ?? null,
|
|
17970
18267
|
lastRed: ledger.red[spec.key] ?? null
|
|
17971
18268
|
};
|
|
17972
|
-
|
|
17973
|
-
|
|
18269
|
+
let audit = auditState(drift, spec.key, range);
|
|
18270
|
+
let execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
|
|
18271
|
+
const driftEntry = drift.specs[spec.key];
|
|
18272
|
+
const auditMoved = specMovedSince(spec.changedAt, driftEntry?.gitHead ?? null, driftEntry?.at ?? "", deployTimes);
|
|
18273
|
+
const runMoved = specMovedSince(spec.changedAt, coords.lastRun?.deployedSha ?? null, coords.lastRun?.at ?? "", deployTimes);
|
|
18274
|
+
if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
|
|
18275
|
+
if (runMoved && execution.execution === "passed") execution = { execution: "stale" };
|
|
17974
18276
|
const held = heldBy(locks, spec.key, now);
|
|
17975
18277
|
out[spec.key] = {
|
|
17976
18278
|
verdict: decide(audit.audit, execution.execution, held),
|
|
18279
|
+
...auditMoved || runMoved ? { specChangedSince: auditMoved ?? runMoved } : {},
|
|
17977
18280
|
...audit,
|
|
17978
18281
|
...execution,
|
|
17979
18282
|
heldBy: held,
|
|
@@ -19170,7 +19473,12 @@ const CSS = `
|
|
|
19170
19473
|
.empty-note { color: var(--muted); font-size: 13px; padding: 16px 2px; }
|
|
19171
19474
|
|
|
19172
19475
|
.runid { font-family: var(--mono); font-size: 13px; font-weight: 600; }
|
|
19173
|
-
.
|
|
19476
|
+
/* Chips wrap when a run carries several labels. Laid out as inline content
|
|
19477
|
+
they wrapped to a line indented by the chips' own left margin, and the two
|
|
19478
|
+
lines sat at text leading — too tight to read as separate rows. Flex with a
|
|
19479
|
+
gap aligns every line at the same left edge and spaces them the same way
|
|
19480
|
+
horizontally and vertically. */
|
|
19481
|
+
.subline { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 4px; }
|
|
19174
19482
|
.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; }
|
|
19175
19483
|
.ci-badge.local { color: var(--muted-2); }
|
|
19176
19484
|
a.ci-badge { text-decoration: none; }
|
|
@@ -19186,6 +19494,15 @@ const CSS = `
|
|
|
19186
19494
|
.badge.skipped .d { background: var(--muted); }
|
|
19187
19495
|
.badge.running { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
19188
19496
|
.badge.running .d { background: var(--amber); }
|
|
19497
|
+
/* A drift verdict is a diagnosis, not a broken test: amber, never fail-red.
|
|
19498
|
+
Without these the badge rendered as bare text next to the pill-shaped
|
|
19499
|
+
pass/fail ones, and read as a different kind of thing. */
|
|
19500
|
+
.badge.dr-found { background: var(--amber-bg); color: var(--amber); border-color: var(--amber-border); }
|
|
19501
|
+
.badge.dr-found .d { background: var(--amber); }
|
|
19502
|
+
.badge.dr-clean { background: var(--pass-bg); color: var(--pass); border-color: var(--pass-border); }
|
|
19503
|
+
.badge.dr-clean .d { background: var(--pass); }
|
|
19504
|
+
.badge.dr-unknown { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
19505
|
+
.badge.dr-unknown .d { background: var(--muted); }
|
|
19189
19506
|
.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; }
|
|
19190
19507
|
.badge-live { background: var(--violet-bg); color: var(--violet); border-color: var(--violet-border); }
|
|
19191
19508
|
.badge-det { background: var(--surface-3); color: var(--muted); border-color: var(--border); }
|
|
@@ -19197,8 +19514,11 @@ const CSS = `
|
|
|
19197
19514
|
order decides). One amber look for every drift label chip — a label chip
|
|
19198
19515
|
is a finding, not a severity, so it does not split into fail-red/amber
|
|
19199
19516
|
the way the old errors/warnings counts did. */
|
|
19200
|
-
.chip.kind-chip { color: var(--violet); background: var(--violet-bg); border-color: var(--violet-border); font-family: var(--font);
|
|
19201
|
-
.chip.drift-count-chip { color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border);
|
|
19517
|
+
.chip.kind-chip { color: var(--violet); background: var(--violet-bg); border-color: var(--violet-border); font-family: var(--font); }
|
|
19518
|
+
.chip.drift-count-chip { color: var(--amber); background: var(--amber-bg); border-color: var(--amber-border); }
|
|
19519
|
+
/* Prose, not an identifier — and neutral, since it qualifies the label chip
|
|
19520
|
+
beside it rather than claiming a severity of its own. */
|
|
19521
|
+
.chip.spec-change-chip { font-family: var(--font); }
|
|
19202
19522
|
.drift-meta-box { display: flex; flex-direction: column; gap: 4px; }
|
|
19203
19523
|
/* The chips carry their own margin for the run list, where they sit inline
|
|
19204
19524
|
after other chips. Here the container owns the spacing, so the margin only
|
|
@@ -19672,6 +19992,7 @@ const CLIENT_JS = `
|
|
|
19672
19992
|
"meta.drift": "Drift",
|
|
19673
19993
|
"diag.cause": "Cause", "diag.fix": "Fix",
|
|
19674
19994
|
"diag.surface": "Surface", "diag.surface.spec": "spec", "diag.surface.generated": "generated code",
|
|
19995
|
+
"diag.specChangeKind.FEATURE_REMOVED": "feature gone", "diag.specChangeKind.BEHAVIOUR_CHANGED": "behaviour changed",
|
|
19675
19996
|
"acc.reasoning": "Reasoning", "acc.evidence": "Evidence", "acc.steps": "Live run steps",
|
|
19676
19997
|
"acc.assertions": "Assertions",
|
|
19677
19998
|
"acc.artifacts": "Artifacts",
|
|
@@ -19680,7 +20001,7 @@ const CLIENT_JS = `
|
|
|
19680
20001
|
"spec.kind.live": "Live", "spec.kind.det": "Deterministic",
|
|
19681
20002
|
"det.steps": "Steps",
|
|
19682
20003
|
"det.noEvidence": "No step screenshots:",
|
|
19683
|
-
"kind.run": "Test run", "kind.drift": "Drift audit",
|
|
20004
|
+
"kind.run": "Test run", "kind.drift": "Drift audit", "kind.record": "Recording",
|
|
19684
20005
|
"drift.summary.ratio": "{found} of {total} specs",
|
|
19685
20006
|
"drift.clean": "No drift issues",
|
|
19686
20007
|
"status.passed": "passed", "status.failed": "failed", "status.skipped": "skipped", "status.running": "running",
|
|
@@ -19830,6 +20151,7 @@ const CLIENT_JS = `
|
|
|
19830
20151
|
"meta.drift": "ドリフト",
|
|
19831
20152
|
"diag.cause": "原因", "diag.fix": "対処",
|
|
19832
20153
|
"diag.surface": "対象", "diag.surface.spec": "spec", "diag.surface.generated": "生成コード",
|
|
20154
|
+
"diag.specChangeKind.FEATURE_REMOVED": "機能が無い", "diag.specChangeKind.BEHAVIOUR_CHANGED": "振る舞いが変わった",
|
|
19833
20155
|
"acc.reasoning": "推論", "acc.evidence": "根拠", "acc.steps": "実行ステップ",
|
|
19834
20156
|
"acc.assertions": "アサーション",
|
|
19835
20157
|
"acc.artifacts": "成果物",
|
|
@@ -19838,7 +20160,7 @@ const CLIENT_JS = `
|
|
|
19838
20160
|
"spec.kind.live": "ライブ", "spec.kind.det": "決定的",
|
|
19839
20161
|
"det.steps": "ステップ",
|
|
19840
20162
|
"det.noEvidence": "ステップのスクリーンショットなし:",
|
|
19841
|
-
"kind.run": "テスト実行", "kind.drift": "ドリフト監査",
|
|
20163
|
+
"kind.run": "テスト実行", "kind.drift": "ドリフト監査", "kind.record": "収録",
|
|
19842
20164
|
"drift.summary.ratio": "{found} / {total} スペック",
|
|
19843
20165
|
"drift.clean": "ドリフトの問題なし",
|
|
19844
20166
|
"status.passed": "合格", "status.failed": "失敗", "status.skipped": "スキップ", "status.running": "実行中",
|
|
@@ -20217,6 +20539,21 @@ const CLIENT_JS = `
|
|
|
20217
20539
|
return run.kind === "drift" ? driftFoundBadge(driftRunState(run), "drift.run.") : statusBadge(run.status);
|
|
20218
20540
|
}
|
|
20219
20541
|
|
|
20542
|
+
// Which command left the run, and whether its spec counts are a tally of
|
|
20543
|
+
// what was verified. A recording's rows are the specs it wrote, not specs it
|
|
20544
|
+
// checked, so "1 / 1 passed" and a full meter would claim a test result.
|
|
20545
|
+
// A kind from a newer hub keeps the generic label but is read the same
|
|
20546
|
+
// cautious way, since nothing here knows what its counts mean.
|
|
20547
|
+
var KINDS = {
|
|
20548
|
+
run: { label: "kind.run", verifies: true },
|
|
20549
|
+
drift: { label: "kind.drift", verifies: true },
|
|
20550
|
+
record: { label: "kind.record", verifies: false },
|
|
20551
|
+
};
|
|
20552
|
+
function kindOf(kind) { return KINDS[kind] || { label: "kind.run", verifies: false }; }
|
|
20553
|
+
function kindChip(kind) {
|
|
20554
|
+
return el("span", "chip kind-chip", t(kindOf(kind).label));
|
|
20555
|
+
}
|
|
20556
|
+
|
|
20220
20557
|
// A run's Claude spend, in the same $x.xxxx form as the per-step badge.
|
|
20221
20558
|
// A run that billed nothing, and one stored before costs were recorded, both
|
|
20222
20559
|
// arrive as a non-number — printing $0.0000 would claim a measured zero.
|
|
@@ -20487,12 +20824,10 @@ const CLIENT_JS = `
|
|
|
20487
20824
|
runCell.appendChild(el("div", "runid", r.id.slice(0, 8)));
|
|
20488
20825
|
var sub = el("div", "subline");
|
|
20489
20826
|
sub.appendChild(ciBadge(r));
|
|
20827
|
+
sub.appendChild(kindChip(r.kind));
|
|
20490
20828
|
if (r.kind === "drift") {
|
|
20491
|
-
sub.appendChild(el("span", "chip kind-chip", t("kind.drift")));
|
|
20492
20829
|
var rowDrift = driftSummary(r);
|
|
20493
20830
|
if (rowDrift) driftChips(rowDrift).forEach(function (chip) { sub.appendChild(chip); });
|
|
20494
|
-
} else {
|
|
20495
|
-
sub.appendChild(el("span", "chip kind-chip", t("kind.run")));
|
|
20496
20831
|
}
|
|
20497
20832
|
runCell.appendChild(sub);
|
|
20498
20833
|
tr.appendChild(runCell);
|
|
@@ -20509,29 +20844,33 @@ const CLIENT_JS = `
|
|
|
20509
20844
|
statusCell.appendChild(runStatusBadge(r));
|
|
20510
20845
|
tr.appendChild(statusCell);
|
|
20511
20846
|
|
|
20512
|
-
// A drift row counts what the audit found, not what "passed" — the same
|
|
20513
|
-
// ratio its detail page shows. Reading passed/total here printed a
|
|
20514
|
-
// different number for the same run in the two places you would compare.
|
|
20515
|
-
var rowDriftSummary = r.kind === "drift" ? driftSummary(r) : null;
|
|
20516
|
-
var found = rowDriftSummary
|
|
20517
|
-
? rowDriftSummary.testDrift + rowDriftSummary.specChange + rowDriftSummary.unknown
|
|
20518
|
-
: null;
|
|
20519
|
-
var num = rowDriftSummary
|
|
20520
|
-
? found + " / " + rowDriftSummary.specs
|
|
20521
|
-
: r.specs.passed + " / " + r.specs.total;
|
|
20522
|
-
var fillTotal = rowDriftSummary ? rowDriftSummary.specs : r.specs.total;
|
|
20523
|
-
var fillPart = rowDriftSummary ? found : r.specs.passed;
|
|
20524
|
-
|
|
20525
20847
|
var specsCell = document.createElement("td");
|
|
20526
|
-
|
|
20527
|
-
|
|
20528
|
-
|
|
20529
|
-
|
|
20530
|
-
|
|
20531
|
-
|
|
20532
|
-
|
|
20533
|
-
|
|
20534
|
-
|
|
20848
|
+
if (!kindOf(r.kind).verifies) {
|
|
20849
|
+
specsCell.appendChild(el("span", "muted", "—"));
|
|
20850
|
+
} else {
|
|
20851
|
+
// A drift row counts what the audit found, not what "passed" — the same
|
|
20852
|
+
// ratio its detail page shows. Reading passed/total here printed a
|
|
20853
|
+
// different number for the same run in the two places you would compare.
|
|
20854
|
+
var rowDriftSummary = r.kind === "drift" ? driftSummary(r) : null;
|
|
20855
|
+
var found = rowDriftSummary
|
|
20856
|
+
? rowDriftSummary.testDrift + rowDriftSummary.specChange + rowDriftSummary.unknown
|
|
20857
|
+
: null;
|
|
20858
|
+
var num = rowDriftSummary
|
|
20859
|
+
? found + " / " + rowDriftSummary.specs
|
|
20860
|
+
: r.specs.passed + " / " + r.specs.total;
|
|
20861
|
+
var fillTotal = rowDriftSummary ? rowDriftSummary.specs : r.specs.total;
|
|
20862
|
+
var fillPart = rowDriftSummary ? found : r.specs.passed;
|
|
20863
|
+
|
|
20864
|
+
var specsWrap = el("div", "specs");
|
|
20865
|
+
var meter = el("span", "meter" + (rowDriftSummary ? " drift" : ""));
|
|
20866
|
+
var pct = fillTotal > 0 ? Math.round((fillPart / fillTotal) * 100) : 0;
|
|
20867
|
+
var bar = el("i");
|
|
20868
|
+
bar.style.width = pct + "%";
|
|
20869
|
+
meter.appendChild(bar);
|
|
20870
|
+
specsWrap.appendChild(meter);
|
|
20871
|
+
specsWrap.appendChild(el("span", "num muted", num));
|
|
20872
|
+
specsCell.appendChild(specsWrap);
|
|
20873
|
+
}
|
|
20535
20874
|
tr.appendChild(specsCell);
|
|
20536
20875
|
|
|
20537
20876
|
tr.appendChild(el("td", "muted num", costText(r.costUsd)));
|
|
@@ -20570,7 +20909,7 @@ const CLIENT_JS = `
|
|
|
20570
20909
|
// What kind of run this is, said once. The spec cards below used to repeat
|
|
20571
20910
|
// it per row, which read as "this spec was drift-audited" — a property of
|
|
20572
20911
|
// the run described as if it varied spec to spec. Same chip as the run list.
|
|
20573
|
-
sub.appendChild(
|
|
20912
|
+
sub.appendChild(kindChip(run.kind));
|
|
20574
20913
|
idblock.appendChild(sub);
|
|
20575
20914
|
head.appendChild(idblock);
|
|
20576
20915
|
|
|
@@ -20604,7 +20943,7 @@ const CLIENT_JS = `
|
|
|
20604
20943
|
driftBox.appendChild(el("div", "muted", ratio));
|
|
20605
20944
|
metaItem(t("meta.drift"), driftBox);
|
|
20606
20945
|
}
|
|
20607
|
-
} else {
|
|
20946
|
+
} else if (kindOf(run.kind).verifies) {
|
|
20608
20947
|
metaItem(t("meta.specs"), run.specs.passed + " / " + run.specs.total + " " + t("meta.passed"));
|
|
20609
20948
|
}
|
|
20610
20949
|
// Everything this run spent on Claude — live browsing, triage, the audit a
|
|
@@ -20713,6 +21052,12 @@ const CLIENT_JS = `
|
|
|
20713
21052
|
var a = r.analysis;
|
|
20714
21053
|
var head = el("div", "analysis-head");
|
|
20715
21054
|
head.appendChild(labelChip(a.label));
|
|
21055
|
+
// Which repair a SPEC_CHANGE needs — delete the spec, or rewrite it. The
|
|
21056
|
+
// label is re-checked rather than trusted: this chip means nothing beside
|
|
21057
|
+
// any other one, however the row reached the browser.
|
|
21058
|
+
if (a.label === "SPEC_CHANGE" && a.specChangeKind) {
|
|
21059
|
+
head.appendChild(el("span", "chip spec-change-chip", t("diag.specChangeKind." + a.specChangeKind)));
|
|
21060
|
+
}
|
|
20716
21061
|
head.appendChild(el("span", "conf", Math.round(a.confidence * 100) + "%"));
|
|
20717
21062
|
wrap.appendChild(head);
|
|
20718
21063
|
var kv = el("div", "analysis-kv");
|
|
@@ -21907,8 +22252,12 @@ const CLIENT_JS = `
|
|
|
21907
22252
|
// runs and no deploys to judge). One runs page answers both: runId -> CI URL,
|
|
21908
22253
|
// and the profiles a run was actually recorded under. A run pushed without a
|
|
21909
22254
|
// profile lands in "default", exactly as the ledger stores it.
|
|
22255
|
+
//
|
|
22256
|
+
// Only the two kinds a ledger entry can point at, so recordings — which
|
|
22257
|
+
// advance no ledger and are never looked up here — cannot crowd them out of
|
|
22258
|
+
// the window.
|
|
21910
22259
|
function fetchRunIndex() {
|
|
21911
|
-
return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&limit=200")
|
|
22260
|
+
return apiFetch("/api/v1/runs?project=" + encodeURIComponent(state.project) + "&kind=run,drift&limit=200")
|
|
21912
22261
|
.then(function (data) {
|
|
21913
22262
|
var urls = {};
|
|
21914
22263
|
var profiles = [];
|
|
@@ -23641,7 +23990,8 @@ function createLearningWorker(deps) {
|
|
|
23641
23990
|
const runLimit = job.input.runLimit > 0 ? job.input.runLimit : DEFAULT_RUN_LIMIT;
|
|
23642
23991
|
const runs = await storage.runs.list({
|
|
23643
23992
|
project: job.project,
|
|
23644
|
-
limit: runLimit
|
|
23993
|
+
limit: runLimit,
|
|
23994
|
+
kinds: ["run"]
|
|
23645
23995
|
});
|
|
23646
23996
|
const cases = [];
|
|
23647
23997
|
let excluded = 0;
|
|
@@ -23840,6 +24190,8 @@ function registerRoutes(router, config, queue) {
|
|
|
23840
24190
|
router.get("/api/v1/projects/:project/audit-needed", createGetAuditNeedHandler(storage));
|
|
23841
24191
|
router.post("/api/v1/projects/:project/locks", createAcquireLocksHandler(storage));
|
|
23842
24192
|
router.delete("/api/v1/projects/:project/locks", createReleaseLocksHandler(storage));
|
|
24193
|
+
router.get("/api/v1/projects/:project/acks/:name", createGetAckHandler(storage));
|
|
24194
|
+
router.put("/api/v1/projects/:project/acks/:name", createPutAckHandler(storage));
|
|
23843
24195
|
const sessionConfig = {
|
|
23844
24196
|
store: storage.sessions,
|
|
23845
24197
|
encryptionKey: config.encryptionKey
|
|
@@ -23874,6 +24226,16 @@ function registerRoutes(router, config, queue) {
|
|
|
23874
24226
|
}
|
|
23875
24227
|
//#endregion
|
|
23876
24228
|
//#region src/hub/core/storage/file/fs-helpers.ts
|
|
24229
|
+
/**
|
|
24230
|
+
* Defense-in-depth for a name this layer joins into a file path. The API's
|
|
24231
|
+
* `SAFE_SEGMENT` (api/validate.ts) is deliberately stricter — it also fixes a
|
|
24232
|
+
* charset and a length — and stays the rule for what a client may name; this
|
|
24233
|
+
* only refuses traversal, so it also covers callers that never came through
|
|
24234
|
+
* HTTP (tests, a library embedding the hub).
|
|
24235
|
+
*/
|
|
24236
|
+
function assertSafeName(value, label) {
|
|
24237
|
+
if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) throw new Error(`invalid ${label}: must be a bare name without path separators or '..'`);
|
|
24238
|
+
}
|
|
23877
24239
|
/** Read and JSON-parse a file, returning `null` when it doesn't exist. Malformed JSON throws. */
|
|
23878
24240
|
async function readJson(path) {
|
|
23879
24241
|
let raw;
|
|
@@ -24013,6 +24375,7 @@ function isNotFound(err) {
|
|
|
24013
24375
|
* drift-ledger/<project>/<branch>.json (DriftLedger, no profile)
|
|
24014
24376
|
* deploys/<project>/<profile>/log.json (DeployLog, ring-buffered)
|
|
24015
24377
|
* deploys/<project>/<profile>/touch.json (SpecTouchIndex derived from the log)
|
|
24378
|
+
* acks/<project>/<profile>/<name>.json (Ack: a consumer's acted-on keys)
|
|
24016
24379
|
*
|
|
24017
24380
|
* IDs and names are validated by their callers (run ids are server-minted
|
|
24018
24381
|
* UUIDs; project/profile/name come from validated request params) before
|
|
@@ -24104,6 +24467,43 @@ function deployTouchIndexPath(root, project, profile) {
|
|
|
24104
24467
|
function specLocksPath(root, project, profile) {
|
|
24105
24468
|
return join(root, "locks", project, profile, "locks.json");
|
|
24106
24469
|
}
|
|
24470
|
+
function ackPath(root, project, profile, name) {
|
|
24471
|
+
return join(root, "acks", project, profile, `${name}.json`);
|
|
24472
|
+
}
|
|
24473
|
+
//#endregion
|
|
24474
|
+
//#region src/hub/core/storage/file/ack-store.ts
|
|
24475
|
+
function assertSafeKey(project, profile, name) {
|
|
24476
|
+
assertSafeName(project, "project");
|
|
24477
|
+
assertSafeName(profile, "profile");
|
|
24478
|
+
assertSafeName(name, "name");
|
|
24479
|
+
}
|
|
24480
|
+
/**
|
|
24481
|
+
* Ack storage: one JSON document per (project, profile, name). A write
|
|
24482
|
+
* replaces the document outright, so unlike the ledgers there is no
|
|
24483
|
+
* read-modify-write to serialize — but it still goes through `writeJson`'s
|
|
24484
|
+
* temp-then-rename, so a concurrent reader never sees a half-written set.
|
|
24485
|
+
*/
|
|
24486
|
+
function createFileAckStore(root) {
|
|
24487
|
+
return {
|
|
24488
|
+
async get(project, profile, name) {
|
|
24489
|
+
assertSafeKey(project, profile, name);
|
|
24490
|
+
const parsed = AckSchema.safeParse(await readJson(ackPath(root, project, profile, name)));
|
|
24491
|
+
return parsed.success ? parsed.data : {
|
|
24492
|
+
keys: [],
|
|
24493
|
+
at: null
|
|
24494
|
+
};
|
|
24495
|
+
},
|
|
24496
|
+
async put(project, profile, name, keys) {
|
|
24497
|
+
assertSafeKey(project, profile, name);
|
|
24498
|
+
const ack = {
|
|
24499
|
+
keys,
|
|
24500
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
24501
|
+
};
|
|
24502
|
+
await writeJson(ackPath(root, project, profile, name), ack);
|
|
24503
|
+
return ack;
|
|
24504
|
+
}
|
|
24505
|
+
};
|
|
24506
|
+
}
|
|
24107
24507
|
//#endregion
|
|
24108
24508
|
//#region src/hub/core/storage/file/artifact-store.ts
|
|
24109
24509
|
/**
|
|
@@ -24274,14 +24674,6 @@ function createFileSpecLedgerStore(root) {
|
|
|
24274
24674
|
//#endregion
|
|
24275
24675
|
//#region src/hub/core/storage/file/perspectives-store.ts
|
|
24276
24676
|
/**
|
|
24277
|
-
* Defense-in-depth path validation: the HTTP layer already checks the project
|
|
24278
|
-
* segment, but this builds a file path from it, so it re-checks rather than
|
|
24279
|
-
* trusting callers. Mirrors the sibling prompt/secret stores.
|
|
24280
|
-
*/
|
|
24281
|
-
function assertSafeName$2(value, label) {
|
|
24282
|
-
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 '..'`);
|
|
24283
|
-
}
|
|
24284
|
-
/**
|
|
24285
24677
|
* Perspectives storage: one JSON document per project, plain UTF-8 with no
|
|
24286
24678
|
* encryption (an inventory of what is tested is not a secret). No meta file —
|
|
24287
24679
|
* the document's own `generatedAt` is its timestamp.
|
|
@@ -24289,19 +24681,19 @@ function assertSafeName$2(value, label) {
|
|
|
24289
24681
|
function createFilePerspectivesStore(root) {
|
|
24290
24682
|
return {
|
|
24291
24683
|
async put(project, blob) {
|
|
24292
|
-
assertSafeName
|
|
24684
|
+
assertSafeName(project, "project");
|
|
24293
24685
|
await writeBytes(perspectivesPath(root, project), blob);
|
|
24294
24686
|
},
|
|
24295
24687
|
async get(project) {
|
|
24296
|
-
assertSafeName
|
|
24688
|
+
assertSafeName(project, "project");
|
|
24297
24689
|
return readBytesOrNull(perspectivesPath(root, project));
|
|
24298
24690
|
},
|
|
24299
24691
|
async update(project, mutate) {
|
|
24300
|
-
assertSafeName
|
|
24692
|
+
assertSafeName(project, "project");
|
|
24301
24693
|
await updateJson(perspectivesPath(root, project), mutate);
|
|
24302
24694
|
},
|
|
24303
24695
|
async delete(project) {
|
|
24304
|
-
assertSafeName
|
|
24696
|
+
assertSafeName(project, "project");
|
|
24305
24697
|
await removePath(perspectivesPath(root, project));
|
|
24306
24698
|
}
|
|
24307
24699
|
};
|
|
@@ -24309,15 +24701,6 @@ function createFilePerspectivesStore(root) {
|
|
|
24309
24701
|
//#endregion
|
|
24310
24702
|
//#region src/hub/core/storage/file/prompt-store.ts
|
|
24311
24703
|
/**
|
|
24312
|
-
* Defense-in-depth path validation: the HTTP layer already checks project/name,
|
|
24313
|
-
* but this builds file paths from them, so it re-checks rather than trusting
|
|
24314
|
-
* callers. (Which names are allowed at all is the handler's job — this only
|
|
24315
|
-
* guards against path traversal.)
|
|
24316
|
-
*/
|
|
24317
|
-
function assertSafeName$1(value, label) {
|
|
24318
|
-
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 '..'`);
|
|
24319
|
-
}
|
|
24320
|
-
/**
|
|
24321
24704
|
* Prompt storage, project-scoped (not per-profile — prompts are project-wide).
|
|
24322
24705
|
* The blob is plain UTF-8 text (Markdown or custom prompt JSON) with no encryption,
|
|
24323
24706
|
* so this works whether or not `CCQA_HUB_ENCRYPTION_KEY` is configured.
|
|
@@ -24325,8 +24708,8 @@ function assertSafeName$1(value, label) {
|
|
|
24325
24708
|
function createFilePromptStore(root) {
|
|
24326
24709
|
return {
|
|
24327
24710
|
async put(project, name, blob, meta = {}) {
|
|
24328
|
-
assertSafeName
|
|
24329
|
-
assertSafeName
|
|
24711
|
+
assertSafeName(project, "project");
|
|
24712
|
+
assertSafeName(name, "name");
|
|
24330
24713
|
await writeJson(promptMetaPath(root, project, name), {
|
|
24331
24714
|
meta,
|
|
24332
24715
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -24334,8 +24717,8 @@ function createFilePromptStore(root) {
|
|
|
24334
24717
|
await writeBytes(promptBlobPath(root, project, name), blob);
|
|
24335
24718
|
},
|
|
24336
24719
|
async get(project, name) {
|
|
24337
|
-
assertSafeName
|
|
24338
|
-
assertSafeName
|
|
24720
|
+
assertSafeName(project, "project");
|
|
24721
|
+
assertSafeName(name, "name");
|
|
24339
24722
|
const blob = await readBytesOrNull(promptBlobPath(root, project, name));
|
|
24340
24723
|
if (!blob) return null;
|
|
24341
24724
|
return {
|
|
@@ -24344,7 +24727,7 @@ function createFilePromptStore(root) {
|
|
|
24344
24727
|
};
|
|
24345
24728
|
},
|
|
24346
24729
|
async list(project) {
|
|
24347
|
-
assertSafeName
|
|
24730
|
+
assertSafeName(project, "project");
|
|
24348
24731
|
const names = (await listDirOrEmpty(promptProjectDir(root, project))).filter((f) => f.endsWith(".txt")).map((f) => f.slice(0, -4));
|
|
24349
24732
|
const out = [];
|
|
24350
24733
|
for (const name of names) {
|
|
@@ -24358,8 +24741,8 @@ function createFilePromptStore(root) {
|
|
|
24358
24741
|
return out;
|
|
24359
24742
|
},
|
|
24360
24743
|
async delete(project, name) {
|
|
24361
|
-
assertSafeName
|
|
24362
|
-
assertSafeName
|
|
24744
|
+
assertSafeName(project, "project");
|
|
24745
|
+
assertSafeName(name, "name");
|
|
24363
24746
|
await removePath(promptBlobPath(root, project, name));
|
|
24364
24747
|
await removePath(promptMetaPath(root, project, name));
|
|
24365
24748
|
},
|
|
@@ -24401,7 +24784,7 @@ function createFileRunStore(root) {
|
|
|
24401
24784
|
};
|
|
24402
24785
|
});
|
|
24403
24786
|
},
|
|
24404
|
-
async list({ project, branch, status, limit }) {
|
|
24787
|
+
async list({ project, branch, status, kinds, limit }) {
|
|
24405
24788
|
const ids = await listSubdirsOrEmpty(runsDir(root));
|
|
24406
24789
|
const runs = [];
|
|
24407
24790
|
for (const id of ids) {
|
|
@@ -24410,6 +24793,7 @@ function createFileRunStore(root) {
|
|
|
24410
24793
|
if (project !== void 0 && run.project !== project) continue;
|
|
24411
24794
|
if (branch !== void 0 && run.branch !== branch) continue;
|
|
24412
24795
|
if (status !== void 0 && run.status !== status) continue;
|
|
24796
|
+
if (kinds !== void 0 && !kinds.includes(run.kind)) continue;
|
|
24413
24797
|
runs.push(run);
|
|
24414
24798
|
}
|
|
24415
24799
|
runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
@@ -24428,14 +24812,6 @@ function createFileRunStore(root) {
|
|
|
24428
24812
|
}
|
|
24429
24813
|
//#endregion
|
|
24430
24814
|
//#region src/hub/core/storage/file/secret-store.ts
|
|
24431
|
-
/**
|
|
24432
|
-
* Defense-in-depth: project/profile/name are expected to already be validated
|
|
24433
|
-
* by the HTTP layer (`requireSafeSegment`), but this store builds file paths
|
|
24434
|
-
* directly from them, so it re-checks rather than trusting callers blindly.
|
|
24435
|
-
*/
|
|
24436
|
-
function assertSafeName(value, label) {
|
|
24437
|
-
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 '..'`);
|
|
24438
|
-
}
|
|
24439
24815
|
function assertSafeScope(scope) {
|
|
24440
24816
|
assertSafeName(scope.project, "project");
|
|
24441
24817
|
assertSafeName(scope.profile, "profile");
|
|
@@ -24529,7 +24905,8 @@ function createFileHubStorage(dataDir) {
|
|
|
24529
24905
|
ledger: createFileSpecLedgerStore(dataDir),
|
|
24530
24906
|
driftLedger: createFileDriftLedgerStore(dataDir),
|
|
24531
24907
|
deploys: createFileDeployStore(dataDir),
|
|
24532
|
-
locks: createFileLockStore(dataDir)
|
|
24908
|
+
locks: createFileLockStore(dataDir),
|
|
24909
|
+
acks: createFileAckStore(dataDir)
|
|
24533
24910
|
};
|
|
24534
24911
|
}
|
|
24535
24912
|
//#endregion
|