@nodii/dev 0.6.28 → 0.6.29

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.
@@ -0,0 +1,308 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile, chmod } from "node:fs/promises";
3
+ import { homedir, platform } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { CliError, configPath, devRequest, encode, readConfig } from "./core.js";
7
+
8
+ const execFile = promisify(execFileCallback);
9
+ export const WATCHDOG_LABEL = "com.nodii.watchdog";
10
+ const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11
+
12
+ export const watchdogHome = (home = homedir()) => join(home, ".config", "nodii-dev", "watchdog");
13
+ export const watchdogRunDir = (home = homedir()) => join(watchdogHome(home), "runs");
14
+
15
+ function validateRecord(record) {
16
+ if (!record || typeof record !== "object") throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run record must be an object.", 2);
17
+ if (!RUN_ID.test(record.runId ?? "")) throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run record needs its Dev run UUID.", 2);
18
+ if (!Number.isSafeInteger(record.pid) || record.pid < 1) throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run record needs a positive process id.", 2);
19
+ if (record.configPath !== undefined && (typeof record.configPath !== "string" || !isAbsolute(record.configPath)))
20
+ throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run config path must be absolute.", 2);
21
+ if (record.worktree !== undefined && (typeof record.worktree !== "string" || !isAbsolute(record.worktree)))
22
+ throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run worktree must be an absolute path.", 2);
23
+ return record;
24
+ }
25
+
26
+ const serializableRecord = (record) => Object.fromEntries(Object.entries(record).filter(([key]) => key !== "registryPath"));
27
+
28
+ async function atomicWrite(path, contents, mode = 0o600) {
29
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
30
+ const temporary = `${path}.tmp-${process.pid}`;
31
+ await writeFile(temporary, contents, { mode });
32
+ await rename(temporary, path);
33
+ await chmod(path, mode);
34
+ }
35
+
36
+ /** Persisted by lane launch / run wrappers; one file means concurrent runs never rewrite a shared array. */
37
+ export async function recordWatchedRun(input, { home = homedir(), now = () => new Date(), processStart = processStartedAt } = {}) {
38
+ const record = validateRecord({ ...input, version: 1, recordedAt: now().toISOString() });
39
+ if (!record.processStartedAt) record.processStartedAt = await processStart(record.pid).catch(() => null);
40
+ const dir = watchdogRunDir(home);
41
+ await mkdir(dir, { recursive: true, mode: 0o700 });
42
+ await chmod(dir, 0o700);
43
+ const path = join(dir, `${record.runId}.json`);
44
+ await atomicWrite(path, `${JSON.stringify(serializableRecord(record), null, 2)}\n`);
45
+ return { ...record, registryPath: path };
46
+ }
47
+
48
+ export async function forgetWatchedRun(runId, { home = homedir() } = {}) {
49
+ if (!RUN_ID.test(runId ?? "")) throw new CliError("WATCHDOG_RECORD_INVALID", "A watched run id must be a UUID.", 2);
50
+ await rm(join(watchdogRunDir(home), `${runId}.json`), { force: true });
51
+ }
52
+
53
+ export async function readWatchedRuns({ home = homedir() } = {}) {
54
+ const dir = watchdogRunDir(home);
55
+ const names = (await readdir(dir).catch((error) => error?.code === "ENOENT" ? [] : Promise.reject(error)))
56
+ .filter((name) => name.endsWith(".json"));
57
+ const records = [], errors = [];
58
+ for (const name of names) {
59
+ try {
60
+ const record = validateRecord(JSON.parse(await readFile(join(dir, name), "utf8")));
61
+ records.push({ ...record, registryPath: join(dir, name) });
62
+ } catch (error) {
63
+ errors.push({ kind: "registry", file: name, code: error?.code ?? "WATCHDOG_RECORD_INVALID" });
64
+ }
65
+ }
66
+ return { records, errors };
67
+ }
68
+
69
+ export async function saveWatchedRun(record) {
70
+ const path = record.registryPath;
71
+ if (!path || !isAbsolute(path)) throw new CliError("WATCHDOG_RECORD_INVALID", "The watched run record has no registry path.", 2);
72
+ await atomicWrite(path, `${JSON.stringify(serializableRecord(validateRecord(record)), null, 2)}\n`);
73
+ }
74
+
75
+ export async function processStartedAt(pid, { execute = execFile } = {}) {
76
+ const { stdout } = await execute("ps", ["-p", String(pid), "-o", "lstart="]);
77
+ const value = stdout.trim();
78
+ if (!value) throw new Error(`process ${pid} is absent`);
79
+ return value;
80
+ }
81
+
82
+ export async function processStatus(pid, { execute = execFile } = {}) {
83
+ const { stdout } = await execute("ps", ["-p", String(pid), "-o", "stat="]);
84
+ return stdout.trim();
85
+ }
86
+
87
+ export async function isProcessAlive(pid, expectedStartedAt, { signal = process.kill, processStart = processStartedAt, processStatus: status = processStatus } = {}) {
88
+ let exists = true;
89
+ try { signal(pid, 0); }
90
+ catch (error) {
91
+ if (error?.code !== "EPERM") return false;
92
+ exists = true;
93
+ }
94
+ try { if (/^Z/.test(await status(pid))) return false; }
95
+ catch { return exists; }
96
+ if (!expectedStartedAt) return true;
97
+ try { return (await processStart(pid)) === expectedStartedAt; }
98
+ catch { return false; }
99
+ }
100
+
101
+ export async function findStaleSockets({ socketDir = "/tmp/cc-socks", isPidAlive = isProcessAlive } = {}) {
102
+ const entries = await readdir(socketDir, { withFileTypes: true }).catch((error) => error?.code === "ENOENT" ? [] : Promise.reject(error));
103
+ const stale = [];
104
+ for (const entry of entries) {
105
+ const match = /^(\d+)\.sock$/.exec(entry.name);
106
+ if (!match || !entry.isSocket()) continue;
107
+ const pid = Number(match[1]);
108
+ if (!Number.isSafeInteger(pid) || pid < 1) continue;
109
+ if (!(await isPidAlive(pid))) stale.push({ path: join(socketDir, entry.name), pid });
110
+ }
111
+ return stale;
112
+ }
113
+
114
+ const git = async (worktree, args, execute = execFile) => (await execute("git", ["-C", worktree, ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 })).stdout.trim();
115
+
116
+ /** Compare a worktree with the remote-tracking branch its branch actually uses. */
117
+ export async function inspectWorktree(worktree, { execute = execFile } = {}) {
118
+ const root = await git(worktree, ["rev-parse", "--show-toplevel"], execute);
119
+ const branch = await git(worktree, ["branch", "--show-current"], execute);
120
+ if (!branch) return { worktree: root, branch: null, remoteBranch: null, aheadCount: 0, commits: [], error: "detached_head" };
121
+ let remoteBranch;
122
+ try { remoteBranch = await git(worktree, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], execute); }
123
+ catch {
124
+ const conventional = `origin/${branch}`;
125
+ try { await git(worktree, ["rev-parse", "--verify", conventional], execute); remoteBranch = conventional; }
126
+ catch { return { worktree: root, branch, remoteBranch: null, aheadCount: 0, commits: [], error: "remote_branch_missing" }; }
127
+ }
128
+ const aheadCount = Number(await git(worktree, ["rev-list", "--count", `${remoteBranch}..HEAD`], execute));
129
+ const commits = aheadCount ? (await git(worktree, ["rev-list", "--max-count=100", `${remoteBranch}..HEAD`], execute)).split("\n").filter(Boolean) : [];
130
+ return { worktree: root, branch, remoteBranch, aheadCount, commits, ...(aheadCount > commits.length ? { commitsTruncated: true } : {}) };
131
+ }
132
+
133
+ export async function collectWatchdogFacts(records, {
134
+ isPidAlive = isProcessAlive,
135
+ listStaleSockets = () => findStaleSockets({ isPidAlive }),
136
+ inspect = inspectWorktree,
137
+ now = () => new Date(),
138
+ errors = [],
139
+ } = {}) {
140
+ const deadRuns = [];
141
+ for (const record of records) {
142
+ if (!(await isPidAlive(record.pid, record.processStartedAt)))
143
+ deadRuns.push({ runId: record.runId, pid: record.pid, ...(record.worktree ? { worktree: record.worktree } : {}) });
144
+ }
145
+ const unpushedWorktrees = [];
146
+ for (const worktree of new Set(records.map((record) => record.worktree).filter(Boolean))) {
147
+ try {
148
+ const result = await inspect(worktree);
149
+ if (result.aheadCount > 0 || result.error === "remote_branch_missing") unpushedWorktrees.push(result);
150
+ } catch (error) {
151
+ errors.push({ kind: "worktree", worktree, code: error?.code ?? "GIT_INSPECTION_FAILED" });
152
+ }
153
+ }
154
+ let staleSockets = [];
155
+ try { staleSockets = await listStaleSockets(); }
156
+ catch (error) { errors.push({ kind: "socket", code: error?.code ?? "SOCKET_INSPECTION_FAILED" }); }
157
+ return { checkedAt: now().toISOString(), deadRuns, staleSockets, unpushedWorktrees, errors };
158
+ }
159
+
160
+ async function assertCoordinator(config, request) {
161
+ if (!config?.agentId) throw new CliError("WATCHDOG_COORDINATOR_REQUIRED", "The watchdog needs a registered machine coordinator config.", 2);
162
+ const agent = (await request("GET", `/api/v1/agents/${encode(config.agentId)}`)).data;
163
+ if (agent?.role !== "coordinator" || !agent.machineId)
164
+ throw new CliError("WATCHDOG_COORDINATOR_REQUIRED", "Install and run the per-Mac watchdog with that machine's coordinator agent config.", 2);
165
+ return agent;
166
+ }
167
+
168
+ async function coordinatorMachine({ request = devRequest, config = readConfig } = {}) {
169
+ return assertCoordinator(await config(), request);
170
+ }
171
+
172
+ const observationBody = ({ observation, machineId, pid, observedAt, detail }) => ({
173
+ observation, machineId, ...(pid === undefined ? {} : { pid }), observedAt, ...(detail ? { detail } : {}),
174
+ });
175
+
176
+ /** One launchd sweep. The machine coordinator reports every observation through Dev's run watchdog contract. */
177
+ export async function sweepWatchdog({
178
+ records,
179
+ loadRecords = readWatchedRuns,
180
+ collect = collectWatchdogFacts,
181
+ request = devRequest,
182
+ saveRecord = saveWatchedRun,
183
+ machineId,
184
+ now = () => new Date(),
185
+ } = {}) {
186
+ const loaded = records ? { records, errors: [] } : await loadRecords();
187
+ const facts = await collect(loaded.records, { errors: [...loaded.errors] });
188
+ const byId = new Map(loaded.records.map((record) => [record.runId, record]));
189
+ const effectiveMachineId = machineId ?? (await coordinatorMachine({ request })).machineId;
190
+ const orphanedRuns = [];
191
+ // Record the non-terminal observations first: process_dead transitions the
192
+ // run to Orphan immediately, and must not prevent its stale socket or local
193
+ // commits from reaching Dev in the same sweep.
194
+ for (const socket of facts.staleSockets) {
195
+ const record = loaded.records.find((candidate) => candidate.pid === socket.pid);
196
+ if (!record) continue;
197
+ try {
198
+ await request("POST", `/api/v1/runs/${encode(record.runId)}/watchdog`, observationBody({
199
+ observation: "socket_stale", machineId: effectiveMachineId, pid: socket.pid, observedAt: now().toISOString(),
200
+ detail: `${socket.path} has no live process.`,
201
+ }));
202
+ } catch (error) {
203
+ facts.errors.push({ kind: "run", runId: record.runId, observation: "socket_stale", code: error?.code ?? "RUN_WATCHDOG_REPORT_FAILED" });
204
+ }
205
+ }
206
+ for (const worktree of facts.unpushedWorktrees) {
207
+ const record = loaded.records.find((candidate) => candidate.worktree && resolve(candidate.worktree) === resolve(worktree.worktree));
208
+ if (!record) continue;
209
+ const remote = worktree.remoteBranch ?? "its missing remote branch";
210
+ const commits = worktree.commits.slice(0, 20).join(",");
211
+ const detail = worktree.error === "remote_branch_missing"
212
+ ? `${worktree.branch} has no remote branch.`
213
+ : `${worktree.branch} is ${worktree.aheadCount} commit(s) ahead of ${remote}${commits ? `: ${commits}` : ""}`;
214
+ try {
215
+ await request("POST", `/api/v1/runs/${encode(record.runId)}/watchdog`, observationBody({
216
+ observation: "worktree_unpushed", machineId: effectiveMachineId, observedAt: now().toISOString(), detail,
217
+ }));
218
+ } catch (error) {
219
+ facts.errors.push({ kind: "run", runId: record.runId, observation: "worktree_unpushed", code: error?.code ?? "RUN_WATCHDOG_REPORT_FAILED" });
220
+ }
221
+ }
222
+ for (const dead of facts.deadRuns) {
223
+ const record = byId.get(dead.runId);
224
+ if (!record || record.orphanReportedAt || record.terminalSeenAt) continue;
225
+ try {
226
+ const observedAt = now().toISOString();
227
+ const response = await request("POST", `/api/v1/runs/${encode(record.runId)}/watchdog`, observationBody({
228
+ observation: "process_dead", machineId: effectiveMachineId, pid: record.pid, observedAt,
229
+ detail: "Process exited without a terminal run state.",
230
+ }));
231
+ if (response?.data?.run?.livenessState === "orphan") {
232
+ record.orphanReportedAt = observedAt;
233
+ orphanedRuns.push({ runId: record.runId, pid: record.pid, reportedAt: record.orphanReportedAt });
234
+ } else if (["done", "handed_off", "failed"].includes(response?.data?.run?.status)) {
235
+ record.terminalSeenAt = observedAt;
236
+ } else {
237
+ throw new CliError("RUN_WATCHDOG_RESPONSE_INVALID", "Dev recorded process_dead without returning the run as orphan or terminal.", 1);
238
+ }
239
+ await saveRecord(record);
240
+ } catch (error) {
241
+ facts.errors.push({ kind: "run", runId: record.runId, observation: "process_dead", code: error?.code ?? "RUN_WATCHDOG_REPORT_FAILED" });
242
+ }
243
+ }
244
+ const report = { ...facts, orphanedRuns };
245
+ return report;
246
+ }
247
+
248
+ const xml = (value) => String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
249
+
250
+ export function launchdPlist({ runtime, entry, config, interval, stdoutPath, stderrPath }) {
251
+ const args = [runtime, entry, "watchdog", "sweep", "--json"].map((value) => `<string>${xml(value)}</string>`).join("");
252
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${WATCHDOG_LABEL}</string><key>ProgramArguments</key><array>${args}</array><key>EnvironmentVariables</key><dict><key>NODII_DEV_CONFIG_PATH</key><string>${xml(config)}</string></dict><key>RunAtLoad</key><true/><key>StartInterval</key><integer>${interval}</integer><key>ProcessType</key><string>Background</string><key>StandardOutPath</key><string>${xml(stdoutPath)}</string><key>StandardErrorPath</key><string>${xml(stderrPath)}</string></dict></plist>\n`;
253
+ }
254
+
255
+ export async function installWatchdog({
256
+ home = homedir(),
257
+ uid = process.getuid?.(),
258
+ os = platform(),
259
+ interval = 60,
260
+ runtime = process.execPath,
261
+ entry = resolve(process.argv[1]),
262
+ configFile = configPath(),
263
+ config = readConfig,
264
+ request = devRequest,
265
+ execute = execFile,
266
+ } = {}) {
267
+ if (os !== "darwin") throw new CliError("WATCHDOG_UNSUPPORTED", "The per-Mac watchdog installs with launchd and is supported on macOS only.", 2);
268
+ if (!Number.isInteger(interval) || interval < 10 || interval > 3600)
269
+ throw new CliError("USAGE", "watchdog install --interval must be 10..3600 seconds.", 2);
270
+ if (!Number.isInteger(uid) || uid < 0) throw new CliError("WATCHDOG_INSTALL_FAILED", "Cannot determine the macOS user id for launchd.", 1);
271
+ const current = typeof config === "function" ? await config() : config;
272
+ await assertCoordinator(current, request);
273
+ const logs = join(home, "Library", "Logs", "nodii-dev");
274
+ const plistPath = join(home, "Library", "LaunchAgents", `${WATCHDOG_LABEL}.plist`);
275
+ const contents = launchdPlist({ runtime, entry, config: resolve(configFile), interval, stdoutPath: join(logs, "watchdog.log"), stderrPath: join(logs, "watchdog.error.log") });
276
+ await mkdir(logs, { recursive: true, mode: 0o700 });
277
+ const previous = await readFile(plistPath, "utf8").catch((error) => error?.code === "ENOENT" ? null : Promise.reject(error));
278
+ const service = `gui/${uid}/${WATCHDOG_LABEL}`;
279
+ const domain = `gui/${uid}`;
280
+ let loaded = true;
281
+ try { await execute("launchctl", ["print", service]); }
282
+ catch { loaded = false; }
283
+ if (previous === contents && loaded) return { changed: false, loaded: true, plistPath, label: WATCHDOG_LABEL, interval };
284
+ if (loaded) await execute("launchctl", ["bootout", service]);
285
+ try {
286
+ if (previous !== contents) await atomicWrite(plistPath, contents);
287
+ await execute("launchctl", ["bootstrap", domain, plistPath]);
288
+ } catch (error) {
289
+ if (previous === null) await rm(plistPath, { force: true });
290
+ else await atomicWrite(plistPath, previous);
291
+ if (loaded && previous !== null) await execute("launchctl", ["bootstrap", domain, plistPath]).catch(() => {});
292
+ throw new CliError("WATCHDOG_INSTALL_FAILED", `Could not load ${WATCHDOG_LABEL}: ${error?.message ?? error}`, 1);
293
+ }
294
+ return { changed: previous !== contents, loaded: true, plistPath, label: WATCHDOG_LABEL, interval };
295
+ }
296
+
297
+ export async function watchdogCommand(action, flags, dependencies = {}) {
298
+ if (action === "install") {
299
+ const interval = Number(flags.interval ?? 60);
300
+ const data = await installWatchdog({ interval, ...dependencies });
301
+ return { payload: { data }, human: `${data.label} is loaded from ${data.plistPath}; sweep every ${data.interval}s${data.changed ? "." : " (already current)."}` };
302
+ }
303
+ if (action === "sweep") {
304
+ const data = await sweepWatchdog(dependencies);
305
+ return { payload: { data }, human: `Watchdog sweep: ${data.orphanedRuns.length} orphaned run(s), ${data.staleSockets.length} stale socket(s), ${data.unpushedWorktrees.length} unpushed worktree(s), ${data.errors.length} error(s).` };
306
+ }
307
+ throw new CliError("USAGE", "watchdog install [--interval SECONDS] | watchdog sweep", 2);
308
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodii/dev",
3
- "version": "0.6.28",
3
+ "version": "0.6.29",
4
4
  "description": "Agent CLI for Nodii Dev, reached through the platform edge",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,8 @@
21
21
  "test": "node --test test/*.test.js",
22
22
  "pack:smoke": "node scripts/pack-smoke.mjs",
23
23
  "openapi:snapshot": "node scripts/openapi-snapshot.mjs",
24
- "openapi:ad-era": "node scripts/ad-era-openapi-snapshot.mjs"
24
+ "openapi:ad-era": "node scripts/ad-era-openapi-snapshot.mjs",
25
+ "openapi:watchdog": "node scripts/watchdog-openapi-snapshot.mjs"
25
26
  },
26
27
  "license": "UNLICENSED",
27
28
  "publishConfig": {