@aragon-agent/cli 0.5.0 → 0.5.2
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.md +158 -0
- package/README.md +254 -7
- package/dist/agent/agent-mode.d.ts +53 -0
- package/dist/agent/agent-mode.js +69 -0
- package/dist/agent/controller.d.ts +69 -0
- package/dist/agent/controller.js +154 -1
- package/dist/agent/plan-prompt.d.ts +18 -0
- package/dist/agent/plan-prompt.js +93 -0
- package/dist/agent/reducer.d.ts +14 -1
- package/dist/agent/reducer.js +9 -0
- package/dist/agent/system-prompt.d.ts +15 -0
- package/dist/agent/system-prompt.js +8 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +247 -23
- package/dist/commands/builtins.js +60 -0
- package/dist/commands/registry.d.ts +17 -0
- package/dist/config/app-paths.d.ts +71 -4
- package/dist/config/app-paths.js +139 -8
- package/dist/config/cli-commands.d.ts +43 -0
- package/dist/config/cli-commands.js +196 -0
- package/dist/config/env.d.ts +7 -1
- package/dist/config/env.js +53 -1
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.js +82 -2
- package/dist/config/migrate-home.d.ts +63 -0
- package/dist/config/migrate-home.js +165 -0
- package/dist/config/migrate-legacy-state.d.ts +10 -0
- package/dist/config/migrate-legacy-state.js +15 -6
- package/dist/config/schema.d.ts +81 -0
- package/dist/config/schema.js +85 -0
- package/dist/config/store.d.ts +30 -12
- package/dist/config/store.js +47 -35
- package/dist/input/mouse-events.d.ts +61 -0
- package/dist/input/mouse-events.js +153 -0
- package/dist/input/stdin-mouse-filter.d.ts +42 -0
- package/dist/input/stdin-mouse-filter.js +153 -0
- package/dist/logging/cli-commands.d.ts +25 -0
- package/dist/logging/cli-commands.js +221 -0
- package/dist/logging/file-sink.d.ts +114 -0
- package/dist/logging/file-sink.js +326 -0
- package/dist/logging/install.d.ts +163 -0
- package/dist/logging/install.js +302 -0
- package/dist/logging/levels.d.ts +40 -0
- package/dist/logging/levels.js +59 -0
- package/dist/logging/logger.d.ts +110 -0
- package/dist/logging/logger.js +237 -0
- package/dist/logging/redact.d.ts +28 -0
- package/dist/logging/redact.js +90 -0
- package/dist/logging/secret-registry.d.ts +30 -0
- package/dist/logging/secret-registry.js +55 -0
- package/dist/skills/paths.d.ts +2 -4
- package/dist/skills/paths.js +7 -9
- package/dist/tools/human-input.d.ts +179 -0
- package/dist/tools/human-input.js +227 -0
- package/dist/tools/index.d.ts +35 -3
- package/dist/tools/index.js +96 -9
- package/dist/tools/plan-tools.d.ts +64 -0
- package/dist/tools/plan-tools.js +198 -0
- package/dist/ui/App.d.ts +12 -1
- package/dist/ui/App.js +223 -17
- package/dist/ui/Composer.d.ts +9 -1
- package/dist/ui/Composer.js +26 -7
- package/dist/ui/ModeChip.d.ts +29 -0
- package/dist/ui/ModeChip.js +15 -0
- package/dist/ui/PromptInput.d.ts +22 -1
- package/dist/ui/PromptInput.js +49 -2
- package/dist/ui/StatusBar.d.ts +15 -0
- package/dist/ui/StatusBar.js +10 -2
- package/dist/ui/entries/ToolPreview.d.ts +2 -0
- package/dist/ui/entries/ToolPreview.js +64 -0
- package/dist/ui/glyphs.d.ts +21 -0
- package/dist/ui/glyphs.js +12 -0
- package/dist/ui/layout/AppShell.d.ts +10 -1
- package/dist/ui/layout/AppShell.js +38 -3
- package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
- package/dist/ui/layout/ScrollIndicator.js +15 -0
- package/dist/ui/layout/ScrollViewport.d.ts +8 -2
- package/dist/ui/layout/ScrollViewport.js +24 -4
- package/dist/ui/layout/regions.d.ts +55 -0
- package/dist/ui/layout/regions.js +52 -0
- package/dist/ui/layout/scroll-indicator.d.ts +27 -0
- package/dist/ui/layout/scroll-indicator.js +35 -0
- package/dist/ui/layout/scroll.d.ts +9 -0
- package/dist/ui/layout/scroll.js +23 -0
- package/dist/ui/layout/wrap-rows.d.ts +24 -0
- package/dist/ui/layout/wrap-rows.js +74 -0
- package/dist/ui/overlays/HelpOverlay.js +15 -0
- package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
- package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
- package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
- package/dist/ui/overlays/QuestionOverlay.js +193 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
- package/dist/ui/overlays/SettingsScreen.js +8 -1
- package/dist/ui/screen.d.ts +14 -1
- package/dist/ui/screen.js +37 -3
- package/dist/ui/use-startup-notices.d.ts +25 -0
- package/dist/ui/use-startup-notices.js +64 -0
- package/dist/ui/use-wheel-routing.d.ts +68 -0
- package/dist/ui/use-wheel-routing.js +139 -0
- package/package.json +3 -2
- package/runtime/insecure-tls-warning.cjs +62 -0
- package/runtime/insecure-tls-warning.d.cts +5 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aragon logs path | list | tail | clear | open` — the non-interactive surface.
|
|
3
|
+
*
|
|
4
|
+
* Exit codes follow the existing convention: 0 success · 1 run-time failure ·
|
|
5
|
+
* 2 usage error.
|
|
6
|
+
*
|
|
7
|
+
* THE WHOLE GROUP IS EXEMPT from the "every subcommand records one info line"
|
|
8
|
+
* rule, and that is a correctness requirement rather than tidiness. Recording a
|
|
9
|
+
* line opens today's log file, and Node opens files without
|
|
10
|
+
* `FILE_SHARE_DELETE`; on Windows — the platform this feature was asked for —
|
|
11
|
+
* `aragon logs clear --yes` would then be unable to delete the very file its own
|
|
12
|
+
* announcement had just created. These commands are the log's operations
|
|
13
|
+
* surface; giving them observable side effects on the log is backwards.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, readdirSync, rmSync, statSync, unwatchFile, watchFile, } from 'node:fs';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import process from 'node:process';
|
|
19
|
+
import { getLogsDir } from '../config/app-paths.js';
|
|
20
|
+
import { loadConfig } from '../config/load.js';
|
|
21
|
+
import { isLogFileName } from './file-sink.js';
|
|
22
|
+
import { getLogger } from './logger.js';
|
|
23
|
+
import { LOG_LEVELS, isLogLevel } from './levels.js';
|
|
24
|
+
const EXIT_OK = 0;
|
|
25
|
+
const EXIT_RUNTIME = 1;
|
|
26
|
+
const EXIT_USAGE = 2;
|
|
27
|
+
const DEFAULT_TAIL_LINES = 100;
|
|
28
|
+
/** `watchFile` poll interval for `--follow` (Q2). */
|
|
29
|
+
const FOLLOW_POLL_MS = 500;
|
|
30
|
+
/** The effective log directory for this run (flag › env › file › default). */
|
|
31
|
+
function resolveLogsDir(flags) {
|
|
32
|
+
const configured = loadConfig(flags).log.dir.trim();
|
|
33
|
+
return configured.length > 0 ? configured : getLogsDir();
|
|
34
|
+
}
|
|
35
|
+
function listLogFiles(dir) {
|
|
36
|
+
if (!existsSync(dir))
|
|
37
|
+
return [];
|
|
38
|
+
try {
|
|
39
|
+
return readdirSync(dir)
|
|
40
|
+
.filter(isLogFileName)
|
|
41
|
+
.sort();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function runLogsCommand(subcommand, opts, flags) {
|
|
48
|
+
const dir = resolveLogsDir(flags);
|
|
49
|
+
switch (subcommand) {
|
|
50
|
+
case 'path':
|
|
51
|
+
return runPath(dir);
|
|
52
|
+
case 'list':
|
|
53
|
+
return runList(dir);
|
|
54
|
+
case 'tail':
|
|
55
|
+
return runTail(dir, opts);
|
|
56
|
+
case 'clear':
|
|
57
|
+
return runClear(dir, opts);
|
|
58
|
+
case 'open':
|
|
59
|
+
return runOpen(dir);
|
|
60
|
+
default:
|
|
61
|
+
process.stderr.write(`Unknown logs subcommand "${subcommand}". Try: path | list | tail | clear | open.\n`);
|
|
62
|
+
return EXIT_USAGE;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function runPath(dir) {
|
|
66
|
+
const logger = getLogger();
|
|
67
|
+
process.stdout.write(`${logger.currentLogPath()}\n`);
|
|
68
|
+
process.stdout.write(`directory: ${dir}\n`);
|
|
69
|
+
// `debug` and `trace` write prompt and tool output verbatim; a user about to
|
|
70
|
+
// attach a log to a bug report should be told that here, not discover it.
|
|
71
|
+
process.stdout.write('level: ' + logger.level + ' (debug/trace record prompt and tool content)\n');
|
|
72
|
+
if (logger.sinkDisabled) {
|
|
73
|
+
process.stdout.write(`WARNING: logging is disabled after write failures: ${logger.sinkError}\n`);
|
|
74
|
+
}
|
|
75
|
+
return EXIT_OK;
|
|
76
|
+
}
|
|
77
|
+
function runList(dir) {
|
|
78
|
+
const files = listLogFiles(dir);
|
|
79
|
+
if (files.length === 0) {
|
|
80
|
+
process.stdout.write(`No log files in ${dir}.\n`);
|
|
81
|
+
return EXIT_OK;
|
|
82
|
+
}
|
|
83
|
+
for (const name of files) {
|
|
84
|
+
try {
|
|
85
|
+
const stat = statSync(join(dir, name));
|
|
86
|
+
process.stdout.write(`${name} ${stat.size} bytes ${stat.mtime.toISOString()}\n`);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
process.stdout.write(`${name} (unreadable)\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return EXIT_OK;
|
|
93
|
+
}
|
|
94
|
+
/** Render one JSONL record as a human-readable line; pass unparseable ones through. */
|
|
95
|
+
function renderRecord(line) {
|
|
96
|
+
try {
|
|
97
|
+
const record = JSON.parse(line);
|
|
98
|
+
const data = record.data ? ` ${JSON.stringify(record.data)}` : '';
|
|
99
|
+
const level = (record.lv ?? '?').padEnd(5);
|
|
100
|
+
return `${record.ts ?? ''} ${level} ${record.scope ?? '-'}: ${record.msg ?? ''}${data}`;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return line;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function passesLevel(line, min) {
|
|
107
|
+
if (!min)
|
|
108
|
+
return true;
|
|
109
|
+
try {
|
|
110
|
+
const record = JSON.parse(line);
|
|
111
|
+
if (!isLogLevel(record.lv))
|
|
112
|
+
return true;
|
|
113
|
+
return LOG_LEVELS[record.lv] <= LOG_LEVELS[min];
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function printLines(lines, opts, min) {
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
if (line.trim().length === 0)
|
|
122
|
+
continue;
|
|
123
|
+
if (!passesLevel(line, min))
|
|
124
|
+
continue;
|
|
125
|
+
process.stdout.write(`${opts.json ? line : renderRecord(line)}\n`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function runTail(dir, opts) {
|
|
129
|
+
const files = listLogFiles(dir);
|
|
130
|
+
const target = files.length > 0 ? join(dir, files[files.length - 1]) : null;
|
|
131
|
+
if (!target) {
|
|
132
|
+
process.stdout.write(`No log files in ${dir}.\n`);
|
|
133
|
+
return EXIT_OK;
|
|
134
|
+
}
|
|
135
|
+
if (opts.level !== undefined && !isLogLevel(opts.level)) {
|
|
136
|
+
process.stderr.write(`Unknown level "${opts.level}".\n`);
|
|
137
|
+
return EXIT_USAGE;
|
|
138
|
+
}
|
|
139
|
+
const min = opts.level;
|
|
140
|
+
const count = Number.parseInt(opts.lines ?? '', 10);
|
|
141
|
+
const wanted = Number.isFinite(count) && count > 0 ? count : DEFAULT_TAIL_LINES;
|
|
142
|
+
let consumed = 0;
|
|
143
|
+
try {
|
|
144
|
+
const raw = readFileSync(target, 'utf-8');
|
|
145
|
+
consumed = raw.length;
|
|
146
|
+
printLines(raw.split('\n').slice(-wanted), opts, min);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
process.stderr.write(`Could not read ${target}: ${err instanceof Error ? err.message : err}\n`);
|
|
150
|
+
return EXIT_RUNTIME;
|
|
151
|
+
}
|
|
152
|
+
if (!opts.follow)
|
|
153
|
+
return EXIT_OK;
|
|
154
|
+
// `watchFile` (polling) rather than `fs.watch`: these files are ROTATED by
|
|
155
|
+
// rename, which is exactly the case `fs.watch` reports inconsistently across
|
|
156
|
+
// platforms. Half a second of latency is invisible to someone reading a log.
|
|
157
|
+
watchFile(target, { interval: FOLLOW_POLL_MS }, () => {
|
|
158
|
+
try {
|
|
159
|
+
const raw = readFileSync(target, 'utf-8');
|
|
160
|
+
if (raw.length < consumed)
|
|
161
|
+
consumed = 0; // truncated or rotated underneath us
|
|
162
|
+
printLines(raw.slice(consumed).split('\n'), opts, min);
|
|
163
|
+
consumed = raw.length;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// The file may vanish mid-rotation; the next poll picks the new one up.
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
process.on('SIGINT', () => unwatchFile(target));
|
|
170
|
+
return EXIT_OK;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Delete every log file.
|
|
174
|
+
*
|
|
175
|
+
* Closes this process's own sink first — otherwise the day's file is held open
|
|
176
|
+
* by the very command trying to remove it. Failures are reported PER FILE
|
|
177
|
+
* rather than aborting: another `aragon` window holding one file open should
|
|
178
|
+
* not stop the other nine from being cleared.
|
|
179
|
+
*/
|
|
180
|
+
function runClear(dir, opts) {
|
|
181
|
+
if (!opts.yes) {
|
|
182
|
+
process.stderr.write('Refusing to delete log files without --yes.\n');
|
|
183
|
+
return EXIT_USAGE;
|
|
184
|
+
}
|
|
185
|
+
getLogger().closeSink();
|
|
186
|
+
const files = listLogFiles(dir);
|
|
187
|
+
const blocked = [];
|
|
188
|
+
let removed = 0;
|
|
189
|
+
for (const name of files) {
|
|
190
|
+
try {
|
|
191
|
+
rmSync(join(dir, name), { force: true });
|
|
192
|
+
removed += 1;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
blocked.push(name);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
process.stdout.write(`Deleted ${removed} log file(s).\n`);
|
|
199
|
+
if (blocked.length === 0)
|
|
200
|
+
return EXIT_OK;
|
|
201
|
+
process.stderr.write(`In use, not deleted: ${blocked.join(', ')}\n`);
|
|
202
|
+
return EXIT_RUNTIME;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Open the log directory in the platform file manager.
|
|
206
|
+
*
|
|
207
|
+
* A convenience, so a failure degrades to printing the path and still exits 0 —
|
|
208
|
+
* being unable to launch a file manager is not an error in a logging command.
|
|
209
|
+
*/
|
|
210
|
+
function runOpen(dir) {
|
|
211
|
+
const opener = process.platform === 'win32' ? 'explorer' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
212
|
+
try {
|
|
213
|
+
const child = spawn(opener, [dir], { stdio: 'ignore', detached: true });
|
|
214
|
+
child.on('error', () => process.stdout.write(`${dir}\n`));
|
|
215
|
+
child.unref();
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
process.stdout.write(`${dir}\n`);
|
|
219
|
+
}
|
|
220
|
+
return EXIT_OK;
|
|
221
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file sink — a memory queue in front of ONE synchronous append fd.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT `createWriteStream` (design P1-4). Three reasons, each fatal on its
|
|
5
|
+
* own:
|
|
6
|
+
* - a stream's internal buffer is DISCARDED by `process.exit()`, and the
|
|
7
|
+
* records still sitting in it during a crash are the ones worth having;
|
|
8
|
+
* - an exit-path `appendFileSync` plus a steady-state stream are two writers
|
|
9
|
+
* on one file with no defined ordering between them;
|
|
10
|
+
* - a stream reports write failures through an async `'error'` event, which
|
|
11
|
+
* THROWS when nobody is listening — so "make the log directory read-only"
|
|
12
|
+
* would take the whole CLI down instead of degrading.
|
|
13
|
+
*
|
|
14
|
+
* With `openSync` + `writeSync` every failure is a synchronous throw one
|
|
15
|
+
* `try/catch` away, and `flushSync()` and the steady-state flush are literally
|
|
16
|
+
* the same code path. `O_APPEND` keeps concurrent `aragon` processes from
|
|
17
|
+
* truncating each other, and one batched `writeSync` stays far below any size
|
|
18
|
+
* that could tear a line.
|
|
19
|
+
*/
|
|
20
|
+
export interface FileSinkOptions {
|
|
21
|
+
dir: string;
|
|
22
|
+
maxFileBytes: number;
|
|
23
|
+
maxFiles: number;
|
|
24
|
+
/**
|
|
25
|
+
* Renders the "records were dropped" warning line. Not a persisted setting —
|
|
26
|
+
* the sink handles LINES and has no session id or clock of its own, so the
|
|
27
|
+
* logger supplies the rendering and the sink decides when it is needed.
|
|
28
|
+
*/
|
|
29
|
+
formatDropNotice?: (count: number) => string;
|
|
30
|
+
/** Called once, when the sink gives up on writing. */
|
|
31
|
+
onFailure?: (reason: string) => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `YYYY-MM-DD` in LOCAL time.
|
|
35
|
+
*
|
|
36
|
+
* Local, not UTC, and the record timestamps use the same clock deliberately: a
|
|
37
|
+
* user west of UTC would otherwise find an evening's records filed under the
|
|
38
|
+
* previous local day and stamped with the next one, which makes "open today's
|
|
39
|
+
* log" a lie twice over.
|
|
40
|
+
*/
|
|
41
|
+
export declare function localDateStamp(date?: Date): string;
|
|
42
|
+
/**
|
|
43
|
+
* Delete the oldest files until at most `maxFiles` remain.
|
|
44
|
+
*
|
|
45
|
+
* A STANDALONE STEP, called on three occasions — first write of the process,
|
|
46
|
+
* midnight roll-over, and size rotation — because retention that only ran
|
|
47
|
+
* during size rotation would never fire for the many users who never reach
|
|
48
|
+
* `maxFileBytes`: one file per day, forever, under a setting that says 10.
|
|
49
|
+
*
|
|
50
|
+
* Never throws. On Windows another `aragon` process may hold a handle on a file
|
|
51
|
+
* we would like to remove; that is a reason to skip it, not to fail a write.
|
|
52
|
+
*/
|
|
53
|
+
export declare function enforceRetention(dir: string, maxFiles: number): void;
|
|
54
|
+
export declare class FileSink {
|
|
55
|
+
private options;
|
|
56
|
+
private queue;
|
|
57
|
+
private dropped;
|
|
58
|
+
private fd;
|
|
59
|
+
private currentFile;
|
|
60
|
+
private currentDay;
|
|
61
|
+
private currentSize;
|
|
62
|
+
private timer;
|
|
63
|
+
private failures;
|
|
64
|
+
private failed;
|
|
65
|
+
private failureReason;
|
|
66
|
+
private retentionRun;
|
|
67
|
+
constructor(options: FileSinkOptions);
|
|
68
|
+
get disabled(): boolean;
|
|
69
|
+
get lastError(): string | undefined;
|
|
70
|
+
/** The file records are going to right now (may not exist until first write). */
|
|
71
|
+
get currentPath(): string;
|
|
72
|
+
/** Enqueue one already-serialised line (including its trailing newline). */
|
|
73
|
+
write(line: string): void;
|
|
74
|
+
/** Threshold/timer flush. Identical to `flushSync` — see the module header. */
|
|
75
|
+
flush(): void;
|
|
76
|
+
/** Idempotent: safe to call from `exit`, a signal handler and a crash handler. */
|
|
77
|
+
flushSync(): void;
|
|
78
|
+
close(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Apply new settings. Changing the directory closes the current fd so the next
|
|
81
|
+
* write lazily opens the new one; the sink never eagerly creates a directory
|
|
82
|
+
* the user may never write a record into.
|
|
83
|
+
*/
|
|
84
|
+
reconfigure(options: FileSinkOptions): void;
|
|
85
|
+
private takeBatch;
|
|
86
|
+
/** Open (or roll) the target file so `incomingBytes` can be appended to it. */
|
|
87
|
+
private prepareTarget;
|
|
88
|
+
private openTarget;
|
|
89
|
+
/**
|
|
90
|
+
* flush → close → rename → open → retention, in that order.
|
|
91
|
+
*
|
|
92
|
+
* `closeSync` MUST precede `renameSync`: on Windows renaming a file the
|
|
93
|
+
* process still holds a handle on fails with EPERM. A failed rename is
|
|
94
|
+
* swallowed and writing continues into the original file — two instances
|
|
95
|
+
* rotating at once should cost an extra small file, not the log itself.
|
|
96
|
+
*/
|
|
97
|
+
private rotateBySize;
|
|
98
|
+
/** Highest existing `aragon-<day>.<n>.log` index, plus one. */
|
|
99
|
+
private nextRotationIndex;
|
|
100
|
+
private closeFd;
|
|
101
|
+
/**
|
|
102
|
+
* Logging failure must never become CLI failure, and must never write to the
|
|
103
|
+
* terminal itself (invariant I-4 — a stray line permanently misaligns the
|
|
104
|
+
* full-screen frame). The owner is told once through `onFailure`, which routes
|
|
105
|
+
* to a toast when the TUI is mounted and to stderr when it is not.
|
|
106
|
+
*/
|
|
107
|
+
private recordFailure;
|
|
108
|
+
private startTimer;
|
|
109
|
+
private stopTimer;
|
|
110
|
+
}
|
|
111
|
+
/** Exposed for the CLI's `logs` commands, which list the same file family. */
|
|
112
|
+
export declare function isLogFileName(name: string): boolean;
|
|
113
|
+
/** The log file the CURRENT day would use inside a log directory. */
|
|
114
|
+
export declare function currentLogFileName(date?: Date): string;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file sink — a memory queue in front of ONE synchronous append fd.
|
|
3
|
+
*
|
|
4
|
+
* WHY NOT `createWriteStream` (design P1-4). Three reasons, each fatal on its
|
|
5
|
+
* own:
|
|
6
|
+
* - a stream's internal buffer is DISCARDED by `process.exit()`, and the
|
|
7
|
+
* records still sitting in it during a crash are the ones worth having;
|
|
8
|
+
* - an exit-path `appendFileSync` plus a steady-state stream are two writers
|
|
9
|
+
* on one file with no defined ordering between them;
|
|
10
|
+
* - a stream reports write failures through an async `'error'` event, which
|
|
11
|
+
* THROWS when nobody is listening — so "make the log directory read-only"
|
|
12
|
+
* would take the whole CLI down instead of degrading.
|
|
13
|
+
*
|
|
14
|
+
* With `openSync` + `writeSync` every failure is a synchronous throw one
|
|
15
|
+
* `try/catch` away, and `flushSync()` and the steady-state flush are literally
|
|
16
|
+
* the same code path. `O_APPEND` keeps concurrent `aragon` processes from
|
|
17
|
+
* truncating each other, and one batched `writeSync` stays far below any size
|
|
18
|
+
* that could tear a line.
|
|
19
|
+
*/
|
|
20
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, writeSync, } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
/** Flush triggers: whichever of the two comes first. */
|
|
23
|
+
const FLUSH_AT_RECORDS = 64;
|
|
24
|
+
const FLUSH_INTERVAL_MS = 200;
|
|
25
|
+
/**
|
|
26
|
+
* Hard queue ceiling. On overflow the NEWEST records are dropped and the head
|
|
27
|
+
* is kept: in a flood, the oldest lines are the ones that explain how it
|
|
28
|
+
* started, so discarding them first would throw away the log's whole value at
|
|
29
|
+
* the moment it finally has some.
|
|
30
|
+
*
|
|
31
|
+
* A backstop rather than a working limit — `write()` flushes at
|
|
32
|
+
* `FLUSH_AT_RECORDS` and the flush is synchronous, so the queue does not
|
|
33
|
+
* normally grow past that. It stays because the batching threshold is a tuning
|
|
34
|
+
* knob and an unbounded array behind one is not something to rediscover later.
|
|
35
|
+
* The drop COUNTER, by contrast, is reached routinely: see `flushSync`, where a
|
|
36
|
+
* failed write would otherwise discard its batch without a word.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_QUEUED_RECORDS = 5000;
|
|
39
|
+
/** Consecutive write failures after which the sink stops trying (§4.4.6). */
|
|
40
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
41
|
+
const FILE_PREFIX = 'aragon-';
|
|
42
|
+
const FILE_SUFFIX = '.log';
|
|
43
|
+
/**
|
|
44
|
+
* `YYYY-MM-DD` in LOCAL time.
|
|
45
|
+
*
|
|
46
|
+
* Local, not UTC, and the record timestamps use the same clock deliberately: a
|
|
47
|
+
* user west of UTC would otherwise find an evening's records filed under the
|
|
48
|
+
* previous local day and stamped with the next one, which makes "open today's
|
|
49
|
+
* log" a lie twice over.
|
|
50
|
+
*/
|
|
51
|
+
export function localDateStamp(date = new Date()) {
|
|
52
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
53
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Delete the oldest files until at most `maxFiles` remain.
|
|
57
|
+
*
|
|
58
|
+
* A STANDALONE STEP, called on three occasions — first write of the process,
|
|
59
|
+
* midnight roll-over, and size rotation — because retention that only ran
|
|
60
|
+
* during size rotation would never fire for the many users who never reach
|
|
61
|
+
* `maxFileBytes`: one file per day, forever, under a setting that says 10.
|
|
62
|
+
*
|
|
63
|
+
* Never throws. On Windows another `aragon` process may hold a handle on a file
|
|
64
|
+
* we would like to remove; that is a reason to skip it, not to fail a write.
|
|
65
|
+
*/
|
|
66
|
+
export function enforceRetention(dir, maxFiles) {
|
|
67
|
+
try {
|
|
68
|
+
const files = readdirSync(dir)
|
|
69
|
+
.filter((name) => name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX))
|
|
70
|
+
.map((name) => {
|
|
71
|
+
const full = join(dir, name);
|
|
72
|
+
try {
|
|
73
|
+
return { full, mtime: statSync(full).mtimeMs };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
.filter((entry) => entry !== null)
|
|
80
|
+
.sort((a, b) => a.mtime - b.mtime);
|
|
81
|
+
for (const entry of files.slice(0, Math.max(0, files.length - maxFiles))) {
|
|
82
|
+
try {
|
|
83
|
+
rmSync(entry.full, { force: true });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Locked by another instance. Retention is best-effort by design.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Directory does not exist yet, or is unreadable. Nothing to retain.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export class FileSink {
|
|
95
|
+
options;
|
|
96
|
+
queue = [];
|
|
97
|
+
dropped = 0;
|
|
98
|
+
fd = null;
|
|
99
|
+
currentFile = null;
|
|
100
|
+
currentDay = '';
|
|
101
|
+
currentSize = 0;
|
|
102
|
+
timer = null;
|
|
103
|
+
failures = 0;
|
|
104
|
+
failed = false;
|
|
105
|
+
failureReason;
|
|
106
|
+
retentionRun = false;
|
|
107
|
+
constructor(options) {
|
|
108
|
+
this.options = { ...options };
|
|
109
|
+
}
|
|
110
|
+
get disabled() {
|
|
111
|
+
return this.failed;
|
|
112
|
+
}
|
|
113
|
+
get lastError() {
|
|
114
|
+
return this.failureReason;
|
|
115
|
+
}
|
|
116
|
+
/** The file records are going to right now (may not exist until first write). */
|
|
117
|
+
get currentPath() {
|
|
118
|
+
if (this.currentFile)
|
|
119
|
+
return this.currentFile;
|
|
120
|
+
return join(this.options.dir, `${FILE_PREFIX}${localDateStamp()}${FILE_SUFFIX}`);
|
|
121
|
+
}
|
|
122
|
+
/** Enqueue one already-serialised line (including its trailing newline). */
|
|
123
|
+
write(line) {
|
|
124
|
+
if (this.failed)
|
|
125
|
+
return;
|
|
126
|
+
if (this.queue.length >= MAX_QUEUED_RECORDS) {
|
|
127
|
+
this.dropped += 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.queue.push(line);
|
|
131
|
+
if (this.queue.length >= FLUSH_AT_RECORDS) {
|
|
132
|
+
this.flushSync();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.startTimer();
|
|
136
|
+
}
|
|
137
|
+
/** Threshold/timer flush. Identical to `flushSync` — see the module header. */
|
|
138
|
+
flush() {
|
|
139
|
+
this.flushSync();
|
|
140
|
+
}
|
|
141
|
+
/** Idempotent: safe to call from `exit`, a signal handler and a crash handler. */
|
|
142
|
+
flushSync() {
|
|
143
|
+
if (this.queue.length === 0 && this.dropped === 0)
|
|
144
|
+
return;
|
|
145
|
+
if (this.failed) {
|
|
146
|
+
this.queue = [];
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const pending = this.queue.length;
|
|
150
|
+
const droppedBefore = this.dropped;
|
|
151
|
+
const batch = this.takeBatch();
|
|
152
|
+
try {
|
|
153
|
+
this.prepareTarget(Buffer.byteLength(batch));
|
|
154
|
+
writeSync(this.fd, batch);
|
|
155
|
+
this.currentSize += Buffer.byteLength(batch);
|
|
156
|
+
this.failures = 0;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
// The batch is gone — retrying it would need the queue to grow unbounded
|
|
160
|
+
// behind a directory that may never become writable. But it is ACCOUNTED
|
|
161
|
+
// FOR: the count (including the notice this batch was carrying) rides
|
|
162
|
+
// along to the next flush that succeeds. Silently losing records is the
|
|
163
|
+
// one thing a logger must not do.
|
|
164
|
+
this.dropped = droppedBefore + pending;
|
|
165
|
+
this.recordFailure(err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
close() {
|
|
169
|
+
this.flushSync();
|
|
170
|
+
this.stopTimer();
|
|
171
|
+
this.closeFd();
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Apply new settings. Changing the directory closes the current fd so the next
|
|
175
|
+
* write lazily opens the new one; the sink never eagerly creates a directory
|
|
176
|
+
* the user may never write a record into.
|
|
177
|
+
*/
|
|
178
|
+
reconfigure(options) {
|
|
179
|
+
const dirChanged = options.dir !== this.options.dir;
|
|
180
|
+
if (dirChanged) {
|
|
181
|
+
this.flushSync();
|
|
182
|
+
this.closeFd();
|
|
183
|
+
this.retentionRun = false;
|
|
184
|
+
}
|
|
185
|
+
this.options = { ...this.options, ...options };
|
|
186
|
+
}
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
// Internals
|
|
189
|
+
// -------------------------------------------------------------------------
|
|
190
|
+
takeBatch() {
|
|
191
|
+
const lines = this.queue;
|
|
192
|
+
this.queue = [];
|
|
193
|
+
if (this.dropped > 0) {
|
|
194
|
+
const notice = this.options.formatDropNotice?.(this.dropped);
|
|
195
|
+
this.dropped = 0;
|
|
196
|
+
// Appended, not prepended: it describes what happened to the records that
|
|
197
|
+
// would have followed these, and silence about a drop is unacceptable.
|
|
198
|
+
if (notice)
|
|
199
|
+
lines.push(notice);
|
|
200
|
+
}
|
|
201
|
+
return lines.join('');
|
|
202
|
+
}
|
|
203
|
+
/** Open (or roll) the target file so `incomingBytes` can be appended to it. */
|
|
204
|
+
prepareTarget(incomingBytes) {
|
|
205
|
+
const day = localDateStamp();
|
|
206
|
+
if (this.fd !== null && day !== this.currentDay) {
|
|
207
|
+
// Midnight: yesterday's file keeps its own name, so there is nothing to
|
|
208
|
+
// rename — just close it and open today's. Retention runs here because
|
|
209
|
+
// this is the path a user who never hits `maxFileBytes` takes every day.
|
|
210
|
+
this.closeFd();
|
|
211
|
+
this.openTarget(day);
|
|
212
|
+
enforceRetention(this.options.dir, this.options.maxFiles);
|
|
213
|
+
}
|
|
214
|
+
if (this.fd === null) {
|
|
215
|
+
this.openTarget(day);
|
|
216
|
+
if (!this.retentionRun) {
|
|
217
|
+
this.retentionRun = true;
|
|
218
|
+
enforceRetention(this.options.dir, this.options.maxFiles);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (this.currentSize + incomingBytes > this.options.maxFileBytes) {
|
|
222
|
+
this.rotateBySize(day);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
openTarget(day) {
|
|
226
|
+
mkdirSync(this.options.dir, { recursive: true, mode: 0o700 });
|
|
227
|
+
const file = join(this.options.dir, `${FILE_PREFIX}${day}${FILE_SUFFIX}`);
|
|
228
|
+
this.fd = openSync(file, 'a', 0o600);
|
|
229
|
+
this.currentFile = file;
|
|
230
|
+
this.currentDay = day;
|
|
231
|
+
this.currentSize = existsSync(file) ? statSync(file).size : 0;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* flush → close → rename → open → retention, in that order.
|
|
235
|
+
*
|
|
236
|
+
* `closeSync` MUST precede `renameSync`: on Windows renaming a file the
|
|
237
|
+
* process still holds a handle on fails with EPERM. A failed rename is
|
|
238
|
+
* swallowed and writing continues into the original file — two instances
|
|
239
|
+
* rotating at once should cost an extra small file, not the log itself.
|
|
240
|
+
*/
|
|
241
|
+
rotateBySize(day) {
|
|
242
|
+
const previous = this.currentFile;
|
|
243
|
+
const index = this.nextRotationIndex(day);
|
|
244
|
+
const target = join(this.options.dir, `${FILE_PREFIX}${day}.${index}${FILE_SUFFIX}`);
|
|
245
|
+
this.closeFd();
|
|
246
|
+
if (previous) {
|
|
247
|
+
try {
|
|
248
|
+
renameSync(previous, target);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Another instance won the race, or holds a handle. Keep writing.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
this.openTarget(day);
|
|
255
|
+
enforceRetention(this.options.dir, this.options.maxFiles);
|
|
256
|
+
}
|
|
257
|
+
/** Highest existing `aragon-<day>.<n>.log` index, plus one. */
|
|
258
|
+
nextRotationIndex(day) {
|
|
259
|
+
const prefix = `${FILE_PREFIX}${day}.`;
|
|
260
|
+
let max = 0;
|
|
261
|
+
try {
|
|
262
|
+
for (const name of readdirSync(this.options.dir)) {
|
|
263
|
+
if (!name.startsWith(prefix) || !name.endsWith(FILE_SUFFIX))
|
|
264
|
+
continue;
|
|
265
|
+
const n = Number.parseInt(name.slice(prefix.length, name.length - FILE_SUFFIX.length), 10);
|
|
266
|
+
if (Number.isFinite(n) && n > max)
|
|
267
|
+
max = n;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Unreadable directory: index 1 is as good a guess as any.
|
|
272
|
+
}
|
|
273
|
+
return max + 1;
|
|
274
|
+
}
|
|
275
|
+
closeFd() {
|
|
276
|
+
if (this.fd === null)
|
|
277
|
+
return;
|
|
278
|
+
try {
|
|
279
|
+
closeSync(this.fd);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Already closed by a racing handler; nothing to do.
|
|
283
|
+
}
|
|
284
|
+
this.fd = null;
|
|
285
|
+
this.currentFile = null;
|
|
286
|
+
this.currentSize = 0;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Logging failure must never become CLI failure, and must never write to the
|
|
290
|
+
* terminal itself (invariant I-4 — a stray line permanently misaligns the
|
|
291
|
+
* full-screen frame). The owner is told once through `onFailure`, which routes
|
|
292
|
+
* to a toast when the TUI is mounted and to stderr when it is not.
|
|
293
|
+
*/
|
|
294
|
+
recordFailure(err) {
|
|
295
|
+
this.failures += 1;
|
|
296
|
+
this.failureReason = err instanceof Error ? err.message : String(err);
|
|
297
|
+
this.closeFd();
|
|
298
|
+
if (this.failures < MAX_CONSECUTIVE_FAILURES)
|
|
299
|
+
return;
|
|
300
|
+
this.failed = true;
|
|
301
|
+
this.stopTimer();
|
|
302
|
+
this.options.onFailure?.(this.failureReason);
|
|
303
|
+
}
|
|
304
|
+
startTimer() {
|
|
305
|
+
if (this.timer)
|
|
306
|
+
return;
|
|
307
|
+
this.timer = setInterval(() => this.flushSync(), FLUSH_INTERVAL_MS);
|
|
308
|
+
// Without `unref`, an `aragon --version` that logs one line would be held
|
|
309
|
+
// open by this timer for 200ms after it had nothing left to do.
|
|
310
|
+
this.timer.unref?.();
|
|
311
|
+
}
|
|
312
|
+
stopTimer() {
|
|
313
|
+
if (!this.timer)
|
|
314
|
+
return;
|
|
315
|
+
clearInterval(this.timer);
|
|
316
|
+
this.timer = null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Exposed for the CLI's `logs` commands, which list the same file family. */
|
|
320
|
+
export function isLogFileName(name) {
|
|
321
|
+
return name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX);
|
|
322
|
+
}
|
|
323
|
+
/** The log file the CURRENT day would use inside a log directory. */
|
|
324
|
+
export function currentLogFileName(date = new Date()) {
|
|
325
|
+
return `${FILE_PREFIX}${localDateStamp(date)}${FILE_SUFFIX}`;
|
|
326
|
+
}
|