@nodii/dev 0.6.27 → 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,270 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { configPath } from "./core.js";
6
+ import { recordWatchedRun } from "./watchdog.js";
7
+
8
+ const RUNTIMES = new Set(["codex", "claude"]);
9
+
10
+ const requiredText = (value, name) => {
11
+ if (typeof value !== "string" || !value.trim())
12
+ throw new TypeError(`${name} must be a non-empty string`);
13
+ return value;
14
+ };
15
+
16
+ const absolutePath = (value, name) => {
17
+ requiredText(value, name);
18
+ if (!isAbsolute(value) || resolve(value) !== value)
19
+ throw new TypeError(`${name} must be an absolute, normalized path`);
20
+ return value;
21
+ };
22
+
23
+ const printable = (value) => {
24
+ if (value === undefined || value === null || value === "") return "(none)";
25
+ if (typeof value === "string") return value;
26
+ return JSON.stringify(value, null, 2);
27
+ };
28
+
29
+ const dataFrom = (payload) => {
30
+ if (!payload || typeof payload !== "object" || !payload.data || typeof payload.data !== "object")
31
+ throw new TypeError("recovery payload must contain data");
32
+ return payload.data;
33
+ };
34
+
35
+ function validateData(data) {
36
+ const runId = requiredText(data?.run?.id, "run.id");
37
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId))
38
+ throw new TypeError("run.id is not a safe state-directory name");
39
+ const laneKey = requiredText(data?.lane?.key, "lane.key");
40
+ const runtime = requiredText(data?.launch?.runtime, "launch.runtime");
41
+ if (!RUNTIMES.has(runtime))
42
+ throw new TypeError("launch.runtime must be codex or claude");
43
+ const model = requiredText(data?.launch?.model, "launch.model");
44
+ const cwd = absolutePath(data?.launch?.cwd, "launch.cwd");
45
+ const addDirs = data?.launch?.addDirs ?? [];
46
+ if (!Array.isArray(addDirs)) throw new TypeError("launch.addDirs must be an array");
47
+ for (const [index, path] of addDirs.entries())
48
+ absolutePath(path, `launch.addDirs[${index}]`);
49
+ return { runId, laneKey, runtime, model, cwd, addDirs };
50
+ }
51
+
52
+ function validateLaunch(options) {
53
+ const runId = requiredText(options?.runId, "runId");
54
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId))
55
+ throw new TypeError("runId is not a safe state-directory name");
56
+ const laneKey = requiredText(options?.laneKey, "laneKey");
57
+ const runtime = requiredText(options?.runtime, "runtime");
58
+ if (!RUNTIMES.has(runtime)) throw new TypeError("runtime must be codex or claude");
59
+ const model = requiredText(options?.model, "model");
60
+ const cwd = absolutePath(options?.cwd, "cwd");
61
+ const devConfigPath = absolutePath(options?.devConfigPath, "devConfigPath");
62
+ const addDirs = options?.addDirs ?? [];
63
+ if (!Array.isArray(addDirs)) throw new TypeError("addDirs must be an array");
64
+ for (const [index, path] of addDirs.entries()) absolutePath(path, `addDirs[${index}]`);
65
+ const prompt = requiredText(options?.prompt, "prompt");
66
+ return { runId, laneKey, runtime, model, cwd, devConfigPath, addDirs, prompt, profileHome: options.profileHome };
67
+ }
68
+
69
+ function profileVariable(runtime, profileHome, home) {
70
+ if (profileHome === undefined || profileHome === null) return {};
71
+ absolutePath(profileHome, "launch.profileHome");
72
+ absolutePath(home, "home");
73
+ const child = relative(home, profileHome);
74
+ const expected = runtime === "codex" ? /^\.codex(?:[-_.][A-Za-z0-9._-]+)?$/ : /^\.claude(?:[-_.][A-Za-z0-9._-]+)?$/;
75
+ if (!child || child.startsWith("..") || isAbsolute(child) || child.includes("/") || !expected.test(basename(profileHome)))
76
+ throw new TypeError(`launch.profileHome must be a safe ${runtime} profile directory directly under home`);
77
+ return runtime === "codex"
78
+ ? { CODEX_HOME: profileHome }
79
+ : { CLAUDE_CONFIG_DIR: profileHome };
80
+ }
81
+
82
+ /** Build the complete recovery message passed to the fresh runtime over stdin. */
83
+ export function buildRecoveryPrompt(data) {
84
+ const { runId, laneKey } = validateData(data);
85
+ return [
86
+ `You are lane ${laneKey}, relaunched as fresh run ${runId} after an orphaned process.`,
87
+ "Continue from the recorded next step. Do not redo finished lane steps.",
88
+ "",
89
+ `Next step:\n${printable(data.lane.next)}`,
90
+ "",
91
+ `Last pushed commit:\n${printable(data.recovery?.lastPushedCommit)}`,
92
+ "",
93
+ `Freeze:\n${printable(data.recovery?.freeze)}`,
94
+ "",
95
+ `Dead run log tail:\n${printable(data.recovery?.logTail)}`,
96
+ "",
97
+ ].join("\n");
98
+ }
99
+
100
+ /** Build an allowlisted, fresh headless runtime invocation. No prompt is put in argv. */
101
+ export function buildRecoveryInvocation(data, { env = process.env, home = homedir() } = {}) {
102
+ const { runId, laneKey, runtime, model, cwd, addDirs } = validateData(data);
103
+ const devConfigPath = absolutePath(data?.launch?.devConfigPath, "launch.devConfigPath");
104
+ return buildInvocation({ runId, laneKey, runtime, model, cwd, addDirs, profileHome: data.launch.profileHome, devConfigPath }, { env, home });
105
+ }
106
+
107
+ function buildInvocation(options, { env = process.env, home = homedir() } = {}) {
108
+ const { runId, laneKey, runtime, model, cwd, addDirs, profileHome, devConfigPath } = options;
109
+ const args = runtime === "codex"
110
+ ? ["exec", "--json", "--cd", cwd, "--model", model, ...addDirs.flatMap((path) => ["--add-dir", path]), "-"]
111
+ : ["--print", "--output-format", "stream-json", "--model", model, ...addDirs.flatMap((path) => ["--add-dir", path])];
112
+ return {
113
+ command: runtime,
114
+ args,
115
+ cwd,
116
+ env: {
117
+ ...env,
118
+ NODII_DEV_RUN_ID: runId,
119
+ NODII_DEV_LANE_KEY: laneKey,
120
+ NODII_DEV_CONFIG_PATH: devConfigPath,
121
+ ...profileVariable(runtime, profileHome, home),
122
+ },
123
+ };
124
+ }
125
+
126
+ const closeQuietly = async (handle) => {
127
+ try {
128
+ await handle?.close?.();
129
+ } catch {}
130
+ };
131
+
132
+ export function recoveryStateRoot(path = configPath()) {
133
+ const configDir = dirname(path);
134
+ const nodiiDir = basename(configDir) === "agents" ? dirname(configDir) : configDir;
135
+ return join(nodiiDir, "runs");
136
+ }
137
+
138
+ async function launchDetached({ invocation, prompt, runId, laneKey, devConfigPath }, {
139
+ spawnImpl,
140
+ fs,
141
+ stateRoot,
142
+ watch,
143
+ terminate,
144
+ }) {
145
+ absolutePath(stateRoot, "stateRoot");
146
+ const runDir = join(stateRoot, runId);
147
+ const logPath = join(runDir, "process.log");
148
+ const pidPath = join(runDir, "process.pid");
149
+
150
+ await fs.mkdir(runDir, { recursive: true, mode: 0o700 });
151
+ const log = await fs.open(logPath, "a", 0o600);
152
+ try {
153
+ await fs.chmod(logPath, 0o600);
154
+ } catch (error) {
155
+ await closeQuietly(log);
156
+ throw error;
157
+ }
158
+
159
+ let child;
160
+ try {
161
+ child = spawnImpl(invocation.command, invocation.args, {
162
+ cwd: invocation.cwd,
163
+ env: invocation.env,
164
+ shell: false,
165
+ detached: true,
166
+ stdio: ["pipe", log.fd, log.fd],
167
+ });
168
+ } catch (error) {
169
+ await closeQuietly(log);
170
+ throw error;
171
+ }
172
+
173
+ return new Promise((resolveLaunch, rejectLaunch) => {
174
+ let settled = false;
175
+ child.once("error", (error) => {
176
+ if (settled) return;
177
+ settled = true;
178
+ closeQuietly(log).finally(() => rejectLaunch(error));
179
+ });
180
+ child.once("spawn", () => {
181
+ if (settled) return;
182
+ settled = true;
183
+ (async () => {
184
+ if (!Number.isInteger(child.pid) || child.pid <= 0)
185
+ throw new Error("runtime process spawned without a PID");
186
+ await fs.writeFile(pidPath, `${child.pid}\n`, { mode: 0o600, flag: "w" });
187
+ try {
188
+ await watch({ runId, pid: child.pid, configPath: devConfigPath, worktree: invocation.cwd });
189
+ } catch (error) {
190
+ // This exact child has already been recorded in its private PID file.
191
+ // Do not leave an untracked runtime alive when registry persistence fails.
192
+ await terminate(child);
193
+ throw error;
194
+ }
195
+ if (!child.stdin || typeof child.stdin.end !== "function")
196
+ throw new Error("runtime process has no stdin pipe");
197
+ child.stdin.end(prompt);
198
+ await closeQuietly(log);
199
+ child.unref();
200
+ resolveLaunch({
201
+ runId,
202
+ laneKey,
203
+ pid: child.pid,
204
+ logPath,
205
+ pidPath,
206
+ command: invocation.command,
207
+ args: invocation.args,
208
+ cwd: invocation.cwd,
209
+ });
210
+ })().catch(async (error) => {
211
+ await closeQuietly(log);
212
+ rejectLaunch(error);
213
+ });
214
+ });
215
+ });
216
+ }
217
+
218
+ const terminateChild = async (child) => {
219
+ if (typeof child.kill === "function") child.kill("SIGTERM");
220
+ };
221
+
222
+ /** Launch a newly registered lane with its lane-scoped Dev identity. */
223
+ export async function launchRuntime(options, {
224
+ spawnImpl = spawn,
225
+ fs = fsPromises,
226
+ stateRoot,
227
+ home = homedir(),
228
+ env = process.env,
229
+ watch = recordWatchedRun,
230
+ terminate = terminateChild,
231
+ } = {}) {
232
+ const launch = validateLaunch(options);
233
+ const invocation = buildInvocation(launch, { env, home });
234
+ return launchDetached({ invocation, prompt: launch.prompt, ...launch }, {
235
+ spawnImpl,
236
+ fs,
237
+ stateRoot: stateRoot ?? recoveryStateRoot(launch.devConfigPath),
238
+ watch,
239
+ terminate,
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Launch a fresh recovery process after Dev has reserved its run.
245
+ *
246
+ * Files live outside the repository under stateRoot/<run-id>. The log is
247
+ * opened append-only and both files are private to the current user.
248
+ */
249
+ export async function launchRecovery(payload, {
250
+ spawnImpl = spawn,
251
+ fs = fsPromises,
252
+ stateRoot,
253
+ home = homedir(),
254
+ env = process.env,
255
+ watch = recordWatchedRun,
256
+ terminate = terminateChild,
257
+ } = {}) {
258
+ const data = dataFrom(payload);
259
+ const { runId, laneKey } = validateData(data);
260
+ const devConfigPath = absolutePath(data?.launch?.devConfigPath, "launch.devConfigPath");
261
+ const prompt = buildRecoveryPrompt(data);
262
+ const invocation = buildRecoveryInvocation(data, { env, home });
263
+ return launchDetached({ invocation, prompt, runId, laneKey, devConfigPath }, {
264
+ spawnImpl,
265
+ fs,
266
+ stateRoot: stateRoot ?? recoveryStateRoot(devConfigPath),
267
+ watch,
268
+ terminate,
269
+ });
270
+ }
package/dist/safety.js CHANGED
@@ -280,6 +280,18 @@ async function harnessGenerated(root) {
280
280
  }
281
281
 
282
282
  function laneOwnership(lane, root) {
283
+ const repository = lane?.repositories?.find((item) => {
284
+ const worktree = resolve(item.worktree);
285
+ return worktree === root;
286
+ });
287
+ if (repository) {
288
+ const paths = repository.ownedPaths.map((item) => {
289
+ if (!isAbsolute(item)) return cleanPath(item);
290
+ const rel = cleanPath(relative(root, item));
291
+ return rel.startsWith("../") ? item : rel;
292
+ });
293
+ return { known: true, paths };
294
+ }
283
295
  const direct = lane && Object.hasOwn(lane, "ownedPaths");
284
296
  const frozen = lane?.freeze?.boundaries && Object.hasOwn(lane.freeze.boundaries, "ownedPaths");
285
297
  const candidates = direct ? lane.ownedPaths : frozen ? lane.freeze.boundaries.ownedPaths : [];
@@ -298,6 +310,13 @@ async function currentLane(cwd, request, config) {
298
310
  const branch = await git(cwd, "branch", "--show-current");
299
311
  const rootText = await git(cwd, "rev-parse", "--show-toplevel");
300
312
  const root = await realpath(rootText).catch(() => resolve(rootText));
313
+ const markerText = await optional(await gitPrivatePath(cwd, "nodii-lane.json"));
314
+ if (markerText) {
315
+ const marker = JSON.parse(markerText);
316
+ const lane = (await request("GET", `/api/v1/lanes/${encode(marker.laneKey)}`)).data;
317
+ const repository = lane.repositories?.find((item) => item.branch === branch && resolve(item.worktree) === root);
318
+ if (lane.agentId === config.agentId && repository && ACTIVE.has(lane.status)) return lane;
319
+ }
301
320
  const lanes = (await request("GET", "/api/v1/lanes?limit=200")).data ?? [];
302
321
  for (const lane of lanes) {
303
322
  if (lane.agentId !== config.agentId || lane.branch !== branch || !ACTIVE.has(lane.status)) continue;
package/dist/setup.js CHANGED
@@ -17,8 +17,8 @@ description: Drive every Nodii build lane through nodii-dev from start to verifi
17
17
 
18
18
  Use \`nodii-dev\` as the control plane. It talks only to Dev, through the platform edge, signed in with \`nodii-dev login\` as the platform tenant's OAuth app. The canonical policy is the build-workflow rulebook in Dev (\`nodii-dev rulebook list\`), §§1–8 (operator rulings A-020/A-021). Never change lane state in Dev by another route.
19
19
 
20
- 1. Start: \`nodii-dev lane start KEY --title "..." --kind dev_fix|new_build|docs_change|uat_fix|prod_fix --side backend|frontend|both [--issue ISSUE]\`.
21
- 2. Ask \`nodii-dev next KEY\`; start, complete, or block the reported API step with \`nodii-dev lane step\`.
20
+ 1. The machine coordinator runs \`nodii-dev coordinator start --machine NAME\`, then \`nodii-dev lane launch KEY\` with the issue (whose title is the objective), done check, build kind/side, repeated repository/branch pairs, runtime profile/model and subagent quota. A launched agent begins with \`nodii-dev lane gather\` from its worktree.
21
+ 2. Re-run \`nodii-dev lane gather\` for the current steps and rules; start, complete, or block the reported API step with \`nodii-dev lane step\`. After an authoritative \`lane amend\`, every run is blocked until its own \`lane gather --delta\` acknowledgement.
22
22
  3. Record durable evidence with \`nodii-dev lane evidence KEY --pr|--commit|--figma|--deploy|--dev-check REF\` and heartbeat long-running work with \`nodii-dev lane heartbeat KEY --note "..."\` (notes are at most 500 characters).
23
23
  4. Record the review gate with \`nodii-dev lane review KEY\`. After merge, run \`nodii-dev lane land KEY\`. Done still means deployed and checked live on dev as the right user: \`nodii-dev lane verify KEY --dev-check REF\`.
24
24
  5. Use \`nodii-dev lane prioritize|deprioritize KEY --reason "..."\` when priority changes; the reason is required. The lane stays on Built until a staff login runs \`nodii-dev built ack KEY\`. Never treat acknowledgement as testing.