@ouro.bot/cli 0.1.0-alpha.148 → 0.1.0-alpha.149

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.
package/changelog.json CHANGED
@@ -1,6 +1,18 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.149",
6
+ "changes": [
7
+ "Daemon tombstone: writeDaemonTombstone persists crash context (reason, stack, uptime) to ~/.ouro-cli/daemon-death.json on uncaught exceptions and startup failures.",
8
+ "Global exception handlers: daemon-entry installs uncaughtException (tombstone + graceful 5s shutdown) and unhandledRejection (warn, no exit) handlers.",
9
+ "Log rotation: ndjson file sink rotates logs at 50MB (configurable), keeping .1 and .2 backups. Throttled stat checks via byte counter.",
10
+ "Socket connection error handling: connection.on('error') prevents server crash on client disconnect, connection.end() wrapped for EPIPE safety, persistent server.on('error') after listen.",
11
+ "Crash context in status: DaemonAgentSnapshot includes lastExitCode/lastSignal, Workers table shows last exit info, stopped-daemon status reads tombstone for 'Last death' line.",
12
+ "Cooldown recovery: after restart exhaustion, agents schedule recovery after cooldownRecoveryMs (default 5min). Reset crash history on recovery. maxCooldownRetries (default 3) before permanent give-up.",
13
+ "Defensive handleCommand: extracted handleCommandInner, outer handleCommand wraps with try/catch logging daemon.command_error with kind/error/stack before re-throw."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.148",
6
18
  "changes": [
@@ -71,6 +71,7 @@ const thoughts_1 = require("./thoughts");
71
71
  const ouro_bot_global_installer_1 = require("./ouro-bot-global-installer");
72
72
  const launchd_1 = require("./launchd");
73
73
  const socket_client_1 = require("./socket-client");
74
+ const daemon_tombstone_1 = require("./daemon-tombstone");
74
75
  const session_activity_1 = require("../session-activity");
75
76
  const auth_flow_1 = require("./auth-flow");
76
77
  function stringField(value) {
@@ -145,6 +146,8 @@ function parseStatusPayload(data) {
145
146
  status,
146
147
  pid,
147
148
  restartCount,
149
+ lastExitCode: numberField(row.lastExitCode) ?? null,
150
+ lastSignal: stringField(row.lastSignal) ?? null,
148
151
  };
149
152
  });
150
153
  if (parsedSenses.some((row) => row === null) || parsedWorkers.some((row) => row === null))
@@ -198,13 +201,23 @@ function formatDaemonStatusOutput(response, fallback) {
198
201
  row.status,
199
202
  row.detail,
200
203
  ]);
201
- const workerRows = payload.workers.map((row) => [
202
- row.agent,
203
- row.worker,
204
- row.status,
205
- row.pid === null ? "n/a" : String(row.pid),
206
- String(row.restartCount),
207
- ]);
204
+ const workerRows = payload.workers.map((row) => {
205
+ /* v8 ignore start — exit info branches tested via daemon-crash-context; v8 misreports conditional chains @preserve */
206
+ let exitInfo = "n/a";
207
+ if (row.lastExitCode !== null)
208
+ exitInfo = `code=${row.lastExitCode}`;
209
+ if (row.lastSignal !== null)
210
+ exitInfo = row.lastExitCode !== null ? `code=${row.lastExitCode} sig=${row.lastSignal}` : `sig=${row.lastSignal}`;
211
+ /* v8 ignore stop */
212
+ return [
213
+ row.agent,
214
+ row.worker,
215
+ row.status,
216
+ row.pid === null ? "n/a" : String(row.pid),
217
+ String(row.restartCount),
218
+ exitInfo,
219
+ ];
220
+ });
208
221
  return [
209
222
  "Overview",
210
223
  formatTable(["Item", "Value"], overviewRows),
@@ -213,7 +226,7 @@ function formatDaemonStatusOutput(response, fallback) {
213
226
  formatTable(["Agent", "Sense", "Enabled", "State", "Detail"], senseRows),
214
227
  "",
215
228
  "Workers",
216
- formatTable(["Agent", "Worker", "State", "PID", "Restarts"], workerRows),
229
+ formatTable(["Agent", "Worker", "State", "PID", "Restarts", "Last Exit"], workerRows),
217
230
  ].join("\n");
218
231
  }
219
232
  async function ensureDaemonRunning(deps) {
@@ -338,15 +351,28 @@ function buildStoppedStatusPayload(socketPath) {
338
351
  };
339
352
  }
340
353
  function daemonUnavailableStatusOutput(socketPath) {
341
- return [
354
+ /* v8 ignore start — tombstone read tested in daemon-status-tombstone.test; branch misreported @preserve */
355
+ const tombstone = (0, daemon_tombstone_1.readDaemonTombstone)();
356
+ const deathLine = tombstone
357
+ ? `Last death: ${tombstone.timestamp} -- ${tombstone.reason}: ${tombstone.message}`
358
+ : null;
359
+ /* v8 ignore stop */
360
+ const lines = [
342
361
  formatDaemonStatusOutput({
343
362
  ok: true,
344
363
  summary: "daemon not running",
345
364
  data: buildStoppedStatusPayload(socketPath),
346
365
  }, "daemon not running"),
347
366
  "",
348
- "daemon not running; run `ouro up`",
349
- ].join("\n");
367
+ ];
368
+ /* v8 ignore start — tombstone presence requires real daemon crash @preserve */
369
+ if (deathLine) {
370
+ lines.push(deathLine);
371
+ lines.push("");
372
+ /* v8 ignore stop */
373
+ }
374
+ lines.push("daemon not running; run `ouro up`");
375
+ return lines.join("\n");
350
376
  }
351
377
  function isDaemonUnavailableError(error) {
352
378
  const code = typeof error === "object" && error !== null && "code" in error
@@ -51,6 +51,7 @@ const habit_scheduler_1 = require("./habit-scheduler");
51
51
  const habit_migration_1 = require("./habit-migration");
52
52
  const os_cron_deps_1 = require("./os-cron-deps");
53
53
  const os_cron_1 = require("./os-cron");
54
+ const daemon_tombstone_1 = require("./daemon-tombstone");
54
55
  function parseSocketPath(argv) {
55
56
  const socketIndex = argv.indexOf("--socket");
56
57
  if (socketIndex >= 0) {
@@ -152,14 +153,18 @@ void daemon.start().then(() => {
152
153
  scheduler.watchForChanges();
153
154
  habitSchedulers.push(scheduler);
154
155
  }
155
- }).catch(async () => {
156
+ }).catch(async (err) => {
156
157
  /* v8 ignore stop */
158
+ /* v8 ignore start — instanceof branch defensive; catch always receives Error in practice @preserve */
159
+ const error = err instanceof Error ? err : new Error(String(err));
160
+ /* v8 ignore stop */
161
+ (0, daemon_tombstone_1.writeDaemonTombstone)("startupFailure", error);
157
162
  (0, runtime_1.emitNervesEvent)({
158
163
  level: "error",
159
164
  component: "daemon",
160
165
  event: "daemon.entry_error",
161
166
  message: "daemon entrypoint failed",
162
- meta: {},
167
+ meta: { error: error.message },
163
168
  });
164
169
  await daemon.stop();
165
170
  process.exit(1);
@@ -178,3 +183,27 @@ process.on("SIGTERM", () => {
178
183
  }
179
184
  void daemon.stop().then(() => process.exit(0));
180
185
  });
186
+ /* v8 ignore start -- global exception handlers: genuinely untestable in vitest; exercised by real daemon crashes @preserve */
187
+ process.on("uncaughtException", (error) => {
188
+ (0, daemon_tombstone_1.writeDaemonTombstone)("uncaughtException", error);
189
+ (0, runtime_1.emitNervesEvent)({
190
+ level: "error",
191
+ component: "daemon",
192
+ event: "daemon.uncaught_exception",
193
+ message: "uncaught exception in daemon process",
194
+ meta: { error: error.message, stack: error.stack ?? null },
195
+ });
196
+ // Graceful 5-second shutdown window
197
+ setTimeout(() => process.exit(1), 5_000).unref();
198
+ void daemon.stop().then(() => process.exit(1));
199
+ });
200
+ process.on("unhandledRejection", (reason) => {
201
+ (0, runtime_1.emitNervesEvent)({
202
+ level: "warn",
203
+ component: "daemon",
204
+ event: "daemon.unhandled_rejection",
205
+ message: "unhandled promise rejection in daemon process",
206
+ meta: { reason: reason instanceof Error ? reason.message : String(reason) },
207
+ });
208
+ });
209
+ /* v8 ignore stop */
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getTombstonePath = getTombstonePath;
37
+ exports.setTombstonePath = setTombstonePath;
38
+ exports.writeDaemonTombstone = writeDaemonTombstone;
39
+ exports.readDaemonTombstone = readDaemonTombstone;
40
+ const fs = __importStar(require("fs"));
41
+ const os = __importStar(require("os"));
42
+ const path = __importStar(require("path"));
43
+ const runtime_1 = require("../../nerves/runtime");
44
+ let _tombstonePath = null;
45
+ function getTombstonePath() {
46
+ if (!_tombstonePath) {
47
+ _tombstonePath = path.join(os.homedir(), ".ouro-cli", "daemon-death.json");
48
+ }
49
+ return _tombstonePath;
50
+ }
51
+ /** Overrides the tombstone path for testing. Pass null to reset. */
52
+ function setTombstonePath(p) {
53
+ _tombstonePath = p;
54
+ }
55
+ function writeDaemonTombstone(reason, error) {
56
+ const tombstone = {
57
+ reason,
58
+ message: error?.message ?? reason,
59
+ stack: error?.stack ?? null,
60
+ timestamp: new Date().toISOString(),
61
+ pid: process.pid,
62
+ uptimeSeconds: Math.floor(process.uptime()),
63
+ };
64
+ const filePath = getTombstonePath();
65
+ try {
66
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
67
+ fs.writeFileSync(filePath, JSON.stringify(tombstone, null, 2) + "\n", "utf-8");
68
+ }
69
+ catch {
70
+ // Best-effort: if we can't write, we're already dying.
71
+ }
72
+ (0, runtime_1.emitNervesEvent)({
73
+ level: "error",
74
+ component: "daemon",
75
+ event: "daemon.tombstone_written",
76
+ message: `daemon tombstone written: ${reason}`,
77
+ meta: { reason, errorMessage: error?.message ?? null, filePath },
78
+ });
79
+ }
80
+ function readDaemonTombstone() {
81
+ const filePath = getTombstonePath();
82
+ try {
83
+ const raw = fs.readFileSync(filePath, "utf-8");
84
+ const parsed = JSON.parse(raw);
85
+ if (typeof parsed.reason !== "string" || typeof parsed.timestamp !== "string") {
86
+ return null;
87
+ }
88
+ (0, runtime_1.emitNervesEvent)({
89
+ component: "daemon",
90
+ event: "daemon.tombstone_read",
91
+ message: "read daemon tombstone",
92
+ meta: { filePath },
93
+ });
94
+ return {
95
+ reason: parsed.reason,
96
+ message: typeof parsed.message === "string" ? parsed.message : String(parsed.reason),
97
+ stack: typeof parsed.stack === "string" ? parsed.stack : null,
98
+ timestamp: parsed.timestamp,
99
+ pid: typeof parsed.pid === "number" ? parsed.pid : 0,
100
+ uptimeSeconds: typeof parsed.uptimeSeconds === "number" ? parsed.uptimeSeconds : 0,
101
+ };
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
@@ -135,6 +135,8 @@ function buildWorkerRows(snapshots) {
135
135
  pid: snapshot.pid,
136
136
  restartCount: snapshot.restartCount,
137
137
  startedAt: snapshot.startedAt,
138
+ lastExitCode: snapshot.lastExitCode ?? null,
139
+ lastSignal: snapshot.lastSignal ?? null,
138
140
  }));
139
141
  }
140
142
  function formatStatusSummary(payload) {
@@ -269,12 +271,36 @@ class OuroDaemon {
269
271
  this.server = net.createServer((connection) => {
270
272
  let raw = "";
271
273
  let responded = false;
274
+ /* v8 ignore start — connection error handler requires real socket error @preserve */
275
+ connection.on("error", (err) => {
276
+ (0, runtime_1.emitNervesEvent)({
277
+ level: "warn",
278
+ component: "daemon",
279
+ event: "daemon.connection_error",
280
+ message: "socket connection error",
281
+ meta: { error: err.message, code: err.code ?? null },
282
+ });
283
+ });
284
+ /* v8 ignore stop */
272
285
  const flushResponse = async () => {
273
286
  if (responded)
274
287
  return;
275
288
  responded = true;
276
289
  const response = await this.handleRawPayload(raw);
277
- connection.end(response);
290
+ try {
291
+ connection.end(response);
292
+ /* v8 ignore start — EPIPE catch requires real socket disconnect @preserve */
293
+ }
294
+ catch (err) {
295
+ (0, runtime_1.emitNervesEvent)({
296
+ level: "warn",
297
+ component: "daemon",
298
+ event: "daemon.connection_end_error",
299
+ message: "failed to send response to client (EPIPE)",
300
+ meta: { error: err instanceof Error ? err.message : String(err) },
301
+ });
302
+ }
303
+ /* v8 ignore stop */
278
304
  };
279
305
  connection.on("data", (chunk) => {
280
306
  raw += chunk.toString("utf-8");
@@ -287,7 +313,22 @@ class OuroDaemon {
287
313
  const server = this.server;
288
314
  await new Promise((resolve, reject) => {
289
315
  server.once("error", reject);
290
- server.listen(this.socketPath, () => resolve());
316
+ server.listen(this.socketPath, () => {
317
+ // Replace the one-time error listener with a persistent one after successful listen
318
+ server.removeAllListeners("error");
319
+ /* v8 ignore start — server error after listen requires real socket race condition @preserve */
320
+ server.on("error", (err) => {
321
+ (0, runtime_1.emitNervesEvent)({
322
+ level: "error",
323
+ component: "daemon",
324
+ event: "daemon.server_error",
325
+ message: "daemon server error after listen",
326
+ meta: { error: err.message, code: err.code ?? null },
327
+ });
328
+ });
329
+ /* v8 ignore stop */
330
+ resolve();
331
+ });
291
332
  });
292
333
  }
293
334
  async drainPendingBundleMessages() {
@@ -467,6 +508,27 @@ class OuroDaemon {
467
508
  message: "handling daemon command",
468
509
  meta: { kind: command.kind },
469
510
  });
511
+ try {
512
+ return await this.handleCommandInner(command);
513
+ /* v8 ignore start — command error catch tested in daemon-command-error.test; instanceof branches defensive @preserve */
514
+ }
515
+ catch (error) {
516
+ (0, runtime_1.emitNervesEvent)({
517
+ level: "error",
518
+ component: "daemon",
519
+ event: "daemon.command_error",
520
+ message: "unexpected error handling daemon command",
521
+ meta: {
522
+ kind: command.kind,
523
+ error: error instanceof Error ? error.message : String(error),
524
+ stack: error instanceof Error ? error.stack ?? null : null,
525
+ },
526
+ });
527
+ throw error;
528
+ }
529
+ /* v8 ignore stop */
530
+ }
531
+ async handleCommandInner(command) {
470
532
  switch (command.kind) {
471
533
  case "daemon.start":
472
534
  await this.start();
@@ -51,12 +51,16 @@ class DaemonProcessManager {
51
51
  now;
52
52
  setTimeoutFn;
53
53
  clearTimeoutFn;
54
+ cooldownRecoveryMs;
55
+ maxCooldownRetries;
54
56
  existsSyncFn;
55
57
  constructor(options) {
56
58
  this.maxRestartsPerHour = options.maxRestartsPerHour ?? 10;
57
59
  this.stabilityThresholdMs = options.stabilityThresholdMs ?? 60_000;
58
60
  this.initialBackoffMs = options.initialBackoffMs ?? 1_000;
59
61
  this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
62
+ this.cooldownRecoveryMs = options.cooldownRecoveryMs ?? 5 * 60 * 1_000;
63
+ this.maxCooldownRetries = options.maxCooldownRetries ?? 3;
60
64
  this.spawnFn = options.spawn ?? ((command, args, spawnOptions) => (0, child_process_1.spawn)(command, args, spawnOptions));
61
65
  this.now = options.now ?? (() => Date.now());
62
66
  this.setTimeoutFn = options.setTimeoutFn ?? ((cb, delay) => setTimeout(cb, delay));
@@ -69,6 +73,8 @@ class DaemonProcessManager {
69
73
  restartTimer: null,
70
74
  crashTimestamps: [],
71
75
  stopRequested: false,
76
+ cooldownTimer: null,
77
+ cooldownRetryCount: 0,
72
78
  snapshot: {
73
79
  name: agent.name,
74
80
  channel: agent.channel,
@@ -78,6 +84,8 @@ class DaemonProcessManager {
78
84
  startedAt: null,
79
85
  lastCrashAt: null,
80
86
  backoffMs: this.initialBackoffMs,
87
+ lastExitCode: null,
88
+ lastSignal: null,
81
89
  },
82
90
  });
83
91
  }
@@ -143,6 +151,7 @@ class DaemonProcessManager {
143
151
  async stopAgent(agent) {
144
152
  const state = this.requireAgent(agent);
145
153
  this.clearRestartTimer(state);
154
+ this.clearCooldownTimer(state);
146
155
  state.stopRequested = true;
147
156
  if (!state.process) {
148
157
  state.snapshot.status = "stopped";
@@ -203,6 +212,8 @@ class DaemonProcessManager {
203
212
  return;
204
213
  state.process = null;
205
214
  state.snapshot.pid = null;
215
+ state.snapshot.lastExitCode = code;
216
+ state.snapshot.lastSignal = signal;
206
217
  const crashed = !state.stopRequested && code !== 0;
207
218
  const now = this.now();
208
219
  const startedAt = state.snapshot.startedAt ? Date.parse(state.snapshot.startedAt) : now;
@@ -233,6 +244,7 @@ class DaemonProcessManager {
233
244
  message: "managed agent exceeded restart limit and is marked crashed",
234
245
  meta: { agent: state.config.name, maxRestartsPerHour: this.maxRestartsPerHour },
235
246
  });
247
+ this.scheduleCooldownRecovery(state);
236
248
  return;
237
249
  }
238
250
  state.snapshot.status = "starting";
@@ -250,6 +262,45 @@ class DaemonProcessManager {
250
262
  this.clearTimeoutFn(state.restartTimer);
251
263
  state.restartTimer = null;
252
264
  }
265
+ scheduleCooldownRecovery(state) {
266
+ if (state.cooldownRetryCount >= this.maxCooldownRetries) {
267
+ (0, runtime_1.emitNervesEvent)({
268
+ level: "error",
269
+ component: "daemon",
270
+ event: "daemon.agent_permanent_failure",
271
+ message: "managed agent exhausted all cooldown retries — permanently stopped",
272
+ meta: { agent: state.config.name, cooldownRetryCount: state.cooldownRetryCount, maxCooldownRetries: this.maxCooldownRetries },
273
+ });
274
+ return;
275
+ }
276
+ this.clearCooldownTimer(state);
277
+ state.cooldownTimer = this.setTimeoutFn(() => {
278
+ state.cooldownRetryCount += 1;
279
+ state.crashTimestamps = [];
280
+ state.snapshot.backoffMs = this.initialBackoffMs;
281
+ state.snapshot.status = "starting";
282
+ state.snapshot.restartCount += 1;
283
+ (0, runtime_1.emitNervesEvent)({
284
+ component: "daemon",
285
+ event: "daemon.agent_cooldown_recovery",
286
+ message: "attempting cooldown recovery for managed agent",
287
+ meta: { agent: state.config.name, cooldownRetryCount: state.cooldownRetryCount },
288
+ });
289
+ void this.startAgent(state.config.name);
290
+ }, this.cooldownRecoveryMs);
291
+ (0, runtime_1.emitNervesEvent)({
292
+ component: "daemon",
293
+ event: "daemon.agent_cooldown_scheduled",
294
+ message: `scheduled cooldown recovery in ${this.cooldownRecoveryMs}ms`,
295
+ meta: { agent: state.config.name, cooldownRecoveryMs: this.cooldownRecoveryMs, cooldownRetryCount: state.cooldownRetryCount },
296
+ });
297
+ }
298
+ clearCooldownTimer(state) {
299
+ if (state.cooldownTimer === null)
300
+ return;
301
+ this.clearTimeoutFn(state.cooldownTimer);
302
+ state.cooldownTimer = null;
303
+ }
253
304
  requireAgent(agent) {
254
305
  const state = this.agents.get(agent);
255
306
  if (!state) {
@@ -7,6 +7,7 @@ exports.formatTerminalEntry = formatTerminalEntry;
7
7
  exports.registerSpinnerHooks = registerSpinnerHooks;
8
8
  exports.createTerminalSink = createTerminalSink;
9
9
  exports.createStderrSink = createStderrSink;
10
+ exports.rotateIfNeeded = rotateIfNeeded;
10
11
  exports.createNdjsonFileSink = createNdjsonFileSink;
11
12
  exports.registerGlobalLogSink = registerGlobalLogSink;
12
13
  exports.createLogger = createLogger;
@@ -99,22 +100,66 @@ function createTerminalSink(write = (chunk) => process.stderr.write(chunk), colo
99
100
  function createStderrSink(write = (chunk) => process.stderr.write(chunk)) {
100
101
  return createTerminalSink(write);
101
102
  }
102
- function createNdjsonFileSink(filePath) {
103
+ const DEFAULT_MAX_LOG_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
104
+ const ROTATION_CHECK_INTERVAL_BYTES = 1024 * 1024; // ~1MB between stat checks
105
+ /**
106
+ * Rotate a log file: shift .1 -> .2, delete old .2, rename current -> .1.
107
+ * Uses the ndjson extension pattern: file.ndjson -> file.1.ndjson -> file.2.ndjson
108
+ */
109
+ function rotateIfNeeded(filePath, maxSizeBytes) {
110
+ let size;
111
+ try {
112
+ size = (0, fs_1.statSync)(filePath).size;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ if (size < maxSizeBytes)
118
+ return false;
119
+ const ext = filePath.endsWith(".ndjson") ? ".ndjson" : "";
120
+ const base = ext ? filePath.slice(0, -ext.length) : filePath;
121
+ const file1 = `${base}.1${ext}`;
122
+ const file2 = `${base}.2${ext}`;
123
+ try {
124
+ (0, fs_1.unlinkSync)(file2);
125
+ }
126
+ catch { /* may not exist */ }
127
+ try {
128
+ (0, fs_1.renameSync)(file1, file2);
129
+ }
130
+ catch { /* may not exist */ }
131
+ try {
132
+ (0, fs_1.renameSync)(filePath, file1);
133
+ }
134
+ catch { /* best effort */ }
135
+ return true;
136
+ }
137
+ function createNdjsonFileSink(filePath, maxSizeBytes) {
103
138
  (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
104
139
  const queue = [];
105
140
  let flushing = false;
141
+ let bytesSinceCheck = 0;
142
+ const maxSize = maxSizeBytes ?? DEFAULT_MAX_LOG_SIZE_BYTES;
106
143
  function flush() {
107
144
  if (flushing || queue.length === 0)
108
145
  return;
109
146
  flushing = true;
110
147
  const line = queue.shift();
148
+ /* v8 ignore start — rotation triggers only after ~1MB of writes @preserve */
149
+ if (bytesSinceCheck >= ROTATION_CHECK_INTERVAL_BYTES) {
150
+ bytesSinceCheck = 0;
151
+ rotateIfNeeded(filePath, maxSize);
152
+ }
153
+ /* v8 ignore stop */
111
154
  (0, fs_1.appendFile)(filePath, line, "utf8", () => {
112
155
  flushing = false;
113
156
  flush();
114
157
  });
115
158
  }
116
159
  return (entry) => {
117
- queue.push(`${JSON.stringify(entry)}\n`);
160
+ const line = `${JSON.stringify(entry)}\n`;
161
+ bytesSinceCheck += line.length;
162
+ queue.push(line);
118
163
  flush();
119
164
  };
120
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.148",
3
+ "version": "0.1.0-alpha.149",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",