@ouro.bot/cli 0.1.0-alpha.273 → 0.1.0-alpha.275
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 +26 -10
- package/dist/heart/daemon/cli-defaults.js +2 -0
- package/dist/heart/daemon/cli-exec.js +11 -0
- package/dist/heart/daemon/cli-parse.js +4 -1
- package/dist/heart/daemon/launchd.js +7 -1
- package/dist/heart/daemon/log-tailer.js +78 -9
- package/dist/heart/daemon/logs-prune.js +105 -0
- package/dist/heart/daemon/runtime-logging.js +9 -1
- package/dist/nerves/cli-logging.js +7 -1
- package/dist/nerves/index.js +179 -23
- package/dist/senses/cli/ouro-tui.js +6 -23
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,25 +1,41 @@
|
|
|
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.275",
|
|
6
|
+
"changes": [
|
|
7
|
+
"fix(tui): backspace on macOS \u2014 Ink 3.2 maps \\x7f to key.delete not key.backspace."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.274",
|
|
12
|
+
"changes": [
|
|
13
|
+
"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.",
|
|
14
|
+
"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 \u2014 gzipped generations are historical by definition and never tailed.",
|
|
15
|
+
"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 \u2014 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`.",
|
|
16
|
+
"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.",
|
|
17
|
+
"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 \u2014 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."
|
|
18
|
+
]
|
|
19
|
+
},
|
|
4
20
|
{
|
|
5
21
|
"version": "0.1.0-alpha.273",
|
|
6
22
|
"changes": [
|
|
7
|
-
"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
|
|
8
|
-
"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
|
|
23
|
+
"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 \u2014 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` \u2014 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.",
|
|
24
|
+
"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 \u2014 same defense-in-depth pattern as BYPASS_USE_ALLOWLIST in alpha.265."
|
|
9
25
|
]
|
|
10
26
|
},
|
|
11
27
|
{
|
|
12
28
|
"version": "0.1.0-alpha.272",
|
|
13
29
|
"changes": [
|
|
14
|
-
"fix(daemon): the daemon's periodic update checker can now actually auto-update itself. The `onUpdate` callback in `daemon.ts` invoked `performStagedRestart` which (a) ran `npm install -g @ouro.bot/cli@{version}` to install to the global node prefix, and (b) tried to find the new code via `node -e \"console.log(require.resolve('@ouro.bot/cli/package.json'))\"` which depends on the daemon process's NODE_PATH. Neither path was actually reachable from the daemon process running out of `~/.ouro-cli/versions/{version}/...`, so every auto-update attempt bailed at `daemon.staged_restart_path_failed` (\"could not resolve new code path\") and the daemon never updated itself. The user had to manually run `ouro up` to pick up new versions. Fix: switch the staged restart to use the version-managed installer (same one the CLI's `up` flow uses)
|
|
30
|
+
"fix(daemon): the daemon's periodic update checker can now actually auto-update itself. The `onUpdate` callback in `daemon.ts` invoked `performStagedRestart` which (a) ran `npm install -g @ouro.bot/cli@{version}` to install to the global node prefix, and (b) tried to find the new code via `node -e \"console.log(require.resolve('@ouro.bot/cli/package.json'))\"` which depends on the daemon process's NODE_PATH. Neither path was actually reachable from the daemon process running out of `~/.ouro-cli/versions/{version}/...`, so every auto-update attempt bailed at `daemon.staged_restart_path_failed` (\"could not resolve new code path\") and the daemon never updated itself. The user had to manually run `ouro up` to pick up new versions. Fix: switch the staged restart to use the version-managed installer (same one the CLI's `up` flow uses) \u2014 `installVersion(version)` puts files at `~/.ouro-cli/versions/{version}/node_modules/@ouro.bot/cli` (deterministic, computable), then `activateVersion(version)` flips the CurrentVersion symlink so the next user-driven `ouro up` sees the same version the daemon is running. `performStagedRestart` gained an optional `installNewVersion` dep that production callers inject; the legacy `npm install -g` fallback path is preserved for tests. Two new regression tests in `staged-restart.test.ts`.",
|
|
15
31
|
"fix(cli): `ouro up` no longer prints the 'ouro updated to ...' message twice during npx invocations. Three independent paths can detect a CLI version change: (1) `checkForCliUpdate` finds a newer version on npm and re-execs (cross-process print), (2) `ensureCurrentVersionInstalled` flips the CurrentVersion symlink during `performSystemSetup` because the running package version is newer than what the symlink pointed at (path 2, in-process), (3) `bundle-meta.json`'s stored runtime version differs from the running version (path 3, in-process fallback). Path 3's existing guard `linkedVersionBeforeUp !== currentVersion` correctly skipped path 3 when path 1 had already printed in a different process, but did NOT catch the case where path 2 fired in the same process. Verified live on 2026-04-08: `npx --yes @ouro.bot/cli@alpha up` printed the message twice. Fix: track an in-process `printedUpdateMessage` flag set by path 2; path 3 checks it before printing. Path 3 still acts as a fallback when path 2 didn't fire. New regression test in `daemon-cli-update-flow.test.ts` simulates the npx scenario and asserts exactly one print.",
|
|
16
|
-
"fix(hooks): Claude Code lifecycle hooks (`ouro hook session-start|stop|post-tool-use`) now short-circuit when the daemon socket file doesn't exist, instead of attempting `sendDaemonCommand` and logging two ENOENT errors per hook fire (one for `message.send`, one for `inner.wake`). Every Claude Code event during a daemon-down window was producing noisy `connect ENOENT /tmp/ouroboros-daemon.sock` entries in `ouro.ndjson`, which made it hard to read logs around outages. The hook is best-effort by design
|
|
32
|
+
"fix(hooks): Claude Code lifecycle hooks (`ouro hook session-start|stop|post-tool-use`) now short-circuit when the daemon socket file doesn't exist, instead of attempting `sendDaemonCommand` and logging two ENOENT errors per hook fire (one for `message.send`, one for `inner.wake`). Every Claude Code event during a daemon-down window was producing noisy `connect ENOENT /tmp/ouroboros-daemon.sock` entries in `ouro.ndjson`, which made it hard to read logs around outages. The hook is best-effort by design \u2014 dropping notifications when the daemon is down is correct behavior; we just don't want to log spam about it. New nerves event `daemon.hook_skipped_no_socket` (info level) when the short-circuit fires."
|
|
17
33
|
]
|
|
18
34
|
},
|
|
19
35
|
{
|
|
20
36
|
"version": "0.1.0-alpha.271",
|
|
21
37
|
"changes": [
|
|
22
|
-
"fix(daemon): unblock daemon.stop deadlock that hung `ouro up` after a CLI auto-update. When the running daemon's version drifted from the local CLI version, `ensureDaemonRunning` would send `daemon.stop` over the socket, the daemon's command handler would `await this.stop()`, and `stop()` would `await server.close()`. server.close() resolves only after every open connection has closed
|
|
38
|
+
"fix(daemon): unblock daemon.stop deadlock that hung `ouro up` after a CLI auto-update. When the running daemon's version drifted from the local CLI version, `ensureDaemonRunning` would send `daemon.stop` over the socket, the daemon's command handler would `await this.stop()`, and `stop()` would `await server.close()`. server.close() resolves only after every open connection has closed \u2014 but the calling client's connection was the ONE thing keeping the server open: its `flushResponse()` was awaiting THIS function call. Both processes sat in kevent forever. Verified live on 2026-04-08: alpha.268 daemon hung at `daemon.server_end` log line for 5+ minutes after a fresh alpha.270 ouro process sent daemon.stop, and the alpha.270 ouro process hung waiting for the response. The deadlock had existed since the original `await server.close()` line was added (2026-03-05) but was masked for weeks by the half-close behavior in socket-client: the client called `client.end()` after writing its command, which (with `allowHalfOpen: false`) caused node to auto-tear-down the server's writable side, incidentally unblocking server.close() before the response was sent. The fix in #303/#334/#339 (which removed `client.end()` and switched to `allowHalfOpen: true` to stop dropping long-running responses like agent.senseTurn) accidentally exposed the underlying deadlock. Fix: don't `await` server.close() in stop() \u2014 just fire it. Once stop() returns, the daemon.stop case returns its response, flushResponse calls connection.end(response), the connection closes, and server.close()'s pending callback fires asynchronously. Includes a new daemon-stop-deadlock.test.ts that uses real net sockets to drive daemon.stop and asserts the response comes back within 2s \u2014 the test fails (24s timeout) without the fix and passes (110ms) with it."
|
|
23
39
|
]
|
|
24
40
|
},
|
|
25
41
|
{
|
|
@@ -31,31 +47,31 @@
|
|
|
31
47
|
{
|
|
32
48
|
"version": "0.1.0-alpha.269",
|
|
33
49
|
"changes": [
|
|
34
|
-
"fix(prompt): revert alpha.267 over-engineering and correct the two targeted additions. The existing contextSection already contained the 'save to disk or lose it' teaching ('my conversation memory is ephemeral -- it resets between sessions. anything i learn about my friend, i save with save_friend_note so future me remembers.'), and toolContractsSection already told agents to call save_friend_note/diary_write before responding. alpha.267 added a third block in memoryJudgementSection about 'not just remembering between sessions' which was redundant and misplaced (memoryJudgementSection sits under 'my tools & capabilities' alongside tool routing heuristics, not nature-of-self content). That block is reverted. What alpha.267 got right and this PR keeps: (a) bodyMapSection guidance that standard folders are a floor, not a ceiling
|
|
50
|
+
"fix(prompt): revert alpha.267 over-engineering and correct the two targeted additions. The existing contextSection already contained the 'save to disk or lose it' teaching ('my conversation memory is ephemeral -- it resets between sessions. anything i learn about my friend, i save with save_friend_note so future me remembers.'), and toolContractsSection already told agents to call save_friend_note/diary_write before responding. alpha.267 added a third block in memoryJudgementSection about 'not just remembering between sessions' which was redundant and misplaced (memoryJudgementSection sits under 'my tools & capabilities' alongside tool routing heuristics, not nature-of-self content). That block is reverted. What alpha.267 got right and this PR keeps: (a) bodyMapSection guidance that standard folders are a floor, not a ceiling \u2014 bundles can have custom top-level folders (slugger's travel/ was the motivating example) \u2014 with a nudge to try the file-listing tool on the bundle root BEFORE falling back to recall, and (b) a diary-routing bullet that flags bundle-layout discoveries as worth persisting. Both had errors corrected here: alpha.267 told agents to use `list_directory` but the actual tool is `glob` (with a pattern like `*/`), and it said 'write a diary note like bundle-layout.md' but diary/ is a jsonl fact store queried via recall, not a directory of .md files \u2014 the corrected bullet says 'save the fact with diary_write'."
|
|
35
51
|
]
|
|
36
52
|
},
|
|
37
53
|
{
|
|
38
54
|
"version": "0.1.0-alpha.268",
|
|
39
55
|
"changes": [
|
|
40
|
-
"fix(identity): `loadAgentConfig` now preserves the `sync` block from agent.json. The hand-rolled object literal that constructs the typed `AgentConfig` was missing `sync` from its field list, so `agentConfig.sync` was always `undefined`, `getSyncConfig()` always returned `enabled: false`, and the entire sync code path (`preTurnPull` / `postTurnPush` in `pipeline.ts`) was dead from the moment the field was added to the type. The bug hid for weeks because `ouro status` reads `agent.json` directly via `listBundleSyncRows`, not through `loadAgentConfig`
|
|
56
|
+
"fix(identity): `loadAgentConfig` now preserves the `sync` block from agent.json. The hand-rolled object literal that constructs the typed `AgentConfig` was missing `sync` from its field list, so `agentConfig.sync` was always `undefined`, `getSyncConfig()` always returned `enabled: false`, and the entire sync code path (`preTurnPull` / `postTurnPush` in `pipeline.ts`) was dead from the moment the field was added to the type. The bug hid for weeks because `ouro status` reads `agent.json` directly via `listBundleSyncRows`, not through `loadAgentConfig` \u2014 so the per-agent Git Sync display correctly showed `enabled origin \u2192 ...` while sync did literally nothing. Slugger accumulated 5+ days of dirty files and zero `sync: post-turn update` commits before this surfaced. Also adds 3 regression tests asserting the sync block round-trips through `loadAgentConfig` (full block, partial block, missing block). The first two would have caught the original bug; without them future field additions to `AgentConfig` could repeat the pattern."
|
|
41
57
|
]
|
|
42
58
|
},
|
|
43
59
|
{
|
|
44
60
|
"version": "0.1.0-alpha.267",
|
|
45
61
|
"changes": [
|
|
46
|
-
"fix(prompt): teach agents that they do NOT 'just remember' things between sessions. Observed failure mode: slugger spent a recall scan searching diary/journal for a `travel/` folder that was right at the bundle root, then concluded 'i should know my own folder structure next time' and refused to persist the discovery because 'that's just me knowing my own home.' That is impossible
|
|
62
|
+
"fix(prompt): teach agents that they do NOT 'just remember' things between sessions. Observed failure mode: slugger spent a recall scan searching diary/journal for a `travel/` folder that was right at the bundle root, then concluded 'i should know my own folder structure next time' and refused to persist the discovery because 'that's just me knowing my own home.' That is impossible \u2014 next session is a blank slate. The agent conflated in-session realizations with persistent knowledge. Three prompt changes fix this: (1) bodyMapSection now explicitly states that standard folders are a floor, bundles MAY have custom top-level folders created by the friend over time, and the agent should `list_directory` the bundle root BEFORE reaching for `recall` when something might be in a custom location. (2) memoryJudgementSection opens with an always-on reminder that the agent does not 'just remember' anything between sessions \u2014 every session carries only (a) the prompt, (b) what's on disk, (c) what tools observe this turn \u2014 and that thoughts like 'i should know this next time' or 'i'll look there first in the future' are CUES to write a concrete diary/friend-note RIGHT NOW. Future me cannot inherit resolutions, only files. (3) memoryJudgementSection's diary-routing rules now explicitly call out bundle-layout discoveries as a thing worth persisting (e.g. to a `bundle-layout.md` diary note). Includes 3 new tests locking the directives into place so future prompt edits cannot silently drop them."
|
|
47
63
|
]
|
|
48
64
|
},
|
|
49
65
|
{
|
|
50
66
|
"version": "0.1.0-alpha.266",
|
|
51
67
|
"changes": [
|
|
52
|
-
"fix(daemon): vitest guard for production pidfile (~/.ouro-cli/daemon.pids). The pidfile path is hardcoded with no DI seam, so when a test creates a real OuroDaemon instance and calls start(), the daemon's killOrphanProcesses() reads the REAL pidfile, ps-verifies the PIDs, and SIGTERMs the production daemon's PIDs. Verified live: alpha.265 daemon (PID 64988) was killed 93s after startup by `npx vitest run` invoking daemon.start() in 6 different test files. SIGTERM forensics added in alpha.265 captured the death (parentPid=1, parentCommand=/sbin/launchd) but the killer hint was misleading
|
|
68
|
+
"fix(daemon): vitest guard for production pidfile (~/.ouro-cli/daemon.pids). The pidfile path is hardcoded with no DI seam, so when a test creates a real OuroDaemon instance and calls start(), the daemon's killOrphanProcesses() reads the REAL pidfile, ps-verifies the PIDs, and SIGTERMs the production daemon's PIDs. Verified live: alpha.265 daemon (PID 64988) was killed 93s after startup by `npx vitest run` invoking daemon.start() in 6 different test files. SIGTERM forensics added in alpha.265 captured the death (parentPid=1, parentCommand=/sbin/launchd) but the killer hint was misleading \u2014 the real culprit was the production-pidfile leak. Fix: killOrphanProcesses() and writePidfile() now short-circuit under vitest with warn-level nerves events. Tests that need to verify these functions' behavior continue to use the extracted pure helpers (parseOrphanPidsFromPs, filterPidfilePidsToActualOrphans). New unit tests verify the pidfile is unchanged after no-op calls. Same defense-in-depth pattern as the alpha.265 socket-client hardening \u2014 production-side state being touched from tests is now physically impossible from a vitest worker."
|
|
53
69
|
]
|
|
54
70
|
},
|
|
55
71
|
{
|
|
56
72
|
"version": "0.1.0-alpha.265",
|
|
57
73
|
"changes": [
|
|
58
|
-
"fix(daemon): bulletproof vitest socket leak + SIGTERM forensics. Hardens the socket-client vitest guard so production daemon socket calls (DEFAULT_DAEMON_SOCKET_PATH = /tmp/ouroboros-daemon.sock) are unconditionally blocked under vitest, regardless of __bypassVitestGuardForTests state. Cross-file leaks via the globalThis bypass flag (process-wide, leaks across concurrent test files in the same vitest worker) can no longer reach the production daemon. Test files that legitimately exercise the real socket-client transport against synthetic test socket paths (/tmp/daemon.sock) continue to work. Background: a daemon outage on 2026-04-08 was traced to a 14-call burst of leaked `inner.wake testagent` errors, signature of vitest test runs hammering the production socket
|
|
74
|
+
"fix(daemon): bulletproof vitest socket leak + SIGTERM forensics. Hardens the socket-client vitest guard so production daemon socket calls (DEFAULT_DAEMON_SOCKET_PATH = /tmp/ouroboros-daemon.sock) are unconditionally blocked under vitest, regardless of __bypassVitestGuardForTests state. Cross-file leaks via the globalThis bypass flag (process-wide, leaks across concurrent test files in the same vitest worker) can no longer reach the production daemon. Test files that legitimately exercise the real socket-client transport against synthetic test socket paths (/tmp/daemon.sock) continue to work. Background: a daemon outage on 2026-04-08 was traced to a 14-call burst of leaked `inner.wake testagent` errors, signature of vitest test runs hammering the production socket \u2014 same pattern as 1,460 historical daemon log entries.",
|
|
59
75
|
"feat(daemon): SIGTERM/SIGINT tombstone forensics. New writeDaemonTombstone path captures process.ppid, parent command via `ps -p <ppid> -o command=`, and a filtered process snapshot (only node/vitest/ouro/kill lines) at signal-driven death time. Adds a killerHint heuristic: launchd parent reparenting suggests `launchctl bootout`/KeepAlive thrash; vitest worker presence suggests test cleanup; pkill/killall presence is an explicit kill. Forensics field is parsed by readDaemonTombstone and included in the daemon.tombstone_written nerves event meta. Also caps recentCrashes at 100 entries (was 12,265 from a March 31 thrash loop).",
|
|
60
76
|
"test(daemon): contract test BYPASS_USE_ALLOWLIST flags any new file calling __bypassVitestGuardForTests outside the two known-good test files (socket-client.test.ts and daemon-cli-defaults.test.ts). Prevents future regressions of the cross-file leak vector."
|
|
61
77
|
]
|
|
@@ -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");
|
|
@@ -534,6 +535,7 @@ function createDefaultOuroCliDeps(socketPath = socket_client_1.DEFAULT_DAEMON_SO
|
|
|
534
535
|
},
|
|
535
536
|
/* v8 ignore stop */
|
|
536
537
|
syncGlobalOuroBotWrapper: ouro_bot_global_installer_1.syncGlobalOuroBotWrapper,
|
|
538
|
+
pruneDaemonLogs: logs_prune_1.pruneDaemonLogs,
|
|
537
539
|
ensureSkillManagement: skill_management_installer_1.ensureSkillManagement,
|
|
538
540
|
ensureDaemonBootPersistence: defaultEnsureDaemonBootPersistence,
|
|
539
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
|
-
|
|
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
|
|
92
|
+
const entries = [];
|
|
59
93
|
if (existsSync(logDir)) {
|
|
60
94
|
for (const name of readdirSync(logDir)) {
|
|
61
|
-
|
|
95
|
+
const parsed = parseLogFilename(name);
|
|
96
|
+
if (!parsed)
|
|
62
97
|
continue;
|
|
63
98
|
if (options.agentFilter && !name.includes(options.agentFilter))
|
|
64
99
|
continue;
|
|
65
|
-
|
|
100
|
+
entries.push({ name, parsed });
|
|
66
101
|
}
|
|
67
102
|
}
|
|
68
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -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
|
-
|
|
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,
|
package/dist/nerves/index.js
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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
|
|
110
|
-
*
|
|
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,
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
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 {
|
|
134
|
-
|
|
135
|
-
(
|
|
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,
|
|
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
|
-
|
|
152
|
-
if (bytesSinceCheck >= ROTATION_CHECK_INTERVAL_BYTES) {
|
|
304
|
+
if (bytesSinceCheck >= rotationCheckInterval) {
|
|
153
305
|
bytesSinceCheck = 0;
|
|
154
|
-
|
|
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();
|
|
@@ -282,29 +282,12 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
|
|
|
282
282
|
historyIdx.current = -1;
|
|
283
283
|
return;
|
|
284
284
|
}
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
else {
|
|
293
|
-
// Token-aware: check for image ref chip at cursor
|
|
294
|
-
const chip = (0, image_ref_navigation_1.deleteTokenAfter)(inputRef.current, cursorRef.current);
|
|
295
|
-
if (chip) {
|
|
296
|
-
updateInput(chip.text, chip.pos);
|
|
297
|
-
}
|
|
298
|
-
else {
|
|
299
|
-
const result = (0, input_keys_1.handleForwardDelete)(inputRef.current, cursorRef.current);
|
|
300
|
-
updateInput(result.text, result.cursorPos);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
historyIdx.current = -1;
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
// Backspace: delete character before cursor
|
|
307
|
-
if (key.backspace) {
|
|
285
|
+
// Backspace / Delete: Ink 3.2 maps \x7f (macOS backspace) to key.delete,
|
|
286
|
+
// and \x08 to key.backspace. Both mean "delete backward" on macOS.
|
|
287
|
+
// Only \x1b[3~ (fn+Backspace) is true forward-delete — but Ink also maps
|
|
288
|
+
// it to key.delete. We treat key.backspace OR key.delete as backspace
|
|
289
|
+
// (since \x7f is the common case), and handle forward-delete via Ctrl+D.
|
|
290
|
+
if (key.backspace || key.delete) {
|
|
308
291
|
if (cursorRef.current > 0) {
|
|
309
292
|
// Token-aware: check for image ref chip before cursor
|
|
310
293
|
const chip = (0, image_ref_navigation_1.deleteTokenBefore)(inputRef.current, cursorRef.current);
|