Skip to content

Commit 045c99c

Browse files
saltboCopilot
andcommitted
feat(cli): add restart command and inode-aware log follow
- Add `ak restart` — stops daemon (SIGTERM/SIGKILL) then re-spawns using saved state options; CLI flags override saved values - Replace `tail -f` in `ak logs -f` with a Node.js inode-aware poller that detects log rotation and prints a divider line (──── daemon restarted ────) before streaming the new file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 04f46ca commit 045c99c

2 files changed

Lines changed: 204 additions & 6 deletions

File tree

packages/cli/src/commands/start.ts

Lines changed: 202 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
import { spawn } from "node:child_process";
2-
import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2+
import {
3+
closeSync,
4+
existsSync,
5+
mkdirSync,
6+
openSync,
7+
readdirSync,
8+
readFileSync,
9+
readSync,
10+
renameSync,
11+
rmSync,
12+
statSync,
13+
unlinkSync,
14+
writeFileSync,
15+
} from "node:fs";
316
import { join } from "node:path";
417
import type { Command } from "commander";
518
import { getCredentials, saveCredentials, setCurrent } from "../config.js";
@@ -282,6 +295,186 @@ export function registerStatusCommand(program: Command) {
282295
});
283296
}
284297

298+
export function registerRestartCommand(program: Command) {
299+
program
300+
.command("restart")
301+
.description("Restart the Machine daemon (stop + start with saved or new options)")
302+
.option("--api-url <url>", "API server URL")
303+
.option("--api-key <key>", "Machine API key")
304+
.option("--max-concurrent <n>", "Max concurrent agents")
305+
.option("--poll-interval <ms>", "Poll interval in ms")
306+
.option("--task-timeout <ms>", "Task timeout in ms (0 to disable)")
307+
.action(async (opts) => {
308+
// Stop existing daemon if running
309+
const pid = readDaemonPid();
310+
if (pid) {
311+
process.kill(pid, "SIGTERM");
312+
313+
const deadline = Date.now() + 10_000;
314+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
315+
while (Date.now() < deadline) {
316+
try {
317+
process.kill(pid, 0);
318+
} catch {
319+
break;
320+
}
321+
await sleep(200);
322+
}
323+
324+
let alive = false;
325+
try {
326+
process.kill(pid, 0);
327+
alive = true;
328+
} catch {
329+
// dead — good
330+
}
331+
332+
if (alive) {
333+
process.kill(pid, "SIGKILL");
334+
console.log(`● Daemon force-killed (PID ${pid})`);
335+
} else {
336+
console.log(`● Daemon stopped (PID ${pid})`);
337+
}
338+
} else {
339+
console.log("○ Daemon was not running");
340+
}
341+
342+
// Resolve credentials — opts override saved state override config
343+
const prevState = readDaemonState();
344+
345+
if (opts.apiUrl && opts.apiKey) {
346+
saveCredentials(opts.apiUrl, opts.apiKey);
347+
} else if (opts.apiUrl) {
348+
try {
349+
setCurrent(opts.apiUrl);
350+
} catch {
351+
console.error(`No saved credentials for ${opts.apiUrl}. Pass --api-key as well.`);
352+
process.exit(1);
353+
}
354+
}
355+
356+
let creds: { apiUrl: string; apiKey: string };
357+
try {
358+
creds = getCredentials();
359+
} catch {
360+
console.error("API URL and key required. Pass --api-url and --api-key, or run `ak start` first.");
361+
process.exit(1);
362+
}
363+
const apiUrl = creds.apiUrl;
364+
365+
// Clear session cache if API URL changed
366+
if (prevState && prevState.apiUrl !== apiUrl) {
367+
rmSync(SESSIONS_DIR, { recursive: true, force: true });
368+
}
369+
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
370+
371+
// Resolve options: CLI opts → saved state → defaults
372+
const maxConcurrent = opts.maxConcurrent ?? String(prevState?.maxConcurrent ?? 3);
373+
const pollInterval = opts.pollInterval ?? String(prevState?.pollInterval ?? 10000);
374+
const taskTimeout = opts.taskTimeout ?? String(prevState?.taskTimeout ?? 7200000);
375+
376+
rotateLogs();
377+
378+
const logFile = join(LOGS_DIR, "daemon.log");
379+
const logFd = openSync(logFile, "a");
380+
381+
const available = getAvailableProviders();
382+
383+
const child = spawn(
384+
process.execPath,
385+
[
386+
process.argv[1],
387+
"__daemon",
388+
"--max-concurrent",
389+
String(maxConcurrent),
390+
"--poll-interval",
391+
String(pollInterval),
392+
"--task-timeout",
393+
String(taskTimeout),
394+
],
395+
{ detached: true, stdio: ["ignore", logFd, logFd] },
396+
);
397+
398+
mkdirSync(STATE_DIR, { recursive: true });
399+
writeFileSync(PID_FILE, String(child.pid));
400+
401+
const state: DaemonState = {
402+
providers: available.map((p) => p.name),
403+
maxConcurrent: parseInt(String(maxConcurrent), 10),
404+
pollInterval: parseInt(String(pollInterval), 10),
405+
taskTimeout: parseInt(String(taskTimeout), 10),
406+
apiUrl,
407+
startedAt: new Date().toISOString(),
408+
};
409+
writeFileSync(DAEMON_STATE_FILE, JSON.stringify(state, null, 2));
410+
411+
child.unref();
412+
413+
const timeoutLabel = state.taskTimeout === 0 ? "none" : `${state.taskTimeout / 1000}s`;
414+
const providersLabel = formatProviders(state.providers);
415+
console.log(`● Daemon started (PID ${child.pid}, v${getVersion()})`);
416+
console.log(` Providers: ${providersLabel}`);
417+
console.log(` Concurrency: ${state.maxConcurrent}`);
418+
console.log(` Poll: ${state.pollInterval / 1000}s`);
419+
console.log(` Timeout: ${timeoutLabel}`);
420+
console.log(` API: ${maskApiUrl(state.apiUrl)}`);
421+
console.log(` Logs: ak logs -f`);
422+
process.exit(0);
423+
});
424+
}
425+
426+
const LOG_DIVIDER = "\n──────────────────────── daemon restarted ────────────────────────\n\n";
427+
const FOLLOW_POLL_MS = 500;
428+
429+
function followLogFile(logFile: string): void {
430+
let currentInode: number | null = null;
431+
let currentOffset = 0;
432+
433+
// Initialise inode/offset from current file end
434+
try {
435+
const stat = statSync(logFile);
436+
currentInode = stat.ino;
437+
currentOffset = stat.size;
438+
} catch {
439+
// File may not exist yet; will pick it up on first poll
440+
}
441+
442+
const poll = (): void => {
443+
try {
444+
const stat = statSync(logFile);
445+
446+
if (currentInode !== null && stat.ino !== currentInode) {
447+
// File was rotated — new daemon.log created
448+
process.stdout.write(LOG_DIVIDER);
449+
currentOffset = 0;
450+
}
451+
452+
currentInode = stat.ino;
453+
454+
if (stat.size > currentOffset) {
455+
const fd = openSync(logFile, "r");
456+
const buf = Buffer.alloc(stat.size - currentOffset);
457+
readSync(fd, buf, 0, buf.length, currentOffset);
458+
closeSync(fd);
459+
process.stdout.write(buf);
460+
currentOffset = stat.size;
461+
}
462+
} catch {
463+
// File temporarily absent during rotation — retry next tick
464+
}
465+
};
466+
467+
const timer = setInterval(poll, FOLLOW_POLL_MS);
468+
process.on("SIGINT", () => {
469+
clearInterval(timer);
470+
process.exit(0);
471+
});
472+
process.on("SIGTERM", () => {
473+
clearInterval(timer);
474+
process.exit(0);
475+
});
476+
}
477+
285478
export function registerLogsCommand(program: Command) {
286479
program
287480
.command("logs")
@@ -295,9 +488,13 @@ export function registerLogsCommand(program: Command) {
295488
return;
296489
}
297490

298-
const args = opts.follow ? ["-n", String(opts.lines), "-f", logFile] : ["-n", String(opts.lines), logFile];
299-
300-
const tail = spawn("tail", args, { stdio: "inherit" });
301-
tail.on("exit", (code) => process.exit(code ?? 0));
491+
if (opts.follow) {
492+
// Print last N lines via tail, then hand off to our inode-aware follower
493+
const init = spawn("tail", ["-n", String(opts.lines), logFile], { stdio: "inherit" });
494+
init.on("exit", () => followLogFile(logFile));
495+
} else {
496+
const tail = spawn("tail", ["-n", String(opts.lines), logFile], { stdio: "inherit" });
497+
tail.on("exit", (code) => process.exit(code ?? 0));
498+
}
302499
});
303500
}

packages/cli/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { registerCreateCommand } from "./commands/create.js";
88
import { registerDeleteCommand } from "./commands/delete.js";
99
import { registerDescribeCommand } from "./commands/describe.js";
1010
import { registerGetCommand } from "./commands/get.js";
11-
import { registerLogsCommand, registerStartCommand, registerStatusCommand, registerStopCommand } from "./commands/start.js";
11+
import { registerLogsCommand, registerRestartCommand, registerStartCommand, registerStatusCommand, registerStopCommand } from "./commands/start.js";
1212
import { registerUpdateCommand } from "./commands/update.js";
1313
import { registerUpgradeCommand } from "./commands/upgrade.js";
1414
import { registerWaitCommand } from "./commands/wait.js";
@@ -260,6 +260,7 @@ program
260260

261261
registerStartCommand(program);
262262
registerStopCommand(program);
263+
registerRestartCommand(program);
263264
registerStatusCommand(program);
264265
registerLogsCommand(program);
265266
registerUpgradeCommand(program);

0 commit comments

Comments
 (0)