@exaudeus/workrail 3.52.0 → 3.54.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.
@@ -21,6 +21,7 @@ export interface WorktrainOverviewCommandDeps {
21
21
  readonly joinPath: (...parts: string[]) => string;
22
22
  readonly print: (line: string) => void;
23
23
  readonly getDataDirEnv: () => string | undefined;
24
+ readonly readEventLog: (filePath: string) => Promise<string>;
24
25
  }
25
26
  export interface WorktrainOverviewCommandOpts {
26
27
  readonly json?: boolean;
@@ -45,6 +45,65 @@ function buildSessionTitle(s) {
45
45
  function extractStepLabel(_s) {
46
46
  return null;
47
47
  }
48
+ function parseDaemonStatusLine(nowMs, eventLogContent) {
49
+ let latestHeartbeat = null;
50
+ let latestStopped = null;
51
+ for (const raw of eventLogContent.split('\n')) {
52
+ if (!raw.trim())
53
+ continue;
54
+ let obj;
55
+ try {
56
+ obj = JSON.parse(raw);
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ const kind = typeof obj['kind'] === 'string' ? obj['kind'] : '';
62
+ const ts = typeof obj['ts'] === 'number' ? obj['ts'] : null;
63
+ if (ts === null)
64
+ continue;
65
+ if (kind === 'daemon_heartbeat') {
66
+ if (latestHeartbeat === null || ts > latestHeartbeat.ts) {
67
+ const activeSessions = typeof obj['activeSessions'] === 'number' ? obj['activeSessions'] : 0;
68
+ latestHeartbeat = { ts, activeSessions };
69
+ }
70
+ }
71
+ else if (kind === 'daemon_stopped') {
72
+ if (latestStopped === null || ts > latestStopped.ts) {
73
+ const reason = typeof obj['reason'] === 'string' ? obj['reason'] : 'unknown';
74
+ latestStopped = { ts, reason };
75
+ }
76
+ }
77
+ }
78
+ if (latestHeartbeat === null && latestStopped === null) {
79
+ return 'Daemon: no events today';
80
+ }
81
+ const heartbeatTs = latestHeartbeat?.ts ?? -Infinity;
82
+ const stoppedTs = latestStopped?.ts ?? -Infinity;
83
+ if (stoppedTs >= heartbeatTs && latestStopped !== null) {
84
+ const stoppedTime = new Date(latestStopped.ts).toLocaleTimeString('en-US', {
85
+ hour: '2-digit',
86
+ minute: '2-digit',
87
+ second: '2-digit',
88
+ hour12: false,
89
+ });
90
+ if (latestStopped.reason === 'graceful') {
91
+ return `Daemon: stopped gracefully (${stoppedTime})`;
92
+ }
93
+ return `Daemon: crashed (${stoppedTime})`;
94
+ }
95
+ if (latestHeartbeat !== null) {
96
+ const agoMs = nowMs - latestHeartbeat.ts;
97
+ if (agoMs < 90000) {
98
+ const agoSec = Math.round(agoMs / 1000);
99
+ const sessions = latestHeartbeat.activeSessions;
100
+ const sessionStr = sessions === 1 ? '1 active session' : `${sessions} active sessions`;
101
+ return `Daemon: running (last heartbeat ${agoSec}s ago, ${sessionStr})`;
102
+ }
103
+ return `Daemon: may have crashed (last heartbeat ${formatRelativeTime(agoMs)})`;
104
+ }
105
+ return 'Daemon: no events today';
106
+ }
48
107
  function isCompleted(s) {
49
108
  return s.status === 'complete' || s.status === 'complete_with_gaps';
50
109
  }
@@ -100,6 +159,10 @@ async function executeWorktrainOverviewCommand(deps, opts = {}) {
100
159
  deps.print(JSON.stringify(packet, null, 2));
101
160
  return;
102
161
  }
162
+ const todayDate = new Date(nowMs).toISOString().slice(0, 10);
163
+ const eventLogPath = deps.joinPath(deps.homedir(), '.workrail', 'events', 'daemon', `${todayDate}.jsonl`);
164
+ const eventLogContent = await deps.readEventLog(eventLogPath);
165
+ const daemonStatusLine = parseDaemonStatusLine(nowMs, eventLogContent);
103
166
  const date = new Date(nowMs);
104
167
  const dateStr = date.toLocaleDateString('en-US', {
105
168
  weekday: 'short',
@@ -113,6 +176,7 @@ async function executeWorktrainOverviewCommand(deps, opts = {}) {
113
176
  hour12: false,
114
177
  });
115
178
  deps.print(`WorkTrain [${dateStr} ${timeStr}]`);
179
+ deps.print(daemonStatusLine);
116
180
  deps.print('Note: live session detection requires daemon (showing last-known state).');
117
181
  deps.print('');
118
182
  if (activeSessions.length === 0 && recentSessions.length === 0) {
@@ -47,6 +47,7 @@ const child_process_1 = require("child_process");
47
47
  const util_1 = require("util");
48
48
  const crypto_1 = require("crypto");
49
49
  const interpret_result_js_1 = require("./cli/interpret-result.js");
50
+ const daemon_env_js_1 = require("./daemon/daemon-env.js");
50
51
  const index_js_1 = require("./context-assembly/index.js");
51
52
  const infra_js_1 = require("./context-assembly/infra.js");
52
53
  const index_js_2 = require("./cli/commands/index.js");
@@ -243,6 +244,7 @@ program
243
244
  .option('--uninstall', 'Stop the daemon service and remove the launchd plist')
244
245
  .option('--status', 'Show the current status of the daemon service')
245
246
  .action(async (options) => {
247
+ await (0, daemon_env_js_1.loadDaemonEnv)();
246
248
  const { execFile: execFileRaw } = await Promise.resolve().then(() => __importStar(require('child_process')));
247
249
  const execFilePromise = (0, util_1.promisify)(execFileRaw);
248
250
  const result = await (0, index_js_2.executeWorktrainDaemonCommand)({
@@ -283,6 +285,7 @@ program
283
285
  print: (line) => console.log(line),
284
286
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
285
287
  startDaemon: async () => {
288
+ await (0, daemon_env_js_1.loadDaemonEnv)();
286
289
  const { startTriggerListener } = await Promise.resolve().then(() => __importStar(require('./trigger/trigger-listener.js')));
287
290
  const { startDaemonConsole } = await Promise.resolve().then(() => __importStar(require('./trigger/daemon-console.js')));
288
291
  const { DaemonEventEmitter } = await Promise.resolve().then(() => __importStar(require('./daemon/daemon-events.js')));
@@ -346,8 +349,24 @@ program
346
349
  console.warn(`[DaemonConsole] Could not start console: ${consoleResult.error.message}`);
347
350
  }
348
351
  await new Promise((resolve) => {
352
+ const heartbeatInterval = setInterval(() => {
353
+ const sessionsDir = path_1.default.join(os_1.default.homedir(), '.workrail', 'daemon-sessions');
354
+ fs_1.default.promises.readdir(sessionsDir)
355
+ .then((files) => files.filter((f) => f.endsWith('.json')).length)
356
+ .catch(() => 0)
357
+ .then((activeSessions) => {
358
+ emitter.emit({ kind: 'daemon_heartbeat', activeSessions, ts: Date.now() });
359
+ });
360
+ }, 30000);
361
+ process.on('uncaughtException', (err) => {
362
+ console.error('[WorkTrain] Uncaught exception -- daemon shutting down:', err);
363
+ emitter.emit({ kind: 'daemon_stopped', reason: 'crash', ts: Date.now() });
364
+ process.exit(1);
365
+ });
349
366
  const shutdown = async () => {
350
367
  console.log('\nShutting down daemon...');
368
+ clearInterval(heartbeatInterval);
369
+ emitter.emit({ kind: 'daemon_stopped', reason: 'graceful', ts: Date.now() });
351
370
  if (consoleHandle) {
352
371
  await consoleHandle.stop();
353
372
  }
@@ -581,6 +600,7 @@ program
581
600
  joinPath: path_1.default.join,
582
601
  print: (line) => process.stdout.write(line + '\n'),
583
602
  getDataDirEnv: () => process.env['WORKRAIL_DATA_DIR'],
603
+ readEventLog: (p) => fs_1.default.promises.readFile(p, 'utf-8').catch(() => ''),
584
604
  }, {
585
605
  json: options.json,
586
606
  workspace: options.workspace,