11import { 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" ;
316import { join } from "node:path" ;
417import type { Command } from "commander" ;
518import { 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+
285478export 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}
0 commit comments