catherd-cli 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +114 -30
  2. package/THIRD_PARTY_NOTICES.md +37 -0
  3. package/catalog/jev.json +89 -0
  4. package/catalog/models.json +236 -0
  5. package/catalog/scores.json +366 -0
  6. package/package.json +24 -8
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin/.mcp.json +1 -1
  9. package/plugin/commands/catherd.md +1 -1
  10. package/plugin/skills/catherd/SKILL.md +97 -51
  11. package/plugin/skills/catherd-setup/SKILL.md +34 -24
  12. package/src/adapters/all.ts +8 -0
  13. package/src/adapters/backend.ts +144 -0
  14. package/src/adapters/claude-code/events.ts +86 -0
  15. package/src/adapters/claude-code/index.ts +223 -0
  16. package/src/adapters/claude-code/models-api.ts +65 -0
  17. package/src/adapters/claude-code/models.ts +26 -0
  18. package/src/adapters/cli.ts +74 -0
  19. package/src/adapters/codex/events.ts +65 -0
  20. package/src/adapters/codex/home.ts +47 -0
  21. package/src/adapters/codex/index.ts +227 -0
  22. package/src/adapters/discovery.ts +87 -0
  23. package/src/adapters/opencode/agents.ts +84 -0
  24. package/src/adapters/opencode/events.ts +109 -0
  25. package/src/adapters/opencode/index.ts +316 -0
  26. package/src/adapters/registry.ts +14 -0
  27. package/src/cli.ts +159 -19
  28. package/src/domain/agents.ts +60 -0
  29. package/src/domain/budget.ts +45 -0
  30. package/src/domain/catalog.ts +262 -0
  31. package/src/domain/changes.ts +52 -0
  32. package/src/domain/cost.ts +89 -0
  33. package/src/domain/errors.ts +58 -0
  34. package/src/domain/hints.ts +22 -0
  35. package/src/domain/ids.ts +97 -0
  36. package/src/domain/jev.ts +283 -0
  37. package/src/domain/lane.ts +69 -0
  38. package/src/domain/profile-rules.ts +205 -0
  39. package/src/domain/profile.ts +383 -0
  40. package/src/domain/record.ts +84 -0
  41. package/src/{profile → domain}/role-prompts.ts +42 -26
  42. package/src/domain/roles.ts +25 -0
  43. package/src/domain/route.ts +126 -0
  44. package/src/domain/runtime.ts +21 -0
  45. package/src/domain/sanitize.ts +29 -0
  46. package/src/domain/secrets.ts +57 -0
  47. package/src/domain/select.ts +136 -0
  48. package/src/domain/state.ts +33 -0
  49. package/src/domain/util.ts +18 -0
  50. package/src/entry/capture-fixtures-command.ts +57 -0
  51. package/src/entry/catalog-command.ts +106 -0
  52. package/src/entry/cli-kit.ts +36 -0
  53. package/src/entry/deps.ts +16 -0
  54. package/src/entry/doctor-command.ts +45 -0
  55. package/src/entry/glyphs.ts +33 -0
  56. package/src/entry/init-command.ts +142 -0
  57. package/src/entry/lock-command.ts +123 -0
  58. package/src/entry/mcp/dispatch-tools.ts +82 -0
  59. package/src/entry/mcp/handshake.ts +48 -0
  60. package/src/entry/mcp/lane-tools.ts +83 -0
  61. package/src/entry/mcp/result.ts +51 -0
  62. package/src/entry/mcp/run-tools.ts +144 -0
  63. package/src/entry/mcp/server.ts +72 -0
  64. package/src/entry/mcp/setup-tools.ts +81 -0
  65. package/src/entry/profile-command.ts +320 -0
  66. package/src/entry/prompt.ts +103 -0
  67. package/src/entry/runs-command.ts +217 -0
  68. package/src/entry/supervise-bin.ts +18 -0
  69. package/src/entry/supervise-command.ts +37 -0
  70. package/src/entry/tui/commands.ts +545 -0
  71. package/src/entry/tui/effects.ts +270 -0
  72. package/src/entry/tui/fixtures.ts +318 -0
  73. package/src/entry/tui/profile-edits.ts +138 -0
  74. package/src/entry/tui/profile-tree.ts +419 -0
  75. package/src/entry/tui/providers/app.tsx +162 -0
  76. package/src/entry/tui/providers/boundary.tsx +57 -0
  77. package/src/entry/tui/providers/data.tsx +133 -0
  78. package/src/entry/tui/providers/keymap.tsx +166 -0
  79. package/src/entry/tui/providers/root.tsx +45 -0
  80. package/src/entry/tui/providers/theme.tsx +17 -0
  81. package/src/entry/tui/providers/toast.tsx +77 -0
  82. package/src/entry/tui/run.tsx +98 -0
  83. package/src/entry/tui/state.ts +336 -0
  84. package/src/entry/tui/stories.ts +75 -0
  85. package/src/entry/tui/text.ts +118 -0
  86. package/src/entry/tui/theme.ts +139 -0
  87. package/src/entry/tui/views/app.tsx +208 -0
  88. package/src/entry/tui/views/dialogs.tsx +41 -0
  89. package/src/entry/tui/views/profile-actions.ts +321 -0
  90. package/src/entry/tui/views/profiles.tsx +440 -0
  91. package/src/entry/tui/views/runs.tsx +281 -0
  92. package/src/entry/tui/views/save-dialog.tsx +332 -0
  93. package/src/entry/tui/views/status.tsx +206 -0
  94. package/src/entry/tui/widgets/chrome.tsx +114 -0
  95. package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
  96. package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
  97. package/src/entry/tui/widgets/dialog-select.tsx +212 -0
  98. package/src/entry/tui/widgets/dialog.tsx +68 -0
  99. package/src/entry/tui/widgets/line.tsx +92 -0
  100. package/src/entry/tui/widgets/list.tsx +196 -0
  101. package/src/entry/tui/widgets/spinner.tsx +49 -0
  102. package/src/entry/tui/widgets/toast.tsx +62 -0
  103. package/src/infra/assets.ts +4 -0
  104. package/src/infra/dispatch-dir.ts +278 -0
  105. package/src/infra/env.ts +43 -0
  106. package/src/infra/filelock.ts +159 -0
  107. package/src/infra/git.ts +91 -0
  108. package/src/infra/heavy-lock.ts +36 -0
  109. package/src/infra/jev-client.ts +162 -0
  110. package/src/infra/launch.ts +25 -0
  111. package/src/infra/log.ts +84 -0
  112. package/src/infra/paths.ts +38 -0
  113. package/src/infra/proc.ts +110 -0
  114. package/src/infra/store.ts +229 -0
  115. package/src/infra/supervisor.ts +257 -0
  116. package/src/infra/version.ts +6 -0
  117. package/src/services/admission.ts +277 -0
  118. package/src/services/agent-links.ts +139 -0
  119. package/src/services/backends.ts +44 -0
  120. package/src/services/budget.ts +47 -0
  121. package/src/services/capture.ts +233 -0
  122. package/src/services/catalog-service.ts +382 -0
  123. package/src/services/dispatch-service.ts +479 -0
  124. package/src/services/dispatches.ts +145 -0
  125. package/src/services/doctor-backends.ts +185 -0
  126. package/src/services/doctor-checks.ts +103 -0
  127. package/src/services/doctor.ts +358 -0
  128. package/src/services/finalize.ts +359 -0
  129. package/src/services/jev-service.ts +183 -0
  130. package/src/services/lane-service.ts +248 -0
  131. package/src/services/ports.ts +132 -0
  132. package/src/services/preflight.ts +207 -0
  133. package/src/services/profile-service.ts +292 -0
  134. package/src/services/profile-store.ts +141 -0
  135. package/src/services/reconcile.ts +101 -0
  136. package/src/services/routing-service.ts +190 -0
  137. package/src/services/run-debug.ts +79 -0
  138. package/src/services/run-service.ts +161 -0
  139. package/src/services/run-store.ts +316 -0
  140. package/src/services/setup.ts +87 -0
  141. package/src/services/state.ts +104 -0
  142. package/src/services/summary.ts +227 -0
  143. package/catalog/catalog.json +0 -112
  144. package/catalog/models-dev.json +0 -56453
  145. package/src/core/codex-events.ts +0 -49
  146. package/src/core/codex.ts +0 -166
  147. package/src/core/harness.ts +0 -89
  148. package/src/core/lanes.ts +0 -67
  149. package/src/core/lock.ts +0 -111
  150. package/src/core/opencode.ts +0 -207
  151. package/src/core/reconcile.ts +0 -26
  152. package/src/core/reply.ts +0 -54
  153. package/src/core/runstore.ts +0 -163
  154. package/src/core/spawn.ts +0 -89
  155. package/src/core/status.ts +0 -193
  156. package/src/files.ts +0 -19
  157. package/src/mcp/dispatch.ts +0 -154
  158. package/src/mcp/lane-tools.ts +0 -200
  159. package/src/mcp/out.ts +0 -6
  160. package/src/mcp/preflight.ts +0 -84
  161. package/src/mcp/run-tools.ts +0 -121
  162. package/src/mcp/runs.ts +0 -118
  163. package/src/mcp/server.ts +0 -26
  164. package/src/mcp/setup-tools.ts +0 -175
  165. package/src/paths.ts +0 -20
  166. package/src/profile/agents.ts +0 -130
  167. package/src/profile/profile.ts +0 -228
  168. package/src/routing/catalog.ts +0 -227
  169. package/src/routing/commands.ts +0 -22
  170. package/src/routing/jev.ts +0 -185
  171. package/src/routing/route.ts +0 -155
  172. package/src/routing/select.ts +0 -90
  173. package/src/tui/backends.ts +0 -57
  174. package/src/tui/commands.ts +0 -53
  175. package/src/tui/dashboard.tsx +0 -245
  176. package/src/tui/editor.tsx +0 -214
  177. package/src/tui/init.tsx +0 -208
  178. package/src/tui/matrix-model.ts +0 -279
  179. package/src/tui/matrix.tsx +0 -512
  180. package/src/tui/profiles.ts +0 -38
  181. package/src/tui/theme.ts +0 -177
  182. package/src/tui/ui.tsx +0 -267
  183. package/src/tui/watch-model.ts +0 -83
  184. package/src/tui/watch.tsx +0 -236
  185. package/src/types.ts +0 -184
  186. package/src/version.ts +0 -7
  187. /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
@@ -0,0 +1,278 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ linkSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ statSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ writeSync,
13
+ } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { z } from "zod";
16
+ import { EXIT_REASONS, type ExitInfo } from "../domain/record.ts";
17
+ import { lockHeld, withFileLock } from "./filelock.ts";
18
+ import { isAlive, selfIdentity } from "./proc.ts";
19
+ import { PRIVATE_FILE, readVersioned } from "./store.ts";
20
+
21
+ export function dispatchPaths(dir: string) {
22
+ return {
23
+ brief: join(dir, "brief.md"),
24
+ spec: join(dir, "spec.json"),
25
+ proc: join(dir, "proc.json"),
26
+ events: join(dir, "events.jsonl"),
27
+ stderr: join(dir, "stderr"),
28
+ reply: join(dir, "reply.md"),
29
+ exit: join(dir, "exit.json"),
30
+ claim: join(dir, "claim"),
31
+ cancel: join(dir, "cancel"),
32
+ /** held by the one supervisor of this dispatch for its lifetime (filelock, dead holders reclaimed) */
33
+ supervisorLock: join(dir, "supervisor.lock"),
34
+ /** exists from launch until a `wait` hands the dispatch's record to the orchestrator */
35
+ collect: join(dir, "collect"),
36
+ /** while a `wait` hands the record back: names that wait's process, so a crash leaves it reclaimable */
37
+ lease: join(dir, "collect.lease"),
38
+ supervisorLog: join(dir, "supervisor.log"),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Exclusive create: the first caller finalizes; every later caller reads the record it wrote. The claim
44
+ * names its claimant (pid and start time), so a later caller can tell a dead claimant from a slow one.
45
+ */
46
+ export function tryClaim(dir: string): boolean {
47
+ let fd: number;
48
+ try {
49
+ fd = openSync(dispatchPaths(dir).claim, "wx", PRIVATE_FILE);
50
+ } catch (e) {
51
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
52
+ return false;
53
+ }
54
+ try {
55
+ writeSync(fd, JSON.stringify(selfIdentity()));
56
+ } catch (e) {
57
+ // a claim that names no one would hold every other finalizer off until it is stale: drop it
58
+ closeSync(fd);
59
+ try {
60
+ unlinkSync(dispatchPaths(dir).claim);
61
+ } catch {
62
+ // already gone
63
+ }
64
+ throw e;
65
+ }
66
+ closeSync(fd);
67
+ return true;
68
+ }
69
+
70
+ /** Who holds the claim, or null when it names no one (an older build's empty claim, or one being written). */
71
+ export function readClaimant(dir: string): { pid: number; startTime: string | null } | null {
72
+ try {
73
+ const c = JSON.parse(readFileSync(dispatchPaths(dir).claim, "utf8")) as {
74
+ pid?: unknown;
75
+ startTime?: unknown;
76
+ };
77
+ if (typeof c.pid !== "number") return null;
78
+ return { pid: c.pid, startTime: typeof c.startTime === "string" ? c.startTime : null };
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** Marks a dispatch as one a `wait` is to hand back; written before its launch. */
85
+ export function markForCollect(dir: string): void {
86
+ writeFileSync(dispatchPaths(dir).collect, "", { mode: PRIVATE_FILE });
87
+ }
88
+
89
+ const errno = (e: unknown): string | undefined => (e as NodeJS.ErrnoException).code;
90
+
91
+ /** Test seams: `beforeTakeover` runs after a collector judged a lease dead, before it takes the lock. */
92
+ export const collectSeams = { beforeTakeover: async (_dir: string): Promise<void> => {} };
93
+ /** The hard link a lease is made with; tests replace it to stand for a filesystem without hard links. */
94
+ export const leaseFs = { link: linkSync };
95
+
96
+ /** An ownerless lease this old was left by a crash mid-write (only the no-hard-link fallback can leave one). */
97
+ const OWNERLESS_STALE_MS = 5_000;
98
+ /** A lease temp file this old, or whose pid is dead, was left by a crash. */
99
+ const TEMP_STALE_MS = 60_000;
100
+
101
+ const self = selfIdentity;
102
+
103
+ /** Who holds a collection lease, or null when none can be read. */
104
+ function leaseOwner(file: string): { pid: number; startTime: string | null } | null {
105
+ try {
106
+ const c = JSON.parse(readFileSync(file, "utf8")) as { pid?: unknown; startTime?: unknown };
107
+ if (typeof c.pid !== "number") return null;
108
+ return { pid: c.pid, startTime: typeof c.startTime === "string" ? c.startTime : null };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function olderThan(file: string, ms: number): boolean {
115
+ try {
116
+ return statSync(file).mtimeMs < Date.now() - ms;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * A lease no live process holds: it exists, and its owner is dead, or it names none and is too old to be
124
+ * one still being written.
125
+ */
126
+ function leaseDead(file: string): boolean {
127
+ if (!existsSync(file)) return false;
128
+ const who = leaseOwner(file);
129
+ return who ? !isAlive(who.pid, who.startTime) : olderThan(file, OWNERLESS_STALE_MS);
130
+ }
131
+
132
+ /**
133
+ * Whether a `wait` still has this dispatch's record to hand back: it has the mark, or a lease whose
134
+ * collector died before handing the record back.
135
+ */
136
+ export function awaitsCollect(dir: string): boolean {
137
+ const p = dispatchPaths(dir);
138
+ return existsSync(p.collect) || leaseDead(p.lease);
139
+ }
140
+
141
+ /** Creates a lease naming this process, exclusively: true when it is now this process's. */
142
+ function createLease(dir: string): boolean {
143
+ const p = dispatchPaths(dir);
144
+ const content = JSON.stringify(self());
145
+ const tmp = `${p.lease}.${process.pid}.${Math.random().toString(36).slice(2)}`;
146
+ writeFileSync(tmp, content, { mode: PRIVATE_FILE });
147
+ try {
148
+ // linked in whole: no reader ever sees a lease without its owner
149
+ leaseFs.link(tmp, p.lease);
150
+ return true;
151
+ } catch (e) {
152
+ if (errno(e) === "EEXIST") return false;
153
+ if (!["EPERM", "ENOTSUP", "EXDEV", "EOPNOTSUPP"].includes(errno(e) ?? "")) throw e;
154
+ } finally {
155
+ rmSync(tmp, { force: true });
156
+ }
157
+ // no hard links here: an exclusive create, its owner written at once (an ownerless lease is young)
158
+ let fd: number;
159
+ try {
160
+ fd = openSync(p.lease, "wx", PRIVATE_FILE);
161
+ } catch (e) {
162
+ if (errno(e) === "EEXIST") return false;
163
+ throw e;
164
+ }
165
+ try {
166
+ writeSync(fd, content);
167
+ } finally {
168
+ closeSync(fd);
169
+ }
170
+ return true;
171
+ }
172
+
173
+ /**
174
+ * Turns a dead collector's lease back into the mark, serialized by a lock on the lease, and only after
175
+ * reading it again under the lock: the mark is written before the lease goes, so the record is never
176
+ * without both, and a lease that is not dead (gone, or a live collector's) is left alone. A moved lease
177
+ * is never linked back.
178
+ */
179
+ async function reviveDeadLease(dir: string): Promise<void> {
180
+ const p = dispatchPaths(dir);
181
+ await withFileLock(p.lease, () => {
182
+ if (!leaseDead(p.lease)) return;
183
+ markForCollect(dir);
184
+ rmSync(p.lease, { force: true });
185
+ sweepLeaseTemps(dir);
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Collects the dispatch as a lease naming this process: true for the one caller that got it, so each
191
+ * record reaches one `wait` only. The common path takes no lock: the lease is created exclusively, and
192
+ * the record is this caller's only if the mark still exists then; the mark goes only once the lease
193
+ * exists. A dead collector's lease is first turned back into the mark (`reviveDeadLease`), then
194
+ * collected the same way; a live collector's is never taken. The caller ends the lease with `endCollect`
195
+ * once the record has gone out, or turns it back into the mark with `putBackCollect`.
196
+ */
197
+ export async function tryCollect(dir: string): Promise<boolean> {
198
+ const p = dispatchPaths(dir);
199
+ if (!existsSync(p.collect)) {
200
+ if (!leaseDead(p.lease)) return false;
201
+ await collectSeams.beforeTakeover(dir);
202
+ await reviveDeadLease(dir);
203
+ }
204
+ if (!createLease(dir)) {
205
+ // a lease is there: collect it only if it is dead, and only by way of the mark
206
+ if (!leaseDead(p.lease)) return false;
207
+ await collectSeams.beforeTakeover(dir);
208
+ await reviveDeadLease(dir);
209
+ if (!createLease(dir)) return false;
210
+ }
211
+ // no lease was there when this one was made: the record is ours only if the mark still is
212
+ if (!existsSync(p.collect)) {
213
+ endCollect(dir);
214
+ return false;
215
+ }
216
+ rmSync(p.collect, { force: true });
217
+ return true;
218
+ }
219
+
220
+ /** Ends this process's lease (never another's): the record has gone out. Sweeps leftover temp files. */
221
+ export function endCollect(dir: string): void {
222
+ const p = dispatchPaths(dir);
223
+ const who = leaseOwner(p.lease);
224
+ // the pid alone: while this process lives no other can hold its pid, so a lease naming it is its own
225
+ if (who?.pid === process.pid) rmSync(p.lease, { force: true });
226
+ sweepLeaseTemps(dir);
227
+ }
228
+
229
+ /** Turns this process's lease back into the mark: the record did not go out. */
230
+ export function putBackCollect(dir: string): void {
231
+ markForCollect(dir);
232
+ endCollect(dir);
233
+ }
234
+
235
+ const LEASE_TEMP = /^collect\.lease\.(?:dead\.)?(\d+)\.[^.]+$/;
236
+
237
+ /** Removes lease temp files a crash left behind: a dead pid's, or any older than a minute. */
238
+ function sweepLeaseTemps(dir: string): void {
239
+ let names: string[];
240
+ try {
241
+ names = readdirSync(dir);
242
+ } catch {
243
+ return;
244
+ }
245
+ for (const n of names) {
246
+ const m = LEASE_TEMP.exec(n);
247
+ if (!m) continue;
248
+ const f = join(dir, n);
249
+ if (!isAlive(Number(m[1]), null) || olderThan(f, TEMP_STALE_MS)) rmSync(f, { force: true });
250
+ }
251
+ }
252
+
253
+ const ExitFileSchema = z.looseObject({
254
+ schema: z.literal(1),
255
+ code: z.number().nullable(),
256
+ signal: z.string().nullable(),
257
+ reason: z.enum(EXIT_REASONS),
258
+ endedAt: z.string(),
259
+ });
260
+
261
+ export function readExit(dir: string): ExitInfo | null {
262
+ try {
263
+ const e = readVersioned(dispatchPaths(dir).exit, ExitFileSchema, 1);
264
+ return { code: e.code, signal: e.signal, reason: e.reason, endedAt: e.endedAt };
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+
270
+ export function requestCancel(dir: string): void {
271
+ writeFileSync(dispatchPaths(dir).cancel, new Date().toISOString(), { mode: PRIVATE_FILE });
272
+ }
273
+
274
+ /** The target of the dispatch's supervisor lock (`supervisor.lock`, as `dispatchPaths` names it). */
275
+ export const supervisorLockTarget = (dir: string): string => join(dir, "supervisor");
276
+
277
+ /** Whether a live supervisor holds the dispatch: launch evidence even before launch.json or proc.json. */
278
+ export const supervisorAlive = (dir: string): boolean => lockHeld(supervisorLockTarget(dir));
@@ -0,0 +1,43 @@
1
+ /** catherd's own secrets; the user's backend credentials (OPENAI_API_KEY, …) stay, workers need them. */
2
+ const SECRET_ENV = new Set(["TYPESAFE_API_KEY"]);
3
+
4
+ /** `base` without catherd's own secrets and without unset keys; for every process catherd starts. */
5
+ export function scrubSecrets(base: Record<string, string | undefined>): Record<string, string> {
6
+ const env: Record<string, string> = {};
7
+ for (const [k, v] of Object.entries(base)) if (v !== undefined && !SECRET_ENV.has(k)) env[k] = v;
8
+ return env;
9
+ }
10
+
11
+ export function workerEnv(
12
+ base: Record<string, string | undefined>,
13
+ overrides: Record<string, string>,
14
+ cwd: string,
15
+ ): Record<string, string> {
16
+ return { ...scrubSecrets(base), ...overrides, PWD: cwd };
17
+ }
18
+
19
+ /** What a preflight check may see; everything else, credentials included, stays out (spec §4.7). */
20
+ const CHECK_ENV = new Set([
21
+ "PATH",
22
+ "HOME",
23
+ "USER",
24
+ "LOGNAME",
25
+ "SHELL",
26
+ "LANG",
27
+ "TZ",
28
+ "TERM",
29
+ "TMPDIR",
30
+ "CI",
31
+ "NODE_ENV",
32
+ "CATHERD_HOME",
33
+ "CATHERD_LOCK_SLOTS",
34
+ ]);
35
+ const CHECK_ENV_PATTERN = /^(LC_[A-Z_]+|XDG_[A-Z_]+_(HOME|DIR|DIRS))$/;
36
+
37
+ /** A strict allowlist of `base` plus PWD, for lane-authored shell text such as preflight checks. */
38
+ export function checkEnv(base: Record<string, string | undefined>, cwd: string): Record<string, string> {
39
+ const env: Record<string, string> = {};
40
+ for (const [k, v] of Object.entries(base))
41
+ if (v !== undefined && (CHECK_ENV.has(k) || CHECK_ENV_PATTERN.test(k))) env[k] = v;
42
+ return { ...env, PWD: cwd };
43
+ }
@@ -0,0 +1,159 @@
1
+ import { closeSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
2
+ import { CatherdError } from "../domain/errors.ts";
3
+ import { isAlive, isValidPid, selfIdentity } from "./proc.ts";
4
+ import { PRIVATE_FILE } from "./store.ts";
5
+
6
+ interface Holder {
7
+ pid: number;
8
+ startTime: string | null;
9
+ }
10
+
11
+ /** A lock or reclaim marker this old whose owner cannot be identified was left by a crash. */
12
+ const STALE_MS = 5_000;
13
+
14
+ const me = (): Holder => selfIdentity();
15
+
16
+ /** The holder recorded in `lock`, or null when it is missing, empty, unparsable or malformed. */
17
+ function readHolder(lock: string): Holder | null {
18
+ let v: unknown;
19
+ try {
20
+ v = JSON.parse(readFileSync(lock, "utf8"));
21
+ } catch {
22
+ return null;
23
+ }
24
+ const h = v as Partial<Holder> | null;
25
+ if (typeof h !== "object" || h === null || !isValidPid(h.pid)) return null;
26
+ if (h.startTime !== null && typeof h.startTime !== "string") return null;
27
+ return { pid: h.pid, startTime: h.startTime };
28
+ }
29
+
30
+ function olderThan(file: string, ms: number): boolean {
31
+ try {
32
+ return statSync(file).mtimeMs < Date.now() - ms;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Creates `file` exclusively with `content`; false when it already exists. */
39
+ function tryCreate(file: string, content: string): boolean {
40
+ try {
41
+ const fd = openSync(file, "wx", PRIVATE_FILE);
42
+ try {
43
+ writeSync(fd, content);
44
+ } finally {
45
+ closeSync(fd);
46
+ }
47
+ return true;
48
+ } catch (e) {
49
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
50
+ return false;
51
+ }
52
+ }
53
+
54
+ const tryTake = (lock: string, self: Holder): boolean => tryCreate(lock, JSON.stringify(self));
55
+
56
+ /** Whether `lock` is abandoned: its holder is dead, or it names no valid holder and is older than STALE_MS. */
57
+ function abandoned(lock: string, h: Holder | null): boolean {
58
+ return h ? !isAlive(h.pid, h.startTime) : olderThan(lock, STALE_MS);
59
+ }
60
+
61
+ const sameHolder = (a: Holder | null, b: Holder | null): boolean =>
62
+ a === b || (a !== null && b !== null && a.pid === b.pid && a.startTime === b.startTime);
63
+
64
+ /**
65
+ * Removes an abandoned lock. Reclaims are serialised by `${lock}.reclaim`, and the reclaimer
66
+ * re-reads the lock while holding it and removes it only if it still names the same abandoned
67
+ * holder, so a waiter can never delete a lock that a live process took in the meantime.
68
+ */
69
+ function reclaimIfDead(lock: string): void {
70
+ const seen = readHolder(lock);
71
+ if (!abandoned(lock, seen)) return;
72
+ const marker = `${lock}.reclaim`;
73
+ if (!tryCreate(marker, String(process.pid))) {
74
+ // A reclaim takes milliseconds; a marker this old was left by a reclaimer that crashed.
75
+ if (olderThan(marker, STALE_MS)) rmSync(marker, { force: true });
76
+ return;
77
+ }
78
+ try {
79
+ const now = readHolder(lock);
80
+ if (sameHolder(now, seen) && abandoned(lock, now)) rmSync(lock, { force: true });
81
+ } finally {
82
+ rmSync(marker, { force: true });
83
+ }
84
+ }
85
+
86
+ const timedOut = (target: string, lock: string) =>
87
+ new CatherdError("E_IO_LOCK", `timed out waiting for the lock on ${target}`, {
88
+ fix: `if no catherd process is running, delete ${lock}`,
89
+ });
90
+
91
+ function release(lock: string, self: Holder): void {
92
+ const h = readHolder(lock);
93
+ if (h?.pid === self.pid && h.startTime === self.startTime) rmSync(lock, { force: true });
94
+ }
95
+
96
+ export async function withFileLock<T>(
97
+ target: string,
98
+ fn: () => T | Promise<T>,
99
+ o: { timeoutMs?: number; pollMs?: number } = {},
100
+ ): Promise<T> {
101
+ const lock = `${target}.lock`;
102
+ const self = me();
103
+ const deadline = Date.now() + (o.timeoutMs ?? 10_000);
104
+ while (!tryTake(lock, self)) {
105
+ reclaimIfDead(lock);
106
+ if (Date.now() > deadline) throw timedOut(target, lock);
107
+ await Bun.sleep(o.pollMs ?? 25);
108
+ }
109
+ try {
110
+ return await fn();
111
+ } finally {
112
+ release(lock, self);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * `withFileLock` for a synchronous critical section, waiting with a blocking sleep. For writers whose
118
+ * callers are synchronous (`withProfilesLock` in the profile service); the lock is held for milliseconds.
119
+ * Not reentrant: a section must not take the same lock again.
120
+ */
121
+ export function withFileLockSync<T>(
122
+ target: string,
123
+ fn: () => T,
124
+ o: { timeoutMs?: number; pollMs?: number } = {},
125
+ ): T {
126
+ const lock = `${target}.lock`;
127
+ const self = me();
128
+ const deadline = Date.now() + (o.timeoutMs ?? 10_000);
129
+ while (!tryTake(lock, self)) {
130
+ reclaimIfDead(lock);
131
+ if (Date.now() > deadline) throw timedOut(target, lock);
132
+ Bun.sleepSync(o.pollMs ?? 25);
133
+ }
134
+ try {
135
+ return fn();
136
+ } finally {
137
+ release(lock, self);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * One attempt at the lock on `target`, reclaiming it first from a dead holder. Returns its release,
143
+ * or null while a live holder keeps it. For locks held for a long time, like the heavy-command slots.
144
+ */
145
+ export function tryLock(target: string): (() => void) | null {
146
+ const lock = `${target}.lock`;
147
+ const self = me();
148
+ if (!tryTake(lock, self)) {
149
+ reclaimIfDead(lock);
150
+ if (!tryTake(lock, self)) return null;
151
+ }
152
+ return () => release(lock, self);
153
+ }
154
+
155
+ /** Whether a live process holds the lock on `target` (an unreadable holder counts as none). */
156
+ export function lockHeld(target: string): boolean {
157
+ const h = readHolder(`${target}.lock`);
158
+ return h !== null && isAlive(h.pid, h.startTime);
159
+ }
@@ -0,0 +1,91 @@
1
+ import { statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parsePorcelainZ, type Snapshot } from "../domain/changes.ts";
4
+ import { CatherdError } from "../domain/errors.ts";
5
+ import { scrubSecrets } from "./env.ts";
6
+
7
+ /** How one git command ended: its stdout, a failure (exit null when git could not start), or a timeout. */
8
+ export type GitResult =
9
+ | { kind: "ok"; out: string }
10
+ | { kind: "failed"; exit: number | null }
11
+ | { kind: "timed-out" };
12
+
13
+ /** How long one git command may run by default; finalize's claim window counts it (tests shorten it). */
14
+ export const gitLimits = { timeoutMs: 15_000 };
15
+
16
+ /**
17
+ * `git -C repo <args>` with a timeout. GIT_OPTIONAL_LOCKS=0 keeps `status` from taking index.lock,
18
+ * which would fail a worker's own git command running at the same moment. On a timeout git is killed
19
+ * and its stdout is not awaited, since a grandchild may still hold the pipe open.
20
+ */
21
+ export async function git(repo: string, args: string[], timeoutMs = gitLimits.timeoutMs): Promise<GitResult> {
22
+ let p: Bun.Subprocess<"ignore", "pipe", "ignore">;
23
+ try {
24
+ p = Bun.spawn(["git", "-C", repo, ...args], {
25
+ stdin: "ignore",
26
+ stdout: "pipe",
27
+ stderr: "ignore",
28
+ env: { ...scrubSecrets(process.env), GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" },
29
+ });
30
+ } catch {
31
+ return { kind: "failed", exit: null };
32
+ }
33
+ let timedOut = false;
34
+ const timer = setTimeout(() => {
35
+ timedOut = true;
36
+ p.kill("SIGKILL");
37
+ }, timeoutMs);
38
+ const out = new Response(p.stdout).text().catch(() => "");
39
+ const code = await p.exited;
40
+ clearTimeout(timer);
41
+ if (timedOut) return { kind: "timed-out" };
42
+ return code === 0 ? { kind: "ok", out: await out } : { kind: "failed", exit: code };
43
+ }
44
+
45
+ const gitBroken = (repo: string, args: string[], r: GitResult) =>
46
+ new CatherdError(
47
+ "E_IO_UNEXPECTED",
48
+ `git ${args[0]} ${r.kind === "timed-out" ? "timed out" : "failed"} in ${repo}`,
49
+ { fix: `check that git works in ${repo}` },
50
+ );
51
+
52
+ export async function gitToplevel(dir: string): Promise<string | null> {
53
+ const r = await git(dir, ["rev-parse", "--show-toplevel"]);
54
+ return r.kind === "ok" ? r.out.trim() || null : null;
55
+ }
56
+
57
+ /** The short HEAD, or null when git cannot say (no commit, not a repo, failure, timeout). */
58
+ export async function gitHead(repo: string, timeoutMs?: number): Promise<string | null> {
59
+ const r = await git(repo, ["rev-parse", "--short", "HEAD"], timeoutMs);
60
+ return (r.kind === "ok" && r.out.trim()) || null;
61
+ }
62
+
63
+ /** False for a missing commit; throws E_IO_UNEXPECTED when git times out, since that proves nothing. */
64
+ export async function commitExists(repo: string, commit: string, timeoutMs?: number): Promise<boolean> {
65
+ const args = ["cat-file", "-e", `${commit}^{commit}`];
66
+ const r = await git(repo, args, timeoutMs);
67
+ if (r.kind === "timed-out") throw gitBroken(repo, args, r);
68
+ return r.kind === "ok";
69
+ }
70
+
71
+ function fingerprint(repo: string, xy: string, path: string): string {
72
+ try {
73
+ const s = statSync(join(repo, path));
74
+ return `${xy} ${s.size} ${s.mtimeMs}`;
75
+ } catch {
76
+ return `${xy} gone`;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The repo's dirty files, each with a fingerprint. Throws E_IO_UNEXPECTED when git fails or times out
82
+ * (including outside a repository), so a broken git never reads as a clean tree.
83
+ */
84
+ export async function statusSnapshot(repo: string, timeoutMs?: number): Promise<Snapshot> {
85
+ const args = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];
86
+ const r = await git(repo, args, timeoutMs);
87
+ if (r.kind !== "ok") throw gitBroken(repo, args, r);
88
+ const snap: Snapshot = {};
89
+ for (const { xy, path } of parsePorcelainZ(r.out)) snap[path] = fingerprint(repo, xy, path);
90
+ return snap;
91
+ }
@@ -0,0 +1,36 @@
1
+ import { availableParallelism } from "node:os";
2
+ import { join } from "node:path";
3
+ import { tryLock } from "./filelock.ts";
4
+ import { locksDir } from "./paths.ts";
5
+ import { ensurePrivateDir } from "./store.ts";
6
+
7
+ /** A profile's `lock.heavy` as a slot count: a number, or half the cores. */
8
+ export function heavySlots(setting: number | "cpus/2" | undefined): number {
9
+ if (typeof setting === "number" && Number.isFinite(setting)) return Math.max(1, Math.floor(setting));
10
+ return Math.max(1, Math.floor(availableParallelism() / 2));
11
+ }
12
+
13
+ /**
14
+ * Runs `fn` holding one of `slots` machine-wide heavy-command slots, waiting for a free one. A slot is
15
+ * a file lock, so a slot whose holder died, or died before writing its name, is reclaimed.
16
+ */
17
+ export async function withHeavySlot<T>(
18
+ slots: number,
19
+ fn: (slot: number) => T | Promise<T>,
20
+ o: { pollMs?: number } = {},
21
+ ): Promise<T> {
22
+ const dir = locksDir();
23
+ ensurePrivateDir(dir);
24
+ for (;;) {
25
+ for (let i = 0; i < slots; i++) {
26
+ const release = tryLock(join(dir, `slot-${i}`));
27
+ if (!release) continue;
28
+ try {
29
+ return await fn(i);
30
+ } finally {
31
+ release();
32
+ }
33
+ }
34
+ await Bun.sleep(o.pollMs ?? 500);
35
+ }
36
+ }