Skip to content

Commit 8a98f15

Browse files
refactor(automation): split launchd plist ownership
1 parent cdeedad commit 8a98f15

7 files changed

Lines changed: 130 additions & 83 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
@@ -209,6 +209,8 @@ Viewer read models are route-payload owners, not one API bucket. `src/edges/view
209209

210210
Bare-`codealmanac` bootstrap is a flow coordinator, not a package-manager bucket. `src/platform/install/global.ts` owns the high-level bootstrap sequence. `bootstrap-process.ts` owns child-process spawn/capture mechanics. `bootstrap-package.ts` owns package-root detection, package version reads, root equality, and version comparison. `bootstrap-npm.ts` owns npm global-root discovery, global install execution, and npm failure text.
211211

212+
Launchd action mechanics and launchd plist XML are separate platform owners. `src/platform/automation/launchd.ts` owns launchctl target construction, PATH construction, plist file writes/removals, directory creation, bootstrap/bootout, and loaded-state checks. `src/platform/automation/launchd-plist.ts` owns XML rendering, XML escaping/unescaping, StartInterval parsing, and ProgramArguments parsing. `src/platform/automation/paths.ts` owns plist and log path construction.
213+
212214
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.
213215

214216
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: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Branch: `codex/intentional-architecture-rewrite`
55

66
## Current State
77

8-
The branch has more than 340 committed rewrite commits past `dev`. The worklog records 289 production slices so far.
8+
The branch has more than 340 committed rewrite commits past `dev`. The worklog records 290 production slices so far.
99

1010
The diff is broad: more than 680 files changed, with tens of thousands of lines reshaped.
1111

@@ -148,6 +148,7 @@ This is no longer a small cleanup branch. It is a real ownership rewrite.
148148
- Split setup input controls into line prompts, single-choice select, raw input capability, multi-select, and interruption handling.
149149
- Split viewer read-model route payloads into overview, page, topic, search/file, DB freshness, and type owners.
150150
- Split bare-`codealmanac` install bootstrap into flow coordination, package-root/version, npm global install, and process-spawn owners.
151+
- Split launchd plist XML mechanics from launchctl action mechanics in the automation platform adapter.
151152
- Moved repeated store atomic-write temp-file mechanics into `src/stores/atomic-write.ts`, removing process-PID temp names from job and sync stores.
152153
- Split most command rendering into command-private render files.
153154
- Added architecture-boundary tests to stop old dependency leaks from returning.
@@ -164,12 +165,12 @@ This is no longer a small cleanup branch. It is a real ownership rewrite.
164165

165166
## Latest Checkpoint
166167

167-
The latest slice split the bare-`codealmanac` install bootstrap bucket. `global.ts` now coordinates the bootstrap flow, while `bootstrap-package.ts`, `bootstrap-npm.ts`, and `bootstrap-process.ts` own package-root/version mechanics, npm mechanics, and child-process mechanics.
168+
The latest slice split launchd plist XML mechanics from launchctl action mechanics. `launchd.ts` now owns launchctl/file/action mechanics, while `launchd-plist.ts` owns XML rendering, escaping, and parsing.
168169

169170
Verification passed:
170171

171172
- `npm run lint`
172-
- `npx vitest run test/global-bootstrap.test.ts test/architecture-setup-boundaries.test.ts`
173+
- `npx vitest run test/automation.test.ts test/architecture-automation-update-boundaries.test.ts`
173174
- `npx vitest run test/architecture-*-boundaries.test.ts`
174175
- `git diff --check`
175176
- `npm test`

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2076,3 +2076,12 @@ Two-hundred-eighty-ninth production slice:
20762076
- Added `src/platform/install/bootstrap-npm.ts` for npm global-root discovery, global package install execution, and npm-install failure text.
20772077
- Kept `global.ts` focused on the bare-`codealmanac` bootstrap flow: local setup bypass, global-root resolution, global install decision, and rerun through the global launcher.
20782078
- Strengthened setup architecture tests so process spawning, package-root/version probing, and npm install mechanics stay in separate install-platform owners.
2079+
2080+
Two-hundred-ninetieth production slice:
2081+
2082+
- Split launchd plist XML mechanics out of `src/platform/automation/launchd.ts`.
2083+
- Added `src/platform/automation/launchd-plist.ts` for plist XML rendering, XML escaping/unescaping, StartInterval parsing, and ProgramArguments parsing.
2084+
- Kept `launchd.ts` focused on launchctl target construction, launch PATH construction, plist file writes/removals, directory creation, bootstrap/bootout, and loaded-state checks.
2085+
- Updated the launchd scheduler adapter to consume `readLaunchdProgramArguments()` from the plist owner instead of parsing plist XML locally.
2086+
- Removed the unused `automationLogsDir()` helper because automation log path ownership already lives in `src/platform/automation/paths.ts`.
2087+
- Strengthened automation boundary tests so launchd action mechanics and plist XML mechanics stay separate.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
export interface LaunchdPlistDefinition {
2+
label: string;
3+
programArguments: string[];
4+
intervalSeconds: number;
5+
environmentVariables: Record<string, string>;
6+
stdoutPath: string;
7+
stderrPath: string;
8+
workingDirectory?: string;
9+
}
10+
11+
export function renderLaunchdPlist(args: LaunchdPlistDefinition): string {
12+
const programArguments = args.programArguments
13+
.map((arg) => ` <string>${escapeXml(arg)}</string>`)
14+
.join("\n");
15+
const environmentVariables = Object.entries(args.environmentVariables)
16+
.map(
17+
([key, value]) =>
18+
` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`,
19+
)
20+
.join("\n");
21+
return `<?xml version="1.0" encoding="UTF-8"?>
22+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
23+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
24+
<plist version="1.0">
25+
<dict>
26+
<key>Label</key>
27+
<string>${escapeXml(args.label)}</string>
28+
<key>ProgramArguments</key>
29+
<array>
30+
${programArguments}
31+
</array>
32+
<key>StartInterval</key>
33+
<integer>${args.intervalSeconds}</integer>
34+
${args.workingDirectory !== undefined
35+
? ` <key>WorkingDirectory</key>\n <string>${escapeXml(args.workingDirectory)}</string>\n`
36+
: ""} <key>EnvironmentVariables</key>
37+
<dict>
38+
${environmentVariables}
39+
</dict>
40+
<key>RunAtLoad</key>
41+
<true/>
42+
<key>StandardOutPath</key>
43+
<string>${escapeXml(args.stdoutPath)}</string>
44+
<key>StandardErrorPath</key>
45+
<string>${escapeXml(args.stderrPath)}</string>
46+
</dict>
47+
</plist>
48+
`;
49+
}
50+
51+
export function readLaunchdStartInterval(contents: string): number | null {
52+
const value = contents.match(
53+
/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/,
54+
)?.[1];
55+
return value === undefined ? null : Number(value);
56+
}
57+
58+
export function readLaunchdProgramArguments(contents: string): string[] {
59+
return [...contents.matchAll(/<string>([^<]*)<\/string>/g)]
60+
.map((match) => unescapeXml(match[1] ?? ""));
61+
}
62+
63+
function escapeXml(value: string): string {
64+
return value
65+
.replaceAll("&", "&amp;")
66+
.replaceAll("<", "&lt;")
67+
.replaceAll(">", "&gt;")
68+
.replaceAll('"', "&quot;")
69+
.replaceAll("'", "&apos;");
70+
}
71+
72+
function unescapeXml(value: string): string {
73+
return value
74+
.replaceAll("&apos;", "'")
75+
.replaceAll("&quot;", '"')
76+
.replaceAll("&gt;", ">")
77+
.replaceAll("&lt;", "<")
78+
.replaceAll("&amp;", "&");
79+
}

src/platform/automation/launchd.ts

Lines changed: 17 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import { execFile } from "node:child_process";
22
import { existsSync } from "node:fs";
33
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4-
import { homedir, userInfo } from "node:os";
4+
import { userInfo } from "node:os";
55
import path from "node:path";
66
import { promisify } from "node:util";
77

8+
import {
9+
readLaunchdStartInterval,
10+
renderLaunchdPlist,
11+
type LaunchdPlistDefinition,
12+
} from "./launchd-plist.js";
13+
814
const execFileAsync = promisify(execFile);
915

1016
const LAUNCHD_FALLBACK_PATHS = [
@@ -16,15 +22,9 @@ const LAUNCHD_FALLBACK_PATHS = [
1622
"/sbin",
1723
];
1824

19-
export interface LaunchdJobDefinition {
25+
export interface LaunchdJobDefinition extends LaunchdPlistDefinition {
2026
label: string;
2127
plistPath: string;
22-
programArguments: string[];
23-
intervalSeconds: number;
24-
environmentVariables: Record<string, string>;
25-
stdoutPath: string;
26-
stderrPath: string;
27-
workingDirectory?: string;
2828
}
2929

3030
export type ExecFn = (
@@ -47,10 +47,6 @@ export function launchdTarget(): string {
4747
return `gui/${userInfo().uid}`;
4848
}
4949

50-
export function automationLogsDir(home: string = homedir()): string {
51-
return path.join(home, ".almanac", "logs");
52-
}
53-
5450
export function buildLaunchPath(home: string, envPath: string | undefined): string {
5551
const installPaths = (envPath ?? "")
5652
.split(":")
@@ -60,13 +56,19 @@ export function buildLaunchPath(home: string, envPath: string | undefined): stri
6056
path.join(home, ".local", "bin"),
6157
path.join(home, ".bun", "bin"),
6258
];
63-
return unique([...installPaths, ...userPaths, ...LAUNCHD_FALLBACK_PATHS]).join(":");
59+
return unique([...installPaths, ...userPaths, ...LAUNCHD_FALLBACK_PATHS])
60+
.join(":");
6461
}
6562

6663
export async function ensureLaunchdDirs(jobs: LaunchdJobDefinition[]): Promise<void> {
6764
await Promise.all([
6865
...jobs.map((job) => mkdir(path.dirname(job.plistPath), { recursive: true })),
69-
...unique(jobs.flatMap((job) => [path.dirname(job.stdoutPath), path.dirname(job.stderrPath)]))
66+
...unique(
67+
jobs.flatMap((job) => [
68+
path.dirname(job.stdoutPath),
69+
path.dirname(job.stderrPath),
70+
]),
71+
)
7072
.map((dir) => mkdir(dir, { recursive: true })),
7173
]);
7274
}
@@ -113,7 +115,7 @@ export async function readLaunchdPlistStatus(
113115
installed: true,
114116
plistPath,
115117
contents,
116-
intervalSeconds: readStartInterval(contents),
118+
intervalSeconds: readLaunchdStartInterval(contents),
117119
};
118120
}
119121

@@ -141,57 +143,6 @@ async function isLaunchdJobLoaded(
141143
}
142144
}
143145

144-
function renderLaunchdPlist(args: LaunchdJobDefinition): string {
145-
const programArguments = args.programArguments
146-
.map((arg) => ` <string>${escapeXml(arg)}</string>`)
147-
.join("\n");
148-
const environmentVariables = Object.entries(args.environmentVariables)
149-
.map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`)
150-
.join("\n");
151-
return `<?xml version="1.0" encoding="UTF-8"?>
152-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
153-
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
154-
<plist version="1.0">
155-
<dict>
156-
<key>Label</key>
157-
<string>${escapeXml(args.label)}</string>
158-
<key>ProgramArguments</key>
159-
<array>
160-
${programArguments}
161-
</array>
162-
<key>StartInterval</key>
163-
<integer>${args.intervalSeconds}</integer>
164-
${args.workingDirectory !== undefined
165-
? ` <key>WorkingDirectory</key>\n <string>${escapeXml(args.workingDirectory)}</string>\n`
166-
: ""} <key>EnvironmentVariables</key>
167-
<dict>
168-
${environmentVariables}
169-
</dict>
170-
<key>RunAtLoad</key>
171-
<true/>
172-
<key>StandardOutPath</key>
173-
<string>${escapeXml(args.stdoutPath)}</string>
174-
<key>StandardErrorPath</key>
175-
<string>${escapeXml(args.stderrPath)}</string>
176-
</dict>
177-
</plist>
178-
`;
179-
}
180-
181-
function readStartInterval(contents: string): number | null {
182-
const value = contents.match(/<key>StartInterval<\/key>\s*<integer>(\d+)<\/integer>/)?.[1];
183-
return value === undefined ? null : Number(value);
184-
}
185-
186-
function escapeXml(value: string): string {
187-
return value
188-
.replaceAll("&", "&amp;")
189-
.replaceAll("<", "&lt;")
190-
.replaceAll(">", "&gt;")
191-
.replaceAll('"', "&quot;")
192-
.replaceAll("'", "&apos;");
193-
}
194-
195146
function unique(values: string[]): string[] {
196147
return [...new Set(values)];
197148
}

src/platform/automation/scheduler.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
writeLaunchdPlist,
88
type ExecFn,
99
} from "./launchd.js";
10+
import { readLaunchdProgramArguments } from "./launchd-plist.js";
1011
import { detectLegacyCaptureSweepAutomation } from "./legacy-capture.js";
1112
import { cleanupLegacyHooks } from "./legacy-hooks.js";
1213
import { automationLogPaths, launchAgentPlistPath } from "./paths.js";
@@ -88,17 +89,3 @@ function buildLaunchdAutomationJob(
8889
stderrPath: logs.stderrPath,
8990
};
9091
}
91-
92-
function readLaunchdProgramArguments(contents: string): string[] {
93-
return [...contents.matchAll(/<string>([^<]*)<\/string>/g)]
94-
.map((match) => unescapeXml(match[1] ?? ""));
95-
}
96-
97-
function unescapeXml(value: string): string {
98-
return value
99-
.replaceAll("&apos;", "'")
100-
.replaceAll("&quot;", '"')
101-
.replaceAll("&gt;", ">")
102-
.replaceAll("&lt;", "<")
103-
.replaceAll("&amp;", "&");
104-
}

test/architecture-automation-update-boundaries.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ describe("architecture boundaries: automation, update, config, and agents", () =
3939
const setupAutoUpdateStep = await readSource("src/edges/cli/setup/auto-update-step.ts");
4040
const uninstallEdge = await readSource("src/edges/cli/uninstall.ts");
4141
const launchdAutomationScheduler = await readSource("src/platform/automation/scheduler.ts");
42+
const launchd = await readSource("src/platform/automation/launchd.ts");
43+
const launchdPlist = await readSource(
44+
"src/platform/automation/launchd-plist.ts",
45+
);
4246
const automationPaths = await readSource("src/platform/automation/paths.ts");
4347
const automationInstallCommand = await readSource(
4448
"src/edges/cli/commands/automation/install.ts",
@@ -155,6 +159,20 @@ describe("architecture boundaries: automation, update, config, and agents", () =
155159
expect(launchdAutomationScheduler).toContain("automationLogPaths");
156160
expect(launchdAutomationScheduler).toContain("launchAgentPlistPath");
157161
expect(launchdAutomationScheduler).toContain("readLaunchdProgramArguments");
162+
expect(launchdAutomationScheduler).toContain("launchd-plist.js");
163+
expect(existsSync(join(ROOT, "src/platform/automation/launchd-plist.ts")))
164+
.toBe(true);
165+
expect(launchd).toContain("renderLaunchdPlist");
166+
expect(launchd).toContain("readLaunchdStartInterval");
167+
expect(launchd).not.toContain("function renderLaunchdPlist");
168+
expect(launchd).not.toContain("function escapeXml");
169+
expect(launchd).not.toContain("function readLaunchdStartInterval");
170+
expect(launchd).not.toContain("automationLogsDir");
171+
expect(launchdPlist).toContain("renderLaunchdPlist");
172+
expect(launchdPlist).toContain("readLaunchdStartInterval");
173+
expect(launchdPlist).toContain("readLaunchdProgramArguments");
174+
expect(launchdPlist).toContain("function escapeXml");
175+
expect(launchdPlist).toContain("function unescapeXml");
158176
expect(existsSync(join(ROOT, "src/platform/automation/job-plan.ts"))).toBe(
159177
false,
160178
);

0 commit comments

Comments
 (0)