ccqa 1.17.0 → 1.18.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/README.md +7 -0
- package/dist/bin/ccqa.mjs +241 -98
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -125,6 +125,13 @@ run executes it claims its specs, so a cycle that starts before the last one
|
|
|
125
125
|
finishes skips what is already running instead of driving the same flow
|
|
126
126
|
twice.
|
|
127
127
|
|
|
128
|
+
Specs that write to the same place outside your app — a chat channel, a
|
|
129
|
+
shared inbox — join a
|
|
130
|
+
[`serialGroups`](./docs/targets.md#serialgroups--specs-that-must-not-run-at-the-same-time)
|
|
131
|
+
entry in `.ccqa/config.yaml`. The claim covers those groups too, so
|
|
132
|
+
`--concurrency` shortens a run without letting two specs read each other's
|
|
133
|
+
effects, in this run or the next one.
|
|
134
|
+
|
|
128
135
|
When a clean spec still fails, `--on-fail-explain` labels whose problem
|
|
129
136
|
it is: `TEST_DRIFT`, `SPEC_CHANGE`, `PRODUCT_BUG`, or `UNKNOWN`. You
|
|
130
137
|
grade the calls on the hub, and it learns from your grades.
|
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -180,27 +180,31 @@ const StepSchema = z.union([ActionStepSchema, IncludeStepSchema]);
|
|
|
180
180
|
*/
|
|
181
181
|
const SpecModeSchema = z.enum(["deterministic", "live"]);
|
|
182
182
|
/**
|
|
183
|
-
* A
|
|
184
|
-
*
|
|
185
|
-
* `.ccqa/sessions/<profile>/<name>.json` at run time. Restricted to a safe
|
|
186
|
-
* slug so the name can't escape the sessions directory.
|
|
183
|
+
* A name a spec chooses that ccqa resolves to a path or looks up in a
|
|
184
|
+
* registry. Restricted to a slug so it cannot escape a directory.
|
|
187
185
|
*/
|
|
188
|
-
|
|
186
|
+
function slug(what) {
|
|
187
|
+
return z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, `${what} must be a slug (letters, digits, '.', '_', '-'; no path separators)`);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* A saved browser session (cookies + localStorage) to restore before the spec
|
|
191
|
+
* runs, resolved to `.ccqa/sessions/<profile>/<name>.json` at run time.
|
|
192
|
+
*/
|
|
193
|
+
const SessionNameSchema = slug("session name");
|
|
189
194
|
/**
|
|
190
|
-
* Sessions to restore before a `mode: live` spec runs:
|
|
191
|
-
*
|
|
192
|
-
* agent-browser state file; multiple names are merged (their cookies +
|
|
195
|
+
* Sessions to restore before a `mode: live` spec runs: one name or a list,
|
|
196
|
+
* always read back as a list. Multiple names are merged (their cookies +
|
|
193
197
|
* localStorage are unioned) and restored together, so a spec can start
|
|
194
198
|
* signed-in to several providers at once.
|
|
195
199
|
*/
|
|
196
200
|
const SessionFieldSchema = z.union([SessionNameSchema, z.array(SessionNameSchema).min(1)]).transform((v) => Array.isArray(v) ? v : [v]);
|
|
197
201
|
/**
|
|
198
202
|
* A generation-target id: which plugin turns this spec into runnable tests
|
|
199
|
-
* (e.g. "agent-browser", "playwright", "runn").
|
|
200
|
-
*
|
|
201
|
-
*
|
|
203
|
+
* (e.g. "agent-browser", "playwright", "runn"). Whether the id names a
|
|
204
|
+
* registered target is the registry's responsibility, so new targets don't
|
|
205
|
+
* require a schema change.
|
|
202
206
|
*/
|
|
203
|
-
const TargetIdSchema =
|
|
207
|
+
const TargetIdSchema = slug("target");
|
|
204
208
|
/** The built-in recorder-backed target. `mode:` / `session:` only apply to it. */
|
|
205
209
|
const AGENT_BROWSER_TARGET = "agent-browser";
|
|
206
210
|
/**
|
|
@@ -794,23 +798,42 @@ function stepArtifactPaths(runDir, stepId) {
|
|
|
794
798
|
}
|
|
795
799
|
//#endregion
|
|
796
800
|
//#region src/runtime/pool.ts
|
|
797
|
-
|
|
798
|
-
* Run each item through `fn` with at most `concurrency` running at once.
|
|
799
|
-
* Results preserve input order. A throwing `fn` rejects the whole pool
|
|
800
|
-
* (callers that want per-item isolation should catch inside `fn`).
|
|
801
|
-
*/
|
|
802
|
-
async function runPool(items, concurrency, fn) {
|
|
801
|
+
async function runPool(items, concurrency, fn, opts = {}) {
|
|
803
802
|
const results = new Array(items.length);
|
|
804
|
-
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
803
|
+
const needs = items.map((item) => opts.resources?.(item) ?? []);
|
|
804
|
+
const busy = /* @__PURE__ */ new Set();
|
|
805
|
+
const queued = new Set(items.map((_, i) => i));
|
|
806
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
807
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
808
|
+
const failures = [];
|
|
809
|
+
const start = (idx) => {
|
|
810
|
+
queued.delete(idx);
|
|
811
|
+
for (const name of needs[idx]) busy.add(name);
|
|
812
|
+
inFlight.set(idx, Promise.resolve().then(async () => {
|
|
813
|
+
try {
|
|
814
|
+
results[idx] = await fn(items[idx], idx);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
failures.push(err);
|
|
817
|
+
} finally {
|
|
818
|
+
for (const name of needs[idx]) busy.delete(name);
|
|
819
|
+
inFlight.delete(idx);
|
|
820
|
+
}
|
|
821
|
+
}));
|
|
811
822
|
};
|
|
812
|
-
|
|
813
|
-
|
|
823
|
+
while (queued.size > 0 || inFlight.size > 0) {
|
|
824
|
+
if (failures.length === 0) {
|
|
825
|
+
for (const idx of queued) {
|
|
826
|
+
if (inFlight.size >= limit) break;
|
|
827
|
+
if (needs[idx].some((name) => busy.has(name))) continue;
|
|
828
|
+
start(idx);
|
|
829
|
+
}
|
|
830
|
+
if (inFlight.size === 0) throw new Error(`runPool: ${queued.size} item(s) unrunnable with nothing in flight`);
|
|
831
|
+
}
|
|
832
|
+
if (inFlight.size === 0) break;
|
|
833
|
+
await Promise.race(inFlight.values());
|
|
834
|
+
}
|
|
835
|
+
if (failures.length === 1) throw failures[0];
|
|
836
|
+
if (failures.length > 1) throw new AggregateError(failures, `${failures.length} items failed`);
|
|
814
837
|
return results;
|
|
815
838
|
}
|
|
816
839
|
//#endregion
|
|
@@ -2966,7 +2989,7 @@ const runCommandRunner = { async run(specs, opts) {
|
|
|
2966
2989
|
warn(`${key}: could not report row incrementally: ${err instanceof Error ? err.message : String(err)}`);
|
|
2967
2990
|
}
|
|
2968
2991
|
return row;
|
|
2969
|
-
});
|
|
2992
|
+
}, { resources: opts.resources });
|
|
2970
2993
|
} };
|
|
2971
2994
|
/** File `ccqa generate` writes into the spec directory for external targets. */
|
|
2972
2995
|
const GENERATED_MANIFEST_FILE = "generated.json";
|
|
@@ -6711,27 +6734,57 @@ async function tryDeployHeadSha(hubCtx, profile) {
|
|
|
6711
6734
|
* produced, and leaving them out would make the list look shorter than the
|
|
6712
6735
|
* run's report will be.
|
|
6713
6736
|
*/
|
|
6714
|
-
function formatDryRunLines(agentBrowser, routed) {
|
|
6737
|
+
function formatDryRunLines(agentBrowser, routed, resources) {
|
|
6715
6738
|
const tagged = [
|
|
6716
6739
|
...agentBrowser.map((s) => ({
|
|
6717
6740
|
key: specKey(s),
|
|
6718
|
-
tag: s.mode
|
|
6741
|
+
tag: s.mode,
|
|
6742
|
+
held: resources(s)
|
|
6719
6743
|
})),
|
|
6720
6744
|
...routed.external.flatMap((g) => g.specs.map((s) => ({
|
|
6721
6745
|
key: specKey(s),
|
|
6722
|
-
tag: g.targetId
|
|
6746
|
+
tag: g.targetId,
|
|
6747
|
+
held: resources(s)
|
|
6723
6748
|
}))),
|
|
6724
6749
|
...routed.skipped.map((s) => ({
|
|
6725
6750
|
key: specKey(s),
|
|
6726
|
-
tag: `skipped — ${s.reason}
|
|
6751
|
+
tag: `skipped — ${s.reason}`,
|
|
6752
|
+
held: []
|
|
6727
6753
|
})),
|
|
6728
6754
|
...routed.unresolved.map((s) => ({
|
|
6729
6755
|
key: specKey(s),
|
|
6730
|
-
tag: `unresolved — ${s.reason}
|
|
6756
|
+
tag: `unresolved — ${s.reason}`,
|
|
6757
|
+
held: []
|
|
6731
6758
|
}))
|
|
6732
6759
|
];
|
|
6733
6760
|
const width = Math.max(0, ...tagged.map((t) => t.key.length));
|
|
6734
|
-
return tagged.map((t) => ` ${t.key.padEnd(width)} ${t.tag}`);
|
|
6761
|
+
return tagged.map((t) => ` ${t.key.padEnd(width)} ${t.tag}` + (t.held.length > 0 ? ` serial: ${t.held.join(", ")}` : ""));
|
|
6762
|
+
}
|
|
6763
|
+
//#endregion
|
|
6764
|
+
//#region src/run/serial-groups.ts
|
|
6765
|
+
const NO_GROUPS = () => [];
|
|
6766
|
+
/**
|
|
6767
|
+
* Invert `serialGroups` into a per-spec lookup, checking every member names a
|
|
6768
|
+
* spec that exists.
|
|
6769
|
+
*
|
|
6770
|
+
* The check is the point of putting the groups here: a member that resolves to
|
|
6771
|
+
* nothing is a typo, and left unchecked it would quietly shrink the group
|
|
6772
|
+
* instead of failing. Validated against every spec in the project, not the
|
|
6773
|
+
* selection, so a group whose members this run did not select is still read as
|
|
6774
|
+
* correct.
|
|
6775
|
+
*/
|
|
6776
|
+
async function resolveSerialGroups(groups, cwd) {
|
|
6777
|
+
const names = Object.keys(groups);
|
|
6778
|
+
if (names.length === 0) return NO_GROUPS;
|
|
6779
|
+
const known = new Set((await listAllSpecsWithSpecFile(cwd)).map(specKey));
|
|
6780
|
+
const bySpec = /* @__PURE__ */ new Map();
|
|
6781
|
+
for (const name of names) for (const member of groups[name] ?? []) {
|
|
6782
|
+
if (!known.has(member)) throw new RunUsageError(`serialGroups.${name} lists "${member}", which is not a spec in this project`);
|
|
6783
|
+
const list = bySpec.get(member);
|
|
6784
|
+
if (list) list.push(name);
|
|
6785
|
+
else bySpec.set(member, [name]);
|
|
6786
|
+
}
|
|
6787
|
+
return (ref) => bySpec.get(specKey(ref)) ?? [];
|
|
6735
6788
|
}
|
|
6736
6789
|
//#endregion
|
|
6737
6790
|
//#region src/hub/contract/schema.ts
|
|
@@ -6998,7 +7051,11 @@ const SpecLockSchema = z.object({
|
|
|
6998
7051
|
holder: z.string(),
|
|
6999
7052
|
expiresAt: z.string()
|
|
7000
7053
|
});
|
|
7001
|
-
/**
|
|
7054
|
+
/**
|
|
7055
|
+
* The per-(project, profile) lock document: key → who holds it. A key is a
|
|
7056
|
+
* spec ("feature/spec") or a shared resource ("resource:<name>", ADR-0015);
|
|
7057
|
+
* the separators keep the two apart.
|
|
7058
|
+
*/
|
|
7002
7059
|
const SpecLocksSchema = z.object({ specs: z.record(z.string(), SpecLockSchema).default({}) });
|
|
7003
7060
|
/** Body of `POST /projects/:project/locks?profile=`. */
|
|
7004
7061
|
const AcquireLocksRequestSchema = z.object({
|
|
@@ -7281,26 +7338,34 @@ function emitGithubAnnotations(data) {
|
|
|
7281
7338
|
return lines;
|
|
7282
7339
|
}
|
|
7283
7340
|
//#endregion
|
|
7284
|
-
//#region src/
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7341
|
+
//#region src/run/spec-catalog.ts
|
|
7342
|
+
async function readSpecs(refs, cwd) {
|
|
7343
|
+
const entries = await Promise.all(refs.map(async (ref) => {
|
|
7344
|
+
const yaml = await tryReadSpecFile(ref.featureName, ref.specName, cwd);
|
|
7345
|
+
if (yaml === null) return [specKey(ref), {
|
|
7346
|
+
spec: null,
|
|
7347
|
+
error: null
|
|
7348
|
+
}];
|
|
7349
|
+
try {
|
|
7350
|
+
return [specKey(ref), {
|
|
7351
|
+
spec: parseTestSpec(yaml),
|
|
7352
|
+
error: null
|
|
7353
|
+
}];
|
|
7354
|
+
} catch (err) {
|
|
7355
|
+
return [specKey(ref), {
|
|
7356
|
+
spec: null,
|
|
7357
|
+
error: errMessage(err)
|
|
7358
|
+
}];
|
|
7359
|
+
}
|
|
7360
|
+
}));
|
|
7361
|
+
return new Map(entries);
|
|
7295
7362
|
}
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
}
|
|
7302
|
-
return DEFAULT_SPEC_MODE;
|
|
7303
|
-
}
|
|
7363
|
+
/** Spec-declared `mode:` wins; otherwise `DEFAULT_SPEC_MODE`. */
|
|
7364
|
+
function resolveSpecsModes(specs, catalog) {
|
|
7365
|
+
return specs.map((s) => ({
|
|
7366
|
+
...s,
|
|
7367
|
+
mode: catalog.get(specKey(s))?.spec?.mode ?? "deterministic"
|
|
7368
|
+
}));
|
|
7304
7369
|
}
|
|
7305
7370
|
//#endregion
|
|
7306
7371
|
//#region src/cli/stale-blocks.ts
|
|
@@ -9695,7 +9760,7 @@ async function runLiveSpecs(specs, opts) {
|
|
|
9695
9760
|
row
|
|
9696
9761
|
};
|
|
9697
9762
|
});
|
|
9698
|
-
});
|
|
9763
|
+
}, { resources: opts.resources });
|
|
9699
9764
|
const runs = built.map((b) => b.outcome);
|
|
9700
9765
|
const failedCount = runs.filter((r) => r.kind === "error" || r.kind === "run" && r.result.status === "failed").length;
|
|
9701
9766
|
blank();
|
|
@@ -10083,13 +10148,26 @@ const TargetConfigSchema = z.object({
|
|
|
10083
10148
|
})
|
|
10084
10149
|
}).strict();
|
|
10085
10150
|
/**
|
|
10151
|
+
* Specs that must not run at the same time, grouped by the thing they share.
|
|
10152
|
+
*
|
|
10153
|
+
* The key names the shared thing (a chat channel, a seeded account, a tenant);
|
|
10154
|
+
* the list names the specs that write to it. `ccqa run` never runs two members
|
|
10155
|
+
* of one group concurrently, and specs sharing no group still run in parallel.
|
|
10156
|
+
*
|
|
10157
|
+
* Kept here rather than on each spec so there is one place to read the whole
|
|
10158
|
+
* picture, and so a mistyped member is a spec key that does not resolve —
|
|
10159
|
+
* caught — rather than a resource name that silently matches nothing.
|
|
10160
|
+
*/
|
|
10161
|
+
const SerialGroupsSchema = z.record(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/i, "serial group name must be a slug (letters, digits, '.', '_', '-')"), z.array(z.string().min(1)).min(1));
|
|
10162
|
+
/**
|
|
10086
10163
|
* Top-level `.ccqa/config.yaml` schema. `defaultTarget` is used by specs
|
|
10087
10164
|
* with no `target:` of their own. Both defaults make a missing config file
|
|
10088
10165
|
* equivalent to "agent-browser only, no extra settings".
|
|
10089
10166
|
*/
|
|
10090
10167
|
const ProjectConfigSchema = z.object({
|
|
10091
10168
|
defaultTarget: TargetIdSchema.default(AGENT_BROWSER_TARGET),
|
|
10092
|
-
targets: z.record(TargetIdSchema, TargetConfigSchema).default({})
|
|
10169
|
+
targets: z.record(TargetIdSchema, TargetConfigSchema).default({}),
|
|
10170
|
+
serialGroups: SerialGroupsSchema.default({})
|
|
10093
10171
|
}).strict();
|
|
10094
10172
|
/** Config file location, relative to the project root (`--cwd`). */
|
|
10095
10173
|
const PROJECT_CONFIG_PATH = ".ccqa/config.yaml";
|
|
@@ -11635,20 +11713,30 @@ function resolveTargetFrom(spec, config, registry) {
|
|
|
11635
11713
|
//#endregion
|
|
11636
11714
|
//#region src/run/target-dispatch.ts
|
|
11637
11715
|
/**
|
|
11638
|
-
*
|
|
11639
|
-
*
|
|
11640
|
-
*
|
|
11641
|
-
*
|
|
11642
|
-
*
|
|
11643
|
-
*
|
|
11716
|
+
* Resolve each spec's target and group. A spec with no spec.yaml at all falls
|
|
11717
|
+
* through to the agent-browser path, whose runner surfaces the real error
|
|
11718
|
+
* itself. A spec whose file will not parse, or whose target resolution throws
|
|
11719
|
+
* (unknown target, agent-browser-only fields on another target), is recorded
|
|
11720
|
+
* per-spec instead of stopping the run. `resolve` is injectable so tests can
|
|
11721
|
+
* supply a registry of fake targets.
|
|
11644
11722
|
*/
|
|
11645
|
-
|
|
11723
|
+
function groupSpecsByTarget(specs, catalog, config, resolve = resolveTarget) {
|
|
11646
11724
|
const agentBrowser = [];
|
|
11647
11725
|
const externalById = /* @__PURE__ */ new Map();
|
|
11648
11726
|
const skipped = [];
|
|
11649
11727
|
const unresolved = [];
|
|
11650
11728
|
for (const ref of specs) {
|
|
11651
|
-
const
|
|
11729
|
+
const read = catalog.get(specKey(ref));
|
|
11730
|
+
if (read?.error) {
|
|
11731
|
+
unresolved.push({
|
|
11732
|
+
...ref,
|
|
11733
|
+
title: null,
|
|
11734
|
+
reason: read.error,
|
|
11735
|
+
targetId: null
|
|
11736
|
+
});
|
|
11737
|
+
continue;
|
|
11738
|
+
}
|
|
11739
|
+
const spec = read?.spec ?? null;
|
|
11652
11740
|
if (spec === null) {
|
|
11653
11741
|
agentBrowser.push(ref);
|
|
11654
11742
|
continue;
|
|
@@ -11756,6 +11844,7 @@ async function runExternalSpecs(dispatch, ctx) {
|
|
|
11756
11844
|
cwd: ctx.cwd,
|
|
11757
11845
|
reportDir: ctx.reportDir,
|
|
11758
11846
|
concurrency: ctx.concurrency,
|
|
11847
|
+
resources: ctx.resources,
|
|
11759
11848
|
...ctx.model ? { model: ctx.model } : {},
|
|
11760
11849
|
...ctx.language ? { language: ctx.language } : {},
|
|
11761
11850
|
targetId: group.targetId,
|
|
@@ -12259,34 +12348,66 @@ function dedupeSpecs(specs) {
|
|
|
12259
12348
|
*/
|
|
12260
12349
|
const LOCK_TTL_SECONDS = 10800;
|
|
12261
12350
|
/**
|
|
12262
|
-
*
|
|
12263
|
-
*
|
|
12264
|
-
* not stop a run that would otherwise work.
|
|
12351
|
+
* A serial group's name as a lock key. The `:` keeps it out of reach of spec
|
|
12352
|
+
* keys, which are always `feature/spec`.
|
|
12265
12353
|
*/
|
|
12266
|
-
|
|
12354
|
+
function resourceKey(name) {
|
|
12355
|
+
return `resource:${name}`;
|
|
12356
|
+
}
|
|
12357
|
+
/**
|
|
12358
|
+
* Claim what this run is about to execute, and arrange for the claim to be
|
|
12359
|
+
* dropped. Resources first, then only the specs that survived them: a spec
|
|
12360
|
+
* held but not run reads to every other job as covered when it is not.
|
|
12361
|
+
*
|
|
12362
|
+
* Best-effort against a hub too old to serve claims — but only when nothing
|
|
12363
|
+
* declares a resource. Running a declared spec unprotected trades a wrong
|
|
12364
|
+
* verdict for a completed run, which is the wrong way round.
|
|
12365
|
+
*/
|
|
12366
|
+
async function holdSpecs(hubCtx, profile, specs, needed, teardown) {
|
|
12267
12367
|
const holder = randomUUID();
|
|
12268
|
-
|
|
12269
|
-
|
|
12368
|
+
const resources = [...new Set(specs.flatMap(needed))];
|
|
12369
|
+
const take = async (keys) => {
|
|
12270
12370
|
const res = await hubCtx.hub.acquireLocks(hubCtx.project, { profile }, {
|
|
12271
|
-
specs:
|
|
12371
|
+
specs: keys,
|
|
12272
12372
|
kind: "run",
|
|
12273
12373
|
holder,
|
|
12274
12374
|
ttlSeconds: LOCK_TTL_SECONDS
|
|
12275
12375
|
});
|
|
12276
|
-
|
|
12277
|
-
}
|
|
12278
|
-
warn(`could not claim specs on the hub, running without exclusion: ${errMessage(err)}`);
|
|
12279
|
-
return specs;
|
|
12280
|
-
}
|
|
12376
|
+
return new Set(res.granted);
|
|
12377
|
+
};
|
|
12281
12378
|
const release = async () => {
|
|
12282
12379
|
try {
|
|
12283
12380
|
await hubCtx.hub.releaseLocks(hubCtx.project, { profile }, holder);
|
|
12284
12381
|
} catch (err) {
|
|
12285
|
-
warn(`could not release
|
|
12382
|
+
warn(`could not release this run's claims: ${errMessage(err)} — they stay held for up to ${LOCK_TTL_SECONDS / 3600}h, so other jobs will skip these specs until then`);
|
|
12286
12383
|
}
|
|
12287
12384
|
};
|
|
12288
12385
|
teardown?.onFinalize(release);
|
|
12289
|
-
|
|
12386
|
+
let runnable = specs;
|
|
12387
|
+
let deniedResources = [];
|
|
12388
|
+
try {
|
|
12389
|
+
if (resources.length > 0) {
|
|
12390
|
+
const granted = await take(resources.map(resourceKey));
|
|
12391
|
+
deniedResources = resources.filter((name) => !granted.has(resourceKey(name)));
|
|
12392
|
+
runnable = specs.filter((spec) => needed(spec).every((n) => !deniedResources.includes(n)));
|
|
12393
|
+
}
|
|
12394
|
+
if (runnable.length > 0) {
|
|
12395
|
+
const granted = await take(runnable.map(specKey));
|
|
12396
|
+
runnable = runnable.filter((spec) => granted.has(specKey(spec)));
|
|
12397
|
+
}
|
|
12398
|
+
} catch (err) {
|
|
12399
|
+
if (resources.length > 0) throw new RunUsageError(`could not claim ${resources.join(", ")} on the hub: ${errMessage(err)} — these specs are in a \`serialGroups\` entry, so running without the claim risks two jobs writing to the same thing. Upgrade the hub, or drop the group to run them unprotected`);
|
|
12400
|
+
warn(`could not claim specs on the hub, running without exclusion: ${errMessage(err)}`);
|
|
12401
|
+
return {
|
|
12402
|
+
specs,
|
|
12403
|
+
deniedResources: []
|
|
12404
|
+
};
|
|
12405
|
+
}
|
|
12406
|
+
if (deniedResources.length > 0) warn(`another job holds ${deniedResources.join(", ")}; the specs needing them wait`);
|
|
12407
|
+
return {
|
|
12408
|
+
specs: runnable,
|
|
12409
|
+
deniedResources
|
|
12410
|
+
};
|
|
12290
12411
|
}
|
|
12291
12412
|
/**
|
|
12292
12413
|
* Run specs and (optionally) write a unified report. This is the library
|
|
@@ -12421,12 +12542,27 @@ async function executeRun(targets, opts) {
|
|
|
12421
12542
|
reportDir: null
|
|
12422
12543
|
};
|
|
12423
12544
|
}
|
|
12545
|
+
const catalog = await readSpecs(specs, cwd);
|
|
12546
|
+
const projectConfig = await loadProjectConfig(cwd);
|
|
12547
|
+
const resources = await resolveSerialGroups(projectConfig.serialGroups, cwd);
|
|
12548
|
+
const declared = [...new Set(specs.flatMap(resources))];
|
|
12549
|
+
if (declared.length > 0) meta("serial groups", declared.join(", "));
|
|
12550
|
+
let waitingOnGroup = [];
|
|
12424
12551
|
if (hubCtx && rerunProfile !== null) {
|
|
12425
|
-
const held = await holdSpecs(hubCtx, rerunProfile, specs, opts.teardown);
|
|
12426
|
-
|
|
12427
|
-
|
|
12552
|
+
const held = await holdSpecs(hubCtx, rerunProfile, specs, resources, opts.teardown);
|
|
12553
|
+
waitingOnGroup = specs.flatMap((spec) => {
|
|
12554
|
+
const groups = resources(spec).filter((n) => held.deniedResources.includes(n));
|
|
12555
|
+
return groups.length > 0 ? [{
|
|
12556
|
+
spec,
|
|
12557
|
+
groups
|
|
12558
|
+
}] : [];
|
|
12559
|
+
});
|
|
12560
|
+
const claimed = specs.length - held.specs.length - waitingOnGroup.length;
|
|
12561
|
+
if (claimed > 0) warn(`${claimed} spec(s) another job is already running`);
|
|
12562
|
+
specs = held.specs;
|
|
12428
12563
|
if (specs.length === 0) {
|
|
12429
|
-
|
|
12564
|
+
if (held.deniedResources.length > 0) throw new RunUsageError(`every selected spec waits on a shared resource another job holds (${held.deniedResources.join(", ")}): exiting non-zero rather than reporting a green run that verified nothing`);
|
|
12565
|
+
warn("every selected spec is claimed by another job");
|
|
12430
12566
|
return {
|
|
12431
12567
|
exitCode: 0,
|
|
12432
12568
|
report: null,
|
|
@@ -12436,11 +12572,17 @@ async function executeRun(targets, opts) {
|
|
|
12436
12572
|
}
|
|
12437
12573
|
let dispatch;
|
|
12438
12574
|
try {
|
|
12439
|
-
dispatch =
|
|
12575
|
+
dispatch = groupSpecsByTarget(specs, catalog, projectConfig);
|
|
12576
|
+
for (const { spec, groups } of waitingOnGroup) dispatch.skipped.push({
|
|
12577
|
+
...spec,
|
|
12578
|
+
title: catalog.get(specKey(spec))?.spec?.title ?? null,
|
|
12579
|
+
reason: `waiting on ${groups.join(", ")}, held by another job`,
|
|
12580
|
+
targetId: null
|
|
12581
|
+
});
|
|
12440
12582
|
} catch (err) {
|
|
12441
12583
|
throw new RunUsageError(errMessage(err));
|
|
12442
12584
|
}
|
|
12443
|
-
const withMode =
|
|
12585
|
+
const withMode = resolveSpecsModes(dispatch.agentBrowser, catalog);
|
|
12444
12586
|
const detSpecs = withMode.filter((s) => s.mode === "deterministic");
|
|
12445
12587
|
const liveSpecs = withMode.filter((s) => s.mode === "live");
|
|
12446
12588
|
meta("modes", `${detSpecs.length} deterministic / ${liveSpecs.length} live`);
|
|
@@ -12454,7 +12596,7 @@ async function executeRun(targets, opts) {
|
|
|
12454
12596
|
if (detSpecs.length === 0 && opts.replaySkipEvidence === true) warn("--no-evidence is ignored: it only applies to agent-browser 'mode: deterministic' specs, and this run has none");
|
|
12455
12597
|
blank();
|
|
12456
12598
|
if (opts.dryRun) {
|
|
12457
|
-
for (const line of formatDryRunLines(withMode, dispatch)) emitRaw(line + "\n");
|
|
12599
|
+
for (const line of formatDryRunLines(withMode, dispatch, resources)) emitRaw(line + "\n");
|
|
12458
12600
|
blank();
|
|
12459
12601
|
info("dry run: nothing was executed and no report was written");
|
|
12460
12602
|
return {
|
|
@@ -12463,7 +12605,7 @@ async function executeRun(targets, opts) {
|
|
|
12463
12605
|
reportDir: null
|
|
12464
12606
|
};
|
|
12465
12607
|
}
|
|
12466
|
-
const det = await runDeterministicSpecs(detSpecs, opts, cwd, reportDir);
|
|
12608
|
+
const det = await runDeterministicSpecs(detSpecs, opts, cwd, reportDir, resources);
|
|
12467
12609
|
let hubRunId = null;
|
|
12468
12610
|
let hubSink;
|
|
12469
12611
|
if (hubCtx != null && opts.reportToHub) try {
|
|
@@ -12522,6 +12664,7 @@ async function executeRun(targets, opts) {
|
|
|
12522
12664
|
cwd,
|
|
12523
12665
|
reportDir,
|
|
12524
12666
|
concurrency: opts.concurrency ?? 1,
|
|
12667
|
+
resources,
|
|
12525
12668
|
...opts.model ? { model: opts.model } : {},
|
|
12526
12669
|
...opts.language ? { language: opts.language } : {},
|
|
12527
12670
|
report: incrementalReport
|
|
@@ -12534,6 +12677,7 @@ async function executeRun(targets, opts) {
|
|
|
12534
12677
|
reportDir,
|
|
12535
12678
|
...typeof opts.liveStepRetry === "number" ? { retry: opts.liveStepRetry } : {},
|
|
12536
12679
|
concurrency: opts.concurrency ?? 1,
|
|
12680
|
+
resources,
|
|
12537
12681
|
...opts.hubProfile ? { profile: opts.hubProfile } : {},
|
|
12538
12682
|
diffProvider,
|
|
12539
12683
|
hubContext: hubCtx,
|
|
@@ -12679,7 +12823,7 @@ function oneLineSummary$1(s) {
|
|
|
12679
12823
|
* Captures step-boundary evidence under `<reportDir>/evidence/<feature>/<spec>/`
|
|
12680
12824
|
* when enabled.
|
|
12681
12825
|
*/
|
|
12682
|
-
async function runDeterministicSpecs(specs, opts, cwd, reportDirAbs) {
|
|
12826
|
+
async function runDeterministicSpecs(specs, opts, cwd, reportDirAbs, resources) {
|
|
12683
12827
|
if (specs.length === 0) return {
|
|
12684
12828
|
summaries: [],
|
|
12685
12829
|
exitCode: 0
|
|
@@ -12698,7 +12842,7 @@ async function runDeterministicSpecs(specs, opts, cwd, reportDirAbs) {
|
|
|
12698
12842
|
captureEvidence
|
|
12699
12843
|
};
|
|
12700
12844
|
try {
|
|
12701
|
-
const summaries = (await runPool(specs, concurrency, (spec, i) => withBuffer(`${spec.featureName}/${spec.specName}`, concurrency > 1, () => runOneDeterministicSpec(spec, i, ctx)))).filter((s) => s !== null);
|
|
12845
|
+
const summaries = (await runPool(specs, concurrency, (spec, i) => withBuffer(`${spec.featureName}/${spec.specName}`, concurrency > 1, () => runOneDeterministicSpec(spec, i, ctx)), { resources })).filter((s) => s !== null);
|
|
12702
12846
|
printSummary(summaries);
|
|
12703
12847
|
return {
|
|
12704
12848
|
summaries,
|
|
@@ -13138,7 +13282,7 @@ function installTeardownSignalHandlers(teardown) {
|
|
|
13138
13282
|
}
|
|
13139
13283
|
//#endregion
|
|
13140
13284
|
//#region src/cli/run.ts
|
|
13141
|
-
const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --report-to-hub to also stream it to a hub.").optionsGroup("Which specs to run:").option("--only-affected-by <ref>", "Only specs `ccqa select-specs` judges reached by the diff against <ref> (e.g. origin/main). In pull_request CI, pass $GITHUB_BASE_REF. Cannot be combined with an explicit spec id.").option("--only-hub-rerun-needed", "Only specs the hub answers `rerunNeeded` for: the audit cleared them, and their last result does not cover what is deployed. A spec whose audit has not caught up answers `inProgress`, and one the audit rejected or whose last run failed answers `needsRepair`; neither is taken, because running them races the audit or repairs nothing. No git diff involved. Requires a hub connection and --hub-profile.").option("--only-hub-rerun-needed-with-unknown", "With --only-hub-rerun-needed: also take specs the hub cannot answer for at all ('unanswerable' — a hole in the deploy log). Off by default: an unanswerable question is reported, not guessed.").option("--dry-run", "Print the specs this invocation would run, then exit 0 without executing anything and without writing a report. Works with every selection flag.").optionsGroup("How to run them:").option("--concurrency <n>", "Run up to N specs in parallel within each phase (deterministic / external-target / live), never across phases. Default 1 (sequential). Live specs each get an isolated agent-browser session; high values spawn many headed Chrome instances.", parseConcurrency$1, 1).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--live-step-retry <n>", "(live only) Retry each failed step up to N more times before recording failure. This retries a step, not the whole spec — see --on-fail-explain-rerun for that.", (raw) => {
|
|
13285
|
+
const runCommand = addHubOptions(addProfileOption(addLanguageOption(new Command("run").argument("[targets...]", "Specs to run, space-separated: each '<feature>/<spec>', '<feature>', or omit for all. Duplicates are de-duped.").description("Run specs, on any target. Agent-browser specs replay the recorded test.spec.ts under vitest (default), or, with spec.yaml `mode: live`, have Claude drive agent-browser live per step. External-target specs (playwright, runn) run through the target's configured `runCommand`. A structured report (report.json + evidence) is always written; use --report-to-hub to also stream it to a hub.").optionsGroup("Which specs to run:").option("--only-affected-by <ref>", "Only specs `ccqa select-specs` judges reached by the diff against <ref> (e.g. origin/main). In pull_request CI, pass $GITHUB_BASE_REF. Cannot be combined with an explicit spec id.").option("--only-hub-rerun-needed", "Only specs the hub answers `rerunNeeded` for: the audit cleared them, and their last result does not cover what is deployed. A spec whose audit has not caught up answers `inProgress`, and one the audit rejected or whose last run failed answers `needsRepair`; neither is taken, because running them races the audit or repairs nothing. No git diff involved. Requires a hub connection and --hub-profile.").option("--only-hub-rerun-needed-with-unknown", "With --only-hub-rerun-needed: also take specs the hub cannot answer for at all ('unanswerable' — a hole in the deploy log). Off by default: an unanswerable question is reported, not guessed.").option("--dry-run", "Print the specs this invocation would run, then exit 0 without executing anything and without writing a report. Works with every selection flag.").optionsGroup("How to run them:").option("--concurrency <n>", "Run up to N specs in parallel within each phase (deterministic / external-target / live), never across phases. Default 1 (sequential). Specs in the same `serialGroups` entry of .ccqa/config.yaml still take turns. Live specs each get an isolated agent-browser session; high values spawn many headed Chrome instances.", parseConcurrency$1, 1).option("-m, --model <name>", "Claude model alias ('sonnet'|'opus'|'haiku') or full ID. Overrides CCQA_MODEL.").option("--live-step-retry <n>", "(live only) Retry each failed step up to N more times before recording failure. This retries a step, not the whole spec — see --on-fail-explain-rerun for that.", (raw) => {
|
|
13142
13286
|
const n = Number(raw);
|
|
13143
13287
|
if (!Number.isFinite(n) || n < 0 || Math.floor(n) !== n) throw new Error(`--live-step-retry must be a non-negative integer, got "${raw}"`);
|
|
13144
13288
|
return n;
|
|
@@ -13174,23 +13318,22 @@ async function runCliAction(targets, opts) {
|
|
|
13174
13318
|
const cwd = resolveCwd(opts.cwd);
|
|
13175
13319
|
const teardown = createRunTeardown();
|
|
13176
13320
|
const disposeSignalHandlers = installTeardownSignalHandlers(teardown);
|
|
13321
|
+
let exitCode;
|
|
13177
13322
|
try {
|
|
13178
|
-
|
|
13323
|
+
exitCode = (await executeRun(targets, {
|
|
13179
13324
|
...opts,
|
|
13180
13325
|
cwd,
|
|
13181
13326
|
teardown
|
|
13182
|
-
});
|
|
13183
|
-
await teardown.run();
|
|
13184
|
-
process.exit(result.exitCode);
|
|
13327
|
+
})).exitCode;
|
|
13185
13328
|
} catch (err) {
|
|
13186
|
-
if (err instanceof RunUsageError)
|
|
13187
|
-
|
|
13188
|
-
|
|
13189
|
-
}
|
|
13190
|
-
throw err;
|
|
13329
|
+
if (!(err instanceof RunUsageError)) throw err;
|
|
13330
|
+
error(err.message);
|
|
13331
|
+
exitCode = err.exitCode;
|
|
13191
13332
|
} finally {
|
|
13333
|
+
await teardown.run();
|
|
13192
13334
|
disposeSignalHandlers();
|
|
13193
13335
|
}
|
|
13336
|
+
process.exit(exitCode);
|
|
13194
13337
|
}
|
|
13195
13338
|
//#endregion
|
|
13196
13339
|
//#region src/store/spec-lock.ts
|
package/dist/package.json
CHANGED