@ouro.bot/cli 0.1.0-alpha.272 → 0.1.0-alpha.274

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,23 @@
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.274",
6
+ "changes": [
7
+ "feat(nerves): daemon log rotation is now 25 MB x 5 gzipped generations instead of 50 MB x 2 uncompressed, dropping peak disk per stream from ~150 MB to ~30 MB. `createNdjsonFileSink` and `rotateIfNeeded` in `src/nerves/index.ts` accept an options object `{ maxSizeBytes, maxGenerations, compress, rotationCheckIntervalBytes }` (with number-form backcompat for the old positional API). Rotation uses a rename-then-gzip pattern so active writers can keep their fd alive while the renamed file gets compressed. Legacy uncompressed `.1.ndjson`/`.2.ndjson` files from the old scheme are tolerated and gzip-migrated on first rotation. Lifecycle emits paired `nerves.rotation_start` / `nerves.rotation_end` events with a shared trace_id, plus `nerves.rotation_error` on failure via a completion-flag try/catch.",
8
+ "feat(log-tailer): `ouro logs` can now read rotated `.ndjson.gz` generations, so `--lines N` spans across historical files. `discoverLogFiles` matches both `.ndjson` and `.ndjson.gz`, parses filenames into (streamBase, rank) tuples, and sorts chronologically (oldest generation first, active last). A new internal `readNdjsonFileContents` helper dispatches to `zlib.gunzipSync` for `.gz` paths while preserving the DI-stubbed plain-file path for existing tests. Follow mode only watches the active stream — gzipped generations are historical by definition and never tailed.",
9
+ "feat(logs-prune): new `ouro logs prune` subcommand applies the active rotation policy to every oversized `.ndjson` file in the agent daemon logs directory. Idempotent — a second run on a compliant dir is a no-op. Concurrent-writer-safe because it delegates to `rotateIfNeeded`'s rename-then-gzip pattern (no locking needed). Emits paired `nerves.logs_prune_start` / `nerves.logs_prune_end` with `nerves.logs_prune_error` on failure. Prints `compacted N file(s), freed M bytes` to stdout. New module `src/heart/daemon/logs-prune.ts` exports `pruneDaemonLogs(options)`; the CLI wire-up adds a `daemon.logs.prune` command kind across cli-types/cli-parse/cli-exec and a `pruneDaemonLogs` dep to `createDefaultOuroCliDeps`.",
10
+ "fix(launchd): drop the stale `StandardErrorPath` plist key that pointed at `ouro-daemon-stderr.log`. The file grew to 366 MB in the wild because the daemon stopped writing to it (the nerves ndjson pipeline has been the source of truth for diagnostics since the nerves layer landed) but nothing ever removed the plist key, so launchd kept the path registered and occasional process-level stderr still dripped in. Removing the key lets launchd forward stray stderr to the system log forwarder where the OS rotates it. The 366 MB orphaned file on disk at `~/AgentBundles/slugger.ouro/state/daemon/logs/ouro-daemon-stderr.log` is safe to delete manually and is flagged in the PR summary for user cleanup.",
11
+ "refactor(runtime-logging,cli-logging): every `createNdjsonFileSink` call site now passes an explicit `{ maxSizeBytes, maxGenerations, compress }` options object. No callsite silently relies on the old 50 MB default — the policy is visible at each wire-up point. This also removes the `/* v8 ignore */` around the rotation trigger in the sink's flush() loop; tests now exercise it directly via the new `rotationCheckIntervalBytes` option."
12
+ ]
13
+ },
14
+ {
15
+ "version": "0.1.0-alpha.273",
16
+ "changes": [
17
+ "feat(version-manager): auto-prune old CLI versions during activate. The user observed `~/.ouro-cli/versions/` accumulating every CLI version they'd ever installed (alpha.85 from 2026-03-20 onward, ~100MB+ of dead node_modules trees) because nothing ever GCed. New `pruneOldVersions(retain=5, deps?)` walks `~/.ouro-cli/versions/`, sorts by alpha-suffix numerically, and deletes everything outside the retention window — except always preserves (a) the currently-active version (CurrentVersion symlink target), and (b) the previous version (previous symlink target), so `ouro rollback` stays one command away. Wired into `cli-defaults.ts` via `ensureCurrentVersionInstalled` and `activateCliVersion` — every successful version activation self-prunes. New helpers `compareCliVersions(a, b)` and `selectVersionsToPrune(installed, protected, retain)` are pure and exported for direct unit coverage. 9 new tests covering version comparison, retention selection, current/previous protection, partial-failure handling, missing-symlink fallback, and non-directory entry filtering.",
18
+ "test(contract): new OURO_DAEMON_INSTANTIATION_ALLOWLIST in test-isolation.contract.test.ts flags any new test file that constructs `new OuroDaemon(...)` outside the 11 grandfathered files. Constructing a real daemon and calling start() runs killOrphanProcesses() and writePidfile() against the production pidfile at ~/.ouro-cli/daemon.pids. The runtime guards added in #346 short-circuit those functions under vitest, but if a future change to start() adds a NEW production-state side-effect, the existing 11 tests would silently exercise it. The contract test forces conscious review of each new file taking this shape — same defense-in-depth pattern as BYPASS_USE_ALLOWLIST in alpha.265."
19
+ ]
20
+ },
4
21
  {
5
22
  "version": "0.1.0-alpha.272",
6
23
  "changes": [
@@ -63,6 +63,7 @@ const runtime_mode_1 = require("./runtime-mode");
63
63
  const agent_discovery_1 = require("./agent-discovery");
64
64
  const bundle_manifest_1 = require("../../mind/bundle-manifest");
65
65
  const ouro_bot_global_installer_1 = require("../versioning/ouro-bot-global-installer");
66
+ const logs_prune_1 = require("./logs-prune");
66
67
  const launchd_1 = require("./launchd");
67
68
  const socket_client_1 = require("./socket-client");
68
69
  const session_activity_1 = require("../session-activity");
@@ -494,6 +495,14 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
494
495
  (0, ouro_version_manager_1.installVersion)(version, {});
495
496
  }
496
497
  (0, ouro_version_manager_1.activateVersion)(version, {});
498
+ // Self-prune: every successful version activation cleans up old
499
+ // versions outside the retention window. Without this, every CLI
500
+ // version ever installed accumulates indefinitely under
501
+ // ~/.ouro-cli/versions/ — the user observed installs going back to
502
+ // alpha.85 from March 20 (~100MB of dead node_modules trees).
503
+ // pruneOldVersions keeps the 5 most recent + the active + the
504
+ // previous version, so rollback stays one command away.
505
+ (0, ouro_version_manager_1.pruneOldVersions)(undefined, {});
497
506
  },
498
507
  /* v8 ignore stop */
499
508
  /* v8 ignore start -- CLI version management defaults: integration code @preserve */
@@ -508,7 +517,14 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
508
517
  });
509
518
  },
510
519
  installCliVersion: async (version) => { (0, ouro_version_manager_1.installVersion)(version, {}); },
511
- activateCliVersion: (version) => { (0, ouro_version_manager_1.activateVersion)(version, {}); },
520
+ activateCliVersion: (version) => {
521
+ (0, ouro_version_manager_1.activateVersion)(version, {});
522
+ // Same self-prune as ensureCurrentVersionInstalled — fires from the
523
+ // checkForCliUpdate path 1 (in-process update detected → activate
524
+ // → re-exec). Without this, the path 1 code path never triggers
525
+ // a prune.
526
+ (0, ouro_version_manager_1.pruneOldVersions)(undefined, {});
527
+ },
512
528
  getCurrentCliVersion: () => (0, ouro_version_manager_1.getCurrentVersion)({}),
513
529
  getPreviousCliVersion: () => (0, ouro_version_manager_1.getPreviousVersion)({}),
514
530
  listCliVersions: () => (0, ouro_version_manager_1.listInstalledVersions)({}),
@@ -519,6 +535,7 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
519
535
  },
520
536
  /* v8 ignore stop */
521
537
  syncGlobalOuroBotWrapper: ouro_bot_global_installer_1.syncGlobalOuroBotWrapper,
538
+ pruneDaemonLogs: logs_prune_1.pruneDaemonLogs,
522
539
  ensureSkillManagement: skill_management_installer_1.ensureSkillManagement,
523
540
  ensureDaemonBootPersistence: defaultEnsureDaemonBootPersistence,
524
541
  /* v8 ignore start -- dev-mode defaults: tests inject mocks for mode detection and binary resolution @preserve */
@@ -1080,6 +1080,17 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
1080
1080
  deps.tailLogs();
1081
1081
  return "";
1082
1082
  }
1083
+ if (command.kind === "daemon.logs.prune") {
1084
+ if (!deps.pruneDaemonLogs) {
1085
+ const message = "logs prune unavailable (dep not wired)";
1086
+ deps.writeStdout(message);
1087
+ return message;
1088
+ }
1089
+ const result = deps.pruneDaemonLogs();
1090
+ const message = `compacted ${result.filesCompacted} file${result.filesCompacted === 1 ? "" : "s"}, freed ${result.bytesFreed} bytes`;
1091
+ deps.writeStdout(message);
1092
+ return message;
1093
+ }
1083
1094
  if (command.kind === "outlook") {
1084
1095
  let status;
1085
1096
  try {
@@ -696,8 +696,11 @@ function parseOuroCommand(args) {
696
696
  return { kind: "daemon.stop" };
697
697
  if (head === "status")
698
698
  return { kind: "daemon.status" };
699
- if (head === "logs")
699
+ if (head === "logs") {
700
+ if (second === "prune")
701
+ return { kind: "daemon.logs.prune" };
700
702
  return { kind: "daemon.logs" };
703
+ }
701
704
  if (head === "outlook")
702
705
  return { kind: "outlook", ...(args.includes("--json") ? { json: true } : {}) };
703
706
  if (head === "hatch")
@@ -78,7 +78,13 @@ function generateDaemonPlist(options) {
78
78
  lines.push(` <key>EnvironmentVariables</key>`, ` <dict>`, ` <key>PATH</key>`, ` <string>${options.envPath}</string>`, ` </dict>`);
79
79
  }
80
80
  if (options.logDir) {
81
- lines.push(` <key>StandardOutPath</key>`, ` <string>${path.join(options.logDir, "ouro-daemon-stdout.log")}</string>`, ` <key>StandardErrorPath</key>`, ` <string>${path.join(options.logDir, "ouro-daemon-stderr.log")}</string>`);
81
+ // PR 1 decision: we no longer emit `StandardErrorPath` for the daemon.
82
+ // The daemon's structured nerves ndjson pipeline (rotated + gzipped via
83
+ // createNdjsonFileSink) is the source of truth for diagnostics. Writing
84
+ // raw process stderr to an unrotated file grew to 366 MB in the wild;
85
+ // dropping the key lets launchd forward stray stderr to the system log
86
+ // where it gets rotated by the OS.
87
+ lines.push(` <key>StandardOutPath</key>`, ` <string>${path.join(options.logDir, "ouro-daemon-stdout.log")}</string>`);
82
88
  }
83
89
  lines.push(`</dict>`, `</plist>`, ``);
84
90
  return lines.join("\n");
@@ -38,6 +38,7 @@ exports.readLastLines = readLastLines;
38
38
  exports.formatLogLine = formatLogLine;
39
39
  exports.tailLogs = tailLogs;
40
40
  const path = __importStar(require("path"));
41
+ const zlib = __importStar(require("zlib"));
41
42
  const nerves_1 = require("../../nerves");
42
43
  const runtime_1 = require("../../nerves/runtime");
43
44
  const identity_1 = require("../identity");
@@ -47,6 +48,39 @@ const LEVEL_COLORS = {
47
48
  warn: "\x1b[33m",
48
49
  error: "\x1b[31m",
49
50
  };
51
+ /**
52
+ * Parse a log filename into a (streamBase, generationRank) tuple.
53
+ *
54
+ * - `daemon.ndjson` → { streamBase: "daemon", rank: 0 } (active, newest)
55
+ * - `daemon.1.ndjson.gz` → { streamBase: "daemon", rank: 1 }
56
+ * - `daemon.5.ndjson.gz` → { streamBase: "daemon", rank: 5 } (oldest)
57
+ * - Anything else → null (not a log file).
58
+ *
59
+ * Higher rank = older. Used to sort so the oldest generation is read first
60
+ * and the active stream is read last, producing chronological output.
61
+ */
62
+ function parseLogFilename(name) {
63
+ if (name.endsWith(".ndjson.gz")) {
64
+ // e.g. daemon.1.ndjson.gz → base "daemon.1", strip ".gz" then ".ndjson" then ".<n>"
65
+ const withoutGz = name.slice(0, -".gz".length); // daemon.1.ndjson
66
+ const withoutNdjson = withoutGz.slice(0, -".ndjson".length); // daemon.1
67
+ const genMatch = withoutNdjson.match(/^(.+)\.(\d+)$/);
68
+ if (!genMatch)
69
+ return null;
70
+ return { streamBase: genMatch[1], rank: parseInt(genMatch[2], 10) };
71
+ }
72
+ if (name.endsWith(".ndjson")) {
73
+ const withoutNdjson = name.slice(0, -".ndjson".length);
74
+ // Active file: daemon.ndjson → base "daemon", rank 0
75
+ // Legacy numeric gen: daemon.1.ndjson → base "daemon", rank 1 (treat same as gzipped)
76
+ const legacyMatch = withoutNdjson.match(/^(.+)\.(\d+)$/);
77
+ if (legacyMatch) {
78
+ return { streamBase: legacyMatch[1], rank: parseInt(legacyMatch[2], 10) };
79
+ }
80
+ return { streamBase: withoutNdjson, rank: 0 };
81
+ }
82
+ return null;
83
+ }
50
84
  function discoverLogFiles(options) {
51
85
  /* v8 ignore start -- integration: default DI stubs for real OS @preserve */
52
86
  const existsSync = options.existsSync ?? (() => false);
@@ -55,22 +89,51 @@ function discoverLogFiles(options) {
55
89
  const logDir = options.homeDir
56
90
  ? path.join(options.homeDir, "AgentBundles", `${options.agentName ?? "slugger"}.ouro`, "state", "daemon", "logs")
57
91
  : (0, identity_1.getAgentDaemonLogsDir)(options.agentName);
58
- const files = [];
92
+ const entries = [];
59
93
  if (existsSync(logDir)) {
60
94
  for (const name of readdirSync(logDir)) {
61
- if (!name.endsWith(".ndjson"))
95
+ const parsed = parseLogFilename(name);
96
+ if (!parsed)
62
97
  continue;
63
98
  if (options.agentFilter && !name.includes(options.agentFilter))
64
99
  continue;
65
- files.push(path.join(logDir, name));
100
+ entries.push({ name, parsed });
66
101
  }
67
102
  }
68
- return files.sort();
103
+ // Sort chronologically: for each stream, oldest generation first, active last.
104
+ // Across streams, sort alphabetically by streamBase so output is stable.
105
+ entries.sort((a, b) => {
106
+ if (a.parsed.streamBase !== b.parsed.streamBase) {
107
+ return a.parsed.streamBase < b.parsed.streamBase ? -1 : 1;
108
+ }
109
+ // Same stream: higher rank = older = read first.
110
+ return b.parsed.rank - a.parsed.rank;
111
+ });
112
+ return entries.map((e) => path.join(logDir, e.name));
113
+ }
114
+ /**
115
+ * Read a log file as a string, transparently handling gzipped rotations.
116
+ *
117
+ * For `.ndjson.gz` files we read the raw bytes via `fs.readFileSync` (ignoring
118
+ * any caller-supplied DI `readFileSync`, since that stub returns a `string`
119
+ * and would corrupt gzip bytes) and then `zlib.gunzipSync` to recover the
120
+ * original text. For plain `.ndjson` files we defer to the DI stub so tests
121
+ * can keep mocking fs.
122
+ */
123
+ function readNdjsonFileContents(filePath, readFileSync) {
124
+ if (filePath.endsWith(".gz")) {
125
+ // Binary path: tests can mock readFileSync to return a Buffer (typed as
126
+ // string) via `as unknown as string` — we accept both Buffer and Uint8Array.
127
+ const raw = readFileSync(filePath);
128
+ const buf = typeof raw === "string" ? Buffer.from(raw, "binary") : Buffer.from(raw);
129
+ return zlib.gunzipSync(buf).toString("utf-8");
130
+ }
131
+ return readFileSync(filePath, "utf-8");
69
132
  }
70
133
  function readLastLines(filePath, count, readFileSync) {
71
134
  let content;
72
135
  try {
73
- content = readFileSync(filePath, "utf-8");
136
+ content = readNdjsonFileContents(filePath, readFileSync);
74
137
  }
75
138
  catch {
76
139
  return [];
@@ -100,12 +163,17 @@ function tailLogs(options = {}) {
100
163
  const files = discoverLogFiles(options);
101
164
  (0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.log_tailer_started", message: "log tailer started", meta: { fileCount: files.length, follow: !!options.follow } });
102
165
  const fileSizes = new Map();
103
- // Read initial lines
166
+ // Read initial lines from each discovered file (oldest gz → newest gz → active).
167
+ // Gzipped files are historical and never tailed in follow mode.
104
168
  for (const file of files) {
105
169
  const lines = readLastLines(file, lineCount, readFileSync);
106
170
  for (const line of lines) {
107
171
  writer(`${formatLogLine(line)}\n`);
108
172
  }
173
+ if (file.endsWith(".gz")) {
174
+ // Historical rotation — skip size tracking, never followed.
175
+ continue;
176
+ }
109
177
  try {
110
178
  const content = readFileSync(file, "utf-8");
111
179
  fileSizes.set(file, content.length);
@@ -114,9 +182,10 @@ function tailLogs(options = {}) {
114
182
  fileSizes.set(file, 0);
115
183
  }
116
184
  }
117
- // Follow mode
185
+ // Follow mode: only the active (non-gzipped) stream is watched.
118
186
  if (options.follow && watchFile && unwatchFile) {
119
- for (const file of files) {
187
+ const activeFiles = files.filter((f) => !f.endsWith(".gz"));
188
+ for (const file of activeFiles) {
120
189
  watchFile(file, () => {
121
190
  let content;
122
191
  try {
@@ -138,7 +207,7 @@ function tailLogs(options = {}) {
138
207
  });
139
208
  }
140
209
  return () => {
141
- for (const file of files) {
210
+ for (const file of activeFiles) {
142
211
  unwatchFile(file);
143
212
  }
144
213
  };
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pruneDaemonLogs = pruneDaemonLogs;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ const crypto_1 = require("crypto");
7
+ const nerves_1 = require("../../nerves");
8
+ const runtime_1 = require("../../nerves/runtime");
9
+ const identity_1 = require("../identity");
10
+ function pruneDaemonLogs(options = {}) {
11
+ /* v8 ignore next -- defensive: tests always pass logsDir to avoid prod paths @preserve */
12
+ const logsDir = options.logsDir ?? (0, identity_1.getAgentDaemonLogsDir)(options.agentName);
13
+ const maxSizeBytes = options.maxSizeBytes ?? nerves_1.DEFAULT_MAX_LOG_SIZE_BYTES;
14
+ const maxGenerations = options.maxGenerations ?? nerves_1.DEFAULT_MAX_GENERATIONS;
15
+ const traceId = (0, crypto_1.randomUUID)();
16
+ (0, runtime_1.emitNervesEvent)({
17
+ component: "nerves",
18
+ event: "nerves.logs_prune_start",
19
+ trace_id: traceId,
20
+ message: "pruning daemon logs",
21
+ meta: { logsDir, maxSizeBytes, maxGenerations },
22
+ });
23
+ let completed = false;
24
+ try {
25
+ if (!(0, fs_1.existsSync)(logsDir)) {
26
+ completed = true;
27
+ (0, runtime_1.emitNervesEvent)({
28
+ component: "nerves",
29
+ event: "nerves.logs_prune_end",
30
+ trace_id: traceId,
31
+ message: "daemon logs dir does not exist",
32
+ meta: { logsDir, filesCompacted: 0, bytesFreed: 0 },
33
+ });
34
+ return { filesCompacted: 0, bytesFreed: 0 };
35
+ }
36
+ let filesCompacted = 0;
37
+ let bytesFreed = 0;
38
+ // Enumerate and rotate each active .ndjson stream. We explicitly skip
39
+ // .gz and other files — only the active stream can be a rotation
40
+ // candidate. Legacy .N.ndjson uncompressed files are handled inside
41
+ // rotateIfNeeded's generation-shift step, so we skip them here to
42
+ // avoid double-rotating.
43
+ for (const name of (0, fs_1.readdirSync)(logsDir)) {
44
+ if (!name.endsWith(".ndjson"))
45
+ continue;
46
+ // Skip legacy generation files like daemon.1.ndjson; rotateIfNeeded
47
+ // will migrate them on the next rotation of their parent stream.
48
+ if (/\.\d+\.ndjson$/.test(name))
49
+ continue;
50
+ const filePath = (0, path_1.join)(logsDir, name);
51
+ let sizeBefore;
52
+ try {
53
+ sizeBefore = (0, fs_1.statSync)(filePath).size;
54
+ /* v8 ignore start -- defensive: file disappears between readdir and stat @preserve */
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ /* v8 ignore stop */
60
+ if (sizeBefore < maxSizeBytes)
61
+ continue;
62
+ // rotateIfNeeded returns true because we pre-checked sizeBefore >=
63
+ // maxSizeBytes above. The false branch of `if (rotated)` is defensive
64
+ // and unreachable under normal flow; we skip it from coverage so a
65
+ // future refactor that weakens the pre-check still reports correct
66
+ // counts without needing a contrived test.
67
+ const rotated = (0, nerves_1.rotateIfNeeded)(filePath, {
68
+ maxSizeBytes,
69
+ maxGenerations,
70
+ compress: true,
71
+ });
72
+ /* v8 ignore next 3 -- defensive: pre-check guarantees rotated=true @preserve */
73
+ if (!rotated) {
74
+ continue;
75
+ }
76
+ filesCompacted += 1;
77
+ bytesFreed += sizeBefore;
78
+ }
79
+ completed = true;
80
+ (0, runtime_1.emitNervesEvent)({
81
+ component: "nerves",
82
+ event: "nerves.logs_prune_end",
83
+ trace_id: traceId,
84
+ message: "daemon logs pruned",
85
+ meta: { logsDir, filesCompacted, bytesFreed },
86
+ });
87
+ return { filesCompacted, bytesFreed };
88
+ }
89
+ catch (err) {
90
+ /* v8 ignore next -- defensive: completed=true only reached after try returns @preserve */
91
+ if (!completed) {
92
+ /* v8 ignore next -- defensive: rotation always throws real Errors @preserve */
93
+ const reason = err instanceof Error ? err.message : String(err);
94
+ (0, runtime_1.emitNervesEvent)({
95
+ component: "nerves",
96
+ event: "nerves.logs_prune_error",
97
+ trace_id: traceId,
98
+ level: "error",
99
+ message: "daemon logs prune failed",
100
+ meta: { logsDir, error: reason },
101
+ });
102
+ }
103
+ throw err;
104
+ }
105
+ }
@@ -119,12 +119,20 @@ function configureDaemonRuntimeLogger(processName, options = {}) {
119
119
  const logsDir = options.homeDir
120
120
  ? path.join(options.homeDir, "AgentBundles", `${agentName}.ouro`, "state", "daemon", "logs")
121
121
  : (0, identity_1.getAgentDaemonLogsDir)(agentName);
122
+ // Rotation policy per daemon ndjson stream (Unit 1c):
123
+ // 25 MB threshold x 5 gzipped generations = ~30 MB peak per stream.
124
+ // Call sites previously relied on the implicit 50 MB default; we now pass
125
+ // the options object explicitly so the policy is visible at the call site.
122
126
  const sinks = config.sinks.map((sinkName) => {
123
127
  if (sinkName === "terminal") {
124
128
  return (0, nerves_1.createTerminalSink)();
125
129
  }
126
130
  const ndjsonPath = path.join(logsDir, `${processName}.ndjson`);
127
- return (0, nerves_1.createNdjsonFileSink)(ndjsonPath);
131
+ return (0, nerves_1.createNdjsonFileSink)(ndjsonPath, {
132
+ maxSizeBytes: 25 * 1024 * 1024,
133
+ maxGenerations: 5,
134
+ compress: true,
135
+ });
128
136
  });
129
137
  const logger = (0, nerves_1.createLogger)({
130
138
  level: config.level,
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_RETAIN_VERSIONS = void 0;
36
37
  exports.getOuroCliHome = getOuroCliHome;
37
38
  exports.getCurrentVersion = getCurrentVersion;
38
39
  exports.getPreviousVersion = getPreviousVersion;
@@ -40,11 +41,16 @@ exports.buildChangelogCommand = buildChangelogCommand;
40
41
  exports.listInstalledVersions = listInstalledVersions;
41
42
  exports.installVersion = installVersion;
42
43
  exports.activateVersion = activateVersion;
44
+ exports.compareCliVersions = compareCliVersions;
45
+ exports.selectVersionsToPrune = selectVersionsToPrune;
46
+ exports.pruneOldVersions = pruneOldVersions;
43
47
  exports.ensureLayout = ensureLayout;
44
48
  const fs = __importStar(require("fs"));
45
49
  const os = __importStar(require("os"));
46
50
  const path = __importStar(require("path"));
47
51
  const runtime_1 = require("../../nerves/runtime");
52
+ /** Maximum number of installed CLI versions to retain after pruning. */
53
+ exports.DEFAULT_RETAIN_VERSIONS = 5;
48
54
  function getOuroCliHome(homeDir) {
49
55
  /* v8 ignore next -- dep default: tests always inject @preserve */
50
56
  const home = homeDir ?? os.homedir();
@@ -155,6 +161,124 @@ function activateVersion(version, deps) {
155
161
  }
156
162
  symlinkSync(newTarget, currentVersionPath);
157
163
  }
164
+ /**
165
+ * Compare two version strings of the form `0.1.0-alpha.{n}`. Returns
166
+ * positive when `a` is newer, negative when `b` is newer, 0 when equal.
167
+ *
168
+ * The harness only ships `0.1.0-alpha.{n}` versions today; this comparator
169
+ * extracts the numeric tail and falls back to a lexicographic compare for
170
+ * any version that doesn't match the pattern (so unexpected version
171
+ * formats sort consistently rather than throwing).
172
+ */
173
+ function compareCliVersions(a, b) {
174
+ const aMatch = /alpha\.(\d+)/.exec(a);
175
+ const bMatch = /alpha\.(\d+)/.exec(b);
176
+ if (aMatch && bMatch) {
177
+ const aN = parseInt(aMatch[1], 10);
178
+ const bN = parseInt(bMatch[1], 10);
179
+ return aN - bN;
180
+ }
181
+ // Fallback: lexicographic. Both-mismatched and one-mismatched cases land
182
+ // here. Predictable, even if not strictly semver-correct.
183
+ if (a < b)
184
+ return -1;
185
+ if (a > b)
186
+ return 1;
187
+ return 0;
188
+ }
189
+ /**
190
+ * Identify which installed CLI versions can be safely deleted, given a
191
+ * retention policy. Pure: takes the full version list and the protected
192
+ * (current/previous) versions, returns the versions to delete.
193
+ *
194
+ * Retention rules:
195
+ * - Always keep the N most recent versions (default 5).
196
+ * - Always keep the currently-active version (CurrentVersion symlink target).
197
+ * - Always keep the previous version (previous symlink target) so rollback
198
+ * stays one command away.
199
+ * - Delete everything else.
200
+ *
201
+ * Exported so tests can pin the policy without shelling out to a real fs.
202
+ */
203
+ function selectVersionsToPrune(installedVersions, protectedVersions, retain = exports.DEFAULT_RETAIN_VERSIONS) {
204
+ if (installedVersions.length <= retain)
205
+ return [];
206
+ const sorted = [...installedVersions].sort(compareCliVersions).reverse();
207
+ const keepers = new Set(sorted.slice(0, retain));
208
+ if (protectedVersions.current)
209
+ keepers.add(protectedVersions.current);
210
+ if (protectedVersions.previous)
211
+ keepers.add(protectedVersions.previous);
212
+ return installedVersions.filter((v) => !keepers.has(v));
213
+ }
214
+ /**
215
+ * Prune installed CLI versions according to the retention policy. Removes
216
+ * `~/.ouro-cli/versions/{version}/` directories for versions outside the
217
+ * retention window. Best-effort: per-version delete failures are logged
218
+ * via nerves but don't propagate.
219
+ *
220
+ * Called from the activate path (cli-defaults.ts) so that every successful
221
+ * `ouro up` self-prunes. The user observed `~/.ouro-cli/versions/` going
222
+ * back to alpha.85 from March 20 — every CLI version they'd ever installed
223
+ * was still on disk because nothing ever GCed.
224
+ */
225
+ function pruneOldVersions(retain = exports.DEFAULT_RETAIN_VERSIONS, deps = {}) {
226
+ const cliHome = getOuroCliHome(deps.homeDir);
227
+ /* v8 ignore start -- dep defaults: tests always inject @preserve */
228
+ const readdirSync = deps.readdirSync ?? ((p, opts) => fs.readdirSync(p, opts));
229
+ const readlinkSync = deps.readlinkSync ?? fs.readlinkSync;
230
+ const rmSync = deps.rmSync ?? fs.rmSync;
231
+ /* v8 ignore stop */
232
+ const versionsDir = path.join(cliHome, "versions");
233
+ let installed;
234
+ try {
235
+ installed = readdirSync(versionsDir, { withFileTypes: true })
236
+ .filter((e) => e.isDirectory())
237
+ .map((e) => e.name);
238
+ }
239
+ catch {
240
+ return { kept: [], deleted: [], failed: [] };
241
+ }
242
+ const current = (() => {
243
+ try {
244
+ return path.basename(readlinkSync(path.join(cliHome, "CurrentVersion")));
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ })();
250
+ const previous = (() => {
251
+ try {
252
+ return path.basename(readlinkSync(path.join(cliHome, "previous")));
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ })();
258
+ const toDelete = selectVersionsToPrune(installed, { current, previous }, retain);
259
+ const deleted = [];
260
+ const failed = [];
261
+ for (const version of toDelete) {
262
+ const versionDir = path.join(versionsDir, version);
263
+ try {
264
+ rmSync(versionDir, { recursive: true, force: true });
265
+ deleted.push(version);
266
+ }
267
+ catch (error) {
268
+ failed.push({
269
+ version,
270
+ error: error instanceof Error ? error.message : /* v8 ignore next -- defensive: non-Error catch branch @preserve */ String(error),
271
+ });
272
+ }
273
+ }
274
+ (0, runtime_1.emitNervesEvent)({
275
+ component: "daemon",
276
+ event: "daemon.cli_versions_pruned",
277
+ message: `pruned ${deleted.length} old CLI versions`,
278
+ meta: { retain, deleted, failed: failed.length, kept: installed.filter((v) => !deleted.includes(v)) },
279
+ });
280
+ return { kept: installed.filter((v) => !deleted.includes(v)), deleted, failed };
281
+ }
158
282
  function ensureLayout(deps) {
159
283
  const cliHome = getOuroCliHome(deps.homeDir);
160
284
  /* v8 ignore next -- dep default: tests always inject @preserve */
@@ -28,7 +28,13 @@ function configureCliRuntimeLogger(_friendId, options = {}) {
28
28
  // for an interactive session. Full detail goes to the ndjson file.
29
29
  return filterSink((0, nerves_1.createTerminalSink)(), "warn");
30
30
  }
31
- return (0, nerves_1.createNdjsonFileSink)((0, config_1.logPath)("cli", "runtime"));
31
+ // Rotation policy for the CLI runtime sink (Unit 1c):
32
+ // 25 MB x 5 gzipped generations, matching the daemon stream policy.
33
+ return (0, nerves_1.createNdjsonFileSink)((0, config_1.logPath)("cli", "runtime"), {
34
+ maxSizeBytes: 25 * 1024 * 1024,
35
+ maxGenerations: 5,
36
+ compress: true,
37
+ });
32
38
  });
33
39
  const logger = (0, nerves_1.createLogger)({
34
40
  level,
@@ -1,5 +1,39 @@
1
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
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_MAX_GENERATIONS = exports.DEFAULT_MAX_LOG_SIZE_BYTES = void 0;
3
37
  exports.createTraceId = createTraceId;
4
38
  exports.ensureTraceId = ensureTraceId;
5
39
  exports.createFanoutSink = createFanoutSink;
@@ -14,6 +48,12 @@ exports.createLogger = createLogger;
14
48
  const fs_1 = require("fs");
15
49
  const path_1 = require("path");
16
50
  const crypto_1 = require("crypto");
51
+ const zlib = __importStar(require("zlib"));
52
+ // NOTE: runtime.ts imports `createLogger` and `ensureTraceId` from this file.
53
+ // The cycle is safe in CommonJS because runtime.ts only uses those values
54
+ // lazily inside `getRuntimeLogger()`, and we only call `emitNervesEvent` from
55
+ // inside `rotateIfNeeded` — never at module top level.
56
+ const runtime_1 = require("./runtime");
17
57
  const LEVEL_PRIORITY = {
18
58
  debug: 10,
19
59
  info: 20,
@@ -103,13 +143,44 @@ catch { /* EPIPE: daemon detached, no terminal */ } }, colorize = true) {
103
143
  function createStderrSink(write) {
104
144
  return createTerminalSink(write);
105
145
  }
106
- const DEFAULT_MAX_LOG_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
107
- const ROTATION_CHECK_INTERVAL_BYTES = 1024 * 1024; // ~1MB between stat checks
146
+ exports.DEFAULT_MAX_LOG_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB per active stream
147
+ exports.DEFAULT_MAX_GENERATIONS = 5; // keep 5 gzipped historical generations
148
+ const DEFAULT_ROTATION_CHECK_INTERVAL_BYTES = 1024 * 1024; // ~1MB between stat checks
149
+ /** Internal: compute the gzipped generation path for a given ndjson file. */
150
+ function generationGzPath(base, ext, n) {
151
+ return ext === ".ndjson" ? `${base}.${n}.ndjson.gz` : `${base}.${n}.gz`;
152
+ }
153
+ /** Internal: compute the legacy uncompressed generation path for a given file. */
154
+ function generationPlainPath(base, ext, n) {
155
+ return ext === ".ndjson" ? `${base}.${n}.ndjson` : `${base}.${n}`;
156
+ }
108
157
  /**
109
- * Rotate a log file: shift .1 -> .2, delete old .2, rename current -> .1.
110
- * Uses the ndjson extension pattern: file.ndjson -> file.1.ndjson -> file.2.ndjson
158
+ * Rotate a log file in place.
159
+ *
160
+ * Scheme (25 MB × 5 gzipped generations by default):
161
+ * active : foo.ndjson
162
+ * newest gen : foo.1.ndjson.gz
163
+ * oldest gen : foo.5.ndjson.gz (dropped on next rotation)
164
+ *
165
+ * Legacy tolerance: uncompressed `foo.1.ndjson` / `foo.2.ndjson` files from the
166
+ * old scheme are treated as the corresponding generation and gzipped on first
167
+ * rotation.
168
+ *
169
+ * Concurrent-writer safety: the active file is renamed (inode stays alive for
170
+ * any open writer fd) then gzipped at the renamed path. An active writer
171
+ * continues writing to its original fd; on the next write cycle it sees the
172
+ * old path is missing and creates a fresh file.
173
+ *
174
+ * Backwards-compatible signature: passing a `number` as the second argument
175
+ * (the old API) is still accepted and interpreted as `maxSizeBytes`.
111
176
  */
112
- function rotateIfNeeded(filePath, maxSizeBytes) {
177
+ function rotateIfNeeded(filePath, optionsOrMaxSize) {
178
+ const options = typeof optionsOrMaxSize === "number"
179
+ ? { maxSizeBytes: optionsOrMaxSize }
180
+ : optionsOrMaxSize ?? {};
181
+ const maxSizeBytes = options.maxSizeBytes ?? exports.DEFAULT_MAX_LOG_SIZE_BYTES;
182
+ const maxGenerations = options.maxGenerations ?? exports.DEFAULT_MAX_GENERATIONS;
183
+ const compress = options.compress ?? true;
113
184
  let size;
114
185
  try {
115
186
  size = (0, fs_1.statSync)(filePath).size;
@@ -121,39 +192,124 @@ function rotateIfNeeded(filePath, maxSizeBytes) {
121
192
  return false;
122
193
  const ext = filePath.endsWith(".ndjson") ? ".ndjson" : "";
123
194
  const base = ext ? filePath.slice(0, -ext.length) : filePath;
124
- const file1 = `${base}.1${ext}`;
125
- const file2 = `${base}.2${ext}`;
195
+ const traceId = (0, crypto_1.randomUUID)();
196
+ (0, runtime_1.emitNervesEvent)({
197
+ component: "nerves",
198
+ event: "nerves.rotation_start",
199
+ trace_id: traceId,
200
+ message: "rotating log file",
201
+ meta: { path: filePath, currentSize: size, threshold: maxSizeBytes, generation: 1 },
202
+ });
203
+ let completed = false;
126
204
  try {
127
- (0, fs_1.unlinkSync)(file2);
128
- }
129
- catch { /* may not exist */ }
130
- try {
131
- (0, fs_1.renameSync)(file1, file2);
205
+ // Step 1: drop / shift existing generations starting from the oldest.
206
+ // For each slot N (maxGenerations..2), find whatever occupies slot (N-1)
207
+ // (as .gz or legacy uncompressed) and move it to slot N. Slot N occupants
208
+ // get overwritten (oldest is dropped). Non-existent slots are skipped.
209
+ for (let n = maxGenerations; n >= 2; n--) {
210
+ const destGz = generationGzPath(base, ext, n);
211
+ const srcGz = generationGzPath(base, ext, n - 1);
212
+ const srcPlain = generationPlainPath(base, ext, n - 1);
213
+ // Drop whatever currently occupies the destination slot (oldest generation).
214
+ if ((0, fs_1.existsSync)(destGz)) {
215
+ (0, fs_1.unlinkSync)(destGz);
216
+ }
217
+ const destPlain = generationPlainPath(base, ext, n);
218
+ if ((0, fs_1.existsSync)(destPlain)) {
219
+ (0, fs_1.unlinkSync)(destPlain);
220
+ }
221
+ // Prefer moving .gz → .gz (cheap rename).
222
+ if ((0, fs_1.existsSync)(srcGz)) {
223
+ (0, fs_1.renameSync)(srcGz, destGz);
224
+ continue;
225
+ }
226
+ // Legacy migration: if a plain .ndjson generation file exists from the
227
+ // old scheme, read it, gzip it into the destination slot, and delete
228
+ // the plain source.
229
+ if ((0, fs_1.existsSync)(srcPlain)) {
230
+ if (compress) {
231
+ const buf = (0, fs_1.readFileSync)(srcPlain);
232
+ const compressed = zlib.gzipSync(buf);
233
+ (0, fs_1.writeFileSync)(destGz, compressed);
234
+ (0, fs_1.unlinkSync)(srcPlain);
235
+ }
236
+ else {
237
+ // compress=false: just rename
238
+ (0, fs_1.renameSync)(srcPlain, destPlain);
239
+ }
240
+ }
241
+ }
242
+ // Step 2: rename the active file to the generation-1 plain path. The
243
+ // active writer keeps its open fd; the file path now points elsewhere.
244
+ const plain1 = generationPlainPath(base, ext, 1);
245
+ // If a stale .1 plain path exists (e.g. a previous compress=false run),
246
+ // remove it so the rename can claim the slot cleanly.
247
+ if ((0, fs_1.existsSync)(plain1)) {
248
+ (0, fs_1.unlinkSync)(plain1);
249
+ }
250
+ (0, fs_1.renameSync)(filePath, plain1);
251
+ // Step 3: gzip (or keep plain) the renamed file into the .1 generation.
252
+ if (compress) {
253
+ const gz1 = generationGzPath(base, ext, 1);
254
+ // Remove any lingering .1.ndjson.gz to avoid "file exists" on write.
255
+ if ((0, fs_1.existsSync)(gz1)) {
256
+ (0, fs_1.unlinkSync)(gz1);
257
+ }
258
+ const buf = (0, fs_1.readFileSync)(plain1);
259
+ const compressed = zlib.gzipSync(buf);
260
+ (0, fs_1.writeFileSync)(gz1, compressed);
261
+ (0, fs_1.unlinkSync)(plain1);
262
+ }
263
+ completed = true;
264
+ (0, runtime_1.emitNervesEvent)({
265
+ component: "nerves",
266
+ event: "nerves.rotation_end",
267
+ trace_id: traceId,
268
+ message: "log rotation complete",
269
+ meta: { path: filePath, compressedPath: compress ? generationGzPath(base, ext, 1) : plain1, bytesFreed: size },
270
+ });
271
+ return true;
132
272
  }
133
- catch { /* may not exist */ }
134
- try {
135
- (0, fs_1.renameSync)(filePath, file1);
273
+ catch (err) {
274
+ /* v8 ignore next -- defensive: completed=true only reached after the try block's return @preserve */
275
+ if (!completed) {
276
+ /* v8 ignore next -- defensive: fs ops throw real Errors @preserve */
277
+ const reason = err instanceof Error ? err.message : String(err);
278
+ (0, runtime_1.emitNervesEvent)({
279
+ component: "nerves",
280
+ event: "nerves.rotation_error",
281
+ trace_id: traceId,
282
+ level: "error",
283
+ message: "log rotation failed",
284
+ meta: { path: filePath, error: reason },
285
+ });
286
+ }
287
+ throw err;
136
288
  }
137
- catch { /* best effort */ }
138
- return true;
139
289
  }
140
- function createNdjsonFileSink(filePath, maxSizeBytes) {
290
+ function createNdjsonFileSink(filePath, optionsOrMaxSize) {
141
291
  (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
292
+ const options = typeof optionsOrMaxSize === "number"
293
+ ? { maxSizeBytes: optionsOrMaxSize }
294
+ : optionsOrMaxSize ?? {};
295
+ const rotationCheckInterval = options.rotationCheckIntervalBytes ?? DEFAULT_ROTATION_CHECK_INTERVAL_BYTES;
142
296
  const queue = [];
143
297
  let flushing = false;
144
298
  let bytesSinceCheck = 0;
145
- const maxSize = maxSizeBytes ?? DEFAULT_MAX_LOG_SIZE_BYTES;
146
299
  function flush() {
147
300
  if (flushing || queue.length === 0)
148
301
  return;
149
302
  flushing = true;
150
303
  const line = queue.shift();
151
- /* v8 ignore start — rotation triggers only after ~1MB of writes @preserve */
152
- if (bytesSinceCheck >= ROTATION_CHECK_INTERVAL_BYTES) {
304
+ if (bytesSinceCheck >= rotationCheckInterval) {
153
305
  bytesSinceCheck = 0;
154
- rotateIfNeeded(filePath, maxSize);
306
+ try {
307
+ rotateIfNeeded(filePath, options);
308
+ }
309
+ catch {
310
+ // Rotation errors are surfaced via nerves events; never block writes.
311
+ }
155
312
  }
156
- /* v8 ignore stop */
157
313
  (0, fs_1.appendFile)(filePath, line, "utf8", () => {
158
314
  flushing = false;
159
315
  flush();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.272",
3
+ "version": "0.1.0-alpha.274",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",