Skip to content

Commit 8fa7e0b

Browse files
refactor(registry): split registry store ownership
1 parent d0c2f0c commit 8fa7e0b

11 files changed

Lines changed: 201 additions & 136 deletions

File tree

docs/intentional-architecture-rewrite-2026-06-27/decision-log.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ Setup has two edge layers. `src/edges/cli/setup/index.ts` is the public command
197197

198198
Claude auth stays provider-owned, but provider-owned does not mean one file. `src/agent/providers/claude/auth.ts` owns auth policy and public re-exports. `auth-cli.ts` owns Claude executable discovery, public CLI spawning, legacy SDK CLI fallback, timeout behavior, and subprocess output collection. `auth-status.ts` owns the parsed JSON contract returned by `claude auth status --json`.
199199

200+
Wiki-registry storage is not one bucket. `src/stores/wiki-registry/store.ts` owns registry read/write/add/drop/find persistence verbs. `codec.ts` owns parsing and validating `registry.json`. `lookup.ts` owns name/path matching over injected path equality. `filesystem.ts` owns path reachability, wiki-root checks, and global-directory creation. `types.ts` owns registry entry and lookup contracts.
201+
200202
Codex app-server runtime has two layers. `app-server.ts` coordinates provider runtime state: request/config setup, JSON-RPC transport wiring, notification mapping, root-turn completion, turn watchdogs, and final result projection. `app-server-process.ts` owns child-process mechanics: spawning the Codex app-server, decoding stdout JSONL into protocol messages, collecting stderr for close failures, registering signal handlers, writing JSON-RPC messages to stdin, and terminating the managed child.
201203

202204
Codex app-server notifications are routed by notification kind. `app-notifications.ts` owns the top-level method router and generic notification categories. `app-agent-messages.ts` owns agent-message semantics: text deltas, root result capture, structured output parsing, invalid structured-output failure state, and helper-agent completion events. `app-terminal-events.ts` owns terminal event semantics: turn completion, warnings, app-server error notifications, terminal run-state success/failure mutation, and `classifyCodexFailure` calls. Tool display, usage parsing, actor tracing, root-turn detection, and process mechanics stay in their existing named files.

docs/intentional-architecture-rewrite-2026-06-27/status.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ This is no longer a small cleanup branch. It is a real ownership rewrite.
142142
- Moved setup raw multi-select keyboard mechanics into a reusable setup input-control file, leaving instruction-target choice focused on target semantics and line-mode parsing.
143143
- Split setup phase orchestration into `setup-flow.ts`, leaving setup `index.ts` as the public TUI entry wrapper and next-step renderer.
144144
- Split Claude auth provider internals into auth policy, CLI subprocess mechanics, and parsed status-contract files under `src/agent/providers/claude/`.
145+
- Split wiki-registry storage into read/write mutation verbs, JSON codec, lookup, filesystem reachability, and type contracts.
145146
- Moved repeated store atomic-write temp-file mechanics into `src/stores/atomic-write.ts`, removing process-PID temp names from job and sync stores.
146147
- Split most command rendering into command-private render files.
147148
- Added architecture-boundary tests to stop old dependency leaks from returning.

docs/intentional-architecture-rewrite-2026-06-27/worklog.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2020,3 +2020,13 @@ Two-hundred-eighty-third production slice:
20202020
- Added `src/agent/providers/claude/auth-status.ts` for the parsed Claude auth JSON contract.
20212021
- Kept `auth.ts` as the provider-owned auth policy surface: public auth check, subscription/API-key acceptance, unauthenticated message, and public re-exports.
20222022
- Strengthened provider boundary tests so auth policy does not regain child-process mechanics or raw JSON parsing.
2023+
2024+
Two-hundred-eighty-fourth production slice:
2025+
2026+
- Split `src/stores/wiki-registry/store.ts` into registry persistence, codec, lookup, filesystem, and type owners.
2027+
- Added `src/stores/wiki-registry/codec.ts` for registry JSON parsing and validation.
2028+
- Added `src/stores/wiki-registry/lookup.ts` for name/path matching over injected path equality.
2029+
- Added `src/stores/wiki-registry/filesystem.ts` for reachability, wiki-root checks, and global directory creation.
2030+
- Added `src/stores/wiki-registry/types.ts` for registry entry and path-lookup contracts.
2031+
- Kept `store.ts` focused on read/write/add/drop/find persistence verbs.
2032+
- Strengthened architecture tests so registry parsing, path lookup, and filesystem checks do not collapse back into the store catchall.

src/stores/wiki-registry/codec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { UserFacingError } from "../../shared/user-facing-error.js";
2+
import type { RegistryEntry } from "./types.js";
3+
4+
export function parseRegistryFile(
5+
raw: string,
6+
registryPath: string,
7+
): RegistryEntry[] {
8+
const trimmed = raw.trim();
9+
if (trimmed.length === 0) {
10+
return [];
11+
}
12+
13+
let parsed: unknown;
14+
try {
15+
parsed = JSON.parse(trimmed);
16+
} catch (err: unknown) {
17+
const message = err instanceof Error ? err.message : String(err);
18+
throw new UserFacingError(
19+
`registry at ${registryPath} is not valid JSON: ${message}`,
20+
{ data: { path: registryPath } },
21+
);
22+
}
23+
24+
if (!Array.isArray(parsed)) {
25+
throw new UserFacingError(
26+
`registry at ${registryPath} must be a JSON array`,
27+
{ data: { path: registryPath } },
28+
);
29+
}
30+
31+
return parsed.map((item, index) =>
32+
parseRegistryEntry(item, index, registryPath)
33+
);
34+
}
35+
36+
function parseRegistryEntry(
37+
item: unknown,
38+
index: number,
39+
registryPath: string,
40+
): RegistryEntry {
41+
if (typeof item !== "object" || item === null) {
42+
throw new UserFacingError(
43+
`registry entry ${index} is not an object`,
44+
{ data: { path: registryPath, index } },
45+
);
46+
}
47+
const entry = item as Record<string, unknown>;
48+
const name = typeof entry.name === "string" ? entry.name : "";
49+
const path = typeof entry.path === "string" ? entry.path : "";
50+
if (name.length === 0) {
51+
throw new UserFacingError(
52+
`registry entry ${index} is missing a non-empty "name"`,
53+
{ data: { path: registryPath, index, field: "name" } },
54+
);
55+
}
56+
if (path.length === 0) {
57+
throw new UserFacingError(
58+
`registry entry ${index} is missing a non-empty "path"`,
59+
{ data: { path: registryPath, index, field: "path" } },
60+
);
61+
}
62+
return {
63+
name,
64+
description: typeof entry.description === "string" ? entry.description : "",
65+
path,
66+
registered_at:
67+
typeof entry.registered_at === "string" ? entry.registered_at : "",
68+
};
69+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { existsSync } from "node:fs";
2+
import { mkdir } from "node:fs/promises";
3+
import { join } from "node:path";
4+
5+
import { getGlobalAlmanacDir } from "../global-paths.js";
6+
import type { RegistryEntry } from "./types.js";
7+
8+
/**
9+
* A registry path is reachable if something still exists at that path.
10+
* Unreachable entries stay in the registry until an explicit drop.
11+
*/
12+
export function isRegistryEntryReachable(entry: RegistryEntry): boolean {
13+
return entry.path.length > 0 && existsSync(entry.path);
14+
}
15+
16+
export function isRegistryEntryWikiRoot(entry: RegistryEntry): boolean {
17+
return entry.path.length > 0 && existsSync(join(entry.path, ".almanac"));
18+
}
19+
20+
/**
21+
* Ensure the global `.almanac/` directory exists. Safe to call repeatedly;
22+
* `mkdir recursive` is a no-op when the directory already exists.
23+
*/
24+
export async function ensureGlobalDir(): Promise<void> {
25+
await mkdir(getGlobalAlmanacDir(), { recursive: true });
26+
}

src/stores/wiki-registry/index.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
export {
22
addEntry,
33
dropEntry,
4-
ensureGlobalDir,
54
findEntry,
6-
findRegistryEntry,
7-
isRegistryEntryReachable,
8-
isRegistryEntryWikiRoot,
95
readRegistry,
106
writeRegistry,
11-
type RegistryEntry,
12-
type RegistryPathLookupOptions,
137
} from "./store.js";
8+
export { ensureGlobalDir } from "./filesystem.js";
9+
export {
10+
isRegistryEntryReachable,
11+
isRegistryEntryWikiRoot,
12+
} from "./filesystem.js";
13+
export { findRegistryEntry } from "./lookup.js";
14+
export type {
15+
RegistryEntry,
16+
RegistryPathLookupOptions,
17+
} from "./types.js";

src/stores/wiki-registry/lookup.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type {
2+
RegistryEntry,
3+
RegistryPathLookupOptions,
4+
} from "./types.js";
5+
6+
export function findRegistryEntry(
7+
entries: RegistryEntry[],
8+
params: {
9+
name?: string;
10+
path?: string;
11+
},
12+
options: RegistryPathLookupOptions = {},
13+
): RegistryEntry | null {
14+
const pathEquals = options.pathEquals ?? exactPathEquality;
15+
for (const entry of entries) {
16+
if (params.name !== undefined && entry.name === params.name) return entry;
17+
if (params.path !== undefined && pathEquals(entry.path, params.path)) {
18+
return entry;
19+
}
20+
}
21+
return null;
22+
}
23+
24+
export function exactPathEquality(a: string, b: string): boolean {
25+
return a === b;
26+
}

src/stores/wiki-registry/store.ts

Lines changed: 11 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,16 @@
1-
import { existsSync } from "node:fs";
2-
import { mkdir, readFile } from "node:fs/promises";
3-
import { join } from "node:path";
1+
import { readFile } from "node:fs/promises";
42

5-
import { getGlobalAlmanacDir } from "../global-paths.js";
63
import { getRegistryPath } from "./paths.js";
7-
import type { PathEquality } from "../../shared/path-equality.js";
8-
import { UserFacingError } from "../../shared/user-facing-error.js";
94
import { writeTextFileAtomically } from "../atomic-write.js";
10-
11-
/**
12-
* One entry in `~/.almanac/registry.json`.
13-
*
14-
* `name` is the canonical kebab-case slug the user types. `path` is the
15-
* absolute repo root (the directory that contains `.almanac/`). We store
16-
* absolute paths so cross-wiki resolution works regardless of the caller's
17-
* cwd.
18-
*/
19-
export interface RegistryEntry {
20-
name: string;
21-
description: string;
22-
path: string;
23-
registered_at: string;
24-
}
25-
26-
export interface RegistryPathLookupOptions {
27-
pathEquals?: PathEquality;
28-
}
5+
import { parseRegistryFile } from "./codec.js";
6+
import {
7+
exactPathEquality,
8+
findRegistryEntry,
9+
} from "./lookup.js";
10+
import type {
11+
RegistryEntry,
12+
RegistryPathLookupOptions,
13+
} from "./types.js";
2914

3015
/**
3116
* Read the registry file into memory.
@@ -46,64 +31,7 @@ export async function readRegistry(): Promise<RegistryEntry[]> {
4631
throw err;
4732
}
4833

49-
const trimmed = raw.trim();
50-
if (trimmed.length === 0) {
51-
return [];
52-
}
53-
54-
let parsed: unknown;
55-
try {
56-
parsed = JSON.parse(trimmed);
57-
} catch (err: unknown) {
58-
const message = err instanceof Error ? err.message : String(err);
59-
throw new UserFacingError(
60-
`registry at ${registryPath} is not valid JSON: ${message}`,
61-
{ data: { path: registryPath } },
62-
);
63-
}
64-
65-
if (!Array.isArray(parsed)) {
66-
throw new UserFacingError(
67-
`registry at ${registryPath} must be a JSON array`,
68-
{ data: { path: registryPath } },
69-
);
70-
}
71-
72-
// Validate every entry. We do NOT silently coerce missing `name` or
73-
// `path` — an entry with `name: ""` would be unremovable via `--drop`
74-
// and an empty `path` would match any `findEntry({ path: "" })` call.
75-
// If someone hand-edited the registry into a bad state, surfacing the
76-
// error is strictly better than limping along with corrupt data.
77-
return parsed.map((item, idx) => {
78-
if (typeof item !== "object" || item === null) {
79-
throw new UserFacingError(
80-
`registry entry ${idx} is not an object`,
81-
{ data: { path: registryPath, index: idx } },
82-
);
83-
}
84-
const e = item as Record<string, unknown>;
85-
const name = typeof e.name === "string" ? e.name : "";
86-
const path = typeof e.path === "string" ? e.path : "";
87-
if (name.length === 0) {
88-
throw new UserFacingError(
89-
`registry entry ${idx} is missing a non-empty "name"`,
90-
{ data: { path: registryPath, index: idx, field: "name" } },
91-
);
92-
}
93-
if (path.length === 0) {
94-
throw new UserFacingError(
95-
`registry entry ${idx} is missing a non-empty "path"`,
96-
{ data: { path: registryPath, index: idx, field: "path" } },
97-
);
98-
}
99-
return {
100-
name,
101-
description: typeof e.description === "string" ? e.description : "",
102-
path,
103-
registered_at:
104-
typeof e.registered_at === "string" ? e.registered_at : "",
105-
};
106-
});
34+
return parseRegistryFile(raw, registryPath);
10735
}
10836

10937
/**
@@ -177,48 +105,6 @@ export async function findEntry(
177105
return findRegistryEntry(await readRegistry(), params, options);
178106
}
179107

180-
export function findRegistryEntry(
181-
entries: RegistryEntry[],
182-
params: {
183-
name?: string;
184-
path?: string;
185-
},
186-
options: RegistryPathLookupOptions = {},
187-
): RegistryEntry | null {
188-
const pathEquals = options.pathEquals ?? exactPathEquality;
189-
for (const entry of entries) {
190-
if (params.name !== undefined && entry.name === params.name) return entry;
191-
if (params.path !== undefined && pathEquals(entry.path, params.path)) {
192-
return entry;
193-
}
194-
}
195-
return null;
196-
}
197-
198-
/**
199-
* A registry path is reachable if something still exists at that path.
200-
* Unreachable entries stay in the registry until an explicit drop.
201-
*/
202-
export function isRegistryEntryReachable(entry: RegistryEntry): boolean {
203-
return entry.path.length > 0 && existsSync(entry.path);
204-
}
205-
206-
export function isRegistryEntryWikiRoot(entry: RegistryEntry): boolean {
207-
return entry.path.length > 0 && existsSync(join(entry.path, ".almanac"));
208-
}
209-
210-
/**
211-
* Ensure the global `.almanac/` directory exists. Safe to call repeatedly;
212-
* `mkdir recursive` is a no-op when the directory already exists.
213-
*/
214-
export async function ensureGlobalDir(): Promise<void> {
215-
await mkdir(getGlobalAlmanacDir(), { recursive: true });
216-
}
217-
218108
function isNodeError(err: unknown): err is NodeJS.ErrnoException {
219109
return err instanceof Error && "code" in err;
220110
}
221-
222-
function exactPathEquality(a: string, b: string): boolean {
223-
return a === b;
224-
}

src/stores/wiki-registry/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { PathEquality } from "../../shared/path-equality.js";
2+
3+
/**
4+
* One entry in `~/.almanac/registry.json`.
5+
*
6+
* `name` is the canonical kebab-case slug the user types. `path` is the
7+
* absolute repo root (the directory that contains `.almanac/`). We store
8+
* absolute paths so cross-wiki resolution works regardless of the caller's
9+
* cwd.
10+
*/
11+
export interface RegistryEntry {
12+
name: string;
13+
description: string;
14+
path: string;
15+
registered_at: string;
16+
}
17+
18+
export interface RegistryPathLookupOptions {
19+
pathEquals?: PathEquality;
20+
}

test/architecture-indexer-diagnostics-boundaries.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,27 +290,35 @@ describe("architecture boundaries: indexer, diagnostics, and registry", () => {
290290
"src/services/wiki/autoregistration.ts",
291291
);
292292
const registryStore = await readSource("src/stores/wiki-registry/store.ts");
293+
const registryTypes = await readSource("src/stores/wiki-registry/types.ts");
294+
const registryLookup = await readSource("src/stores/wiki-registry/lookup.ts");
293295
const cliAutoRegistration = await readSource(
294296
"src/edges/cli/autoregistration.ts",
295297
);
296298
const platformPathCase = await readSource("src/platform/path-case.ts");
297299
const sharedPathEquality = await readSource("src/shared/path-equality.ts");
298300

299301
expect(existsSync(join(ROOT, "src/stores/wiki-registry/store.ts"))).toBe(true);
302+
expect(existsSync(join(ROOT, "src/stores/wiki-registry/types.ts"))).toBe(true);
303+
expect(existsSync(join(ROOT, "src/stores/wiki-registry/lookup.ts"))).toBe(true);
300304
expect(existsSync(join(ROOT, "src/platform/path-case.ts"))).toBe(true);
301305
expect(existsSync(join(ROOT, "src/shared/path-equality.ts"))).toBe(true);
302306
expect(existsSync(join(ROOT, "src/edges/cli/autoregistration.ts"))).toBe(true);
303307
expect(existsSync(join(ROOT, "src/stores/wiki/registry/store.ts"))).toBe(false);
304308
expect(existsSync(join(ROOT, "src/stores/wiki/registry/index.ts"))).toBe(false);
305309
expect(existsSync(join(ROOT, "src/stores/wiki/registry"))).toBe(false);
306310
expect(sharedPathEquality).toContain("type PathEquality");
307-
expect(registryStore).toContain("shared/path-equality.js");
311+
expect(registryTypes).toContain("shared/path-equality.js");
308312
expect(registryStore).not.toContain("RegistryPathEquality");
309-
expect(registryStore).toContain("pathEquals");
313+
expect(registryLookup).toContain("pathEquals");
310314
expect(registryStore).not.toContain("platform/path-case");
315+
expect(registryLookup).not.toContain("platform/path-case");
311316
expect(registryStore).not.toContain("pathsEqualOnCurrentPlatform");
317+
expect(registryLookup).not.toContain("pathsEqualOnCurrentPlatform");
312318
expect(registryStore).not.toContain("process.platform");
319+
expect(registryLookup).not.toContain("process.platform");
313320
expect(registryStore).not.toContain("toLowerCase()");
321+
expect(registryLookup).not.toContain("toLowerCase()");
314322
expect(autoRegistration).toContain("findRegistryEntry");
315323
expect(autoRegistration).toContain("RegistryPathLookupOptions");
316324
expect(autoRegistration).not.toContain("platform/path-case");

0 commit comments

Comments
 (0)