@databricks-solutions/lakebase-scm-utils 0.3.6 → 0.3.8

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 (51) hide show
  1. package/dist/scripts/index.cjs +40 -21
  2. package/dist/scripts/index.d.cts +1 -1
  3. package/dist/scripts/index.d.ts +1 -1
  4. package/dist/scripts/index.js +39 -21
  5. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
  6. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
  7. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
  8. package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
  9. package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
  10. package/dist/scripts/lakebase/detect-language.cli.js +2 -2
  11. package/dist/scripts/lakebase/doctor.cli.cjs +2 -2
  12. package/dist/scripts/lakebase/doctor.cli.js +2 -2
  13. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  14. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  15. package/dist/scripts/lakebase/index.cjs +40 -21
  16. package/dist/scripts/lakebase/index.d.cts +13 -4
  17. package/dist/scripts/lakebase/index.d.ts +13 -4
  18. package/dist/scripts/lakebase/index.js +39 -21
  19. package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
  20. package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
  21. package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
  22. package/dist/scripts/lakebase/new-migration.cli.js +2 -2
  23. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  24. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  25. package/dist/scripts/lakebase/schema-migrate.cli.cjs +2 -2
  26. package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
  27. package/dist/scripts/lakebase/scm-abandon-feature.cli.cjs +37 -22
  28. package/dist/scripts/lakebase/scm-abandon-feature.cli.js +34 -22
  29. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
  30. package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
  31. package/dist/scripts/lakebase/scm-doctor.cli.cjs +36 -21
  32. package/dist/scripts/lakebase/scm-doctor.cli.js +36 -21
  33. package/dist/scripts/lakebase/scm-merge.cli.cjs +2 -2
  34. package/dist/scripts/lakebase/scm-merge.cli.js +2 -2
  35. package/dist/scripts/lakebase/scm-park-feature.cli.cjs +586 -0
  36. package/dist/scripts/lakebase/scm-park-feature.cli.d.cts +4 -0
  37. package/dist/scripts/lakebase/scm-park-feature.cli.d.ts +4 -0
  38. package/dist/scripts/lakebase/scm-park-feature.cli.js +546 -0
  39. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
  40. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
  41. package/dist/scripts/util/index.cjs +2 -2
  42. package/dist/scripts/util/index.js +2 -2
  43. package/package.json +2 -1
  44. package/scripts/lakebase/scm-abandon-feature.cli.ts +3 -3
  45. package/scripts/lakebase/scm-abandon-feature.ts +19 -83
  46. package/scripts/lakebase/scm-park-feature.cli.ts +150 -0
  47. package/scripts/lakebase/scm-park-feature.ts +71 -0
  48. package/scripts/lakebase/scm-release-claim.ts +146 -0
  49. package/templates/project/common/consort-capture.ts +121 -3
  50. package/templates/project/python/tests/__pycache__/conftest.cpython-314.pyc +0 -0
  51. package/templates/project/python/tests/conftest.py +90 -1
@@ -13,28 +13,19 @@
13
13
  // completes the state reset.
14
14
 
15
15
  import { deletePairedBranch } from "./paired-branch.js";
16
- import { getCurrentBranch } from "../git/inspect.js";
17
- import { isDirty } from "../git/status.js";
18
- import { exec } from "../util/exec.js";
16
+ import { type ScmWorkflowState } from "./scm-workflow-state.js";
19
17
  import {
20
- readWorkflowState,
21
- writeWorkflowState,
22
- type ScmWorkflowState,
23
- } from "./scm-workflow-state.js";
18
+ ScmReleaseError,
19
+ prepareClaimRelease,
20
+ resetToScaffoldComplete,
21
+ } from "./scm-release-claim.js";
24
22
 
25
- export class ScmAbandonError extends Error {
26
- constructor(
27
- message: string,
28
- public readonly code:
29
- | "no-state-file"
30
- | "bad-precondition"
31
- | "dirty-working-tree"
32
- | "missing-claim-fields",
33
- ) {
34
- super(message);
35
- this.name = "ScmAbandonError";
36
- }
37
- }
23
+ export { ScmReleaseError };
24
+
25
+ /** Back-compat alias: abandon historically threw its own error class. It is now a subclass of the
26
+ * shared `ScmReleaseError` so existing `instanceof ScmAbandonError` checks still narrow, while the
27
+ * shared release core (which both abandon and park use) throws the base. */
28
+ export class ScmAbandonError extends ScmReleaseError {}
38
29
 
39
30
  export interface AbandonFeatureArgs {
40
31
  projectDir: string;
@@ -64,76 +55,21 @@ export interface AbandonFeatureResult {
64
55
  export async function abandonFeatureBranch(
65
56
  args: AbandonFeatureArgs,
66
57
  ): Promise<AbandonFeatureResult> {
67
- const current = readWorkflowState(args.projectDir);
68
- if (!current) {
69
- throw new ScmAbandonError(
70
- `No SCM workflow state found at ${args.projectDir}/.lakebase/workflow-state.json.`,
71
- "no-state-file",
72
- );
73
- }
74
- if (current.state !== "feature-claimed") {
75
- throw new ScmAbandonError(
76
- `abandon refuses state "${current.state}". Only feature-claimed is abandonable; later states must complete or be reverted via gh.`,
77
- "bad-precondition",
78
- );
79
- }
80
- if (
81
- !current.feature_id ||
82
- !current.branch ||
83
- !current.parent_branch ||
84
- !current.lakebase_branch_uid
85
- ) {
86
- throw new ScmAbandonError(
87
- "feature-claimed row is missing required invariants. Cannot abandon safely; consider re-adopting state first.",
88
- "missing-claim-fields",
89
- );
90
- }
91
-
92
- if (!args.force) {
93
- const dirty = await isDirty({ cwd: args.projectDir });
94
- if (dirty) {
95
- throw new ScmAbandonError(
96
- "Working tree has uncommitted changes; refusing to abandon (the branch delete would lose them). Commit / stash / discard first, or pass --force.",
97
- "dirty-working-tree",
98
- );
99
- }
100
- }
101
-
102
- const instance = args.instance ?? current.project_id;
103
- const switchTo = args.switchTo ?? current.parent_branch;
104
- const warnings: string[] = [];
105
-
106
- // Switch HEAD off the feature branch first so deletePairedBranch can
107
- // delete the local git branch (it skips when the branch is checked out).
108
- const headBranch = await getCurrentBranch({ cwd: args.projectDir });
109
- if (headBranch === current.branch) {
110
- try {
111
- await exec(`git checkout ${JSON.stringify(switchTo)}`, {
112
- cwd: args.projectDir,
113
- timeout: 10_000,
114
- });
115
- } catch (err) {
116
- warnings.push(
117
- `git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch delete may be skipped.`,
118
- );
119
- }
120
- }
58
+ // Shared preamble: validate the claim is releasable + switch HEAD off the feature branch.
59
+ const { current, warnings } = await prepareClaimRelease(
60
+ { projectDir: args.projectDir, force: args.force, switchTo: args.switchTo },
61
+ "abandon",
62
+ );
121
63
 
64
+ // Abandon's distinguishing step: tear down the paired Lakebase + git branch.
122
65
  const del = await deletePairedBranch({
123
- instance,
66
+ instance: args.instance ?? current.project_id,
124
67
  branch: current.branch,
125
68
  cwd: args.projectDir,
126
69
  });
127
70
  warnings.push(...del.warnings);
128
71
 
129
- const reset: ScmWorkflowState = {
130
- $schema: current.$schema,
131
- version: 1,
132
- state: "scaffold-complete",
133
- tier_topology: current.tier_topology,
134
- project_id: current.project_id,
135
- };
136
- writeWorkflowState(args.projectDir, reset);
72
+ const reset: ScmWorkflowState = resetToScaffoldComplete(args.projectDir, current);
137
73
 
138
74
  return {
139
75
  state: reset,
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // CLI: park the in-flight feature claim (free it, KEEP the branch) and reset to scaffold-complete.
3
+ // The resume-later counterpart to abandon. phase B+.
4
+
5
+ import * as path from "node:path";
6
+ import { isCliEntry } from "../util/cli-entry.js";
7
+ import { ScmReleaseError } from "./scm-release-claim.js";
8
+ import {
9
+ parkFeatureBranch,
10
+ type ParkFeatureResult,
11
+ } from "./scm-park-feature.js";
12
+
13
+ interface ParsedArgs {
14
+ projectDir?: string;
15
+ switchTo?: string;
16
+ force?: boolean;
17
+ json?: boolean;
18
+ pretty?: boolean;
19
+ help?: boolean;
20
+ }
21
+
22
+ function parseArgs(argv: string[]): ParsedArgs {
23
+ const out: ParsedArgs = {};
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const a = argv[i];
26
+ switch (a) {
27
+ case "--project-dir":
28
+ case "--cwd":
29
+ out.projectDir = argv[++i];
30
+ break;
31
+ case "--switch-to":
32
+ out.switchTo = argv[++i];
33
+ break;
34
+ case "--force":
35
+ out.force = true;
36
+ break;
37
+ case "--json":
38
+ out.json = true;
39
+ break;
40
+ case "--pretty":
41
+ out.pretty = true;
42
+ break;
43
+ case "--help":
44
+ case "-h":
45
+ out.help = true;
46
+ break;
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+
52
+ const HELP = `lakebase-scm-park-feature (phase B+)
53
+
54
+ Release a feature claim but KEEP its paired branch: switch HEAD to the parent
55
+ tier, reset workflow-state to scaffold-complete, leave the Lakebase + git
56
+ branch intact. Frees the single SCM claim so another feature can be claimed
57
+ and driven; resume the parked feature later with
58
+ 'lakebase-scm-claim-feature-branch <feature>' (it reuses the kept branch).
59
+
60
+ Usage:
61
+ lakebase-scm-park-feature [flags]
62
+
63
+ Flags:
64
+ --project-dir <dir> Project root (default: cwd)
65
+ --switch-to <branch> Branch to checkout after releasing (default: parent_branch)
66
+ --force Allow park even with a dirty working tree
67
+ (edits are kept but stranded on the feature branch)
68
+ --json Machine-readable JSON output
69
+ --pretty Pretty-print JSON (only with --json)
70
+ -h, --help Show this help
71
+
72
+ Exit codes:
73
+ 0 = parked (claim freed, branch kept, state reset to scaffold-complete)
74
+ 1 = no state file
75
+ 2 = precondition refused (wrong state, dirty tree without --force, missing fields)
76
+ 3 = substrate failure
77
+ `;
78
+
79
+ interface ParkReport {
80
+ ok: boolean;
81
+ result?: ParkFeatureResult;
82
+ error?: { code: string; message: string };
83
+ }
84
+
85
+ function renderHuman(report: ParkReport): string {
86
+ if (!report.ok) {
87
+ return `lakebase-scm-park-feature: ${report.error?.code}\n\n ${report.error?.message}`;
88
+ }
89
+ const r = report.result!;
90
+ const lines: string[] = ["Feature parked (branch kept):"];
91
+ lines.push(` state : ${r.state.state}`);
92
+ lines.push(` parked_feature : ${r.parkedFeatureId}`);
93
+ lines.push(` kept_branch : ${r.keptBranch}`);
94
+ lines.push(` switched_to : ${r.switchedTo}`);
95
+ lines.push("");
96
+ lines.push(` resume with : lakebase-scm-claim-feature-branch ${r.parkedFeatureId}`);
97
+ if (r.warnings.length > 0) {
98
+ lines.push("");
99
+ lines.push("warnings:");
100
+ for (const w of r.warnings) lines.push(` - ${w}`);
101
+ }
102
+ return lines.join("\n");
103
+ }
104
+
105
+ function exitCodeForError(err: Error): number {
106
+ if (err instanceof ScmReleaseError) {
107
+ if (err.code === "no-state-file") return 1;
108
+ return 2;
109
+ }
110
+ return 3;
111
+ }
112
+
113
+ export async function runScmParkFeatureCli(argv: string[]): Promise<number> {
114
+ const args = parseArgs(argv);
115
+ if (args.help) {
116
+ process.stdout.write(`${HELP}\n`);
117
+ return 0;
118
+ }
119
+ const projectDir = path.resolve(args.projectDir ?? process.cwd());
120
+ try {
121
+ const result = await parkFeatureBranch({
122
+ projectDir,
123
+ switchTo: args.switchTo,
124
+ force: args.force,
125
+ });
126
+ const report: ParkReport = { ok: true, result };
127
+ if (args.json) {
128
+ const indent = args.pretty ? 2 : 0;
129
+ process.stdout.write(`${JSON.stringify(report, null, indent)}\n`);
130
+ } else {
131
+ process.stdout.write(`${renderHuman(report)}\n`);
132
+ }
133
+ return 0;
134
+ } catch (e) {
135
+ const err = e as Error;
136
+ const code = err instanceof ScmReleaseError ? err.code : "substrate-failure";
137
+ const report: ParkReport = { ok: false, error: { code, message: err.message } };
138
+ if (args.json) {
139
+ const indent = args.pretty ? 2 : 0;
140
+ process.stdout.write(`${JSON.stringify(report, null, indent)}\n`);
141
+ } else {
142
+ process.stderr.write(`${renderHuman(report)}\n`);
143
+ }
144
+ return exitCodeForError(err);
145
+ }
146
+ }
147
+
148
+ if (isCliEntry(import.meta.url)) {
149
+ void runScmParkFeatureCli(process.argv.slice(2)).then((c) => process.exit(c));
150
+ }
@@ -0,0 +1,71 @@
1
+ // SCM workflow park (phase B+): release a feature claim WITHOUT tearing down the branch.
2
+ //
3
+ // Transitions feature-claimed -> scaffold-complete and switches HEAD to the parent tier, but — unlike
4
+ // abandon — KEEPS the paired Lakebase + git branch intact. This frees the single SCM claim so another
5
+ // feature can be claimed and driven, while the parked feature's work sits safely on its branch. The
6
+ // claim model makes RESUMING free: `lakebase-scm-claim-feature-branch <same-feature>` re-claims from
7
+ // scaffold-complete and `createFeaturePairedBranch` reuses the existing paired branch as-is (DB and
8
+ // all), picking the feature back up where it stopped.
9
+ //
10
+ // Use when a feature is built but NOT yet deployed/merged and you want to shelve it and go work on
11
+ // another. (Abandon is the throwaway counterpart: it deletes the branch.) Refuses on a dirty working
12
+ // tree by default (switching HEAD would strand uncommitted edits) unless --force.
13
+ //
14
+ // The preamble (precondition + dirty-guard + HEAD switch) and the scaffold-complete reset are the
15
+ // shared release core in scm-release-claim.ts, so park and abandon cannot drift.
16
+
17
+ import { type ScmWorkflowState } from "./scm-workflow-state.js";
18
+ import {
19
+ ScmReleaseError,
20
+ prepareClaimRelease,
21
+ resetToScaffoldComplete,
22
+ } from "./scm-release-claim.js";
23
+
24
+ export { ScmReleaseError };
25
+
26
+ export interface ParkFeatureArgs {
27
+ /** Project root (must contain .lakebase/workflow-state.json + .git/). */
28
+ projectDir: string;
29
+ /**
30
+ * Skip the dirty-working-tree refusal. Uncommitted edits are NOT lost (the branch is kept), but
31
+ * switching HEAD off the feature branch would leave them stranded on it, so the default refuses.
32
+ */
33
+ force?: boolean;
34
+ /**
35
+ * Switch HEAD to this branch after releasing the claim. Defaults to the workflow row's
36
+ * parent_branch (the tier the feature forked from).
37
+ */
38
+ switchTo?: string;
39
+ }
40
+
41
+ export interface ParkFeatureResult {
42
+ state: ScmWorkflowState;
43
+ /** The branch left intact (git name) — resume with `lakebase-scm-claim-feature-branch`. */
44
+ keptBranch: string;
45
+ /** The feature id that was parked (for logging / the consort bookkeeping repoint). */
46
+ parkedFeatureId: string;
47
+ switchedTo: string;
48
+ warnings: string[];
49
+ }
50
+
51
+ export async function parkFeatureBranch(
52
+ args: ParkFeatureArgs,
53
+ ): Promise<ParkFeatureResult> {
54
+ // Shared preamble: validate the claim is releasable + switch HEAD off the feature branch.
55
+ const { current, switchTo, warnings } = await prepareClaimRelease(
56
+ { projectDir: args.projectDir, force: args.force, switchTo: args.switchTo },
57
+ "park",
58
+ );
59
+
60
+ // Park's distinguishing (non-)step: the paired branch is DELIBERATELY left in place — this is the
61
+ // whole point. Just reset the claim row so the next feature can be claimed.
62
+ const reset = resetToScaffoldComplete(args.projectDir, current);
63
+
64
+ return {
65
+ state: reset,
66
+ keptBranch: current.branch,
67
+ parkedFeatureId: current.feature_id,
68
+ switchedTo: switchTo,
69
+ warnings,
70
+ };
71
+ }
@@ -0,0 +1,146 @@
1
+ // Shared core for the two ways to RELEASE an in-flight feature claim back to scaffold-complete:
2
+ // - abandon (scm-abandon-feature): free the claim AND tear down the paired branch.
3
+ // - park (scm-park-feature): free the claim but KEEP the paired branch, so the feature can
4
+ // be resumed later (a re-claim reuses the existing branch as-is).
5
+ //
6
+ // Both share the exact same preamble — read state, require feature-claimed, require the claim's
7
+ // invariants, refuse a dirty tree (switching HEAD or deleting the branch would strand uncommitted
8
+ // work) unless --force, then switch HEAD off the feature branch — and the exact same tail: reset the
9
+ // workflow-state row to a bare scaffold-complete. The ONLY difference is whether the paired branch is
10
+ // deleted in between. Factored here so the two commands cannot drift (DRY); each command owns only its
11
+ // middle step and its own CLI surface.
12
+
13
+ import { getCurrentBranch } from "../git/inspect.js";
14
+ import { isDirty } from "../git/status.js";
15
+ import { exec } from "../util/exec.js";
16
+ import {
17
+ readWorkflowState,
18
+ writeWorkflowState,
19
+ type ScmWorkflowState,
20
+ } from "./scm-workflow-state.js";
21
+
22
+ export type ScmReleaseErrorCode =
23
+ | "no-state-file"
24
+ | "bad-precondition"
25
+ | "dirty-working-tree"
26
+ | "missing-claim-fields";
27
+
28
+ /** The shared error for a claim release. `abandon` keeps its own `ScmAbandonError` as a subclass for
29
+ * back-compat; CLIs that map codes to exit statuses check `instanceof ScmReleaseError` (the base). */
30
+ export class ScmReleaseError extends Error {
31
+ constructor(
32
+ message: string,
33
+ public readonly code: ScmReleaseErrorCode,
34
+ ) {
35
+ super(message);
36
+ this.name = "ScmReleaseError";
37
+ }
38
+ }
39
+
40
+ export interface ReleaseClaimArgs {
41
+ /** Project root (must contain .lakebase/workflow-state.json + .git/). */
42
+ projectDir: string;
43
+ /** Skip the dirty-working-tree refusal. */
44
+ force?: boolean;
45
+ /** Branch to checkout before releasing. Defaults to the workflow row's parent_branch. */
46
+ switchTo?: string;
47
+ }
48
+
49
+ /** A feature-claimed row with its claim invariants proven present, plus the resolved HEAD target. */
50
+ export interface PreparedRelease {
51
+ current: ScmWorkflowState & {
52
+ feature_id: string;
53
+ branch: string;
54
+ parent_branch: string;
55
+ lakebase_branch_uid: string;
56
+ };
57
+ switchTo: string;
58
+ warnings: string[];
59
+ }
60
+
61
+ /**
62
+ * The shared preamble for abandon + park: validate the claim is releasable and switch HEAD off the
63
+ * feature branch so the next step (delete, or just reset) is safe. `verb` ("abandon" | "park") only
64
+ * shapes the human-facing refusal messages. Throws `ScmReleaseError` on any precondition failure.
65
+ */
66
+ export async function prepareClaimRelease(
67
+ args: ReleaseClaimArgs,
68
+ verb: "abandon" | "park",
69
+ ): Promise<PreparedRelease> {
70
+ const current = readWorkflowState(args.projectDir);
71
+ if (!current) {
72
+ throw new ScmReleaseError(
73
+ `No SCM workflow state found at ${args.projectDir}/.lakebase/workflow-state.json.`,
74
+ "no-state-file",
75
+ );
76
+ }
77
+ if (current.state !== "feature-claimed") {
78
+ throw new ScmReleaseError(
79
+ `${verb} refuses state "${current.state}". Only feature-claimed is ${verb}able; later states must complete or be reverted via gh.`,
80
+ "bad-precondition",
81
+ );
82
+ }
83
+ if (
84
+ !current.feature_id ||
85
+ !current.branch ||
86
+ !current.parent_branch ||
87
+ !current.lakebase_branch_uid
88
+ ) {
89
+ throw new ScmReleaseError(
90
+ `feature-claimed row is missing required invariants. Cannot ${verb} safely; consider re-adopting state first.`,
91
+ "missing-claim-fields",
92
+ );
93
+ }
94
+
95
+ if (!args.force) {
96
+ const dirty = await isDirty({ cwd: args.projectDir });
97
+ if (dirty) {
98
+ throw new ScmReleaseError(
99
+ `Working tree has uncommitted changes; refusing to ${verb} (switching HEAD off the feature branch would strand them). Commit / stash / discard first, or pass --force.`,
100
+ "dirty-working-tree",
101
+ );
102
+ }
103
+ }
104
+
105
+ const switchTo = args.switchTo ?? current.parent_branch;
106
+ const warnings: string[] = [];
107
+
108
+ // Switch HEAD off the feature branch first: abandon needs it so the local git branch can be
109
+ // deleted (delete skips a checked-out branch); park needs it so the tree sits on the tier, ready
110
+ // for the next claim. Best-effort — a checkout failure is a warning, not a hard stop.
111
+ const headBranch = await getCurrentBranch({ cwd: args.projectDir });
112
+ if (headBranch === current.branch) {
113
+ try {
114
+ await exec(`git checkout ${JSON.stringify(switchTo)}`, {
115
+ cwd: args.projectDir,
116
+ timeout: 10_000,
117
+ });
118
+ } catch (err) {
119
+ warnings.push(
120
+ `git checkout ${switchTo} failed: ${err instanceof Error ? err.message : String(err)}. Local branch state may be left on the feature branch.`,
121
+ );
122
+ }
123
+ }
124
+
125
+ return { current: current as PreparedRelease["current"], switchTo, warnings };
126
+ }
127
+
128
+ /**
129
+ * Reset the workflow-state row to a bare scaffold-complete (no feature in flight), preserving only
130
+ * the project-scoped facts (tier topology, project id). The shared tail of both abandon and park:
131
+ * after this the next claim can proceed.
132
+ */
133
+ export function resetToScaffoldComplete(
134
+ projectDir: string,
135
+ current: ScmWorkflowState,
136
+ ): ScmWorkflowState {
137
+ const reset: ScmWorkflowState = {
138
+ $schema: current.$schema,
139
+ version: 1,
140
+ state: "scaffold-complete",
141
+ tier_topology: current.tier_topology,
142
+ project_id: current.project_id,
143
+ };
144
+ writeWorkflowState(projectDir, reset);
145
+ return reset;
146
+ }
@@ -9,8 +9,8 @@
9
9
  // the client's bundler-resolution tsconfig. Importing these from here keeps the client Playwright-free.
10
10
  //
11
11
  // Pure observability: everything is wrapped so a write can NEVER fail a test/suite.
12
- import { appendFileSync, existsSync, mkdirSync } from "node:fs";
13
- import { dirname, join } from "node:path";
12
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
13
+ import { basename, dirname, join } from "node:path";
14
14
 
15
15
  /** Walk up from `start` to the project's `.consort` dir (tests run from root OR client/, so cwd
16
16
  * varies); fall back to `<cwd>/.consort` (created on write) if none is found. */
@@ -33,15 +33,133 @@ export function emitTestRun(id: string, status: TestRunStatus, absFile: string,
33
33
  mkdirSync(dir, { recursive: true });
34
34
  const root = dirname(dir);
35
35
  const file = (absFile || "").replace(`${root}/`, "");
36
+ const ts = new Date().toISOString();
36
37
  appendFileSync(
37
38
  join(dir, "test-run.jsonl"),
38
- JSON.stringify({ ts: new Date().toISOString(), test_id: id, status, file, kind }) + "\n",
39
+ JSON.stringify({ ts, test_id: id, status, file, kind }) + "\n",
39
40
  );
41
+ // RECORD a durable per-test `test.result` into the agent-log AT RUN TIME (not a cycle-end batch),
42
+ // so the dashboard lights each bubble the moment its test runs (live), scrubbing lights each test
43
+ // AT the point it actually ran (the event sits at its real position in the stream), and a REPLAY of
44
+ // the recording does the same — the per-test trail is part of the durable record, not a live-only
45
+ // overlay. Attributed to its story via the on-disk test-lists (the agent-log fold keys by
46
+ // feature|story|test). Best-effort: a reporter write must never fail the suite.
47
+ recordTestResultEvent(dir, id, status, file, ts);
40
48
  } catch {
41
49
  /* best-effort: a per-test reporter write must never fail the suite */
42
50
  }
43
51
  }
44
52
 
53
+ /** Owner of a test id, resolved from the project's per-story test-lists (the attribution the reporter
54
+ * lacks — it only sees id + file). Cached per process (a reporter process is one suite run). */
55
+ interface TestOwner { feature_id: string; story_id: string; ac_id: string; scenario_file?: string }
56
+ const ownerIndexCache = new Map<string, Map<string, TestOwner[]>>();
57
+
58
+ function buildOwnerIndex(consortDir: string): Map<string, TestOwner[]> {
59
+ const cached = ownerIndexCache.get(consortDir);
60
+ if (cached) return cached;
61
+ const index = new Map<string, TestOwner[]>();
62
+ try {
63
+ const featuresDir = join(consortDir, "features");
64
+ for (const f of readdirSync(featuresDir)) {
65
+ const storiesDir = join(featuresDir, f, "stories");
66
+ let stories: string[];
67
+ try { stories = readdirSync(storiesDir); } catch { continue; }
68
+ for (const s of stories) {
69
+ const listFile = join(storiesDir, s, "test-list-per-story.json");
70
+ if (!existsSync(listFile)) continue;
71
+ let list: { feature_id?: string; story_id?: string; items?: unknown[] };
72
+ try { list = JSON.parse(readFileSync(listFile, "utf8")); } catch { continue; }
73
+ const featureId = typeof list.feature_id === "string" ? list.feature_id : f;
74
+ const storyId = typeof list.story_id === "string" ? list.story_id : s;
75
+ for (const raw of list.items ?? []) {
76
+ const it = raw as { id?: unknown; ac_id?: unknown; scenario_file?: unknown };
77
+ if (typeof it.id !== "string") continue;
78
+ const owners = index.get(it.id) ?? [];
79
+ owners.push({
80
+ feature_id: featureId,
81
+ story_id: storyId,
82
+ ac_id: typeof it.ac_id === "string" ? it.ac_id : "unknown",
83
+ ...(typeof it.scenario_file === "string" ? { scenario_file: it.scenario_file } : {}),
84
+ });
85
+ index.set(it.id, owners);
86
+ }
87
+ }
88
+ }
89
+ } catch {
90
+ /* no test-lists (pre-design project) — leave the index empty; the kit's cycle-scoped emit covers it */
91
+ }
92
+ ownerIndexCache.set(consortDir, index);
93
+ return index;
94
+ }
95
+
96
+ /** The feature currently on the build lane, from `.consort/next.json` — the disambiguator of last
97
+ * resort when a reused T-id's file does not pin a single owner (e.g. a fitness/behavior test whose
98
+ * test-list item carries no scenario_file). Null when unreadable. */
99
+ function activeFeature(consortDir: string): string | null {
100
+ try {
101
+ const next = JSON.parse(readFileSync(join(consortDir, "next.json"), "utf8")) as { feature?: unknown };
102
+ return typeof next.feature === "string" ? next.feature : null;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /** Resolve a (test id, file) to its single owning story. One owner -> it. Several (a T-id reused across
109
+ * features — ids are unique only WITHIN a story) -> (1) the owner whose scenario_file basename matches
110
+ * the running file (a client/e2e spec pins ITSELF, so a sibling feature's same-id spec resolves to its
111
+ * own story); then (2) the owner in the feature currently on the build lane (next.json) — a
112
+ * fitness/behavior test carries no scenario_file, so during THIS feature's build its reused id belongs
113
+ * to the active feature's story, while another feature's same-id test already resolved via (1). Else
114
+ * null (skip, never mis-attribute). */
115
+ function resolveOwner(consortDir: string, id: string, file: string): TestOwner | null {
116
+ const base = /^(T\d+)/.exec(id)?.[1] ?? id;
117
+ const index = buildOwnerIndex(consortDir);
118
+ const owners = index.get(id) ?? index.get(base) ?? [];
119
+ if (owners.length === 0) return null;
120
+ if (owners.length === 1) return owners[0];
121
+ // (1) The owner whose scenario_file matches the running file (unique match).
122
+ const fileBase = basename(file || "");
123
+ if (fileBase) {
124
+ const byFile = owners.filter((o) => o.scenario_file && basename(o.scenario_file) === fileBase);
125
+ if (byFile.length === 1) return byFile[0];
126
+ }
127
+ // (2) Fall back to the owner in the currently-building feature (one match only).
128
+ const active = activeFeature(consortDir);
129
+ if (active) {
130
+ const byFeature = owners.filter((o) => o.feature_id === active);
131
+ if (byFeature.length === 1) return byFeature[0];
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** Append a durable, schema-valid `test.result` to the agent-log (+ the corpus mirror when a recording
137
+ * is in flight), shaped exactly like the kit's emitAgentLogEvent output so the dashboard fold reads it
138
+ * identically. Only emitted for an attributable test; best-effort. */
139
+ export function recordTestResultEvent(consortDir: string, id: string, status: TestRunStatus, file: string, ts: string): void {
140
+ try {
141
+ const owner = resolveOwner(consortDir, id, file);
142
+ if (!owner) return; // cannot attribute (ambiguous reused id) — skip rather than mis-key a bubble
143
+ const line =
144
+ JSON.stringify({
145
+ timestamp: ts,
146
+ level: "info",
147
+ role: "driver",
148
+ event: "test.result",
149
+ message: `TEST ${id} ${status}`,
150
+ metadata: { feature_id: owner.feature_id, test_id: id, status, story_id: owner.story_id, ac_id: owner.ac_id },
151
+ }) + "\n";
152
+ appendFileSync(join(consortDir, "agent-log.jsonl"), line);
153
+ // Mirror into the corpus so a RECORDED run replays the per-test trail (same env the kit uses).
154
+ const recordDir = process.env.LAKEBASE_CONSORT_RECORD_DIR?.trim();
155
+ if (recordDir) {
156
+ try { mkdirSync(recordDir, { recursive: true }); appendFileSync(join(recordDir, "agent-log.jsonl"), line); } catch { /* mirror is best-effort */ }
157
+ }
158
+ } catch {
159
+ /* best-effort: recording a per-test result must never fail the suite */
160
+ }
161
+ }
162
+
45
163
  /** Append a per-test TABLE snapshot (one line per table) to `.consort/table-snapshots.jsonl`, keyed
46
164
  * by test id — parallel to `emitTestRun`. Best-effort. */
47
165
  export function emitTableSnapshot(id: string, absFile: string, table: string, rows: unknown[], rowCount: number | null): void {