From 49ab7bc46ad4a54375d5783f0444c43e774023df Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Thu, 20 Aug 2026 22:55:46 -0500 Subject: [PATCH 01/12] RT-50: five global singletons read the stores Ports rt.notifications, rt.cron, rt.repoTracking, rt.runaway, and rt.workspacePrefs off their legacy ~/.mattstack/rt/*.json[c] files and onto the settings resolver (getSetting/setSetting), flipping all five registry rows to migrated:true with legacyFile/siblingCommand dropped. No fallback reads remain. Co-Authored-By: Claude Fable 5 --- commands/__tests__/code-prefs.test.ts | 48 +++++++ .../__tests__/settings-keys-render.test.ts | 4 +- commands/__tests__/settings-runaway.test.ts | 50 ++++++++ commands/code.ts | 33 ++--- commands/daemon.ts | 2 +- commands/settings.ts | 17 +-- lib/__tests__/notifier.test.ts | 47 ++++++- lib/daemon.ts | 2 +- lib/daemon/__tests__/cron.test.ts | 66 ++++++++-- lib/daemon/__tests__/repo-tracking.test.ts | 121 ++++++++++-------- .../__tests__/system-process-scanner.test.ts | 30 ++++- lib/daemon/cron.ts | 39 +++--- lib/daemon/system-process-scanner.ts | 14 +- lib/notifier.ts | 23 ++-- lib/repo-tracking.ts | 50 +++----- .../src/settings/__tests__/registry.test.ts | 44 +++---- .../src/settings/__tests__/write.test.ts | 12 +- .../rt-client/src/settings/registry-defs.ts | 67 +++++----- 18 files changed, 420 insertions(+), 249 deletions(-) create mode 100644 commands/__tests__/code-prefs.test.ts create mode 100644 commands/__tests__/settings-runaway.test.ts diff --git a/commands/__tests__/code-prefs.test.ts b/commands/__tests__/code-prefs.test.ts new file mode 100644 index 00000000..1fe76cad --- /dev/null +++ b/commands/__tests__/code-prefs.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { machineSettingsPath } from "../../lib/rt-paths.ts"; +import { getSetting } from "../../lib/settings/resolve.ts"; +import { setSetting } from "../../lib/settings/write.ts"; +import { __test__ } from "../code.ts"; + +describe("workspace prefs through the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-code-prefs-"))); + process.env.HOME = home; + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("empty store: defaults to empty editors/workspaces", () => { + expect(__test__.loadPrefs()).toEqual({ editors: {}, workspaces: {} }); + }); + + test("a store-seeded value resolves through the loader", () => { + setSetting("rt.workspacePrefs", { editors: { myrepo: "code" } }, "machine"); + + expect(__test__.loadPrefs().editors.myrepo).toBe("code"); + }); + + test("legacy 'entries' alias still resolves as workspaces", () => { + setSetting("rt.workspacePrefs", { entries: { "/some/dir": "x.code-workspace" } }, "machine"); + + expect(__test__.loadPrefs().workspaces["/some/dir"]).toBe("x.code-workspace"); + }); + + test("savePrefs lands in the machine store", () => { + __test__.savePrefs({ editors: { myrepo: "cursor" }, workspaces: {} }); + + const stored = getSetting<{ editors: Record }>("rt.workspacePrefs").value; + expect(stored.editors.myrepo).toBe("cursor"); + const raw = JSON.parse(readFileSync(machineSettingsPath(), "utf8").replace(/^\/\/.*\n/, "")); + expect(raw["rt.workspacePrefs"].editors.myrepo).toBe("cursor"); + }); +}); diff --git a/commands/__tests__/settings-keys-render.test.ts b/commands/__tests__/settings-keys-render.test.ts index 2442c9fb..6b4ffbaf 100644 --- a/commands/__tests__/settings-keys-render.test.ts +++ b/commands/__tests__/settings-keys-render.test.ts @@ -29,8 +29,8 @@ describe("renderListRow", () => { }); test("a registered migrated:false key still carries its legacy note", () => { - // rt.llm is a real wave-1 registered migrated:false key. - const out = plain(renderListRow(row({ key: "rt.llm", migrated: false }))); + // rt.hooks is the deliberately-deferred registered migrated:false key. + const out = plain(renderListRow(row({ key: "rt.hooks", migrated: false }))); expect(out).toMatch(/legacy|not writable/); expect(out).not.toContain("unregistered"); diff --git a/commands/__tests__/settings-runaway.test.ts b/commands/__tests__/settings-runaway.test.ts new file mode 100644 index 00000000..8577daa1 --- /dev/null +++ b/commands/__tests__/settings-runaway.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { getSetting } from "../../lib/settings/resolve.ts"; +import { setSetting } from "../../lib/settings/write.ts"; +import { configureRunaway } from "../settings.ts"; + +describe("configureRunaway through the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; + let logs: string[]; + const origLog = console.log; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-runaway-cmd-"))); + process.env.HOME = home; + logs = []; + console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); }; + }); + + afterEach(() => { + process.env.HOME = origHome; + console.log = origLog; + rmSync(home, { recursive: true, force: true }); + }); + + test("empty store: the summary view prints today's defaults", async () => { + await configureRunaway([]); + expect(logs.some((l) => l.includes("80%"))).toBe(true); + }); + + test("setting a field lands in the machine store", async () => { + await configureRunaway(["cpu-threshold", "90"]); + + const stored = getSetting>("rt.runaway").value; + expect(stored.cpuThreshold).toBe(90); + expect(logs.some((l) => l.includes("restart daemon"))).toBe(true); + }); + + test("an existing stored value is preserved across an unrelated field write", async () => { + setSetting("rt.runaway", { cpuThreshold: 70 }, "machine"); + + await configureRunaway(["sustain-min", "10"]); + + const stored = getSetting>("rt.runaway").value; + expect(stored.cpuThreshold).toBe(70); + expect(stored.sustainMs).toBe(600_000); + }); +}); diff --git a/commands/code.ts b/commands/code.ts index f475ac6b..4a0ccecc 100644 --- a/commands/code.ts +++ b/commands/code.ts @@ -4,21 +4,20 @@ * Editor-preference and launch machinery shared with `rt nav`. * * Tracks a per-repo editor choice and per-directory workspace-file choice - * (~/.mattstack/rt/workspace-prefs.json), detects installed editors, and + * (rt.workspacePrefs, machine-scoped), detects installed editors, and * launches one via its CLI command (code, cursor, zed, etc.), falling back * to the app bundle when the CLI shim is missing or broken. */ import { execSync } from "child_process"; -import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { existsSync, readdirSync } from "fs"; import { join } from "path"; import { homedir } from "os"; -import { rtDir } from "../lib/rt-paths.ts"; +import { getSetting } from "../lib/settings/resolve.ts"; +import { setSetting } from "../lib/settings/write.ts"; import { dim, green, red, reset } from "../lib/tui.ts"; -// ─── Preference storage (~/.mattstack/rt/workspace-prefs.json) ───────────────────────── - -const PREFS_PATH = join(rtDir(), "workspace-prefs.json"); +// ─── Preference storage (rt.workspacePrefs, machine-scoped) ──────────────── interface Prefs { editors: Record; @@ -26,25 +25,19 @@ interface Prefs { } function loadPrefs(): Prefs { - try { - const raw = JSON.parse(readFileSync(PREFS_PATH, "utf8")); - return { - editors: raw.editors || {}, - workspaces: raw.workspaces || raw.entries || {}, - }; - } catch { - return { editors: {}, workspaces: {} }; - } + const raw = getSetting | undefined>("rt.workspacePrefs").value; + return { + editors: (raw?.editors as Record) || {}, + workspaces: (raw?.workspaces as Record) || (raw?.entries as Record) || {}, + }; } function savePrefs(prefs: Prefs): void { - try { - const dir = rtDir(); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2)); - } catch { /* best-effort */ } + setSetting("rt.workspacePrefs", prefs, "machine"); } +export const __test__ = { loadPrefs, savePrefs }; + // ─── Editor detection ──────────────────────────────────────────────────────── interface EditorOption { diff --git a/commands/daemon.ts b/commands/daemon.ts index 7f33ca3a..55ab81a1 100644 --- a/commands/daemon.ts +++ b/commands/daemon.ts @@ -330,7 +330,7 @@ export async function manageTracking(args: string[] = []): Promise { const freshness = ((status?.ok ? status.data?.freshness : undefined) ?? {}) as Record; - console.log(`\n ${bold}repo tracking${reset} ${dim}(opt-in · ~/.mattstack/rt/repo-tracking.json · unlisted = off)${reset}\n`); + console.log(`\n ${bold}repo tracking${reset} ${dim}(opt-in · rt.repoTracking · unlisted = off)${reset}\n`); for (const name of Object.keys(repos).sort()) { const g = grants(tracking, name); const watcher = freshness[name]; diff --git a/commands/settings.ts b/commands/settings.ts index b5e19f07..05c8f24c 100644 --- a/commands/settings.ts +++ b/commands/settings.ts @@ -30,6 +30,8 @@ import { NOTIFICATION_TYPES, } from "../lib/notifier.ts"; import { installShellIntegration } from "../lib/shell-integration.ts"; +import { getSetting } from "../lib/settings/resolve.ts"; +import { setSetting } from "../lib/settings/write.ts"; // ─── Linear token ──────────────────────────────────────────────────────────── @@ -252,18 +254,12 @@ export async function configureNotifications(): Promise { // ─── Runaway process detection thresholds ──────────────────────────────────── -const RUNAWAY_CONFIG_PATH = join(rtDir(), "runaway-config.json"); - export async function configureRunaway(args: string[]): Promise { const field = args[0]; const value = args[1]; - let config: Record = {}; - try { - if (existsSync(RUNAWAY_CONFIG_PATH)) { - config = JSON.parse(readFileSync(RUNAWAY_CONFIG_PATH, "utf8")); - } - } catch { /* fresh */ } + const stored = getSetting | undefined>("rt.runaway").value; + const config: Record = stored ? { ...stored } : {}; if (!field) { console.log(`\n ${bold}Runaway process detection${reset}\n`); @@ -301,9 +297,8 @@ export async function configureRunaway(args: string[]): Promise { return; } - mkdirSync(dirname(RUNAWAY_CONFIG_PATH), { recursive: true }); - writeFileSync(RUNAWAY_CONFIG_PATH, JSON.stringify(config, null, 2)); - console.log(` ${green}✓${reset} saved to ${dim}${RUNAWAY_CONFIG_PATH}${reset}`); + setSetting("rt.runaway", config, "machine"); + console.log(` ${green}✓${reset} saved`); console.log(` ${dim}restart daemon to apply: rt daemon restart${reset}`); } diff --git a/lib/__tests__/notifier.test.ts b/lib/__tests__/notifier.test.ts index efb84089..ba719b66 100644 --- a/lib/__tests__/notifier.test.ts +++ b/lib/__tests__/notifier.test.ts @@ -1,5 +1,48 @@ -import { describe, expect, test } from "bun:test"; -import { __test__ } from "../notifier.ts"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { userSettingsPath } from "../rt-paths.ts"; +import { getSetting } from "../settings/resolve.ts"; +import { setSetting } from "../settings/write.ts"; +import { __test__, loadNotificationPrefs, saveNotificationPrefs, NOTIFICATION_TYPES } from "../notifier.ts"; + +describe("notification prefs through the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-notifier-"))); + process.env.HOME = home; + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("empty store: every notification type defaults to enabled", () => { + const prefs = loadNotificationPrefs(); + for (const t of NOTIFICATION_TYPES) expect(prefs[t.key]).toBe(true); + }); + + test("a store-seeded value resolves through the loader", () => { + setSetting("rt.notifications", { pipeline_failed: false }, "user"); + + const prefs = loadNotificationPrefs(); + expect(prefs.pipeline_failed).toBe(false); + expect(prefs.mr_merged).toBe(true); // untouched types still default true + }); + + test("saveNotificationPrefs lands in the user store", () => { + saveNotificationPrefs({ pipeline_failed: false, mr_merged: true }); + + const stored = getSetting>("rt.notifications").value; + expect(stored.pipeline_failed).toBe(false); + const raw = JSON.parse(readFileSync(userSettingsPath(), "utf8").replace(/^\/\/.*\n/, "")); + expect(raw["rt.notifications"]).toEqual({ pipeline_failed: false, mr_merged: true }); + }); +}); const baseSnapshot = { pipelineStatus: null, diff --git a/lib/daemon.ts b/lib/daemon.ts index e71da86a..f73fe081 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -132,7 +132,7 @@ setInterval(() => eventsBus.sweep(), 60 * 60 * 1000); setTimeout(() => eventsBus.sweep(), 30_000); // Cron trigger layer (mechanism-only, MAT-161): sees every broadcast frame. -const cron = startCron(loadCronConfig(undefined, log), { log }); +const cron = startCron(loadCronConfig(log), { log }); const emit: typeof broadcast = (type, data) => { broadcast(type, data); cron.onBroadcast(type, data); diff --git a/lib/daemon/__tests__/cron.test.ts b/lib/daemon/__tests__/cron.test.ts index 4b4be2c3..813243d7 100644 --- a/lib/daemon/__tests__/cron.test.ts +++ b/lib/daemon/__tests__/cron.test.ts @@ -1,22 +1,70 @@ -import { describe, expect, test } from "bun:test"; -import { parseCronConfig, startCron, type CronTrigger } from "../cron.ts"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, realpathSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { setSetting } from "../../settings/write.ts"; +import { parseCronConfig, loadCronConfig, startCron, type CronTrigger } from "../cron.ts"; const log = { info: () => {}, warn: () => {} }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); describe("parseCronConfig", () => { - test("parses jsonc and validates triggers", () => { - const cfg = parseCronConfig(`{ - // the auto-doctor trigger - "triggers": [{ "name": "t", "event": "project-mrs", "repoName": "assured-dev", "run": ["echo", "hi"] }] - }`); + test("parses a resolved object and validates triggers", () => { + const cfg = parseCronConfig({ + triggers: [{ name: "t", event: "project-mrs", repoName: "assured-dev", run: ["echo", "hi"] }], + }); expect(cfg.triggers).toHaveLength(1); expect(cfg.triggers[0]!.debounceMs).toBeUndefined(); }); test("rejects triggers missing name/event/run", () => { - expect(() => parseCronConfig(`{"triggers":[{"event":"x","run":["a"]}]}`)).toThrow(); - expect(() => parseCronConfig(`{"triggers":[{"name":"t","event":"x","run":[]}]}`)).toThrow(); + expect(() => parseCronConfig({ triggers: [{ event: "x", run: ["a"] }] })).toThrow(); + expect(() => parseCronConfig({ triggers: [{ name: "t", event: "x", run: [] }] })).toThrow(); + }); + + test("undefined/empty input degrades to no triggers", () => { + expect(parseCronConfig(undefined)).toEqual({ triggers: [] }); + expect(parseCronConfig({})).toEqual({ triggers: [] }); + }); +}); + +describe("loadCronConfig through the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-cron-"))); + process.env.HOME = home; + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("empty store degrades to no triggers", () => { + expect(loadCronConfig()).toEqual({ triggers: [] }); + }); + + test("a store-seeded value resolves through the loader", () => { + setSetting( + "rt.cron", + { triggers: [{ name: "t", event: "project-mrs", run: ["echo", "hi"] }] }, + "machine", + ); + + const cfg = loadCronConfig(); + expect(cfg.triggers).toHaveLength(1); + expect(cfg.triggers[0]!.name).toBe("t"); + }); + + test("an invalid stored value degrades to no triggers and warns", () => { + setSetting("rt.cron", { triggers: [{ name: "t" }] }, "machine"); + + const warnings: string[] = []; + const cfg = loadCronConfig({ info: () => {}, warn: (m) => warnings.push(m) }); + expect(cfg.triggers).toEqual([]); + expect(warnings.length).toBeGreaterThan(0); }); }); diff --git a/lib/daemon/__tests__/repo-tracking.test.ts b/lib/daemon/__tests__/repo-tracking.test.ts index b86f1d7b..f06dd97f 100644 --- a/lib/daemon/__tests__/repo-tracking.test.ts +++ b/lib/daemon/__tests__/repo-tracking.test.ts @@ -1,38 +1,51 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "fs"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; +import { machineSettingsPath } from "../../rt-paths.ts"; +import { getSetting } from "../../settings/resolve.ts"; +import { setSetting } from "../../settings/write.ts"; import { loadRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, } from "../../repo-tracking.ts"; -function tmpFile(contents?: string): string { - const dir = mkdtempSync(join(tmpdir(), "rt-tracking-")); - const p = join(dir, "repo-tracking.json"); - if (contents !== undefined) writeFileSync(p, contents); - return p; +function writeStore(file: string, obj: unknown): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(obj, null, 2)); } -describe("loadRepoTracking", () => { - test("missing file → empty", () => { - expect(loadRepoTracking(tmpFile())).toEqual({}); +describe("loadRepoTracking through the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-tracking-"))); + process.env.HOME = home; + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("empty store → empty (nothing tracked)", () => { + expect(loadRepoTracking()).toEqual({}); }); - test("corrupt file → empty", () => { - expect(loadRepoTracking(tmpFile("{nope"))).toEqual({}); + test("a malformed stored value degrades to empty", () => { + writeStore(machineSettingsPath(), { "rt.repoTracking": ["not", "an", "object"] }); + expect(loadRepoTracking()).toEqual({}); }); - test("v2 envelope parses; unknown cache names dropped; empty caches drops entry", () => { - const p = tmpFile(JSON.stringify({ - version: 2, - repos: { - a: { mode: "live", caches: ["branches", "project-mrs"] }, - b: { mode: "poll", caches: ["branches", "bogus"] }, - c: { mode: "live", caches: [] }, - d: { mode: "sideways", caches: ["branches"] }, - }, - })); - const t = loadRepoTracking(p); + test("a store-seeded v2-shaped entry resolves; unknown cache names dropped; empty caches drops entry", () => { + setSetting("rt.repoTracking", { + a: { mode: "live", caches: ["branches", "project-mrs"] }, + b: { mode: "poll", caches: ["branches", "bogus"] }, + c: { mode: "live", caches: [] }, + d: { mode: "sideways", caches: ["branches"] }, + }, "machine"); + + const t = loadRepoTracking(); expect(t.a).toEqual({ mode: "live", caches: ["branches", "project-mrs"] }); expect(t.b).toEqual({ mode: "poll", caches: ["branches"] }); expect(t.c).toBeUndefined(); @@ -40,32 +53,22 @@ describe("loadRepoTracking", () => { }); test("legacy flat strings migrate to {mode, caches:[branches]}; off/unknown dropped", () => { - const p = tmpFile(JSON.stringify({ a: "live", b: "poll", c: "off", d: "bogus" })); - const t = loadRepoTracking(p); + writeStore(machineSettingsPath(), { "rt.repoTracking": { a: "live", b: "poll", c: "off", d: "bogus" } }); + + const t = loadRepoTracking(); expect(t.a).toEqual({ mode: "live", caches: ["branches"] }); expect(t.b).toEqual({ mode: "poll", caches: ["branches"] }); expect(t.c).toBeUndefined(); expect(t.d).toBeUndefined(); }); - test('a repo literally named "version" survives in both shapes', () => { - const legacy = tmpFile(JSON.stringify({ version: "live", other: "poll" })); - expect(loadRepoTracking(legacy).version).toEqual({ mode: "live", caches: ["branches"] }); - const v2 = tmpFile(JSON.stringify({ - version: 2, - repos: { version: { mode: "poll", caches: ["branches"] }!, other: { mode: "live", caches: ["branches"] }! }, - })); - expect(loadRepoTracking(v2).version).toEqual({ mode: "poll", caches: ["branches"] }); - expect(loadRepoTracking(v2).other).toBeDefined(); - }); - test("invalid projectMrsWindowDays values are dropped, entry survives", () => { - const file = join(mkdtempSync(join(tmpdir(), "rt-track-")), "t.json"); - writeFileSync(file, JSON.stringify({ version: 2, repos: { + setSetting("rt.repoTracking", { a: { mode: "live", caches: ["branches"], projectMrsWindowDays: -5 }, b: { mode: "live", caches: ["branches"], projectMrsWindowDays: "soon" }, - }})); - const t = loadRepoTracking(file); + }, "machine"); + + const t = loadRepoTracking(); expect(t.a).toBeDefined(); expect(t.a?.projectMrsWindowDays).toBeUndefined(); expect(t.b).toBeDefined(); expect(t.b?.projectMrsWindowDays).toBeUndefined(); }); @@ -93,23 +96,37 @@ describe("grants", () => { }); }); -describe("saveRepoTracking", () => { - test("writes v2 envelope, sorted repos; round-trips through loadRepoTracking", () => { - const p = tmpFile(); +describe("saveRepoTracking through the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-tracking-save-"))); + process.env.HOME = home; + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("writes to the machine store, repos sorted; round-trips through loadRepoTracking", () => { saveRepoTracking({ zed: { mode: "poll", caches: ["branches"] }, abc: { mode: "live", caches: ["branches", "project-mrs"] }, - }, p); - const raw = JSON.parse(require("fs").readFileSync(p, "utf8")); - expect(raw.version).toBe(2); - expect(Object.keys(raw.repos)).toEqual(["abc", "zed"]); - expect(loadRepoTracking(p).abc!.caches).toEqual(["branches", "project-mrs"]); + }); + + const stored = getSetting>("rt.repoTracking").value; + expect(Object.keys(stored)).toEqual(["abc", "zed"]); + expect(loadRepoTracking().abc!.caches).toEqual(["branches", "project-mrs"]); + + const raw = JSON.parse(readFileSync(machineSettingsPath(), "utf8").replace(/^\/\/.*\n/, "")); + expect(Object.keys(raw["rt.repoTracking"])).toEqual(["abc", "zed"]); }); test("projectMrsWindowDays round-trips through save/load", () => { - const file = join(mkdtempSync(join(tmpdir(), "rt-track-")), "t.json"); - saveRepoTracking({ repo: { mode: "live", caches: ["project-mrs"], projectMrsWindowDays: 60 } }, file); - expect(loadRepoTracking(file).repo?.projectMrsWindowDays).toBe(60); + saveRepoTracking({ repo: { mode: "live", caches: ["project-mrs"], projectMrsWindowDays: 60 } }); + expect(loadRepoTracking().repo?.projectMrsWindowDays).toBe(60); }); }); diff --git a/lib/daemon/__tests__/system-process-scanner.test.ts b/lib/daemon/__tests__/system-process-scanner.test.ts index 0f4153e9..e9b09ab0 100644 --- a/lib/daemon/__tests__/system-process-scanner.test.ts +++ b/lib/daemon/__tests__/system-process-scanner.test.ts @@ -1,8 +1,34 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync, mkdirSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { SystemProcessScanner } from "../system-process-scanner.ts"; +import { setSetting } from "../../settings/write.ts"; +import { SystemProcessScanner, loadRunawayConfig } from "../system-process-scanner.ts"; + +describe("loadRunawayConfig through the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-runaway-"))); + process.env.HOME = home; + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("empty store → {} (today's defaults apply downstream)", () => { + expect(loadRunawayConfig()).toEqual({}); + }); + + test("a store-seeded value resolves through the loader", () => { + setSetting("rt.runaway", { cpuThreshold: 90, sustainMs: 60_000 }, "machine"); + + expect(loadRunawayConfig()).toEqual({ cpuThreshold: 90, sustainMs: 60_000 }); + }); +}); describe("SystemProcessScanner", () => { let dataDir: string; diff --git a/lib/daemon/cron.ts b/lib/daemon/cron.ts index 97582a76..be947377 100644 --- a/lib/daemon/cron.ts +++ b/lib/daemon/cron.ts @@ -5,12 +5,9 @@ * conditions, no chaining, no retries-with-policy, no output parsing, no * workflow. The moment logic needs "if X and Y", it belongs in the invoked * program. Ships with rt's installation (tray-app precedent); dormant when - * ~/.mattstack/rt/cron.jsonc is absent or empty. + * the rt.cron machine-store setting is absent or empty. */ -import { readFileSync } from "fs"; -import { join } from "path"; -import { stripJsonc } from "../jsonc.ts"; -import { rtDir } from "../rt-paths.ts"; +import { getSetting } from "../settings/resolve.ts"; export interface CronTrigger { name: string; @@ -33,41 +30,37 @@ export interface CronLog { const DEFAULT_DEBOUNCE_MS = 5000; -export function parseCronConfig(text: string): CronConfig { - const raw = JSON.parse(stripJsonc(text)) as { triggers?: unknown }; +export function parseCronConfig(value: unknown): CronConfig { + const raw = (value ?? {}) as { triggers?: unknown }; if (raw.triggers === undefined) return { triggers: [] }; - if (!Array.isArray(raw.triggers)) throw new Error(`cron.jsonc "triggers" must be an array`); + if (!Array.isArray(raw.triggers)) throw new Error(`rt.cron "triggers" must be an array`); const triggers = raw.triggers.map((t, i): CronTrigger => { const o = t as Record; - if (typeof o.name !== "string" || !o.name) throw new Error(`cron.jsonc triggers[${i}] needs a "name"`); - if (typeof o.event !== "string" || !o.event) throw new Error(`cron.jsonc trigger "${o.name}" needs an "event"`); + if (typeof o.name !== "string" || !o.name) throw new Error(`rt.cron triggers[${i}] needs a "name"`); + if (typeof o.event !== "string" || !o.event) throw new Error(`rt.cron trigger "${o.name}" needs an "event"`); if (!Array.isArray(o.run) || o.run.length === 0 || o.run.some((a) => typeof a !== "string")) { - throw new Error(`cron.jsonc trigger "${o.name}" needs a non-empty string[] "run"`); + throw new Error(`rt.cron trigger "${o.name}" needs a non-empty string[] "run"`); } if (o.repoName !== undefined && typeof o.repoName !== "string") { - throw new Error(`cron.jsonc trigger "${o.name}": "repoName" must be a string`); + throw new Error(`rt.cron trigger "${o.name}": "repoName" must be a string`); } if (o.debounceMs !== undefined && (typeof o.debounceMs !== "number" || o.debounceMs <= 0)) { - throw new Error(`cron.jsonc trigger "${o.name}": "debounceMs" must be a positive number`); + throw new Error(`rt.cron trigger "${o.name}": "debounceMs" must be a positive number`); } return { name: o.name, event: o.event, repoName: o.repoName as string | undefined, run: o.run as string[], debounceMs: o.debounceMs as number | undefined }; }); return { triggers }; } -/** Missing file → dormant. Invalid file → warn and dormant: a config typo +/** Absent setting → dormant. Invalid value → warn and dormant: a config typo must never take the daemon down. */ -export function loadCronConfig(path: string = join(rtDir(), "cron.jsonc"), log?: CronLog): CronConfig { - let text: string; +export function loadCronConfig(log?: CronLog): CronConfig { + const raw = getSetting("rt.cron").value; + if (raw === undefined) return { triggers: [] }; try { - text = readFileSync(path, "utf8"); - } catch { - return { triggers: [] }; - } - try { - return parseCronConfig(text); + return parseCronConfig(raw); } catch (err) { - log?.warn(`cron: ignoring invalid ${path}: ${err instanceof Error ? err.message : err}`); + log?.warn(`cron: ignoring invalid rt.cron setting: ${err instanceof Error ? err.message : err}`); return { triggers: [] }; } } diff --git a/lib/daemon/system-process-scanner.ts b/lib/daemon/system-process-scanner.ts index c856c02d..14b32732 100644 --- a/lib/daemon/system-process-scanner.ts +++ b/lib/daemon/system-process-scanner.ts @@ -13,10 +13,8 @@ import { getDaemonLogger } from "./../daemon-logger.ts"; import { runCapture } from "../subprocess.ts"; const log = (await getDaemonLogger()).childLogger("process-scan"); -import { existsSync, readFileSync } from "fs"; -import { join } from "path"; import { homedir } from "os"; -import { rtDir } from "../rt-paths.ts"; +import { getSetting } from "../settings/resolve.ts"; import { loadRepoIndex, buildWorktreeMap, @@ -88,15 +86,9 @@ export interface ScannerConfig { graceMs?: number; } -const RUNAWAY_CONFIG_PATH = join(rtDir(), "runaway-config.json"); - export function loadRunawayConfig(): ScannerConfig { - try { - if (!existsSync(RUNAWAY_CONFIG_PATH)) return {}; - return JSON.parse(readFileSync(RUNAWAY_CONFIG_PATH, "utf8")); - } catch { - return {}; - } + const raw = getSetting("rt.runaway").value; + return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}; } export function parseProcessList( diff --git a/lib/notifier.ts b/lib/notifier.ts index 42c85775..5f5a8205 100644 --- a/lib/notifier.ts +++ b/lib/notifier.ts @@ -15,10 +15,12 @@ * Called at the end of each daemon cache refresh cycle. */ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { existsSync } from "fs"; import { join } from "path"; import { isTransitionalMergeStatus } from "@mattstack/glance"; import { RT_DIR } from "./daemon-config.ts"; +import { getSetting } from "./settings/resolve.ts"; +import { setSetting } from "./settings/write.ts"; import { parseEtimeMs, type PortEntry } from "./port-scanner.ts"; import type { SystemProcess } from "./daemon/system-process-scanner.ts"; import { agentSessionPids } from "./daemon/worktree-process-kill.ts"; @@ -80,7 +82,6 @@ export type { NotificationEvent }; // ─── Config ────────────────────────────────────────────────────────────────── -const PREFS_PATH = join(RT_DIR, "notifications.json"); const TRAY_SOCK_PATH = join(RT_DIR, "tray.sock"); // ─── Broadcast hook (set by daemon.ts to push to WebSocket clients) ────────── @@ -115,21 +116,15 @@ export const NOTIFICATION_TYPES = [ export type NotificationPrefs = Record; export function loadNotificationPrefs(): NotificationPrefs { - try { - return JSON.parse(readFileSync(PREFS_PATH, "utf8")); - } catch { - // Default: everything enabled - const defaults: NotificationPrefs = {}; - for (const t of NOTIFICATION_TYPES) defaults[t.key] = true; - return defaults; - } + const defaults: NotificationPrefs = {}; + for (const t of NOTIFICATION_TYPES) defaults[t.key] = true; + + const stored = getSetting("rt.notifications").value; + return stored ? { ...defaults, ...stored } : defaults; } export function saveNotificationPrefs(prefs: NotificationPrefs): void { - try { - mkdirSync(RT_DIR, { recursive: true }); - writeFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2)); - } catch { /* best-effort */ } + setSetting("rt.notifications", prefs, "user"); } /** diff --git a/lib/repo-tracking.ts b/lib/repo-tracking.ts index 5986b0c3..f15cad17 100644 --- a/lib/repo-tracking.ts +++ b/lib/repo-tracking.ts @@ -1,23 +1,19 @@ /** - * Per-repo background-tracking grants — the ONE parser for - * ~/.mattstack/rt/repo-tracking.json, shared by the daemon (pure reader) and the CLI + * Per-repo background-tracking grants — the ONE parser for the rt.repoTracking + * machine-store setting, shared by the daemon (pure reader) and the CLI * (reader + writer). Spec: .local-dev/2026-07-26-typed-stores-board-rewire-design.md §4. * - * v2 file shape: - * { "version": 2, "repos": { "": { "mode": "live"|"poll", "caches": [...] } } } + * Value shape: a flat repo → entry map, { "": { "mode": "live"|"poll", "caches": [...] } }. * * `mode` is the freshness transport (live = events watcher + 5-min cycle, * poll = 5-min cycle only); `caches` is what that transport may maintain. * Unlisted repo = off. Nothing is granted implicitly. Legacy flat entries * ({ "": "live" }) are read as { mode, caches: ["branches"] } and - * rewritten as v2 on the next save. + * rewritten to the object shape on the next save. */ -import { readFileSync, writeFileSync } from "fs"; -import { join } from "path"; -import { RT_DIR } from "./daemon-config.ts"; - -export const REPO_TRACKING_PATH = join(RT_DIR, "repo-tracking.json"); +import { getSetting } from "./settings/resolve.ts"; +import { setSetting } from "./settings/write.ts"; export const CACHE_KINDS = ["branches", "project-mrs", "discussions"] as const; export type CacheKind = (typeof CACHE_KINDS)[number]; @@ -52,28 +48,18 @@ function normalizeEntry(value: unknown): RepoTrackingEntry | null { } /** - * Read the tracking file, accepting BOTH the v2 envelope and the legacy flat - * map. Missing/corrupt file, unknown modes, and unknown cache names all - * degrade toward "off" — a typo must never cause accidental polling. + * Read the rt.repoTracking machine-store setting: a flat repo → entry map + * (each entry either the v2 shape or a legacy flat string — see + * normalizeEntry). Absent/malformed setting, unknown modes, and unknown + * cache names all degrade toward "off" — a typo must never cause accidental + * polling. */ -export function loadRepoTracking(filePath: string = REPO_TRACKING_PATH): RepoTracking { - let parsed: unknown; - try { - parsed = JSON.parse(readFileSync(filePath, "utf8")); - } catch { - return {}; // missing file is the normal nothing-tracked state - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; - - const isV2 = (parsed as { version?: unknown }).version === 2 - && typeof (parsed as { repos?: unknown }).repos === "object" - && (parsed as { repos?: unknown }).repos !== null - && !Array.isArray((parsed as { repos?: unknown }).repos); - const repos = isV2 ? (parsed as { repos: Record }).repos : parsed; - if (!repos || typeof repos !== "object" || Array.isArray(repos)) return {}; +export function loadRepoTracking(): RepoTracking { + const raw = getSetting("rt.repoTracking").value; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; const out: RepoTracking = {}; - for (const [repo, value] of Object.entries(repos)) { + for (const [repo, value] of Object.entries(raw as Record)) { const entry = normalizeEntry(value); if (entry) out[repo] = entry; } @@ -87,12 +73,12 @@ export function grants(tracking: RepoTracking, repoName: string): RepoGrants { projectMrsWindowDays: entry.projectMrsWindowDays ?? DEFAULT_PROJECT_MRS_WINDOW_DAYS }; } -/** Write the v2 envelope, repos sorted for stable diffs. */ -export function saveRepoTracking(tracking: RepoTracking, filePath: string = REPO_TRACKING_PATH): void { +/** Writes the flat repo → entry map to the machine store, repos sorted for stable diffs. */ +export function saveRepoTracking(tracking: RepoTracking): void { const repos = Object.fromEntries( Object.entries(tracking).sort(([a], [b]) => a.localeCompare(b)), ); - writeFileSync(filePath, JSON.stringify({ version: 2, repos }, null, 2) + "\n"); + setSetting("rt.repoTracking", repos, "machine"); } /** "branches, project-mrs" → kinds. Null on empty input or any unknown name. */ diff --git a/packages/rt-client/src/settings/__tests__/registry.test.ts b/packages/rt-client/src/settings/__tests__/registry.test.ts index d2cd1d7e..17b3b59d 100644 --- a/packages/rt-client/src/settings/__tests__/registry.test.ts +++ b/packages/rt-client/src/settings/__tests__/registry.test.ts @@ -44,11 +44,14 @@ describe("settings/registry", () => { } }); - test("exactly 5 keys are migrated:true", () => { + test("exactly 10 keys are migrated:true", () => { const migrated = allDefs().filter((d) => d.migrated); expect(migrated.map((d) => d.key).sort()).toEqual( - ["rt.intercepts", "rt.repoIdentityOverrides", "rt.repoRoots", "rt.roles", "rt.worktrees"].sort(), + [ + "rt.intercepts", "rt.repoIdentityOverrides", "rt.repoRoots", "rt.roles", "rt.worktrees", + "rt.notifications", "rt.cron", "rt.repoTracking", "rt.runaway", "rt.workspacePrefs", + ].sort(), ); }); @@ -84,16 +87,17 @@ describe("settings/registry", () => { expect(def?.default).toEqual({ onDeck: 0 }); }); - test("rt.notifications and rt.runaway carry their sibling commands", () => { - expect(getDef("rt.notifications")?.siblingCommand).toBe("rt settings notifications"); - expect(getDef("rt.runaway")?.siblingCommand).toBe("rt settings runaway"); + test("the five migrated global singletons carry no siblingCommand or legacyFile", () => { + for (const key of ["rt.notifications", "rt.cron", "rt.repoTracking", "rt.runaway", "rt.workspacePrefs"]) { + const def = getDef(key); + expect(def?.siblingCommand, `${key} should carry no siblingCommand`).toBeUndefined(); + expect(def?.legacyFile, `${key} should carry no legacyFile`).toBeUndefined(); + } }); - test("legacyFile values match the trace for wave-1 migrated:false keys", () => { + test("legacyFile values match the trace for the remaining migrated:false keys", () => { expect(getDef("rt.llm")?.legacyFile).toBe("llm.json"); - expect(getDef("rt.cron")?.legacyFile).toBe("cron.jsonc"); - expect(getDef("rt.repoTracking")?.legacyFile).toBe("repo-tracking.json"); - expect(getDef("rt.notifications")?.legacyFile).toBe("notifications.json"); + expect(getDef("rt.sync")?.legacyFile).toBe("repos//sync.json"); }); test("repoScoped is consistent with a repos//... legacyFile prefix, in both directions", () => { @@ -134,30 +138,26 @@ describe("settings/registry", () => { } }); - test("the six genuinely global legacy keys stay repoScoped:undefined with a bare (non-repos/) legacyFile", () => { - for (const key of ["rt.llm", "rt.cron", "rt.repoTracking", "rt.notifications", "rt.workspacePrefs", "rt.runaway"]) { - const def = getDef(key); - expect(def?.repoScoped, `${key} should not be repoScoped`).toBeFalsy(); - expect(def?.legacyFile?.startsWith("repos/"), `${key} legacyFile should not be repo-prefixed`).toBe(false); - } + test("the one remaining genuinely global legacy key stays repoScoped:undefined with a bare (non-repos/) legacyFile", () => { + const def = getDef("rt.llm"); + expect(def?.repoScoped, "rt.llm should not be repoScoped").toBeFalsy(); + expect(def?.legacyFile?.startsWith("repos/"), "rt.llm legacyFile should not be repo-prefixed").toBe(false); }); - test("has exactly the 12 wave-1 migrated:false keys, the 5 migrated:true keys, and the 30 suite keys", () => { + test("has exactly the 7 remaining migrated:false keys, the 10 migrated:true keys, and the 30 suite keys", () => { const migratedFalseKeys = [ "rt.llm", - "rt.cron", - "rt.repoTracking", - "rt.notifications", "rt.sync", "rt.branchNaming", "rt.variations", "rt.presets", "rt.dopplerTemplate", - "rt.workspacePrefs", - "rt.runaway", "rt.hooks", ]; - const migratedTrueKeys = ["rt.roles", "rt.intercepts", "rt.worktrees", "rt.repoIdentityOverrides", "rt.repoRoots"]; + const migratedTrueKeys = [ + "rt.roles", "rt.intercepts", "rt.worktrees", "rt.repoIdentityOverrides", "rt.repoRoots", + "rt.notifications", "rt.cron", "rt.repoTracking", "rt.runaway", "rt.workspacePrefs", + ]; const suiteKeys = [ "mattstack.integrations", "mattstack.tracking", diff --git a/packages/rt-client/src/settings/__tests__/write.test.ts b/packages/rt-client/src/settings/__tests__/write.test.ts index a4fd0d13..00f5caae 100644 --- a/packages/rt-client/src/settings/__tests__/write.test.ts +++ b/packages/rt-client/src/settings/__tests__/write.test.ts @@ -147,20 +147,12 @@ describe("settings/write", () => { }); test("refuses a migrated:false key, naming the legacyFile", () => { - const def = getDef("rt.cron") as SettingDef; - expect(() => setSetting("rt.cron", { enabled: true }, "user")).toThrow( + const def = getDef("rt.hooks") as SettingDef; + expect(() => setSetting("rt.hooks", { enabled: true }, "user")).toThrow( new RegExp(def.legacyFile!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), ); }); - test("refuses a migrated:false key, naming the siblingCommand when present", () => { - const def = getDef("rt.notifications") as SettingDef; - expect(def.siblingCommand).toBeTruthy(); - expect(() => setSetting("rt.notifications", {}, "user")).toThrow( - new RegExp(def.siblingCommand!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), - ); - }); - test("refuses a path-literal in a pathGuardFields field at user scope", () => { expect(() => setSetting("rt.roles", { backend: { hook: "/Users/matt/bin/dev.sh" } }, "user", { diff --git a/packages/rt-client/src/settings/registry-defs.ts b/packages/rt-client/src/settings/registry-defs.ts index 71798585..b1ce35b1 100644 --- a/packages/rt-client/src/settings/registry-defs.ts +++ b/packages/rt-client/src/settings/registry-defs.ts @@ -62,44 +62,56 @@ export const REGISTRY: readonly SettingDef[] = [ description: 'Directories rt scans for git repos (rt cd, run-outside-a-repo pickers). Entries may start with "~/" or use "${home}". One level deep, plus worktree-pool parent folders one level deeper.', }, - - // --- migrated:false (wave 1 legacy-file keys) --------------------------- { - key: "rt.llm", + key: "rt.notifications", type: "object", - scopes: ALL_SCOPES, + scopes: ["user"], merge: "deep", - migrated: false, - legacyFile: "llm.json", - description: "LLM provider and model selection for rt's AI-assisted commands.", + migrated: true, + description: "Desktop notification preferences (which events notify, sound on/off).", }, { key: "rt.cron", type: "object", - scopes: ALL_SCOPES, + scopes: ["machine"], merge: "deep", - migrated: false, - legacyFile: "cron.jsonc", - description: "Scheduled rt job definitions and their cron expressions.", + migrated: true, + description: "Scheduled rt job definitions and their cron expressions. Restart the daemon to apply changes.", }, { key: "rt.repoTracking", type: "object", - scopes: ALL_SCOPES, + scopes: ["machine"], merge: "deep", - migrated: false, - legacyFile: "repo-tracking.json", + migrated: true, description: "Which repos rt tracks for background sync and status polling.", }, { - key: "rt.notifications", + key: "rt.runaway", + type: "object", + scopes: ["machine"], + merge: "deep", + migrated: true, + description: "Thresholds for the runaway-process guard that kills stuck dev servers. Restart the daemon to apply changes.", + }, + { + key: "rt.workspacePrefs", + type: "object", + scopes: ["machine"], + merge: "deep", + migrated: true, + description: "Per-machine editor/terminal preferences applied when opening a worktree.", + }, + + // --- migrated:false (wave 1 legacy-file keys) --------------------------- + { + key: "rt.llm", type: "object", scopes: ALL_SCOPES, merge: "deep", migrated: false, - legacyFile: "notifications.json", - siblingCommand: "rt settings notifications", - description: "Desktop notification preferences (which events notify, sound on/off).", + legacyFile: "llm.json", + description: "LLM provider and model selection for rt's AI-assisted commands.", }, { key: "rt.sync", @@ -151,25 +163,6 @@ export const REGISTRY: readonly SettingDef[] = [ legacyFile: "repos//doppler-template.yaml", description: "Template used to generate a repo's Doppler secrets config.", }, - { - key: "rt.workspacePrefs", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "workspace-prefs.json", - description: "Per-machine editor/terminal preferences applied when opening a worktree.", - }, - { - key: "rt.runaway", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "runaway-config.json", - siblingCommand: "rt settings runaway", - description: "Thresholds for the runaway-process guard that kills stuck dev servers.", - }, { key: "rt.hooks", type: "object", From 236554521b831932d18770f17b02af92fd2ec402 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Thu, 20 Aug 2026 23:10:59 -0500 Subject: [PATCH 02/12] RT-50: fail-open resolver reads + repoTracking envelope guard Review fix wave for the five global-singleton loaders/writers: getSetting calls now live inside each loader's existing try/catch (an unexpandable ${...} variable degrades exactly like today's missing/corrupt-file case instead of crashing daemon boot), and loadRepoTracking warns loudly and auto-unwraps a versioned {version, repos} envelope instead of silently dropping every grant. Plus a stale local rename and a registry test invariant. Co-Authored-By: Claude Fable 5 --- commands/__tests__/code-prefs.test.ts | 7 ++++ commands/code.ts | 16 +++++--- commands/settings.ts | 7 +++- lib/__tests__/notifier.test.ts | 8 ++++ lib/daemon/__tests__/cron.test.ts | 13 +++++++ lib/daemon/__tests__/repo-tracking.test.ts | 26 +++++++++++++ .../__tests__/system-process-scanner.test.ts | 7 ++++ lib/daemon/cron.ts | 12 ++++-- lib/daemon/system-process-scanner.ts | 14 +++++-- lib/notifier.ts | 12 +++++- lib/repo-tracking.ts | 39 ++++++++++++++++--- .../src/settings/__tests__/registry.test.ts | 8 ++++ 12 files changed, 148 insertions(+), 21 deletions(-) diff --git a/commands/__tests__/code-prefs.test.ts b/commands/__tests__/code-prefs.test.ts index 1fe76cad..be368a5f 100644 --- a/commands/__tests__/code-prefs.test.ts +++ b/commands/__tests__/code-prefs.test.ts @@ -45,4 +45,11 @@ describe("workspace prefs through the settings resolver", () => { const raw = JSON.parse(readFileSync(machineSettingsPath(), "utf8").replace(/^\/\/.*\n/, "")); expect(raw["rt.workspacePrefs"].editors.myrepo).toBe("cursor"); }); + + test("an unexpandable ${repoRoot} in a stored value degrades to empty prefs instead of throwing", () => { + setSetting("rt.workspacePrefs", { editors: { myrepo: "${repoRoot}" } }, "machine"); + + expect(() => __test__.loadPrefs()).not.toThrow(); + expect(__test__.loadPrefs()).toEqual({ editors: {}, workspaces: {} }); + }); }); diff --git a/commands/code.ts b/commands/code.ts index 4a0ccecc..596a50d6 100644 --- a/commands/code.ts +++ b/commands/code.ts @@ -24,12 +24,18 @@ interface Prefs { workspaces: Record; } +/** A resolver throw (unexpandable ${...} variable) degrades to the same + empty prefs a missing/corrupt file gave today. */ function loadPrefs(): Prefs { - const raw = getSetting | undefined>("rt.workspacePrefs").value; - return { - editors: (raw?.editors as Record) || {}, - workspaces: (raw?.workspaces as Record) || (raw?.entries as Record) || {}, - }; + try { + const raw = getSetting | undefined>("rt.workspacePrefs").value; + return { + editors: (raw?.editors as Record) || {}, + workspaces: (raw?.workspaces as Record) || (raw?.entries as Record) || {}, + }; + } catch { + return { editors: {}, workspaces: {} }; + } } function savePrefs(prefs: Prefs): void { diff --git a/commands/settings.ts b/commands/settings.ts index 05c8f24c..524e443b 100644 --- a/commands/settings.ts +++ b/commands/settings.ts @@ -258,7 +258,12 @@ export async function configureRunaway(args: string[]): Promise { const field = args[0]; const value = args[1]; - const stored = getSetting | undefined>("rt.runaway").value; + // A resolver throw (unexpandable ${...} variable) must not block editing + // the setting that would fix it — degrade to {} same as loadRunawayConfig. + let stored: Record | undefined; + try { + stored = getSetting | undefined>("rt.runaway").value; + } catch { /* degrade below */ } const config: Record = stored ? { ...stored } : {}; if (!field) { diff --git a/lib/__tests__/notifier.test.ts b/lib/__tests__/notifier.test.ts index ba719b66..ed044d38 100644 --- a/lib/__tests__/notifier.test.ts +++ b/lib/__tests__/notifier.test.ts @@ -42,6 +42,14 @@ describe("notification prefs through the settings resolver", () => { const raw = JSON.parse(readFileSync(userSettingsPath(), "utf8").replace(/^\/\/.*\n/, "")); expect(raw["rt.notifications"]).toEqual({ pipeline_failed: false, mr_merged: true }); }); + + test("an unexpandable ${repoRoot} in a stored value degrades to all-enabled defaults instead of throwing", () => { + setSetting("rt.notifications", { pipeline_failed: "${repoRoot}" }, "user"); + + expect(() => loadNotificationPrefs()).not.toThrow(); + const prefs = loadNotificationPrefs(); + for (const t of NOTIFICATION_TYPES) expect(prefs[t.key]).toBe(true); + }); }); const baseSnapshot = { diff --git a/lib/daemon/__tests__/cron.test.ts b/lib/daemon/__tests__/cron.test.ts index 813243d7..6ae236ef 100644 --- a/lib/daemon/__tests__/cron.test.ts +++ b/lib/daemon/__tests__/cron.test.ts @@ -66,6 +66,19 @@ describe("loadCronConfig through the settings resolver", () => { expect(cfg.triggers).toEqual([]); expect(warnings.length).toBeGreaterThan(0); }); + + test("an unexpandable ${repoRoot} in a trigger's run string degrades to no triggers instead of throwing", () => { + setSetting( + "rt.cron", + { triggers: [{ name: "t", event: "e", run: ["${repoRoot}/script.sh"] }] }, + "machine", + ); + + const warnings: string[] = []; + expect(() => loadCronConfig({ info: () => {}, warn: (m) => warnings.push(m) })).not.toThrow(); + expect(loadCronConfig()).toEqual({ triggers: [] }); + expect(warnings.length).toBeGreaterThan(0); + }); }); describe("startCron", () => { diff --git a/lib/daemon/__tests__/repo-tracking.test.ts b/lib/daemon/__tests__/repo-tracking.test.ts index f06dd97f..69b14277 100644 --- a/lib/daemon/__tests__/repo-tracking.test.ts +++ b/lib/daemon/__tests__/repo-tracking.test.ts @@ -72,6 +72,32 @@ describe("loadRepoTracking through the settings resolver", () => { expect(t.a).toBeDefined(); expect(t.a?.projectMrsWindowDays).toBeUndefined(); expect(t.b).toBeDefined(); expect(t.b?.projectMrsWindowDays).toBeUndefined(); }); + + test("an unexpandable ${repoRoot} in a stored value degrades to empty instead of throwing", () => { + setSetting("rt.repoTracking", { a: { mode: "${repoRoot}", caches: ["branches"] } }, "machine"); + + expect(() => loadRepoTracking()).not.toThrow(); + expect(loadRepoTracking()).toEqual({}); + }); + + test("a versioned {version, repos} envelope warns loudly and auto-unwraps to the inner repos map", () => { + writeStore(machineSettingsPath(), { + "rt.repoTracking": { version: 2, repos: { a: { mode: "live", caches: ["branches"] } } }, + }); + + const warnings: string[] = []; + const orig = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + let t: ReturnType; + try { + t = loadRepoTracking(); + } finally { + console.warn = orig; + } + + expect(t.a).toEqual({ mode: "live", caches: ["branches"] }); + expect(warnings.some((w) => w.includes("store the repos map, not the versioned envelope"))).toBe(true); + }); }); describe("grants", () => { diff --git a/lib/daemon/__tests__/system-process-scanner.test.ts b/lib/daemon/__tests__/system-process-scanner.test.ts index e9b09ab0..8c7927cb 100644 --- a/lib/daemon/__tests__/system-process-scanner.test.ts +++ b/lib/daemon/__tests__/system-process-scanner.test.ts @@ -28,6 +28,13 @@ describe("loadRunawayConfig through the settings resolver", () => { expect(loadRunawayConfig()).toEqual({ cpuThreshold: 90, sustainMs: 60_000 }); }); + + test("an unexpandable ${repoRoot} in a stored value degrades to {} instead of throwing", () => { + setSetting("rt.runaway", { note: "${repoRoot}" }, "machine"); + + expect(() => loadRunawayConfig()).not.toThrow(); + expect(loadRunawayConfig()).toEqual({}); + }); }); describe("SystemProcessScanner", () => { diff --git a/lib/daemon/cron.ts b/lib/daemon/cron.ts index be947377..3720afc9 100644 --- a/lib/daemon/cron.ts +++ b/lib/daemon/cron.ts @@ -52,12 +52,16 @@ export function parseCronConfig(value: unknown): CronConfig { return { triggers }; } -/** Absent setting → dormant. Invalid value → warn and dormant: a config typo - must never take the daemon down. */ +/** Absent/unresolvable setting → dormant. Invalid value → warn and dormant: a + config typo (or an unexpandable ${...} variable in a trigger's `run` + string) must never take the daemon down. getSetting's own throws (a + closed-set variable resolved without context) are caught here alongside + parseCronConfig's — both are "this setting can't be used right now", not + a boot failure. */ export function loadCronConfig(log?: CronLog): CronConfig { - const raw = getSetting("rt.cron").value; - if (raw === undefined) return { triggers: [] }; try { + const raw = getSetting("rt.cron").value; + if (raw === undefined) return { triggers: [] }; return parseCronConfig(raw); } catch (err) { log?.warn(`cron: ignoring invalid rt.cron setting: ${err instanceof Error ? err.message : err}`); diff --git a/lib/daemon/system-process-scanner.ts b/lib/daemon/system-process-scanner.ts index 14b32732..2fe76dfd 100644 --- a/lib/daemon/system-process-scanner.ts +++ b/lib/daemon/system-process-scanner.ts @@ -86,9 +86,15 @@ export interface ScannerConfig { graceMs?: number; } +/** A resolver throw (unexpandable ${...} variable) degrades to {} — the same + "use the DEFAULT_* constants" fallback a missing/corrupt file gave today. */ export function loadRunawayConfig(): ScannerConfig { - const raw = getSetting("rt.runaway").value; - return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}; + try { + const raw = getSetting("rt.runaway").value; + return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}; + } catch { + return {}; + } } export function parseProcessList( @@ -239,8 +245,8 @@ export class SystemProcessScanner { private lastScanAt: number | null = null; constructor(config: ScannerConfig = {}) { - const diskConfig = loadRunawayConfig(); - const merged = { ...diskConfig, ...config }; + const storeConfig = loadRunawayConfig(); + const merged = { ...storeConfig, ...config }; this.config = { cpuThreshold: merged.cpuThreshold ?? DEFAULT_CPU_THRESHOLD, sustainMs: merged.sustainMs ?? DEFAULT_SUSTAIN_MS, diff --git a/lib/notifier.ts b/lib/notifier.ts index 5f5a8205..fa9214af 100644 --- a/lib/notifier.ts +++ b/lib/notifier.ts @@ -119,8 +119,16 @@ export function loadNotificationPrefs(): NotificationPrefs { const defaults: NotificationPrefs = {}; for (const t of NOTIFICATION_TYPES) defaults[t.key] = true; - const stored = getSetting("rt.notifications").value; - return stored ? { ...defaults, ...stored } : defaults; + // A resolver throw (e.g. an unexpandable ${...} variable authored into the + // store by hand) must degrade to the same all-enabled default a missing or + // corrupt file gave today — this fires on every notify() call, so it can + // never be allowed to crash the daemon's transition loop. + try { + const stored = getSetting("rt.notifications").value; + return stored ? { ...defaults, ...stored } : defaults; + } catch { + return defaults; + } } export function saveNotificationPrefs(prefs: NotificationPrefs): void { diff --git a/lib/repo-tracking.ts b/lib/repo-tracking.ts index f15cad17..3e38c50b 100644 --- a/lib/repo-tracking.ts +++ b/lib/repo-tracking.ts @@ -47,19 +47,48 @@ function normalizeEntry(value: unknown): RepoTrackingEntry | null { return { mode: mode as TrackingMode, caches: kept, ...(window !== undefined ? { projectMrsWindowDays: window } : {}) }; } +/** + * A hand-authored (or freshly-imported) value still shaped like the old + * on-disk file: `{ version: 2, repos: {...} }`. The settings key IS the + * repos map now — the version wrapper is redundant — but silently + * normalizing this to "nothing tracked" would look like every grant vanished + * instead of naming the fixable mistake. + */ +function isVersionedEnvelope(value: Record): value is { version: number; repos: Record } { + return typeof value.version === "number" + && value.repos !== null && typeof value.repos === "object" && !Array.isArray(value.repos); +} + /** * Read the rt.repoTracking machine-store setting: a flat repo → entry map * (each entry either the v2 shape or a legacy flat string — see - * normalizeEntry). Absent/malformed setting, unknown modes, and unknown - * cache names all degrade toward "off" — a typo must never cause accidental - * polling. + * normalizeEntry). Absent/malformed setting, an unresolvable resolver value + * (e.g. an unexpandable ${...} variable), unknown modes, and unknown cache + * names all degrade toward "off" — a typo must never cause accidental + * polling, and this loader runs on every freshness tick so it can never + * throw into the daemon. */ export function loadRepoTracking(): RepoTracking { - const raw = getSetting("rt.repoTracking").value; + let raw: unknown; + try { + raw = getSetting("rt.repoTracking").value; + } catch (err) { + console.warn(`rt: rt.repoTracking could not be resolved (${err instanceof Error ? err.message : err}) — tracking nothing`); + return {}; + } if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + let repos = raw as Record; + if (isVersionedEnvelope(repos)) { + console.warn( + "rt: rt.repoTracking holds a versioned {version, repos} envelope — store the repos map, not the versioned envelope " + + "(e.g. `rt settings set rt.repoTracking` with just the inner repos object); using the inner repos map for now.", + ); + repos = repos.repos; + } + const out: RepoTracking = {}; - for (const [repo, value] of Object.entries(raw as Record)) { + for (const [repo, value] of Object.entries(repos)) { const entry = normalizeEntry(value); if (entry) out[repo] = entry; } diff --git a/packages/rt-client/src/settings/__tests__/registry.test.ts b/packages/rt-client/src/settings/__tests__/registry.test.ts index 17b3b59d..f5021b3a 100644 --- a/packages/rt-client/src/settings/__tests__/registry.test.ts +++ b/packages/rt-client/src/settings/__tests__/registry.test.ts @@ -44,6 +44,14 @@ describe("settings/registry", () => { } }); + test("every migrated:true (or suite, migrated-absent) def carries no legacyFile or siblingCommand", () => { + for (const def of allDefs()) { + if (!isMigrated(def)) continue; + expect(def.legacyFile, `${def.key} is migrated but still carries a legacyFile`).toBeUndefined(); + expect(def.siblingCommand, `${def.key} is migrated but still carries a siblingCommand`).toBeUndefined(); + } + }); + test("exactly 10 keys are migrated:true", () => { const migrated = allDefs().filter((d) => d.migrated); From ee5ddda9fb1b780d6167b3a642f4ba975c92cee3 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Thu, 20 Aug 2026 23:38:56 -0500 Subject: [PATCH 03/12] RT-50: per-repo keys read the stores; presets reshape; doppler template is a key Ports rt.sync, rt.variations, rt.presets, rt.dopplerTemplate off the legacy repos// files onto the settings resolver, and flips rt.branchNaming's registry row (rt has no reader for it). rt.presets moves from a directory of per-preset files to one { "": {entries} } object; rt.dopplerTemplate's registry type corrects from object to array (merge: replace) to match its actual shape. saveSyncConfig, saveTemplate, and captureFromActualConfig are deleted (zero production callers). Co-Authored-By: Claude Fable 5 --- commands/git/rebase.ts | 8 +- commands/run.ts | 22 ++- commands/sync.ts | 9 +- lib/__tests__/doppler-template.test.ts | 167 ++++++++---------- lib/__tests__/repo-layout.test.ts | 42 ----- lib/__tests__/run-presets.test.ts | 91 ++++++---- lib/__tests__/variations.test.ts | 141 ++++++++++----- lib/daemon/__tests__/doppler-sync.test.ts | 69 +++++--- lib/daemon/cache-refresh.ts | 8 +- lib/daemon/doppler-sync.ts | 26 ++- lib/doppler-template.ts | 88 +++------ lib/run-presets.ts | 85 ++++----- lib/sync-config.ts | 40 ++--- lib/variations.ts | 51 +++--- lib/worktree/__tests__/dispose.test.ts | 37 ++-- lib/worktree/create.ts | 4 +- lib/worktree/dispose.ts | 5 +- .../src/settings/__tests__/registry.test.ts | 38 ++-- .../rt-client/src/settings/registry-defs.ts | 41 ++--- 19 files changed, 472 insertions(+), 500 deletions(-) delete mode 100644 lib/__tests__/repo-layout.test.ts diff --git a/commands/git/rebase.ts b/commands/git/rebase.ts index 95d7ca15..2f7d3b93 100644 --- a/commands/git/rebase.ts +++ b/commands/git/rebase.ts @@ -31,6 +31,7 @@ import { ruleGlobs, type AutoResolveRule, } from "../../lib/sync-config.ts"; +import { deriveRepoIdentity } from "../../lib/settings/identity.ts"; import type { CommandContext } from "../../lib/command-tree.ts"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -60,14 +61,12 @@ export interface RebaseResult { export interface RebaseOptions { /** Target ref to rebase onto (default: auto-detect origin/master or origin/main). */ target?: string; - /** Auto-resolve rules (default: loaded from ~/.mattstack/rt/repos//sync.json). */ + /** Auto-resolve rules (default: loaded from the rt.sync setting for cwd's repo identity). */ autoResolve?: AutoResolveRule[]; /** If true, show what would happen without doing it. */ dryRun?: boolean; /** Working directory. */ cwd: string; - /** Repo data dir for loading config (e.g. ~/.mattstack/rt/repos/). */ - dataDir?: string; /** If true, suppress output (used when called from rt sync --all). */ quiet?: boolean; /** If true, skip git fetch (caller already fetched). */ @@ -243,7 +242,7 @@ export async function rebaseOnto(opts: RebaseOptions): Promise { } // 4. Load auto-resolve rules - const rules = opts.autoResolve ?? (opts.dataDir ? loadSyncConfig(opts.dataDir).autoResolve : []); + const rules = opts.autoResolve ?? loadSyncConfig(await deriveRepoIdentity(cwd)).autoResolve; // 5. Dry run if (dryRun) { @@ -516,7 +515,6 @@ async function runRebaseWithEscalation( const result = await rebaseOnto({ cwd, - dataDir, target, dryRun, quiet: mode === "json", diff --git a/commands/run.ts b/commands/run.ts index 5008772a..26e6d468 100644 --- a/commands/run.ts +++ b/commands/run.ts @@ -47,6 +47,7 @@ import { type LaunchItem, } from "../lib/herdr-launch.ts"; import { findPreset, loadPresets, savePreset, type Preset } from "../lib/run-presets.ts"; +import { deriveRepoIdentity } from "../lib/settings/identity.ts"; import { navSeparator, type NavOption } from "../lib/navigate.ts"; const LAST_RUN_SENTINEL = "__rt:last-run__"; @@ -130,6 +131,7 @@ async function selectPackageAndScript( const { runNavPicker } = await import("../lib/navigate.ts"); const packages = getWorkspacePackages(worktreePath); const label = contextLabel ? `${contextLabel}` : ""; + const repoIdentity = await deriveRepoIdentity(worktreePath); let cameFromScript = false; const q: QueuedItem[] = queue ?? []; @@ -198,7 +200,7 @@ async function selectPackageAndScript( } // ── Saved presets (shown above packages, only outside an active queue) ── - const presets = dataDir && q.length === 0 ? loadPresets(dataDir) : []; + const presets = q.length === 0 ? loadPresets(repoIdentity) : []; const savedPresetOptions = presets.length > 0 ? [ ...presets.map((p) => ({ @@ -284,9 +286,9 @@ async function selectPackageAndScript( } // Saved preset selected — resolve against this worktree and launch - if (val.startsWith(PRESET_PREFIX) && dataDir) { + if (val.startsWith(PRESET_PREFIX)) { const presetName = val.slice(PRESET_PREFIX.length, -2); // strip prefix + trailing "__" - const preset = findPreset(dataDir, presetName); + const preset = findPreset(repoIdentity, presetName); if (preset) { await launchPreset(preset, worktreePath); return QUEUE_LAUNCHED; // signal "already launched" — q is empty, launchQueue() is a no-op @@ -296,7 +298,7 @@ async function selectPackageAndScript( } // Save as preset, then ask whether to run it now - if (val === SAVE_PRESET_SENTINEL && dataDir) { + if (val === SAVE_PRESET_SENTINEL) { const { confirm } = await import("../lib/rt-render.tsx"); const name = await textInput({ message: "Preset name", @@ -304,7 +306,7 @@ async function selectPackageAndScript( stderr: true, }); if (name) { - savePreset(dataDir, { + savePreset(repoIdentity, { name, entries: q.map((qi) => ({ packageRelPath: qi.packageRelPath, @@ -475,9 +477,7 @@ async function selectPackageAndScript( if (scriptResult.key === "alt-enter") { // ── Variations sub-picker ──────────────────────────────────────────── - const existing = dataDir - ? (loadVariations(dataDir)[variationKey(worktreePath, packagePath, scriptName)] ?? []) - : []; + const existing = loadVariations(repoIdentity)[variationKey(worktreePath, packagePath, scriptName)] ?? []; const ADD_SENTINEL = "__rt:add-variation__"; @@ -519,9 +519,7 @@ async function selectPackageAndScript( }); if (!command) process.exit(1); - if (dataDir) { - saveVariation(dataDir, worktreePath, packagePath, scriptName, { name, command }); - } + saveVariation(repoIdentity, worktreePath, packagePath, scriptName, { name, command }); // Tab or Enter-with-queue: queue the new variation if (varResult.key === "tab" || q.length > 0) { @@ -650,7 +648,7 @@ export async function runCommand( // ── Preset direct invoke: `rt run ` ────────────────────── const presetArg = args.find((a) => !a.startsWith("-") && a !== "again"); if (presetArg) { - const preset = findPreset(dataDir, presetArg); + const preset = findPreset(await deriveRepoIdentity(worktreePath), presetArg); if (preset) { await launchPreset(preset, worktreePath); return; diff --git a/commands/sync.ts b/commands/sync.ts index 3ada034b..16779dd8 100644 --- a/commands/sync.ts +++ b/commands/sync.ts @@ -21,6 +21,7 @@ import { exec, execSync, spawnSync } from "child_process"; import { bold, cyan, dim, green, yellow, red, reset } from "../lib/tui.ts"; import { getCurrentBranch, getRemoteDefaultBranch, hasUncommittedChanges } from "../lib/git-ops.ts"; import { loadSyncConfig } from "../lib/sync-config.ts"; +import { deriveRepoIdentity } from "../lib/settings/identity.ts"; import { rebaseOnto, type RebaseResult } from "./git/rebase.ts"; import { resetToOrigin, type ResetResult } from "./git/reset.ts"; import { syncLog } from "../lib/sync-log.ts"; @@ -95,7 +96,6 @@ function hasDivergedFromRemote(branch: string, cwd: string): boolean { async function syncBranch( cwd: string, - dataDir: string, opts: { dryRun?: boolean; quiet?: boolean; onConflict?: "abort" | "pause" }, ): Promise { // Guard: rebase-in-progress takes priority — getCurrentBranch returns null @@ -158,7 +158,7 @@ async function syncBranch( }; } - const config = loadSyncConfig(dataDir); + const config = loadSyncConfig(await deriveRepoIdentity(cwd)); let resetResult: ResetResult | null = null; let rebaseResult: RebaseResult | null = null; let needsPush = false; @@ -229,7 +229,6 @@ async function syncBranch( // 3. Check if behind origin/master — rebase rebaseResult = await rebaseOnto({ cwd, - dataDir, autoResolve: config.autoResolve, dryRun: opts.dryRun, quiet: opts.quiet, @@ -387,7 +386,7 @@ async function syncAll( continue; } - const summary = await syncBranch(wt.path, identity.dataDir, { + const summary = await syncBranch(wt.path, { dryRun: opts.dryRun, quiet: false, onConflict: "abort", @@ -458,7 +457,7 @@ export async function syncCommand( // the try/finally below has fully completed. let escalationExit: number | null = null; try { - summary = await syncBranch(cwd, dataDir, { + summary = await syncBranch(cwd, { dryRun, quiet: mode === "json", onConflict: mode === "off" ? "abort" : "pause", diff --git a/lib/__tests__/doppler-template.test.ts b/lib/__tests__/doppler-template.test.ts index ceef5e0e..72818be7 100644 --- a/lib/__tests__/doppler-template.test.ts +++ b/lib/__tests__/doppler-template.test.ts @@ -1,123 +1,94 @@ -import { afterAll, afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; +import { setSetting } from "../settings/write.ts"; +import { machineSettingsPath } from "../rt-paths.ts"; +import { loadTemplate } from "../doppler-template.ts"; -// Pin HOME to a tmpdir BEFORE importing the module under test, since -// daemon-config.ts reads RT_DIR at import time from the user's home. -const origHome = process.env.HOME; -const tmpHome = mkdtempSync(join(tmpdir(), "rt-doppler-template-")); -process.env.HOME = tmpHome; +const IDENTITY = "gitlab.com/acme/test-repo"; -// bun test runs every file in one shared process — restore HOME so later -// test files (and their per-test HOME juggling) don't inherit the temp dir. -afterAll(() => { - process.env.HOME = origHome; - rmSync(tmpHome, { recursive: true, force: true }); -}); - -const { loadTemplate, saveTemplate, templatePath } = - await import("../doppler-template.ts"); +describe("doppler-template over the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; -describe("doppler-template I/O", () => { - const repo = "test-repo"; + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-doppler-template-"))); + process.env.HOME = home; + }); afterEach(() => { - try { rmSync(join(tmpHome, ".mattstack", "rt", "repos", repo), { recursive: true, force: true }); } catch { /* */ } + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); }); - test("templatePath is ~/.mattstack/rt/repos//doppler-template.yaml", () => { - expect(templatePath(repo)).toBe(join(tmpHome, ".mattstack", "rt", "repos", repo, "doppler-template.yaml")); + test("loadTemplate returns null when nothing is declared", () => { + expect(loadTemplate(IDENTITY)).toBeNull(); }); - test("loadTemplate returns null when the file is missing", () => { - expect(loadTemplate(repo)).toBeNull(); + test("loadTemplate returns null when no repo identity is available", () => { + expect(loadTemplate(null)).toBeNull(); }); - test("loadTemplate returns null on malformed YAML", () => { - mkdirSync(join(tmpHome, ".mattstack", "rt", "repos", repo), { recursive: true }); - writeFileSync(templatePath(repo), "this: is: not: valid: yaml::"); - expect(loadTemplate(repo)).toBeNull(); - }); + test("a store-seeded array resolves through the loader", () => { + setSetting( + "rt.dopplerTemplate", + [ + { path: "apps/backend", project: "backend", config: "dev" }, + { path: "apps/frontend", project: "frontend", config: "dev" }, + ], + "machine", + { repoIdentity: IDENTITY }, + ); - test("saveTemplate then loadTemplate round-trips entries", () => { - const entries = [ - { path: "apps/backend", project: "backend", config: "dev" }, + expect(loadTemplate(IDENTITY)).toEqual([ + { path: "apps/backend", project: "backend", config: "dev" }, { path: "apps/frontend", project: "frontend", config: "dev" }, - ]; - saveTemplate(repo, entries); - expect(loadTemplate(repo)).toEqual(entries); - }); - - test("saveTemplate creates the parent directory if missing", () => { - saveTemplate(repo, [{ path: "apps/x", project: "x", config: "dev" }]); - const raw = readFileSync(templatePath(repo), "utf8"); - expect(raw).toContain("project: x"); + ]); }); -}); -const { captureFromActualConfig } = await import("../doppler-template.ts"); + test("filters out entries missing a required field", () => { + setSetting( + "rt.dopplerTemplate", + [ + { path: "apps/backend", project: "backend", config: "dev" }, + { path: "apps/broken" }, + ], + "machine", + { repoIdentity: IDENTITY }, + ); -describe("captureFromActualConfig", () => { - test("captures enclave entries under the given worktree path, relative-pathed", () => { - const dopplerCfg: any = { - scoped: { - "/repo/primary": { token: "secret-xxx" }, - "/repo/primary/apps/backend": { - "enclave.project": "backend", - "enclave.config": "dev", - }, - "/repo/primary/apps/frontend": { - "enclave.project": "frontend", - "enclave.config": "dev", - }, - "/repo/primary/packages/sidekick": { - "enclave.project": "adjuster", - "enclave.config": "dev", - }, - "/repo/wktree-2/apps/backend": { - "enclave.project": "backend", - "enclave.config": "dev", - }, - }, - }; - const captured = captureFromActualConfig(dopplerCfg, "/repo/primary"); - expect(captured).toEqual([ - { path: "apps/backend", project: "backend", config: "dev" }, - { path: "apps/frontend", project: "frontend", config: "dev" }, - { path: "packages/sidekick", project: "adjuster", config: "dev" }, + expect(loadTemplate(IDENTITY)).toEqual([ + { path: "apps/backend", project: "backend", config: "dev" }, ]); }); - test("ignores token-only entries (no enclave fields)", () => { - const dopplerCfg: any = { - scoped: { - "/repo/primary": { token: "secret-xxx" }, - "/repo/primary/apps/x": { - "enclave.project": "x", - "enclave.config": "dev", - }, - }, - }; - expect(captureFromActualConfig(dopplerCfg, "/repo/primary")).toEqual([ - { path: "apps/x", project: "x", config: "dev" }, - ]); - }); + test("returns null when the resolved value isn't array-shaped", () => { + // setSetting refuses a non-array write (registry type is "array"); a + // hand-edited store can still hold one, and the resolver's own type + // check degrades it away rather than throwing — loadTemplate must + // return null for that "nothing usable" case too. + const path = machineSettingsPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + JSON.stringify({ repos: { [IDENTITY]: { "rt.dopplerTemplate": { oops: true } } } }), + ); - test("returns empty array if no enclave entries exist under the worktree", () => { - const dopplerCfg: any = { scoped: { "/": { token: "x" } } }; - expect(captureFromActualConfig(dopplerCfg, "/repo/primary")).toEqual([]); + expect(loadTemplate(IDENTITY)).toBeNull(); }); - test("returns entries sorted by path for deterministic output", () => { - const dopplerCfg: any = { - scoped: { - "/repo/primary/zebra": { "enclave.project": "z", "enclave.config": "dev" }, - "/repo/primary/alpha": { "enclave.project": "a", "enclave.config": "dev" }, - "/repo/primary/middle": { "enclave.project": "m", "enclave.config": "dev" }, - }, - }; - const captured = captureFromActualConfig(dopplerCfg, "/repo/primary"); - expect(captured.map(e => e.path)).toEqual(["alpha", "middle", "zebra"]); + test("an unexpandable ${repoRoot} in a stored value degrades to null instead of throwing", () => { + setSetting( + "rt.dopplerTemplate", + [{ path: "${repoRoot}", project: "backend", config: "dev" }], + "machine", + { repoIdentity: IDENTITY }, + ); + + // ${repoRoot} has no expand context here, so the resolver throws on + // expansion — loadTemplate must degrade to null rather than propagate. + expect(() => loadTemplate(IDENTITY)).not.toThrow(); + expect(loadTemplate(IDENTITY)).toBeNull(); }); }); diff --git a/lib/__tests__/repo-layout.test.ts b/lib/__tests__/repo-layout.test.ts deleted file mode 100644 index 3e4b9756..00000000 --- a/lib/__tests__/repo-layout.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Behavioral guard: the public per-repo save APIs write under ~/.mattstack/rt/repos// - * and NOT directly under ~/.mattstack/rt//. Exercises the real modules (not just the - * path helper) so a regression in any one of them is caught. - * - * Uses an isolated HOME under a temp dir. rt-paths resolves HOME at call time, - * so setting process.env.HOME before calling redirects the whole tree. - */ - -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, existsSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { repoDataDir } from "../rt-paths.ts"; -import { saveTemplate } from "../doppler-template.ts"; - -describe("per-repo files land under ~/.mattstack/rt/repos//", () => { - const origHome = process.env.HOME; - let home: string; - - beforeEach(() => { - home = mkdtempSync(join(tmpdir(), "rt-layout-home-")); - process.env.HOME = home; - }); - afterEach(() => { - process.env.HOME = origHome; - rmSync(home, { recursive: true, force: true }); - }); - - const newPath = (repo: string, file: string) => join(home, ".mattstack", "rt", "repos", repo, file); - const oldPath = (repo: string, file: string) => join(home, ".mattstack", "rt", repo, file); - - test("repoDataDir resolves under the isolated HOME", () => { - expect(repoDataDir("acme")).toBe(join(home, ".mattstack", "rt", "repos", "acme")); - }); - - test("doppler saveTemplate writes under repos/", () => { - saveTemplate("acme", [{ path: "apps/api", project: "api", config: "dev" }]); - expect(existsSync(newPath("acme", "doppler-template.yaml"))).toBe(true); - expect(existsSync(oldPath("acme", "doppler-template.yaml"))).toBe(false); - }); -}); diff --git a/lib/__tests__/run-presets.test.ts b/lib/__tests__/run-presets.test.ts index e8a3d9d3..9d0dc6e0 100644 --- a/lib/__tests__/run-presets.test.ts +++ b/lib/__tests__/run-presets.test.ts @@ -1,22 +1,33 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "fs"; +import { mkdtempSync, realpathSync, rmSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; +import { getSetting } from "../settings/resolve.ts"; +import { setSetting } from "../settings/write.ts"; import { loadPresets, savePreset, findPreset, type Preset } from "../run-presets.ts"; -describe("run-presets", () => { - let dataDir: string; +const IDENTITY = "gitlab.com/acme/test-repo"; + +describe("run-presets over the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; beforeEach(() => { - dataDir = mkdtempSync(join(tmpdir(), "rt-presets-test-")); + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-presets-test-"))); + process.env.HOME = home; }); afterEach(() => { - rmSync(dataDir, { recursive: true, force: true }); + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + it("returns empty array when nothing is declared", () => { + expect(loadPresets(IDENTITY)).toEqual([]); }); - it("returns empty array when no presets directory exists", () => { - expect(loadPresets(dataDir)).toEqual([]); + it("returns empty array when no repo identity is available", () => { + expect(loadPresets(null)).toEqual([]); }); it("saves and loads a preset", () => { @@ -27,8 +38,8 @@ describe("run-presets", () => { { packageRelPath: "apps/adjuster", packageLabel: "adjuster", script: "start:lite" }, ], }; - savePreset(dataDir, preset); - const loaded = loadPresets(dataDir); + savePreset(IDENTITY, preset); + const loaded = loadPresets(IDENTITY); expect(loaded).toHaveLength(1); expect(loaded[0]!.name).toBe("full-stack"); expect(loaded[0]!.entries).toHaveLength(2); @@ -41,10 +52,10 @@ describe("run-presets", () => { { packageRelPath: "apps/backend", packageLabel: "backend", script: "start:lite" }, ], }; - savePreset(dataDir, preset); - expect(findPreset(dataDir, "lite")).not.toBeNull(); - expect(findPreset(dataDir, "lite")!.name).toBe("lite"); - expect(findPreset(dataDir, "nonexistent")).toBeNull(); + savePreset(IDENTITY, preset); + expect(findPreset(IDENTITY, "lite")).not.toBeNull(); + expect(findPreset(IDENTITY, "lite")!.name).toBe("lite"); + expect(findPreset(IDENTITY, "nonexistent")).toBeNull(); }); it("saves preset with variation info", () => { @@ -60,42 +71,56 @@ describe("run-presets", () => { }, ], }; - savePreset(dataDir, preset); - const found = findPreset(dataDir, "with-vars")!; + savePreset(IDENTITY, preset); + const found = findPreset(IDENTITY, "with-vars")!; expect(found.entries[0]!.variationName).toBe("dashboard"); expect(found.entries[0]!.command).toBe("DASHBOARD=1 doppler run -- parcel serve"); }); - it("handles corrupted JSON gracefully", () => { - mkdirSync(join(dataDir, "presets"), { recursive: true }); - writeFileSync(join(dataDir, "presets", "bad.json"), "not json{{{"); - expect(loadPresets(dataDir)).toEqual([]); - }); - - it("overwrites an existing preset with the same name", () => { - savePreset(dataDir, { + it("overwrites an existing preset with the same name, leaving others intact", () => { + savePreset(IDENTITY, { name: "lite", entries: [{ packageRelPath: "apps/backend", packageLabel: "backend", script: "start:lite" }], }); - savePreset(dataDir, { + savePreset(IDENTITY, { + name: "full", + entries: [{ packageRelPath: "apps/frontend", packageLabel: "frontend", script: "start" }], + }); + savePreset(IDENTITY, { name: "lite", entries: [{ packageRelPath: "apps/adjuster", packageLabel: "adjuster", script: "start:lite" }], }); - const loaded = loadPresets(dataDir); - expect(loaded).toHaveLength(1); - expect(loaded[0]!.entries).toHaveLength(1); - expect(loaded[0]!.entries[0]!.packageRelPath).toBe("apps/adjuster"); + const loaded = loadPresets(IDENTITY); + expect(loaded).toHaveLength(2); + const lite = findPreset(IDENTITY, "lite")!; + expect(lite.entries).toHaveLength(1); + expect(lite.entries[0]!.packageRelPath).toBe("apps/adjuster"); + expect(findPreset(IDENTITY, "full")).not.toBeNull(); }); - it("ignores non-json files in the presets directory", () => { - mkdirSync(join(dataDir, "presets"), { recursive: true }); - writeFileSync(join(dataDir, "presets", "notes.txt"), "hello"); - savePreset(dataDir, { + it("savePreset is a no-op when no repo identity is available", () => { + savePreset(null, { name: "lite", entries: [{ packageRelPath: "apps/backend", packageLabel: "backend", script: "start:lite" }], }); + expect(loadPresets(null)).toEqual([]); + }); + + it("stores the shape as { : { entries: [...] } } under the key", () => { + savePreset(IDENTITY, { + name: "lite", + entries: [{ packageRelPath: "apps/backend", packageLabel: "backend", script: "start:lite" }], + }); + const stored = getSetting>("rt.presets", { repoIdentity: IDENTITY }).value; + expect(stored).toEqual({ + lite: { entries: [{ packageRelPath: "apps/backend", packageLabel: "backend", script: "start:lite" }] }, + }); + }); + + it("an unexpandable ${repoRoot} in a stored value degrades to empty instead of throwing", () => { + setSetting("rt.presets", { lite: { entries: "${repoRoot}" } }, "user", { repoIdentity: IDENTITY }); - expect(loadPresets(dataDir)).toHaveLength(1); + expect(() => loadPresets(IDENTITY)).not.toThrow(); }); }); diff --git a/lib/__tests__/variations.test.ts b/lib/__tests__/variations.test.ts index fbc73847..037b53e1 100644 --- a/lib/__tests__/variations.test.ts +++ b/lib/__tests__/variations.test.ts @@ -1,7 +1,10 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "fs"; -import { join } from "path"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; import { tmpdir } from "os"; +import { getSetting } from "../settings/resolve.ts"; +import { setSetting } from "../settings/write.ts"; +import { teamSettingsPath } from "../rt-paths.ts"; import { loadVariations, saveVariation, @@ -9,17 +12,16 @@ import { type Variation, } from "../variations.ts"; -describe("variations", () => { - let dataDir: string; +const IDENTITY = "gitlab.com/acme/test-repo"; - beforeEach(() => { - dataDir = mkdtempSync(join(tmpdir(), "rt-variations-test-")); - }); - - afterEach(() => { - rmSync(dataDir, { recursive: true, force: true }); - }); +/** saveVariation writes to team scope, which refuses without a local team store. */ +function seedTeam(): void { + const path = teamSettingsPath("acme"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "// team store\n{}\n"); +} +describe("variations", () => { describe("variationKey", () => { test("joins repo-relative package path and script with colon", () => { // packagePath is absolute; variationKey computes relative(repoRoot, packagePath) @@ -35,59 +37,100 @@ describe("variations", () => { }); }); - describe("loadVariations", () => { - test("returns empty object when file does not exist", () => { - expect(loadVariations(dataDir)).toEqual({}); + describe("over the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-variations-test-"))); + process.env.HOME = home; + seedTeam(); }); - test("returns empty object for malformed JSON", () => { - const { writeFileSync, mkdirSync } = require("fs"); - mkdirSync(dataDir, { recursive: true }); - writeFileSync(join(dataDir, "variations.json"), "not json{"); - expect(loadVariations(dataDir)).toEqual({}); + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); }); - }); - describe("saveVariation + loadVariations roundtrip", () => { - test("saves and loads a single variation", () => { - saveVariation(dataDir, "/repo", "/repo/pkg/a", "dev", { - name: "debug", - command: "DEBUG=1 pnpm run dev", + describe("loadVariations", () => { + test("returns empty object when nothing is declared", () => { + expect(loadVariations(IDENTITY)).toEqual({}); + }); + + test("returns empty object when no repo identity is available", () => { + expect(loadVariations(null)).toEqual({}); }); - const all = loadVariations(dataDir); - expect(all["pkg/a:dev"]).toEqual([ - { name: "debug", command: "DEBUG=1 pnpm run dev" }, - ]); + test("an unexpandable ${repoRoot} in a stored value degrades to empty instead of throwing", () => { + setSetting("rt.variations", { "pkg/a:dev": "${repoRoot}" }, "team", { repoIdentity: IDENTITY }); + + expect(() => loadVariations(IDENTITY)).not.toThrow(); + expect(loadVariations(IDENTITY)).toEqual({}); + }); }); - test("appends to existing variations for the same key", () => { - saveVariation(dataDir, "/repo", "/repo/pkg/a", "dev", { - name: "debug", - command: "DEBUG=1 pnpm run dev", + describe("saveVariation + loadVariations roundtrip", () => { + test("saves and loads a single variation", () => { + saveVariation(IDENTITY, "/repo", "/repo/pkg/a", "dev", { + name: "debug", + command: "DEBUG=1 pnpm run dev", + }); + + const all = loadVariations(IDENTITY); + expect(all["pkg/a:dev"]).toEqual([ + { name: "debug", command: "DEBUG=1 pnpm run dev" }, + ]); }); - saveVariation(dataDir, "/repo", "/repo/pkg/a", "dev", { - name: "inspect", - command: "pnpm run dev -- --inspect", + + test("appends to existing variations for the same key", () => { + saveVariation(IDENTITY, "/repo", "/repo/pkg/a", "dev", { + name: "debug", + command: "DEBUG=1 pnpm run dev", + }); + saveVariation(IDENTITY, "/repo", "/repo/pkg/a", "dev", { + name: "inspect", + command: "pnpm run dev -- --inspect", + }); + + const all = loadVariations(IDENTITY); + expect(all["pkg/a:dev"]).toHaveLength(2); + expect(all["pkg/a:dev"]![1]!.name).toBe("inspect"); }); - const all = loadVariations(dataDir); - expect(all["pkg/a:dev"]).toHaveLength(2); - expect(all["pkg/a:dev"]![1]!.name).toBe("inspect"); - }); + test("stores variations for different keys independently", () => { + saveVariation(IDENTITY, "/repo", "/repo/pkg/a", "dev", { + name: "debug", + command: "DEBUG=1 pnpm run dev", + }); + saveVariation(IDENTITY, "/repo", "/repo/pkg/b", "start", { + name: "verbose", + command: "VERBOSE=1 pnpm start", + }); - test("stores variations for different keys independently", () => { - saveVariation(dataDir, "/repo", "/repo/pkg/a", "dev", { - name: "debug", - command: "DEBUG=1 pnpm run dev", + const all = loadVariations(IDENTITY); + expect(Object.keys(all)).toHaveLength(2); }); - saveVariation(dataDir, "/repo", "/repo/pkg/b", "start", { - name: "verbose", - command: "VERBOSE=1 pnpm start", + + test("saveVariation is a no-op when no repo identity is available", () => { + saveVariation(null, "/repo", "/repo/pkg/a", "dev", { + name: "debug", + command: "DEBUG=1 pnpm run dev", + }); + expect(loadVariations(null)).toEqual({}); }); - const all = loadVariations(dataDir); - expect(Object.keys(all)).toHaveLength(2); + test("lands in the team store (scope decision: team.repo)", () => { + saveVariation(IDENTITY, "/repo", "/repo/pkg/a", "dev", { + name: "debug", + command: "DEBUG=1 pnpm run dev", + }); + + const explicit: Variation[] = getSetting>( + "rt.variations", + { repoIdentity: IDENTITY }, + ).value["pkg/a:dev"]!; + expect(explicit).toEqual([{ name: "debug", command: "DEBUG=1 pnpm run dev" }]); + }); }); }); }); diff --git a/lib/daemon/__tests__/doppler-sync.test.ts b/lib/daemon/__tests__/doppler-sync.test.ts index 9db078b9..2a7e6f6a 100644 --- a/lib/daemon/__tests__/doppler-sync.test.ts +++ b/lib/daemon/__tests__/doppler-sync.test.ts @@ -7,33 +7,46 @@ const tmpHome = mkdtempSync(join(tmpdir(), "rt-doppler-sync-")); process.env.HOME = tmpHome; const { reconcileForRepo } = await import("../doppler-sync.ts"); -const { saveTemplate } = await import("../../doppler-template.ts"); +const { setSetting } = await import("../../settings/write.ts"); +const { machineSettingsPath } = await import("../../rt-paths.ts"); const { loadDopplerConfig, writeDopplerConfig } = await import("../../doppler-config.ts"); -const REPO = "test-repo"; +const IDENTITY = "gitlab.com/acme/test-repo"; + +function seedTemplate(entries: unknown[]): void { + setSetting("rt.dopplerTemplate", entries, "machine", { repoIdentity: IDENTITY }); +} afterEach(() => { - try { rmSync(join(tmpHome, ".mattstack", "rt"), { recursive: true, force: true }); } catch { /* */ } - try { rmSync(join(tmpHome, ".doppler"), { recursive: true, force: true }); } catch { /* */ } + try { rmSync(join(tmpHome, ".mattstack"), { recursive: true, force: true }); } catch { /* */ } + try { rmSync(join(tmpHome, ".doppler"), { recursive: true, force: true }); } catch { /* */ } }); describe("reconcileForRepo", () => { - test("returns wrote=0 when no template exists", async () => { + test("returns wrote=0 when no template is declared", async () => { const summary = await reconcileForRepo({ - repoName: REPO, + repoIdentity: IDENTITY, + worktreeRoots: ["/repo/primary"], + }); + expect(summary).toEqual({ wrote: 0, overridden: 0, unchanged: 0, skipped: "no-template" }); + }); + + test("returns wrote=0 when identity can't be derived (no repo section reachable)", async () => { + const summary = await reconcileForRepo({ + repoIdentity: null, worktreeRoots: ["/repo/primary"], }); expect(summary).toEqual({ wrote: 0, overridden: 0, unchanged: 0, skipped: "no-template" }); }); test("writes per-app entries for each worktree × template entry", async () => { - saveTemplate(REPO, [ + seedTemplate([ { path: "apps/backend", project: "backend", config: "dev" }, { path: "apps/frontend", project: "frontend", config: "dev" }, ]); const summary = await reconcileForRepo({ - repoName: REPO, + repoIdentity: IDENTITY, worktreeRoots: ["/repo/primary", "/repo/wktree-2"], }); @@ -52,16 +65,10 @@ describe("reconcileForRepo", () => { }); test("is idempotent — second run reports unchanged", async () => { - saveTemplate(REPO, [{ path: "apps/backend", project: "backend", config: "dev" }]); + seedTemplate([{ path: "apps/backend", project: "backend", config: "dev" }]); - await reconcileForRepo({ - repoName: REPO, - worktreeRoots: ["/repo/primary"], - }); - const second = await reconcileForRepo({ - repoName: REPO, - worktreeRoots: ["/repo/primary"], - }); + await reconcileForRepo({ repoIdentity: IDENTITY, worktreeRoots: ["/repo/primary"] }); + const second = await reconcileForRepo({ repoIdentity: IDENTITY, worktreeRoots: ["/repo/primary"] }); expect(second.wrote).toBe(0); expect(second.unchanged).toBe(1); @@ -69,7 +76,7 @@ describe("reconcileForRepo", () => { }); test("does not overwrite a user override (different config)", async () => { - saveTemplate(REPO, [{ path: "apps/backend", project: "backend", config: "dev" }]); + seedTemplate([{ path: "apps/backend", project: "backend", config: "dev" }]); writeDopplerConfig({ scoped: { "/repo/primary/apps/backend": { @@ -80,7 +87,7 @@ describe("reconcileForRepo", () => { }); const summary = await reconcileForRepo({ - repoName: REPO, + repoIdentity: IDENTITY, worktreeRoots: ["/repo/primary"], }); expect(summary.wrote).toBe(0); @@ -89,15 +96,29 @@ describe("reconcileForRepo", () => { .toBe("staging"); }); - test("returns skipped=malformed-template if template can't parse", async () => { - mkdirSync(join(tmpHome, ".mattstack", "rt", "repos", REPO), { recursive: true }); + test("a value present but wrong-shaped at every scope resolves as absent, not malformed", async () => { + // The resolver's own per-scope type check already skips a non-array + // value with a warning before it ever reaches reconcileForRepo — so + // this degrades the same way "nothing declared" does, honestly. + const path = machineSettingsPath(); + mkdirSync(join(tmpHome, ".mattstack"), { recursive: true }); writeFileSync( - join(tmpHome, ".mattstack", "rt", "repos", REPO, "doppler-template.yaml"), - "[invalid yaml::", + path, + JSON.stringify({ repos: { [IDENTITY]: { "rt.dopplerTemplate": { oops: true } } } }), ); const summary = await reconcileForRepo({ - repoName: REPO, + repoIdentity: IDENTITY, + worktreeRoots: ["/repo/primary"], + }); + expect(summary).toEqual({ wrote: 0, overridden: 0, unchanged: 0, skipped: "no-template" }); + }); + + test("returns skipped=malformed-template when a declared entry can't be expanded", async () => { + seedTemplate([{ path: "${repoRoot}", project: "backend", config: "dev" }]); + + const summary = await reconcileForRepo({ + repoIdentity: IDENTITY, worktreeRoots: ["/repo/primary"], }); expect(summary).toEqual({ diff --git a/lib/daemon/cache-refresh.ts b/lib/daemon/cache-refresh.ts index 4dd0ad4f..ce28e2a5 100644 --- a/lib/daemon/cache-refresh.ts +++ b/lib/daemon/cache-refresh.ts @@ -23,6 +23,7 @@ import { syncProjectMRs } from "./project-sync.ts"; import { getProjectMRs } from "./project-mrs-store.ts"; import { pruneDiscussionsStore } from "./discussions-file-store.ts"; import { reconcileForRepo } from "./doppler-sync.ts"; +import { deriveRepoIdentity } from "../settings/identity.ts"; import { listWorktreeRoots, listWorktrees } from "../git-worktrees.ts"; export interface CacheRefresherDeps { @@ -197,13 +198,14 @@ export function createCacheRefresher(deps: CacheRefresherDeps): () => Promise/doppler-template.yaml. Cheap (file I/O - // only) and additive — never overwrites existing entries. + // with each repo's rt.dopplerTemplate setting. Cheap and additive — + // never overwrites existing entries. for (const [repoName, repoPath] of Object.entries(repoIndex())) { if (!existsSync(repoPath)) continue; try { const worktreeRoots = listWorktreeRoots(repoPath); - const summary = await reconcileForRepo({ repoName, worktreeRoots }); + const repoIdentity = await deriveRepoIdentity(repoPath); + const summary = await reconcileForRepo({ repoIdentity, worktreeRoots }); if (summary.skipped) { if (summary.skipped === "malformed-template") { log.debug({ repo: repoName, skipped: summary.skipped }, "doppler sync skipped"); diff --git a/lib/daemon/doppler-sync.ts b/lib/daemon/doppler-sync.ts index 1f3f8cb1..53cdfdc9 100644 --- a/lib/daemon/doppler-sync.ts +++ b/lib/daemon/doppler-sync.ts @@ -1,6 +1,6 @@ /** * Doppler-sync reconciler — keeps `~/.doppler/.doppler.yaml` consistent with - * each repo's `~/.mattstack/rt/repos//doppler-template.yaml` across all worktrees. + * each repo's `rt.dopplerTemplate` setting across all worktrees. * * Called once per cache-refresh tick by the daemon (`refreshCacheImpl` in * `lib/daemon.ts`) and once when a new worktree is created (`lib/worktree/create.ts`). @@ -9,9 +9,9 @@ * are preserved. */ -import { existsSync } from "fs"; import { join } from "path"; -import { loadTemplate, templatePath } from "../doppler-template.ts"; +import { loadTemplate } from "../doppler-template.ts"; +import { getSetting } from "../settings/resolve.ts"; import { loadDopplerConfig, writeDopplerConfig, addScopedEntry } from "../doppler-config.ts"; export interface ReconcileSummary { @@ -23,17 +23,27 @@ export interface ReconcileSummary { } export interface ReconcileOpts { - repoName: string; + repoIdentity: string | null; worktreeRoots: string[]; } export async function reconcileForRepo(opts: ReconcileOpts): Promise { - // Distinguish "no template" (silent opt-out) from "malformed template" (error). - const path = templatePath(opts.repoName); - if (!existsSync(path)) { + // Distinguish "no template declared" (silent opt-out) from "declared but + // unusable" (error) — a presence check on the raw resolved value, not + // explainSetting, so an authored-but-empty array still counts as declared. + // A resolver throw (e.g. an unexpandable ${...} variable authored by hand) + // counts as "declared but unusable", not "nothing declared" — this runs + // once per repo per cache-refresh tick and must never take the cycle down. + let value: unknown; + try { + value = getSetting("rt.dopplerTemplate", { repoIdentity: opts.repoIdentity }).value; + } catch { + return { wrote: 0, overridden: 0, unchanged: 0, skipped: "malformed-template" }; + } + if (value === undefined) { return { wrote: 0, overridden: 0, unchanged: 0, skipped: "no-template" }; } - const template = loadTemplate(opts.repoName); + const template = loadTemplate(opts.repoIdentity); if (template === null) { return { wrote: 0, overridden: 0, unchanged: 0, skipped: "malformed-template" }; } diff --git a/lib/doppler-template.ts b/lib/doppler-template.ts index 89973488..82726264 100644 --- a/lib/doppler-template.ts +++ b/lib/doppler-template.ts @@ -2,7 +2,8 @@ * Per-repo Doppler template — the source of truth for which app subdir of a * worktree maps to which Doppler project + config. * - * Path: ~/.mattstack/rt/repos//doppler-template.yaml. Format is a flat list of objects: + * Resolved through the settings resolver (`rt.dopplerTemplate`, team.repo + * scope): a flat array of objects: * - { path: apps/backend, project: backend, config: dev } * - { path: apps/frontend, project: frontend, config: dev } * @@ -11,10 +12,7 @@ * `make initDoppler`. See docs/superpowers/specs/2026-04-30-doppler-template-sync-design.md. */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { join } from "path"; -import { parse, stringify } from "yaml"; -import { repoDataDir } from "./rt-paths.ts"; +import { getSetting } from "./settings/resolve.ts"; export interface DopplerTemplateEntry { /** Path relative to the worktree root (e.g. "apps/backend"). */ @@ -25,69 +23,31 @@ export interface DopplerTemplateEntry { config: string; } -export function templatePath(repoName: string): string { - return join(repoDataDir(repoName), "doppler-template.yaml"); -} - -/** Load the template. Returns `null` if missing or malformed. */ -export function loadTemplate(repoName: string): DopplerTemplateEntry[] | null { - const path = templatePath(repoName); - if (!existsSync(path)) return null; +/** + * The resolved template, or `null` when nothing resolves or the resolved + * value isn't a template-shaped array — the same "opt out" answer a missing + * or malformed file gave before. A resolver throw (e.g. an unexpandable + * ${...} variable authored by hand) degrades the same way. + */ +export function loadTemplate(repoIdentity: string | null): DopplerTemplateEntry[] | null { + let raw: unknown; try { - const raw = readFileSync(path, "utf8"); - const parsed = parse(raw); - if (!Array.isArray(parsed)) return null; - const entries: DopplerTemplateEntry[] = []; - for (const item of parsed) { - if ( - item && typeof item === "object" && - typeof item.path === "string" && - typeof item.project === "string" && - typeof item.config === "string" - ) { - entries.push({ path: item.path, project: item.project, config: item.config }); - } - } - return entries; + raw = getSetting("rt.dopplerTemplate", { repoIdentity }).value; } catch { return null; } -} + if (raw === undefined || !Array.isArray(raw)) return null; -/** Persist entries to disk. Creates the parent directory if needed. */ -export function saveTemplate( - repoName: string, - entries: DopplerTemplateEntry[], -): void { - const path = templatePath(repoName); - mkdirSync(repoDataDir(repoName), { recursive: true }); - const yaml = stringify(entries); - writeFileSync(path, yaml); -} - -import type { DopplerConfig } from "./doppler-config.ts"; - -/** - * Walk a loaded Doppler config and capture every `enclave.*` entry under the - * given worktree path. Returns relative-pathed template entries, sorted by - * path for deterministic output. - * - * Templates are hand-authored today (a settings-driven bootstrap is planned - * as a future migration); this capture helper backs its test coverage. - */ -export function captureFromActualConfig( - dopplerCfg: DopplerConfig, - worktreeRoot: string, -): DopplerTemplateEntry[] { - const prefix = worktreeRoot.endsWith("/") ? worktreeRoot : worktreeRoot + "/"; - const out: DopplerTemplateEntry[] = []; - for (const [absPath, entry] of Object.entries(dopplerCfg.scoped)) { - if (!absPath.startsWith(prefix)) continue; - const project = entry["enclave.project"]; - const config = entry["enclave.config"]; - if (typeof project !== "string" || typeof config !== "string") continue; - out.push({ path: absPath.slice(prefix.length), project, config }); + const entries: DopplerTemplateEntry[] = []; + for (const item of raw) { + if ( + item && typeof item === "object" && + typeof item.path === "string" && + typeof item.project === "string" && + typeof item.config === "string" + ) { + entries.push({ path: item.path, project: item.project, config: item.config }); + } } - out.sort((a, b) => a.path.localeCompare(b.path)); - return out; + return entries; } diff --git a/lib/run-presets.ts b/lib/run-presets.ts index d39e9b42..a9359cff 100644 --- a/lib/run-presets.ts +++ b/lib/run-presets.ts @@ -1,19 +1,19 @@ /** * Named presets of package+script selections for rt run. * - * Storage: /presets/.json — one file per preset, named after - * the preset itself so saving a preset with an existing name overwrites it. + * Resolved through the settings resolver (`rt.presets`, user.repo scope): + * one object keyed by preset name, each value `{ entries: [...] }`. * * Entries store repo-relative package paths so presets are portable across * worktrees (worktree roots differ, but the relative package path within * the repo is stable). * - * Best-effort I/O — silently swallows errors so a broken/missing file or - * directory never blocks the user's actual command invocation. + * Best-effort I/O — silently swallows errors so a broken/missing store value + * never blocks the user's actual command invocation. */ -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs"; -import { join, basename } from "path"; +import { getSetting } from "./settings/resolve.ts"; +import { setSetting } from "./settings/write.ts"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -35,64 +35,55 @@ export interface Preset { entries: PresetEntry[]; } -// ─── Paths ────────────────────────────────────────────────────────────────── - -function presetsDir(dataDir: string): string { - return join(dataDir, "presets"); -} - -function presetPath(dataDir: string, name: string): string { - return join(presetsDir(dataDir), `${name}.json`); -} - // ─── Read ─────────────────────────────────────────────────────────────────── -export function loadPresets(dataDir: string): Preset[] { - const dir = presetsDir(dataDir); - if (!existsSync(dir)) return []; +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} - let files: string[]; +/** + * The resolved `rt.presets` object as a name → entries map, or empty when + * nothing resolves. A resolver throw (e.g. an unexpandable ${...} variable + * authored by hand) degrades the same way a missing/corrupt file did before. + */ +function presetsMap(repoIdentity: string | null): Record { + let raw: unknown; try { - files = readdirSync(dir); + raw = getSetting("rt.presets", { repoIdentity }).value; } catch { - return []; + return {}; } + if (!isPlainObject(raw)) return {}; - const presets: Preset[] = []; - for (const file of files) { - if (!file.endsWith(".json")) continue; - try { - const raw = JSON.parse(readFileSync(join(dir, file), "utf8")); - presets.push({ - name: raw.name ?? basename(file, ".json"), - entries: raw.entries ?? [], - }); - } catch { - // skip corrupted preset files — best effort + const out: Record = {}; + for (const [name, value] of Object.entries(raw)) { + if (isPlainObject(value) && Array.isArray(value.entries)) { + out[name] = { entries: value.entries as PresetEntry[] }; } } - return presets; + return out; } -export function findPreset(dataDir: string, name: string): Preset | null { - const path = presetPath(dataDir, name); - if (!existsSync(path)) return null; +export function loadPresets(repoIdentity: string | null): Preset[] { + return Object.entries(presetsMap(repoIdentity)).map(([name, { entries }]) => ({ name, entries })); +} - try { - const raw = JSON.parse(readFileSync(path, "utf8")); - return { name: raw.name ?? name, entries: raw.entries ?? [] }; - } catch { - return null; - } +export function findPreset(repoIdentity: string | null, name: string): Preset | null { + const entry = presetsMap(repoIdentity)[name]; + return entry ? { name, entries: entry.entries } : null; } // ─── Write ────────────────────────────────────────────────────────────────── -export function savePreset(dataDir: string, preset: Preset): void { - const path = presetPath(dataDir, preset.name); +/** Overwrites `preset.name`'s entry; every other saved preset survives the write. */ +export function savePreset(repoIdentity: string | null, preset: Preset): void { + if (repoIdentity === null) return; // best effort: no identity, nowhere repo-scoped to write + + const all = presetsMap(repoIdentity); + all[preset.name] = { entries: preset.entries }; + try { - mkdirSync(presetsDir(dataDir), { recursive: true }); - writeFileSync(path, JSON.stringify(preset, null, 2) + "\n"); + setSetting("rt.presets", all, "user", { repoIdentity }); } catch { // best effort — don't break the user's command over a write error } diff --git a/lib/sync-config.ts b/lib/sync-config.ts index c0f0d4b7..ee10814c 100644 --- a/lib/sync-config.ts +++ b/lib/sync-config.ts @@ -1,16 +1,15 @@ /** * rt sync config — Auto-resolve rules and post-resolve steps. * - * Config lives at ~/.mattstack/rt/repos//sync.json (zero footprint — never in the repo). - * The rules define how to handle known-trivial conflicts during rebases: + * Resolved through the settings resolver (`rt.sync`, team.repo scope). The + * rules define how to handle known-trivial conflicts during rebases: * - glob pattern → strategy (theirs/ours) * - per-rule postResolve steps (e.g. "pnpm install" after lockfile resolve) * * Only the postResolve steps for rules that actually matched are executed. */ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; -import { join, dirname } from "path"; +import { getSetting } from "./settings/resolve.ts"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -40,37 +39,26 @@ const DEFAULT_CONFIG: SyncConfig = { autoResolve: [], }; -// ─── Load / Save ───────────────────────────────────────────────────────────── +// ─── Load ──────────────────────────────────────────────────────────────────── /** - * Load sync config from ~/.mattstack/rt/repos//sync.json. - * Returns default config if the file doesn't exist. - * - * @param dataDir - The repo's data directory (e.g. ~/.mattstack/rt/repos/) + * The resolved `rt.sync` value for a repo, or defaults when nothing resolves. + * A resolver throw (e.g. an unexpandable ${...} variable authored by hand) + * degrades the same way a missing/corrupt file did before — this runs on + * every rebase/dispose call and must never take that down. */ -export function loadSyncConfig(dataDir: string): SyncConfig { - const configPath = join(dataDir, "sync.json"); - if (!existsSync(configPath)) return { ...DEFAULT_CONFIG }; - +export function loadSyncConfig(repoIdentity: string | null): SyncConfig { try { - const raw = JSON.parse(readFileSync(configPath, "utf8")); - return { - autoResolve: Array.isArray(raw.autoResolve) ? raw.autoResolve : [], - }; + const raw = getSetting("rt.sync", { repoIdentity }).value; + if (raw && typeof raw === "object" && !Array.isArray(raw) && Array.isArray((raw as { autoResolve?: unknown }).autoResolve)) { + return { autoResolve: (raw as { autoResolve: AutoResolveRule[] }).autoResolve }; + } + return { ...DEFAULT_CONFIG }; } catch { return { ...DEFAULT_CONFIG }; } } -/** - * Save sync config to ~/.mattstack/rt/repos//sync.json. - */ -export function saveSyncConfig(dataDir: string, config: SyncConfig): void { - const configPath = join(dataDir, "sync.json"); - mkdirSync(dirname(configPath), { recursive: true }); - writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); -} - // ─── Rule Matching ─────────────────────────────────────────────────────────── /** diff --git a/lib/variations.ts b/lib/variations.ts index efe5174e..4c3b0818 100644 --- a/lib/variations.ts +++ b/lib/variations.ts @@ -1,19 +1,21 @@ /** * Per-repo script variations for rt run. * - * Storage: /variations.json — a single JSON object keyed by - * `repoRelativePath:scriptName`, each value an array of {name, command}. + * Resolved through the settings resolver (`rt.variations`, team.repo scope): + * a single object keyed by `repoRelativePath:scriptName`, each value an array + * of {name, command}. * * Keys use repo-relative paths so variations are shared across worktrees * (worktree roots differ, but the relative package path within the repo * is stable). * - * Best-effort I/O — silently swallows errors so a broken/missing file + * Best-effort I/O — silently swallows errors so a broken/missing store value * never blocks the user's actual command invocation. */ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; -import { join, dirname, relative } from "path"; +import { relative } from "path"; +import { getSetting } from "./settings/resolve.ts"; +import { setSetting } from "./settings/write.ts"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -24,12 +26,6 @@ export interface Variation { command: string; } -// ─── Paths ────────────────────────────────────────────────────────────────── - -function variationsPath(dataDir: string): string { - return join(dataDir, "variations.json"); -} - // ─── Keys ─────────────────────────────────────────────────────────────────── /** @@ -44,45 +40,42 @@ export function variationKey(repoRoot: string, packagePath: string, script: stri // ─── Read ─────────────────────────────────────────────────────────────────── +/** + * A resolver throw (e.g. an unexpandable ${...} variable authored by hand) + * degrades to "no variations" the same way a missing/corrupt file did before — + * this runs on every rt run invocation and must never block it. + */ export function loadVariations( - dataDir: string, + repoIdentity: string | null, ): Record { - const path = variationsPath(dataDir); - if (!existsSync(path)) return {}; - - let raw: string; + let raw: unknown; try { - raw = readFileSync(path, "utf8"); - } catch { - return {}; - } - - try { - return JSON.parse(raw) as Record; + raw = getSetting("rt.variations", { repoIdentity }).value; } catch { return {}; } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return raw as Record; } // ─── Write ────────────────────────────────────────────────────────────────── export function saveVariation( - dataDir: string, + repoIdentity: string | null, repoRoot: string, packagePath: string, script: string, variation: Variation, ): void { - const path = variationsPath(dataDir); - const key = variationKey(repoRoot, packagePath, script); + if (repoIdentity === null) return; // best effort: no identity, nowhere repo-scoped to write - const all = loadVariations(dataDir); + const key = variationKey(repoRoot, packagePath, script); + const all = loadVariations(repoIdentity); const list = all[key] ?? []; all[key] = [...list, variation]; try { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify(all, null, 2) + "\n"); + setSetting("rt.variations", all, "team", { repoIdentity }); } catch { // best effort — don't break the user's command over a write error } diff --git a/lib/worktree/__tests__/dispose.test.ts b/lib/worktree/__tests__/dispose.test.ts index 95002c95..e9005ea5 100644 --- a/lib/worktree/__tests__/dispose.test.ts +++ b/lib/worktree/__tests__/dispose.test.ts @@ -3,8 +3,8 @@ import { execSync } from "child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, realpathSync } from "fs"; import { tmpdir } from "os"; import { basename, dirname, join } from "path"; -import { repoDataDir } from "../../rt-paths.ts"; -import { saveSyncConfig } from "../../sync-config.ts"; +import { teamSettingsPath } from "../../rt-paths.ts"; +import { setSetting } from "../../settings/write.ts"; import { loadRegistry, saveRegistry, type TreeRecord } from "../registry.ts"; import { branchExistsLocalAsync, listWorktreesAsync, remoteRefExists } from "../git-async.ts"; import { hasFreshAttendantLease } from "../lease.ts"; @@ -40,6 +40,23 @@ function addBareOrigin(repo: string): string { return bare; } +const IDENTITY = "test/acme"; + +/** + * A bare-origin remote is a local filesystem path, which `deriveRepoIdentity` + * can't normalize into an identity on its own (identity.ts: "bare local + * paths are the main case" that returns null). Pin one via the machine + * store's fork override so `rt.sync` reads for these test repos land + * somewhere, and seed one team store so `setSetting(..., "team", ...)` can + * auto-select it instead of refusing (write.ts's team-selection rule). + */ +function seedIdentity(originUrl: string): void { + setSetting("rt.repoIdentityOverrides", { [originUrl]: IDENTITY }, "machine"); + const teamPath = teamSettingsPath("acme"); + mkdirSync(dirname(teamPath), { recursive: true }); + writeFileSync(teamPath, "// team store\n{}\n"); +} + /** Add a worktree on a fresh branch cut from `base`, and return its (canonical) path. */ function addTree(repo: string, name: string, branch: string, base = "origin/main"): string { const path = join(repo, ".worktrees", name); @@ -179,7 +196,7 @@ describe("classifyDirtyAsync", () => { beforeEach(() => { process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-home-"))); repo = makeRepo(); - addBareOrigin(repo); + seedIdentity(addBareOrigin(repo)); tree = addTree(repo, "tree-a", "feature-a"); }); @@ -197,8 +214,8 @@ describe("classifyDirtyAsync", () => { }); test("declared generated file with whitespace-only drift is discardable", async () => { - saveSyncConfig(repoDataDir(repoName), { - autoResolve: [{ glob: "gen.txt", strategy: "theirs" }], + setSetting("rt.sync", { autoResolve: [{ glob: "gen.txt", strategy: "theirs" }] }, "team", { + repoIdentity: IDENTITY, }); writeFileSync(join(tree, "gen.txt"), "alpha \nbeta\n"); @@ -208,8 +225,8 @@ describe("classifyDirtyAsync", () => { }); test("declared generated file with a substantive edit is a blocker", async () => { - saveSyncConfig(repoDataDir(repoName), { - autoResolve: [{ glob: "gen.txt", strategy: "theirs" }], + setSetting("rt.sync", { autoResolve: [{ glob: "gen.txt", strategy: "theirs" }] }, "team", { + repoIdentity: IDENTITY, }); writeFileSync(join(tree, "gen.txt"), "alpha\nbeta\ngamma\n"); @@ -245,7 +262,7 @@ describe("disposeTree", () => { beforeEach(() => { process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-home-"))); repo = makeRepo(); - addBareOrigin(repo); + seedIdentity(addBareOrigin(repo)); events = []; }); @@ -312,8 +329,8 @@ describe("disposeTree", () => { }); test("whitespace-only drift in a declared generated file does not refuse", async () => { - saveSyncConfig(repoDataDir(repoName), { - autoResolve: [{ glob: "gen.txt", strategy: "theirs" }], + setSetting("rt.sync", { autoResolve: [{ glob: "gen.txt", strategy: "theirs" }] }, "team", { + repoIdentity: IDENTITY, }); const path = addTree(repo, "tree-a", "feature-a"); writeFileSync(join(path, "gen.txt"), "alpha \nbeta\n"); diff --git a/lib/worktree/create.ts b/lib/worktree/create.ts index f3b67405..62237999 100644 --- a/lib/worktree/create.ts +++ b/lib/worktree/create.ts @@ -30,6 +30,7 @@ import { runReadySteps } from "./ready.ts"; import { withTreeLock } from "./locks.ts"; import { reapTrashDir, trashTree } from "./trash.ts"; import { reconcileForRepo } from "../daemon/doppler-sync.ts"; +import { deriveRepoIdentity } from "../settings/identity.ts"; const CREATE_TIMEOUT_MS = 5 * 60_000; @@ -137,7 +138,8 @@ async function runCreate( log.warn({ repo: repoName, tree: name, path }, "worktree create: git worktree list failed; skipping doppler sync"); } else { const worktreeRoots = gitEntries.map((w) => w.path); - await reconcileForRepo({ repoName, worktreeRoots }); + const repoIdentity = await deriveRepoIdentity(repoPath); + await reconcileForRepo({ repoIdentity, worktreeRoots }); } const readyStamp = await headSha(path); diff --git a/lib/worktree/dispose.ts b/lib/worktree/dispose.ts index 6c739b6e..c3f197b0 100644 --- a/lib/worktree/dispose.ts +++ b/lib/worktree/dispose.ts @@ -16,7 +16,7 @@ import { gitOk, isAncestorAsync, remoteDefaultRef, remoteRefExists, runGit } fro import { loadRegistry, saveRegistry, type TreeRecord } from "./registry.ts"; import { hasFreshAttendantLease } from "./lease.ts"; import { loadSyncConfig, matchRule } from "../sync-config.ts"; -import { repoDataDir } from "../rt-paths.ts"; +import { deriveRepoIdentity } from "../settings/identity.ts"; import { killWorktreeProcesses } from "../daemon/worktree-process-kill.ts"; import { RETENTION_MS, reapTrashDir, retireTree, stripTrashDir } from "./trash.ts"; @@ -83,7 +83,8 @@ export async function classifyDirtyAsync( worktreePath: string, repoName: string, ): Promise<{ discard: string[]; blockers: string[] }> { - const rules = loadSyncConfig(repoDataDir(repoName)).autoResolve; + const repoIdentity = await deriveRepoIdentity(worktreePath); + const rules = loadSyncConfig(repoIdentity).autoResolve; const status = await runGit(worktreePath, ["status", "--porcelain"]); if (status.exitCode !== 0) { return { discard: [], blockers: [STATUS_FAILED_BLOCKER] }; diff --git a/packages/rt-client/src/settings/__tests__/registry.test.ts b/packages/rt-client/src/settings/__tests__/registry.test.ts index f5021b3a..aa41bfbc 100644 --- a/packages/rt-client/src/settings/__tests__/registry.test.ts +++ b/packages/rt-client/src/settings/__tests__/registry.test.ts @@ -52,13 +52,14 @@ describe("settings/registry", () => { } }); - test("exactly 10 keys are migrated:true", () => { + test("exactly 15 keys are migrated:true", () => { const migrated = allDefs().filter((d) => d.migrated); expect(migrated.map((d) => d.key).sort()).toEqual( [ "rt.intercepts", "rt.repoIdentityOverrides", "rt.repoRoots", "rt.roles", "rt.worktrees", "rt.notifications", "rt.cron", "rt.repoTracking", "rt.runaway", "rt.workspacePrefs", + "rt.sync", "rt.branchNaming", "rt.variations", "rt.presets", "rt.dopplerTemplate", ].sort(), ); }); @@ -105,7 +106,7 @@ describe("settings/registry", () => { test("legacyFile values match the trace for the remaining migrated:false keys", () => { expect(getDef("rt.llm")?.legacyFile).toBe("llm.json"); - expect(getDef("rt.sync")?.legacyFile).toBe("repos//sync.json"); + expect(getDef("rt.hooks")?.legacyFile).toBe("repos//hooks.json"); }); test("repoScoped is consistent with a repos//... legacyFile prefix, in both directions", () => { @@ -129,42 +130,41 @@ describe("settings/registry", () => { } }); - test("the six traced repo-scoped legacy keys carry repoScoped:true and the repos// prefix", () => { - const repoScopedLegacyKeys: Record = { - "rt.sync": "repos//sync.json", - "rt.branchNaming": "repos//branch-naming.json", - "rt.variations": "repos//variations.json", - "rt.presets": "repos//presets/.json", - "rt.dopplerTemplate": "repos//doppler-template.yaml", - "rt.hooks": "repos//hooks.json", - }; + test("the one remaining repo-scoped legacy key carries repoScoped:true and the repos// prefix", () => { + const def = getDef("rt.hooks"); + expect(def?.repoScoped, "rt.hooks should be repoScoped:true").toBe(true); + expect(def?.legacyFile, "rt.hooks legacyFile").toBe("repos//hooks.json"); + }); - for (const [key, legacyFile] of Object.entries(repoScopedLegacyKeys)) { + test("the five newly migrated repo-scoped keys carry repoScoped:true and no legacyFile", () => { + for (const key of ["rt.sync", "rt.branchNaming", "rt.variations", "rt.presets", "rt.dopplerTemplate"]) { const def = getDef(key); expect(def?.repoScoped, `${key} should be repoScoped:true`).toBe(true); - expect(def?.legacyFile, `${key} legacyFile`).toBe(legacyFile); + expect(def?.legacyFile, `${key} should carry no legacyFile`).toBeUndefined(); } }); + test("rt.dopplerTemplate is an array key with replace merge (YAML list -> JSON array)", () => { + const def = getDef("rt.dopplerTemplate"); + expect(def?.type).toBe("array"); + expect(def?.merge).toBe("replace"); + }); + test("the one remaining genuinely global legacy key stays repoScoped:undefined with a bare (non-repos/) legacyFile", () => { const def = getDef("rt.llm"); expect(def?.repoScoped, "rt.llm should not be repoScoped").toBeFalsy(); expect(def?.legacyFile?.startsWith("repos/"), "rt.llm legacyFile should not be repo-prefixed").toBe(false); }); - test("has exactly the 7 remaining migrated:false keys, the 10 migrated:true keys, and the 30 suite keys", () => { + test("has exactly the 2 remaining migrated:false keys, the 15 migrated:true keys, and the 30 suite keys", () => { const migratedFalseKeys = [ "rt.llm", - "rt.sync", - "rt.branchNaming", - "rt.variations", - "rt.presets", - "rt.dopplerTemplate", "rt.hooks", ]; const migratedTrueKeys = [ "rt.roles", "rt.intercepts", "rt.worktrees", "rt.repoIdentityOverrides", "rt.repoRoots", "rt.notifications", "rt.cron", "rt.repoTracking", "rt.runaway", "rt.workspacePrefs", + "rt.sync", "rt.branchNaming", "rt.variations", "rt.presets", "rt.dopplerTemplate", ]; const suiteKeys = [ "mattstack.integrations", diff --git a/packages/rt-client/src/settings/registry-defs.ts b/packages/rt-client/src/settings/registry-defs.ts index b1ce35b1..bd6fce2c 100644 --- a/packages/rt-client/src/settings/registry-defs.ts +++ b/packages/rt-client/src/settings/registry-defs.ts @@ -102,25 +102,13 @@ export const REGISTRY: readonly SettingDef[] = [ migrated: true, description: "Per-machine editor/terminal preferences applied when opening a worktree.", }, - - // --- migrated:false (wave 1 legacy-file keys) --------------------------- - { - key: "rt.llm", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "llm.json", - description: "LLM provider and model selection for rt's AI-assisted commands.", - }, { key: "rt.sync", type: "object", scopes: ALL_SCOPES, merge: "deep", repoScoped: true, - migrated: false, - legacyFile: "repos//sync.json", + migrated: true, description: "Branch sync behavior: fast-forward rules and stale-branch handling.", }, { @@ -129,8 +117,7 @@ export const REGISTRY: readonly SettingDef[] = [ scopes: ALL_SCOPES, merge: "deep", repoScoped: true, - migrated: false, - legacyFile: "repos//branch-naming.json", + migrated: true, description: "Templates rt uses to derive branch names from ticket identifiers.", }, { @@ -139,8 +126,7 @@ export const REGISTRY: readonly SettingDef[] = [ scopes: ALL_SCOPES, merge: "deep", repoScoped: true, - migrated: false, - legacyFile: "repos//variations.json", + migrated: true, description: "Named parameter sets rt run can pick between for a command.", }, { @@ -149,19 +135,28 @@ export const REGISTRY: readonly SettingDef[] = [ scopes: ALL_SCOPES, merge: "deep", repoScoped: true, - migrated: false, - legacyFile: "repos//presets/.json", - description: "Saved argument presets for frequently repeated rt commands.", + migrated: true, + description: "Saved argument presets for frequently repeated rt commands, keyed by name.", }, { key: "rt.dopplerTemplate", + type: "array", + scopes: ALL_SCOPES, + merge: "replace", + repoScoped: true, + migrated: true, + description: "Template used to generate a repo's Doppler secrets config.", + }, + + // --- migrated:false (wave 1 legacy-file keys) --------------------------- + { + key: "rt.llm", type: "object", scopes: ALL_SCOPES, merge: "deep", - repoScoped: true, migrated: false, - legacyFile: "repos//doppler-template.yaml", - description: "Template used to generate a repo's Doppler secrets config.", + legacyFile: "llm.json", + description: "LLM provider and model selection for rt's AI-assisted commands.", }, { key: "rt.hooks", From 5677de32b73226d37bf88ae1b7c47ed4b56fde09 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 21 Aug 2026 00:09:15 -0500 Subject: [PATCH 04/12] =?UTF-8?q?RT-50:=20fix=20wave=20=E2=80=94=20honest?= =?UTF-8?q?=20save=20reporting,=20sync-config=20tests,=20dead-param=20clea?= =?UTF-8?q?nup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit savePreset/saveVariation now return a SaveResult instead of silently no-op'ing or swallowing setSetting refusals; rt run prints a checkmark only on an actual write, otherwise one honest line (no repo identity, or the refusal's own message for a missing team store). Adds lib/__tests__/sync- config.test.ts (previously untested): defaults, wrong-shape degrade, null- identity degrade, unexpandable-variable degrade, and a team.repo happy path. Drops classifyDirtyAsync's dead repoName param everywhere (including a worktree.ts call site missed in the prior pass) and adds a doppler-sync test seeding at team.repo scope, the scope the cutover actually writes. Fixes three stale comments naming files these keys no longer read from disk. Co-Authored-By: Claude Fable 5 --- commands/run.ts | 35 ++++++++++- lib/__tests__/run-presets.test.ts | 13 ++++- lib/__tests__/sync-config.test.ts | 71 +++++++++++++++++++++++ lib/__tests__/variations.test.ts | 40 +++++++++++-- lib/daemon/__tests__/doppler-sync.test.ts | 32 +++++++++- lib/daemon/handlers/worktree.ts | 2 +- lib/daemon/worktree-reconciler.ts | 4 +- lib/doppler-config.ts | 6 +- lib/run-presets.ts | 31 +++++++--- lib/sync-config.ts | 4 +- lib/variations.ts | 31 ++++++++-- lib/worktree/__tests__/dispose.test.ts | 15 +++-- lib/worktree/dispose.ts | 7 +-- 13 files changed, 247 insertions(+), 44 deletions(-) create mode 100644 lib/__tests__/sync-config.test.ts diff --git a/commands/run.ts b/commands/run.ts index 26e6d468..f142d14e 100644 --- a/commands/run.ts +++ b/commands/run.ts @@ -115,6 +115,29 @@ interface QueuedItem { /** Sentinel returned when the picker loop built and launched a queue. */ const QUEUE_LAUNCHED = Symbol("queue-launched"); +/** The shape savePreset/saveVariation both return — structurally, not by import, so either fits. */ +type SaveOutcome = + | { ok: true } + | { ok: false; reason: "no-identity" } + | { ok: false; reason: "write-failed"; message: string }; + +/** + * Prints the truth about a save: a checkmark only when it actually landed, + * one honest line otherwise. `savePreset`/`saveVariation` no-op or refuse + * rather than throw (best-effort I/O), so this is the only place that ever + * tells the user whether their save happened. + */ +function reportSave(kind: string, label: string, result: SaveOutcome, repoLabel: string): void { + if (result.ok) { + process.stderr.write(` ${green}✓${reset} ${dim}saved ${kind} "${label}"${reset}\n`); + return; + } + const detail = result.reason === "no-identity" + ? `no repo identity for ${repoLabel}; pin one with \`rt settings set rt.repoIdentityOverrides\`` + : result.message; + process.stderr.write(` ${yellow}⚠${reset} ${dim}not saved — ${detail}${reset}\n`); +} + /** * Package → script → variations picker loop. * @@ -306,7 +329,7 @@ async function selectPackageAndScript( stderr: true, }); if (name) { - savePreset(repoIdentity, { + const result = savePreset(repoIdentity, { name, entries: q.map((qi) => ({ packageRelPath: qi.packageRelPath, @@ -316,7 +339,8 @@ async function selectPackageAndScript( command: qi.variationName ? qi.command : undefined, })), }); - process.stderr.write(` ${green}✓${reset} ${dim}saved preset "${name}"${reset}\n\n`); + reportSave("preset", name, result, label || worktreePath); + process.stderr.write("\n"); const runNow = await confirm({ message: `Run "${name}" now?`, initialValue: true, @@ -519,7 +543,12 @@ async function selectPackageAndScript( }); if (!command) process.exit(1); - saveVariation(repoIdentity, worktreePath, packagePath, scriptName, { name, command }); + reportSave( + "variation", + name, + saveVariation(repoIdentity, worktreePath, packagePath, scriptName, { name, command }), + label || worktreePath, + ); // Tab or Enter-with-queue: queue the new variation if (varResult.key === "tab" || q.length > 0) { diff --git a/lib/__tests__/run-presets.test.ts b/lib/__tests__/run-presets.test.ts index 9d0dc6e0..59590987 100644 --- a/lib/__tests__/run-presets.test.ts +++ b/lib/__tests__/run-presets.test.ts @@ -99,14 +99,23 @@ describe("run-presets over the settings resolver", () => { expect(findPreset(IDENTITY, "full")).not.toBeNull(); }); - it("savePreset is a no-op when no repo identity is available", () => { - savePreset(null, { + it("savePreset reports no-identity and is a no-op when no repo identity is available", () => { + const result = savePreset(null, { name: "lite", entries: [{ packageRelPath: "apps/backend", packageLabel: "backend", script: "start:lite" }], }); + expect(result).toEqual({ ok: false, reason: "no-identity" }); expect(loadPresets(null)).toEqual([]); }); + it("savePreset reports ok:true on a successful write", () => { + const result = savePreset(IDENTITY, { + name: "lite", + entries: [{ packageRelPath: "apps/backend", packageLabel: "backend", script: "start:lite" }], + }); + expect(result).toEqual({ ok: true }); + }); + it("stores the shape as { : { entries: [...] } } under the key", () => { savePreset(IDENTITY, { name: "lite", diff --git a/lib/__tests__/sync-config.test.ts b/lib/__tests__/sync-config.test.ts new file mode 100644 index 00000000..70dbada8 --- /dev/null +++ b/lib/__tests__/sync-config.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { teamSettingsPath } from "../rt-paths.ts"; +import { setSetting } from "../settings/write.ts"; +import { loadSyncConfig } from "../sync-config.ts"; + +const IDENTITY = "gitlab.com/acme/test-repo"; + +/** setSetting(..., "team", ...) refuses without a local team store (write.ts's team-selection rule). */ +function seedTeam(): void { + const path = teamSettingsPath("acme"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "// team store\n{}\n"); +} + +describe("loadSyncConfig over the settings resolver", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-sync-config-"))); + process.env.HOME = home; + seedTeam(); + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("returns defaults when nothing is declared", () => { + expect(loadSyncConfig(IDENTITY)).toEqual({ autoResolve: [] }); + }); + + test("returns defaults when no repo identity is available", () => { + expect(loadSyncConfig(null)).toEqual({ autoResolve: [] }); + }); + + test("a store-seeded value at team.repo scope resolves through the loader", () => { + setSetting( + "rt.sync", + { autoResolve: [{ glob: "gen.txt", strategy: "theirs", postResolve: ["pnpm install"] }] }, + "team", + { repoIdentity: IDENTITY }, + ); + + expect(loadSyncConfig(IDENTITY)).toEqual({ + autoResolve: [{ glob: "gen.txt", strategy: "theirs", postResolve: ["pnpm install"] }], + }); + }); + + test("a wrong-shaped resolved value degrades to defaults", () => { + setSetting("rt.sync", { autoResolve: "not-an-array" }, "team", { repoIdentity: IDENTITY }); + + expect(loadSyncConfig(IDENTITY)).toEqual({ autoResolve: [] }); + }); + + test("an unexpandable ${repoRoot} in a stored value degrades to defaults instead of throwing", () => { + setSetting( + "rt.sync", + { autoResolve: [{ glob: "${repoRoot}/gen.txt", strategy: "theirs" }] }, + "team", + { repoIdentity: IDENTITY }, + ); + + expect(() => loadSyncConfig(IDENTITY)).not.toThrow(); + expect(loadSyncConfig(IDENTITY)).toEqual({ autoResolve: [] }); + }); +}); diff --git a/lib/__tests__/variations.test.ts b/lib/__tests__/variations.test.ts index 037b53e1..156968f4 100644 --- a/lib/__tests__/variations.test.ts +++ b/lib/__tests__/variations.test.ts @@ -70,11 +70,12 @@ describe("variations", () => { }); describe("saveVariation + loadVariations roundtrip", () => { - test("saves and loads a single variation", () => { - saveVariation(IDENTITY, "/repo", "/repo/pkg/a", "dev", { + test("saves and loads a single variation, reporting ok:true", () => { + const result = saveVariation(IDENTITY, "/repo", "/repo/pkg/a", "dev", { name: "debug", command: "DEBUG=1 pnpm run dev", }); + expect(result).toEqual({ ok: true }); const all = loadVariations(IDENTITY); expect(all["pkg/a:dev"]).toEqual([ @@ -111,11 +112,12 @@ describe("variations", () => { expect(Object.keys(all)).toHaveLength(2); }); - test("saveVariation is a no-op when no repo identity is available", () => { - saveVariation(null, "/repo", "/repo/pkg/a", "dev", { + test("saveVariation reports no-identity and is a no-op when no repo identity is available", () => { + const result = saveVariation(null, "/repo", "/repo/pkg/a", "dev", { name: "debug", command: "DEBUG=1 pnpm run dev", }); + expect(result).toEqual({ ok: false, reason: "no-identity" }); expect(loadVariations(null)).toEqual({}); }); @@ -133,4 +135,34 @@ describe("variations", () => { }); }); }); + + describe("saveVariation write-failed reporting", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-variations-nofail-"))); + process.env.HOME = home; + // deliberately no seedTeam() here — zero local team stores exist. + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("surfaces the team-store refusal instead of silently dropping the save", () => { + const result = saveVariation(IDENTITY, "/repo", "/repo/pkg/a", "dev", { + name: "debug", + command: "DEBUG=1 pnpm run dev", + }); + expect(result.ok).toBe(false); + if (!result.ok && result.reason === "write-failed") { + expect(result.message).toContain("no local team store"); + } else { + throw new Error(`expected a write-failed refusal, got ${JSON.stringify(result)}`); + } + expect(loadVariations(IDENTITY)).toEqual({}); + }); + }); }); diff --git a/lib/daemon/__tests__/doppler-sync.test.ts b/lib/daemon/__tests__/doppler-sync.test.ts index 2a7e6f6a..2d4a805f 100644 --- a/lib/daemon/__tests__/doppler-sync.test.ts +++ b/lib/daemon/__tests__/doppler-sync.test.ts @@ -1,14 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { dirname, join } from "path"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; const tmpHome = mkdtempSync(join(tmpdir(), "rt-doppler-sync-")); process.env.HOME = tmpHome; const { reconcileForRepo } = await import("../doppler-sync.ts"); const { setSetting } = await import("../../settings/write.ts"); -const { machineSettingsPath } = await import("../../rt-paths.ts"); +const { machineSettingsPath, teamSettingsPath } = await import("../../rt-paths.ts"); const { loadDopplerConfig, writeDopplerConfig } = await import("../../doppler-config.ts"); const IDENTITY = "gitlab.com/acme/test-repo"; @@ -17,6 +17,13 @@ function seedTemplate(entries: unknown[]): void { setSetting("rt.dopplerTemplate", entries, "machine", { repoIdentity: IDENTITY }); } +/** setSetting(..., "team", ...) refuses without a local team store (write.ts's team-selection rule). */ +function seedTeam(): void { + const path = teamSettingsPath("acme"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "// team store\n{}\n"); +} + afterEach(() => { try { rmSync(join(tmpHome, ".mattstack"), { recursive: true, force: true }); } catch { /* */ } try { rmSync(join(tmpHome, ".doppler"), { recursive: true, force: true }); } catch { /* */ } @@ -64,6 +71,27 @@ describe("reconcileForRepo", () => { }); }); + test("resolves a template declared at team.repo scope — where the cutover actually writes it", async () => { + seedTeam(); + setSetting( + "rt.dopplerTemplate", + [{ path: "apps/backend", project: "backend", config: "dev" }], + "team", + { repoIdentity: IDENTITY }, + ); + + const summary = await reconcileForRepo({ + repoIdentity: IDENTITY, + worktreeRoots: ["/repo/primary"], + }); + + expect(summary.wrote).toBe(1); + expect(loadDopplerConfig().scoped["/repo/primary/apps/backend"]).toEqual({ + "enclave.project": "backend", + "enclave.config": "dev", + }); + }); + test("is idempotent — second run reports unchanged", async () => { seedTemplate([{ path: "apps/backend", project: "backend", config: "dev" }]); diff --git a/lib/daemon/handlers/worktree.ts b/lib/daemon/handlers/worktree.ts index c19bc091..d15c920a 100644 --- a/lib/daemon/handlers/worktree.ts +++ b/lib/daemon/handlers/worktree.ts @@ -580,7 +580,7 @@ export function createWorktreeHandlers( const parked = rec.branch !== null && PARKING_LOT_BRANCH_RE.test(rec.branch) && - (await classifyDirtyAsync(rec.path, repoName)).blockers.length === 0; + (await classifyDirtyAsync(rec.path)).blockers.length === 0; if (parked) { // Ephemeral+claimed first: the guard only ever deletes rt's own diff --git a/lib/daemon/worktree-reconciler.ts b/lib/daemon/worktree-reconciler.ts index 742f59a4..14fecd99 100644 --- a/lib/daemon/worktree-reconciler.ts +++ b/lib/daemon/worktree-reconciler.ts @@ -663,7 +663,7 @@ async function freshenCandidate(deps: FreshenDeps, rec: TreeRecord): Promise return false; } - const classify = await classifyDirtyAsync(rec.path, repoName); + const classify = await classifyDirtyAsync(rec.path); if (classify.discard.length > 0) { await runGit(rec.path, ["checkout", "--", ...classify.discard]); } diff --git a/lib/doppler-config.ts b/lib/doppler-config.ts index 748e268d..860d0cde 100644 --- a/lib/doppler-config.ts +++ b/lib/doppler-config.ts @@ -2,9 +2,9 @@ * Read/write `~/.doppler/.doppler.yaml`, the global Doppler CLI config. * * Treated by rt as a cache: the reconciler keeps it in sync with each repo's - * doppler-template.yaml. Doppler CLI reads this file at runtime, so any - * process anywhere on the machine that calls `doppler` works as long as the - * cache is up to date. + * `rt.dopplerTemplate` setting. Doppler CLI reads this file at runtime, so + * any process anywhere on the machine that calls `doppler` works as long as + * the cache is up to date. * * Writes are atomic (write to .tmp, rename over original) so Doppler CLI * never sees a half-written file. diff --git a/lib/run-presets.ts b/lib/run-presets.ts index a9359cff..08d3b373 100644 --- a/lib/run-presets.ts +++ b/lib/run-presets.ts @@ -8,8 +8,11 @@ * worktrees (worktree roots differ, but the relative package path within * the repo is stable). * - * Best-effort I/O — silently swallows errors so a broken/missing store value - * never blocks the user's actual command invocation. + * Reads are best-effort — a broken/missing store value degrades to empty + * rather than blocking the user's actual command invocation. Writes are NOT + * silently swallowed: `savePreset` reports success/failure so a caller (`rt + * run`) can tell the user the truth instead of printing a checkmark for a + * save that never landed. */ import { getSetting } from "./settings/resolve.ts"; @@ -75,16 +78,30 @@ export function findPreset(repoIdentity: string | null, name: string): Preset | // ─── Write ────────────────────────────────────────────────────────────────── -/** Overwrites `preset.name`'s entry; every other saved preset survives the write. */ -export function savePreset(repoIdentity: string | null, preset: Preset): void { - if (repoIdentity === null) return; // best effort: no identity, nowhere repo-scoped to write +export type SaveResult = + | { ok: true } + | { ok: false; reason: "no-identity" } + | { ok: false; reason: "write-failed"; message: string }; + +/** + * Overwrites `preset.name`'s entry; every other saved preset survives the + * write — but the base it merges onto is the RESOLVED value across every + * scope the key allows (user/team/machine), not just what's already in the + * user store. Presets are single-scope in practice (personal, written only + * to "user"), so this only matters if a preset was ever hand-authored into + * team or machine: the next save here copies the whole merged map — that + * foreign preset included — into the user store too. Accepted, not guarded. + */ +export function savePreset(repoIdentity: string | null, preset: Preset): SaveResult { + if (repoIdentity === null) return { ok: false, reason: "no-identity" }; const all = presetsMap(repoIdentity); all[preset.name] = { entries: preset.entries }; try { setSetting("rt.presets", all, "user", { repoIdentity }); - } catch { - // best effort — don't break the user's command over a write error + return { ok: true }; + } catch (err) { + return { ok: false, reason: "write-failed", message: err instanceof Error ? err.message : String(err) }; } } diff --git a/lib/sync-config.ts b/lib/sync-config.ts index ee10814c..89bebd79 100644 --- a/lib/sync-config.ts +++ b/lib/sync-config.ts @@ -71,8 +71,8 @@ export function matchRule( filePath: string, rules: AutoResolveRule[], ): AutoResolveRule | null { - // Bun.Glob can throw at match time on an invalid pattern (hand-edited - // sync.json) — catch per call so a bad rule degrades to basic matching + // Bun.Glob can throw at match time on an invalid pattern (hand-authored + // into rt.sync) — catch per call so a bad rule degrades to basic matching // instead of blowing up mid-rebase. (The previous try/catch wrapped only // the closure *creation*, which can never throw.) const basicMatch = (glob: string, path: string): boolean => { diff --git a/lib/variations.ts b/lib/variations.ts index 4c3b0818..1ffae7fc 100644 --- a/lib/variations.ts +++ b/lib/variations.ts @@ -9,8 +9,12 @@ * (worktree roots differ, but the relative package path within the repo * is stable). * - * Best-effort I/O — silently swallows errors so a broken/missing store value - * never blocks the user's actual command invocation. + * Reads are best-effort — a broken/missing store value degrades to empty + * rather than blocking the user's actual command invocation. Writes are NOT + * silently swallowed: `saveVariation` reports success/failure (including a + * team-store refusal, e.g. zero or multiple local team stores — see + * settings/write.ts's team-selection rule) so a caller can tell the user the + * truth instead of pretending the save landed. */ import { relative } from "path"; @@ -60,14 +64,28 @@ export function loadVariations( // ─── Write ────────────────────────────────────────────────────────────────── +export type SaveResult = + | { ok: true } + | { ok: false; reason: "no-identity" } + | { ok: false; reason: "write-failed"; message: string }; + +/** + * Appends `variation` under its key and writes the whole map to team scope + * (ruling: team.repo) — but the base it merges onto is the RESOLVED value + * across every scope the key allows, not just what's already in the team + * store. Variations are meant to live in team scope only, so this only + * matters if one was ever hand-authored into user or machine: the next save + * here copies the whole merged map — that foreign variation included — into + * the team store too. Accepted, not guarded. + */ export function saveVariation( repoIdentity: string | null, repoRoot: string, packagePath: string, script: string, variation: Variation, -): void { - if (repoIdentity === null) return; // best effort: no identity, nowhere repo-scoped to write +): SaveResult { + if (repoIdentity === null) return { ok: false, reason: "no-identity" }; const key = variationKey(repoRoot, packagePath, script); const all = loadVariations(repoIdentity); @@ -76,7 +94,8 @@ export function saveVariation( try { setSetting("rt.variations", all, "team", { repoIdentity }); - } catch { - // best effort — don't break the user's command over a write error + return { ok: true }; + } catch (err) { + return { ok: false, reason: "write-failed", message: err instanceof Error ? err.message : String(err) }; } } diff --git a/lib/worktree/__tests__/dispose.test.ts b/lib/worktree/__tests__/dispose.test.ts index e9005ea5..6adbc856 100644 --- a/lib/worktree/__tests__/dispose.test.ts +++ b/lib/worktree/__tests__/dispose.test.ts @@ -191,7 +191,6 @@ describe("hasFreshAttendantLease", () => { describe("classifyDirtyAsync", () => { let repo: string; let tree: string; - const repoName = "acme"; beforeEach(() => { process.env.HOME = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-home-"))); @@ -201,14 +200,14 @@ describe("classifyDirtyAsync", () => { }); test("clean tree classifies as nothing at all", async () => { - const result = await classifyDirtyAsync(tree, repoName); + const result = await classifyDirtyAsync(tree); expect(result.discard).toEqual([]); expect(result.blockers).toEqual([]); }); test("untracked file is a blocker", async () => { writeFileSync(join(tree, "scratch.txt"), "hi\n"); - const result = await classifyDirtyAsync(tree, repoName); + const result = await classifyDirtyAsync(tree); expect(result.blockers).toEqual(["scratch.txt"]); expect(result.discard).toEqual([]); }); @@ -219,7 +218,7 @@ describe("classifyDirtyAsync", () => { }); writeFileSync(join(tree, "gen.txt"), "alpha \nbeta\n"); - const result = await classifyDirtyAsync(tree, repoName); + const result = await classifyDirtyAsync(tree); expect(result.discard).toEqual(["gen.txt"]); expect(result.blockers).toEqual([]); }); @@ -230,26 +229,26 @@ describe("classifyDirtyAsync", () => { }); writeFileSync(join(tree, "gen.txt"), "alpha\nbeta\ngamma\n"); - const result = await classifyDirtyAsync(tree, repoName); + const result = await classifyDirtyAsync(tree); expect(result.discard).toEqual([]); expect(result.blockers).toEqual(["gen.txt"]); }); test("undeclared modified file is a blocker even when whitespace-only", async () => { writeFileSync(join(tree, "gen.txt"), "alpha \nbeta\n"); - const result = await classifyDirtyAsync(tree, repoName); + const result = await classifyDirtyAsync(tree); expect(result.blockers).toEqual(["gen.txt"]); }); test("a failing git status fails CLOSED, never clean", async () => { const gone = join(tree, "nope", "not-a-worktree"); - const result = await classifyDirtyAsync(gone, repoName); + const result = await classifyDirtyAsync(gone); expect(result.blockers).toEqual([STATUS_FAILED_BLOCKER]); expect(result.discard).toEqual([]); // Same for a directory git refuses to read as a repo. const notARepo = realpathSync(mkdtempSync(join(tmpdir(), "rtdispose-bare-dir-"))); - const outside = await classifyDirtyAsync(notARepo, repoName); + const outside = await classifyDirtyAsync(notARepo); expect(outside.blockers).toEqual([STATUS_FAILED_BLOCKER]); }); }); diff --git a/lib/worktree/dispose.ts b/lib/worktree/dispose.ts index c3f197b0..158d4226 100644 --- a/lib/worktree/dispose.ts +++ b/lib/worktree/dispose.ts @@ -60,8 +60,8 @@ async function isWhitespaceOnlyChange(cwd: string, path: string): Promise { const repoIdentity = await deriveRepoIdentity(worktreePath); const rules = loadSyncConfig(repoIdentity).autoResolve; @@ -213,7 +212,7 @@ export async function disposeTree( if (!force) { // 2. Clean modulo declared generated drift. - const { discard, blockers } = await classifyDirtyAsync(rec.path, repoName); + const { discard, blockers } = await classifyDirtyAsync(rec.path); if (blockers.length > 0) return refuse("dirty"); discarded = discard; From 57336c1a2f4a058bc9b22fdd77e022ef986ec72c Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 21 Aug 2026 00:47:00 -0500 Subject: [PATCH 05/12] =?UTF-8?q?RT-50:=20fix=20wave=20round=202=20?= =?UTF-8?q?=E2=80=94=20direct=20coverage=20for=20reportSave?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reportSave (commands/run.ts) — the only place that renders "✓ saved" vs the failure lines for a preset/variation save — had no direct coverage; savePreset/saveVariation's SaveResult was only verified at the lib layer. Exports a __test__ seam (same convention as lib/notifier.ts, lib/repo-index.ts, commands/code.ts) and adds commands/__tests__/run-report-save.test.ts, driving reportSave directly with a process.stderr.write spy across all three outcomes: ok:true, a null-identity refusal, and a write-failed refusal surfacing the real setSetting message verbatim. Co-Authored-By: Claude Fable 5 --- commands/__tests__/run-report-save.test.ts | 64 ++++++++++++++++++++++ commands/run.ts | 2 + 2 files changed, 66 insertions(+) create mode 100644 commands/__tests__/run-report-save.test.ts diff --git a/commands/__tests__/run-report-save.test.ts b/commands/__tests__/run-report-save.test.ts new file mode 100644 index 00000000..91badb13 --- /dev/null +++ b/commands/__tests__/run-report-save.test.ts @@ -0,0 +1,64 @@ +/** + * `reportSave` (commands/run.ts) — the only place that tells the user + * whether a preset/variation save actually landed. `savePreset`/ + * `saveVariation` no-op or refuse rather than throw (best-effort I/O), so a + * formatting/branch bug here is the one way a failed save could still print + * a checkmark. + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { __test__ } from "../run.ts"; + +const { reportSave } = __test__; + +/** Strips ANSI so assertions read as plain text. */ +// eslint-disable-next-line no-control-regex +const plain = (s: string) => s.replace(/\[[0-9;]*m/g, ""); + +describe("reportSave", () => { + let writes: string[]; + let spy: ReturnType; + + beforeEach(() => { + writes = []; + spy = spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => { + writes.push(typeof chunk === "string" ? chunk : String(chunk)); + return true; + }); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + test("ok:true prints a checkmark with the name, never a failure line", () => { + reportSave("preset", "backend-lite", { ok: true }, "acme"); + + const out = plain(writes.join("")); + expect(out).toContain("✓"); + expect(out).toContain("backend-lite"); + expect(out).not.toContain("not saved"); + }); + + test("no-identity failure names the repo and the override hint", () => { + reportSave("preset", "backend-lite", { ok: false, reason: "no-identity" }, "acme"); + + const out = plain(writes.join("")); + expect(out).toContain("not saved"); + expect(out).toContain("no repo identity for acme"); + expect(out).toContain("pin one with"); + expect(out).toContain("rt settings set rt.repoIdentityOverrides"); + expect(out).not.toContain("✓"); + }); + + test("write-failed surfaces the refusal's own message verbatim", () => { + const message = + "rt: no local team store found — clone a team under ~/.mattstack/teams/ or pass opts.team"; + reportSave("variation", "debug", { ok: false, reason: "write-failed", message }, "acme"); + + const out = plain(writes.join("")); + expect(out).toContain("not saved"); + expect(out).toContain(message); + expect(out).not.toContain("✓"); + }); +}); diff --git a/commands/run.ts b/commands/run.ts index f142d14e..a2888d20 100644 --- a/commands/run.ts +++ b/commands/run.ts @@ -138,6 +138,8 @@ function reportSave(kind: string, label: string, result: SaveOutcome, repoLabel: process.stderr.write(` ${yellow}⚠${reset} ${dim}not saved — ${detail}${reset}\n`); } +export const __test__ = { reportSave }; + /** * Package → script → variations picker loop. * From eb00b02aae55ea239620dda11a5b8cd87b4e73db Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 21 Aug 2026 01:03:05 -0500 Subject: [PATCH 06/12] RT-50: team tracking intent merges under machine grants Co-Authored-By: Claude Fable 5 --- lib/daemon.ts | 11 ++ lib/daemon/__tests__/repo-tracking.test.ts | 148 ++++++++++++++++++++- lib/repo-tracking.ts | 102 ++++++++++++-- 3 files changed, 247 insertions(+), 14 deletions(-) diff --git a/lib/daemon.ts b/lib/daemon.ts index f73fe081..a555357f 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -38,6 +38,7 @@ import { getBranchCacheStore, getStateDb, type BranchCacheStore } from "./state/ import { createCacheRefresher } from "./daemon/cache-refresh.ts"; import { createWorktreeReconciler } from "./daemon/worktree-reconciler.ts"; import { loadRepoIndex, REPOS_JSON_PATH } from "./daemon/repo-index.ts"; +import { primeTeamTrackingIdentityMap } from "./repo-tracking.ts"; import { createHooksGuard } from "./daemon/hooks-guard.ts"; import { buildRoutedHandlers } from "./daemon/command-router.ts"; import { startSocketServer } from "./daemon/socket-server.ts"; @@ -281,11 +282,21 @@ export function startDaemon(): void { // Discover and watch repos hooksGuard.refreshWatchedRepos(); + // Team tracking intent (mattstack.tracking) resolves through a primed + // identity→name map, not live derivation — loadRepoTracking is sync and + // runs on every freshness tick. Team intent is inert until this completes. + primeTeamTrackingIdentityMap(loadRepoIndex()).catch((err) => { + log.warn({ err }, "repo-tracking: failed to prime team-intent identity map"); + }); + // Watch repos.json for changes (new repos added) if (existsSync(REPOS_JSON_PATH)) { watch(REPOS_JSON_PATH, () => { log.info("repos.json changed; refreshing watched repos"); hooksGuard.refreshWatchedRepos(); + primeTeamTrackingIdentityMap(loadRepoIndex()).catch((err) => { + log.warn({ err }, "repo-tracking: failed to re-prime team-intent identity map"); + }); }); } diff --git a/lib/daemon/__tests__/repo-tracking.test.ts b/lib/daemon/__tests__/repo-tracking.test.ts index 69b14277..a85525bc 100644 --- a/lib/daemon/__tests__/repo-tracking.test.ts +++ b/lib/daemon/__tests__/repo-tracking.test.ts @@ -2,11 +2,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; -import { machineSettingsPath } from "../../rt-paths.ts"; +import { machineSettingsPath, teamSettingsPath } from "../../rt-paths.ts"; import { getSetting } from "../../settings/resolve.ts"; import { setSetting } from "../../settings/write.ts"; +import { runCapture } from "../../subprocess.ts"; +import { clearIdentityMemo } from "../../settings/identity.ts"; import { loadRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, + primeTeamTrackingIdentityMap, } from "../../repo-tracking.ts"; function writeStore(file: string, obj: unknown): void { @@ -14,6 +17,13 @@ function writeStore(file: string, obj: unknown): void { writeFileSync(file, JSON.stringify(obj, null, 2)); } +/** setSetting("mattstack.tracking", ..., "team") refuses without a local team store. */ +function seedTeam(): void { + const path = teamSettingsPath("acme"); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "// team store\n{}\n"); +} + describe("loadRepoTracking through the settings resolver", () => { const origHome = process.env.HOME; let home: string; @@ -100,6 +110,142 @@ describe("loadRepoTracking through the settings resolver", () => { }); }); +describe("loadRepoTracking merges mattstack.tracking team intent", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-tracking-team-"))); + process.env.HOME = home; + seedTeam(); + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("team intent for a cloned repo folds in as {mode: live, caches}", () => { + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches", "project-mrs"] } }, + }, "team", { team: "acme" }); + + const t = loadRepoTracking({ identityMap: { "gitlab.com/acme/foo": "foo" } }); + expect(t.foo).toEqual({ mode: "live", caches: ["branches", "project-mrs"] }); + }); + + test("a machine grant for the same repo name wins the whole entry, team ignored", () => { + setSetting("rt.repoTracking", { foo: { mode: "poll", caches: ["branches"] } }, "machine"); + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["discussions"] } }, + }, "team", { team: "acme" }); + + const t = loadRepoTracking({ identityMap: { "gitlab.com/acme/foo": "foo" } }); + expect(t.foo).toEqual({ mode: "poll", caches: ["branches"] }); + }); + + test("an identity with no local resolution is silently dropped", () => { + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/not-cloned": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + + const t = loadRepoTracking({ identityMap: { "gitlab.com/acme/foo": "foo" } }); + expect(t).toEqual({}); + }); + + test("no mattstack.tracking value authored → unchanged from machine-only behavior", () => { + setSetting("rt.repoTracking", { foo: { mode: "live", caches: ["branches"] } }, "machine"); + + const withoutMap = loadRepoTracking(); + const withMap = loadRepoTracking({ identityMap: { "gitlab.com/acme/foo": "foo" } }); + expect(withoutMap).toEqual({ foo: { mode: "live", caches: ["branches"] } }); + expect(withMap).toEqual(withoutMap); + }); + + test("an unresolvable mattstack.tracking value degrades to machine-only, warning once", () => { + setSetting("rt.repoTracking", { foo: { mode: "live", caches: ["branches"] } }, "machine"); + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/bar": { caches: ["${repoRoot}"] } }, + }, "team", { team: "acme" }); + + const warnings: string[] = []; + const orig = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args.map(String).join(" ")); }; + let t: ReturnType; + try { + t = loadRepoTracking({ identityMap: { "gitlab.com/acme/bar": "bar" } }); + } finally { + console.warn = orig; + } + + expect(t).toEqual({ foo: { mode: "live", caches: ["branches"] } }); + expect(warnings.some((w) => w.includes("mattstack.tracking could not be resolved"))).toBe(true); + }); + + test("unknown cache names are dropped from team intent; an empty result drops the entry", () => { + setSetting("mattstack.tracking", { + repos: { + "gitlab.com/acme/foo": { caches: ["branches", "bogus"] }, + "gitlab.com/acme/baz": { caches: ["bogus"] }, + }, + }, "team", { team: "acme" }); + + const t = loadRepoTracking({ + identityMap: { "gitlab.com/acme/foo": "foo", "gitlab.com/acme/baz": "baz" }, + }); + expect(t.foo).toEqual({ mode: "live", caches: ["branches"] }); + expect(t.baz).toBeUndefined(); + }); +}); + +describe("primeTeamTrackingIdentityMap", () => { + const origHome = process.env.HOME; + let home: string; + let repoDir: string; + + beforeEach(async () => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-tracking-prime-"))); + process.env.HOME = home; + seedTeam(); + clearIdentityMemo(); + + repoDir = mkdtempSync(join(tmpdir(), "rt-tracking-prime-repo-")); + await runCapture(["git", "init", "-q"], { cwd: repoDir }); + await runCapture(["git", "remote", "add", "origin", "https://gitlab.com/acme/foo.git"], { cwd: repoDir }); + }); + + afterEach(async () => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + rmSync(repoDir, { recursive: true, force: true }); + clearIdentityMemo(); + // Reset the module-level primed map so later tests in this file that rely + // on the default (unprimed) seam are not affected by this real prime. + await primeTeamTrackingIdentityMap({}); + }); + + test("primes the identity map from a repo index, and the default seam picks it up", async () => { + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + + await primeTeamTrackingIdentityMap({ foo: repoDir }); + + expect(loadRepoTracking().foo).toEqual({ mode: "live", caches: ["branches"] }); + }); + + test("a repo whose identity fails to derive is left out of the primed map", async () => { + const noRemoteDir = mkdtempSync(join(tmpdir(), "rt-tracking-prime-noremote-")); + await runCapture(["git", "init", "-q"], { cwd: noRemoteDir }); + try { + await primeTeamTrackingIdentityMap({ nope: noRemoteDir }); + expect(loadRepoTracking()).toEqual({}); + } finally { + rmSync(noRemoteDir, { recursive: true, force: true }); + } + }); +}); + describe("grants", () => { test("unlisted repo → off with empty set", () => { const g = grants({}, "nope"); diff --git a/lib/repo-tracking.ts b/lib/repo-tracking.ts index 3e38c50b..1dd196a5 100644 --- a/lib/repo-tracking.ts +++ b/lib/repo-tracking.ts @@ -10,10 +10,21 @@ * Unlisted repo = off. Nothing is granted implicitly. Legacy flat entries * ({ "": "live" }) are read as { mode, caches: ["branches"] } and * rewritten to the object shape on the next save. + * + * `loadRepoTracking` also folds in team-declared intent (`mattstack.tracking`, + * team scope, IDENTITY-keyed: `{repos: {"": {caches:[...]}}}`) as + * `{mode: "live", caches}` for any identity that resolves to a locally-known + * repo NAME — machine wins the whole entry per-repo when one exists, and an + * identity with no local resolution is silently dropped (repo not cloned + * here). Resolution goes through a primed identity→name map (see + * `primeTeamTrackingIdentityMap`) rather than deriving live, because this + * loader runs synchronously on every freshness tick while derivation shells + * out to git; an unprimed map means team intent is inert, not an error. */ import { getSetting } from "./settings/resolve.ts"; import { setSetting } from "./settings/write.ts"; +import { deriveRepoIdentity } from "./settings/identity.ts"; export const CACHE_KINDS = ["branches", "project-mrs", "discussions"] as const; export type CacheKind = (typeof CACHE_KINDS)[number]; @@ -59,6 +70,60 @@ function isVersionedEnvelope(value: Record): value is { version && value.repos !== null && typeof value.repos === "object" && !Array.isArray(value.repos); } +export type IdentityNameMap = Record; + +// Primed once (daemon boot) from the repo index, not derived per read — +// loadRepoTracking is sync and called on every freshness tick. +let primedIdentityMap: IdentityNameMap = {}; + +/** + * Builds the identity→name map `loadRepoTracking` consults to resolve team + * intent, from the repo index (name → path) via `deriveRepoIdentity`. Call + * once at daemon boot (and again whenever the repo index changes); a repo + * whose identity fails to derive is left out, not retried here. + */ +export async function primeTeamTrackingIdentityMap(repoIndex: Record): Promise { + const map: IdentityNameMap = {}; + for (const [name, path] of Object.entries(repoIndex)) { + const identity = await deriveRepoIdentity(path); + if (identity) map[identity] = name; + } + primedIdentityMap = map; +} + +function normalizeTeamEntry(value: unknown): { caches: CacheKind[] } | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const { caches } = value as { caches?: unknown }; + if (!Array.isArray(caches)) return null; + const kept = [...new Set(caches.filter((c): c is CacheKind => typeof c === "string" && KINDS.has(c)))]; + if (kept.length === 0) return null; + return { caches: kept }; +} + +// Dedupes the resolver-throw warning by message so a recurring per-tick +// failure logs once, not once per tick, while a genuinely new failure still +// surfaces. +let lastTeamTrackingWarning: string | null = null; + +/** Reads mattstack.tracking's `repos` map; {} on absence, malformed shape, or resolver throw. */ +function loadTeamTracking(): Record { + let raw: unknown; + try { + raw = getSetting("mattstack.tracking").value; + } catch (err) { + const message = `rt: mattstack.tracking could not be resolved (${err instanceof Error ? err.message : err}) — team tracking intent contributes nothing`; + if (message !== lastTeamTrackingWarning) { + lastTeamTrackingWarning = message; + console.warn(message); + } + return {}; + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const { repos } = raw as { repos?: unknown }; + if (!repos || typeof repos !== "object" || Array.isArray(repos)) return {}; + return repos as Record; +} + /** * Read the rt.repoTracking machine-store setting: a flat repo → entry map * (each entry either the v2 shape or a legacy flat string — see @@ -68,7 +133,7 @@ function isVersionedEnvelope(value: Record): value is { version * polling, and this loader runs on every freshness tick so it can never * throw into the daemon. */ -export function loadRepoTracking(): RepoTracking { +export function loadRepoTracking(opts?: { identityMap?: IdentityNameMap }): RepoTracking { let raw: unknown; try { raw = getSetting("rt.repoTracking").value; @@ -76,22 +141,33 @@ export function loadRepoTracking(): RepoTracking { console.warn(`rt: rt.repoTracking could not be resolved (${err instanceof Error ? err.message : err}) — tracking nothing`); return {}; } - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; - let repos = raw as Record; - if (isVersionedEnvelope(repos)) { - console.warn( - "rt: rt.repoTracking holds a versioned {version, repos} envelope — store the repos map, not the versioned envelope " + - "(e.g. `rt settings set rt.repoTracking` with just the inner repos object); using the inner repos map for now.", - ); - repos = repos.repos; + const out: RepoTracking = {}; + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + let repos = raw as Record; + if (isVersionedEnvelope(repos)) { + console.warn( + "rt: rt.repoTracking holds a versioned {version, repos} envelope — store the repos map, not the versioned envelope " + + "(e.g. `rt settings set rt.repoTracking` with just the inner repos object); using the inner repos map for now.", + ); + repos = repos.repos; + } + for (const [repo, value] of Object.entries(repos)) { + const entry = normalizeEntry(value); + if (entry) out[repo] = entry; + } } - const out: RepoTracking = {}; - for (const [repo, value] of Object.entries(repos)) { - const entry = normalizeEntry(value); - if (entry) out[repo] = entry; + const identityMap = opts?.identityMap ?? primedIdentityMap; + if (Object.keys(identityMap).length > 0) { + for (const [identity, value] of Object.entries(loadTeamTracking())) { + const name = identityMap[identity]; + if (!name || out[name]) continue; // uncloned here, or machine wins the whole entry + const entry = normalizeTeamEntry(value); + if (entry) out[name] = { mode: "live", caches: entry.caches }; + } } + return out; } From 7d422d063243cd3bc9e3133ece646440750b0110 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 21 Aug 2026 01:27:15 -0500 Subject: [PATCH 07/12] =?UTF-8?q?RT-50:=20team-tracking=20review=20fixes?= =?UTF-8?q?=20=E2=80=94=20machine-only=20writer/gate,=20real=20re-prime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits loadRepoTracking into a machine-only loadMachineRepoTracking (the only safe base for read-modify-write and for the forge-token grant gate) and the merged view; machine-wins now checks the raw pre-normalization machine map so a typo'd or explicit {mode:"off"} entry blocks team intent and gives repos a real local opt-out; wires the identity-map re-prime into the 60s hooks-scan poller (the reliable mechanism, not the best-effort repos.json watch). Co-Authored-By: Claude Fable 5 --- commands/daemon.ts | 8 +- lib/daemon.ts | 8 +- lib/daemon/__tests__/repo-tracking.test.ts | 92 +++++++++++++++- lib/daemon/__tests__/secrets-handler.test.ts | 57 +++++++++- lib/daemon/handlers/secrets.ts | 8 +- lib/daemon/pollers.ts | 10 ++ lib/repo-tracking.ts | 103 +++++++++++++----- .../rt-client/src/settings/registry-defs.ts | 2 +- 8 files changed, 250 insertions(+), 38 deletions(-) diff --git a/commands/daemon.ts b/commands/daemon.ts index 55ab81a1..c72f47b2 100644 --- a/commands/daemon.ts +++ b/commands/daemon.ts @@ -34,7 +34,7 @@ import { daemonQuery, isDaemonRunning, trayQuery } from "../lib/daemon-client.ts import { classifyDaemonStatus, type DaemonStatusVerdict } from "../lib/daemon-status.ts"; import { isGitLabRemote } from "../lib/enrich.ts"; import type { CacheKind } from "../lib/repo-tracking.ts"; -import { loadRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, DEFAULT_PROJECT_MRS_WINDOW_DAYS } from "../lib/repo-tracking.ts"; +import { loadRepoTracking, loadMachineRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, DEFAULT_PROJECT_MRS_WINDOW_DAYS } from "../lib/repo-tracking.ts"; import { createProjectMRs } from "../lib/daemon/project-mrs-store.ts"; import { getStateDb } from "../lib/state/index.ts"; import { timeAgo } from "../lib/tui/utils/label.ts"; @@ -463,7 +463,11 @@ export async function manageTracking(args: string[] = []): Promise { } } - const tracking = loadRepoTracking(); + // Machine-only read: this is a read-modify-write, and saveRepoTracking + // writes back everything it's handed — a merged (loadRepoTracking) read + // would bake every other repo's team-synthesized entry into the machine + // store as if a human had granted it. + const tracking = loadMachineRepoTracking(); const previousEntry = levelArg2 !== "off" ? tracking[repoArg] : undefined; if (levelArg2 === "off") { delete tracking[repoArg]; diff --git a/lib/daemon.ts b/lib/daemon.ts index a555357f..e5c8d3c2 100644 --- a/lib/daemon.ts +++ b/lib/daemon.ts @@ -285,11 +285,17 @@ export function startDaemon(): void { // Team tracking intent (mattstack.tracking) resolves through a primed // identity→name map, not live derivation — loadRepoTracking is sync and // runs on every freshness tick. Team intent is inert until this completes. + // The RELIABLE re-prime is the 60s hooks-scan poller (pollers.ts); the + // repos.json watch below is best-effort only — see its own comment. primeTeamTrackingIdentityMap(loadRepoIndex()).catch((err) => { log.warn({ err }, "repo-tracking: failed to prime team-intent identity map"); }); - // Watch repos.json for changes (new repos added) + // Watch repos.json for changes (new repos added). Best-effort: repos.json + // is typically rewritten via an atomic rename, which changes the file's + // inode, and fs.watch on most platforms stops delivering events after + // that — this fires once, maybe, and the 60s poller is what actually + // keeps the team-tracking identity map current. if (existsSync(REPOS_JSON_PATH)) { watch(REPOS_JSON_PATH, () => { log.info("repos.json changed; refreshing watched repos"); diff --git a/lib/daemon/__tests__/repo-tracking.test.ts b/lib/daemon/__tests__/repo-tracking.test.ts index a85525bc..ee46905d 100644 --- a/lib/daemon/__tests__/repo-tracking.test.ts +++ b/lib/daemon/__tests__/repo-tracking.test.ts @@ -8,7 +8,7 @@ import { setSetting } from "../../settings/write.ts"; import { runCapture } from "../../subprocess.ts"; import { clearIdentityMemo } from "../../settings/identity.ts"; import { - loadRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, + loadRepoTracking, loadMachineRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, primeTeamTrackingIdentityMap, } from "../../repo-tracking.ts"; @@ -196,6 +196,82 @@ describe("loadRepoTracking merges mattstack.tracking team intent", () => { expect(t.foo).toEqual({ mode: "live", caches: ["branches"] }); expect(t.baz).toBeUndefined(); }); + + test("a typo'd machine entry (rejected by normalizeEntry) still blocks team intent for that name", () => { + setSetting("rt.repoTracking", { foo: { mode: "sideways", caches: ["branches"] } }, "machine"); + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + + const t = loadRepoTracking({ identityMap: { "gitlab.com/acme/foo": "foo" } }); + expect(t.foo).toBeUndefined(); + }); + + test("an explicit {mode:\"off\"} machine entry opts a team-tracked repo out", () => { + setSetting("rt.repoTracking", { foo: { mode: "off" } }, "machine"); + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + + const t = loadRepoTracking({ identityMap: { "gitlab.com/acme/foo": "foo" } }); + expect(t.foo).toBeUndefined(); + }); +}); + +describe("loadMachineRepoTracking — the machine-only read (no team merge)", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-tracking-machine-only-"))); + process.env.HOME = home; + seedTeam(); + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("never contains team-declared entries, even with a primed map and team intent present", () => { + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + + // Sanity: the merged view WOULD show foo if this test used loadRepoTracking. + const merged = loadRepoTracking({ identityMap: { "gitlab.com/acme/foo": "foo" } }); + expect(merged.foo).toBeDefined(); + + expect(loadMachineRepoTracking()).toEqual({}); + }); + + test("a read-modify-write through loadMachineRepoTracking + saveRepoTracking never bakes team intent into the machine store", () => { + setSetting("rt.repoTracking", { existing: { mode: "poll", caches: ["branches"] } }, "machine"); + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches", "project-mrs"] } }, + }, "team", { team: "acme" }); + const identityMap = { "gitlab.com/acme/foo": "foo" }; + + // Prove the merged view sees "foo" (the state a track/untrack call must NOT capture). + expect(loadRepoTracking({ identityMap }).foo).toBeDefined(); + + // track live — a read-modify-write exactly like commands/daemon.ts's manageTracking. + const tracking = loadMachineRepoTracking(); + tracking.existing = { mode: "live", caches: ["branches"] }; + saveRepoTracking(tracking); + + const saved = getSetting>("rt.repoTracking").value; + expect(Object.keys(saved)).toEqual(["existing"]); + expect(saved.foo).toBeUndefined(); + + // untrack off — same primitive, same guarantee. + const tracking2 = loadMachineRepoTracking(); + delete tracking2.existing; + saveRepoTracking(tracking2); + + const savedAfterOff = getSetting>("rt.repoTracking").value; + expect(savedAfterOff).toEqual({}); + }); }); describe("primeTeamTrackingIdentityMap", () => { @@ -234,12 +310,20 @@ describe("primeTeamTrackingIdentityMap", () => { expect(loadRepoTracking().foo).toEqual({ mode: "live", caches: ["branches"] }); }); - test("a repo whose identity fails to derive is left out of the primed map", async () => { + // A no-op prime (one that never populates the map) would make "nope" absent + // for the right reason but ALSO leave "foo" absent — this only passes if + // priming a mixed index actually resolves the repo that CAN derive, not + // just skips the one that can't. + test("a mixed index: the repo that fails to derive is left out, the one that succeeds is folded in", async () => { + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + const noRemoteDir = mkdtempSync(join(tmpdir(), "rt-tracking-prime-noremote-")); await runCapture(["git", "init", "-q"], { cwd: noRemoteDir }); try { - await primeTeamTrackingIdentityMap({ nope: noRemoteDir }); - expect(loadRepoTracking()).toEqual({}); + await primeTeamTrackingIdentityMap({ nope: noRemoteDir, foo: repoDir }); + expect(Object.keys(loadRepoTracking())).toEqual(["foo"]); } finally { rmSync(noRemoteDir, { recursive: true, force: true }); } diff --git a/lib/daemon/__tests__/secrets-handler.test.ts b/lib/daemon/__tests__/secrets-handler.test.ts index f91c1d33..134cfe6f 100644 --- a/lib/daemon/__tests__/secrets-handler.test.ts +++ b/lib/daemon/__tests__/secrets-handler.test.ts @@ -4,8 +4,14 @@ * ~/.mattstack/rt/secrets.json itself, where every caller got every token with no * grant check anywhere. */ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; import { createSecretsHandlers } from "../handlers/secrets.ts"; +import { teamSettingsPath } from "../../rt-paths.ts"; +import { setSetting } from "../../settings/write.ts"; +import { loadRepoTracking } from "../../repo-tracking.ts"; const fakeCtx = { log: { info: () => {}, debug: () => {} } } as any; @@ -55,6 +61,55 @@ describe("secrets:forge-token", () => { }); }); +/** + * The default tracking reader (no `overrides.tracking`) is + * `loadMachineRepoTracking` — machine-only, no team merge. A team file + * (mattstack.tracking) is shared and must never be enough on its own to + * unlock a forge token; only a local machine grant counts. + */ +describe("secrets:forge-token default tracking reader is machine-only", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-secrets-tracking-"))); + process.env.HOME = home; + const teamPath = teamSettingsPath("acme"); + mkdirSync(dirname(teamPath), { recursive: true }); + writeFileSync(teamPath, "// team store\n{}\n"); + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("a repo declared ONLY via team intent is refused, even though the merged view would allow it", async () => { + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + const identityMap = { "gitlab.com/acme/foo": "foo" }; + + // Positive control: the merged view really would consider "foo" tracked. + expect(loadRepoTracking({ identityMap }).foo).toBeDefined(); + + const h = createSecretsHandlers(fakeCtx, { secrets: () => ({ gitlabToken: "glpat-abc" }) }); + const res = await h["secrets:forge-token"]({ repoName: "foo", forge: "gitlab" }); + + if (res.ok) throw new Error("expected a refusal"); + expect(res.error).toContain("not tracked by rt"); + }); + + test("a repo granted via the machine store is allowed", async () => { + setSetting("rt.repoTracking", { foo: { mode: "live", caches: ["branches"] } }, "machine"); + + const h = createSecretsHandlers(fakeCtx, { secrets: () => ({ gitlabToken: "glpat-abc" }) }); + const res = await h["secrets:forge-token"]({ repoName: "foo", forge: "gitlab" }); + + expect(res).toEqual({ ok: true, data: { token: "glpat-abc" } }); + }); +}); + // secrets:read is deliberately token-gated (not grant-gated, unlike // secrets:forge-token above): the api-token check happens IN THE HANDLER so // it covers both the HTTP transport (api-server.ts forwards the verified diff --git a/lib/daemon/handlers/secrets.ts b/lib/daemon/handlers/secrets.ts index 49cbd2e6..bf23a7db 100644 --- a/lib/daemon/handlers/secrets.ts +++ b/lib/daemon/handlers/secrets.ts @@ -32,7 +32,7 @@ */ import { loadSecrets } from "../../linear.ts"; -import { loadRepoTracking, grants, type RepoTracking } from "../../repo-tracking.ts"; +import { loadMachineRepoTracking, grants, type RepoTracking } from "../../repo-tracking.ts"; import { loadOrCreateApiToken, tokenOk } from "../api-auth.ts"; import type { Commands, ForgeSlug } from "../../../packages/rt-client/src/commands.ts"; import type { HandlerContext, HandlerMap, TypedHandlers } from "./types.ts"; @@ -55,7 +55,11 @@ export function createSecretsHandlers( ctx: HandlerContext, overrides: SecretsHandlerOverrides = {}, ): Pick & HandlerMap { - const tracking = overrides.tracking ?? loadRepoTracking; + // Machine-only, deliberately: mattstack.tracking is a SHARED team file, and + // a team-declared repo must not be enough on its own to unlock a token + // read — that consent has to be local (a machine rt.repoTracking grant), + // same as the "off" opt-out rule loadRepoTracking documents. + const tracking = overrides.tracking ?? loadMachineRepoTracking; const secrets = overrides.secrets ?? loadSecrets; const extensionSecrets = overrides.extensionSecrets ?? loadSecrets; const apiToken = overrides.apiToken ?? (() => loadOrCreateApiToken()); diff --git a/lib/daemon/pollers.ts b/lib/daemon/pollers.ts index f02b9cf3..5e8a5080 100644 --- a/lib/daemon/pollers.ts +++ b/lib/daemon/pollers.ts @@ -9,6 +9,7 @@ import { existsSync } from "fs"; import type { Logger } from "pino"; import { scanListeningPorts } from "../port-scanner.ts"; import { checkRunawayProcesses } from "../notifier.ts"; +import { primeTeamTrackingIdentityMap } from "../repo-tracking.ts"; import type { SystemProcessScanner } from "./system-process-scanner.ts"; import type { PortCacheRef, RepoIndex } from "./handlers/types.ts"; @@ -90,8 +91,17 @@ export function startPollers(deps: PollerDeps): void { // Periodic hooks scan — belt-and-suspenders fallback in case a directory // watcher ever misses a write (e.g. watcher limit hit, FS edge-case). // Runs every 60s; each call is cheap (one git-config read per watched repo). + // + // Rides the same interval to re-prime the team-tracking identity map: this + // is the RELIABLE re-prime mechanism, not the repos.json fs.watch in + // daemon.ts — an atomic-rename write (the common way repos.json gets + // replaced) changes the file's inode, and fs.watch on most platforms stops + // delivering events after that, so the watch is best-effort only. setInterval(async () => { const repos = deps.repoIndex(); + await primeTeamTrackingIdentityMap(repos).catch((err) => { + log.warn({ err }, "repo-tracking: failed to re-prime team-intent identity map"); + }); for (const [repoName, repoPath] of Object.entries(repos)) { if (existsSync(repoPath)) await deps.checkAndRepairHooksPath(repoName, repoPath); } diff --git a/lib/repo-tracking.ts b/lib/repo-tracking.ts index 1dd196a5..e8f0ea85 100644 --- a/lib/repo-tracking.ts +++ b/lib/repo-tracking.ts @@ -14,12 +14,21 @@ * `loadRepoTracking` also folds in team-declared intent (`mattstack.tracking`, * team scope, IDENTITY-keyed: `{repos: {"": {caches:[...]}}}`) as * `{mode: "live", caches}` for any identity that resolves to a locally-known - * repo NAME — machine wins the whole entry per-repo when one exists, and an - * identity with no local resolution is silently dropped (repo not cloned - * here). Resolution goes through a primed identity→name map (see - * `primeTeamTrackingIdentityMap`) rather than deriving live, because this - * loader runs synchronously on every freshness tick while derivation shells - * out to git; an unprimed map means team intent is inert, not an error. + * repo NAME. Machine wins the whole entry per-repo whenever the RAW machine + * map names that repo AT ALL — including an entry `normalizeEntry` rejects + * (a typo'd mode, or an explicit `{mode:"off"}`) — not just when it produces + * a valid grant: this is what makes `{mode:"off"}` a real local opt-out for + * a team-tracked repo, and honors the same "a typo must never cause + * accidental polling" rule for the team layer. An identity with no local + * resolution is silently dropped (repo not cloned here). Resolution goes + * through a primed identity→name map (see `primeTeamTrackingIdentityMap`) + * rather than deriving live, because this loader runs synchronously on every + * freshness tick while derivation shells out to git; an unprimed map means + * team intent is inert, not an error. + * + * `loadMachineRepoTracking` is the machine-only half with no team merge — + * the only function safe to read-modify-write through, and the only one a + * grant gate that must not be unlockable by a shared team file may consult. */ import { getSetting } from "./settings/resolve.ts"; @@ -39,6 +48,12 @@ export interface RepoGrants { mode: TrackingMode | "off"; caches: Set const MODES = new Set(["live", "poll"]); const KINDS = new Set(CACHE_KINDS); +/** Valid, deduped cache names out of `value`; [] for a non-array or an all-bogus list. */ +function keptCaches(value: unknown): CacheKind[] { + if (!Array.isArray(value)) return []; + return [...new Set(value.filter((c): c is CacheKind => typeof c === "string" && KINDS.has(c)))]; +} + function normalizeEntry(value: unknown): RepoTrackingEntry | null { // Legacy flat string: "live" | "poll" ("off" meant delete-the-entry). if (typeof value === "string") { @@ -48,8 +63,7 @@ function normalizeEntry(value: unknown): RepoTrackingEntry | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const { mode, caches } = value as { mode?: unknown; caches?: unknown }; if (typeof mode !== "string" || !MODES.has(mode)) return null; - if (!Array.isArray(caches)) return null; - const kept = [...new Set(caches.filter((c): c is CacheKind => typeof c === "string" && KINDS.has(c)))]; + const kept = keptCaches(caches); if (kept.length === 0) return null; // caches must be non-empty; a fully-bogus list degrades to off const { projectMrsWindowDays } = value as { projectMrsWindowDays?: unknown }; const window = typeof projectMrsWindowDays === "number" @@ -78,9 +92,11 @@ let primedIdentityMap: IdentityNameMap = {}; /** * Builds the identity→name map `loadRepoTracking` consults to resolve team - * intent, from the repo index (name → path) via `deriveRepoIdentity`. Call - * once at daemon boot (and again whenever the repo index changes); a repo - * whose identity fails to derive is left out, not retried here. + * intent, from the repo index (name → path) via `deriveRepoIdentity`. Called + * from more than one site (daemon boot, the repos.json watch, the 60s + * hooks-scan poller) — an overlap between two calls in flight at once is + * harmless: both build from the same repo index and the last write wins, so + * there's nothing to guard against races on. */ export async function primeTeamTrackingIdentityMap(repoIndex: Record): Promise { const map: IdentityNameMap = {}; @@ -94,15 +110,17 @@ export async function primeTeamTrackingIdentityMap(repoIndex: Record typeof c === "string" && KINDS.has(c)))]; + const kept = keptCaches(caches); if (kept.length === 0) return null; return { caches: kept }; } // Dedupes the resolver-throw warning by message so a recurring per-tick // failure logs once, not once per tick, while a genuinely new failure still -// surfaces. +// surfaces. The machine-side equivalent below warns on every call instead — +// a deliberate asymmetry: it predates this dedupe and every caller/test +// depends on its exact per-call wording, so it's left alone rather than +// changed as a side effect of adding the team layer. let lastTeamTrackingWarning: string | null = null; /** Reads mattstack.tracking's `repos` map; {} on absence, malformed shape, or resolver throw. */ @@ -124,25 +142,26 @@ function loadTeamTracking(): Record { return repos as Record; } -/** - * Read the rt.repoTracking machine-store setting: a flat repo → entry map - * (each entry either the v2 shape or a legacy flat string — see - * normalizeEntry). Absent/malformed setting, an unresolvable resolver value - * (e.g. an unexpandable ${...} variable), unknown modes, and unknown cache - * names all degrade toward "off" — a typo must never cause accidental - * polling, and this loader runs on every freshness tick so it can never - * throw into the daemon. - */ -export function loadRepoTracking(opts?: { identityMap?: IdentityNameMap }): RepoTracking { +interface MachineTrackingRead { + /** Normalized entries, keyed by repo name — what `loadMachineRepoTracking` returns. */ + out: RepoTracking; + /** Every repo name present in the raw machine map, BEFORE normalization — a typo'd or + * `{mode:"off"}` entry still names its repo here even though it produced no `out` entry. + * This is the set `loadRepoTracking` gates team intent on, not `out`'s keys. */ + rawNames: Set; +} + +function readMachineTracking(): MachineTrackingRead { let raw: unknown; try { raw = getSetting("rt.repoTracking").value; } catch (err) { console.warn(`rt: rt.repoTracking could not be resolved (${err instanceof Error ? err.message : err}) — tracking nothing`); - return {}; + return { out: {}, rawNames: new Set() }; } const out: RepoTracking = {}; + const rawNames = new Set(); if (raw && typeof raw === "object" && !Array.isArray(raw)) { let repos = raw as Record; if (isVersionedEnvelope(repos)) { @@ -153,16 +172,40 @@ export function loadRepoTracking(opts?: { identityMap?: IdentityNameMap }): Repo repos = repos.repos; } for (const [repo, value] of Object.entries(repos)) { + rawNames.add(repo); const entry = normalizeEntry(value); if (entry) out[repo] = entry; } } + return { out, rawNames }; +} + +/** + * Read the rt.repoTracking machine-store setting alone — no team merge. The + * ONLY safe base for a read-modify-write (`rt daemon track`'s save path) and + * the ONLY reader a grant gate that a shared team file must not be able to + * unlock may consult (see `lib/daemon/handlers/secrets.ts`'s forge-token + * gate). Absent/malformed setting, an unresolvable resolver value (e.g. an + * unexpandable ${...} variable), unknown modes, and unknown cache names all + * degrade toward "off" — a typo must never cause accidental polling, and + * this loader runs on every freshness tick so it can never throw into the + * daemon. + */ +export function loadMachineRepoTracking(): RepoTracking { + return readMachineTracking().out; +} + +/** Read the merged view (machine grants + team intent) — see the module doc for the merge rule. */ +export function loadRepoTracking(opts?: { identityMap?: IdentityNameMap }): RepoTracking { + const { out, rawNames } = readMachineTracking(); const identityMap = opts?.identityMap ?? primedIdentityMap; if (Object.keys(identityMap).length > 0) { for (const [identity, value] of Object.entries(loadTeamTracking())) { const name = identityMap[identity]; - if (!name || out[name]) continue; // uncloned here, or machine wins the whole entry + // Uncloned here, or the raw machine map already names this repo + // (valid grant, typo, or explicit {mode:"off"} opt-out alike). + if (!name || rawNames.has(name)) continue; const entry = normalizeTeamEntry(value); if (entry) out[name] = { mode: "live", caches: entry.caches }; } @@ -178,7 +221,13 @@ export function grants(tracking: RepoTracking, repoName: string): RepoGrants { projectMrsWindowDays: entry.projectMrsWindowDays ?? DEFAULT_PROJECT_MRS_WINDOW_DAYS }; } -/** Writes the flat repo → entry map to the machine store, repos sorted for stable diffs. */ +/** + * Writes the flat repo → entry map to the machine store, repos sorted for + * stable diffs. NEVER pass a merged/primed read (`loadRepoTracking`'s + * output) here — a caller doing read-modify-write must start from + * `loadMachineRepoTracking()`, or every other repo's team-synthesized entry + * gets baked into the machine store as if a human had granted it. + */ export function saveRepoTracking(tracking: RepoTracking): void { const repos = Object.fromEntries( Object.entries(tracking).sort(([a], [b]) => a.localeCompare(b)), diff --git a/packages/rt-client/src/settings/registry-defs.ts b/packages/rt-client/src/settings/registry-defs.ts index bd6fce2c..ce514a72 100644 --- a/packages/rt-client/src/settings/registry-defs.ts +++ b/packages/rt-client/src/settings/registry-defs.ts @@ -182,7 +182,7 @@ export const REGISTRY: readonly SettingDef[] = [ type: "object", scopes: ["team"], merge: "deep", - description: "Team-declared repo tracking intent, identity-keyed; the daemon layers it under machine-scoped rt.repoTracking, machine winning per repo.", + description: "Team-declared repo tracking intent, identity-keyed; the daemon layers it under machine-scoped rt.repoTracking, which wins per repo whenever it names that repo at all — including an explicit local {mode:\"off\"} entry, the way to opt a repo out of team-declared tracking.", }, { key: "mattstack.appPath", From 5a18707e0c30f04cf7437ccb06917e9621313b1f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 21 Aug 2026 02:17:51 -0500 Subject: [PATCH 08/12] RT-50: legacy rung, llm chain, and repo-config die Deletes the resolver's legacy per-repo config.json rung (Scope union, collectSlots slot, all five caller opt sites), the whole llm.ts/rt.llm chain, and the dead lib/repo-config.ts. Registry hygiene: siblingCommand field dies (zero defs carried it); rt.hooks remains the sole migrated:false row. Converts every test fixture that rode the legacy config.json path (worktree reconciler/handlers, endpoint config/shim/intercept-run) to seed the same data through the settings stores, pinning bare-clone test origins via rt.repoIdentityOverrides where needed. Rider: `rt daemon track off` now writes an explicit {mode:"off"} machine entry instead of deleting when the team layer still names that repo, so team intent can't resurrect tracking on the next merged read. Co-Authored-By: Claude Fable 5 --- commands/daemon.ts | 25 ++- commands/settings-keys.ts | 34 ++- commands/settings.ts | 71 ------- .../plans/2026-08-20-rt-keys-wave.md | 92 ++++++++ e2e/tests/settings.test.ts | 49 +---- lib/__tests__/llm.test.ts | 197 ------------------ lib/command-tree-def.ts | 7 - .../__tests__/endpoint-handlers.test.ts | 131 ++++++------ lib/daemon/__tests__/repo-tracking.test.ts | 77 ++++++- .../__tests__/settings-handlers.test.ts | 6 +- .../__tests__/worktree-handlers.test.ts | 48 ++++- .../__tests__/worktree-reconciler.test.ts | 117 +++++++---- lib/endpoint/__tests__/config.test.ts | 89 ++++---- lib/endpoint/__tests__/intercept-run.test.ts | 44 ++-- lib/endpoint/__tests__/settings-regen.test.ts | 2 +- lib/endpoint/__tests__/shim.test.ts | 84 +++----- lib/endpoint/config.ts | 31 +-- lib/endpoint/run.ts | 4 +- lib/endpoint/shim.ts | 18 +- lib/llm.ts | 158 -------------- lib/repo-config.ts | 160 -------------- lib/repo-tracking.ts | 26 ++- lib/repo.ts | 4 - lib/worktree/__tests__/config.test.ts | 144 ++++--------- lib/worktree/__tests__/create.test.ts | 52 ++++- lib/worktree/config.ts | 50 ++--- packages/rt-client/src/index.ts | 3 +- .../src/settings/__tests__/registry.test.ts | 22 +- .../src/settings/__tests__/resolve.test.ts | 163 +++------------ .../src/settings/__tests__/stores.test.ts | 4 +- .../rt-client/src/settings/registry-defs.ts | 11 +- .../src/settings/registry-machinery.ts | 1 - packages/rt-client/src/settings/resolve.ts | 96 +-------- packages/rt-client/src/settings/write.ts | 11 +- 34 files changed, 685 insertions(+), 1346 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-20-rt-keys-wave.md delete mode 100644 lib/__tests__/llm.test.ts delete mode 100644 lib/llm.ts delete mode 100644 lib/repo-config.ts diff --git a/commands/daemon.ts b/commands/daemon.ts index c72f47b2..fda1cb65 100644 --- a/commands/daemon.ts +++ b/commands/daemon.ts @@ -34,7 +34,8 @@ import { daemonQuery, isDaemonRunning, trayQuery } from "../lib/daemon-client.ts import { classifyDaemonStatus, type DaemonStatusVerdict } from "../lib/daemon-status.ts"; import { isGitLabRemote } from "../lib/enrich.ts"; import type { CacheKind } from "../lib/repo-tracking.ts"; -import { loadRepoTracking, loadMachineRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, DEFAULT_PROJECT_MRS_WINDOW_DAYS } from "../lib/repo-tracking.ts"; +import { loadRepoTracking, loadMachineRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, DEFAULT_PROJECT_MRS_WINDOW_DAYS, teamNamesIdentity } from "../lib/repo-tracking.ts"; +import { deriveRepoIdentity } from "../lib/settings/identity.ts"; import { createProjectMRs } from "../lib/daemon/project-mrs-store.ts"; import { getStateDb } from "../lib/state/index.ts"; import { timeAgo } from "../lib/tui/utils/label.ts"; @@ -316,9 +317,13 @@ function readRepoIndex(): Record { * rt daemon track off no background API calls (default) * rt daemon track live|poll opt-in to specific caches (branches,project-mrs,discussions) * - * "off" removes the entry (off is the default for unlisted repos). Level - * changes apply immediately for watchers; the 5-min poll picks up poll/off - * changes on its next cycle, so `live`/`poll` also kick a refresh. + * "off" removes the entry (off is the default for unlisted repos) — UNLESS + * the team layer (`mattstack.tracking`) still names this repo, in which case + * a bare delete would let team intent resurrect it on the next merged read; + * "off" then plants an explicit `{mode:"off"}` marker instead, a real local + * opt-out (see lib/repo-tracking.ts's module doc). Level changes apply + * immediately for watchers; the 5-min poll picks up poll/off changes on its + * next cycle, so `live`/`poll` also kick a refresh. */ export async function manageTracking(args: string[] = []): Promise { const [repoArg, levelArg] = args; @@ -469,8 +474,15 @@ export async function manageTracking(args: string[] = []): Promise { // store as if a human had granted it. const tracking = loadMachineRepoTracking(); const previousEntry = levelArg2 !== "off" ? tracking[repoArg] : undefined; + let offMarker: string | undefined; if (levelArg2 === "off") { delete tracking[repoArg]; + // A repo the team layer still declares intent for needs a raw-named + // block, not a bare delete — otherwise the merge in loadRepoTracking + // resurrects team intent for it on the very next read. + const repoPath = readRepoIndex()[repoArg]; + const identity = repoPath ? await deriveRepoIdentity(repoPath) : null; + if (identity && teamNamesIdentity(identity)) offMarker = repoArg; } else { // Only the interactive editor ever touches the window; the positional // CLI form (rt daemon track live [caches]) carries whatever the @@ -484,8 +496,11 @@ export async function manageTracking(args: string[] = []): Promise { ...(windowDays !== undefined ? { projectMrsWindowDays: windowDays } : {}), }; } - saveRepoTracking(tracking); + saveRepoTracking(tracking, offMarker ? [offMarker] : []); console.log(`\n ${green}✓${reset} ${repoArg} tracking: ${levelArg2}${levelArg2 === "off" ? "" : ` [${caches.join(", ")}] window ${formatWindowLabel(tracking[repoArg]?.projectMrsWindowDays)}`}`); + if (offMarker) { + console.log(` ${dim}${repoArg} is still team-tracked — recorded as a local opt-out (rt daemon track ${repoArg} live to re-enable)${reset}`); + } // A write that omits the caches arg always resets to ["branches"] (see // default above). If the entry it replaced granted more than that, the // caller silently lost project-mrs/discussions grants — flag it. diff --git a/commands/settings-keys.ts b/commands/settings-keys.ts index 661ecf7c..05f045d5 100644 --- a/commands/settings-keys.ts +++ b/commands/settings-keys.ts @@ -10,11 +10,10 @@ * * `--repo ` resolves a repo NAME to a path via ~/.mattstack/rt/repos.json, * derives its identity (async — never a sync spawn), and feeds the resolver - * both `legacy.repoName` (the wave-1 legacy rung) and `expandCtx.repoRoot` - * (so a `${repoRoot}` value in a `get` never throws when --repo was given). - * Without --repo, an unexpandable `${repoRoot}` is the honest outcome of - * `get` — its thrown message is rendered cleanly and the process exits 1, - * no stack trace. + * `expandCtx.repoRoot` (so a `${repoRoot}` value in a `get` never throws when + * --repo was given). Without --repo, an unexpandable `${repoRoot}` is the + * honest outcome of `get` — its thrown message is rendered cleanly and the + * process exits 1, no stack trace. * * These verbs run entirely in-process against lib/settings/resolve.ts and * write.ts (both daemon-free, sync-spawn-free) — they do not go through the @@ -81,7 +80,6 @@ interface RepoContext { repoIdentity: string | null; /** Always set when --repo was given, regardless of whether identity derivation succeeded — this is what lets `${repoRoot}` expand even for a repo whose remote doesn't normalize to an identity. */ expandCtx?: { repoRoot: string }; - legacy?: { repoName: string }; } function repoIndex(): Record { @@ -93,13 +91,13 @@ function repoIndex(): Record { * * When the name resolves to a path but no identity derives (a local-path * remote, no remote at all, an unrecognized host), the repo rungs of every - * store are simply unreachable — `${repoRoot}` and the legacy rung still - * answer, so the command succeeds with a strictly smaller ladder. That is an - * honest degrade, but a SILENT one is a trap: the user asked about a repo and - * got an answer that quietly ignored every repo-scoped value. So say it once, - * dim, on stderr — the resolved value still lands on stdout unpolluted, and - * `--json` output is untouched. `set` does not come through here; it refuses - * outright rather than writing into a section nothing will read back. + * store are simply unreachable — `${repoRoot}` still answers, so the command + * succeeds with a strictly smaller ladder. That is an honest degrade, but a + * SILENT one is a trap: the user asked about a repo and got an answer that + * quietly ignored every repo-scoped value. So say it once, dim, on stderr — + * the resolved value still lands on stdout unpolluted, and `--json` output is + * untouched. `set` does not come through here; it refuses outright rather + * than writing into a section nothing will read back. */ async function resolveRepoContext(repoName: string | undefined): Promise { if (!repoName) return { repoIdentity: null }; @@ -114,7 +112,6 @@ async function resolveRepoContext(repoName: string | undefined): Promise { resolved = getSetting(key, { repoIdentity: repoCtx.repoIdentity, expandCtx: repoCtx.expandCtx, - legacy: repoCtx.legacy, }); } catch (err) { failWithError(err); @@ -180,7 +174,7 @@ export async function settingsGet(args: string[]): Promise { value: resolved.value, provenance: resolved.provenance, migrated: isMigrated(def), - ...(isMigrated(def) ? {} : { legacyFile: def.legacyFile ?? null, siblingCommand: def.siblingCommand ?? null }), + ...(isMigrated(def) ? {} : { legacyFile: def.legacyFile ?? null }), })); return; } @@ -315,7 +309,6 @@ export async function settingsList(args: string[]): Promise { const settings = listSettings({ repoIdentity: repoCtx.repoIdentity, expandCtx: repoCtx.expandCtx, - legacy: repoCtx.legacy, }); if (json) { @@ -359,7 +352,6 @@ export async function settingsExplain(args: string[]): Promise { try { rows = explainSetting(key, { repoIdentity: repoCtx.repoIdentity, - legacy: repoCtx.legacy, }); } catch (err) { failWithError(err); diff --git a/commands/settings.ts b/commands/settings.ts index 524e443b..104b4343 100644 --- a/commands/settings.ts +++ b/commands/settings.ts @@ -686,74 +686,3 @@ export async function toggleDevMode(args: string[], exists: (path: string) => bo console.log(""); } -// ─── LLM setup ─────────────────────────────────────────────────────────────── - -export async function configureLlm(): Promise { - const { select } = await import("../lib/rt-render.tsx"); - const { - listOllamaModels, - loadLlmConfig, - saveLlmConfig, - llmPrompt, - } = await import("../lib/llm.ts"); - - const config = loadLlmConfig(); - - // Step 1: Verify Ollama is reachable and list models - console.log(`\n ${dim}checking Ollama at ${config.url}…${reset}`); - - let models: Array<{ name: string; size: string }>; - try { - models = await listOllamaModels(config.url); - } catch (err) { - console.log(`\n ${red}✗${reset} cannot reach Ollama at ${config.url}`); - console.log(` ${dim}make sure Ollama is running (ollama serve)${reset}\n`); - return; - } - - if (models.length === 0) { - console.log(`\n ${yellow}!${reset} no models found`); - console.log(` ${dim}pull one first: ollama pull qwen3:4b${reset}\n`); - return; - } - - // Step 2: Pick a model - const currentModel = config.model; - const options = models.map(m => ({ - value: m.name, - label: `${m.name} ${dim}(${m.size})${reset}`, - hint: m.name === currentModel ? "current" : "", - })); - - const selected = await select({ - message: "Select LLM model", - options, - }); - - if (!selected) return; - - saveLlmConfig({ model: selected }); - - // Step 3: Offer test prompt - console.log(` ${green}✓${reset} model set to ${bold}${selected}${reset}`); - - try { - const test = await select({ - message: "Send a test prompt?", - options: [ - { value: "yes", label: "Yes, test the model", hint: "sends a quick hello" }, - { value: "no", label: "Skip", hint: "" }, - ], - }); - if (test === "yes") { - console.log(`\n ${dim}testing…${reset}`); - const response = await llmPrompt( - "You are a helpful assistant. Reply concisely.", - "Say hello and confirm you are working.", - ); - console.log(` ${green}✓${reset} response: ${dim}${response.slice(0, 120)}${reset}\n`); - } - } catch (err) { - console.log(` ${yellow}!${reset} test failed: ${err instanceof Error ? err.message : String(err)}\n`); - } -} diff --git a/docs/superpowers/plans/2026-08-20-rt-keys-wave.md b/docs/superpowers/plans/2026-08-20-rt-keys-wave.md new file mode 100644 index 00000000..af21b0bf --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-rt-keys-wave.md @@ -0,0 +1,92 @@ +# RT Keys Wave Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every remaining rt legacy config file migrates to the settings stores (or dies), the legacy rung is deleted, and `~/.mattstack/rt` becomes runtime-only. + +**Architecture:** Per key: port the reader to `getSetting`, port the writer to `setSetting`, flip `migrated: true` (dropping `legacyFile`), test. No transition fallbacks — the orchestrator imports each file's current value into its store BEFORE the daemon restarts on this code (Task 6, live). The resolver is `@mattstack/rt-client`'s settings module behind the `lib/settings/` barrels. + +**Tech Stack:** Bun/TypeScript; the RT-47 resolver (`getSetting`/`setSetting`, repo sections by identity). + +**Spec:** `docs/superpowers/specs/2026-08-20-suite-settings-migration.md` — "rt key dispositions" table is binding. Survey facts (reader/writer file:line) below are from the 2026-08-20 resolver survey; line numbers may have drifted ±20 — locate by symbol. + +## Global Constraints + +- Worktree `/Users/matt/Documents/GitHub/repo-tools-rt50b-wt`, branch `goodwinmattheweric/rt-50-keys-wave`. Never touch the main checkout or the live `~/.mattstack` (Task 6 is orchestrator-only). +- The migration order per key is atomic: reader port + writer port + `migrated: true` flip (remove `legacyFile` from the row) + registry-test updates land in ONE commit per task. +- Repo-scoped reads pass `repoIdentity` (derive via `deriveRepoIdentity`) — repo NAME conventions die with the files. +- Readers keep their sanitizers/computed defaults local (the `rt.worktrees` pattern: resolver for values, validation in the reader). +- Deep-merge caution: the resolver deep-merges `type: "object"` keys across scopes; single-scope keys behave as replace. +- Strict TDD; tests never touch real HOME (preload repoint stands). Comments constraint-only, no ticket/task refs. Tree/registry pairs for any new module. No monitor exists — run tests yourself, never wait. +- Gates every task: `bun x tsc --noEmit` 0; `bun run test:all`-scope unit dirs (`bun test lib commands packages`) green. +- Commit trailer: `Co-Authored-By: Claude Fable 5 ` + +--- + +### Task 1: The five global singletons + +**Files (per survey):** +- `rt.notifications`: `lib/notifier.ts` — `PREFS_PATH` (:83), `loadNotificationPrefs()` (:117, bare JSON.parse → all-true defaults from `NOTIFICATION_TYPES`), `saveNotificationPrefs` (:128); writer `commands/settings.ts:243` (`configureNotifications`). Scope: user. Reader becomes `getSetting>("rt.notifications")` merged over the same defaults; save becomes `setSetting(..., "user")`. `notify()` re-reads per call — live propagation preserved by the resolver's unmemoized reads. +- `rt.cron`: `lib/daemon/cron.ts` — `loadCronConfig` (:60), `parseCronConfig` (:36, strict per-field validation KEPT — it now validates the resolved value instead of file text; adjust its input to accept a parsed object, keeping the throw-per-field behavior). Sole reader `lib/daemon.ts:135` at boot. Scope: machine. `rt settings set rt.cron` already prints nothing special — add the daemon-restart hint to the row's description. +- `rt.repoTracking`: `lib/repo-tracking.ts` — `loadRepoTracking` (:59), `saveRepoTracking` (:91), `normalizeEntry` (:35, KEPT — normalizes legacy flat entries in the resolved value), v2 shape. Readers (all per-tick, all stay signature-compatible): `lib/daemon/cache-refresh.ts:69`, `lib/daemon/freshness.ts:434,564,653,744`, `lib/daemon/project-sync.ts:147,324`, `lib/daemon/discussions-poller.ts:99`, `lib/daemon/handlers/{project-mrs.ts:72,secrets.ts,discussions.ts:49}` (preserve the injectable `overrides.tracking` seams). Writer `commands/daemon.ts:478` (`rt daemon track`) → `setSetting(..., "machine")`. Scope: machine. (Team `mattstack.tracking` intent merge is Task 3.) +- `rt.runaway`: reader `lib/daemon/system-process-scanner.ts:91`; writer `commands/settings.ts:304` (`configureRunaway`, direct writeFileSync → setSetting machine). Keep the "restart daemon to apply" print. +- `rt.workspacePrefs`: `commands/code.ts` — `PREFS_PATH` (:21), `loadPrefs` (:28, tolerates legacy `entries` alias — keep), `savePrefs` (:40). Consumer `rt nav` via `openDirectoryInEditor`. Scope: machine. +- Registry: flip all five rows `migrated: true`, drop `legacyFile` + `siblingCommand` labeling changes; update `registry.test.ts` enumerations (migrated set 5→10; the "genuinely global legacy keys" list shrinks) and `commands/__tests__/settings-keys-render.test.ts:31` (hardcodes `rt.llm` — retarget to a surviving migrated:false key, e.g. `rt.hooks`). + +**Interfaces:** every public loader/saver keeps its exact signature (sync loaders may stay sync ONLY if the resolver read is sync — `getSetting` IS sync per the resolver design; verify and keep sync). + +- [ ] **Step 1:** RED tests per key: a store-seeded value resolves through the ported loader; the ported saver lands in the right scope file; defaults on empty store match today's. +- [ ] **Step 2:** run, fail. **Step 3:** port all five (same shape each). **Step 4:** green + tsc 0 + full unit gate. **Step 5:** commit `RT-50: five global singletons read the stores`. + +--- + +### Task 2: The per-repo four + branchNaming flip + +**Files (per survey):** +- `rt.sync`: `lib/sync-config.ts` — `loadSyncConfig(dataDir)` (:51) — NOTE the dataDir param dies; new signature `loadSyncConfig(repoIdentity: string | null)` reading `getSetting("rt.sync", { repoIdentity })`; shape `{autoResolve:[...]}` + `matchRule` kept. Callers updated: `lib/worktree/dispose.ts:86`, `commands/git/rebase.ts:246`, `commands/sync.ts:161` (each already knows its repo; derive identity there). `saveSyncConfig` has no production caller — delete it + its `lib/__tests__/repo-layout.test.ts` usage (that test's per-repo-layout guard shrinks to doppler…which also dies this task: rewrite the test to cover what remains or delete it if empty — report which). +- `rt.variations`: `lib/variations.ts` — `loadVariations(dataDir)` (:47) → identity-based resolver read; `saveVariation` (:68) → `setSetting` team.repo... **scope decision is ruled: team.repo** — but `saveVariation` writes at runtime from `rt run` interactive flows; setSetting to TEAM prints the "local only until you commit and push" reminder — acceptable (solo). Reader `commands/run.ts:479` + `variationKey` unchanged. +- `rt.presets`: `lib/run-presets.ts` — dir-of-files dies; SHAPE CHANGE to `{ "": { entries: [...] } }` under `rt.presets` user.repo. `loadPresets`/`findPreset`/`savePreset` keep signatures, backed by the object. Reader/writer `commands/run.ts:201-204`. +- `rt.dopplerTemplate`: `lib/doppler-template.ts` — `loadTemplate(repoName)` (:33) → `loadTemplate(repoIdentity)` reading the key (shape: the YAML array becomes the same array as JSON); `templatePath` existence check in `lib/daemon/doppler-sync.ts:32` becomes a resolver-presence check ("no-template" opt-out preserved; use value-undefined, not explainSetting). `saveTemplate`/`captureFromActualConfig` have zero production callers — DELETE both + their test usages (`lib/__tests__/doppler-template.test.ts` slims to loadTemplate-over-resolver; `lib/daemon/__tests__/doppler-sync.test.ts:10` re-seeds via store writes). `reconcileForRepo`'s two callers (`lib/worktree/create.ts:140`, `lib/daemon/cache-refresh.ts:200-206`) pass identity — they have repo path/name; derive. +- `rt.branchNaming`: no rt reader exists. Flip `migrated: true`, drop legacyFile; the on-disk files stay (VS Code ext). Nothing else. +- Registry/test updates as in Task 1 (migrated set 10→15). + +- [ ] Steps: RED per key (store-seeded repo-section value resolves; presets shape round-trips; doppler reconciler opts out on absent key) → implement → green + gates → commit `RT-50: per-repo keys read the stores; presets reshape; doppler template is a key`. + +--- + +### Task 3: Team tracking intent (`mattstack.tracking`) + +**Files:** `lib/repo-tracking.ts` (merge layer), `lib/daemon/__tests__/repo-tracking.test.ts`. +Per the spec's installer table: team key is IDENTITY-keyed declared intent `{repos: {"": {caches:[...]}}}`; machine `rt.repoTracking` stays NAME-keyed grants; the daemon merges with machine winning per-repo. Implementation: `loadRepoTracking()` grows an optional identity→name resolution seam (injectable map derived from the repo index — `~/.mattstack/rt/repos.json` names + each repo's derived identity; build the map via `deriveRepoIdentity` per known repo, memoized). Merge: team intent entries whose identity resolves to a locally-known name are folded in as `{mode:"live", caches}` unless the machine key names that repo (machine wins entirely per-repo). Unresolvable identities are ignored silently (repo not cloned here — the installer's fresh-machine case works the day the clone lands). + +- [ ] Steps: RED (team intent for a cloned repo appears; machine override wins; uncloned identity ignored) → implement → green + gates → commit `RT-50: team tracking intent merges under machine grants`. + +--- + +### Task 4: The deletions + +**Files (per survey + spec):** +- `lib/llm.ts` (whole file), `rt settings llm` verb (`commands/settings.ts:681` `configureLlm` + tree subcommand), `lib/__tests__/llm.test.ts`, registry row `rt.llm` (+ enumeration updates). llm.json handling gone. +- `lib/repo-config.ts` (dead: zero callers) + the `lib/repo.ts:18-20` re-export + the wizard. Any test files exercising them. +- **The legacy rung**: `packages/rt-client/src/settings/resolve.ts` lines ~156-196 (`LegacyReader`, `LEGACY_KEY_MAP`, `legacyFilePath`, `defaultLegacyReader`, `setLegacyReader`), the `collectSlots` legacy slot (~:328-339), `"legacy"` out of the `Scope` union + `SCOPE_ORDER`, `legacy?` out of `ResolveOpts`; caller opts deleted at `lib/endpoint/config.ts:272` (+ header note), `lib/worktree/config.ts:122` (+ header), `commands/settings-keys.ts:85,117,168,318,362`; the per-repo `config.json` mtime probe in `lib/endpoint/shim.ts:177-181` (store paths remain). Tests: delete the `describe("legacy layer")` block in the moved resolve.test.ts (~:573-632) and edit the woven assertions the survey enumerated (scope-precedence layer enum, the spec-proof deep-merge case rebuilt on team/user/machine only, explain-ordering rows, e2e/tests/settings.test.ts's legacy fixture + assertions). +- Registry hygiene: every `migrated: true` row has no `legacyFile`; the `repoScoped↔legacyFile` consistency test updates; `rt.hooks` remains the only `migrated: false` row (deferred by ruling) — the write-refusal tests retarget to it. +- `lib/rt-paths.ts` `repoDataDir` STAYS (runtime files still live there). + +- [ ] Steps: delete → update tests per the enumerated list → tsc 0 + full unit gate + e2e settings suite green (fresh dist/rt) → commit `RT-50: legacy rung, llm chain, and repo-config die`. + +--- + +### Task 5: Wave gates + docs + +- [ ] `bun x tsc --noEmit` 0; `bun test lib commands packages` green; `rm -f dist/rt` + full e2e green; `bun scripts/check-docs.ts` clean. +- [ ] Grep live docs (README, website/docs excluding reference/, docs/ excluding superpowers/+dated) for `llm.json`, `cron.jsonc`, `notifications.json`, `repo-tracking.json`, `rt settings llm`, per-repo `config.json` — fix every live-doc hit; regenerate reference docs (the `settings llm` page dies). +- [ ] Commit `RT-50: keys-wave docs + gates`. + +--- + +### Task 6 (ORCHESTRATOR-ONLY, live machine): the cutover + +1. From the worktree, import current values into stores (script piping file contents → `rt settings set --scope [--repo assured-dev]`): notifications→user, cron→machine (the `triggers` object as stored today), **repo-tracking→machine UNWRAPPED — store the inner `repos` map only, NOT the `{version:2, repos:{...}}` envelope** (the ported loader takes the flat map; the envelope reads as nothing-tracked), runaway→machine (if file exists), workspace-prefs→machine, sync/variations/doppler-template(YAML→JSON)/branch-naming→team.repo per the table for assured-dev, **presets→user.repo as ONE `rt.presets` object: key = filename minus `.json` (names keep their spaces/parens), value = the file's `{entries}`** — both preset files under `repos/assured-dev/presets/` fold in before step 3 deletes the directory. +2. Merge the branch (PR, Matt's checkpoint), pull main, `rt daemon restart`. +3. Delete the migrated files + cruft: `~/.mattstack/rt/{cron.jsonc,llm.json,notifications.json,repo-tracking.json,workspace-prefs.json,runaway-config.json}` (if present), `repos/assured-dev/{config.json,sync.json,variations.json,doppler-template.yaml,agent-tasks/}`, `repos/assured-dev/presets/`, `repos/origin/` (path-bug artifact), `repos/*/agent-tasks/`. KEEP: `branch-naming.json` (ext), `hooks.json`+`hooks/` (deferred), `endpoints.json`, `worktrees.json`, `run-history.jsonl`, `panel-columns.json`, `logdy-pino-columns.json`, `secrets.json` (board-lane blocker). +4. Verify: `rt verify` green; `rt settings list` shows the migrated values with provenance; daemon logs clean; `rt run` picker still sees variations/presets for assured-dev; cron trigger fires on next event or at least loads (`RT_LOG_LEVEL=debug` daemon boot line). diff --git a/e2e/tests/settings.test.ts b/e2e/tests/settings.test.ts index 840b9bf6..34f60a45 100644 --- a/e2e/tests/settings.test.ts +++ b/e2e/tests/settings.test.ts @@ -9,23 +9,20 @@ * * The scenario is the whole RT-47 contract in one file: * - * three seeded stores (user + team + machine) plus a legacy per-repo - * config.json → `rt settings get/list/explain` report the resolved value, - * the multi-scope provenance of the deep-merge key, migrated:false labeling - * and an unregistered team key → `rt settings set` at user scope changes the - * answer while every comment in the file survives → `rt intercept install` - * builds the shim from STORE-ONLY roles/intercepts (the per-repo config.json - * deliberately has neither key) and the intercepted command comes back with - * the port the team store's role pool declares AND env from a hook whose path - * was written as `${team:e2eteam}` → the staleness probe fires when a store + * three seeded stores (user + team + machine) → `rt settings get/list/explain` + * report the resolved value, the multi-scope provenance of the deep-merge + * key, migrated:false labeling and an unregistered team key → `rt settings + * set` at user scope changes the answer while every comment in the file + * survives → `rt intercept install` builds the shim from the stores' own + * roles/intercepts and the intercepted command comes back with the port the + * team store's role pool declares AND env from a hook whose path was + * written as `${team:e2eteam}` → the staleness probe fires when a store * file is newer than the rules cache and `rt intercept install` clears it. * * The load-bearing step is the intercept one: it is the only place where the * whole chain (store file → resolver → identity → intercepts.json → shim → * daemon claim → role hook → child env) has to agree, and no unit test can - * reach it. If roles/intercepts ever leak back into the per-repo config.json - * fixture, this test still passes for the WRONG reason — hence the explicit - * assertion that the legacy file carries neither key. + * reach it. */ import { describe, test, expect, beforeAll, afterAll } from "bun:test"; @@ -107,7 +104,6 @@ const TEAM = "e2eteam"; let userStore = ""; let teamStore = ""; let machineStore = ""; -let legacyConfig = ""; let hookStub = ""; const children: Array> = []; @@ -297,11 +293,6 @@ describe("rt settings (four stores, one resolver — e2e)", () => { mkdirSync(join(rtDir, "repos", REPO_NAME), { recursive: true }); writeFileSync(join(rtDir, "repos.json"), JSON.stringify({ [REPO_NAME]: repoPath }, null, 2)); - // The legacy rung. Deliberately carries NEITHER `roles` NOR `intercepts`: - // the interception path below has to be fed by the stores alone. - legacyConfig = join(rtDir, "repos", REPO_NAME, "config.json"); - writeFileSync(legacyConfig, JSON.stringify({ worktrees: { branchFormat: "legacy-" } }, null, 2)); - userStore = join(home, ".mattstack", "user", "settings.jsonc"); teamStore = join(home, ".mattstack", "teams", TEAM, "mattstack", "settings.jsonc"); machineStore = join(home, ".mattstack", "settings.local.jsonc"); @@ -346,12 +337,6 @@ describe("rt settings (four stores, one resolver — e2e)", () => { // ── 1. reads ─────────────────────────────────────────────────────────────── - test("the legacy per-repo file declares neither roles nor intercepts", () => { - const legacy = JSON.parse(readFileSync(legacyConfig, "utf8")); - expect(legacy.roles).toBeUndefined(); - expect(legacy.intercepts).toBeUndefined(); - }); - test("get resolves a store-only key, expands ${team:...} and passes ${port} through", async () => { const out = await rtJson(["settings", "get", "rt.roles", "--repo", REPO_NAME, "--json"]); expect(out.ok).toBe(true); @@ -371,36 +356,28 @@ describe("rt settings (four stores, one resolver — e2e)", () => { expect(out.provenance).toEqual([{ scope: "team.repo", file: teamStore }]); }, 30_000); - test("the deep-merge key merges legacy + team + user + machine with multi-scope provenance", async () => { + test("the deep-merge key merges team + user + machine with multi-scope provenance", async () => { const out = await rtJson(["settings", "get", "rt.worktrees", "--repo", REPO_NAME, "--json"]); expect(out.value).toEqual({ onDeck: 3, // team.repo ready: [{ run: "echo team-ready" }], // team.repo namePool: ["alpha", "beta"], // user.repo - branchFormat: "legacy-", // legacy root: "~/machine-trees", // machine.repo (path literals are legal there) }); expect(out.provenance).toEqual([ - { scope: "legacy", file: legacyConfig }, { scope: "team.repo", file: teamStore }, { scope: "user.repo", file: userStore }, { scope: "machine.repo", file: machineStore }, ]); }, 30_000); - test("get labels a migrated:false key with the legacy file it still reads", async () => { - const out = await rtJson(["settings", "get", "rt.llm", "--json"]); - expect(out.migrated).toBe(false); - expect(out.legacyFile).toBe("llm.json"); - }, 30_000); - test("list reports migrated flags and labels the team store's unregistered key", async () => { const out = await rtJson(["settings", "list", "--repo", REPO_NAME, "--json"]); const byKey = new Map(out.settings.map((s: any) => [s.key, s])); expect(byKey.get("rt.worktrees").migrated).toBe(true); expect(byKey.get("rt.worktrees").value.onDeck).toBe(3); - expect(byKey.get("rt.llm").migrated).toBe(false); + expect(byKey.get("rt.hooks").migrated).toBe(false); const unknown = byKey.get("rt.e2eFutureKey"); expect(unknown.unregistered).toBe(true); @@ -415,12 +392,10 @@ describe("rt settings (four stores, one resolver — e2e)", () => { expect(out).toContain("rt.worktrees"); expect(out).toContain("(registry default)"); - expect(out).toMatch(new RegExp(`legacy\\s+${legacyConfig}\\s+\\{"branchFormat":"legacy-"\\}`)); expect(out).toMatch(new RegExp(`team\\.repo\\s+${teamStore}\\s+\\{"onDeck":3`)); expect(out).toMatch(new RegExp(`user\\.repo\\s+${userStore}\\s+\\{"namePool"`)); expect(out).toMatch(new RegExp(`machine\\.repo\\s+${machineStore}\\s+\\{"root"`)); // Rung ORDER is the contract: weakest first. - expect(out.indexOf("legacy")).toBeLessThan(out.indexOf("team.repo")); expect(out.indexOf("team.repo")).toBeLessThan(out.indexOf("user.repo")); expect(out.indexOf("user.repo")).toBeLessThan(out.indexOf("machine.repo")); }, 30_000); @@ -446,9 +421,7 @@ describe("rt settings (four stores, one resolver — e2e)", () => { expect(out.value.namePool).toEqual(["gamma"]); // The other scopes are untouched by a write that only owns namePool. expect(out.value.onDeck).toBe(3); - expect(out.value.branchFormat).toBe("legacy-"); expect(out.provenance.map((p: any) => p.scope)).toEqual([ - "legacy", "team.repo", "user.repo", "machine.repo", diff --git a/lib/__tests__/llm.test.ts b/lib/__tests__/llm.test.ts deleted file mode 100644 index 1a6746b0..00000000 --- a/lib/__tests__/llm.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, test, expect, afterEach, beforeEach, mock } from "bun:test"; -import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs"; -import { join } from "path"; - -// We'll import from the module once it exists -// For now, define the expected shape inline to write the test first. - -const origHome = process.env.HOME; -const TMP = "/tmp/llm-test-home"; - -beforeEach(() => { - rmSync(TMP, { recursive: true, force: true }); - mkdirSync(join(TMP, ".mattstack", "rt"), { recursive: true }); - process.env.HOME = TMP; -}); - -afterEach(() => { - process.env.HOME = origHome; - rmSync(TMP, { recursive: true, force: true }); -}); - -describe("loadLlmConfig", () => { - test("returns defaults when no config file exists", async () => { - const { loadLlmConfig } = await import("../llm.ts"); - const config = loadLlmConfig(); - expect(config.provider).toBe("ollama"); - expect(config.url).toBe("http://localhost:11434"); - expect(config.model).toBe(""); - expect(config.timeoutMs).toBe(15000); - }); - - test("reads existing config and fills in missing defaults", async () => { - mkdirSync(join(TMP, ".mattstack", "rt"), { recursive: true }); - writeFileSync( - join(TMP, ".mattstack", "rt", "llm.json"), - JSON.stringify({ model: "qwen3:4b" }), - ); - const { loadLlmConfig } = await import("../llm.ts"); - const config = loadLlmConfig(); - expect(config.model).toBe("qwen3:4b"); - expect(config.url).toBe("http://localhost:11434"); // default - expect(config.timeoutMs).toBe(15000); // default - }); - - test("reads full config", async () => { - mkdirSync(join(TMP, ".mattstack", "rt"), { recursive: true }); - writeFileSync( - join(TMP, ".mattstack", "rt", "llm.json"), - JSON.stringify({ - provider: "ollama", - url: "http://10.0.0.5:11434", - model: "codellama:7b", - timeoutMs: 30000, - }), - ); - const { loadLlmConfig } = await import("../llm.ts"); - const config = loadLlmConfig(); - expect(config.url).toBe("http://10.0.0.5:11434"); - expect(config.model).toBe("codellama:7b"); - expect(config.timeoutMs).toBe(30000); - }); - - test("handles malformed JSON gracefully", async () => { - mkdirSync(join(TMP, ".mattstack", "rt"), { recursive: true }); - writeFileSync(join(TMP, ".mattstack", "rt", "llm.json"), "not json"); - const { loadLlmConfig } = await import("../llm.ts"); - const config = loadLlmConfig(); - expect(config.provider).toBe("ollama"); // falls back to defaults - }); -}); - -describe("saveLlmConfig", () => { - test("writes config merging with existing values", async () => { - const { loadLlmConfig, saveLlmConfig } = await import("../llm.ts"); - saveLlmConfig({ model: "qwen3:4b", timeoutMs: 20000 }); - const config = loadLlmConfig(); - expect(config.model).toBe("qwen3:4b"); - expect(config.timeoutMs).toBe(20000); - expect(config.url).toBe("http://localhost:11434"); // preserved from defaults - }); - - test("creates ~/.mattstack/rt directory if it does not exist", async () => { - rmSync(join(TMP, ".mattstack", "rt"), { recursive: true, force: true }); - const { saveLlmConfig, loadLlmConfig } = await import("../llm.ts"); - saveLlmConfig({ model: "llama3:8b" }); - const config = loadLlmConfig(); - expect(config.model).toBe("llama3:8b"); - }); -}); - -describe("llmPrompt", () => { - test("calls Ollama /api/chat with correct payload", async () => { - const { saveLlmConfig } = await import("../llm.ts"); - saveLlmConfig({ - url: "http://localhost:12345", - model: "test-model", - timeoutMs: 5000, - }); - - // Mock fetch - const origFetch = globalThis.fetch; - let capturedBody: string | null = null; - globalThis.fetch = mock(async (url, init) => { - capturedBody = init?.body as string; - return new Response( - JSON.stringify({ message: { content: "hello from llm" } }), - { status: 200 }, - ); - }) as unknown as typeof fetch; - - try { - const { llmPrompt } = await import("../llm.ts"); - const result = await llmPrompt("you are helpful", "say hi", { maxTokens: 10 }); - expect(result).toBe("hello from llm"); - expect(capturedBody).not.toBeNull(); - const parsed = JSON.parse(capturedBody!); - expect(parsed.model).toBe("test-model"); - expect(parsed.messages[0].role).toBe("system"); - expect(parsed.messages[0].content).toBe("you are helpful"); - expect(parsed.messages[1].role).toBe("user"); - expect(parsed.messages[1].content).toBe("say hi"); - expect(parsed.stream).toBe(false); - expect(parsed.options.num_predict).toBe(10); - } finally { - globalThis.fetch = origFetch; - } - }); - - test("throws LlmUnavailableError on connection refused", async () => { - const { saveLlmConfig } = await import("../llm.ts"); - saveLlmConfig({ url: "http://localhost:12346", model: "x", timeoutMs: 500 }); - - // Re-import to pick up new config - const { llmPrompt } = await import("../llm.ts"); - await expect(llmPrompt("s", "u")).rejects.toThrow("LLM unavailable"); - }); - - test("throws LlmUnavailableError on timeout", async () => { - const { saveLlmConfig } = await import("../llm.ts"); - saveLlmConfig({ url: "http://localhost:12346", model: "x", timeoutMs: 100 }); - - const { llmPrompt } = await import("../llm.ts"); - await expect(llmPrompt("s", "u")).rejects.toThrow("LLM unavailable"); - }); - - test("throws LlmEmptyResponseError when model returns empty", async () => { - const { saveLlmConfig } = await import("../llm.ts"); - saveLlmConfig({ url: "http://localhost:12345", model: "x", timeoutMs: 5000 }); - - const origFetch = globalThis.fetch; - globalThis.fetch = mock(async () => - new Response(JSON.stringify({ message: { content: "" } }), { status: 200 }), - ) as unknown as typeof fetch; - try { - const { llmPrompt } = await import("../llm.ts"); - await expect(llmPrompt("s", "u")).rejects.toThrow("empty response"); - } finally { - globalThis.fetch = origFetch; - } - }); -}); - -describe("listOllamaModels", () => { - test("returns model list from Ollama /api/tags", async () => { - const origFetch = globalThis.fetch; - globalThis.fetch = mock(async (url) => { - if (String(url).includes("/api/tags")) { - return new Response( - JSON.stringify({ - models: [ - { name: "qwen3:4b", size: 2411728896 }, - { name: "codellama:7b", size: 3945367808 }, - ], - }), - { status: 200 }, - ); - } - return new Response("not found", { status: 404 }); - }) as unknown as typeof fetch; - try { - const { listOllamaModels } = await import("../llm.ts"); - const models = await listOllamaModels("http://localhost:11434"); - expect(models).toHaveLength(2); - expect(models[0]!.name).toBe("qwen3:4b"); - expect(models[1]!.name).toBe("codellama:7b"); - } finally { - globalThis.fetch = origFetch; - } - }); - - test("throws LlmUnavailableError when Ollama is down", async () => { - const { listOllamaModels } = await import("../llm.ts"); - await expect( - listOllamaModels("http://localhost:12346"), - ).rejects.toThrow("LLM unavailable"); - }); -}); diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 49a9b96b..cdc380fc 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -688,13 +688,6 @@ export const TREE: Record = { { name: "Target", type: "select", hint: "Omit to be prompted interactively", options: [{ value: "dev", label: "dev", hint: "Run from local source" }, { value: "prod", label: "prod", hint: "Run the installed binary (from mattstack.app)" }] }, ], }, - llm: { - description: "Configure local LLM for branch naming and other features", - module: "./commands/settings.ts", - fn: "configureLlm", - requiresTTY: true, - args: [], - }, }, }, diff --git a/lib/daemon/__tests__/endpoint-handlers.test.ts b/lib/daemon/__tests__/endpoint-handlers.test.ts index 340920b8..c1296618 100644 --- a/lib/daemon/__tests__/endpoint-handlers.test.ts +++ b/lib/daemon/__tests__/endpoint-handlers.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, test, beforeEach } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { execSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import pino from "pino"; @@ -11,29 +11,59 @@ import { createEndpointHandlers, releaseEndpointsForWorktree } from "../handlers import type { HandlerContext } from "../handlers/types.ts"; /** - * The daemon's repo index, as the handlers see it. Mutable so a test can - * register a repo path before claiming: the name→path→identity hop is what - * makes the settings stores' `repos.` sections reachable (RT-47). - * A repo absent from here derives a null identity, which is the legacy-only - * path every other test in this file rides. + * The daemon's repo index, as the handlers see it. Reset per test alongside + * HOME (beforeEach): the name→path→identity hop is what makes the settings + * stores' `repos.` sections reachable (RT-47). */ -const repoIndex: RepoIndex = {}; -const ctx = { log: pino({ level: "silent" }), repoIndex: () => repoIndex } as unknown as HandlerContext; +let repoIndex: RepoIndex = {}; +let ctx: HandlerContext; const fakeProbes = async () => ({ listeners: new Set(), pidAlive: () => true, canBind: () => true }); -function declareRoles(repo: string): void { - mkdirSync(repoDataDir(repo), { recursive: true }); - writeFileSync(join(repoDataDir(repo), "config.json"), JSON.stringify({ - roles: { - backend: { pool: [{ from: 10400, to: 10402 }], env: { PORT: "${port}" } }, - adjuster: { pool: [4001, 5001], needs: ["backend"] }, - }, - })); +const DEFAULT_ROLES = { + backend: { pool: [{ from: 10400, to: 10402 }], env: { PORT: "${port}" } }, + adjuster: { pool: [4001, 5001], needs: ["backend"] }, +}; + +/** + * Registers a real git repo (with a fake-but-derivable remote) for `repoName` + * and declares `roles` for it in the team store, keyed by the identity that + * remote normalizes to — the only path a claim can reach a repo's roles + * through now that the legacy per-repo config.json rung is gone. + */ +function declareRoles(repoName: string, roles: unknown = DEFAULT_ROLES): void { + const repoPath = mkdtempSync(join(tmpdir(), `rt-endpoint-${repoName}-`)); + execSync("git init -q", { cwd: repoPath }); + execSync(`git remote add origin git@rttest:${repoName}.git`, { cwd: repoPath }); + repoIndex[repoName] = repoPath; + + const identity = `rttest/${repoName}`; + const store = teamSettingsPath("claimview"); + mkdirSync(dirname(store), { recursive: true }); + let existing: Record = {}; + try { + existing = JSON.parse(readFileSync(store, "utf8")); + } catch { /* absent or malformed — start fresh */ } + const repos = { ...(existing.repos as Record ?? {}), [identity]: { "rt.roles": roles } }; + writeFileSync(store, JSON.stringify({ ...existing, repos })); } describe("endpoint handlers", () => { + const origHome = process.env.HOME; + let home: string; let handlers: ReturnType; - beforeEach(() => { handlers = createEndpointHandlers(ctx, { probes: fakeProbes }); }); + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-endpoint-handlers-"))); + process.env.HOME = home; + repoIndex = {}; + ctx = { log: pino({ level: "silent" }), repoIndex: () => repoIndex } as unknown as HandlerContext; + handlers = createEndpointHandlers(ctx, { probes: fakeProbes }); + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); test("claim allocates, lookup sees it, refs pull the needed role into existence", async () => { declareRoles("repoA"); @@ -47,60 +77,21 @@ describe("endpoint handlers", () => { }); /** - * The path that breaks `pnpm start` if it regresses: roles declared ONLY in - * a settings store, reached through the repo index → `deriveRepoIdentity` → - * `repos.` chain. Every other claim test here rides the legacy - * per-repo config.json, so without this one the whole store side of the - * daemon claim handler is untested. - * - * A real `git init` + remote, not a stubbed derivation: the identity hop is - * an actual `git config --get remote.origin.url` capture, and faking it - * would skip exactly the normalization this test is here to prove. - * - * This is the ONE test in the file that writes a settings STORE, so it runs - * under its own HOME (and drops its repo-index entry afterwards). The - * bunfig preload gives the whole run a single shared temp HOME; a team store - * written into that shared tree would make `listTeams()` non-empty for every - * later suite in the process, which is exactly the cross-suite leak the - * per-test-HOME rule exists to prevent. + * A repo with no index entry derives a null identity, so its store section + * (if any) is unreachable — the honest degrade for an unregistered repo. */ test("claim resolves roles from a settings store section (repoIndex → identity → repos.)", async () => { - const priorHome = process.env.HOME; - process.env.HOME = mkdtempSync(join(tmpdir(), "rt-endpoint-store-home-")); - try { - const repoPath = mkdtempSync(join(tmpdir(), "rt-endpoint-store-repo-")); - execSync("git init -q", { cwd: repoPath }); - execSync("git remote add origin git@gitlab.com:fake/store-claim-repo.git", { cwd: repoPath }); - repoIndex["repoStore"] = repoPath; - - // No repos/repoStore/config.json anywhere — the legacy rung is empty, so - // a port can only come from the store. - const store = teamSettingsPath("claimview"); - mkdirSync(dirname(store), { recursive: true }); - writeFileSync(store, JSON.stringify({ - repos: { - "gitlab.com/fake/store-claim-repo": { - "rt.roles": { web: { pool: [{ from: 10600, to: 10602 }], env: { PORT: "${port}" } } }, - }, - }, - })); - - const r = await handlers["endpoint:claim"]({ repo: "repoStore", worktree: "/wt/store", role: "web", pid: 11 }); - expect(r.ok).toBe(true); - expect(r.data.port).toBe(10600); - - const lk = await handlers["endpoint:lookup"]({ repo: "repoStore", worktree: "/wt/store", role: "web" }); - expect(lk.data).toMatchObject({ claimed: true, port: 10600 }); - - // …and a repo with no index entry still derives a null identity, so this - // store's repo section cannot leak into the legacy-rung tests around it. - const other = await handlers["endpoint:claim"]({ repo: "repoUnindexed", worktree: "/wt/store", role: "web" }); - expect(other).toMatchObject({ ok: false, error: 'role "web" is not declared for repo "repoUnindexed"' }); - } finally { - delete repoIndex["repoStore"]; - if (priorHome === undefined) delete process.env.HOME; - else process.env.HOME = priorHome; - } + declareRoles("repoStore", { web: { pool: [{ from: 10600, to: 10602 }], env: { PORT: "${port}" } } }); + + const r = await handlers["endpoint:claim"]({ repo: "repoStore", worktree: "/wt/store", role: "web", pid: 11 }); + expect(r.ok).toBe(true); + expect(r.data.port).toBe(10600); + + const lk = await handlers["endpoint:lookup"]({ repo: "repoStore", worktree: "/wt/store", role: "web" }); + expect(lk.data).toMatchObject({ claimed: true, port: 10600 }); + + const other = await handlers["endpoint:claim"]({ repo: "repoUnindexed", worktree: "/wt/store", role: "web" }); + expect(other).toMatchObject({ ok: false, error: 'role "web" is not declared for repo "repoUnindexed"' }); }); test("unknown role and unknown repo fail with named errors", async () => { diff --git a/lib/daemon/__tests__/repo-tracking.test.ts b/lib/daemon/__tests__/repo-tracking.test.ts index ee46905d..a1171042 100644 --- a/lib/daemon/__tests__/repo-tracking.test.ts +++ b/lib/daemon/__tests__/repo-tracking.test.ts @@ -9,7 +9,7 @@ import { runCapture } from "../../subprocess.ts"; import { clearIdentityMemo } from "../../settings/identity.ts"; import { loadRepoTracking, loadMachineRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, - primeTeamTrackingIdentityMap, + primeTeamTrackingIdentityMap, teamNamesIdentity, } from "../../repo-tracking.ts"; function writeStore(file: string, obj: unknown): void { @@ -218,6 +218,42 @@ describe("loadRepoTracking merges mattstack.tracking team intent", () => { }); }); +describe("teamNamesIdentity", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-tracking-teamnames-"))); + process.env.HOME = home; + seedTeam(); + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + test("true when mattstack.tracking.repos names the identity, regardless of the value's shape", () => { + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + + expect(teamNamesIdentity("gitlab.com/acme/foo")).toBe(true); + }); + + test("false when no mattstack.tracking value is authored at all", () => { + expect(teamNamesIdentity("gitlab.com/acme/foo")).toBe(false); + }); + + test("false for an identity the team layer never named", () => { + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + + expect(teamNamesIdentity("gitlab.com/acme/bar")).toBe(false); + }); +}); + describe("loadMachineRepoTracking — the machine-only read (no team merge)", () => { const origHome = process.env.HOME; let home: string; @@ -272,6 +308,45 @@ describe("loadMachineRepoTracking — the machine-only read (no team merge)", () const savedAfterOff = getSetting>("rt.repoTracking").value; expect(savedAfterOff).toEqual({}); }); + + test("the rider: turning a team-tracked repo off writes an explicit {mode:\"off\"} marker, not a delete — and the merge stays off", () => { + setSetting("rt.repoTracking", { foo: { mode: "live", caches: ["branches"] } }, "machine"); + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + const identityMap = { "gitlab.com/acme/foo": "foo" }; + + // Sanity: team still declares intent for foo. + expect(teamNamesIdentity("gitlab.com/acme/foo")).toBe(true); + + // untrack foo off, team-named — the fix: pass it as an offMarker instead + // of deleting outright. + const tracking = loadMachineRepoTracking(); + delete tracking.foo; + saveRepoTracking(tracking, ["foo"]); + + const saved = getSetting>("rt.repoTracking").value; + expect(saved.foo).toEqual({ mode: "off" }); + + // The merge must NOT resurrect team intent for foo now that the raw + // machine map names it — this is the bug the rider fixes. + const merged = loadRepoTracking({ identityMap }); + expect(merged.foo).toBeUndefined(); + expect(grants(merged, "foo").mode).toBe("off"); + }); + + test("turning a NON-team-tracked repo off still deletes outright (no marker planted)", () => { + setSetting("rt.repoTracking", { existing: { mode: "poll", caches: ["branches"] } }, "machine"); + // No mattstack.tracking value at all — teamNamesIdentity is false for any identity. + expect(teamNamesIdentity("gitlab.com/acme/existing")).toBe(false); + + const tracking = loadMachineRepoTracking(); + delete tracking.existing; + saveRepoTracking(tracking, []); // no offMarkers — the untracked-by-team path + + const saved = getSetting>("rt.repoTracking").value; + expect(saved).toEqual({}); + }); }); describe("primeTeamTrackingIdentityMap", () => { diff --git a/lib/daemon/__tests__/settings-handlers.test.ts b/lib/daemon/__tests__/settings-handlers.test.ts index 1972a1ef..7bb29a1d 100644 --- a/lib/daemon/__tests__/settings-handlers.test.ts +++ b/lib/daemon/__tests__/settings-handlers.test.ts @@ -96,13 +96,13 @@ describe("settings handlers", () => { describe("settings:list", () => { test("labels a migrated:false key and carries its resolved value", async () => { - write(userSettingsPath(), { "rt.llm": { provider: "ollama" } }); + write(userSettingsPath(), { "rt.hooks": { enabled: true } }); const r = await handlers["settings:list"]({}); expect(r.ok).toBe(true); - const llm = r.data.settings.find((s: any) => s.key === "rt.llm"); - expect(llm).toMatchObject({ migrated: false, value: { provider: "ollama" } }); + const hooks = r.data.settings.find((s: any) => s.key === "rt.hooks"); + expect(hooks).toMatchObject({ migrated: false, value: { enabled: true } }); }); test("labels a migrated:true key as such and includes the registry default when nothing is authored", async () => { diff --git a/lib/daemon/__tests__/worktree-handlers.test.ts b/lib/daemon/__tests__/worktree-handlers.test.ts index e34edb18..89c8d232 100644 --- a/lib/daemon/__tests__/worktree-handlers.test.ts +++ b/lib/daemon/__tests__/worktree-handlers.test.ts @@ -9,12 +9,13 @@ import { describe, test, expect, beforeEach } from "bun:test"; import { execSync } from "child_process"; -import { existsSync, mkdtempSync, realpathSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { basename, join } from "path"; import type { Logger } from "pino"; import { writeJson } from "../../json-store.ts"; -import { repoDataDir, rtDir } from "../../rt-paths.ts"; +import { machineSettingsPath, repoDataDir, rtDir } from "../../rt-paths.ts"; +import { deriveRepoIdentity } from "../../settings/identity.ts"; import { loadRegistry, saveRegistry, type TreeRecord } from "../../worktree/registry.ts"; import { tryLockTree } from "../../worktree/locks.ts"; import { branchExistsLocalAsync, currentBranchAsync, headSha } from "../../worktree/git-async.ts"; @@ -22,6 +23,45 @@ import { createWorktreeHandlers, isClaimable } from "../handlers/worktree.ts"; import type { HandlerContext, HandlerMap } from "../handlers/types.ts"; import { fakeStore } from "./fake-cache-store.ts"; +function readMachineStore(): Record { + try { + return JSON.parse(readFileSync(machineSettingsPath(), "utf8")); + } catch { + return {}; + } +} + +function writeMachineStore(obj: Record): void { + mkdirSync(join(machineSettingsPath(), ".."), { recursive: true }); + writeFileSync(machineSettingsPath(), JSON.stringify(obj)); +} + +/** + * Gives `repoPath` a resolvable settings identity, pinning its (local + * bare-clone) origin via the machine store's `rt.repoIdentityOverrides` when + * it doesn't itself normalize — exactly the fork/local-remote mechanism + * production uses. + */ +async function ensureIdentity(repoPath: string, repoName: string): Promise { + const remote = execSync("git config --get remote.origin.url", { cwd: repoPath, encoding: "utf8" }).trim(); + const direct = await deriveRepoIdentity(repoPath); + if (direct) return direct; + + const identity = `rttest.local/${repoName}`; + const store = readMachineStore(); + const overrides = { ...(store["rt.repoIdentityOverrides"] as Record ?? {}), [remote]: identity }; + writeMachineStore({ ...store, "rt.repoIdentityOverrides": overrides }); + return identity; +} + +/** Seeds `rt.worktrees` for `repoPath` in the machine store — the store-only replacement for the old per-repo config.json fixture. */ +async function declareWorktrees(repoPath: string, repoName: string, declared: unknown): Promise { + const identity = await ensureIdentity(repoPath, repoName); + const store = readMachineStore(); + const repos = { ...(store.repos as Record ?? {}), [identity]: { "rt.worktrees": declared } }; + writeMachineStore({ ...store, repos }); +} + function sh(cmd: string, cwd?: string): string { return execSync(cmd, { cwd, shell: "/bin/zsh", encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); } @@ -320,9 +360,7 @@ describe("worktree:provision", () => { test("a ready step that fails after the claim hands the tree over flagged", async () => { const repo = makeRepo(); - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { ready: [{ run: "exit 3" }] }, - }); + await declareWorktrees(repo, repoName, { ready: [{ run: "exit 3" }] }); seedOnDeck(repo, repoName, "alpha", new Date().toISOString()); const { h } = makeHandlers({ [repoName]: repo }); diff --git a/lib/daemon/__tests__/worktree-reconciler.test.ts b/lib/daemon/__tests__/worktree-reconciler.test.ts index 40c4cfb7..989abbde 100644 --- a/lib/daemon/__tests__/worktree-reconciler.test.ts +++ b/lib/daemon/__tests__/worktree-reconciler.test.ts @@ -1,11 +1,12 @@ import { describe, test, expect, beforeEach } from "bun:test"; import { execSync } from "child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { basename, join } from "path"; import type { Logger } from "pino"; import { readJson, writeJson } from "../../json-store.ts"; -import { repoDataDir, rtDir } from "../../rt-paths.ts"; +import { machineSettingsPath, rtDir } from "../../rt-paths.ts"; +import { deriveRepoIdentity } from "../../settings/identity.ts"; import { findByPath, loadRegistry, saveRegistry, type TreeRecord } from "../../worktree/registry.ts"; import { branchExistsLocalAsync, @@ -38,6 +39,54 @@ function addBareOrigin(repo: string): void { ); } +function readMachineStore(): Record { + try { + return JSON.parse(readFileSync(machineSettingsPath(), "utf8")); + } catch { + return {}; + } +} + +function writeMachineStore(obj: Record): void { + mkdirSync(join(machineSettingsPath(), ".."), { recursive: true }); + writeFileSync(machineSettingsPath(), JSON.stringify(obj)); +} + +/** + * Gives `repoPath` a resolvable settings identity: reuses its origin if it + * has one (adding a throwaway one if not) and, when the origin doesn't + * itself normalize (this file's bare-clone fixtures are local filesystem + * paths), pins it via the machine store's `rt.repoIdentityOverrides` — + * exactly the fork/local-remote mechanism production uses. + */ +async function ensureIdentity(repoPath: string, repoName: string): Promise { + let remote: string | null = null; + try { + remote = execSync("git config --get remote.origin.url", { cwd: repoPath, encoding: "utf8" }).trim() || null; + } catch { /* no origin configured yet */ } + if (!remote) { + remote = `git@rttest:${repoName}.git`; + execSync(`git remote add origin ${remote}`, { cwd: repoPath, shell: "/bin/zsh" }); + } + + const direct = await deriveRepoIdentity(repoPath); + if (direct) return direct; + + const identity = `rttest.local/${repoName}`; + const store = readMachineStore(); + const overrides = { ...(store["rt.repoIdentityOverrides"] as Record ?? {}), [remote]: identity }; + writeMachineStore({ ...store, "rt.repoIdentityOverrides": overrides }); + return identity; +} + +/** Seeds `rt.worktrees` for `repoPath` in the machine store — the store-only replacement for the old per-repo config.json fixture. */ +async function declareWorktrees(repoPath: string, repoName: string, declared: unknown): Promise { + const identity = await ensureIdentity(repoPath, repoName); + const store = readMachineStore(); + const repos = { ...(store.repos as Record ?? {}), [identity]: { "rt.worktrees": declared } }; + writeMachineStore({ ...store, repos }); +} + function fakeLog(): Logger { return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } as unknown as Logger; } @@ -102,9 +151,7 @@ describe("reconcileRepoRegistry", () => { // Reusing the same name must succeed now that `git worktree prune` ran; // without it git still holds the stale worktree registration at manualPath. - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { namePool: [name] }, - }); + await declareWorktrees(repo, repoName, { namePool: [name] }); const result = await createTree({ repoName, @@ -285,7 +332,7 @@ describe("createWorktreeReconciler", () => { execSync(`git worktree add -b manual-branch ${manualPath}`, { cwd: repo, shell: "/bin/zsh" }); // Opt this repo into worktree management so runOnce picks it up even // though its registry starts empty. - writeJson(join(repoDataDir(repoName), "config.json"), { worktrees: {} }); + await declareWorktrees(repo, repoName, {}); const untouchedRepo = makeRepo(); @@ -334,7 +381,7 @@ describe("createWorktreeReconciler", () => { }); test("kick fires runOnce without awaiting and coalesces overlapping calls", async () => { - writeJson(join(repoDataDir(repoName), "config.json"), { worktrees: {} }); + await declareWorktrees(repo, repoName, {}); const reconciler = createWorktreeReconciler({ cache: { entries: {} }, @@ -350,7 +397,7 @@ describe("createWorktreeReconciler", () => { // rather than a blind sleep or a registry-state proxy — a pass that's // still running (even past its registry write) but hasn't returned yet // would otherwise dangle past this test, and since every internal path - // (repoDataDir, rtDir, ...) resolves HOME dynamically at call time, that + // (machineSettingsPath, rtDir, ...) resolves HOME dynamically at call time, that // stale pass can read/write into a LATER test's HOME once that test's // beforeEach repoints the (shared, global) env var. await waitFor(() => !reconciler.passInFlight(), 5000); @@ -362,9 +409,7 @@ describe("createWorktreeReconciler", () => { // What a daemon crash mid-reap leaves behind: the dispose renamed the tree // but its detached `rm -rf` never finished (or never started). const configuredRoot = realpathSync(mkdtempSync(join(tmpdir(), "rtrecon-root-"))); - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { root: configuredRoot }, - }); + await declareWorktrees(repo, repoName, { root: configuredRoot }); const defaultRootTrash = join(repo, ".worktrees", ".trash-hotel-1700000000000"); const configuredTrash = join(configuredRoot, ".trash-india-1700000000001"); @@ -399,9 +444,7 @@ describe("createWorktreeReconciler", () => { // impossible regardless of any other test's timing. const disabledRepoName = "acme-disabled"; addBareOrigin(repo); - writeJson(join(repoDataDir(disabledRepoName), "config.json"), { - worktrees: { onDeck: 1, root: join(repo, ".worktrees") }, - }); + await declareWorktrees(repo, disabledRepoName, { onDeck: 1, root: join(repo, ".worktrees") }); writeJson(join(rtDir(), "worktrees.json"), { enabled: false, killProcesses: false }); // Advance origin so a freshen (if it ran) would have something to do. @@ -794,7 +837,7 @@ describe("merge reactor (detectTransitions)", () => { test("runOnce runs the reactor after the reconcile pass", async () => { const rec = ephemeralTree("golf", "feat-golf"); - writeJson(join(repoDataDir(repoName), "config.json"), { worktrees: {} }); + await declareWorktrees(repo, repoName, {}); const cache = { entries: mrCache("feat-golf", "opened") as Record }; const reconciler = createWorktreeReconciler({ @@ -843,8 +886,8 @@ describe("freshen", () => { }); test("idle main behind origin gets ff'd; readyStamp advances only when a triggered step ran; worktree:freshened emitted", async () => { - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { ready: [{ run: "touch triggered.marker", when: "changed:*.txt" }] }, + await declareWorktrees(repo, repoName, { + ready: [{ run: "touch triggered.marker", when: "changed:*.txt" }], }); // Tracked and pushed so the ready step's own marker file never shows up as // untracked dirt on a later pass (which would otherwise flip "idle main" @@ -886,9 +929,7 @@ describe("freshen", () => { }); test("on-deck tree ff's its on-deck branch", async () => { - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { onDeck: 1, root: join(repo, ".worktrees") }, - }); + await declareWorktrees(repo, repoName, { onDeck: 1, root: join(repo, ".worktrees") }); const created = await createTree({ repoName, @@ -912,8 +953,7 @@ describe("freshen", () => { }); test("a failing ready step sets nextRetryAt; the next immediate pass skips the tree", async () => { - const cfgPath = join(repoDataDir(repoName), "config.json"); - writeJson(cfgPath, { worktrees: { onDeck: 1, root: join(repo, ".worktrees") } }); + await declareWorktrees(repo, repoName, { onDeck: 1, root: join(repo, ".worktrees") }); const created = await createTree({ repoName, @@ -926,8 +966,8 @@ describe("freshen", () => { const treePath = created.tree.path; // Reconfigure with a step that always fails once triggered. - writeJson(cfgPath, { - worktrees: { onDeck: 1, root: join(repo, ".worktrees"), ready: [{ run: "exit 1", when: "changed:*.txt" }] }, + await declareWorktrees(repo, repoName, { + onDeck: 1, root: join(repo, ".worktrees"), ready: [{ run: "exit 1", when: "changed:*.txt" }], }); const clone = cloneOrigin(repo); @@ -972,8 +1012,8 @@ describe("freshen", () => { }); test("a candidate claimed mid-pass is revalidated under the lock and skipped", async () => { - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "sleep 1", when: "changed:*.txt" }] }, + await declareWorktrees(repo, repoName, { + onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "sleep 1", when: "changed:*.txt" }], }); // Two on-deck trees. `freshenRepo` snapshots the whole registry once and @@ -1041,9 +1081,7 @@ describe("replenish / shrink", () => { }); test("onDeck=2 with an empty registry creates 2, serially", async () => { - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { onDeck: 2, root: join(repo, ".worktrees") }, - }); + await declareWorktrees(repo, repoName, { onDeck: 2, root: join(repo, ".worktrees") }); await __test__.replenishAndShrink( { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, @@ -1057,9 +1095,7 @@ describe("replenish / shrink", () => { }); test("an all-failing pool does not overshoot the onDeck cap", async () => { - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "exit 1" }] }, - }); + await declareWorktrees(repo, repoName, { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "exit 1" }] }); const warns: string[] = []; const log = { @@ -1084,9 +1120,7 @@ describe("replenish / shrink", () => { }); test("a failed create backs off, and the next pass skips replenish for that repo", async () => { - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "exit 1" }] }, - }); + await declareWorktrees(repo, repoName, { onDeck: 2, root: join(repo, ".worktrees"), ready: [{ run: "exit 1" }] }); const warns: string[] = []; const log = { @@ -1112,9 +1146,7 @@ describe("replenish / shrink", () => { }); test("a successful create clears the backoff", async () => { - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { onDeck: 1, root: join(repo, ".worktrees") }, - }); + await declareWorktrees(repo, repoName, { onDeck: 1, root: join(repo, ".worktrees") }); // An expired backoff from earlier failures: the pass runs, and success wipes it. __test__.createBackoff.set(repoName, { failures: 3, @@ -1140,8 +1172,7 @@ describe("replenish / shrink", () => { }); test("lowering onDeck disposes the stalest ready entry", async () => { - const cfgPath = join(repoDataDir(repoName), "config.json"); - writeJson(cfgPath, { worktrees: { onDeck: 2, root: join(repo, ".worktrees") } }); + await declareWorktrees(repo, repoName, { onDeck: 2, root: join(repo, ".worktrees") }); await __test__.replenishAndShrink( { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, @@ -1164,7 +1195,7 @@ describe("replenish / shrink", () => { }), ); - writeJson(cfgPath, { worktrees: { onDeck: 1, root: join(repo, ".worktrees") } }); + await declareWorktrees(repo, repoName, { onDeck: 1, root: join(repo, ".worktrees") }); await __test__.replenishAndShrink( { repoName, repoPath: repo, emit: () => {}, log: fakeLog() }, new Map(), @@ -1200,9 +1231,7 @@ describe("detached trigger / latency", () => { // timing assertions below without changing what's under test here. writeFileSync(join(repo, "wip.txt"), "not idle\n"); - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { onDeck: 1, root: join(repo, ".worktrees"), ready: [{ run: "sleep 3" }] }, - }); + await declareWorktrees(repo, repoName, { onDeck: 1, root: join(repo, ".worktrees"), ready: [{ run: "sleep 3" }] }); const events: Array<{ type: string; data: any }> = []; const reconciler = createWorktreeReconciler({ diff --git a/lib/endpoint/__tests__/config.test.ts b/lib/endpoint/__tests__/config.test.ts index 7cf4cecb..55cd44d3 100644 --- a/lib/endpoint/__tests__/config.test.ts +++ b/lib/endpoint/__tests__/config.test.ts @@ -2,17 +2,16 @@ * lib/endpoint/config.ts — the resolver-backed endpoint reader. * * Every test re-points HOME to a fresh temp dir (the lib/settings/resolve.test.ts - * pattern). That is load-bearing here, not hygiene: settings STORE files are - * process-global state resolved through call-time HOME, so a store fixture - * written by any other suite sharing the preload HOME would silently outrank - * the legacy config.json these tests assert on. + * pattern): settings STORE files are process-global state resolved through + * call-time HOME, so a store fixture written by any other suite sharing the + * preload HOME would silently outrank these tests' own fixtures. */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { machineSettingsPath, repoDataDir, teamSettingsPath, teamsDir, userSettingsPath } from "../../rt-paths.ts"; +import { machineSettingsPath, teamSettingsPath, teamsDir, userSettingsPath } from "../../rt-paths.ts"; import { loadEndpointConfig } from "../config.ts"; const IDENTITY = "gitlab.com/fake/endpoint-repo"; @@ -42,32 +41,22 @@ describe("loadEndpointConfig", () => { writeFileSync(file, JSON.stringify(obj, null, 2)); } - function writeRepoConfig(repo: string, obj: unknown): void { - const dir = repoDataDir(repo); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "config.json"), JSON.stringify(obj)); - } - /** Registers `repo` → a path in repos.json, which is where `${repoRoot}` comes from. */ function writeRepoIndex(index: Record): void { write(join(home, ".mattstack", "rt", "repos.json"), index); } - const legacy = (repoName: string) => loadEndpointConfig({ repoIdentity: null, repoName }); - - // ─── legacy rung: the pre-migration behaviour, unchanged ─────────────────── - - test("missing file yields empty config", () => { - const cfg = legacy("no-such-repo"); + test("missing everything yields empty config", () => { + const cfg = loadEndpointConfig({ repoIdentity: null, repoName: "no-such-repo" }); expect(cfg.roles).toEqual({}); expect(cfg.intercepts).toEqual([]); }); test("flattens ranges, sorts and dedupes pools, applies defaults", () => { - writeRepoConfig("r1", { - roles: { backend: { pool: [{ from: 10402, to: 10404 }, 10400, 10400] } }, + write(machineSettingsPath(), { + repos: { [IDENTITY]: { "rt.roles": { backend: { pool: [{ from: 10402, to: 10404 }, 10400, 10400] } } } }, }); - const cfg = legacy("r1"); + const cfg = loadEndpointConfig({ repoIdentity: IDENTITY, repoName: "r1" }); expect(cfg.roles.backend!.pool).toEqual([10400, 10402, 10403, 10404]); expect(cfg.roles.backend!.needs).toEqual([]); expect(cfg.roles.backend!.preserveEnv).toEqual([]); @@ -75,23 +64,22 @@ describe("loadEndpointConfig", () => { }); test("drops malformed entries instead of throwing", () => { - writeRepoConfig("r2", { - roles: { ok: { fixedPort: 4002 }, bad: "nope" }, - intercepts: [{ command: "doppler", matches: [{ cwdGlob: "apps/x/**", role: "ok" }] }, { matches: [] }], + write(machineSettingsPath(), { + repos: { + [IDENTITY]: { + "rt.roles": { ok: { fixedPort: 4002 }, bad: "nope" }, + "rt.intercepts": [{ command: "doppler", matches: [{ cwdGlob: "apps/x/**", role: "ok" }] }, { matches: [] }], + }, + }, }); - const cfg = legacy("r2"); + const cfg = loadEndpointConfig({ repoIdentity: IDENTITY, repoName: "r2" }); expect(Object.keys(cfg.roles)).toEqual(["ok"]); expect(cfg.roles.ok!.fixedPort).toBe(4002); expect(cfg.intercepts).toHaveLength(1); expect(cfg.intercepts[0]!.command).toBe("doppler"); }); - test("coexists with other keys in the same document (worktrees, setup)", () => { - writeRepoConfig("r3", { setup: [], worktrees: { onDeck: 2 }, roles: { web: { pool: [3000] } } }); - expect(legacy("r3").roles.web!.pool).toEqual([3000]); - }); - - test("with no store files at all, a full legacy config resolves byte-identically", () => { + test("with a full store block, resolves byte-identically to the authored shape", () => { const authored = { roles: { backend: { @@ -113,12 +101,14 @@ describe("loadEndpointConfig", () => { }, ], }; - writeRepoConfig("r-full", authored); + write(machineSettingsPath(), { + repos: { [IDENTITY]: { "rt.roles": authored.roles, "rt.intercepts": authored.intercepts } }, + }); - // Identical to what the pre-resolver reader produced: the domain templates - // (${port}, ${roles.*}, ${envKeys}) pass through unexpanded, and the legacy - // file's absolute hook path is untouched (path literals are legal there). - expect(legacy("r-full")).toEqual({ + // The domain templates (${port}, ${roles.*}, ${envKeys}) pass through + // unexpanded, and the machine store's absolute hook path is untouched + // (path literals are legal there). + expect(loadEndpointConfig({ repoIdentity: IDENTITY, repoName: "r-full" })).toEqual({ roles: { backend: { pool: [10400, 10401], @@ -185,26 +175,30 @@ describe("loadEndpointConfig", () => { }); test("rt.roles deep-merges across scopes; rt.intercepts replaces atomically", () => { - writeRepoConfig("r-merge", { - roles: { backend: { pool: [1000], preserveEnv: ["LEGACY_ONLY"] }, legacyOnly: { pool: [9000] } }, - intercepts: [{ command: "legacy-cmd", matches: [{ cwdGlob: ".", role: "legacyOnly" }] }], - }); write(teamSettingsPath(TEAM), { - repos: { [IDENTITY]: { "rt.roles": { backend: { pool: [{ from: 2000, to: 2000 }] } } } }, + repos: { + [IDENTITY]: { + "rt.roles": { backend: { pool: [1000], preserveEnv: ["TEAM_ONLY"] }, teamOnly: { pool: [9000] } }, + "rt.intercepts": [{ command: "team-cmd", matches: [{ cwdGlob: ".", role: "teamOnly" }] }], + }, + }, }); write(userSettingsPath(), { + repos: { [IDENTITY]: { "rt.roles": { backend: { pool: [{ from: 2000, to: 2000 }] } } } }, + }); + write(machineSettingsPath(), { repos: { [IDENTITY]: { - "rt.intercepts": [{ command: "user-cmd", matches: [{ cwdGlob: ".", role: "backend" }] }], + "rt.intercepts": [{ command: "machine-cmd", matches: [{ cwdGlob: ".", role: "backend" }] }], }, }, }); const cfg = loadEndpointConfig({ repoIdentity: IDENTITY, repoName: "r-merge" }); - expect(cfg.roles.backend!.pool).toEqual([2000]); // team wins the pool leaf - expect(cfg.roles.backend!.preserveEnv).toEqual(["LEGACY_ONLY"]); // legacy leaf survives - expect(cfg.roles.legacyOnly!.pool).toEqual([9000]); // legacy-only role survives - expect(cfg.intercepts.map((i) => i.command)).toEqual(["user-cmd"]); // replace, not splice + expect(cfg.roles.backend!.pool).toEqual([2000]); // user wins the pool leaf + expect(cfg.roles.backend!.preserveEnv).toEqual(["TEAM_ONLY"]); // team-only leaf survives + expect(cfg.roles.teamOnly!.pool).toEqual([9000]); // team-only role survives + expect(cfg.intercepts.map((i) => i.command)).toEqual(["machine-cmd"]); // replace, not splice }); test("${repoRoot} expands from the repo index; an unresolvable one degrades to empty, never throws", () => { @@ -229,11 +223,10 @@ describe("loadEndpointConfig", () => { expect(warnSpy.mock.calls.flat().join(" ")).toContain("repoRoot"); }); - test("a null identity makes repo store sections unreachable (legacy still answers)", () => { - writeRepoConfig("r-null", { roles: { web: { pool: [3000] } } }); + test("a null identity makes repo store sections unreachable, even when one is authored", () => { write(teamSettingsPath(TEAM), { repos: { [IDENTITY]: { "rt.roles": { web: { pool: [{ from: 4000, to: 4000 }] } } } }, }); - expect(loadEndpointConfig({ repoIdentity: null, repoName: "r-null" }).roles.web!.pool).toEqual([3000]); + expect(loadEndpointConfig({ repoIdentity: null, repoName: "r-null" }).roles).toEqual({}); }); }); diff --git a/lib/endpoint/__tests__/intercept-run.test.ts b/lib/endpoint/__tests__/intercept-run.test.ts index c29573ef..cdadaf68 100644 --- a/lib/endpoint/__tests__/intercept-run.test.ts +++ b/lib/endpoint/__tests__/intercept-run.test.ts @@ -1,34 +1,42 @@ import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { repoDataDir } from "../../rt-paths.ts"; +import { teamSettingsPath } from "../../rt-paths.ts"; import { runInterception } from "../run.ts"; -function writeRepoConfig(repo: string, obj: unknown): void { - const dir = repoDataDir(repo); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "config.json"), JSON.stringify(obj)); +/** Merges `identity`'s roles into the shared team store rather than clobbering earlier entries — every test in this file shares one preload HOME. */ +function writeRepoRoles(identity: string, roles: unknown): void { + const path = teamSettingsPath("claimview"); + mkdirSync(join(path, ".."), { recursive: true }); + let existing: { repos?: Record } = {}; + try { + existing = JSON.parse(readFileSync(path, "utf8")); + } catch { /* file absent or malformed — start fresh */ } + const repos = { ...(existing.repos ?? {}), [identity]: { "rt.roles": roles } }; + writeFileSync(path, JSON.stringify({ ...existing, repos })); } +const R1_IDENTITY = "x/test/r1"; +const R1_REMOTE = "git@x:test/r1.git"; + // Role "web" for repo "r1" — reached via `loadEndpointConfig` -// (lib/endpoint/config.ts) inside runInterception's env step; this per-repo -// config.json is the resolver's legacy rung, which is all these rules need -// (their repoRemote is null, so the store's repo rungs are out of reach). +// (lib/endpoint/config.ts) inside runInterception's env step, keyed by the +// identity `identityFromRemote` derives from the rule's own `repoRemote`. // env renders ${port}; preserveEnv protects the caller's KEEP_* vars (both // feed argInject's ${envKeys}). -writeRepoConfig("r1", { - roles: { web: { env: { PORT: "${port}" }, preserveEnv: ["KEEP_*"] } }, +writeRepoRoles(R1_IDENTITY, { + web: { env: { PORT: "${port}" }, preserveEnv: ["KEEP_*"] }, }); function harness(over: Partial[0]> = {}) { const calls: { exec?: { bin: string; args: string[]; env: Record }; warned: string[] } = { warned: [] }; const deps = { - rules: [{ command: "fakecmd", repo: "r1", repoRemote: null, + rules: [{ command: "fakecmd", repo: "r1", repoRemote: R1_REMOTE, matches: [{ cwdGlob: ".", argPattern: "serve", role: "web", argInject: { afterArg: "run", template: "--keep=${envKeys}", skipIfArgPresent: "--keep" } }] }], gitToplevel: async () => "/wt/a", - gitRemote: async () => null, + gitRemote: async () => R1_REMOTE, claim: async () => ({ ok: true, data: { role: "web", port: 3000, url: "http://localhost:3000", refs: {} } }), execReal: async (bin: string, args: string[], env: Record) => { calls.exec = { bin, args, env }; throw new Error("EXEC"); }, resolveRealBinary: () => "/usr/bin/fakecmd", @@ -50,11 +58,13 @@ describe("runInterception", () => { expect(calls.exec!.env.KEEP_ME).toBe("1"); }); test("hook-contributed env keys ride argInject so wrappers cannot clobber them", async () => { - writeRepoConfig("r2", { - roles: { web: { env: { PORT: "${port}" }, hook: `echo '{"env":{"NODE_OPTIONS":"--require /x.cjs"}}'` } }, + const R2_IDENTITY = "x/test/r2"; + const R2_REMOTE = "git@x:test/r2.git"; + writeRepoRoles(R2_IDENTITY, { + web: { env: { PORT: "${port}" }, hook: `echo '{"env":{"NODE_OPTIONS":"--require /x.cjs"}}'` }, }); - const { deps, calls } = harness(); - deps.rules = [{ ...deps.rules[0]!, repo: "r2" }]; + const { deps, calls } = harness({ gitRemote: async () => R2_REMOTE }); + deps.rules = [{ ...deps.rules[0]!, repo: "r2", repoRemote: R2_REMOTE }]; await run(deps, ["run", "serve"]); expect(calls.exec!.args).toEqual(["run", "--keep=PORT,NODE_OPTIONS", "serve"]); expect(calls.exec!.env.NODE_OPTIONS).toBe("--require /x.cjs"); diff --git a/lib/endpoint/__tests__/settings-regen.test.ts b/lib/endpoint/__tests__/settings-regen.test.ts index be6759a8..25809e7f 100644 --- a/lib/endpoint/__tests__/settings-regen.test.ts +++ b/lib/endpoint/__tests__/settings-regen.test.ts @@ -82,7 +82,7 @@ describe("regenerateInterceptsCache", () => { test("any other key regenerates nothing and writes no cache file", async () => { registerRepo(); expect(await regenerateInterceptsCache("rt.worktrees")).toEqual({ regenerated: false }); - expect(await regenerateInterceptsCache("rt.llm")).toEqual({ regenerated: false }); + expect(await regenerateInterceptsCache("rt.hooks")).toEqual({ regenerated: false }); expect(loadInterceptRules()).toEqual([]); expect(Bun.file(interceptsPath()).size).toBe(0); // never created }); diff --git a/lib/endpoint/__tests__/shim.test.ts b/lib/endpoint/__tests__/shim.test.ts index 66b1d659..e2d5b693 100644 --- a/lib/endpoint/__tests__/shim.test.ts +++ b/lib/endpoint/__tests__/shim.test.ts @@ -3,7 +3,7 @@ import { execSync } from "child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; -import { machineSettingsPath, repoDataDir, rtDir, teamSettingsPath, userSettingsPath } from "../../rt-paths.ts"; +import { machineSettingsPath, rtDir, teamSettingsPath, userSettingsPath } from "../../rt-paths.ts"; import { buildInterceptRules, installShims, @@ -19,10 +19,14 @@ import { type InterceptRule, } from "../shim.ts"; -function writeRepoConfig(repo: string, obj: unknown): void { - const dir = repoDataDir(repo); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "config.json"), JSON.stringify(obj)); +function writeStore(file: string, obj: unknown): void { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(obj)); +} + +/** Seeds `rt.intercepts` for `identity` in the team store — the store-only path every rule now goes through. */ +function writeRepoIntercepts(identity: string, intercepts: unknown): void { + writeStore(teamSettingsPath("claimview"), { repos: { [identity]: { "rt.intercepts": intercepts } } }); } function writeRepoIndex(index: Record): void { @@ -141,36 +145,32 @@ test("loadInterceptRules degrades to [] on a missing or malformed file", () => { // ─── buildInterceptRules ───────────────────────────────────────────────────── describe("buildInterceptRules", () => { - test("flattens repo index x per-repo intercepts, skips repos with none, captures repoRemote", async () => { + test("flattens repo index x per-repo intercepts, skips repos with none or no derivable identity, captures repoRemote", async () => { const repoWithRemote = makeGitRepo("git@x:assured/assured-dev.git"); + const repoEmptyRemote = makeGitRepo("git@x:assured/empty-repo.git"); const repoNoRemote = makeGitRepo(null); - writeRepoConfig("r-with", { - intercepts: [{ command: "doppler", matches: [{ cwdGlob: "apps/backend{,/**}", role: "backend" }] }], - }); - writeRepoConfig("r-without", {}); - writeRepoConfig("r-no-remote", { - intercepts: [{ command: "pnpm", matches: [{ cwdGlob: ".", role: "root" }] }], - }); - writeRepoIndex({ "r-with": repoWithRemote, "r-without": mkdtempSync(join(tmpdir(), "shim-test-repo-")), "r-no-remote": repoNoRemote }); + writeRepoIntercepts("x/assured/assured-dev", [ + { command: "doppler", matches: [{ cwdGlob: "apps/backend{,/**}", role: "backend" }] }, + ]); + writeRepoIndex({ "r-with": repoWithRemote, "r-without": repoEmptyRemote, "r-no-remote": repoNoRemote }); const built = await buildInterceptRules(); - expect(built).toHaveLength(2); + expect(built).toHaveLength(1); const byRepo = Object.fromEntries(built.map((r) => [r.repo, r])); expect(byRepo["r-with"]!.command).toBe("doppler"); expect(byRepo["r-with"]!.repoRemote).toBe("git@x:assured/assured-dev.git"); - expect(byRepo["r-no-remote"]!.repoRemote).toBeNull(); expect(byRepo["r-without"]).toBeUndefined(); + // No remote → no derivable identity → repo-scoped intercepts unreachable. + expect(byRepo["r-no-remote"]).toBeUndefined(); }); - test("a repo whose intercepts live ONLY in a settings store still gets a rule (remote captured before the resolver is consulted)", async () => { + test("a repo whose intercepts live in a settings store still gets a rule (remote captured before the resolver is consulted)", async () => { const home = realpathSync(mkdtempSync(join(tmpdir(), "rt-shim-store-"))); const origHome = process.env.HOME; process.env.HOME = home; try { const repoPath = makeGitRepo("git@gitlab.com:fake/store-repo.git"); writeRepoIndex({ "r-store": repoPath }); - // No repos/r-store/config.json at all — the legacy rung is empty, so the - // rule can only come from the store, keyed by the repo's IDENTITY. const store = teamSettingsPath("claimview"); mkdirSync(dirname(store), { recursive: true }); writeFileSync(store, JSON.stringify({ @@ -195,13 +195,11 @@ describe("buildInterceptRules", () => { }); test("multiple intercept entries in one repo produce one rule each", async () => { - const repoPath = makeGitRepo(null); - writeRepoConfig("r-multi", { - intercepts: [ - { command: "doppler", matches: [{ cwdGlob: ".", role: "a" }] }, - { command: "pnpm", matches: [{ cwdGlob: ".", role: "b" }] }, - ], - }); + const repoPath = makeGitRepo("git@x:assured/multi-repo.git"); + writeRepoIntercepts("x/assured/multi-repo", [ + { command: "doppler", matches: [{ cwdGlob: ".", role: "a" }] }, + { command: "pnpm", matches: [{ cwdGlob: ".", role: "b" }] }, + ]); writeRepoIndex({ "r-multi": repoPath }); const built = await buildInterceptRules(); expect(built.map((r) => r.command).sort()).toEqual(["doppler", "pnpm"]); @@ -212,10 +210,8 @@ describe("buildInterceptRules", () => { describe("installShims / uninstallShims / shimReport", () => { test("installs a shim per distinct command, classifies installed vs current, uninstall removes only marker files", async () => { - const repoPath = makeGitRepo(null); - writeRepoConfig("r-install", { - intercepts: [{ command: "fakecmd-a", matches: [{ cwdGlob: ".", role: "x" }] }], - }); + const repoPath = makeGitRepo("git@x:assured/r-install.git"); + writeRepoIntercepts("x/assured/r-install", [{ command: "fakecmd-a", matches: [{ cwdGlob: ".", role: "x" }] }]); writeRepoIndex({ "r-install": repoPath }); const first = await installShims(); @@ -242,10 +238,8 @@ describe("installShims / uninstallShims / shimReport", () => { }); test("re-install repairs a stripped exec bit even when the content is already current", async () => { - const repoPath = makeGitRepo(null); - writeRepoConfig("r-chmod", { - intercepts: [{ command: "fakecmd-chmod", matches: [{ cwdGlob: ".", role: "x" }] }], - }); + const repoPath = makeGitRepo("git@x:assured/r-chmod.git"); + writeRepoIntercepts("x/assured/r-chmod", [{ command: "fakecmd-chmod", matches: [{ cwdGlob: ".", role: "x" }] }]); writeRepoIndex({ "r-chmod": repoPath }); await installShims(); @@ -266,10 +260,8 @@ describe("installShims / uninstallShims / shimReport", () => { }); test("shimReport tracks the full installed/current transition (RT-28 verify check)", async () => { - const repoPath = makeGitRepo(null); - writeRepoConfig("r-transition", { - intercepts: [{ command: "fakecmd-transition", matches: [{ cwdGlob: ".", role: "x" }] }], - }); + const repoPath = makeGitRepo("git@x:assured/r-transition.git"); + writeRepoIntercepts("x/assured/r-transition", [{ command: "fakecmd-transition", matches: [{ cwdGlob: ".", role: "x" }] }]); writeRepoIndex({ "r-transition": repoPath }); const built = await buildInterceptRules(); @@ -336,7 +328,6 @@ describe("staleIntercepts", () => { writeAt(userSettingsPath(), "{}", OLDER); writeAt(machineSettingsPath(), "{}", OLDER); writeAt(teamSettingsPath("claimview"), "{}", OLDER); - writeAt(join(repoDataDir("r"), "config.json"), "{}", OLDER); expect(staleIntercepts()).toEqual({ stale: false }); }); @@ -353,19 +344,4 @@ describe("staleIntercepts", () => { writeAt(teamSettingsPath("claimview"), "{}", NEWER); expect(staleIntercepts().stale).toBe(true); }); - - test("a per-repo legacy config.json named in the rules, newer than the cache, is stale", () => { - writeCache("r-legacy"); - writeAt(join(repoDataDir("r-legacy"), "config.json"), "{}", NEWER); - const probe = staleIntercepts(); - expect(probe.stale).toBe(true); - expect(probe.reason).toContain("r-legacy"); - }); - - test("a legacy config.json for a repo NOT in the rules does not make the cache stale", () => { - writeCache("r-in-rules"); - writeAt(join(repoDataDir("r-in-rules"), "config.json"), "{}", OLDER); - writeAt(join(repoDataDir("r-elsewhere"), "config.json"), "{}", NEWER); - expect(staleIntercepts()).toEqual({ stale: false }); - }); }); diff --git a/lib/endpoint/config.ts b/lib/endpoint/config.ts index d0d27981..311711bf 100644 --- a/lib/endpoint/config.ts +++ b/lib/endpoint/config.ts @@ -5,34 +5,21 @@ * * ── Where the values come from ──────────────────────────────────────────── * Everything goes through `lib/settings/resolve.ts#getSetting`, which layers - * the authored stores over the legacy rung: + * the authored stores: * - * default < legacy < team < user < team.repo < user.repo < machine < machine.repo + * default < team < user < team.repo < user.repo < machine < machine.repo * * The store rungs are keyed by repo IDENTITY (a normalized remote), which is * why the entry point takes one. A null identity — a repo whose remote is a * local path, or one whose identity could not be derived — simply makes the - * `*.repo` rungs unreachable; global keys and the legacy rung still answer. - * That is an honest degrade, not an error. - * - * ── The legacy window ───────────────────────────────────────────────────── - * `legacy` is the pre-migration per-repo file, ~/.mattstack/rt/repos// - * config.json, and specifically its `roles` and `intercepts` keys. That file - * has multiple owners (repo-config.ts owns setup/clean/startScript/open, - * lib/worktree/config.ts owns `worktrees`); this module still only ever reads - * those two keys of it, still never writes it, and now reaches it only through - * the resolver's legacy rung — so a store value beats it and `rt settings - * explain rt.roles --repo ` says which one won. The window closes when - * the migrated keys are removed from that file (spec: "Data migration", step - * 4); until then a repo with no store section behaves exactly as it did before - * this migration. + * `*.repo` rungs unreachable; global keys still answer. That is an honest + * degrade, not an error. * * ── Sanitizers still apply to every rung ────────────────────────────────── * The resolver only type-checks the TOP level of a value (`rt.roles` is an - * object, `rt.intercepts` is an array) and the legacy rung is raw file - * content, so the sanitizers below are what actually guarantee the - * `EndpointRepoConfig` shape. They run over whatever the resolver returns, - * from whichever scope it came. + * object, `rt.intercepts` is an array), so the sanitizers below are what + * actually guarantee the `EndpointRepoConfig` shape. They run over whatever + * the resolver returns, from whichever scope it came. * * ── Variables ───────────────────────────────────────────────────────────── * Values are expanded (`expand: true`): `${team:}` and `${home}` always, @@ -256,8 +243,7 @@ function resolveKey(key: string, repoName: string, opts: ResolveOpts): unknown { /** * Resolves a repo's `rt.roles` and `rt.intercepts` into the endpoint config * shape. `repoIdentity` selects the stores' `repos.` sections (null - * = unreachable, global + legacy scopes only); `repoName` names the legacy - * per-repo config.json rung and resolves `${repoRoot}`. + * = unreachable, global scopes only); `repoName` resolves `${repoRoot}`. * * Never throws: missing files, malformed shapes and unexpandable values all * degrade to empty defaults (module header explains why every caller needs @@ -269,7 +255,6 @@ export function loadEndpointConfig(args: { repoIdentity: string | null; repoName const opts: ResolveOpts = { repoIdentity, expand: true, - legacy: { repoName }, ...(repoRoot === undefined ? {} : { expandCtx: { repoRoot } }), }; diff --git a/lib/endpoint/run.ts b/lib/endpoint/run.ts index c035b743..3e24d2c2 100644 --- a/lib/endpoint/run.ts +++ b/lib/endpoint/run.ts @@ -92,8 +92,8 @@ export async function runInterception( // already carries — no spawn, and `identityFromRemote` (never bare // `normalizeRemote`) so a fork pinned in the machine store's // `rt.repoIdentityOverrides` resolves to its upstream identity here too. A - // rule with no recorded remote resolves with a null identity: global + - // legacy scopes only, which is exactly what that repo had before RT-47. + // rule with no recorded remote resolves with a null identity: repo-scoped + // sections are unreachable, only global scopes answer. const repoIdentity = rule.repoRemote === null ? null : identityFromRemote(rule.repoRemote); const repoCfg = loadEndpointConfig({ repoIdentity, repoName: rule.repo }); const roleCfg = repoCfg.roles[match.role]; diff --git a/lib/endpoint/shim.ts b/lib/endpoint/shim.ts index 731c3e6f..7ba50e5a 100644 --- a/lib/endpoint/shim.ts +++ b/lib/endpoint/shim.ts @@ -31,7 +31,6 @@ import { join, relative } from "path"; import { readJson, writeJson } from "../json-store.ts"; import { machineSettingsPath, - repoDataDir, rtDir, teamSettingsPath, userSettingsPath, @@ -163,23 +162,10 @@ export async function buildInterceptRules(): Promise { /** * Every file a rule in the cache could have come from: the three authored - * store files (each cloned team's included) plus the legacy per-repo - * config.json of every repo the CACHED rules name. - * - * Using the cached rules' repos — rather than the whole repo index — keeps the - * probe cheap and spawn-free (no identity derivation), and it is the honest - * set: a legacy file for a repo that contributes no rules cannot make the - * rules wrong. The gap it leaves is a repo that would START contributing rules - * after an edit to a config.json it has never had; that is precisely what the - * store rungs are for now, and `rt intercept install` remains the manual regen - * (spec: three-part answer, part b). + * store files, each cloned team's included. */ function interceptSourceFiles(): string[] { - const files = [userSettingsPath(), machineSettingsPath(), ...listTeams().map(teamSettingsPath)]; - for (const repo of new Set(loadInterceptRules().map((rule) => rule.repo))) { - files.push(join(repoDataDir(repo), "config.json")); - } - return files; + return [userSettingsPath(), machineSettingsPath(), ...listTeams().map(teamSettingsPath)]; } /** diff --git a/lib/llm.ts b/lib/llm.ts deleted file mode 100644 index 69288535..00000000 --- a/lib/llm.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { join } from "path"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; -import { rtDir } from "./rt-paths.ts"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface LlmConfig { - provider: "ollama"; - url: string; - model: string; - timeoutMs: number; -} - -const DEFAULT_CONFIG: LlmConfig = { - provider: "ollama", - url: "http://localhost:11434", - model: "", - timeoutMs: 15_000, -}; - -export class LlmUnavailableError extends Error { - constructor(reason: string) { - super(`LLM unavailable: ${reason}`); - this.name = "LlmUnavailableError"; - } -} - -export class LlmEmptyResponseError extends Error { - constructor() { - super("LLM returned empty response"); - this.name = "LlmEmptyResponseError"; - } -} - -// ─── Config ────────────────────────────────────────────────────────────────── - -// HOME is resolved at call time (see rt-paths.ts), so the path must be too — -// a module-load constant would pin the file to whatever HOME was at import. -export function llmConfigPath(): string { - return join(rtDir(), "llm.json"); -} - -export function loadLlmConfig(): LlmConfig { - try { - const path = llmConfigPath(); - if (existsSync(path)) { - const raw = JSON.parse(readFileSync(path, "utf8")); - return { ...DEFAULT_CONFIG, ...raw }; - } - } catch { /* malformed JSON — use defaults */ } - return { ...DEFAULT_CONFIG }; -} - -export function saveLlmConfig(partial: Partial): void { - const dir = rtDir(); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - const current = loadLlmConfig(); - const merged = { ...current, ...partial }; - writeFileSync(llmConfigPath(), JSON.stringify(merged, null, 2)); -} - -// ─── Ollama API ────────────────────────────────────────────────────────────── - -interface OllamaTagsResponse { - models: Array<{ name: string; size: number }>; -} - -/** - * Send a prompt to the configured local LLM via Ollama. - * - * Uses Ollama's /api/chat endpoint with a system + user message pair. - * - * @throws {LlmUnavailableError} if Ollama is unreachable, times out, or - * returns a non-OK / non-JSON response - * @throws {LlmEmptyResponseError} if the model returns an empty string - */ -export async function llmPrompt( - system: string, - user: string, - opts?: { maxTokens?: number }, -): Promise { - const config = loadLlmConfig(); - - if (!config.model) throw new LlmUnavailableError("no model configured"); - - let response: Response; - try { - response = await fetch(`${config.url}/api/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model: config.model, - messages: [ - { role: "system", content: system }, - { role: "user", content: user }, - ], - stream: false, - options: opts?.maxTokens ? { num_predict: opts.maxTokens } : undefined, - }), - signal: AbortSignal.timeout(config.timeoutMs), - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new LlmUnavailableError(msg); - } - - if (!response.ok) { - throw new LlmUnavailableError(`HTTP ${response.status}`); - } - - let json: { message?: { content?: string } }; - try { - json = (await response.json()) as { message?: { content?: string } }; - } catch { - // 200 with a non-JSON body (proxy, captive portal) — keep the documented - // error contract so callers' fallbacks still engage. - throw new LlmUnavailableError("invalid JSON response"); - } - const text = (json.message?.content ?? "").trim(); - - if (!text) throw new LlmEmptyResponseError(); - - return text; -} - -/** - * List locally installed Ollama models. - * - * @throws {LlmUnavailableError} if Ollama is unreachable - */ -export async function listOllamaModels( - url: string, -): Promise> { - let response: Response; - try { - response = await fetch(`${url}/api/tags`, { - signal: AbortSignal.timeout(10_000), - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - throw new LlmUnavailableError(msg); - } - - if (!response.ok) throw new LlmUnavailableError(`HTTP ${response.status}`); - - const json = (await response.json()) as OllamaTagsResponse; - return (json.models ?? []).map(m => ({ - name: m.name, - size: formatBytes(m.size), - })); -} - -function formatBytes(bytes: number): string { - const gb = bytes / (1024 ** 3); - if (gb >= 1) return `${gb.toFixed(1)}GB`; - const mb = bytes / (1024 ** 2); - return `${Math.round(mb)}MB`; -} diff --git a/lib/repo-config.ts b/lib/repo-config.ts deleted file mode 100644 index 8325bfde..00000000 --- a/lib/repo-config.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Per-repo config — ~/.mattstack/rt/repos//config.json. - * - * Stores setup steps, clean commands, and dev preferences. - * Port discovery is handled automatically by the daemon. - * Includes the first-run config wizard. - */ - -import { existsSync, readFileSync, writeFileSync } from "fs"; -import { join } from "path"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface SetupStep { - label: string; - /** Shell command. Use "auto" for build-deps (auto-wired turbo build). */ - command: string; - /** Optional subdirectory to run from (relative to repo root). */ - cwd?: string; -} - -export interface RepoConfig { - setup: SetupStep[]; - clean: string[]; - startScript: string; - open: { base: string }; -} - -const DEFAULT_CONFIG: RepoConfig = { - setup: [], - clean: [], - startScript: "start", - open: { base: "" }, -}; - -// ─── Load / Save ───────────────────────────────────────────────────────────── - -/** - * Load the repo config from ~/.mattstack/rt/repos//config.json. - * Merges with defaults for any missing fields. - */ -export function loadRepoConfig(dataDir: string): RepoConfig { - const configPath = join(dataDir, "config.json"); - - if (existsSync(configPath)) { - try { - const raw = JSON.parse(readFileSync(configPath, "utf8")); - return { - setup: raw.setup ?? DEFAULT_CONFIG.setup, - clean: raw.clean ?? DEFAULT_CONFIG.clean, - startScript: raw.startScript ?? DEFAULT_CONFIG.startScript, - open: raw.open ?? DEFAULT_CONFIG.open, - }; - } catch { - return { ...DEFAULT_CONFIG }; - } - } - - return { ...DEFAULT_CONFIG }; -} - -/** - * Load config, running the interactive wizard on first use if in a TTY. - */ -export async function loadOrCreateRepoConfig( - dataDir: string, - repoRoot: string, - repoName: string, -): Promise { - const configPath = join(dataDir, "config.json"); - - if (existsSync(configPath)) { - return loadRepoConfig(dataDir); - } - - if (process.stdin.isTTY) { - const config = await runConfigWizard(repoRoot, repoName); - writeFileSync(configPath, JSON.stringify(config, null, 2)); - return config; - } else { - const config = { ...DEFAULT_CONFIG }; - writeFileSync(configPath, JSON.stringify(config, null, 2)); - return config; - } -} - -export function saveRepoConfig(dataDir: string, config: RepoConfig): void { - writeFileSync(join(dataDir, "config.json"), JSON.stringify(config, null, 2)); -} - -// ─── First-run config wizard ───────────────────────────────────────────────── - -async function runConfigWizard(repoRoot: string, repoName: string): Promise { - const { textInput, confirm: inkConfirm } = await import("./rt-render.tsx"); - - console.log(`\n First-time setup for ${repoName}\n`); - - // ── Setup steps ───────────────────────────────────────────────────────────── - - const setup: SetupStep[] = []; - - const wantBuildDeps = await inkConfirm({ - message: "Add 'build deps' setup step? (auto-wired turbo build for selected apps)", - initialValue: true, - }); - - if (wantBuildDeps) { - setup.push({ label: "build deps", command: "auto" }); - } - - let addMore = true; - while (addMore) { - const wantStep = await inkConfirm({ - message: setup.length === 0 - ? "Add a setup step? (runs before dev servers start)" - : "Add another setup step?", - initialValue: setup.length === 0, - }); - - if (!wantStep) { - addMore = false; - break; - } - - try { - const label = await textInput({ message: "Step label", placeholder: "deploy db" }); - const cwd = await textInput({ message: "Subdirectory? (leave empty for repo root)", placeholder: "apps/backend" }).catch(() => ""); - const command = await textInput({ message: "Shell command", placeholder: "pnpm deploy-db" }); - - const step: SetupStep = { label, command }; - if (cwd) step.cwd = cwd; - setup.push(step); - } catch { - addMore = false; - } - } - - // ── Clean commands ────────────────────────────────────────────────────────── - - const clean: string[] = []; - try { - const cleanInput = await textInput({ - message: "Clean-mode commands (comma separated, or press Enter to skip)", - placeholder: "find . -name .parcel-cache -type d -exec rm -rf {} +", - }); - clean.push(...cleanInput.split(",").map(s => s.trim()).filter(Boolean)); - } catch { /* user skipped */ } - - // ── Build config ──────────────────────────────────────────────────────────── - - const config: RepoConfig = { - setup, - clean, - startScript: "start", - open: { base: "" }, - }; - - console.log(`\n Config saved to ~/.mattstack/rt/repos/${repoName}/config.json\n`); - return config; -} diff --git a/lib/repo-tracking.ts b/lib/repo-tracking.ts index e8f0ea85..95beca23 100644 --- a/lib/repo-tracking.ts +++ b/lib/repo-tracking.ts @@ -195,6 +195,17 @@ export function loadMachineRepoTracking(): RepoTracking { return readMachineTracking().out; } +/** + * Whether `mattstack.tracking`'s team-authored `repos` map names `identity` + * at all — any value, valid or not. What `rt daemon track off` needs + * before deciding whether turning a repo off can delete its machine grant + * outright or must instead plant an explicit `{mode:"off"}` marker (see + * `saveRepoTracking`'s `offMarkers` and the module doc's merge rule). + */ +export function teamNamesIdentity(identity: string): boolean { + return Object.prototype.hasOwnProperty.call(loadTeamTracking(), identity); +} + /** Read the merged view (machine grants + team intent) — see the module doc for the merge rule. */ export function loadRepoTracking(opts?: { identityMap?: IdentityNameMap }): RepoTracking { const { out, rawNames } = readMachineTracking(); @@ -227,10 +238,21 @@ export function grants(tracking: RepoTracking, repoName: string): RepoGrants { * output) here — a caller doing read-modify-write must start from * `loadMachineRepoTracking()`, or every other repo's team-synthesized entry * gets baked into the machine store as if a human had granted it. + * + * `offMarkers` plants an explicit `{mode:"off"}` entry for each name listed — + * `normalizeEntry` rejects that shape (mode "off" is not a valid grant), but + * it still names the repo in the RAW machine map, which is what makes it a + * real local opt-out for a repo the team layer still declares intent for + * (module doc's merge rule: the raw machine map winning per-repo, not just a + * valid grant winning). A name must not appear in both `tracking` and + * `offMarkers` — the marker always wins ties, but callers should never rely + * on that. */ -export function saveRepoTracking(tracking: RepoTracking): void { +export function saveRepoTracking(tracking: RepoTracking, offMarkers: string[] = []): void { + const merged: Record = { ...tracking }; + for (const name of offMarkers) merged[name] = { mode: "off" }; const repos = Object.fromEntries( - Object.entries(tracking).sort(([a], [b]) => a.localeCompare(b)), + Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)), ); setSetting("rt.repoTracking", repos, "machine"); } diff --git a/lib/repo.ts b/lib/repo.ts index 2ac8e0f5..99e1a5fb 100644 --- a/lib/repo.ts +++ b/lib/repo.ts @@ -14,10 +14,6 @@ import { repoDataDir } from "./rt-paths.ts"; export { getRepoRoot, getCurrentBranch, getRemoteUrl } from "./git.ts"; export { updateRepoIndex, getKnownRepos, repoOption, type KnownRepo } from "./repo-index.ts"; -export { - loadRepoConfig, loadOrCreateRepoConfig, saveRepoConfig, - type RepoConfig, type SetupStep, -} from "./repo-config.ts"; // ─── Internal imports ──────────────────────────────────────────────────────── diff --git a/lib/worktree/__tests__/config.test.ts b/lib/worktree/__tests__/config.test.ts index faad616e..1d9e2739 100644 --- a/lib/worktree/__tests__/config.test.ts +++ b/lib/worktree/__tests__/config.test.ts @@ -6,7 +6,6 @@ import { dirname, join } from "path"; import { writeJson } from "../../json-store.ts"; import { machineSettingsPath, - repoDataDir, rtDir, teamSettingsPath, userSettingsPath, @@ -49,7 +48,7 @@ describe("worktree config", () => { }); describe("loadWorktreeRepoConfig", () => { - test("defaults when config.json is missing", async () => { + test("defaults when nothing is declared", async () => { const repoPath = tmpRepoPath("rtcfg-repo-"); const cfg = await loadWorktreeRepoConfig("myrepo", repoPath); expect(cfg).toEqual({ @@ -59,26 +58,16 @@ describe("worktree config", () => { ready: [], }); }); + }); - test("defaults when config.json exists but has no 'worktrees' key", async () => { - const repoPath = tmpRepoPath("rtcfg-repo-"); - writeJson(join(repoDataDir("myrepo"), "config.json"), { - setup: [], - clean: [], - startScript: "start", - open: { base: "" }, - }); - const cfg = await loadWorktreeRepoConfig("myrepo", repoPath); - expect(cfg).toEqual({ - onDeck: 0, - root: join(repoPath, ".worktrees"), - branchFormat: "-", - ready: [], - }); - }); + // ─── Through the resolver (RT-47) ────────────────────────────────────────── - test("declared block round-trips", async () => { - const repoPath = tmpRepoPath("rtcfg-repo-"); + describe("loadWorktreeRepoConfig through the settings resolver", () => { + const IDENTITY = "gitlab.com/assured/assured-dev"; + const REMOTE = "git@gitlab.com:assured/assured-dev.git"; + + test("a full declared block round-trips through a store", async () => { + const repoPath = tmpRepoWithRemote("rtcfg-roundtrip-", REMOTE); const declared = { onDeck: 2, namePool: ["hogwarts", "bellatrix"], @@ -88,52 +77,13 @@ describe("worktree config", () => { { run: "pnpm genTypes", when: "changed:db/schema/**" }, ], }; - writeJson(join(repoDataDir("myrepo"), "config.json"), { - setup: [{ label: "x", command: "y" }], - worktrees: declared, - }); - const cfg = await loadWorktreeRepoConfig("myrepo", repoPath); - expect(cfg).toEqual(declared); - }); - - test("expands a leading ~/ in root against call-time HOME", async () => { - const repoPath = tmpRepoPath("rtcfg-repo-"); - writeJson(join(repoDataDir("myrepo"), "config.json"), { - worktrees: { root: "~/wt-root" }, - }); - const cfg = await loadWorktreeRepoConfig("myrepo", repoPath); - expect(cfg.root).toBe(join(process.env.HOME!, "wt-root")); - }); - - test("drops dot-leading namePool entries", async () => { - // A pool entry named ".trash-x" would build a tree the reconciler's reap - // duty then deletes as a leftover. The reaper is the only rm -rf in the - // codebase; this is the door it can come through. - const repoPath = tmpRepoPath("rtcfg-repo-"); - writeJson(join(repoDataDir("myrepo"), "config.json"), { - worktrees: { namePool: [".trash-x", "luna"] }, - }); - const cfg = await loadWorktreeRepoConfig("myrepo", repoPath); - expect(cfg.namePool).toEqual(["luna"]); - }); + writeStore(machineSettingsPath(), { repos: { [IDENTITY]: { "rt.worktrees": declared } } }); - test("leaves an absolute root unchanged", async () => { - const repoPath = tmpRepoPath("rtcfg-repo-"); - writeJson(join(repoDataDir("myrepo"), "config.json"), { - worktrees: { root: "/absolute/wt-root" }, - }); - const cfg = await loadWorktreeRepoConfig("myrepo", repoPath); - expect(cfg.root).toBe("/absolute/wt-root"); + const cfg = await loadWorktreeRepoConfig("assured-dev", repoPath); + expect(cfg).toEqual(declared); }); - }); - - // ─── Through the resolver (RT-47) ────────────────────────────────────────── - - describe("loadWorktreeRepoConfig through the settings resolver", () => { - const IDENTITY = "gitlab.com/assured/assured-dev"; - const REMOTE = "git@gitlab.com:assured/assured-dev.git"; - test("the deep-merge proof case: team onDeck/ready + user namePool + legacy everything", async () => { + test("the deep-merge proof case: team onDeck/ready + user namePool + machine root/branchFormat", async () => { const repoPath = tmpRepoWithRemote("rtcfg-merge-", REMOTE); // team: the shared pool size and the shared ready ladder @@ -151,29 +101,27 @@ describe("worktree config", () => { writeStore(userSettingsPath(), { repos: { [IDENTITY]: { "rt.worktrees": { namePool: ["hogwarts", "bellatrix"] } } }, }); - // legacy: the pre-migration file, still carrying everything - writeJson(join(repoDataDir("assured-dev"), "config.json"), { - worktrees: { - onDeck: 1, - namePool: ["legacy-name"], - root: "/legacy/wt-root", - branchFormat: "", - ready: [{ run: "legacy-step" }], + // machine: the strongest rung — restates onDeck, adds root/branchFormat + writeStore(machineSettingsPath(), { + repos: { + [IDENTITY]: { + "rt.worktrees": { onDeck: 1, root: "/machine/wt-root", branchFormat: "" }, + }, }, }); const cfg = await loadWorktreeRepoConfig("assured-dev", repoPath); expect(cfg).toEqual({ - onDeck: 3, // team beats legacy - namePool: ["hogwarts", "bellatrix"], // user beats legacy (arrays replace whole) - root: "/legacy/wt-root", // legacy-only field survives the merge - branchFormat: "", // legacy-only field survives the merge - ready: [{ run: "pnpm install", when: "changed:pnpm-lock.yaml" }], // team beats legacy + onDeck: 1, // machine beats team + namePool: ["hogwarts", "bellatrix"], // user-only field + root: "/machine/wt-root", // machine-only field + branchFormat: "", // machine-only field + ready: [{ run: "pnpm install", when: "changed:pnpm-lock.yaml" }], // team-only field }); }); - test("a store-only repo resolves with no legacy config.json at all", async () => { + test("a store-only repo resolves with no store section at all", async () => { const repoPath = tmpRepoWithRemote("rtcfg-storeonly-", REMOTE); writeStore(teamSettingsPath("claimview"), { repos: { [IDENTITY]: { "rt.worktrees": { onDeck: 2, namePool: ["luna"] } } }, @@ -210,27 +158,27 @@ describe("worktree config", () => { expect(cfg.root).toBe(join(process.env.HOME!, "wt")); }); - test("the namePool dot-filter applies to a store value, not just the legacy file", async () => { - const repoPath = tmpRepoWithRemote("rtcfg-dotstore-", REMOTE); - writeStore(userSettingsPath(), { - repos: { [IDENTITY]: { "rt.worktrees": { namePool: [".trash-x", "luna"] } } }, + test("a machine-store absolute root is left unchanged", async () => { + const repoPath = tmpRepoWithRemote("rtcfg-abs-", REMOTE); + writeStore(machineSettingsPath(), { + repos: { [IDENTITY]: { "rt.worktrees": { root: "/absolute/wt-root" } } }, }); const cfg = await loadWorktreeRepoConfig("assured-dev", repoPath); - expect(cfg.namePool).toEqual(["luna"]); + expect(cfg.root).toBe("/absolute/wt-root"); }); - test("a repo with no derivable identity still reads its legacy file", async () => { - const repoPath = tmpRepoPath("rtcfg-noident-"); // not a git repo: identity null - writeStore(teamSettingsPath("claimview"), { - repos: { [IDENTITY]: { "rt.worktrees": { onDeck: 9 } } }, - }); - writeJson(join(repoDataDir("assured-dev"), "config.json"), { - worktrees: { onDeck: 4 }, + test("the namePool dot-filter applies to a store value", async () => { + // A pool entry named ".trash-x" would build a tree the reconciler's reap + // duty then deletes as a leftover. The reaper is the only rm -rf in the + // codebase; this is the door it can come through. + const repoPath = tmpRepoWithRemote("rtcfg-dotstore-", REMOTE); + writeStore(userSettingsPath(), { + repos: { [IDENTITY]: { "rt.worktrees": { namePool: [".trash-x", "luna"] } } }, }); const cfg = await loadWorktreeRepoConfig("assured-dev", repoPath); - expect(cfg.onDeck).toBe(4); // legacy answers; the repo section is unreachable + expect(cfg.namePool).toEqual(["luna"]); }); }); @@ -243,7 +191,7 @@ describe("worktree config", () => { expect(await worktreeSettingsDeclared("store-only", repoPath)).toBe(false); }); - test("a team store section with no legacy config.json -> true", async () => { + test("a team store section -> true", async () => { const repoPath = tmpRepoWithRemote("rtcfg-act-store-", REMOTE); writeStore(teamSettingsPath("claimview"), { repos: { [IDENTITY]: { "rt.worktrees": { onDeck: 2 } } }, @@ -251,17 +199,13 @@ describe("worktree config", () => { expect(await worktreeSettingsDeclared("store-only", repoPath)).toBe(true); }); - test("an EMPTY legacy worktrees block still counts, exactly as it did pre-resolver", async () => { - const repoPath = tmpRepoWithRemote("rtcfg-act-legacy-", REMOTE); - writeJson(join(repoDataDir("store-only"), "config.json"), { worktrees: {} }); + test("an EMPTY declared block still counts as declared", async () => { + const repoPath = tmpRepoWithRemote("rtcfg-act-empty-", REMOTE); + writeStore(teamSettingsPath("claimview"), { + repos: { [IDENTITY]: { "rt.worktrees": {} } }, + }); expect(await worktreeSettingsDeclared("store-only", repoPath)).toBe(true); }); - - test("a config.json with other keys but no worktrees block -> false", async () => { - const repoPath = tmpRepoWithRemote("rtcfg-act-other-", REMOTE); - writeJson(join(repoDataDir("store-only"), "config.json"), { setup: [], startScript: "x" }); - expect(await worktreeSettingsDeclared("store-only", repoPath)).toBe(false); - }); }); describe("resolveImplicitInstall", () => { diff --git a/lib/worktree/__tests__/create.test.ts b/lib/worktree/__tests__/create.test.ts index 70b7440d..68ab7cd0 100644 --- a/lib/worktree/__tests__/create.test.ts +++ b/lib/worktree/__tests__/create.test.ts @@ -1,14 +1,53 @@ import { describe, test, expect, beforeEach } from "bun:test"; import { execSync } from "child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync } from "fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { writeJson } from "../../json-store.ts"; -import { repoDataDir } from "../../rt-paths.ts"; +import { machineSettingsPath } from "../../rt-paths.ts"; +import { deriveRepoIdentity } from "../../settings/identity.ts"; import { loadRegistry, saveRegistry, type TreeRecord } from "../registry.ts"; import { branchExistsLocalAsync, listWorktreesAsync } from "../git-async.ts"; import { createTree, scrapTree, type CreateDeps } from "../create.ts"; +function readMachineStore(): Record { + try { + return JSON.parse(readFileSync(machineSettingsPath(), "utf8")); + } catch { + return {}; + } +} + +function writeMachineStore(obj: Record): void { + mkdirSync(join(machineSettingsPath(), ".."), { recursive: true }); + writeFileSync(machineSettingsPath(), JSON.stringify(obj)); +} + +/** + * Gives `repoPath` a resolvable settings identity, pinning its (local + * bare-clone) origin via the machine store's `rt.repoIdentityOverrides` when + * it doesn't itself normalize — exactly the fork/local-remote mechanism + * production uses. + */ +async function ensureIdentity(repoPath: string, repoName: string): Promise { + const remote = execSync("git config --get remote.origin.url", { cwd: repoPath, encoding: "utf8" }).trim(); + const direct = await deriveRepoIdentity(repoPath); + if (direct) return direct; + + const identity = `rttest.local/${repoName}`; + const store = readMachineStore(); + const overrides = { ...(store["rt.repoIdentityOverrides"] as Record ?? {}), [remote]: identity }; + writeMachineStore({ ...store, "rt.repoIdentityOverrides": overrides }); + return identity; +} + +/** Seeds `rt.worktrees` for `repoPath` in the machine store — the store-only replacement for the old per-repo config.json fixture. */ +async function declareWorktrees(repoPath: string, repoName: string, declared: unknown): Promise { + const identity = await ensureIdentity(repoPath, repoName); + const store = readMachineStore(); + const repos = { ...(store.repos as Record ?? {}), [identity]: { "rt.worktrees": declared } }; + writeMachineStore({ ...store, repos }); +} + function makeRepo(): string { // realpathSync: git canonicalizes /var -> /private/var on macOS (Global Constraints) const dir = realpathSync(mkdtempSync(join(tmpdir(), "rtcreate-"))); @@ -88,12 +127,7 @@ describe("createTree", () => { }); test("failing ready step scraps the worktree, branch, and registry entry", async () => { - writeJson(join(repoDataDir(repoName), "config.json"), { - worktrees: { - namePool: ["failtree"], - ready: [{ run: "exit 1" }], - }, - }); + await declareWorktrees(repo, repoName, { namePool: ["failtree"], ready: [{ run: "exit 1" }] }); const deps = makeDeps(repoName, repo, events); const result = await createTree(deps); diff --git a/lib/worktree/config.ts b/lib/worktree/config.ts index 568aec5f..586d722c 100644 --- a/lib/worktree/config.ts +++ b/lib/worktree/config.ts @@ -4,42 +4,31 @@ * * ── Where the per-repo values come from ─────────────────────────────────── * `loadWorktreeRepoConfig` goes through `lib/settings/resolve.ts#getSetting`, - * which layers the authored stores over the legacy rung: + * which layers the authored stores: * - * default < legacy < team < user < team.repo < user.repo < machine < machine.repo + * default < team < user < team.repo < user.repo < machine < machine.repo * - * `rt.worktrees` is a **deep-merge** key (registry: `merge: "deep"`), which is - * the whole point of the migration: the team store can own `onDeck`/`ready`, - * the user store can add a personal `namePool`, and a repo that still has a - * legacy block keeps whatever fields nobody else supplied — all at once. Arrays - * inside the key still replace atomically, so one `ready` ladder or one - * `namePool` wins outright rather than being spliced. + * `rt.worktrees` is a **deep-merge** key (registry: `merge: "deep"`): the team + * store can own `onDeck`/`ready`, the user store can add a personal + * `namePool` — all at once. Arrays inside the key still replace atomically, so + * one `ready` ladder or one `namePool` wins outright rather than being + * spliced. * * The store rungs are keyed by repo IDENTITY (a normalized remote), so this * reader derives one from the repo path — which is why it is ASYNC (the * derivation is a `git config` spawn, never a sync one; it is memoized per * path). A repo with no derivable identity (local-path remote, not a git repo - * yet) simply makes the `*.repo` rungs unreachable; global keys and the legacy - * rung still answer. Honest degrade, not an error. - * - * ── The legacy window ───────────────────────────────────────────────────── - * `legacy` is `~/.mattstack/rt/repos//config.json` and specifically its - * `worktrees` key. That file has multiple owners (repo-config.ts owns - * setup/clean/startScript/open); this module still only ever reads that one - * key, still never writes it, and now reaches it through the resolver's legacy - * rung — so a store value beats it and `rt settings explain rt.worktrees --repo - * ` says which one won. The window closes when the migrated key is - * removed from that file (spec: "Data migration", step 4). Until then a repo - * with no store section behaves exactly as it did before this migration. + * yet) simply makes the `*.repo` rungs unreachable; global keys still answer. + * Honest degrade, not an error. * * ── Computed defaults and sanitizers stay HERE ──────────────────────────── * The registry only carries `{ onDeck: 0 }`; `root` (= `/.worktrees`) * and `branchFormat` cannot live there because they depend on the repo being - * read. And the resolver only type-checks the TOP level of a value (an object), - * while the legacy rung is raw file content — so the sanitizers below are what - * actually guarantee the `WorktreeRepoConfig` shape, from whichever rung a - * field arrived on. That includes the namePool dot-filter, which now guards - * team- and user-authored pools too. + * read. And the resolver only type-checks the TOP level of a value (an + * object), so the sanitizers below are what actually guarantee the + * `WorktreeRepoConfig` shape, from whichever rung a field arrived on. That + * includes the namePool dot-filter, which now guards team- and user-authored + * pools too. * * `expandHome` also stays: the resolver's closed variable set is * `${repoRoot}/${worktree}/${home}/${team:}` and a bare `~` is not in it, @@ -116,10 +105,9 @@ function isPlainObject(value: unknown): value is Record { * `${worktree}` is NOT satisfiable here (this reader has no invocation * context), so a value using it degrades — see the module header. */ -function resolveOpts(repoName: string, repoIdentity: string | null, repoPath: string): ResolveOpts { +function resolveOpts(repoIdentity: string | null, repoPath: string): ResolveOpts { return { repoIdentity, - legacy: { repoName }, expandCtx: { repoRoot: repoPath }, }; } @@ -135,7 +123,7 @@ function resolveDeclared( repoPath: string, ): Record { try { - const { value } = getSetting(SETTING_KEY, resolveOpts(repoName, repoIdentity, repoPath)); + const { value } = getSetting(SETTING_KEY, resolveOpts(repoIdentity, repoPath)); return isPlainObject(value) ? value : {}; } catch (err) { console.warn(`rt: ignoring "${SETTING_KEY}" for repo "${repoName}" — ${(err as Error).message}`); @@ -144,8 +132,8 @@ function resolveDeclared( } // ─── Sanitizers ────────────────────────────────────────────────────────────── -// The resolver type-checks only the top level of the key and the legacy rung is -// raw file content, so these are what guarantee WorktreeRepoConfig's shape. +// The resolver type-checks only the top level of the key, so these are what +// guarantee WorktreeRepoConfig's shape. /** Pool size. Anything that isn't a non-negative integer means "no pool". */ function sanitizeOnDeck(raw: unknown): number { @@ -232,7 +220,7 @@ export async function loadWorktreeRepoConfig( export async function worktreeSettingsDeclared(repoName: string, repoPath: string): Promise { const identity = await deriveRepoIdentity(repoPath); try { - return explainSetting(SETTING_KEY, resolveOpts(repoName, identity, repoPath)).some( + return explainSetting(SETTING_KEY, resolveOpts(identity, repoPath)).some( (row) => row.present && row.scope !== "default", ); } catch (err) { diff --git a/packages/rt-client/src/index.ts b/packages/rt-client/src/index.ts index 709438c9..a5127519 100644 --- a/packages/rt-client/src/index.ts +++ b/packages/rt-client/src/index.ts @@ -25,7 +25,7 @@ export { repoNameForPath } from "./repos.ts"; // ─── Settings (RT-50) ──────────────────────────────────────────────────────── -export { getSetting, listSettings, explainSetting, expandVariables, setLegacyReader, defaultLegacyReader, SCOPE_ORDER } from "./settings/resolve.ts"; +export { getSetting, listSettings, explainSetting, expandVariables, SCOPE_ORDER } from "./settings/resolve.ts"; export type { Scope, Provenance, @@ -35,7 +35,6 @@ export type { ListedSetting, ExplainRow, ExpandCtx, - LegacyReader, } from "./settings/resolve.ts"; export { setSetting } from "./settings/write.ts"; diff --git a/packages/rt-client/src/settings/__tests__/registry.test.ts b/packages/rt-client/src/settings/__tests__/registry.test.ts index aa41bfbc..7b5b45e7 100644 --- a/packages/rt-client/src/settings/__tests__/registry.test.ts +++ b/packages/rt-client/src/settings/__tests__/registry.test.ts @@ -27,7 +27,7 @@ describe("settings/registry", () => { expect(defs.length).toBeGreaterThan(0); expect(defs.map((d) => d.key)).toContain("rt.roles"); - expect(defs.map((d) => d.key)).toContain("rt.llm"); + expect(defs.map((d) => d.key)).toContain("rt.hooks"); }); test("every def has a non-empty one-line description", () => { @@ -44,11 +44,10 @@ describe("settings/registry", () => { } }); - test("every migrated:true (or suite, migrated-absent) def carries no legacyFile or siblingCommand", () => { + test("every migrated:true (or suite, migrated-absent) def carries no legacyFile", () => { for (const def of allDefs()) { if (!isMigrated(def)) continue; expect(def.legacyFile, `${def.key} is migrated but still carries a legacyFile`).toBeUndefined(); - expect(def.siblingCommand, `${def.key} is migrated but still carries a siblingCommand`).toBeUndefined(); } }); @@ -96,19 +95,13 @@ describe("settings/registry", () => { expect(def?.default).toEqual({ onDeck: 0 }); }); - test("the five migrated global singletons carry no siblingCommand or legacyFile", () => { + test("the five migrated global singletons carry no legacyFile", () => { for (const key of ["rt.notifications", "rt.cron", "rt.repoTracking", "rt.runaway", "rt.workspacePrefs"]) { const def = getDef(key); - expect(def?.siblingCommand, `${key} should carry no siblingCommand`).toBeUndefined(); expect(def?.legacyFile, `${key} should carry no legacyFile`).toBeUndefined(); } }); - test("legacyFile values match the trace for the remaining migrated:false keys", () => { - expect(getDef("rt.llm")?.legacyFile).toBe("llm.json"); - expect(getDef("rt.hooks")?.legacyFile).toBe("repos//hooks.json"); - }); - test("repoScoped is consistent with a repos//... legacyFile prefix, in both directions", () => { // Regression test for a metadata error a reviewer caught: a def whose // legacy reader is rooted at repoDataDir() (i.e. its legacyFile is @@ -150,15 +143,8 @@ describe("settings/registry", () => { expect(def?.merge).toBe("replace"); }); - test("the one remaining genuinely global legacy key stays repoScoped:undefined with a bare (non-repos/) legacyFile", () => { - const def = getDef("rt.llm"); - expect(def?.repoScoped, "rt.llm should not be repoScoped").toBeFalsy(); - expect(def?.legacyFile?.startsWith("repos/"), "rt.llm legacyFile should not be repo-prefixed").toBe(false); - }); - - test("has exactly the 2 remaining migrated:false keys, the 15 migrated:true keys, and the 30 suite keys", () => { + test("has exactly the 1 remaining migrated:false key, the 15 migrated:true keys, and the 30 suite keys", () => { const migratedFalseKeys = [ - "rt.llm", "rt.hooks", ]; const migratedTrueKeys = [ diff --git a/packages/rt-client/src/settings/__tests__/resolve.test.ts b/packages/rt-client/src/settings/__tests__/resolve.test.ts index c2c08fbf..10227715 100644 --- a/packages/rt-client/src/settings/__tests__/resolve.test.ts +++ b/packages/rt-client/src/settings/__tests__/resolve.test.ts @@ -15,26 +15,21 @@ import { tmpdir } from "os"; import { dirname, join } from "path"; import { machineSettingsPath, - repoDataDir, teamSettingsPath, teamsDir, userSettingsPath, } from "../paths.ts"; import { getDef, type SettingDef } from "../registry-machinery.ts"; import { - defaultLegacyReader, expandVariables, explainSetting, getSetting, listSettings, - setLegacyReader, type ExplainRow, - type LegacyReader, type Provenance, } from "../resolve.ts"; const IDENTITY = "gitlab.com/assured/assured-dev"; -const REPO_NAME = "assured-dev"; const TEAM = "claimview"; describe("settings/resolve", () => { @@ -50,7 +45,6 @@ describe("settings/resolve", () => { afterEach(() => { warnSpy.mockRestore(); - setLegacyReader(defaultLegacyReader); process.env.HOME = origHome; rmSync(home, { recursive: true, force: true }); }); @@ -65,9 +59,6 @@ describe("settings/resolve", () => { const writeUser = (obj: unknown) => write(userSettingsPath(), obj); const writeMachine = (obj: unknown) => write(machineSettingsPath(), obj); const writeTeam = (name: string, obj: unknown) => write(teamSettingsPath(name), obj); - const writeLegacy = (obj: unknown) => write(join(repoDataDir(REPO_NAME), "config.json"), obj); - - const legacyFile = () => join(repoDataDir(REPO_NAME), "config.json"); /** * No wave-1 key carries `teamLocked` yet, but the resolver must implement @@ -91,7 +82,6 @@ describe("settings/resolve", () => { describe("scope precedence", () => { type Layer = - | "legacy" | "team" | "user" | "team.repo" @@ -103,7 +93,6 @@ describe("settings/resolve", () => { // (rt.intercepts has no registry default; the default rung is covered by // its own test below with rt.worktrees). const LADDER: Layer[] = [ - "legacy", "team", "user", "team.repo", @@ -115,19 +104,17 @@ describe("settings/resolve", () => { const marker = (layer: Layer) => [{ id: layer }]; function fileFor(layer: Layer): string { - if (layer === "legacy") return legacyFile(); if (layer === "team" || layer === "team.repo") return teamSettingsPath(TEAM); if (layer === "user" || layer === "user.repo") return userSettingsPath(); return machineSettingsPath(); } - /** Writes all four stores so that exactly `active` layers hold a value. */ + /** Writes all three stores so that exactly `active` layers hold a value. */ function writeLayers(active: Layer[]): void { const on = (l: Layer) => active.includes(l); const global = (l: Layer) => (on(l) ? { "rt.intercepts": marker(l) } : {}); const repos = (l: Layer) => (on(l) ? { [IDENTITY]: { "rt.intercepts": marker(l) } } : {}); - writeLegacy(on("legacy") ? { intercepts: marker("legacy") } : {}); writeTeam(TEAM, { ...global("team"), repos: repos("team.repo") }); writeUser({ ...global("user"), repos: repos("user.repo") }); writeMachine({ ...global("machine"), repos: repos("machine.repo") }); @@ -141,7 +128,6 @@ describe("settings/resolve", () => { const got = getSetting>("rt.intercepts", { repoIdentity: IDENTITY, - legacy: { repoName: REPO_NAME }, }); expect(got.value).toEqual([{ id: expected }]); @@ -167,37 +153,35 @@ describe("settings/resolve", () => { // ─── deep merge ──────────────────────────────────────────────────────────── describe("deep merge", () => { - test("the spec proof case: team + user + legacy fields all survive", () => { - writeLegacy({ - worktrees: { - onDeck: 1, - root: "${repoRoot}/.worktrees", - branchFormat: "-", - namePool: ["old"], - }, - }); + test("the spec proof case: team + user + machine fields all survive", () => { writeTeam(TEAM, { "rt.worktrees": { onDeck: 3, ready: [{ run: "bun install" }, { run: "bun run build" }] }, }); writeUser({ "rt.worktrees": { namePool: ["alpha", "bravo"] } }); + writeMachine({ + "rt.worktrees": { + onDeck: 5, + root: "${repoRoot}/.worktrees", + branchFormat: "-", + }, + }); const got = getSetting>("rt.worktrees", { repoIdentity: IDENTITY, - legacy: { repoName: REPO_NAME }, expandCtx: { repoRoot: "/repos/assured-dev" }, }); expect(got.value).toEqual({ - onDeck: 3, // team beats legacy beats the registry default - root: "/repos/assured-dev/.worktrees", // legacy-only field survives (and expands) - branchFormat: "-", // legacy-only field survives + onDeck: 5, // machine beats team beats the registry default + root: "/repos/assured-dev/.worktrees", // machine-only field (and expands) + branchFormat: "-", // machine-only field survives namePool: ["alpha", "bravo"], // user-only field ready: [{ run: "bun install" }, { run: "bun run build" }], // team-only field }); expect(got.provenance).toEqual([ - { scope: "legacy", file: legacyFile() }, { scope: "team", file: teamSettingsPath(TEAM) }, { scope: "user", file: userSettingsPath() }, + { scope: "machine", file: machineSettingsPath() }, ]); }); @@ -212,20 +196,19 @@ describe("settings/resolve", () => { }); test("arrays inside a deep key replace atomically — never element-wise", () => { - writeLegacy({ worktrees: { ready: [{ run: "legacy-1" }, { run: "legacy-2" }] } }); - writeTeam(TEAM, { "rt.worktrees": { ready: [{ run: "team-only" }] } }); + writeTeam(TEAM, { "rt.worktrees": { ready: [{ run: "team-1" }, { run: "team-2" }] } }); + writeUser({ "rt.worktrees": { ready: [{ run: "user-only" }] } }); const got = getSetting<{ ready: unknown[] }>("rt.worktrees", { repoIdentity: IDENTITY, - legacy: { repoName: REPO_NAME }, }); - expect(got.value.ready).toEqual([{ run: "team-only" }]); - // legacy set ONLY `ready`, which team replaced whole, so legacy drops out + expect(got.value.ready).toEqual([{ run: "user-only" }]); + // team set ONLY `ready`, which user replaced whole, so team drops out // of provenance; the default still owns onDeck, so it stays in. expect(got.provenance).toEqual([ { scope: "default", file: null }, - { scope: "team", file: teamSettingsPath(TEAM) }, + { scope: "user", file: userSettingsPath() }, ]); }); @@ -307,25 +290,15 @@ describe("settings/resolve", () => { }); }); - test("team.repo still beats team for a locked key, and legacy is shadowed too", () => { - writeLegacy({ intercepts: [{ id: "legacy" }] }); + test("team.repo still beats team for a locked key", () => { writeTeam(TEAM, { "rt.intercepts": [{ id: "team" }], repos: { [IDENTITY]: { "rt.intercepts": [{ id: "team.repo" }] } }, }); withTeamLocked("rt.intercepts", () => { - const got = getSetting("rt.intercepts", { - repoIdentity: IDENTITY, - legacy: { repoName: REPO_NAME }, - }); + const got = getSetting("rt.intercepts", { repoIdentity: IDENTITY }); expect(got.value).toEqual([{ id: "team.repo" }]); - - const rows = explainSetting("rt.intercepts", { - repoIdentity: IDENTITY, - legacy: { repoName: REPO_NAME }, - }); - expect(rows.find((r) => r.scope === "legacy")?.shadowed).toBe("teamLocked"); }); }); }); @@ -532,15 +505,15 @@ describe("settings/resolve", () => { }); test("a non-repoScoped key ignores repo sections entirely", () => { - // rt.llm is not repoScoped. + // rt.notifications is not repoScoped. writeUser({ - "rt.llm": { provider: "ollama" }, - repos: { [IDENTITY]: { "rt.llm": { provider: "sneaky" } } }, + "rt.notifications": { pushes: true }, + repos: { [IDENTITY]: { "rt.notifications": { pushes: false } } }, }); - const got = getSetting("rt.llm", { repoIdentity: IDENTITY }); + const got = getSetting("rt.notifications", { repoIdentity: IDENTITY }); - expect(got.value).toEqual({ provider: "ollama" }); + expect(got.value).toEqual({ pushes: true }); expect(got.provenance).toEqual([{ scope: "user", file: userSettingsPath() }]); }); @@ -568,68 +541,6 @@ describe("settings/resolve", () => { }); }); - // ─── legacy layer ────────────────────────────────────────────────────────── - - describe("legacy layer", () => { - test("the default reader maps the three wave-1 keys onto repos//config.json", () => { - writeLegacy({ - roles: { be: { pool: [] } }, - intercepts: [{ id: "legacy" }], - worktrees: { onDeck: 4 }, - setup: "not a settings key", - }); - - const opts = { repoIdentity: IDENTITY, legacy: { repoName: REPO_NAME } }; - expect(getSetting("rt.roles", opts).value).toEqual({ be: { pool: [] } }); - expect(getSetting("rt.intercepts", opts).value).toEqual([{ id: "legacy" }]); - expect(getSetting("rt.worktrees", opts).value).toEqual({ onDeck: 4 }); - expect(getSetting("rt.roles", opts).provenance).toEqual([ - { scope: "legacy", file: legacyFile() }, - ]); - }); - - test("the legacy layer is only consulted when the caller supplies a repoName", () => { - writeLegacy({ intercepts: [{ id: "legacy" }] }); - - expect(getSetting("rt.intercepts", { repoIdentity: IDENTITY }).value).toBeUndefined(); - }); - - test("a key with no legacy mapping reads nothing from the legacy file", () => { - writeLegacy({ llm: { provider: "from-legacy" } }); - - expect(getSetting("rt.llm", { legacy: { repoName: REPO_NAME } }).value).toBeUndefined(); - }); - - test("a malformed legacy file degrades to no legacy value", () => { - const file = legacyFile(); - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, "{ not json"); - - expect( - getSetting("rt.intercepts", { repoIdentity: IDENTITY, legacy: { repoName: REPO_NAME } }) - .value, - ).toBeUndefined(); - }); - - test("setLegacyReader is a working test seam", () => { - const reader: LegacyReader = (key, repoName) => - key === "rt.intercepts" && repoName === REPO_NAME ? [{ id: "injected" }] : undefined; - setLegacyReader(reader); - - const got = getSetting("rt.intercepts", { - repoIdentity: IDENTITY, - legacy: { repoName: REPO_NAME }, - }); - expect(got.value).toEqual([{ id: "injected" }]); - - setLegacyReader(defaultLegacyReader); - expect( - getSetting("rt.intercepts", { repoIdentity: IDENTITY, legacy: { repoName: REPO_NAME } }) - .value, - ).toBeUndefined(); - }); - }); - // ─── listSettings / explainSetting shape ─────────────────────────────────── describe("listSettings", () => { @@ -638,24 +549,17 @@ describe("settings/resolve", () => { expect(listed[0]?.key).toBe("rt.roles"); expect(listed.find((e) => e.key === "rt.roles")?.migrated).toBe(true); - expect(listed.find((e) => e.key === "rt.llm")?.migrated).toBe(false); + expect(listed.find((e) => e.key === "rt.hooks")?.migrated).toBe(false); expect(listed.every((e) => Array.isArray(e.provenance))).toBe(true); }); - test("resolved values and the legacy layer flow into the listing", () => { - writeLegacy({ worktrees: { onDeck: 2, branchFormat: "x" } }); - writeUser({ "rt.worktrees": { onDeck: 5 } }); + test("resolved values flow into the listing", () => { + writeUser({ "rt.worktrees": { onDeck: 5, branchFormat: "x" } }); - const entry = listSettings({ - repoIdentity: IDENTITY, - legacy: { repoName: REPO_NAME }, - }).find((e) => e.key === "rt.worktrees"); + const entry = listSettings({ repoIdentity: IDENTITY }).find((e) => e.key === "rt.worktrees"); expect(entry?.value).toEqual({ onDeck: 5, branchFormat: "x" }); - expect(entry?.provenance).toEqual([ - { scope: "legacy", file: legacyFile() }, - { scope: "user", file: userSettingsPath() }, - ]); + expect(entry?.provenance).toEqual([{ scope: "user", file: userSettingsPath() }]); }); test("unregistered entries sort after the registered ones", () => { @@ -673,14 +577,10 @@ describe("settings/resolve", () => { test("returns one row per reachable rung, weakest-first, with files and presence", () => { writeTeam(TEAM, { repos: { [IDENTITY]: { "rt.worktrees": { onDeck: 3 } } } }); - const rows = explainSetting("rt.worktrees", { - repoIdentity: IDENTITY, - legacy: { repoName: REPO_NAME }, - }); + const rows = explainSetting("rt.worktrees", { repoIdentity: IDENTITY }); expect(rows.map((r) => r.scope)).toEqual([ "default", - "legacy", "team", "user", "team.repo", @@ -689,7 +589,6 @@ describe("settings/resolve", () => { "machine.repo", ]); expect(rows[0]).toEqual({ scope: "default", file: null, present: true, value: { onDeck: 0 } }); - expect(rows.find((r) => r.scope === "legacy")?.present).toBe(false); const teamRepo = rows.find((r) => r.scope === "team.repo") as ExplainRow; expect(teamRepo.present).toBe(true); expect(teamRepo.file).toBe(teamSettingsPath(TEAM)); diff --git a/packages/rt-client/src/settings/__tests__/stores.test.ts b/packages/rt-client/src/settings/__tests__/stores.test.ts index 1e44dd15..3e070f16 100644 --- a/packages/rt-client/src/settings/__tests__/stores.test.ts +++ b/packages/rt-client/src/settings/__tests__/stores.test.ts @@ -34,7 +34,7 @@ describe("settings/stores", () => { file, `{ // a global key with a trailing comment - "rt.llm": { "provider": "ollama", "model": "qwen3" }, + "rt.hooks": { "provider": "ollama", "model": "qwen3" }, "repos": { "gitlab.com/assured/assured-dev": { "rt.roles": { "backend": { "pool": [] } }, @@ -47,7 +47,7 @@ describe("settings/stores", () => { expect(store.file).toBe(file); expect(store.exists).toBe(true); - expect(store.global).toEqual({ "rt.llm": { provider: "ollama", model: "qwen3" } }); + expect(store.global).toEqual({ "rt.hooks": { provider: "ollama", model: "qwen3" } }); expect(store.repos).toEqual({ "gitlab.com/assured/assured-dev": { "rt.roles": { backend: { pool: [] } } }, }); diff --git a/packages/rt-client/src/settings/registry-defs.ts b/packages/rt-client/src/settings/registry-defs.ts index ce514a72..bdd60fa6 100644 --- a/packages/rt-client/src/settings/registry-defs.ts +++ b/packages/rt-client/src/settings/registry-defs.ts @@ -148,16 +148,7 @@ export const REGISTRY: readonly SettingDef[] = [ description: "Template used to generate a repo's Doppler secrets config.", }, - // --- migrated:false (wave 1 legacy-file keys) --------------------------- - { - key: "rt.llm", - type: "object", - scopes: ALL_SCOPES, - merge: "deep", - migrated: false, - legacyFile: "llm.json", - description: "LLM provider and model selection for rt's AI-assisted commands.", - }, + // --- migrated:false (deferred by ruling) -------------------------------- { key: "rt.hooks", type: "object", diff --git a/packages/rt-client/src/settings/registry-machinery.ts b/packages/rt-client/src/settings/registry-machinery.ts index 7ee1378a..90800d81 100644 --- a/packages/rt-client/src/settings/registry-machinery.ts +++ b/packages/rt-client/src/settings/registry-machinery.ts @@ -38,7 +38,6 @@ export interface SettingDef { repoScoped?: boolean; migrated?: boolean; legacyFile?: string; - siblingCommand?: string; pathGuardFields?: string[]; description: string; } diff --git a/packages/rt-client/src/settings/resolve.ts b/packages/rt-client/src/settings/resolve.ts index 69f46917..9c07a2a8 100644 --- a/packages/rt-client/src/settings/resolve.ts +++ b/packages/rt-client/src/settings/resolve.ts @@ -1,16 +1,11 @@ /** - * The settings resolver (RT-47): one read path that layers the four stores, - * the legacy per-repo file and the registry default into a single answer plus - * the provenance that explains it. + * The settings resolver (RT-47): one read path that layers the four stores + * and the registry default into a single answer plus the provenance that + * explains it. * * Scope ladder, weakest → strongest: * - * default < legacy < team < user < team.repo < user.repo < machine < machine.repo - * - * `legacy` is the pre-migration per-tool file for a key (wave 1: the three - * `repos//config.json` keys). It beats the registry default and loses to - * every authored store, and it carries real provenance so migration progress is - * observable rather than folklore. + * default < team < user < team.repo < user.repo < machine < machine.repo * * Merge is per-key schema, never global (`SettingDef.merge`): * - `replace` — the strongest valid scope wins atomically; provenance has @@ -34,10 +29,8 @@ * implementation: * 1. **The path-literal guard is scope-aware.** `validateValue`'s guarded * fields (`rt.roles.hook`) are only illegal in SHARED scopes. The machine - * store is explicitly allowed path literals, and the legacy per-repo file - * is full of them today — guarding those would reject exactly the values - * wave 1 has to keep reading. So team/user rungs get the full check, - * machine/legacy rungs get the type check alone. + * store is explicitly allowed path literals. So team/user rungs get the + * full check, the machine rung gets the type check alone. * 2. **A value found in a store the def does not allow is skipped**, labeled * like any other invalid value (`rt.repoIdentityOverrides` is machine-only; * honouring a team-store copy of it would defeat the schema). @@ -55,12 +48,10 @@ * Writes (`setSetting`) land in a later task; this module is read-side only. */ -import { readFileSync } from "fs"; import { homedir } from "os"; import { join } from "path"; import { machineSettingsPath, - repoDataDir, teamSettingsPath, teamsDir, userSettingsPath, @@ -77,13 +68,11 @@ export type Scope = | "team.repo" | "user" | "team" - | "legacy" | "default"; /** The scope ladder, weakest first. Also the order every result is built in. */ export const SCOPE_ORDER: Scope[] = [ "default", - "legacy", "team", "user", "team.repo", @@ -104,8 +93,6 @@ export interface ResolveOpts { /** Expand closed-set variables in the resolved value. Default true. */ expand?: boolean; expandCtx?: { repoRoot?: string; worktree?: string }; - /** Supplying a repoName enables the legacy rung for keys the reader maps. */ - legacy?: { repoName?: string }; } export interface Resolved { @@ -153,57 +140,6 @@ export interface ExpandCtx { teamsDir: string; } -// ─── The legacy rung ───────────────────────────────────────────────────────── - -export interface LegacyReader { - (key: string, repoName: string): unknown | undefined; -} - -/** - * The three wave-1 keys and the `repos//config.json` field each one used - * to live in. A key that is not in this map has no legacy rung. - */ -const LEGACY_KEY_MAP: Record = { - "rt.roles": "roles", - "rt.intercepts": "intercepts", - "rt.worktrees": "worktrees", -}; - -function legacyFilePath(repoName: string): string { - return join(repoDataDir(repoName), "config.json"); -} - -/** Read and parse a JSON file; `fallback` on any failure (missing, malformed, or read error). */ -function readJson(path: string, fallback: T): T { - try { - return JSON.parse(readFileSync(path, "utf8")) as T; - } catch { - return fallback; - } -} - -/** - * The shipped legacy reader: the named key's field out of the per-repo - * config.json. `readJson` already degrades a missing or malformed file to the - * fallback, which is exactly the "no legacy value" answer we want. - */ -export const defaultLegacyReader: LegacyReader = (key, repoName) => { - const field = LEGACY_KEY_MAP[key]; - if (field === undefined) return undefined; - const raw = readJson>(legacyFilePath(repoName), {}); - return raw[field]; -}; - -let legacyReader: LegacyReader = defaultLegacyReader; - -/** - * TEST SEAM ONLY. Production code uses the shipped reader; pass - * `defaultLegacyReader` back to restore it. - */ -export function setLegacyReader(fn: LegacyReader): void { - legacyReader = fn; -} - // ─── Variables ─────────────────────────────────────────────────────────────── const VAR_RE = /\$\{([^}]*)\}/g; @@ -332,21 +268,6 @@ function collectSlots(def: SettingDef, stores: StoreBundle, opts: ResolveOpts): : { scope: "default", file: null, present: true, value: structuredClone(def.default) }, ); - // legacy — only reachable when the caller supplies the repo NAME (repos.json - // is the name→path registry; the name/identity bridge is the caller's job). - const repoName = opts.legacy?.repoName; - if (repoName) { - const value = legacyReader(def.key, repoName); - const file = legacyFilePath(repoName); - slots.push( - value === undefined - ? { scope: "legacy", file, present: false } - : { scope: "legacy", file, present: true, value }, - ); - } else { - slots.push({ scope: "legacy", file: null, present: false }); - } - // The ladder itself, weakest → strongest. Repo rungs are omitted entirely // when they are unreachable (key not repoScoped, or no identity in hand) — // an unreachable rung in `explain` would be noise, not honesty. @@ -376,13 +297,12 @@ function baseScope(scope: Scope): SettingScope | null { if (scope === "team" || scope === "team.repo") return "team"; if (scope === "user" || scope === "user.repo") return "user"; if (scope === "machine" || scope === "machine.repo") return "machine"; - return null; // default and legacy are not authored in a store + return null; // default is not authored in a store } /** * The path-literal guard applies to SHARED scopes only — the machine store is - * the one place path literals are legal, and the legacy per-repo file is full - * of them today. + * the one place path literals are legal. */ function validateForScope( def: SettingDef, diff --git a/packages/rt-client/src/settings/write.ts b/packages/rt-client/src/settings/write.ts index e1b28f33..dfa4ccf7 100644 --- a/packages/rt-client/src/settings/write.ts +++ b/packages/rt-client/src/settings/write.ts @@ -10,7 +10,7 @@ * document — including comments — is untouched text). * * JSONPath segments are literal object keys, not `.`-namespaced walks: a - * global write targets `[key]` (e.g. `["rt.llm"]`) and a repoScoped write + * global write targets `[key]` (e.g. `["rt.hooks"]`) and a repoScoped write * targets `["repos", identity, key]`. A dotted key like `"rt.roles"` is one * path segment, not two — jsonc-parser never splits on `.` (verified with a * throwaway script against the installed 3.3.1). Missing parents (`"repos"`, @@ -28,8 +28,8 @@ * exactly where it was written, above the object. * * ── Refusals ──────────────────────────────────────────────────────────── - * In order: unregistered key, migrated:false (naming `def.legacyFile` and, - * when present, `def.siblingCommand`), a scope the def does not list, a + * In order: unregistered key, migrated:false (naming `def.legacyFile`), a + * scope the def does not list, a * repoIdentity supplied for a key that is not `repoScoped`, a value that * fails `registry.validateValue` (type check + the path-literal guard), and * finally — only for `scope: "team"` — a team store that cannot be resolved @@ -160,8 +160,7 @@ export function setSetting(key: string, value: unknown, scope: SettingScope, opt function migratedFalseMessage(key: string, def: SettingDef): string { const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : ""; - const siblingPart = def.siblingCommand ? `; use \`${def.siblingCommand}\` instead` : ""; - return `"${key}" is not writable through the settings resolver yet${legacyPart}${siblingPart}`; + return `"${key}" is not writable through the settings resolver yet${legacyPart}`; } /** Resolves which store file a write targets, applying the team-selection rule for `scope: "team"`. */ @@ -203,7 +202,7 @@ function seedHeader(): string { * occurrence of a duplicate key by offset, while every reader (`parse`, * `JSON.parse`) takes the LAST — so a naive edit-in-place would report * success while the effective value never changes (verified with a - * throwaway script: `modify` touched offset 14 in `{"rt.llm":1,"rt.llm":2}`, + * throwaway script: `modify` touched offset 14 in `{"rt.hooks":1,"rt.hooks":2}`, * but re-parsing the "fixed" text still returned `2`). Both classes refuse * rather than silently editing around the damage — the alternative is a * write that reports success but does nothing, or one that writes a still- From d2f25e4cb9ab79420a5fcb7298394fb3c95936bb Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 21 Aug 2026 02:46:32 -0500 Subject: [PATCH 09/12] =?UTF-8?q?RT-50:=20fix=20wave=20=E2=80=94=20off-mar?= =?UTF-8?q?ker=20durability,=20HOME=20leak=20cleanup,=20restored=20coverag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rider's off-marker survived being planted but not a SUBSEQUENT unrelated write: manageTracking rebuilt the whole rt.repoTracking value from the normalized (marker-dropping) view. Adds loadMachineRepoTrackingRaw/saveRepoTrackingRaw so the write path preserves every untouched repo's raw entry, marker included. Fixes two converted test files (intercept-run, shim) that leaked the claimview team store into the shared preload HOME with no per-test isolation. Restores coverage dropped during the legacy-rung conversion: an e2e migrated:false case (retargeted to rt.hooks), two worktree-config scenarios, and resolve.test.ts's multi-scope listSettings provenance proof. Fixes three stale doc comments referencing the deleted legacy rung/siblingCommand. Deletes the now-dead reposDir/repoDataDir duplicate in packages/rt-client's path layout. Adds a CLI-wiring test driving manageTracking's off-branch through its real seams. Co-Authored-By: Claude Fable 5 --- commands/__tests__/daemon-tracking.test.ts | 116 ++++++++++++++++++ commands/daemon.ts | 36 ++++-- commands/settings-keys.ts | 7 +- e2e/tests/settings.test.ts | 6 + lib/__tests__/settings-paths-parity.test.ts | 3 +- lib/daemon/__tests__/repo-tracking.test.ts | 70 ++++++++++- lib/endpoint/__tests__/intercept-run.test.ts | 36 ++++-- lib/endpoint/__tests__/shim.test.ts | 64 ++++++---- lib/repo-tracking.ts | 58 +++++++-- lib/worktree/__tests__/config.test.ts | 27 ++++ .../src/settings/__tests__/resolve.test.ts | 10 +- packages/rt-client/src/settings/identity.ts | 2 +- packages/rt-client/src/settings/paths.ts | 13 -- packages/rt-client/src/settings/write.ts | 4 +- 14 files changed, 363 insertions(+), 89 deletions(-) create mode 100644 commands/__tests__/daemon-tracking.test.ts diff --git a/commands/__tests__/daemon-tracking.test.ts b/commands/__tests__/daemon-tracking.test.ts new file mode 100644 index 00000000..01471764 --- /dev/null +++ b/commands/__tests__/daemon-tracking.test.ts @@ -0,0 +1,116 @@ +/** + * manageTracking's off-branch — CLI wiring (the rider, RT-50). + * + * `lib/daemon-config.ts`'s RT_DIR is a MODULE-LOAD-TIME constant (frozen to + * whatever HOME was active the first time that module was imported in this + * process), so `readRepoIndex()` in commands/daemon.ts does NOT follow a + * per-test HOME repoint the way the settings stores do. Rather than fight + * that, this test drives manageTracking through its real seams as they + * actually exist: repos.json under the (ambient, process-wide) RT_DIR, and + * the settings stores under the (same, dynamically-resolved) HOME. Nothing + * is mocked — console.log is captured only to keep the run quiet. Every + * fixture is written with a name unique to this file and precisely restored + * in afterEach, since the ambient HOME is shared with every other test file + * in this process that doesn't repoint it. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { RT_DIR } from "../../lib/daemon-config.ts"; +import { machineSettingsPath, teamSettingsPath } from "../../lib/rt-paths.ts"; +import { getSetting } from "../../lib/settings/resolve.ts"; +import { manageTracking } from "../daemon.ts"; + +const REPO_NAME = "rt-rider-cli-wiring-repo"; +const TEAM_NAME = "rt-rider-cli-wiring-team"; +const IDENTITY = `rttest/${REPO_NAME}`; +const REPOS_JSON_PATH = join(RT_DIR, "repos.json"); + +function readOrNull(path: string): string | null { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** Restores `path` to its prior content, or removes it if it didn't exist before. */ +function restore(path: string, prior: string | null): void { + if (prior === null) { + try { rmSync(path, { force: true }); } catch { /* already gone */ } + } else { + writeFileSync(path, prior); + } +} + +describe("manageTracking off-branch (CLI wiring)", () => { + const origLog = console.log; + let priorReposJson: string | null; + let priorTeamStore: string | null; + let priorMachineStore: string | null; + let repoPath: string; + + beforeEach(() => { + console.log = () => {}; + + priorReposJson = readOrNull(REPOS_JSON_PATH); + priorTeamStore = readOrNull(teamSettingsPath(TEAM_NAME)); + priorMachineStore = readOrNull(machineSettingsPath()); + + // A real git repo with a fake-but-normalizable remote — identity derives + // directly (`rttest/${REPO_NAME}`), no override plumbing needed. + repoPath = realpathSync(mkdtempSync(join(tmpdir(), "rt-rider-cli-repo-"))); + execSync("git init -q", { cwd: repoPath }); + execSync(`git remote add origin git@rttest:${REPO_NAME}.git`, { cwd: repoPath }); + + mkdirSync(RT_DIR, { recursive: true }); + const repos = priorReposJson ? JSON.parse(priorReposJson) : {}; + repos[REPO_NAME] = repoPath; + writeFileSync(REPOS_JSON_PATH, JSON.stringify(repos)); + + // Team intent still declares this repo — mattstack.tracking's VALUE has + // its own "repos" field (identity → intent); it is not the store file's + // top-level repo-section sharding (that's for repo-scoped setting keys). + const teamStore = teamSettingsPath(TEAM_NAME); + mkdirSync(dirname(teamStore), { recursive: true }); + writeFileSync(teamStore, JSON.stringify({ + "mattstack.tracking": { repos: { [IDENTITY]: { caches: ["branches"] } } }, + })); + + // An existing machine grant for it, as if it had been tracked already. + const machineStore = machineSettingsPath(); + mkdirSync(dirname(machineStore), { recursive: true }); + const machine = priorMachineStore ? JSON.parse(priorMachineStore) : {}; + machine["rt.repoTracking"] = { + ...(machine["rt.repoTracking"] ?? {}), + [REPO_NAME]: { mode: "live", caches: ["branches"] }, + }; + writeFileSync(machineStore, JSON.stringify(machine)); + }); + + afterEach(() => { + console.log = origLog; + rmSync(repoPath, { recursive: true, force: true }); + restore(REPOS_JSON_PATH, priorReposJson); + restore(teamSettingsPath(TEAM_NAME), priorTeamStore); + restore(machineSettingsPath(), priorMachineStore); + }); + + test("off on a team-tracked repo plants an explicit {mode:\"off\"} marker, not a delete", async () => { + await manageTracking([REPO_NAME, "off"]); + + const saved = getSetting>("rt.repoTracking").value; + expect(saved[REPO_NAME]).toEqual({ mode: "off" }); + }); + + test("off on a repo the team no longer names deletes outright", async () => { + writeFileSync(teamSettingsPath(TEAM_NAME), JSON.stringify({ "mattstack.tracking": { repos: {} } })); + + await manageTracking([REPO_NAME, "off"]); + + const saved = getSetting>("rt.repoTracking").value; + expect(saved[REPO_NAME]).toBeUndefined(); + }); +}); diff --git a/commands/daemon.ts b/commands/daemon.ts index fda1cb65..383c8688 100644 --- a/commands/daemon.ts +++ b/commands/daemon.ts @@ -33,8 +33,8 @@ import { import { daemonQuery, isDaemonRunning, trayQuery } from "../lib/daemon-client.ts"; import { classifyDaemonStatus, type DaemonStatusVerdict } from "../lib/daemon-status.ts"; import { isGitLabRemote } from "../lib/enrich.ts"; -import type { CacheKind } from "../lib/repo-tracking.ts"; -import { loadRepoTracking, loadMachineRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, DEFAULT_PROJECT_MRS_WINDOW_DAYS, teamNamesIdentity } from "../lib/repo-tracking.ts"; +import type { CacheKind, RepoTrackingEntry } from "../lib/repo-tracking.ts"; +import { loadRepoTracking, loadMachineRepoTracking, loadMachineRepoTrackingRaw, saveRepoTrackingRaw, grants, parseCachesArg, CACHE_KINDS, DEFAULT_PROJECT_MRS_WINDOW_DAYS, teamNamesIdentity } from "../lib/repo-tracking.ts"; import { deriveRepoIdentity } from "../lib/settings/identity.ts"; import { createProjectMRs } from "../lib/daemon/project-mrs-store.ts"; import { getStateDb } from "../lib/state/index.ts"; @@ -468,21 +468,32 @@ export async function manageTracking(args: string[] = []): Promise { } } - // Machine-only read: this is a read-modify-write, and saveRepoTracking - // writes back everything it's handed — a merged (loadRepoTracking) read - // would bake every other repo's team-synthesized entry into the machine - // store as if a human had granted it. + // `previousEntry` only ever feeds the window/caches-reset bookkeeping below, + // which only cares about valid live/poll entries, so the NORMALIZED view is + // fine for it. The WRITE itself must start from the RAW map instead: + // loadMachineRepoTracking() drops any entry normalizeEntry rejects — a + // typo'd mode, or another repo's explicit {mode:"off"} opt-out marker — so + // rebuilding the whole store from it would silently erase that marker the + // moment ANY repo's tracking is next written (the bug the rider fixes). A + // merged (loadRepoTracking) read must never be the base either — that would + // bake every other repo's team-synthesized entry into the machine store as + // if a human had granted it. const tracking = loadMachineRepoTracking(); + const rawTracking = loadMachineRepoTrackingRaw(); const previousEntry = levelArg2 !== "off" ? tracking[repoArg] : undefined; - let offMarker: string | undefined; + let offMarker = false; + let newEntry: RepoTrackingEntry | undefined; if (levelArg2 === "off") { - delete tracking[repoArg]; + delete rawTracking[repoArg]; // A repo the team layer still declares intent for needs a raw-named // block, not a bare delete — otherwise the merge in loadRepoTracking // resurrects team intent for it on the very next read. const repoPath = readRepoIndex()[repoArg]; const identity = repoPath ? await deriveRepoIdentity(repoPath) : null; - if (identity && teamNamesIdentity(identity)) offMarker = repoArg; + if (identity && teamNamesIdentity(identity)) { + rawTracking[repoArg] = { mode: "off" }; + offMarker = true; + } } else { // Only the interactive editor ever touches the window; the positional // CLI form (rt daemon track live [caches]) carries whatever the @@ -490,14 +501,15 @@ export async function manageTracking(args: string[] = []): Promise { const windowDays = interactiveWindowDays !== undefined ? (interactiveWindowDays ?? undefined) : previousEntry?.projectMrsWindowDays; - tracking[repoArg] = { + newEntry = { mode: levelArg2 as "live" | "poll", caches, ...(windowDays !== undefined ? { projectMrsWindowDays: windowDays } : {}), }; + rawTracking[repoArg] = newEntry; } - saveRepoTracking(tracking, offMarker ? [offMarker] : []); - console.log(`\n ${green}✓${reset} ${repoArg} tracking: ${levelArg2}${levelArg2 === "off" ? "" : ` [${caches.join(", ")}] window ${formatWindowLabel(tracking[repoArg]?.projectMrsWindowDays)}`}`); + saveRepoTrackingRaw(rawTracking); + console.log(`\n ${green}✓${reset} ${repoArg} tracking: ${levelArg2}${levelArg2 === "off" ? "" : ` [${caches.join(", ")}] window ${formatWindowLabel(newEntry?.projectMrsWindowDays)}`}`); if (offMarker) { console.log(` ${dim}${repoArg} is still team-tracked — recorded as a local opt-out (rt daemon track ${repoArg} live to re-enable)${reset}`); } diff --git a/commands/settings-keys.ts b/commands/settings-keys.ts index 05f045d5..05d4d989 100644 --- a/commands/settings-keys.ts +++ b/commands/settings-keys.ts @@ -137,10 +137,9 @@ export function formatProvenance(provenance: Provenance[]): string { } /** - * The `migrated:false` loud-degrade label: "reads legacy: " plus the - * sibling live command when the registry names one — spec: "list LABELS - * them (`reads legacy: `)". Returns null for a migrated key (nothing - * to render). + * The `migrated:false` loud-degrade label: "reads legacy: " — spec: + * "list LABELS them (`reads legacy: `)". Returns null for a migrated + * key (nothing to render). */ export function migratedNote(def: SettingDef): string | null { if (isMigrated(def)) return null; diff --git a/e2e/tests/settings.test.ts b/e2e/tests/settings.test.ts index 34f60a45..57dc91db 100644 --- a/e2e/tests/settings.test.ts +++ b/e2e/tests/settings.test.ts @@ -371,6 +371,12 @@ describe("rt settings (four stores, one resolver — e2e)", () => { ]); }, 30_000); + test("get labels a migrated:false key with the legacy file it still reads", async () => { + const out = await rtJson(["settings", "get", "rt.hooks", "--json"]); + expect(out.migrated).toBe(false); + expect(out.legacyFile).toBe("repos//hooks.json"); + }, 30_000); + test("list reports migrated flags and labels the team store's unregistered key", async () => { const out = await rtJson(["settings", "list", "--repo", REPO_NAME, "--json"]); const byKey = new Map(out.settings.map((s: any) => [s.key, s])); diff --git a/lib/__tests__/settings-paths-parity.test.ts b/lib/__tests__/settings-paths-parity.test.ts index 91cc73ed..ae141660 100644 --- a/lib/__tests__/settings-paths-parity.test.ts +++ b/lib/__tests__/settings-paths-parity.test.ts @@ -18,14 +18,13 @@ describe("settings paths parity (lib/rt-paths.ts vs rt-client/settings/paths.ts) process.env.HOME = origHome; }); - test("userSettingsPath/teamSettingsPath/machineSettingsPath/teamsDir/repoDataDir agree under a faked HOME", () => { + test("userSettingsPath/teamSettingsPath/machineSettingsPath/teamsDir agree under a faked HOME", () => { process.env.HOME = "/tmp/parity-fake-home"; expect(clientPaths.userSettingsPath()).toBe(rtPaths.userSettingsPath()); expect(clientPaths.teamSettingsPath("someteam")).toBe(rtPaths.teamSettingsPath("someteam")); expect(clientPaths.machineSettingsPath()).toBe(rtPaths.machineSettingsPath()); expect(clientPaths.teamsDir()).toBe(rtPaths.teamsDir()); - expect(clientPaths.repoDataDir("some-repo")).toBe(rtPaths.repoDataDir("some-repo")); }); test("both resolve HOME at call time, not module load", () => { diff --git a/lib/daemon/__tests__/repo-tracking.test.ts b/lib/daemon/__tests__/repo-tracking.test.ts index a1171042..58796ba0 100644 --- a/lib/daemon/__tests__/repo-tracking.test.ts +++ b/lib/daemon/__tests__/repo-tracking.test.ts @@ -8,8 +8,9 @@ import { setSetting } from "../../settings/write.ts"; import { runCapture } from "../../subprocess.ts"; import { clearIdentityMemo } from "../../settings/identity.ts"; import { - loadRepoTracking, loadMachineRepoTracking, grants, saveRepoTracking, parseCachesArg, CACHE_KINDS, - primeTeamTrackingIdentityMap, teamNamesIdentity, + loadRepoTracking, loadMachineRepoTracking, loadMachineRepoTrackingRaw, grants, saveRepoTracking, + saveRepoTrackingRaw, parseCachesArg, CACHE_KINDS, primeTeamTrackingIdentityMap, teamNamesIdentity, + type CacheKind, } from "../../repo-tracking.ts"; function writeStore(file: string, obj: unknown): void { @@ -349,6 +350,71 @@ describe("loadMachineRepoTracking — the machine-only read (no team merge)", () }); }); +describe("loadMachineRepoTrackingRaw / saveRepoTrackingRaw — off-marker durability", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-tracking-rawoff-"))); + process.env.HOME = home; + seedTeam(); + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + + /** manageTracking's actual read-modify-write algorithm, minus the CLI/daemonQuery plumbing. */ + function trackOff(repo: string, identity: string | null): void { + const raw = loadMachineRepoTrackingRaw(); + delete raw[repo]; + if (identity && teamNamesIdentity(identity)) raw[repo] = { mode: "off" }; + saveRepoTrackingRaw(raw); + } + + function trackLive(repo: string, caches: CacheKind[] = ["branches"]): void { + const raw = loadMachineRepoTrackingRaw(); + raw[repo] = { mode: "live", caches }; + saveRepoTrackingRaw(raw); + } + + test("A's off-marker survives an unrelated B write, is replaced when A is turned live again, and a non-team A still deletes plainly", () => { + setSetting("rt.repoTracking", { a: { mode: "live", caches: ["branches"] } }, "machine"); + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/a": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + + // track A off — team-named, so A gets an explicit marker. + trackOff("a", "gitlab.com/acme/a"); + expect(getSetting>("rt.repoTracking").value.a).toEqual({ mode: "off" }); + + // track B live — an UNRELATED write. Before the fix this read-modify-write + // started from the normalized view, which drops A's marker entirely. + trackLive("b"); + const afterB = getSetting>("rt.repoTracking").value; + expect(afterB.a).toEqual({ mode: "off" }); // A's marker must still be there + expect(afterB.b).toEqual({ mode: "live", caches: ["branches"] }); + + // The merge must still show A as off, not resurrected by team intent. + const merged = loadRepoTracking({ identityMap: { "gitlab.com/acme/a": "a" } }); + expect(grants(merged, "a").mode).toBe("off"); + + // track A live again — the marker is replaced by a real grant. + trackLive("a", ["project-mrs"]); + const afterALive = getSetting>("rt.repoTracking").value; + expect(afterALive.a).toEqual({ mode: "live", caches: ["project-mrs"] }); + expect(afterALive.b).toEqual({ mode: "live", caches: ["branches"] }); // B untouched + + // track A off once more, but the team no longer names it — plain delete. + setSetting("mattstack.tracking", { repos: {} }, "team", { team: "acme" }); + trackOff("a", "gitlab.com/acme/a"); + const afterFinal = getSetting>("rt.repoTracking").value; + expect(afterFinal.a).toBeUndefined(); + expect(afterFinal.b).toEqual({ mode: "live", caches: ["branches"] }); // B still untouched + }); +}); + describe("primeTeamTrackingIdentityMap", () => { const origHome = process.env.HOME; let home: string; diff --git a/lib/endpoint/__tests__/intercept-run.test.ts b/lib/endpoint/__tests__/intercept-run.test.ts index cdadaf68..51e16e1b 100644 --- a/lib/endpoint/__tests__/intercept-run.test.ts +++ b/lib/endpoint/__tests__/intercept-run.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { teamSettingsPath } from "../../rt-paths.ts"; import { runInterception } from "../run.ts"; -/** Merges `identity`'s roles into the shared team store rather than clobbering earlier entries — every test in this file shares one preload HOME. */ +/** Merges `identity`'s roles into the shared team store rather than clobbering earlier entries — every test in this describe shares one per-test HOME. */ function writeRepoRoles(identity: string, roles: unknown): void { const path = teamSettingsPath("claimview"); mkdirSync(join(path, ".."), { recursive: true }); @@ -20,15 +20,6 @@ function writeRepoRoles(identity: string, roles: unknown): void { const R1_IDENTITY = "x/test/r1"; const R1_REMOTE = "git@x:test/r1.git"; -// Role "web" for repo "r1" — reached via `loadEndpointConfig` -// (lib/endpoint/config.ts) inside runInterception's env step, keyed by the -// identity `identityFromRemote` derives from the rule's own `repoRemote`. -// env renders ${port}; preserveEnv protects the caller's KEEP_* vars (both -// feed argInject's ${envKeys}). -writeRepoRoles(R1_IDENTITY, { - web: { env: { PORT: "${port}" }, preserveEnv: ["KEEP_*"] }, -}); - function harness(over: Partial[0]> = {}) { const calls: { exec?: { bin: string; args: string[]; env: Record }; warned: string[] } = { warned: [] }; const deps = { @@ -49,6 +40,27 @@ const run = (deps: any, args: string[], env: Record runInterception(deps, "fakecmd", args, "/wt/a", { PATH: "/usr/bin", ...env }, 42).catch((e) => { if (e.message !== "EXEC") throw e; }); describe("runInterception", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-intercept-run-"))); + process.env.HOME = home; + // Role "web" for repo "r1" — reached via `loadEndpointConfig` + // (lib/endpoint/config.ts) inside runInterception's env step, keyed by + // the identity `identityFromRemote` derives from the rule's own + // `repoRemote`. env renders ${port}; preserveEnv protects the caller's + // KEEP_* vars (both feed argInject's ${envKeys}). + writeRepoRoles(R1_IDENTITY, { + web: { env: { PORT: "${port}" }, preserveEnv: ["KEEP_*"] }, + }); + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + test("match → claim → env rendered, preserveEnv expanded into argInject, exec real", async () => { const { deps, calls } = harness(); await run(deps, ["run", "serve"], { KEEP_ME: "1" }); diff --git a/lib/endpoint/__tests__/shim.test.ts b/lib/endpoint/__tests__/shim.test.ts index e2d5b693..fa5f9064 100644 --- a/lib/endpoint/__tests__/shim.test.ts +++ b/lib/endpoint/__tests__/shim.test.ts @@ -145,6 +145,19 @@ test("loadInterceptRules degrades to [] on a missing or malformed file", () => { // ─── buildInterceptRules ───────────────────────────────────────────────────── describe("buildInterceptRules", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-shim-build-"))); + process.env.HOME = home; + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + test("flattens repo index x per-repo intercepts, skips repos with none or no derivable identity, captures repoRemote", async () => { const repoWithRemote = makeGitRepo("git@x:assured/assured-dev.git"); const repoEmptyRemote = makeGitRepo("git@x:assured/empty-repo.git"); @@ -165,33 +178,17 @@ describe("buildInterceptRules", () => { }); test("a repo whose intercepts live in a settings store still gets a rule (remote captured before the resolver is consulted)", async () => { - const home = realpathSync(mkdtempSync(join(tmpdir(), "rt-shim-store-"))); - const origHome = process.env.HOME; - process.env.HOME = home; - try { - const repoPath = makeGitRepo("git@gitlab.com:fake/store-repo.git"); - writeRepoIndex({ "r-store": repoPath }); - const store = teamSettingsPath("claimview"); - mkdirSync(dirname(store), { recursive: true }); - writeFileSync(store, JSON.stringify({ - repos: { - "gitlab.com/fake/store-repo": { - "rt.intercepts": [{ command: "storecmd", matches: [{ cwdGlob: ".", role: "web" }] }], - }, - }, - })); - - const built = await buildInterceptRules(); - expect(built).toEqual([{ - command: "storecmd", - repo: "r-store", - repoRemote: "git@gitlab.com:fake/store-repo.git", - matches: [{ cwdGlob: ".", role: "web" }], - }]); - } finally { - process.env.HOME = origHome; - rmSync(home, { recursive: true, force: true }); - } + const repoPath = makeGitRepo("git@gitlab.com:fake/store-repo.git"); + writeRepoIndex({ "r-store": repoPath }); + writeRepoIntercepts("gitlab.com/fake/store-repo", [{ command: "storecmd", matches: [{ cwdGlob: ".", role: "web" }] }]); + + const built = await buildInterceptRules(); + expect(built).toEqual([{ + command: "storecmd", + repo: "r-store", + repoRemote: "git@gitlab.com:fake/store-repo.git", + matches: [{ cwdGlob: ".", role: "web" }], + }]); }); test("multiple intercept entries in one repo produce one rule each", async () => { @@ -209,6 +206,19 @@ describe("buildInterceptRules", () => { // ─── installShims / uninstallShims / shimReport ────────────────────────────── describe("installShims / uninstallShims / shimReport", () => { + const origHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), "rt-shim-install-"))); + process.env.HOME = home; + }); + + afterEach(() => { + process.env.HOME = origHome; + rmSync(home, { recursive: true, force: true }); + }); + test("installs a shim per distinct command, classifies installed vs current, uninstall removes only marker files", async () => { const repoPath = makeGitRepo("git@x:assured/r-install.git"); writeRepoIntercepts("x/assured/r-install", [{ command: "fakecmd-a", matches: [{ cwdGlob: ".", role: "x" }] }]); diff --git a/lib/repo-tracking.ts b/lib/repo-tracking.ts index 95beca23..c1eb63c4 100644 --- a/lib/repo-tracking.ts +++ b/lib/repo-tracking.ts @@ -149,21 +149,27 @@ interface MachineTrackingRead { * `{mode:"off"}` entry still names its repo here even though it produced no `out` entry. * This is the set `loadRepoTracking` gates team intent on, not `out`'s keys. */ rawNames: Set; + /** Every repo name's RAW authored value, unnormalized — includes entries `out` drops + * (a typo'd mode, or an explicit `{mode:"off"}` opt-out marker). The only base a + * read-modify-write may rebuild the WHOLE map from without silently erasing one of + * those — see `loadMachineRepoTrackingRaw`/`saveRepoTrackingRaw`. */ + raw: Record; } function readMachineTracking(): MachineTrackingRead { - let raw: unknown; + let rawValue: unknown; try { - raw = getSetting("rt.repoTracking").value; + rawValue = getSetting("rt.repoTracking").value; } catch (err) { console.warn(`rt: rt.repoTracking could not be resolved (${err instanceof Error ? err.message : err}) — tracking nothing`); - return { out: {}, rawNames: new Set() }; + return { out: {}, rawNames: new Set(), raw: {} }; } const out: RepoTracking = {}; const rawNames = new Set(); - if (raw && typeof raw === "object" && !Array.isArray(raw)) { - let repos = raw as Record; + const raw: Record = {}; + if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) { + let repos = rawValue as Record; if (isVersionedEnvelope(repos)) { console.warn( "rt: rt.repoTracking holds a versioned {version, repos} envelope — store the repos map, not the versioned envelope " + @@ -173,11 +179,12 @@ function readMachineTracking(): MachineTrackingRead { } for (const [repo, value] of Object.entries(repos)) { rawNames.add(repo); + raw[repo] = value; const entry = normalizeEntry(value); if (entry) out[repo] = entry; } } - return { out, rawNames }; + return { out, rawNames, raw }; } /** @@ -195,6 +202,19 @@ export function loadMachineRepoTracking(): RepoTracking { return readMachineTracking().out; } +/** + * The raw machine map, unnormalized — every repo's authored value exactly as + * stored, including entries `normalizeEntry` rejects (a typo'd mode, or an + * explicit `{mode:"off"}` opt-out marker). `loadMachineRepoTracking()` drops + * those, so a read-modify-write that rebuilds the WHOLE `rt.repoTracking` + * value must start here instead, or it silently erases another repo's + * off-marker (or any other raw value) the moment ANY repo's tracking is next + * written — see `saveRepoTrackingRaw`, the companion writer. + */ +export function loadMachineRepoTrackingRaw(): Record { + return readMachineTracking().raw; +} + /** * Whether `mattstack.tracking`'s team-authored `repos` map names `identity` * at all — any value, valid or not. What `rt daemon track off` needs @@ -232,12 +252,31 @@ export function grants(tracking: RepoTracking, repoName: string): RepoGrants { projectMrsWindowDays: entry.projectMrsWindowDays ?? DEFAULT_PROJECT_MRS_WINDOW_DAYS }; } +/** + * Writes an already-assembled raw repo → value map to the machine store, + * sorted for stable diffs. The companion to `loadMachineRepoTrackingRaw`: a + * read-modify-write that must preserve an untouched repo's off-marker (or + * any other raw value) starts from that raw map, mutates only the repo(s) it + * means to change, and saves through here. + */ +export function saveRepoTrackingRaw(raw: Record): void { + const repos = Object.fromEntries( + Object.entries(raw).sort(([a], [b]) => a.localeCompare(b)), + ); + setSetting("rt.repoTracking", repos, "machine"); +} + /** * Writes the flat repo → entry map to the machine store, repos sorted for * stable diffs. NEVER pass a merged/primed read (`loadRepoTracking`'s * output) here — a caller doing read-modify-write must start from * `loadMachineRepoTracking()`, or every other repo's team-synthesized entry - * gets baked into the machine store as if a human had granted it. + * gets baked into the machine store as if a human had granted it. This is a + * fixture-convenience wrapper over `saveRepoTrackingRaw`: it only ever sees + * NORMALIZED entries, so it is NOT safe for a read-modify-write that must + * preserve an existing off-marker for some OTHER repo this write doesn't + * touch — that needs `loadMachineRepoTrackingRaw`/`saveRepoTrackingRaw` + * directly (see `commands/daemon.ts`'s `manageTracking`). * * `offMarkers` plants an explicit `{mode:"off"}` entry for each name listed — * `normalizeEntry` rejects that shape (mode "off" is not a valid grant), but @@ -251,10 +290,7 @@ export function grants(tracking: RepoTracking, repoName: string): RepoGrants { export function saveRepoTracking(tracking: RepoTracking, offMarkers: string[] = []): void { const merged: Record = { ...tracking }; for (const name of offMarkers) merged[name] = { mode: "off" }; - const repos = Object.fromEntries( - Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)), - ); - setSetting("rt.repoTracking", repos, "machine"); + saveRepoTrackingRaw(merged); } /** "branches, project-mrs" → kinds. Null on empty input or any unknown name. */ diff --git a/lib/worktree/__tests__/config.test.ts b/lib/worktree/__tests__/config.test.ts index 1d9e2739..e823693b 100644 --- a/lib/worktree/__tests__/config.test.ts +++ b/lib/worktree/__tests__/config.test.ts @@ -180,6 +180,25 @@ describe("worktree config", () => { const cfg = await loadWorktreeRepoConfig("assured-dev", repoPath); expect(cfg.namePool).toEqual(["luna"]); }); + + test("a repo with no derivable identity honestly degrades to pure defaults", async () => { + // Not a git repo at all: deriveRepoIdentity resolves null, so the + // store's repo section — declared for a DIFFERENT identity here to + // prove it can never leak in — is simply unreachable. No legacy + // fallback exists anymore; defaults are the honest answer. + const repoPath = tmpRepoPath("rtcfg-noident-"); + writeStore(teamSettingsPath("claimview"), { + repos: { [IDENTITY]: { "rt.worktrees": { onDeck: 9 } } }, + }); + + const cfg = await loadWorktreeRepoConfig("assured-dev", repoPath); + expect(cfg).toEqual({ + onDeck: 0, + root: join(repoPath, ".worktrees"), + branchFormat: "-", + ready: [], + }); + }); }); describe("worktreeSettingsDeclared", () => { @@ -206,6 +225,14 @@ describe("worktree config", () => { }); expect(await worktreeSettingsDeclared("store-only", repoPath)).toBe(true); }); + + test("a repo section with other keys but no worktrees block -> false", async () => { + const repoPath = tmpRepoWithRemote("rtcfg-act-other-", REMOTE); + writeStore(teamSettingsPath("claimview"), { + repos: { [IDENTITY]: { "rt.roles": { web: { pool: [3000] } } } }, + }); + expect(await worktreeSettingsDeclared("store-only", repoPath)).toBe(false); + }); }); describe("resolveImplicitInstall", () => { diff --git a/packages/rt-client/src/settings/__tests__/resolve.test.ts b/packages/rt-client/src/settings/__tests__/resolve.test.ts index 10227715..e913ab7d 100644 --- a/packages/rt-client/src/settings/__tests__/resolve.test.ts +++ b/packages/rt-client/src/settings/__tests__/resolve.test.ts @@ -553,13 +553,17 @@ describe("settings/resolve", () => { expect(listed.every((e) => Array.isArray(e.provenance))).toBe(true); }); - test("resolved values flow into the listing", () => { - writeUser({ "rt.worktrees": { onDeck: 5, branchFormat: "x" } }); + test("resolved values flow into the listing, with multi-scope provenance", () => { + writeTeam(TEAM, { "rt.worktrees": { onDeck: 3, branchFormat: "x" } }); + writeUser({ "rt.worktrees": { onDeck: 5 } }); const entry = listSettings({ repoIdentity: IDENTITY }).find((e) => e.key === "rt.worktrees"); expect(entry?.value).toEqual({ onDeck: 5, branchFormat: "x" }); - expect(entry?.provenance).toEqual([{ scope: "user", file: userSettingsPath() }]); + expect(entry?.provenance).toEqual([ + { scope: "team", file: teamSettingsPath(TEAM) }, + { scope: "user", file: userSettingsPath() }, + ]); }); test("unregistered entries sort after the registered ones", () => { diff --git a/packages/rt-client/src/settings/identity.ts b/packages/rt-client/src/settings/identity.ts index 4b4a7dfb..d0c091d8 100644 --- a/packages/rt-client/src/settings/identity.ts +++ b/packages/rt-client/src/settings/identity.ts @@ -8,7 +8,7 @@ * the same identity. A remote that doesn't match a recognized host form * (bare local paths are the main case — repos.json has two) normalizes to * null, meaning repo-scoped sections are unreachable for it and only global - * scopes + legacy apply. That's an honest degrade, not a crash. + * scopes apply. That's an honest degrade, not a crash. * * Three entry points: * - `normalizeRemote` is the pure string transform, no I/O. diff --git a/packages/rt-client/src/settings/paths.ts b/packages/rt-client/src/settings/paths.ts index 598875ae..86ccf02d 100644 --- a/packages/rt-client/src/settings/paths.ts +++ b/packages/rt-client/src/settings/paths.ts @@ -16,19 +16,6 @@ function home(): string { return process.env.HOME ?? homedir(); } -function rtDir(): string { - return join(home(), ".mattstack", "rt"); -} - -function reposDir(): string { - return join(rtDir(), "repos"); -} - -/** ~/.mattstack/rt/repos/ — a single repo's data directory. */ -export function repoDataDir(repoName: string): string { - return join(reposDir(), repoName); -} - /** ~/.mattstack/user/settings.jsonc — the user store. */ export function userSettingsPath(): string { return join(home(), ".mattstack", "user", "settings.jsonc"); diff --git a/packages/rt-client/src/settings/write.ts b/packages/rt-client/src/settings/write.ts index dfa4ccf7..ce331367 100644 --- a/packages/rt-client/src/settings/write.ts +++ b/packages/rt-client/src/settings/write.ts @@ -1,7 +1,7 @@ /** * The settings write path (RT-47): `setSetting` — a single, comment-preserving - * write into one of the three AUTHORED stores (user/team/machine; `legacy` and - * `default` are read-only rungs and never appear here). + * write into one of the three AUTHORED stores (user/team/machine; `default` + * is a read-only rung and never appears here). * * Writes go through jsonc-parser's `modify`/`applyEdits` rather than * parse-mutate-stringify, so existing comments and formatting in the store From 6bbd78beca9c7f8cadee498bfcf9ca01ad37254a Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 21 Aug 2026 03:03:03 -0500 Subject: [PATCH 10/12] RT-50: keys-wave docs + gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerates the command reference: rt settings llm's page is gone (verb deleted in Task 4) and unrelated pre-existing generator drift (home/secrets pages, rebrand description text) is caught up in the same run. Live docs (README, website guides, docs/) never described any of the migrated/deleted keys or files, so no prose changes were needed — grep swept every dead filename and verb across all in-scope files with zero hits. Full wave gates: tsc 0, lib/commands/packages unit suites green, docs:check clean. Full e2e has 8 pre-existing failures in e2e/tests/endpoint.test.ts, traced to Task 4's legacy config.json rung removal (that test still seeds repo config through the dead file); reported, not fixed here, per scope. Co-Authored-By: Claude Fable 5 --- website/docs/reference/home/index.mdx | 25 +++++++++++++++++ website/docs/reference/home/init.mdx | 26 ++++++++++++++++++ website/docs/reference/home/key/export.mdx | 20 ++++++++++++++ website/docs/reference/home/key/index.mdx | 24 +++++++++++++++++ website/docs/reference/secrets/index.mdx | 26 ++++++++++++++++++ website/docs/reference/secrets/list.mdx | 26 ++++++++++++++++++ website/docs/reference/secrets/rotate.mdx | 28 ++++++++++++++++++++ website/docs/reference/secrets/set.mdx | 28 ++++++++++++++++++++ website/docs/reference/settings/dev-mode.mdx | 2 +- website/docs/reference/settings/index.mdx | 3 +-- website/docs/reference/settings/llm.mdx | 20 -------------- website/docs/reference/update.mdx | 2 +- website/docs/reference/verify.mdx | 2 +- 13 files changed, 207 insertions(+), 25 deletions(-) create mode 100644 website/docs/reference/home/index.mdx create mode 100644 website/docs/reference/home/init.mdx create mode 100644 website/docs/reference/home/key/export.mdx create mode 100644 website/docs/reference/home/key/index.mdx create mode 100644 website/docs/reference/secrets/index.mdx create mode 100644 website/docs/reference/secrets/list.mdx create mode 100644 website/docs/reference/secrets/rotate.mdx create mode 100644 website/docs/reference/secrets/set.mdx delete mode 100644 website/docs/reference/settings/llm.mdx diff --git a/website/docs/reference/home/index.mdx b/website/docs/reference/home/index.mdx new file mode 100644 index 00000000..051f0541 --- /dev/null +++ b/website/docs/reference/home/index.mdx @@ -0,0 +1,25 @@ +--- +title: rt home +sidebar_label: home +--- + +# rt home + +`rt › home` + +The git-backed ~/.mattstack home repo + +## Usage + +```bash +rt home +``` + +## Subcommands + +| Command | Description | +| --- | --- | +| [`init`](init) | Provision the home repo: print, then run, the adoption plan | +| [`key`](key) | The mattstack age key (keychain-custodied) | + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/home/init.mdx b/website/docs/reference/home/init.mdx new file mode 100644 index 00000000..47cc2daf --- /dev/null +++ b/website/docs/reference/home/init.mdx @@ -0,0 +1,26 @@ +--- +title: rt home init +sidebar_label: init +--- + +# rt home init + +`rt › home › init` + +Provision the home repo: print, then run, the adoption plan + +## Usage + +```bash +rt home init [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| [`--dry-run`](/guides/common-flags) | boolean | `false` | Print the plan without running it | + +_See code: [commands/home.ts › homeInit](https://github.com/m4ttstack/rt/blob/main/commands/home.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/home/key/export.mdx b/website/docs/reference/home/key/export.mdx new file mode 100644 index 00000000..1a92d91b --- /dev/null +++ b/website/docs/reference/home/key/export.mdx @@ -0,0 +1,20 @@ +--- +title: rt home key export +sidebar_label: export +--- + +# rt home key export + +`rt › home › key › export` + +Print the age private key once, for your password manager + +## Usage + +```bash +rt home key export +``` + +_See code: [commands/home.ts › homeKeyExport](https://github.com/m4ttstack/rt/blob/main/commands/home.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/home/key/index.mdx b/website/docs/reference/home/key/index.mdx new file mode 100644 index 00000000..939a8a3b --- /dev/null +++ b/website/docs/reference/home/key/index.mdx @@ -0,0 +1,24 @@ +--- +title: rt home key +sidebar_label: key +--- + +# rt home key + +`rt › home › key` + +The mattstack age key (keychain-custodied) + +## Usage + +```bash +rt home key +``` + +## Subcommands + +| Command | Description | +| --- | --- | +| [`export`](export) | Print the age private key once, for your password manager | + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/secrets/index.mdx b/website/docs/reference/secrets/index.mdx new file mode 100644 index 00000000..20bca360 --- /dev/null +++ b/website/docs/reference/secrets/index.mdx @@ -0,0 +1,26 @@ +--- +title: rt secrets +sidebar_label: secrets +--- + +# rt secrets + +`rt › secrets` + +sops-encrypted secrets under ~/.mattstack/user/secrets/ + +## Usage + +```bash +rt secrets +``` + +## Subcommands + +| Command | Description | +| --- | --- | +| [`set`](set) | Write a secret (creates the domain file, or one key within it) — value prompted, never a CLI arg | +| [`list`](list) | List a domain's secret names (never prints values) | +| [`rotate`](rotate) | Replace a secret's value; prints the rotation commit message — value prompted, never a CLI arg | + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/secrets/list.mdx b/website/docs/reference/secrets/list.mdx new file mode 100644 index 00000000..0b226911 --- /dev/null +++ b/website/docs/reference/secrets/list.mdx @@ -0,0 +1,26 @@ +--- +title: rt secrets list +sidebar_label: list +--- + +# rt secrets list + +`rt › secrets › list` + +List a domain's secret names (never prints values) + +## Usage + +```bash +rt secrets list +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `` | text | | Secrets domain (rt, deck, board) | + +_See code: [commands/secrets.ts › secretsList](https://github.com/m4ttstack/rt/blob/main/commands/secrets.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/secrets/rotate.mdx b/website/docs/reference/secrets/rotate.mdx new file mode 100644 index 00000000..4122ac12 --- /dev/null +++ b/website/docs/reference/secrets/rotate.mdx @@ -0,0 +1,28 @@ +--- +title: rt secrets rotate +sidebar_label: rotate +--- + +# rt secrets rotate + +`rt › secrets › rotate` + +Replace a secret's value; prints the rotation commit message — value prompted, never a CLI arg + +## Usage + +```bash +rt secrets rotate [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `` | text | | Secrets domain (rt, deck, board) | +| `` | text | | Key name within the domain | +| `--stdin` | boolean | `false` | Read the new value from stdin instead of a no-echo prompt (scripting) | + +_See code: [commands/secrets.ts › secretsRotate](https://github.com/m4ttstack/rt/blob/main/commands/secrets.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/secrets/set.mdx b/website/docs/reference/secrets/set.mdx new file mode 100644 index 00000000..30a7d12c --- /dev/null +++ b/website/docs/reference/secrets/set.mdx @@ -0,0 +1,28 @@ +--- +title: rt secrets set +sidebar_label: set +--- + +# rt secrets set + +`rt › secrets › set` + +Write a secret (creates the domain file, or one key within it) — value prompted, never a CLI arg + +## Usage + +```bash +rt secrets set [flags] +``` + +## Arguments & flags + +| Flag / Arg | Type | Default | Description | +| --- | --- | --- | --- | +| `` | text | | Secrets domain (rt, deck, board) | +| `` | text | | Key name within the domain | +| `--stdin` | boolean | `false` | Read the value from stdin instead of a no-echo prompt (scripting) | + +_See code: [commands/secrets.ts › secretsSet](https://github.com/m4ttstack/rt/blob/main/commands/secrets.ts)_ + +{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/settings/dev-mode.mdx b/website/docs/reference/settings/dev-mode.mdx index 5d78f33f..227ffce8 100644 --- a/website/docs/reference/settings/dev-mode.mdx +++ b/website/docs/reference/settings/dev-mode.mdx @@ -7,7 +7,7 @@ sidebar_label: dev-mode `rt › settings › dev-mode` -Toggle between local dev source and Homebrew production binary +Toggle between local dev source and the installed production binary ## Usage diff --git a/website/docs/reference/settings/index.mdx b/website/docs/reference/settings/index.mdx index d318e797..83add610 100644 --- a/website/docs/reference/settings/index.mdx +++ b/website/docs/reference/settings/index.mdx @@ -29,7 +29,6 @@ rt settings | [`test-push`](test-push) | Send a test push notification via rt tray | | [`runaway`](runaway) | Configure runaway process detection thresholds | | [`extension`](extension) | Install RT Context extension in editors | -| [`dev-mode`](dev-mode) | Toggle between local dev source and Homebrew production binary | -| [`llm`](llm) | Configure local LLM for branch naming and other features | +| [`dev-mode`](dev-mode) | Toggle between local dev source and the installed production binary | {/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/settings/llm.mdx b/website/docs/reference/settings/llm.mdx deleted file mode 100644 index ebf34a0a..00000000 --- a/website/docs/reference/settings/llm.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: rt settings llm -sidebar_label: llm ---- - -# rt settings llm - -`rt › settings › llm` - -Configure local LLM for branch naming and other features - -## Usage - -```bash -rt settings llm -``` - -_See code: [commands/settings.ts › configureLlm](https://github.com/m4ttstack/rt/blob/main/commands/settings.ts)_ - -{/* generated by scripts/gen-docs.ts; edit prose in _partials, not here */} \ No newline at end of file diff --git a/website/docs/reference/update.mdx b/website/docs/reference/update.mdx index 93d7b50d..b4daa36e 100644 --- a/website/docs/reference/update.mdx +++ b/website/docs/reference/update.mdx @@ -7,7 +7,7 @@ sidebar_label: update `rt › update` -Update rt to the latest version via Homebrew +Update rt to the latest GitHub release ## Usage diff --git a/website/docs/reference/verify.mdx b/website/docs/reference/verify.mdx index a824cdcd..3e132e18 100644 --- a/website/docs/reference/verify.mdx +++ b/website/docs/reference/verify.mdx @@ -7,7 +7,7 @@ sidebar_label: verify `rt › verify` -Verify an rt installation end-to-end (run after brew install) +Verify an rt installation end-to-end (run after installing) ## Usage From 71bef3ec85e2d95dcda519f7264a879fa3a98d65 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 21 Aug 2026 03:08:02 -0500 Subject: [PATCH 11/12] RT-50: convert endpoint.test.ts's last legacy config.json fixture e2e/tests/endpoint.test.ts seeded rt.roles/rt.intercepts via the dead per-repo repos//config.json path (invisible to the settings-only e2e gate my task ran). Seventh conversion of the same pattern: writes into the machine store instead, keyed by the identity both fixture repos' shared real remote normalizes to directly (no override pinning needed here, unlike the local-bare-clone conversions). Co-Authored-By: Claude Fable 5 --- e2e/tests/endpoint.test.ts | 44 ++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/e2e/tests/endpoint.test.ts b/e2e/tests/endpoint.test.ts index 288cd598..1d3c8c7d 100644 --- a/e2e/tests/endpoint.test.ts +++ b/e2e/tests/endpoint.test.ts @@ -228,29 +228,41 @@ describe("rt endpoint / intercept (just-works e2e)", () => { // Only repo-main is indexed. repo-b is matched purely by remote URL — // that's the "any worktree of a registered repo" contract. writeFileSync(join(rtDir, "repos.json"), JSON.stringify({ [REPO_NAME]: repoMain }, null, 2)); + + // rt.roles/rt.intercepts live in the machine store now, keyed by the + // IDENTITY the remote normalizes to (repo-main and repo-b share one + // remote, so they share one identity — the same "matched by remote, not + // by index membership" contract as before, just resolved through the + // settings resolver instead of the (now-gone) per-repo config.json). + const identity = "github.com/rt-test/endpoint-repo"; + mkdirSync(join(home, ".mattstack"), { recursive: true }); writeFileSync( - join(rtDir, "repos", REPO_NAME, "config.json"), + join(home, ".mattstack", "settings.local.jsonc"), JSON.stringify( { - roles: { - web: { - pool: [{ from: poolBase, to: poolBase + 5 }], - env: { PORT: "${port}" }, - preserveEnv: ["KEEP_*"], - }, - }, - intercepts: [ - { - command: "fakestart", - matches: [ + repos: { + [identity]: { + "rt.roles": { + web: { + pool: [{ from: poolBase, to: poolBase + 5 }], + env: { PORT: "${port}" }, + preserveEnv: ["KEEP_*"], + }, + }, + "rt.intercepts": [ { - cwdGlob: ".", - role: "web", - argInject: { afterArg: "go", template: "--keep=${envKeys}", skipIfArgPresent: "--keep" }, + command: "fakestart", + matches: [ + { + cwdGlob: ".", + role: "web", + argInject: { afterArg: "go", template: "--keep=${envKeys}", skipIfArgPresent: "--keep" }, + }, + ], }, ], }, - ], + }, }, null, 2, From 60a42b2c11b2579ec4bf2c806d272c4307dfc7fc Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Fri, 21 Aug 2026 03:58:21 -0500 Subject: [PATCH 12/12] =?UTF-8?q?RT-50:=20final-review=20fix=20wave=20?= =?UTF-8?q?=E2=80=94=20best-effort=20editor=20prefs,=20tracking-map=20flap?= =?UTF-8?q?=20guard,=20doppler=20single=20resolve,=20naming/description=20?= =?UTF-8?q?cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commands/code.ts: savePrefs degrades to a warning on a malformed machine store instead of throwing, matching loadPrefs and the wave's honest-save doctrine; saveNotificationPrefs is unaffected (verb-only, stays throwing). - lib/repo-tracking.ts: primeTeamTrackingIdentityMap no longer replaces a non-empty identity map with an empty one, so a transient repos.json read failure can't flap every team-tracked watcher. - lib/daemon/doppler-sync.ts + lib/doppler-template.ts: resolve rt.dopplerTemplate once per repo per tick — loadTemplate now takes the already-resolved value instead of re-fetching it itself. - commands/daemon.ts: rename the merged-view local at the interactive-editor call site from rawTracking to displayTracking (pure rename; the raw-machine binding elsewhere keeps its name). - registry-defs.ts: rt.branchNaming's description now says rt has no readers of the key yet and the VS Code extension's branch-naming.json file remains authoritative until it ports over. Co-Authored-By: Claude Fable 5 --- commands/__tests__/code-prefs.test.ts | 19 +++- commands/code.ts | 9 +- commands/daemon.ts | 6 +- lib/__tests__/doppler-template.test.ts | 93 ++++--------------- lib/daemon/__tests__/repo-tracking.test.ts | 17 +++- lib/daemon/doppler-sync.ts | 2 +- lib/doppler-template.ts | 21 ++--- lib/repo-tracking.ts | 11 +++ .../rt-client/src/settings/registry-defs.ts | 2 +- 9 files changed, 81 insertions(+), 99 deletions(-) diff --git a/commands/__tests__/code-prefs.test.ts b/commands/__tests__/code-prefs.test.ts index be368a5f..5140abf8 100644 --- a/commands/__tests__/code-prefs.test.ts +++ b/commands/__tests__/code-prefs.test.ts @@ -1,7 +1,7 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, realpathSync, rmSync } from "fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; import { machineSettingsPath } from "../../lib/rt-paths.ts"; import { getSetting } from "../../lib/settings/resolve.ts"; import { setSetting } from "../../lib/settings/write.ts"; @@ -52,4 +52,17 @@ describe("workspace prefs through the settings resolver", () => { expect(() => __test__.loadPrefs()).not.toThrow(); expect(__test__.loadPrefs()).toEqual({ editors: {}, workspaces: {} }); }); + + test("savePrefs warns and does not throw when the machine store is malformed (duplicate key anywhere in the document)", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + const path = machineSettingsPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `{\n "rt.other": { "x": 1 },\n "rt.other": { "x": 2 }\n}\n`); + + expect(() => __test__.savePrefs({ editors: { myrepo: "cursor" }, workspaces: {} })).not.toThrow(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain("rt: could not save workspace prefs"); + + warnSpy.mockRestore(); + }); }); diff --git a/commands/code.ts b/commands/code.ts index 596a50d6..e3be8424 100644 --- a/commands/code.ts +++ b/commands/code.ts @@ -38,8 +38,15 @@ function loadPrefs(): Prefs { } } +/** A store typo (e.g. a duplicate key anywhere in the machine document) must + never brick editor launch — degrade to a warning, same as loadPrefs. */ function savePrefs(prefs: Prefs): void { - setSetting("rt.workspacePrefs", prefs, "machine"); + try { + setSetting("rt.workspacePrefs", prefs, "machine"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn("rt: could not save workspace prefs — " + message); + } } export const __test__ = { loadPrefs, savePrefs }; diff --git a/commands/daemon.ts b/commands/daemon.ts index 383c8688..5cfc05db 100644 --- a/commands/daemon.ts +++ b/commands/daemon.ts @@ -365,9 +365,9 @@ export async function manageTracking(args: string[] = []): Promise { return; } const { filterableSelect, filterableMultiselect, textInput } = await import("../lib/rt-render.tsx"); - const rawTracking = loadRepoTracking(); - const rawEntry = rawTracking[repoArg]; - const current = grants(rawTracking, repoArg); + const displayTracking = loadRepoTracking(); + const rawEntry = displayTracking[repoArg]; + const current = grants(displayTracking, repoArg); const modeHint = (m: string) => (current.mode === m ? "current" : undefined); console.log(`\n ${bold}${repoArg}${reset} ${dim}window ${formatWindowLabel(rawEntry?.projectMrsWindowDays)}${reset}`); diff --git a/lib/__tests__/doppler-template.test.ts b/lib/__tests__/doppler-template.test.ts index 72818be7..4302d31e 100644 --- a/lib/__tests__/doppler-template.test.ts +++ b/lib/__tests__/doppler-template.test.ts @@ -1,94 +1,35 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import { dirname, join } from "path"; -import { setSetting } from "../settings/write.ts"; -import { machineSettingsPath } from "../rt-paths.ts"; +import { describe, expect, test } from "bun:test"; import { loadTemplate } from "../doppler-template.ts"; -const IDENTITY = "gitlab.com/acme/test-repo"; - -describe("doppler-template over the settings resolver", () => { - const origHome = process.env.HOME; - let home: string; - - beforeEach(() => { - home = realpathSync(mkdtempSync(join(tmpdir(), "rt-doppler-template-"))); - process.env.HOME = home; - }); - - afterEach(() => { - process.env.HOME = origHome; - rmSync(home, { recursive: true, force: true }); +describe("doppler-template parsing", () => { + test("returns null when nothing was declared (undefined)", () => { + expect(loadTemplate(undefined)).toBeNull(); }); - test("loadTemplate returns null when nothing is declared", () => { - expect(loadTemplate(IDENTITY)).toBeNull(); + test("returns null when the value isn't array-shaped", () => { + expect(loadTemplate({ oops: true })).toBeNull(); }); - test("loadTemplate returns null when no repo identity is available", () => { - expect(loadTemplate(null)).toBeNull(); - }); - - test("a store-seeded array resolves through the loader", () => { - setSetting( - "rt.dopplerTemplate", - [ - { path: "apps/backend", project: "backend", config: "dev" }, - { path: "apps/frontend", project: "frontend", config: "dev" }, - ], - "machine", - { repoIdentity: IDENTITY }, - ); - - expect(loadTemplate(IDENTITY)).toEqual([ + test("parses a valid entry array", () => { + expect(loadTemplate([ + { path: "apps/backend", project: "backend", config: "dev" }, + { path: "apps/frontend", project: "frontend", config: "dev" }, + ])).toEqual([ { path: "apps/backend", project: "backend", config: "dev" }, { path: "apps/frontend", project: "frontend", config: "dev" }, ]); }); test("filters out entries missing a required field", () => { - setSetting( - "rt.dopplerTemplate", - [ - { path: "apps/backend", project: "backend", config: "dev" }, - { path: "apps/broken" }, - ], - "machine", - { repoIdentity: IDENTITY }, - ); - - expect(loadTemplate(IDENTITY)).toEqual([ + expect(loadTemplate([ + { path: "apps/backend", project: "backend", config: "dev" }, + { path: "apps/broken" }, + ])).toEqual([ { path: "apps/backend", project: "backend", config: "dev" }, ]); }); - test("returns null when the resolved value isn't array-shaped", () => { - // setSetting refuses a non-array write (registry type is "array"); a - // hand-edited store can still hold one, and the resolver's own type - // check degrades it away rather than throwing — loadTemplate must - // return null for that "nothing usable" case too. - const path = machineSettingsPath(); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync( - path, - JSON.stringify({ repos: { [IDENTITY]: { "rt.dopplerTemplate": { oops: true } } } }), - ); - - expect(loadTemplate(IDENTITY)).toBeNull(); - }); - - test("an unexpandable ${repoRoot} in a stored value degrades to null instead of throwing", () => { - setSetting( - "rt.dopplerTemplate", - [{ path: "${repoRoot}", project: "backend", config: "dev" }], - "machine", - { repoIdentity: IDENTITY }, - ); - - // ${repoRoot} has no expand context here, so the resolver throws on - // expansion — loadTemplate must degrade to null rather than propagate. - expect(() => loadTemplate(IDENTITY)).not.toThrow(); - expect(loadTemplate(IDENTITY)).toBeNull(); + test("an empty array resolves to an empty template, not null", () => { + expect(loadTemplate([])).toEqual([]); }); }); diff --git a/lib/daemon/__tests__/repo-tracking.test.ts b/lib/daemon/__tests__/repo-tracking.test.ts index 58796ba0..bbcb5bdc 100644 --- a/lib/daemon/__tests__/repo-tracking.test.ts +++ b/lib/daemon/__tests__/repo-tracking.test.ts @@ -10,6 +10,7 @@ import { clearIdentityMemo } from "../../settings/identity.ts"; import { loadRepoTracking, loadMachineRepoTracking, loadMachineRepoTrackingRaw, grants, saveRepoTracking, saveRepoTrackingRaw, parseCachesArg, CACHE_KINDS, primeTeamTrackingIdentityMap, teamNamesIdentity, + __test__ as repoTrackingTest, type CacheKind, } from "../../repo-tracking.ts"; @@ -438,7 +439,9 @@ describe("primeTeamTrackingIdentityMap", () => { clearIdentityMemo(); // Reset the module-level primed map so later tests in this file that rely // on the default (unprimed) seam are not affected by this real prime. - await primeTeamTrackingIdentityMap({}); + // primeTeamTrackingIdentityMap({}) won't do it — it now refuses to + // replace a non-empty map with an empty one — so bypass via __test__. + repoTrackingTest.resetPrimedIdentityMap(); }); test("primes the identity map from a repo index, and the default seam picks it up", async () => { @@ -469,6 +472,18 @@ describe("primeTeamTrackingIdentityMap", () => { rmSync(noRemoteDir, { recursive: true, force: true }); } }); + + test("a transient empty repoIndex (e.g. a repos.json read failure) never blanks an already-primed map", async () => { + setSetting("mattstack.tracking", { + repos: { "gitlab.com/acme/foo": { caches: ["branches"] } }, + }, "team", { team: "acme" }); + + await primeTeamTrackingIdentityMap({ foo: repoDir }); + expect(loadRepoTracking().foo).toEqual({ mode: "live", caches: ["branches"] }); + + await primeTeamTrackingIdentityMap({}); + expect(loadRepoTracking().foo).toEqual({ mode: "live", caches: ["branches"] }); + }); }); describe("grants", () => { diff --git a/lib/daemon/doppler-sync.ts b/lib/daemon/doppler-sync.ts index 53cdfdc9..5223fd18 100644 --- a/lib/daemon/doppler-sync.ts +++ b/lib/daemon/doppler-sync.ts @@ -43,7 +43,7 @@ export async function reconcileForRepo(opts: ReconcileOpts): Promise("rt.dopplerTemplate", { repoIdentity }).value; - } catch { - return null; - } +export function loadTemplate(raw: unknown): DopplerTemplateEntry[] | null { if (raw === undefined || !Array.isArray(raw)) return null; const entries: DopplerTemplateEntry[] = []; diff --git a/lib/repo-tracking.ts b/lib/repo-tracking.ts index c1eb63c4..7b08acf6 100644 --- a/lib/repo-tracking.ts +++ b/lib/repo-tracking.ts @@ -104,6 +104,9 @@ export async function primeTeamTrackingIdentityMap(repoIndex: Record 0) return; primedIdentityMap = map; } @@ -304,3 +307,11 @@ export function parseCachesArg(raw: string): CacheKind[] | null { } return out; } + +// Bypasses the non-empty-map guard in primeTeamTrackingIdentityMap — for test +// teardown only, where a hard reset back to the unprimed seam is required. +export const __test__ = { + resetPrimedIdentityMap(): void { + primedIdentityMap = {}; + }, +}; diff --git a/packages/rt-client/src/settings/registry-defs.ts b/packages/rt-client/src/settings/registry-defs.ts index bdd60fa6..7c0826f2 100644 --- a/packages/rt-client/src/settings/registry-defs.ts +++ b/packages/rt-client/src/settings/registry-defs.ts @@ -118,7 +118,7 @@ export const REGISTRY: readonly SettingDef[] = [ merge: "deep", repoScoped: true, migrated: true, - description: "Templates rt uses to derive branch names from ticket identifiers.", + description: "Branch-naming templates. rt itself has no readers of this key yet — the VS Code extension still reads repos//branch-naming.json by repo name, which stays authoritative until the extension ports over. Setting this key stores a value nothing consumes.", }, { key: "rt.variations",