Skip to content

Commit b1d3e8d

Browse files
authored
fix(miner): scope run-state by forge host, not bare repoFullName (#5585)
repo_full_name TEXT PRIMARY KEY let two forge hosts (github.com vs. a GitHub Enterprise host, #4784) serving a same-named owner/repo share one "current discover/plan/prepare state" row. Rebuild the constraint to PRIMARY KEY (api_base_url, repo_full_name), backfilling existing rows with the pre-#4784 implicit default. The migration uses INSERT OR IGNORE so a pre-existing row with an already-invalid state (this store's read path already fails closed on those) can't abort the whole rebuild. Thread an optional apiBaseUrl through the store's API (getRunState/ setRunState) and the admin CLI (--api-base-url on state get/set). Every existing caller is unaffected: apiBaseUrl defaults to https://api.github.com when omitted. manage-status.js's collectRunPortfolio folds run state into one row per repo NAME (not per host) for its dashboard view -- documented in-code as a known, safe (no data loss, read-only) display limitation for the narrow case of the same repo name existing on two hosts, rather than silently patched over; broadening that fold to be host-aware is a separate, larger dashboard-shape change. Advances #5563 (run-state.js of 5 affected stores; claim-ledger.js landed in #5576, portfolio-queue.js in #5583).
1 parent 8e42774 commit b1d3e8d

7 files changed

Lines changed: 220 additions & 30 deletions

File tree

packages/gittensory-miner/lib/manage-status.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ export function collectRunPortfolio(sources) {
125125
list.push(row);
126126
prsByRepo.set(row.repoFullName, list);
127127
}
128+
// NOTE (#5563): keyed by repoFullName alone, not apiBaseUrl -- this dashboard fold predates multi-forge run
129+
// states and produces exactly ONE row per repo name. If the same repo name has a recorded run state on two
130+
// different hosts, only one (the later entry in listRunStates' order) survives here; the other's row is still
131+
// intact in the store, just not surfaced in this particular view. Safe (no data loss, no write), just a display
132+
// limitation -- broadening this fold to be host-aware is a separate, larger dashboard-shape change.
128133
const runStateByRepo = new Map(runStateStore.listRunStates().map((entry) => [entry.repoFullName, entry]));
129134

130135
const repoFullNames = new Set([...prsByRepo.keys(), ...runStateByRepo.keys()]);

packages/gittensory-miner/lib/run-state-cli.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export type ParsedStateGetArgs =
22
| {
33
repoFullName: string;
44
json: boolean;
5+
apiBaseUrl: string | undefined;
56
}
67
| { error: string };
78

@@ -11,6 +12,7 @@ export type ParsedStateSetArgs =
1112
state: "idle" | "discovering" | "planning" | "preparing";
1213
dryRun: boolean;
1314
json: boolean;
15+
apiBaseUrl: string | undefined;
1416
}
1517
| { error: string };
1618

packages/gittensory-miner/lib/run-state-cli.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { RUN_STATES, getRunState, setRunState } from "./run-state.js";
22
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
33

4-
const STATE_GET_USAGE = "Usage: gittensory-miner state get <owner/repo> [--json]";
4+
const STATE_GET_USAGE = "Usage: gittensory-miner state get <owner/repo> [--api-base-url <url>] [--json]";
55
const STATE_SET_USAGE =
6-
"Usage: gittensory-miner state set <owner/repo> <idle|discovering|planning|preparing> [--dry-run] [--json]";
6+
"Usage: gittensory-miner state set <owner/repo> <idle|discovering|planning|preparing> [--api-base-url <url>] [--dry-run] [--json]";
77

88
const allowedRunStates = new Set(RUN_STATES);
99

@@ -18,7 +18,7 @@ function parseRepoArg(value, usage) {
1818
}
1919

2020
export function parseStateGetArgs(args) {
21-
const options = { json: false };
21+
const options = { json: false, apiBaseUrl: undefined };
2222
const positional = [];
2323

2424
for (let index = 0; index < args.length; index += 1) {
@@ -27,6 +27,17 @@ export function parseStateGetArgs(args) {
2727
options.json = true;
2828
continue;
2929
}
30+
// #5563: scope the lookup to a non-default forge host, so it doesn't collide with (or get confused for) a
31+
// same-named repo on the default github.com host.
32+
if (token === "--api-base-url") {
33+
const value = args[index + 1];
34+
if (!value || value.startsWith("-")) {
35+
return { error: STATE_GET_USAGE };
36+
}
37+
options.apiBaseUrl = value;
38+
index += 1;
39+
continue;
40+
}
3041
if (token.startsWith("-")) {
3142
return { error: `Unknown option: ${token}` };
3243
}
@@ -44,7 +55,7 @@ export function parseStateGetArgs(args) {
4455
}
4556

4657
export function parseStateSetArgs(args) {
47-
const options = { json: false, dryRun: false };
58+
const options = { json: false, dryRun: false, apiBaseUrl: undefined };
4859
const positional = [];
4960

5061
for (let index = 0; index < args.length; index += 1) {
@@ -58,6 +69,15 @@ export function parseStateSetArgs(args) {
5869
options.dryRun = true;
5970
continue;
6071
}
72+
if (token === "--api-base-url") {
73+
const value = args[index + 1];
74+
if (!value || value.startsWith("-")) {
75+
return { error: STATE_SET_USAGE };
76+
}
77+
options.apiBaseUrl = value;
78+
index += 1;
79+
continue;
80+
}
6181
if (token.startsWith("-")) {
6282
return { error: `Unknown option: ${token}` };
6383
}
@@ -86,7 +106,7 @@ export function runStateGet(args) {
86106
}
87107

88108
try {
89-
const state = getRunState(parsed.repoFullName);
109+
const state = getRunState(parsed.repoFullName, parsed.apiBaseUrl);
90110
if (parsed.json) {
91111
console.log(JSON.stringify({ repoFullName: parsed.repoFullName, state }));
92112
} else {
@@ -115,7 +135,7 @@ export function runStateSet(args) {
115135
}
116136

117137
try {
118-
const write = setRunState(parsed.repoFullName, parsed.state);
138+
const write = setRunState(parsed.repoFullName, parsed.state, parsed.apiBaseUrl);
119139
if (parsed.json) {
120140
console.log(JSON.stringify(write));
121141
} else {

packages/gittensory-miner/lib/run-state.d.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
export type RunState = "idle" | "discovering" | "planning" | "preparing";
22

33
export type RunStateWrite = {
4+
apiBaseUrl: string;
45
repoFullName: string;
56
state: RunState;
67
updatedAt: string;
78
};
89

910
export type RunStateRow = {
11+
apiBaseUrl: string;
1012
repoFullName: string;
1113
state: RunState;
1214
updatedAt: string;
1315
};
1416

1517
export type RunStateStore = {
1618
dbPath: string;
17-
getRunState(repoFullName: string): RunState | null;
18-
setRunState(repoFullName: string, state: RunState): RunStateWrite;
19+
getRunState(repoFullName: string, apiBaseUrl?: string): RunState | null;
20+
setRunState(repoFullName: string, state: RunState, apiBaseUrl?: string): RunStateWrite;
1921
listRunStates(): RunStateRow[];
2022
close(): void;
2123
};
@@ -26,9 +28,9 @@ export function resolveRunStateDbPath(env?: Record<string, string | undefined>):
2628

2729
export function initRunStateStore(dbPath?: string): RunStateStore;
2830

29-
export function getRunState(repoFullName: string): RunState | null;
31+
export function getRunState(repoFullName: string, apiBaseUrl?: string): RunState | null;
3032

31-
export function setRunState(repoFullName: string, state: RunState): RunStateWrite;
33+
export function setRunState(repoFullName: string, state: RunState, apiBaseUrl?: string): RunStateWrite;
3234

3335
export function listRunStates(): RunStateRow[];
3436

packages/gittensory-miner/lib/run-state.js

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DEFAULT_FORGE_CONFIG } from "./forge-config.js";
12
import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js";
23
import { applySchemaMigrations } from "./schema-version.js";
34

@@ -28,9 +29,43 @@ function normalizeRunState(state) {
2829
throw new Error("invalid_run_state");
2930
}
3031

32+
/** Optional forge host, scoping rows so two hosts serving the same owner/repo name never collide (#5563).
33+
* Omitted/nullish → the github.com default, so every pre-existing single-forge caller is unaffected. */
34+
function normalizeApiBaseUrl(apiBaseUrl) {
35+
if (apiBaseUrl === undefined || apiBaseUrl === null) return DEFAULT_FORGE_CONFIG.apiBaseUrl;
36+
if (typeof apiBaseUrl !== "string" || !apiBaseUrl.trim()) throw new Error("invalid_api_base_url");
37+
return apiBaseUrl.trim();
38+
}
39+
40+
// v1 -> v2 (#5563): rebuild the bare `repo_full_name` PRIMARY KEY into a (api_base_url, repo_full_name) composite
41+
// -- two forge hosts serving a same-named owner/repo must not share one "current state" row. SQLite cannot ALTER
42+
// a PRIMARY KEY in place, so this rebuilds the table: create the new shape, copy every existing row with the
43+
// pre-#4784 implicit single-forge default backfilled, drop the old table, rename the new one in.
44+
function addApiBaseUrlScope(db) {
45+
db.exec(`
46+
CREATE TABLE miner_run_state_v2 (
47+
api_base_url TEXT NOT NULL,
48+
repo_full_name TEXT NOT NULL,
49+
state TEXT NOT NULL CHECK (state IN ('idle', 'discovering', 'planning', 'preparing')),
50+
updated_at TEXT NOT NULL,
51+
PRIMARY KEY (api_base_url, repo_full_name)
52+
)
53+
`);
54+
// OR IGNORE: a row this store's own read path already treats as unusable garbage (an unrecognized `state`,
55+
// e.g. from a hand-edited or otherwise corrupted file -- getRunState/listRunStates fail closed on it too)
56+
// would violate the CHECK constraint above and abort the whole migration. Skipping it here is consistent with
57+
// that same fail-closed posture, rather than turning one bad row into a permanently unmigratable file.
58+
db.prepare(
59+
`INSERT OR IGNORE INTO miner_run_state_v2 (api_base_url, repo_full_name, state, updated_at)
60+
SELECT ?, repo_full_name, state, updated_at FROM miner_run_state`,
61+
).run(DEFAULT_FORGE_CONFIG.apiBaseUrl);
62+
db.exec("DROP TABLE miner_run_state");
63+
db.exec("ALTER TABLE miner_run_state_v2 RENAME TO miner_run_state");
64+
}
65+
3166
/**
3267
* Opens the 100% local/client-side miner run-state store. The database only lives on this machine;
33-
* this module never uploads, syncs, or phones home with its contents. (#2289)
68+
* this module never uploads, syncs, or phones home with its contents. (#2289, #5563)
3469
*/
3570
export function initRunStateStore(dbPath = resolveRunStateDbPath()) {
3671
const resolvedPath = normalizeDbPath(dbPath);
@@ -42,42 +77,48 @@ export function initRunStateStore(dbPath = resolveRunStateDbPath()) {
4277
updated_at TEXT NOT NULL
4378
)
4479
`);
45-
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet).
46-
applySchemaMigrations(db, []);
80+
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations.
81+
applySchemaMigrations(db, [addApiBaseUrlScope]);
4782

4883
const getStatement = db.prepare(
49-
"SELECT state FROM miner_run_state WHERE repo_full_name = ?",
84+
"SELECT state FROM miner_run_state WHERE api_base_url = ? AND repo_full_name = ?",
5085
);
5186
const setStatement = db.prepare(`
52-
INSERT INTO miner_run_state (repo_full_name, state, updated_at)
53-
VALUES (?, ?, ?)
54-
ON CONFLICT(repo_full_name) DO UPDATE SET
87+
INSERT INTO miner_run_state (api_base_url, repo_full_name, state, updated_at)
88+
VALUES (?, ?, ?, ?)
89+
ON CONFLICT(api_base_url, repo_full_name) DO UPDATE SET
5590
state = excluded.state,
5691
updated_at = excluded.updated_at
5792
`);
5893
const listStatement = db.prepare(
59-
"SELECT repo_full_name, state, updated_at FROM miner_run_state ORDER BY repo_full_name",
94+
"SELECT api_base_url, repo_full_name, state, updated_at FROM miner_run_state ORDER BY repo_full_name",
6095
);
6196

6297
return {
6398
dbPath: resolvedPath,
64-
getRunState(repoFullName) {
65-
const row = getStatement.get(normalizeRepoFullName(repoFullName));
99+
getRunState(repoFullName, apiBaseUrl) {
100+
const row = getStatement.get(normalizeApiBaseUrl(apiBaseUrl), normalizeRepoFullName(repoFullName));
66101
return runStateSet.has(row?.state) ? row.state : null;
67102
},
68-
setRunState(repoFullName, state) {
103+
setRunState(repoFullName, state, apiBaseUrl) {
104+
const normalizedForge = normalizeApiBaseUrl(apiBaseUrl);
69105
const normalizedRepo = normalizeRepoFullName(repoFullName);
70106
const normalizedState = normalizeRunState(state);
71107
const updatedAt = new Date().toISOString();
72-
setStatement.run(normalizedRepo, normalizedState, updatedAt);
73-
return { repoFullName: normalizedRepo, state: normalizedState, updatedAt };
108+
setStatement.run(normalizedForge, normalizedRepo, normalizedState, updatedAt);
109+
return { apiBaseUrl: normalizedForge, repoFullName: normalizedRepo, state: normalizedState, updatedAt };
74110
},
75111
/** Every repo with a recorded run state, across the whole store — the per-repo discover/plan/prepare
76112
* signal a "run portfolio" view folds alongside managed PR rows (#4279). */
77113
listRunStates() {
78114
return listStatement.all()
79115
.filter((row) => runStateSet.has(row.state))
80-
.map((row) => ({ repoFullName: row.repo_full_name, state: row.state, updatedAt: row.updated_at }));
116+
.map((row) => ({
117+
apiBaseUrl: row.api_base_url,
118+
repoFullName: row.repo_full_name,
119+
state: row.state,
120+
updatedAt: row.updated_at,
121+
}));
81122
},
82123
close() {
83124
db.close();
@@ -90,12 +131,12 @@ function getDefaultRunStateStore() {
90131
return defaultRunStateStore;
91132
}
92133

93-
export function getRunState(repoFullName) {
94-
return getDefaultRunStateStore().getRunState(repoFullName);
134+
export function getRunState(repoFullName, apiBaseUrl) {
135+
return getDefaultRunStateStore().getRunState(repoFullName, apiBaseUrl);
95136
}
96137

97-
export function setRunState(repoFullName, state) {
98-
return getDefaultRunStateStore().setRunState(repoFullName, state);
138+
export function setRunState(repoFullName, state, apiBaseUrl) {
139+
return getDefaultRunStateStore().setRunState(repoFullName, state, apiBaseUrl);
99140
}
100141

101142
export function listRunStates() {

test/unit/miner-cli-run-state.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,50 @@ describe("gittensory-miner state CLI", () => {
4141
});
4242
});
4343

44+
it("parseStateGetArgs and parseStateSetArgs accept --api-base-url (#5563)", () => {
45+
expect(parseStateGetArgs(["acme/widgets", "--api-base-url", "https://ghe.example.com/api/v3"])).toEqual({
46+
repoFullName: "acme/widgets",
47+
json: false,
48+
apiBaseUrl: "https://ghe.example.com/api/v3",
49+
});
50+
expect(parseStateGetArgs(["acme/widgets", "--api-base-url"])).toEqual({
51+
error: expect.stringContaining("Usage: gittensory-miner state get"),
52+
});
53+
expect(parseStateSetArgs(["acme/widgets", "planning", "--api-base-url", "https://ghe.example.com/api/v3"])).toEqual({
54+
repoFullName: "acme/widgets",
55+
state: "planning",
56+
dryRun: false,
57+
json: false,
58+
apiBaseUrl: "https://ghe.example.com/api/v3",
59+
});
60+
expect(parseStateSetArgs(["acme/widgets", "planning", "--api-base-url"])).toEqual({
61+
error: expect.stringContaining("Usage: gittensory-miner state set"),
62+
});
63+
});
64+
4465
it("runStateGet prints none before any write", () => {
4566
getRunState.mockReturnValue(null);
4667
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
4768
expect(runStateGet(["acme/widgets"])).toBe(0);
48-
expect(getRunState).toHaveBeenCalledWith("acme/widgets");
69+
expect(getRunState).toHaveBeenCalledWith("acme/widgets", undefined);
4970
expect(log).toHaveBeenCalledWith("none");
5071
});
5172

73+
it("runStateGet and runStateSet thread --api-base-url through to the store (#5563)", () => {
74+
getRunState.mockReturnValue("planning");
75+
setRunState.mockReturnValue({
76+
apiBaseUrl: "https://ghe.example.com/api/v3",
77+
repoFullName: "acme/widgets",
78+
state: "planning",
79+
updatedAt: "2026-07-03T00:00:00.000Z",
80+
});
81+
expect(runStateGet(["acme/widgets", "--api-base-url", "https://ghe.example.com/api/v3"])).toBe(0);
82+
expect(getRunState).toHaveBeenCalledWith("acme/widgets", "https://ghe.example.com/api/v3");
83+
84+
expect(runStateSet(["acme/widgets", "planning", "--api-base-url", "https://ghe.example.com/api/v3"])).toBe(0);
85+
expect(setRunState).toHaveBeenCalledWith("acme/widgets", "planning", "https://ghe.example.com/api/v3");
86+
});
87+
5288
it("runStateSet persists state and runStateGet returns JSON output", () => {
5389
setRunState.mockReturnValue({
5490
repoFullName: "acme/widgets",

0 commit comments

Comments
 (0)