@elench/testkit 0.1.150 → 0.1.151

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.
Files changed (48) hide show
  1. package/README.md +21 -9
  2. package/lib/cli/assistant/view-model.mjs +1 -1
  3. package/lib/cli/components/blocks/run-tree.mjs +1 -1
  4. package/lib/cli/renderers/run/events.mjs +4 -3
  5. package/lib/cli/renderers/run/failure.mjs +2 -2
  6. package/lib/cli/renderers/run/inline-detail.mjs +2 -2
  7. package/lib/cli/renderers/run/interactive.mjs +2 -2
  8. package/lib/cli/renderers/run/text-reporter.mjs +9 -9
  9. package/lib/cli/state/run/model.mjs +7 -7
  10. package/lib/cli/state/run/state.mjs +3 -3
  11. package/lib/cli/terminal/colors.mjs +1 -1
  12. package/lib/config/runtime.mjs +130 -0
  13. package/lib/config-api/index.d.ts +22 -0
  14. package/lib/database/cleanup.mjs +76 -1
  15. package/lib/database/index.mjs +6 -0
  16. package/lib/database/local-postgres.mjs +95 -4
  17. package/lib/database/naming.mjs +7 -0
  18. package/lib/database/state-files.mjs +12 -0
  19. package/lib/docker-compat/matrix.mjs +5 -3
  20. package/lib/kiln/client.mjs +8 -0
  21. package/lib/local/kiln-driver.mjs +96 -69
  22. package/lib/ownership/docker.mjs +67 -1
  23. package/lib/regressions/github-transport.mjs +178 -4
  24. package/lib/regressions/github.mjs +52 -16
  25. package/lib/regressions/index.d.ts +56 -28
  26. package/lib/regressions/index.mjs +122 -47
  27. package/lib/regressions/workflow.mjs +266 -0
  28. package/lib/results/artifacts.mjs +8 -7
  29. package/lib/runner/formatting.mjs +17 -16
  30. package/lib/runner/orchestrator.mjs +5 -4
  31. package/lib/runner/regressions.mjs +175 -33
  32. package/lib/runner/run-finalization.mjs +34 -4
  33. package/node_modules/@elench/next-analysis/package.json +1 -1
  34. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  35. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  36. package/node_modules/@elench/ts-analysis/package.json +1 -1
  37. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  38. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  39. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  40. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  41. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  42. package/node_modules/esprima/ChangeLog +235 -0
  43. package/package.json +6 -5
  44. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
  45. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
  46. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
  47. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
  48. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
package/README.md CHANGED
@@ -148,19 +148,28 @@ results without any separate follow-up maintenance command.
148
148
  - `new regressions`
149
149
  - `known regressions`
150
150
  - `fixed known regressions`
151
- - `catalog stale`
151
+ - `case store stale`
152
152
 
153
153
  The default CLI keeps those signals lightweight:
154
154
 
155
155
  - failed files print inline diagnosis immediately under the file line
156
156
  - the final summary box reports aggregate regression counts only
157
157
  - machine-readable artifacts gain per-file `diagnosis` plus top-level
158
- `regressions.summary`, `regressions.catalog`, and prepared `regressions.drafts`
158
+ `regressions.summary`, `regressions.caseStore`, `regressions.workflow`, and prepared `regressions.drafts`
159
159
 
160
- `catalog stale` is repo hygiene, not a test failure. It means the regression
161
- catalog or linked issue tracker metadata needs attention, for example because a
160
+ `case store stale` is repo hygiene, not a test failure. It means the regression
161
+ case store or linked issue tracker metadata needs attention, for example because a
162
162
  linked issue is closed but the regression still reproduces.
163
163
 
164
+ The canonical durable file is `testkit.regressions.json` with
165
+ `schemaVersion: 2` and a top-level `cases` array. When a configured full run sees
166
+ a new failure, Testkit records an untriaged case automatically. When a known case
167
+ passes, Testkit moves it through `fixed_pending_verification`; after the
168
+ configured stability window it can close the linked GitHub issue and mark the
169
+ case resolved. Open GitHub bug issues with no linked regression case are recorded
170
+ as `coverage_missing` cases so missing regression coverage is visible in normal
171
+ run output instead of becoming a separate manual audit.
172
+
164
173
  ## Tooling Adapters
165
174
 
166
175
  `testkit` also ships tool-specific config helpers so consumer repos do not need
@@ -382,7 +391,9 @@ destroyed. `testkit cleanup --resources` cleans stale resources for the current
382
391
  product; add `--global` to clean labelled resources whose product directory no
383
392
  longer exists, and add `--include-legacy` only when you intentionally want to
384
393
  remove old unlabelled `testkit_pg_*` containers from pre-ownership Testkit
385
- versions. Use `--dry-run` first when cleaning globally.
394
+ versions. Host-local Postgres data lives in a Testkit-labelled named Docker
395
+ volume instead of an image-declared anonymous volume, so cleanup can account for
396
+ and remove it safely. Use `--dry-run` first when cleaning globally.
386
397
 
387
398
  `database.template` is the database-side equivalent for reusable template DB
388
399
  state. When `database.sourceSchema` is configured, Testkit treats the configured
@@ -488,7 +499,7 @@ If `regressions.file` is configured, `testkit` enriches
488
499
  - per-file `diagnosis` metadata (new regression, known regression, fixed known regression)
489
500
  - top-level `regressions` summary and prepared draft updates
490
501
 
491
- Regression-catalog entry authoring uses this contract:
502
+ Regression case authoring uses this contract:
492
503
 
493
504
  - `summary`
494
505
  - concise local statement of the regression slice
@@ -498,12 +509,12 @@ Regression-catalog entry authoring uses this contract:
498
509
  - selectors that let testkit automatically recognize the regression in future runs
499
510
 
500
511
  If `regressions.sync` is also configured, `testkit` syncs linked GitHub issues and
501
- adds top-level regression catalog health to the run/status artifacts. The most
502
- important catalog-staleness signal is:
512
+ adds top-level regression case health to the run/status artifacts. The most
513
+ important stale-case signal is:
503
514
 
504
515
  - a known regression still fails, but the linked GitHub issue is closed
505
516
 
506
- In `mode: "error"`, catalog health can also fail the run for problems such as:
517
+ In `mode: "error"`, case-store health can also fail the run for problems such as:
507
518
 
508
519
  - closed issues that still reproduce
509
520
  - missing issue refs
@@ -811,6 +822,7 @@ npm run test:unit
811
822
  npm run test:integration
812
823
  npm run test:system
813
824
  npm run test:live:github
825
+ npm run test:live:regression-workflow
814
826
  npm run test:live:neon
815
827
  npm run test:database-version:compat
816
828
  ```
@@ -118,7 +118,7 @@ export function buildWelcomeModel(snapshot, { cwd = process.cwd(), providerLabel
118
118
  const issues = [
119
119
  rowValue("New regressions") ? `${rowValue("New regressions")} new regression${rowValue("New regressions") === "1" ? "" : "s"}` : null,
120
120
  rowValue("Known regressions") ? `${rowValue("Known regressions")} known` : null,
121
- rowValue("Catalog stale") ? `${rowValue("Catalog stale")} stale` : null,
121
+ rowValue("Case store stale") ? `${rowValue("Case store stale")} stale` : null,
122
122
  ].filter(Boolean);
123
123
 
124
124
  return {
@@ -120,7 +120,7 @@ function renderTreeLine(snapshot, spinnerFrame, terminalWidth, entry) {
120
120
 
121
121
  if (entry.kind === "file" && !entry.collapsed && snapshot.finished) {
122
122
  const detailLines = entry.status === "failed"
123
- ? renderFailureDetail(entry, { width: terminalWidth, regressionCatalog: snapshot.regressionCatalog })
123
+ ? renderFailureDetail(entry, { width: terminalWidth, regressionCaseStore: snapshot.regressionCaseStore })
124
124
  : entry.status === "passed"
125
125
  ? renderPassedDetail(entry, { width: terminalWidth })
126
126
  : [];
@@ -6,9 +6,10 @@ export function createRunEventsReporter({ stdout = process.stdout, stderr = proc
6
6
  return {
7
7
  outputMode: "events",
8
8
 
9
- setRegressionCatalog(document) {
10
- writeEvent("run.regression_catalog", {
11
- configured: Boolean(document?.configured),
9
+ setRegressionCaseStore(document) {
10
+ writeEvent("run.regression_case_store", {
11
+ configured: Boolean(document),
12
+ caseCount: document?.cases?.length || 0,
12
13
  });
13
14
  },
14
15
 
@@ -1,7 +1,7 @@
1
1
  import { buildFailurePresentation } from "../../../runner/formatting.mjs";
2
2
  import { renderIndentedBlock } from "../../terminal/layout.mjs";
3
3
 
4
- export function renderFailureBlock(task, outcome, { width, regressionCatalog } = {}) {
4
+ export function renderFailureBlock(task, outcome, { width, regressionCaseStore } = {}) {
5
5
  const failureView = buildFailurePresentation(
6
6
  {
7
7
  service: task.serviceName,
@@ -11,7 +11,7 @@ export function renderFailureBlock(task, outcome, { width, regressionCatalog } =
11
11
  failureDetails: Array.isArray(outcome.failureDetails) ? outcome.failureDetails : [],
12
12
  suiteError: null,
13
13
  },
14
- regressionCatalog
14
+ regressionCaseStore
15
15
  );
16
16
 
17
17
  const lines = [];
@@ -3,7 +3,7 @@ import { renderIndentedBlock } from "../../terminal/layout.mjs";
3
3
  import { dim, green, red } from "../../terminal/colors.mjs";
4
4
  import figures from "figures";
5
5
 
6
- export function renderFailureDetail(entry, { width, regressionCatalog } = {}) {
6
+ export function renderFailureDetail(entry, { width, regressionCaseStore } = {}) {
7
7
  const fileSummary = {
8
8
  service: entry.serviceName,
9
9
  type: normalizeType(entry),
@@ -13,7 +13,7 @@ export function renderFailureDetail(entry, { width, regressionCatalog } = {}) {
13
13
  suiteError: null,
14
14
  };
15
15
 
16
- const failureView = buildFailurePresentation(fileSummary, regressionCatalog);
16
+ const failureView = buildFailurePresentation(fileSummary, regressionCaseStore);
17
17
  const lines = [];
18
18
  const indent = " ".repeat(2 + (entry.depth || 0) * 2 + 2);
19
19
 
@@ -32,8 +32,8 @@ export function createRunSession({
32
32
  runState.setTotalFileCount(count);
33
33
  },
34
34
 
35
- setRegressionCatalog(document) {
36
- runState.setRegressionCatalog(document);
35
+ setRegressionCaseStore(document) {
36
+ runState.setRegressionCaseStore(document);
37
37
  },
38
38
 
39
39
  serviceSkipped(config, reason) {
@@ -13,15 +13,15 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
13
13
  const mode = outputMode || "compact";
14
14
  let completedCount = 0;
15
15
  let totalFileCount = 0;
16
- let regressionCatalog = null;
16
+ let regressionCaseStore = null;
17
17
 
18
18
  return {
19
19
  outputMode: mode,
20
20
  setTotalFileCount(count) {
21
21
  totalFileCount = count;
22
22
  },
23
- setRegressionCatalog(document) {
24
- regressionCatalog = document;
23
+ setRegressionCaseStore(document) {
24
+ regressionCaseStore = document;
25
25
  },
26
26
  writeLine(line = "") {
27
27
  stdout.write(`${line}\n`);
@@ -92,7 +92,7 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
92
92
  if (status === "FAIL") {
93
93
  const detailLines = renderFailureBlock(task, outcome, {
94
94
  width: getTerminalWidth(stdout, 100),
95
- regressionCatalog,
95
+ regressionCaseStore,
96
96
  });
97
97
  for (const line of detailLines) {
98
98
  stdout.write(`${line}\n`);
@@ -138,13 +138,13 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
138
138
  if (summary.fixedKnownRegressions > 0) {
139
139
  rows.push(["Fixed known", String(summary.fixedKnownRegressions)]);
140
140
  }
141
- if (summary.catalogStale > 0) {
142
- rows.push(["Catalog stale", String(summary.catalogStale)]);
141
+ if (summary.staleCases > 0) {
142
+ rows.push(["Case store stale", String(summary.staleCases)]);
143
143
  }
144
- if (summary.catalogSyncUnavailable) {
145
- rows.push(["Catalog sync", "Unavailable"]);
144
+ if (summary.syncUnavailable) {
145
+ rows.push(["Case store sync", "Unavailable"]);
146
146
  } else if (summary.usedStaleCache) {
147
- rows.push(["Catalog sync", "Used stale cache"]);
147
+ rows.push(["Case store sync", "Used stale cache"]);
148
148
  }
149
149
  const boxed = renderSummaryBox(rows, { stdout });
150
150
  stdout.write("\n");
@@ -32,8 +32,8 @@ export function buildSummaryRows({
32
32
  if ((regressionSummary?.fixedKnownRegressions || 0) > 0) {
33
33
  rows.push(["Fixed known", String(regressionSummary.fixedKnownRegressions)]);
34
34
  }
35
- if ((regressionSummary?.catalogStale || 0) > 0) {
36
- rows.push(["Catalog stale", String(regressionSummary.catalogStale)]);
35
+ if ((regressionSummary?.staleCases || 0) > 0) {
36
+ rows.push(["Case store stale", String(regressionSummary.staleCases)]);
37
37
  }
38
38
  return rows;
39
39
  }
@@ -44,7 +44,7 @@ export function createEmptyRunModel(dataSource = "live", options = {}) {
44
44
  autoCollapsePassedTreeBranches: options.autoCollapsePassedTreeBranches !== false,
45
45
  services: new Map(),
46
46
  summaryData: null,
47
- regressionCatalog: null,
47
+ regressionCaseStore: null,
48
48
  runArtifact: null,
49
49
  finished: dataSource === "artifact",
50
50
  totalCount: 0,
@@ -59,7 +59,7 @@ export function resetRunModel(model, dataSource = model.dataSource) {
59
59
  model.dataSource = dataSource;
60
60
  model.services = new Map();
61
61
  model.summaryData = null;
62
- model.regressionCatalog = null;
62
+ model.regressionCaseStore = null;
63
63
  model.runArtifact = null;
64
64
  model.finished = dataSource === "artifact";
65
65
  model.totalCount = 0;
@@ -250,8 +250,8 @@ export function setPhase(model, label) {
250
250
  model.phase = label;
251
251
  }
252
252
 
253
- export function setRegressionCatalog(model, document) {
254
- model.regressionCatalog = document;
253
+ export function setRegressionCaseStore(model, document) {
254
+ model.regressionCaseStore = document;
255
255
  }
256
256
 
257
257
  export function finishModel(model, results, durationMs, regressionReport) {
@@ -316,7 +316,7 @@ export function buildSnapshot(model) {
316
316
  phase: model.phase,
317
317
  finished: model.finished,
318
318
  summaryData: model.summaryData,
319
- regressionCatalog: model.regressionCatalog,
319
+ regressionCaseStore: model.regressionCaseStore,
320
320
  runArtifact: model.runArtifact,
321
321
  };
322
322
  }
@@ -14,7 +14,7 @@ import {
14
14
  resetRunModel,
15
15
  revealEntry,
16
16
  setPhase,
17
- setRegressionCatalog,
17
+ setRegressionCaseStore,
18
18
  setTotalFileCount,
19
19
  toggleCollapsed,
20
20
  } from "./model.mjs";
@@ -66,8 +66,8 @@ export function createRunState({ dataSource = "live", autoCollapsePassedTreeBran
66
66
  notify();
67
67
  },
68
68
 
69
- setRegressionCatalog(document) {
70
- setRegressionCatalog(model, document);
69
+ setRegressionCaseStore(document) {
70
+ setRegressionCaseStore(model, document);
71
71
  notify();
72
72
  },
73
73
 
@@ -82,7 +82,7 @@ export function colorResultLine(line) {
82
82
  }
83
83
 
84
84
  export function colorSectionLine(line) {
85
- if (line === "Catalog issues:" || line === "Diagnosis:") {
85
+ if (line === "Case store issues:" || line === "Diagnosis:") {
86
86
  return pc.bold(line);
87
87
  }
88
88
  if (/^Summary: /.test(line)) return line.replace("Summary:", `${pc.bold("Summary:")}`);
@@ -32,9 +32,139 @@ export function normalizeRegressionsConfig(value) {
32
32
  return {
33
33
  file,
34
34
  sync,
35
+ workflow: normalizeRegressionWorkflowConfig(value.workflow),
35
36
  };
36
37
  }
37
38
 
39
+ function normalizeRegressionWorkflowConfig(value) {
40
+ const defaults = {
41
+ mode: "autopilot",
42
+ github: {
43
+ read: true,
44
+ write: false,
45
+ bugLabels: ["bug"],
46
+ issueLabels: ["bug"],
47
+ createIssues: "confirmed",
48
+ reopenIssues: "closed_reproduced",
49
+ closeIssues: "after_stability_window",
50
+ comment: "state_changes",
51
+ },
52
+ evidence: {
53
+ autoRerunThinFailures: true,
54
+ maxAutoReruns: 2,
55
+ requireForIssueCreation: true,
56
+ },
57
+ stability: {
58
+ cleanRunsBeforeClose: 3,
59
+ },
60
+ };
61
+ if (value == null) return defaults;
62
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
63
+ throw new Error("testkit.config.ts regressions.workflow must be an object");
64
+ }
65
+ const mode = normalizeOptionalString(value.mode) || defaults.mode;
66
+ if (!["off", "observe", "autopilot"].includes(mode)) {
67
+ throw new Error('testkit.config.ts regressions.workflow.mode must be one of: off, observe, autopilot');
68
+ }
69
+ const github = normalizeObject(value.github, "testkit.config.ts regressions.workflow.github");
70
+ const evidence = normalizeObject(value.evidence, "testkit.config.ts regressions.workflow.evidence");
71
+ const stability = normalizeObject(value.stability, "testkit.config.ts regressions.workflow.stability");
72
+ return {
73
+ mode,
74
+ github: {
75
+ ...defaults.github,
76
+ ...normalizeRegressionWorkflowGithub(github),
77
+ },
78
+ evidence: {
79
+ ...defaults.evidence,
80
+ ...normalizeRegressionWorkflowEvidence(evidence),
81
+ },
82
+ stability: {
83
+ ...defaults.stability,
84
+ ...normalizeRegressionWorkflowStability(stability),
85
+ },
86
+ };
87
+ }
88
+
89
+ function normalizeRegressionWorkflowGithub(value) {
90
+ if (!value) return {};
91
+ return {
92
+ ...(value.read == null ? {} : { read: normalizeBoolean(value.read, "testkit.config.ts regressions.workflow.github.read") }),
93
+ ...(value.write == null ? {} : { write: normalizeBoolean(value.write, "testkit.config.ts regressions.workflow.github.write") }),
94
+ ...(value.apiBaseUrl == null ? {} : { apiBaseUrl: requireNonEmptyString(value.apiBaseUrl, "testkit.config.ts regressions.workflow.github.apiBaseUrl") }),
95
+ ...(value.bugLabels == null ? {} : { bugLabels: normalizeStringArray(value.bugLabels, "testkit.config.ts regressions.workflow.github.bugLabels") }),
96
+ ...(value.issueLabels == null ? {} : { issueLabels: normalizeStringArray(value.issueLabels, "testkit.config.ts regressions.workflow.github.issueLabels") }),
97
+ ...(value.createIssues == null ? {} : { createIssues: normalizeEnum(value.createIssues, ["never", "confirmed"], "testkit.config.ts regressions.workflow.github.createIssues") }),
98
+ ...(value.reopenIssues == null ? {} : { reopenIssues: normalizeEnum(value.reopenIssues, ["never", "closed_reproduced"], "testkit.config.ts regressions.workflow.github.reopenIssues") }),
99
+ ...(value.closeIssues == null ? {} : { closeIssues: normalizeEnum(value.closeIssues, ["never", "after_stability_window"], "testkit.config.ts regressions.workflow.github.closeIssues") }),
100
+ ...(value.comment == null ? {} : { comment: normalizeEnum(value.comment, ["never", "state_changes"], "testkit.config.ts regressions.workflow.github.comment") }),
101
+ };
102
+ }
103
+
104
+ function normalizeRegressionWorkflowEvidence(value) {
105
+ if (!value) return {};
106
+ return {
107
+ ...(value.autoRerunThinFailures == null ? {} : { autoRerunThinFailures: normalizeBoolean(value.autoRerunThinFailures, "testkit.config.ts regressions.workflow.evidence.autoRerunThinFailures") }),
108
+ ...(value.maxAutoReruns == null ? {} : { maxAutoReruns: normalizeNonNegativeInteger(value.maxAutoReruns, "testkit.config.ts regressions.workflow.evidence.maxAutoReruns") }),
109
+ ...(value.requireForIssueCreation == null ? {} : { requireForIssueCreation: normalizeBoolean(value.requireForIssueCreation, "testkit.config.ts regressions.workflow.evidence.requireForIssueCreation") }),
110
+ };
111
+ }
112
+
113
+ function normalizeRegressionWorkflowStability(value) {
114
+ if (!value) return {};
115
+ return {
116
+ ...(value.cleanRunsBeforeClose == null ? {} : { cleanRunsBeforeClose: normalizePositiveInteger(value.cleanRunsBeforeClose, "testkit.config.ts regressions.workflow.stability.cleanRunsBeforeClose") }),
117
+ };
118
+ }
119
+
120
+ function normalizeObject(value, label) {
121
+ if (value == null) return null;
122
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
123
+ throw new Error(`${label} must be an object`);
124
+ }
125
+ return value;
126
+ }
127
+
128
+ function normalizeBoolean(value, label) {
129
+ if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
130
+ return value;
131
+ }
132
+
133
+ function normalizeEnum(value, allowed, label) {
134
+ const normalized = requireNonEmptyString(value, label);
135
+ if (!allowed.includes(normalized)) {
136
+ throw new Error(`${label} must be one of: ${allowed.join(", ")}`);
137
+ }
138
+ return normalized;
139
+ }
140
+
141
+ function normalizeStringArray(value, label) {
142
+ if (!Array.isArray(value) || value.length === 0) {
143
+ throw new Error(`${label} must be a non-empty array`);
144
+ }
145
+ return value.map((entry, index) => requireNonEmptyString(entry, `${label}[${index}]`));
146
+ }
147
+
148
+ function normalizeNonNegativeInteger(value, label) {
149
+ if (!Number.isInteger(value) || value < 0) {
150
+ throw new Error(`${label} must be a non-negative integer`);
151
+ }
152
+ return value;
153
+ }
154
+
155
+ function normalizePositiveInteger(value, label) {
156
+ if (!Number.isInteger(value) || value <= 0) {
157
+ throw new Error(`${label} must be a positive integer`);
158
+ }
159
+ return value;
160
+ }
161
+
162
+ function requireNonEmptyString(value, label) {
163
+ const normalized = normalizeOptionalString(value);
164
+ if (!normalized) throw new Error(`${label} must be a non-empty string`);
165
+ return normalized;
166
+ }
167
+
38
168
  export function inferLocalRuntime(productDir, cwd) {
39
169
  const absoluteCwd = resolveServiceCwd(productDir, cwd);
40
170
  if (!fs.existsSync(absoluteCwd)) return undefined;
@@ -586,6 +586,28 @@ export interface TestkitConfig {
586
586
  regressions?: {
587
587
  file?: string;
588
588
  sync?: RegressionSyncConfig;
589
+ workflow?: {
590
+ mode?: "off" | "observe" | "autopilot";
591
+ github?: {
592
+ read?: boolean;
593
+ write?: boolean;
594
+ apiBaseUrl?: string;
595
+ bugLabels?: string[];
596
+ issueLabels?: string[];
597
+ createIssues?: "never" | "confirmed";
598
+ reopenIssues?: "never" | "closed_reproduced";
599
+ closeIssues?: "never" | "after_stability_window";
600
+ comment?: "never" | "state_changes";
601
+ };
602
+ evidence?: {
603
+ autoRerunThinFailures?: boolean;
604
+ maxAutoReruns?: number;
605
+ requireForIssueCreation?: boolean;
606
+ };
607
+ stability?: {
608
+ cleanRunsBeforeClose?: number;
609
+ };
610
+ };
589
611
  };
590
612
  services?: Record<string, ServiceConfig>;
591
613
  toolchains?: Record<string, ToolchainConfig>;
@@ -7,9 +7,12 @@ import {
7
7
  TESTKIT_SCOPE_LABEL,
8
8
  buildProductIdentity,
9
9
  dockerContainerSummary,
10
+ dockerVolumeSummary,
10
11
  listLegacyTestkitPostgresContainers,
11
12
  listManagedDockerContainers,
13
+ listManagedDockerVolumes,
12
14
  removeDockerContainer,
15
+ removeDockerVolume,
13
16
  stopDockerContainer,
14
17
  } from "../ownership/docker.mjs";
15
18
  import { buildContainerName } from "./naming.mjs";
@@ -22,6 +25,7 @@ import {
22
25
  export async function cleanupLocalPostgresDockerResources(options = {}) {
23
26
  const product = buildProductIdentity(options.productDir || process.cwd());
24
27
  const managed = await listManagedDockerContainers();
28
+ const managedVolumes = await listManagedDockerVolumes();
25
29
  const targets = [];
26
30
  const kept = [];
27
31
  const stopped = [];
@@ -38,6 +42,16 @@ export async function cleanupLocalPostgresDockerResources(options = {}) {
38
42
  }
39
43
  }
40
44
 
45
+ for (const volume of managedVolumes) {
46
+ if (!isManagedLocalPostgresVolume(volume)) continue;
47
+ const classification = classifyManagedLocalPostgresVolume(volume, product, options);
48
+ if (classification.action === "remove") {
49
+ targets.push({ ...volume, action: "remove", reason: classification.reason, legacy: false, resourceType: "volume" });
50
+ } else if (classification.reason) {
51
+ kept.push({ ...volume, reason: classification.reason, legacy: false, resourceType: "volume" });
52
+ }
53
+ }
54
+
41
55
  if (options.includeLegacy) {
42
56
  const currentLegacyName = buildContainerName(product.dir);
43
57
  for (const container of await listLegacyTestkitPostgresContainers()) {
@@ -51,6 +65,8 @@ export async function cleanupLocalPostgresDockerResources(options = {}) {
51
65
  if (container.action === "stop") {
52
66
  await stopDockerContainer(container.name || container.id);
53
67
  stopped.push(container);
68
+ } else if (container.resourceType === "volume") {
69
+ await removeDockerVolume(container.name);
54
70
  } else {
55
71
  await removeDockerContainer(container.name || container.id);
56
72
  }
@@ -71,7 +87,10 @@ export function formatDatabaseResourceCleanupLine(entry, dryRun = false) {
71
87
  ? isStop ? "Would stop" : "Would remove"
72
88
  : isStop ? "Stopped" : "Removed";
73
89
  const legacy = entry.legacy ? " legacy" : "";
74
- return `${action}${legacy} database resource ${dockerContainerSummary(entry)} reason=${entry.reason}`;
90
+ const summary = entry.resourceType === "volume"
91
+ ? `volume ${dockerVolumeSummary(entry)}`
92
+ : dockerContainerSummary(entry);
93
+ return `${action}${legacy} database resource ${summary} reason=${entry.reason}`;
75
94
  }
76
95
 
77
96
  function classifyManagedLocalPostgresContainer(container, product, options) {
@@ -112,6 +131,41 @@ function classifyManagedLocalPostgresContainer(container, product, options) {
112
131
  return { action: "keep", reason: options.global ? "other-product-retained" : "different-product" };
113
132
  }
114
133
 
134
+ function classifyManagedLocalPostgresVolume(volume, product, options) {
135
+ const labels = volume.labels || {};
136
+ const volumeProductId = labels[TESTKIT_PRODUCT_ID_LABEL] || "";
137
+ const volumeProductDir = labels[TESTKIT_PRODUCT_DIR_LABEL] || "";
138
+ const currentProduct = volumeProductId === product.id;
139
+
140
+ if (options.force && currentProduct) {
141
+ return { action: "remove", reason: "destroy-current-product" };
142
+ }
143
+
144
+ if (!options.global && !currentProduct) {
145
+ return { action: "keep", reason: "different-product" };
146
+ }
147
+
148
+ if (currentProduct) {
149
+ if (!hasRemainingLocalArtifacts(product.dir, readStateValue)) {
150
+ return { action: "remove", reason: "current-product-no-local-artifacts" };
151
+ }
152
+ if (!localArtifactsReferenceVolume(product.dir, volume.name)) {
153
+ return { action: "remove", reason: "current-product-unreferenced" };
154
+ }
155
+ return { action: "keep", reason: "current-product-referenced" };
156
+ }
157
+
158
+ if (options.global && volumeProductDir && !fs.existsSync(volumeProductDir)) {
159
+ return { action: "remove", reason: "product-dir-missing" };
160
+ }
161
+
162
+ if (options.global && volumeProductDir && !hasRemainingLocalArtifacts(volumeProductDir, readStateValue)) {
163
+ return { action: "remove", reason: "product-has-no-local-artifacts" };
164
+ }
165
+
166
+ return { action: "keep", reason: options.global ? "other-product-retained" : "different-product" };
167
+ }
168
+
115
169
  function shouldStopIdleLocalPostgresContainer(container, productDir, options) {
116
170
  return Boolean(
117
171
  options.stopIdle &&
@@ -130,6 +184,14 @@ function isManagedLocalPostgresContainer(container) {
130
184
  );
131
185
  }
132
186
 
187
+ function isManagedLocalPostgresVolume(volume) {
188
+ const labels = volume.labels || {};
189
+ return (
190
+ labels[TESTKIT_RESOURCE_KIND_LABEL] === "postgres-volume" &&
191
+ labels[TESTKIT_SCOPE_LABEL] === "local-postgres"
192
+ );
193
+ }
194
+
133
195
  function localArtifactsReferenceContainer(productDir, containerName) {
134
196
  if (!containerName) return false;
135
197
  const root = path.join(productDir, ".testkit");
@@ -146,6 +208,19 @@ function localArtifactsReferenceContainer(productDir, containerName) {
146
208
  return referenced;
147
209
  }
148
210
 
211
+ function localArtifactsReferenceVolume(productDir, volumeName) {
212
+ if (!volumeName) return false;
213
+ const root = path.join(productDir, ".testkit");
214
+ let referenced = false;
215
+ visitDirs(root, (dir) => {
216
+ if (referenced) return;
217
+ if (readStateValue(path.join(dir, "volume_name")) === volumeName) {
218
+ referenced = true;
219
+ }
220
+ });
221
+ return referenced;
222
+ }
223
+
149
224
  function hasActiveProductRuntime(productDir) {
150
225
  return hasActiveRunManifest(productDir) || hasActiveLocalEnvironmentManifest(productDir);
151
226
  }
@@ -457,6 +457,12 @@ async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint,
457
457
  fs.writeFileSync(path.join(config.stateDir, `${backend}_template_database_name`), templateDbName);
458
458
  if (backend === "local") {
459
459
  fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
460
+ if (infra.volumeName) {
461
+ fs.writeFileSync(path.join(config.stateDir, "volume_name"), infra.volumeName);
462
+ }
463
+ if (infra.volumeMountPath) {
464
+ fs.writeFileSync(path.join(config.stateDir, "volume_mount_path"), infra.volumeMountPath);
465
+ }
460
466
  } else {
461
467
  fs.writeFileSync(path.join(config.stateDir, "resource_name"), infra.resourceName);
462
468
  writeResourceConnectionState(config.stateDir, infra);