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 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 session name: the identifier of a saved browser session (cookies +
184
- * localStorage) to restore before the spec runs. Resolved to
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
- const SessionNameSchema = z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "session name must be a slug (letters, digits, '.', '_', '-'; no path separators)");
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: a single name or a
191
- * list. Always normalized to an array. Each name maps to a saved
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"). The schema only enforces a
200
- * safe slug whether the id names a registered target is the registry's
201
- * responsibility, so new targets don't require a schema change.
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 = z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "target must be a slug (letters, digits, '.', '_', '-'; no path separators)");
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
- let cursor = 0;
805
- const worker = async () => {
806
- while (true) {
807
- const idx = cursor++;
808
- if (idx >= items.length) return;
809
- results[idx] = await fn(items[idx], idx);
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
- const n = Math.max(1, Math.min(concurrency, items.length));
813
- await Promise.all(Array.from({ length: n }, () => worker()));
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
- /** The per-(project, profile) lock document: "feature/spec" → who holds it. */
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/cli/spec-mode.ts
7285
- /**
7286
- * Read each spec.yaml and resolve its execution mode. Spec-declared `mode:`
7287
- * wins; otherwise `DEFAULT_SPEC_MODE`. Unreadable/unparseable YAML falls back
7288
- * to the default so the per-mode runner surfaces the real error itself.
7289
- */
7290
- async function resolveSpecsModes(specs, cwd) {
7291
- return Promise.all(specs.map(async (s) => ({
7292
- ...s,
7293
- mode: await resolveOne(s, cwd)
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
- async function resolveOne(spec, cwd) {
7297
- const yaml = await tryReadSpecFile(spec.featureName, spec.specName, cwd);
7298
- if (yaml === null) return DEFAULT_SPEC_MODE;
7299
- try {
7300
- return parseTestSpec(yaml).mode ?? "deterministic";
7301
- } catch {
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
- * Read each spec.yaml, resolve its target, and group. A spec whose YAML is
11639
- * missing or unparseable keeps today's behaviour: it falls through to the
11640
- * agent-browser path, whose runner surfaces the real error itself. A spec
11641
- * whose target resolution throws (unknown target, agent-browser-only fields
11642
- * on another target) is recorded per-spec instead of stopping the run.
11643
- * `resolve` is injectable so tests can supply a registry of fake targets.
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
- async function groupSpecsByTarget(specs, config, cwd, resolve = resolveTarget) {
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 spec = tryParseTestSpec(await tryReadSpecFile(ref.featureName, ref.specName, cwd));
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
- * Claim the specs this run is about to execute, and arrange for the claim to
12263
- * be dropped. Best-effort against the hub: one too old to serve claims must
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
- async function holdSpecs(hubCtx, profile, specs, teardown) {
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
- let granted;
12269
- try {
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: specs.map(specKey),
12371
+ specs: keys,
12272
12372
  kind: "run",
12273
12373
  holder,
12274
12374
  ttlSeconds: LOCK_TTL_SECONDS
12275
12375
  });
12276
- granted = new Set(res.granted);
12277
- } catch (err) {
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 the spec claims: ${errMessage(err)}`);
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
- return specs.filter((spec) => granted.has(specKey(spec)));
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
- if (held.length < specs.length) meta("held-elsewhere", `${specs.length - held.length} spec(s) another job is already running`);
12427
- specs = held;
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
- warn("every selected spec is already being run by another job");
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 = await groupSpecsByTarget(specs, await loadProjectConfig(cwd), cwd);
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 = await resolveSpecsModes(dispatch.agentBrowser, cwd);
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
- const result = await executeRun(targets, {
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
- error(err.message);
13188
- process.exit(err.exitCode);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {