@bookedsolid/rea 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATING.md +89 -0
- package/dist/cli/audit-by-tool.d.ts +173 -0
- package/dist/cli/audit-by-tool.js +373 -0
- package/dist/cli/audit-timeline.d.ts +180 -0
- package/dist/cli/audit-timeline.js +723 -0
- package/dist/cli/audit-top-blocks.d.ts +154 -0
- package/dist/cli/audit-top-blocks.js +419 -0
- package/dist/cli/index.js +15 -0
- package/dist/config/tier-map.js +32 -0
- package/package.json +1 -1
- package/scripts/profile-hooks.mjs +377 -88
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea audit timeline [--bucket=HOUR|DAY|<DUR>] [--since=DUR] [--json]`
|
|
3
|
+
* — 0.46.0 charter item 2.
|
|
4
|
+
*
|
|
5
|
+
* Time-bucketed event counts over the audit log. Useful for spotting
|
|
6
|
+
* activity spikes ("what happened during the 3pm CI build?") and
|
|
7
|
+
* day/week cadence patterns.
|
|
8
|
+
*
|
|
9
|
+
* # Bucket sizes
|
|
10
|
+
*
|
|
11
|
+
* - `HOUR` (default) — bucket boundaries align to the UTC hour
|
|
12
|
+
* (`HH:00:00.000Z`)
|
|
13
|
+
* - `DAY` — bucket boundaries align to the UTC day
|
|
14
|
+
* (`YYYY-MM-DDT00:00:00.000Z`)
|
|
15
|
+
* - `<DUR>` (`15m`, `30m`, `1h`, `2h`, `1d`, etc) — arbitrary
|
|
16
|
+
* duration. Boundaries align to the UTC epoch (multiples of the
|
|
17
|
+
* bucket size from `1970-01-01T00:00:00Z`). The `<DUR>` form is
|
|
18
|
+
* useful for sub-hour cadence (`--bucket=15m`) or unusual cuts
|
|
19
|
+
* (`--bucket=6h` for "morning / afternoon / evening / night").
|
|
20
|
+
*
|
|
21
|
+
* Bucket boundaries are half-open `[start, end)` so a record at
|
|
22
|
+
* `15:00:00.000Z` lands in the `15:00 → 16:00` bucket, not the
|
|
23
|
+
* `14:00 → 15:00` one.
|
|
24
|
+
*
|
|
25
|
+
* # Window
|
|
26
|
+
*
|
|
27
|
+
* - `--since=DUR` with same shape as `audit summary` / `audit
|
|
28
|
+
* by-tool` (`24h`, `7d`, etc). When set, the timeline emits a
|
|
29
|
+
* bucket for every interval intersecting `[now - DUR, now]`, even
|
|
30
|
+
* zero-count ones — silence is signal too. Without `--since`,
|
|
31
|
+
* buckets are emitted only for intervals that actually contain a
|
|
32
|
+
* record (no implicit filler — we don't know the operator's
|
|
33
|
+
* intended window).
|
|
34
|
+
*
|
|
35
|
+
* # Output (default `--bucket=HOUR`, last 24h)
|
|
36
|
+
*
|
|
37
|
+
* rea audit timeline (last 24h, hourly)
|
|
38
|
+
* ──────────────────────────────────────
|
|
39
|
+
* 2026-05-16 14:00 ▁▁▁▁▁ 23 events
|
|
40
|
+
* 2026-05-16 15:00 ▁▁▁▁▁▁▁▁ 47 events
|
|
41
|
+
* 2026-05-16 16:00 ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 127 events ← peak
|
|
42
|
+
* 2026-05-16 17:00 ▁▁▁▁▁▁▁▁▁▁▁▁ 89 events
|
|
43
|
+
* …
|
|
44
|
+
*
|
|
45
|
+
* The histogram bar uses a single Unicode block char (▁) repeated
|
|
46
|
+
* proportionally to peak — chosen for terminal friendliness over the
|
|
47
|
+
* staircase forms (▁▂▃▄▅▆▇█) because the staircase forms render
|
|
48
|
+
* unevenly in many terminals and the proportional bar carries the
|
|
49
|
+
* same information at-a-glance. Bar width is capped at 32 chars so
|
|
50
|
+
* the line still fits in a typical 100-col terminal alongside the
|
|
51
|
+
* timestamp and count.
|
|
52
|
+
*
|
|
53
|
+
* Peak marker (`← peak`) sits next to the bucket with the highest
|
|
54
|
+
* count. Ties go to the first occurrence.
|
|
55
|
+
*
|
|
56
|
+
* # JSON output
|
|
57
|
+
*
|
|
58
|
+
* {
|
|
59
|
+
* "schema_version": 1,
|
|
60
|
+
* "bucket": { "raw": "HOUR", "seconds": 3600 },
|
|
61
|
+
* "window": {
|
|
62
|
+
* "seconds": 86400,
|
|
63
|
+
* "start": "2026-05-16T14:00:00.000Z",
|
|
64
|
+
* "end": "2026-05-17T14:00:00.000Z"
|
|
65
|
+
* },
|
|
66
|
+
* "buckets": [
|
|
67
|
+
* { "start": "2026-05-16T14:00:00.000Z",
|
|
68
|
+
* "end": "2026-05-16T15:00:00.000Z",
|
|
69
|
+
* "count": 23 },
|
|
70
|
+
* …
|
|
71
|
+
* ],
|
|
72
|
+
* "total_events": 287,
|
|
73
|
+
* "peak_index": 2,
|
|
74
|
+
* "files_scanned": ["/abs/path/.rea/audit.jsonl"]
|
|
75
|
+
* }
|
|
76
|
+
*/
|
|
77
|
+
import fs from 'node:fs/promises';
|
|
78
|
+
import path from 'node:path';
|
|
79
|
+
import { listRotatedAuditFiles } from './audit-specialists.js';
|
|
80
|
+
import { AuditSummarySinceError, parseDurationSeconds, } from './audit-summary.js';
|
|
81
|
+
import { AUDIT_FILE, REA_DIR, err } from './utils.js';
|
|
82
|
+
export const AUDIT_TIMELINE_SCHEMA_VERSION = 1;
|
|
83
|
+
/** Histogram bar character. See module docstring for rationale. */
|
|
84
|
+
const BAR_CHAR = '▁';
|
|
85
|
+
/** Maximum bar width in characters. */
|
|
86
|
+
const MAX_BAR_WIDTH = 32;
|
|
87
|
+
/**
|
|
88
|
+
* Hard ceiling on the number of buckets the command will produce. A
|
|
89
|
+
* `--since=7d` with `--bucket=1m` would emit 10,080 buckets — well
|
|
90
|
+
* past what a terminal renderer handles gracefully. Capping at 2000
|
|
91
|
+
* still allows `--bucket=15m --since=21d` (`~2016 buckets`) which
|
|
92
|
+
* covers the realistic ops use cases.
|
|
93
|
+
*/
|
|
94
|
+
export const MAX_BUCKETS = 2000;
|
|
95
|
+
export class AuditTimelineOptionError extends Error {
|
|
96
|
+
constructor(message) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.name = 'AuditTimelineOptionError';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve `--bucket` to a number of seconds. Accepts:
|
|
103
|
+
* - `HOUR` / `H` / `1H` (case-insensitive) → 3600
|
|
104
|
+
* - `DAY` / `D` / `1D` (case-insensitive) → 86400
|
|
105
|
+
* - duration form (`15m`, `30s`, `2h`, `1d`, `1w`) → parsed via
|
|
106
|
+
* `parseDurationSeconds` for shape compatibility with `--since`
|
|
107
|
+
*
|
|
108
|
+
* Bucket size must be >= 1 second; on the upper end we accept any
|
|
109
|
+
* value but `MAX_BUCKETS` will bound the rendered output.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveBucketSeconds(raw) {
|
|
112
|
+
const t = raw.trim();
|
|
113
|
+
if (t.length === 0) {
|
|
114
|
+
throw new AuditTimelineOptionError('--bucket: must not be empty.');
|
|
115
|
+
}
|
|
116
|
+
const upper = t.toUpperCase();
|
|
117
|
+
if (upper === 'HOUR' || upper === 'H' || upper === '1H')
|
|
118
|
+
return 3600;
|
|
119
|
+
if (upper === 'DAY' || upper === 'D' || upper === '1D')
|
|
120
|
+
return 86400;
|
|
121
|
+
// Fall through to duration shape. `parseDurationSeconds` throws
|
|
122
|
+
// `AuditSummarySinceError` on bad input; re-throw under our class.
|
|
123
|
+
try {
|
|
124
|
+
return parseDurationSeconds(t);
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
if (e instanceof AuditSummarySinceError) {
|
|
128
|
+
throw new AuditTimelineOptionError(`--bucket: ${e.message.replace(/^--since: */, '')}`);
|
|
129
|
+
}
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function resolveTimelineFileWalk(baseDir) {
|
|
134
|
+
const reaDir = path.join(baseDir, REA_DIR);
|
|
135
|
+
const currentAudit = path.join(reaDir, AUDIT_FILE);
|
|
136
|
+
const files = [];
|
|
137
|
+
const rotated = await listRotatedAuditFiles(reaDir);
|
|
138
|
+
for (const name of rotated)
|
|
139
|
+
files.push(path.join(reaDir, name));
|
|
140
|
+
try {
|
|
141
|
+
const stat = await fs.stat(currentAudit);
|
|
142
|
+
if (stat.isFile())
|
|
143
|
+
files.push(currentAudit);
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (e.code !== 'ENOENT')
|
|
147
|
+
throw e;
|
|
148
|
+
}
|
|
149
|
+
return files;
|
|
150
|
+
}
|
|
151
|
+
function parseTimestamp(raw) {
|
|
152
|
+
if (typeof raw !== 'string')
|
|
153
|
+
return null;
|
|
154
|
+
const d = new Date(raw);
|
|
155
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Align an epoch-millisecond instant DOWN to a bucket boundary of
|
|
159
|
+
* `bucketSeconds`. The boundary lattice is anchored at the UTC epoch
|
|
160
|
+
* (`1970-01-01T00:00:00Z`) so day/hour buckets fall on natural UTC
|
|
161
|
+
* boundaries and sub-hour buckets (15m / 30m / 5m) align to natural
|
|
162
|
+
* sub-hour boundaries.
|
|
163
|
+
*/
|
|
164
|
+
function alignToBucket(epochMs, bucketSeconds) {
|
|
165
|
+
const bucketMs = bucketSeconds * 1000;
|
|
166
|
+
return Math.floor(epochMs / bucketMs) * bucketMs;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Format a duration in seconds as the coarsest single-unit compact
|
|
170
|
+
* string that round-trips through `parseDurationSeconds`. Mirrors the
|
|
171
|
+
* shape `--since` accepts (`s`/`m`/`h`/`d`/`w`).
|
|
172
|
+
*
|
|
173
|
+
* 0.47.0 charter item 1: powers the helpful-error suggestion + the
|
|
174
|
+
* auto-clamp `clamped_since` field. The largest-unit pass keeps the
|
|
175
|
+
* suggestion readable — `"21d"` not `"1814400s"`.
|
|
176
|
+
*/
|
|
177
|
+
export function formatDurationCompact(seconds) {
|
|
178
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
179
|
+
return '0s';
|
|
180
|
+
const units = [
|
|
181
|
+
['w', 60 * 60 * 24 * 7],
|
|
182
|
+
['d', 60 * 60 * 24],
|
|
183
|
+
['h', 60 * 60],
|
|
184
|
+
['m', 60],
|
|
185
|
+
['s', 1],
|
|
186
|
+
];
|
|
187
|
+
for (const [unit, factor] of units) {
|
|
188
|
+
if (seconds % factor === 0) {
|
|
189
|
+
return `${String(seconds / factor)}${unit}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return `${String(seconds)}s`;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* 0.47.0 charter item 1: build a helpful error message for the
|
|
196
|
+
* MAX_BUCKETS guard. Computes a concrete "use --bucket=X" and
|
|
197
|
+
* "use --since=Y" suggestion based on the actual inputs, so the
|
|
198
|
+
* operator sees the next step inline instead of having to do the
|
|
199
|
+
* division themselves.
|
|
200
|
+
*
|
|
201
|
+
* Strategy:
|
|
202
|
+
* - "Try a wider bucket" — smallest unit from {1h, 4h, 1d, 1w} that
|
|
203
|
+
* brings projected ≤ MAX_BUCKETS. Falls back to a concrete second
|
|
204
|
+
* count if no unit fits (extreme `--since`).
|
|
205
|
+
* - "Try a narrower since" — largest multiple of the requested bucket
|
|
206
|
+
* that fits under MAX_BUCKETS, rendered compactly.
|
|
207
|
+
*
|
|
208
|
+
* The error text always includes the substrings `bucket=`, `since=`,
|
|
209
|
+
* and `Try` so test assertions can pin the shape.
|
|
210
|
+
*/
|
|
211
|
+
function bucketOverflowMessage(windowSeconds, bucketSeconds, rawBucket, rawSince, sinceImplicit) {
|
|
212
|
+
const projected = Math.ceil(windowSeconds / bucketSeconds);
|
|
213
|
+
// Candidate wider buckets, in ascending size — pick the first that fits.
|
|
214
|
+
const allCandidates = [
|
|
215
|
+
['1h', 60 * 60],
|
|
216
|
+
['4h', 4 * 60 * 60],
|
|
217
|
+
['1d', 60 * 60 * 24],
|
|
218
|
+
['1w', 60 * 60 * 24 * 7],
|
|
219
|
+
];
|
|
220
|
+
const widerCandidates = allCandidates.filter((entry) => entry[1] > bucketSeconds);
|
|
221
|
+
let bucketSuggestion = null;
|
|
222
|
+
let bucketSuggestionCount = null;
|
|
223
|
+
for (const [label, secs] of widerCandidates) {
|
|
224
|
+
// Account for alignment slack: a window of N seconds at bucket
|
|
225
|
+
// size S emits up to `ceil(N/S) + 1` buckets after alignment
|
|
226
|
+
// (lower-edge + upper-edge alignment can each contribute one
|
|
227
|
+
// extra bucket vs the naive division). Codex round-4 P2 (0.47.0):
|
|
228
|
+
// suggesting a bucket where `ceil(N/S) === MAX_BUCKETS` would
|
|
229
|
+
// still re-throw at the post-alignment guard. Use the +1
|
|
230
|
+
// worst-case so the operator's retry actually succeeds.
|
|
231
|
+
const cnt = Math.ceil(windowSeconds / secs) + 1;
|
|
232
|
+
if (cnt <= MAX_BUCKETS) {
|
|
233
|
+
bucketSuggestion = label;
|
|
234
|
+
bucketSuggestionCount = cnt;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Largest --since that fits at the requested bucket size, rendered
|
|
239
|
+
// compactly. Subtract one bucket so the suggested value survives the
|
|
240
|
+
// post-alignment guard regardless of where `now` falls on the
|
|
241
|
+
// bucket lattice — codex round-1 P2 (0.47.0): a window of exactly
|
|
242
|
+
// `MAX_BUCKETS * bucketSeconds` aligns to MAX_BUCKETS+1 buckets in
|
|
243
|
+
// the common case (`now` not already on a boundary), so the
|
|
244
|
+
// operator pasting our suggestion would hit the same error they
|
|
245
|
+
// just got. `(MAX_BUCKETS - 1) * bucketSeconds` leaves alignment
|
|
246
|
+
// slack on either edge.
|
|
247
|
+
const fittingSinceSeconds = (MAX_BUCKETS - 1) * bucketSeconds;
|
|
248
|
+
const sinceSuggestion = formatDurationCompact(fittingSinceSeconds);
|
|
249
|
+
const sinceSuggestionCount = Math.floor(fittingSinceSeconds / bucketSeconds);
|
|
250
|
+
const parts = [];
|
|
251
|
+
const reason = sinceImplicit
|
|
252
|
+
? `--since not specified; defaulting to full audit log (${rawSince}) at --bucket=${rawBucket} = ${String(projected)} buckets exceeds MAX_BUCKETS=${String(MAX_BUCKETS)}.`
|
|
253
|
+
: `--bucket=${rawBucket} × --since=${rawSince} = ${String(projected)} buckets exceeds MAX_BUCKETS=${String(MAX_BUCKETS)}.`;
|
|
254
|
+
parts.push(reason);
|
|
255
|
+
const suggestions = [];
|
|
256
|
+
if (bucketSuggestion !== null && bucketSuggestionCount !== null) {
|
|
257
|
+
suggestions.push(`--bucket=${bucketSuggestion} (${String(bucketSuggestionCount)} buckets)`);
|
|
258
|
+
}
|
|
259
|
+
suggestions.push(`--since=${sinceSuggestion} (${String(sinceSuggestionCount)} buckets)`);
|
|
260
|
+
parts.push(`Try ${suggestions.join(' or ')}.`);
|
|
261
|
+
return parts.join(' ');
|
|
262
|
+
}
|
|
263
|
+
// 0.47.0 round-3 (codex P1+P2): the pre-scan `measureLogBounds`
|
|
264
|
+
// helper was removed. The all-time auto-clamp now runs as a
|
|
265
|
+
// post-scan recovery against observed bucket keys (the only data
|
|
266
|
+
// that can't be fooled by caller-supplied timestamps or empty
|
|
267
|
+
// current-file edge cases). See the "post-scan auto-clamp" branch
|
|
268
|
+
// inside `computeAuditTimeline`.
|
|
269
|
+
/**
|
|
270
|
+
* Compute the bucketed timeline. Pure (read-only). Throws
|
|
271
|
+
* `AuditTimelineOptionError` on bad `--since` / `--bucket`; throws on
|
|
272
|
+
* unreadable rotated segments (mirror of audit-summary's stance).
|
|
273
|
+
*/
|
|
274
|
+
export async function computeAuditTimeline(options = {}) {
|
|
275
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
276
|
+
const now = options.now ?? new Date();
|
|
277
|
+
const bucketRaw = options.bucket ?? 'HOUR';
|
|
278
|
+
const bucketSeconds = resolveBucketSeconds(bucketRaw);
|
|
279
|
+
if (bucketSeconds < 1) {
|
|
280
|
+
throw new AuditTimelineOptionError(`--bucket: resolved bucket size must be >= 1 second; got ${String(bucketSeconds)}.`);
|
|
281
|
+
}
|
|
282
|
+
let windowSeconds = null;
|
|
283
|
+
let windowStart = null;
|
|
284
|
+
let windowEnd = null;
|
|
285
|
+
let clampedSince = null;
|
|
286
|
+
if (options.since !== undefined && options.since.length > 0) {
|
|
287
|
+
try {
|
|
288
|
+
windowSeconds = parseDurationSeconds(options.since);
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
if (e instanceof AuditSummarySinceError) {
|
|
292
|
+
throw new AuditTimelineOptionError(e.message);
|
|
293
|
+
}
|
|
294
|
+
throw e;
|
|
295
|
+
}
|
|
296
|
+
windowEnd = now;
|
|
297
|
+
windowStart = new Date(now.getTime() - windowSeconds * 1000);
|
|
298
|
+
// Guard against runaway bucket counts under a wide --since with a
|
|
299
|
+
// tiny --bucket. 0.47.0 charter item 1: deliver a concrete-suggestion
|
|
300
|
+
// error rather than the prior "use a larger --bucket or narrower
|
|
301
|
+
// --since" generic line — the operator should see the next step
|
|
302
|
+
// inline.
|
|
303
|
+
const projected = Math.ceil(windowSeconds / bucketSeconds);
|
|
304
|
+
if (projected > MAX_BUCKETS) {
|
|
305
|
+
throw new AuditTimelineOptionError(bucketOverflowMessage(windowSeconds, bucketSeconds, bucketRaw, options.since, false));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const files = await resolveTimelineFileWalk(baseDir);
|
|
309
|
+
// 0.47.0 charter item 2: auto-clamp on long-history repos is
|
|
310
|
+
// implemented as a POST-SCAN recovery, not a pre-scan guess. Codex
|
|
311
|
+
// round-3 P1: a pre-scan clamp based on `latestMs - earliestMs`
|
|
312
|
+
// span is wrong for the no-`--since` path, which normally emits
|
|
313
|
+
// ONLY event-bearing buckets. A sparse long-lived repo (two
|
|
314
|
+
// records a year apart at `--bucket=1h`) has a 365d span but a
|
|
315
|
+
// 2-bucket result — pre-clamping would incorrectly drop one of
|
|
316
|
+
// those events. We must let the scan see what bucket count the
|
|
317
|
+
// actual records produce, then clamp only if that exceeds
|
|
318
|
+
// MAX_BUCKETS. The clamp anchor uses the busiest in-data range
|
|
319
|
+
// (max observed timestamp + alignment buffer), so we never have
|
|
320
|
+
// to guess from disk metadata. See the post-scan branch below.
|
|
321
|
+
// Bucket key is the aligned epoch-ms boundary; value is the count.
|
|
322
|
+
const buckets = new Map();
|
|
323
|
+
let totalEvents = 0;
|
|
324
|
+
let earliestRecordMs = null;
|
|
325
|
+
let latestRecordMs = null;
|
|
326
|
+
const filesScanned = [];
|
|
327
|
+
for (const filePath of files) {
|
|
328
|
+
let raw;
|
|
329
|
+
try {
|
|
330
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
const errno = e.code;
|
|
334
|
+
if (errno === 'ENOENT')
|
|
335
|
+
continue;
|
|
336
|
+
throw new Error(`rea audit timeline: cannot read ${filePath} (${errno ?? 'unknown errno'}). ` +
|
|
337
|
+
`An unreadable audit segment may contain in-window records, so the ` +
|
|
338
|
+
`timeline would be silently incomplete. Fix permissions ` +
|
|
339
|
+
`(e.g. \`chmod u+r ${filePath}\`), or move the file out of \`.rea/\` ` +
|
|
340
|
+
`if you no longer need it.`);
|
|
341
|
+
}
|
|
342
|
+
filesScanned.push(filePath);
|
|
343
|
+
for (const line of raw.split('\n')) {
|
|
344
|
+
if (line.length === 0)
|
|
345
|
+
continue;
|
|
346
|
+
let parsed;
|
|
347
|
+
try {
|
|
348
|
+
parsed = JSON.parse(line);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const ts = parseTimestamp(parsed.timestamp);
|
|
354
|
+
if (ts === null)
|
|
355
|
+
continue;
|
|
356
|
+
if (windowStart !== null && ts < windowStart)
|
|
357
|
+
continue;
|
|
358
|
+
// Upper bound: when --since is set, also drop records strictly
|
|
359
|
+
// AFTER `now` so a future-dated record doesn't bend the
|
|
360
|
+
// peak/heat. The summary path counts them; the timeline path
|
|
361
|
+
// would have nowhere coherent to place them under a fixed-end
|
|
362
|
+
// window (their bucket falls outside the rendered range).
|
|
363
|
+
if (windowEnd !== null && ts > windowEnd)
|
|
364
|
+
continue;
|
|
365
|
+
totalEvents += 1;
|
|
366
|
+
const tsMs = ts.getTime();
|
|
367
|
+
const bucketKey = alignToBucket(tsMs, bucketSeconds);
|
|
368
|
+
buckets.set(bucketKey, (buckets.get(bucketKey) ?? 0) + 1);
|
|
369
|
+
if (earliestRecordMs === null || tsMs < earliestRecordMs)
|
|
370
|
+
earliestRecordMs = tsMs;
|
|
371
|
+
if (latestRecordMs === null || tsMs > latestRecordMs)
|
|
372
|
+
latestRecordMs = tsMs;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Determine the bucket span we'll emit.
|
|
376
|
+
// - --since set → emit every bucket from `windowStart`'s aligned
|
|
377
|
+
// boundary up through `windowEnd`'s aligned boundary, inclusive
|
|
378
|
+
// of zero-count intervals (silence is signal).
|
|
379
|
+
// - --since unset → emit only buckets that actually contained a
|
|
380
|
+
// record (no implicit filler).
|
|
381
|
+
const result = [];
|
|
382
|
+
if (windowStart !== null && windowEnd !== null) {
|
|
383
|
+
const startKey = alignToBucket(windowStart.getTime(), bucketSeconds);
|
|
384
|
+
const endKey = alignToBucket(windowEnd.getTime(), bucketSeconds);
|
|
385
|
+
const stepMs = bucketSeconds * 1000;
|
|
386
|
+
// Hard re-check after alignment — pathological inputs (huge
|
|
387
|
+
// --since, tiny --bucket) would already have failed at the
|
|
388
|
+
// projected-count guard above, but a runaway here would freeze
|
|
389
|
+
// the renderer.
|
|
390
|
+
const emit = Math.floor((endKey - startKey) / stepMs) + 1;
|
|
391
|
+
if (emit > MAX_BUCKETS) {
|
|
392
|
+
// Post-alignment overflow is a near-miss vs the pre-scan
|
|
393
|
+
// projection check (alignment can add at most one bucket on
|
|
394
|
+
// either edge). Codex round-6 P3 (0.47.0): the helpful-error
|
|
395
|
+
// builder previously recomputed the projected count from
|
|
396
|
+
// `windowSeconds` and could end up saying "= 2000 buckets
|
|
397
|
+
// exceeds MAX_BUCKETS=2000" when the actual post-alignment
|
|
398
|
+
// count was 2001. Inflate `windowSeconds` by enough to make
|
|
399
|
+
// the projection match the actual aligned emit count — that
|
|
400
|
+
// way the operator sees a consistent number, and the
|
|
401
|
+
// remediation suggestions inherit the same +1 bias.
|
|
402
|
+
const effectiveSince = clampedSince ?? (options.since ?? formatDurationCompact(windowSeconds ?? 0));
|
|
403
|
+
const reportedSeconds = Math.max(windowSeconds ?? 0, (emit - 1) * bucketSeconds + 1);
|
|
404
|
+
throw new AuditTimelineOptionError(bucketOverflowMessage(reportedSeconds, bucketSeconds, bucketRaw, effectiveSince, options.since === undefined || options.since.length === 0));
|
|
405
|
+
}
|
|
406
|
+
for (let k = startKey; k <= endKey; k += stepMs) {
|
|
407
|
+
result.push({
|
|
408
|
+
start: new Date(k).toISOString(),
|
|
409
|
+
end: new Date(k + stepMs).toISOString(),
|
|
410
|
+
count: buckets.get(k) ?? 0,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else if (buckets.size > 0) {
|
|
415
|
+
const keys = Array.from(buckets.keys()).sort((a, b) => a - b);
|
|
416
|
+
const stepMs = bucketSeconds * 1000;
|
|
417
|
+
if (keys.length > MAX_BUCKETS) {
|
|
418
|
+
// 0.47.0 charter item 2 (post-scan auto-clamp): the actual
|
|
419
|
+
// observed bucket count exceeds MAX_BUCKETS. The no-`--since`
|
|
420
|
+
// path emits only event-bearing buckets (not a zero-filled
|
|
421
|
+
// lattice), so clamping by a contiguous time window would
|
|
422
|
+
// discard most of the newest activity on SPARSE logs (codex
|
|
423
|
+
// round-5 P1: e.g. one record/day across 2001 days at
|
|
424
|
+
// --bucket=1h, a time-window clamp keeps ~83 buckets — the
|
|
425
|
+
// newest-2000-keys clamp keeps all 2000 newest event-bearing
|
|
426
|
+
// buckets). The right shape: slice the keys array to the newest
|
|
427
|
+
// MAX_BUCKETS entries directly.
|
|
428
|
+
//
|
|
429
|
+
// Codex round-3 P1: clamp on OBSERVED data, not guessed span.
|
|
430
|
+
// Codex round-3 P2: observed-max timestamp sidesteps both the
|
|
431
|
+
// empty-current-file case and the out-of-order-timestamp case.
|
|
432
|
+
// Codex round-4 P2: full MAX_BUCKETS budget (the +1 alignment
|
|
433
|
+
// slack doesn't apply when cherry-picking from observed keys).
|
|
434
|
+
const fittingBuckets = MAX_BUCKETS;
|
|
435
|
+
const kept = keys.slice(keys.length - fittingBuckets);
|
|
436
|
+
const startKey = kept[0];
|
|
437
|
+
const anchorKey = kept[kept.length - 1];
|
|
438
|
+
// Determine whether the kept buckets form a CONTIGUOUS lattice
|
|
439
|
+
// (every bucket between startKey and anchorKey is present) or a
|
|
440
|
+
// SPARSE one (gaps inside). The no-`--since` path emits only
|
|
441
|
+
// event-bearing buckets, so a sparse clamp is the common case.
|
|
442
|
+
// Codex round-6 P2 (0.47.0): if we filled `window.start/end/
|
|
443
|
+
// seconds` with the bucket span of a sparse clamp, the JSON
|
|
444
|
+
// would lie to dashboard consumers — `total_events / window.
|
|
445
|
+
// seconds` would derive a wildly-wrong rate, and the operator
|
|
446
|
+
// could NOT reproduce the view by re-running with
|
|
447
|
+
// `--since=<clamped_since>` (it would either error or include
|
|
448
|
+
// far more buckets). Treat the two shapes distinctly:
|
|
449
|
+
// - contiguous: report the time-window shape (operator can
|
|
450
|
+
// paste `--since=<clamped_since>` to reproduce).
|
|
451
|
+
// - sparse: leave `window` null (no reproducible duration
|
|
452
|
+
// exists), but still report `clamped_since` so callers
|
|
453
|
+
// know the kept-bucket count was budgeted.
|
|
454
|
+
const expectedContiguousCount = Math.floor((anchorKey - startKey) / stepMs) + 1;
|
|
455
|
+
const isContiguous = expectedContiguousCount === kept.length;
|
|
456
|
+
// 0.47.0 charter item 2: `clamped_since` is ALWAYS a duration
|
|
457
|
+
// string (per the charter `clamped_since: "<DUR>"` contract).
|
|
458
|
+
// It carries the approximate time span the rendered window
|
|
459
|
+
// covers — informative, not necessarily paste-back-safe.
|
|
460
|
+
//
|
|
461
|
+
// Codex round-8 P2 (0.47.0): on stale logs (latest record
|
|
462
|
+
// hours/days ago) `--since=<DUR>` would NOT reproduce the
|
|
463
|
+
// returned data because `--since` always anchors on `now`,
|
|
464
|
+
// not on the audit's latest record. The reproducibility
|
|
465
|
+
// promise we entertained briefly across rounds 7-8 is
|
|
466
|
+
// inherently unsound — `--since` semantics fix one side of
|
|
467
|
+
// the window (now), so any clamp anchored at an older
|
|
468
|
+
// timestamp can't round-trip through it. The `note:` line in
|
|
469
|
+
// human output now describes the field as APPROXIMATE
|
|
470
|
+
// rather than pasteable.
|
|
471
|
+
//
|
|
472
|
+
// Codex round-8 P2 (0.47.0): the sparse-clamp branch
|
|
473
|
+
// previously emitted `"newest 2000 buckets"` for clarity, but
|
|
474
|
+
// that broke the documented `<DUR>` shape — dashboards trying
|
|
475
|
+
// to parse it as a duration would fail only on sparse logs.
|
|
476
|
+
// Both branches now emit a duration string; the human note
|
|
477
|
+
// adds the "sparse" qualifier so operators understand what
|
|
478
|
+
// they're looking at.
|
|
479
|
+
const spanSeconds = Math.max(bucketSeconds, Math.ceil((anchorKey - startKey) / 1000) + bucketSeconds);
|
|
480
|
+
clampedSince = formatDurationCompact(spanSeconds);
|
|
481
|
+
// For contiguous clamps, also fill window.* so consumers can
|
|
482
|
+
// compute rates against a real duration. For sparse clamps,
|
|
483
|
+
// window.* stays null — `total_events / window.seconds` would
|
|
484
|
+
// be meaningless when the kept buckets don't form a contiguous
|
|
485
|
+
// lattice.
|
|
486
|
+
if (isContiguous) {
|
|
487
|
+
windowSeconds = spanSeconds;
|
|
488
|
+
windowEnd = new Date(anchorKey + stepMs);
|
|
489
|
+
windowStart = new Date(startKey);
|
|
490
|
+
}
|
|
491
|
+
// Track the contiguous-vs-sparse shape so the renderer can
|
|
492
|
+
// surface the right note.
|
|
493
|
+
void expectedContiguousCount; // used above via isContiguous
|
|
494
|
+
// Emit each kept bucket. total_events under the post-scan clamp
|
|
495
|
+
// path counts only what the rendered buckets contain — older
|
|
496
|
+
// sliced-out buckets contribute nothing to the report.
|
|
497
|
+
let inWindow = 0;
|
|
498
|
+
for (const k of kept) {
|
|
499
|
+
const cnt = buckets.get(k) ?? 0;
|
|
500
|
+
result.push({
|
|
501
|
+
start: new Date(k).toISOString(),
|
|
502
|
+
end: new Date(k + stepMs).toISOString(),
|
|
503
|
+
count: cnt,
|
|
504
|
+
});
|
|
505
|
+
inWindow += cnt;
|
|
506
|
+
}
|
|
507
|
+
totalEvents = inWindow;
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
for (const k of keys) {
|
|
511
|
+
result.push({
|
|
512
|
+
start: new Date(k).toISOString(),
|
|
513
|
+
end: new Date(k + stepMs).toISOString(),
|
|
514
|
+
count: buckets.get(k) ?? 0,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// Peak index. -1 when no events at all (every bucket is 0 or the
|
|
520
|
+
// list is empty). Ties go to first occurrence — `findIndex` does
|
|
521
|
+
// that for free.
|
|
522
|
+
let peakIndex = -1;
|
|
523
|
+
if (totalEvents > 0) {
|
|
524
|
+
let peakCount = -1;
|
|
525
|
+
for (let i = 0; i < result.length; i += 1) {
|
|
526
|
+
if (result[i].count > peakCount) {
|
|
527
|
+
peakCount = result[i].count;
|
|
528
|
+
peakIndex = i;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
schema_version: AUDIT_TIMELINE_SCHEMA_VERSION,
|
|
534
|
+
bucket: { raw: bucketRaw, seconds: bucketSeconds },
|
|
535
|
+
window: {
|
|
536
|
+
seconds: windowSeconds,
|
|
537
|
+
start: windowStart !== null ? windowStart.toISOString() : null,
|
|
538
|
+
end: windowEnd !== null ? windowEnd.toISOString() : null,
|
|
539
|
+
},
|
|
540
|
+
buckets: result,
|
|
541
|
+
total_events: totalEvents,
|
|
542
|
+
peak_index: peakIndex,
|
|
543
|
+
files_scanned: filesScanned,
|
|
544
|
+
clamped_since: clampedSince,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Format a bucket-start timestamp for the human renderer. Uses
|
|
549
|
+
* `YYYY-MM-DD HH:MM` (UTC) so the columns stay narrow.
|
|
550
|
+
*/
|
|
551
|
+
function formatBucketTimestamp(iso, bucketSeconds) {
|
|
552
|
+
const d = new Date(iso);
|
|
553
|
+
const yyyy = String(d.getUTCFullYear());
|
|
554
|
+
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
555
|
+
const dd = String(d.getUTCDate()).padStart(2, '0');
|
|
556
|
+
const hh = String(d.getUTCHours()).padStart(2, '0');
|
|
557
|
+
const mi = String(d.getUTCMinutes()).padStart(2, '0');
|
|
558
|
+
// Day buckets don't need the HH:MM noise (always `00:00`); show
|
|
559
|
+
// just the date to reduce visual clutter.
|
|
560
|
+
if (bucketSeconds % 86400 === 0)
|
|
561
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
562
|
+
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
|
|
563
|
+
}
|
|
564
|
+
function bucketLabel(seconds, raw) {
|
|
565
|
+
// Honor explicit `HOUR` / `DAY` so the header reads naturally.
|
|
566
|
+
const upper = raw.toUpperCase();
|
|
567
|
+
if (upper === 'HOUR' || upper === 'H' || upper === '1H')
|
|
568
|
+
return 'hourly';
|
|
569
|
+
if (upper === 'DAY' || upper === 'D' || upper === '1D')
|
|
570
|
+
return 'daily';
|
|
571
|
+
// Duration form — show the raw value the operator typed.
|
|
572
|
+
return `every ${raw}`;
|
|
573
|
+
}
|
|
574
|
+
function formatWindowLabel(seconds) {
|
|
575
|
+
if (seconds === null)
|
|
576
|
+
return 'all time';
|
|
577
|
+
const units = [
|
|
578
|
+
['w', 60 * 60 * 24 * 7],
|
|
579
|
+
['d', 60 * 60 * 24],
|
|
580
|
+
['h', 60 * 60],
|
|
581
|
+
['m', 60],
|
|
582
|
+
['s', 1],
|
|
583
|
+
];
|
|
584
|
+
for (const [unit, factor] of units) {
|
|
585
|
+
if (seconds % factor === 0) {
|
|
586
|
+
return `last ${String(seconds / factor)}${unit}`;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return `last ${String(seconds)}s`;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Render the result as a human-readable terminal block with inline
|
|
593
|
+
* histogram bars. See module docstring for the rendering choices.
|
|
594
|
+
*/
|
|
595
|
+
export function renderAuditTimeline(result) {
|
|
596
|
+
const lines = [];
|
|
597
|
+
// Codex round-9 P2 (0.47.0): when auto-clamp fires, the regular
|
|
598
|
+
// `last <DUR>` header (derived from `window.seconds`) is wrong —
|
|
599
|
+
// contiguous stale logs would print "last 1d" even though the
|
|
600
|
+
// newest event was days ago, and sparse clamps would fall back
|
|
601
|
+
// to "all time". Use a clamp-aware header that describes the
|
|
602
|
+
// returned shape instead.
|
|
603
|
+
const cadenceLabel = bucketLabel(result.bucket.seconds, result.bucket.raw);
|
|
604
|
+
let windowLabel;
|
|
605
|
+
if (result.clamped_since !== null) {
|
|
606
|
+
windowLabel = `clamped to ~${result.clamped_since} of newest activity`;
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
windowLabel = formatWindowLabel(result.window.seconds);
|
|
610
|
+
}
|
|
611
|
+
lines.push(`rea audit timeline (${windowLabel}, ${cadenceLabel})`);
|
|
612
|
+
lines.push('─'.repeat(40));
|
|
613
|
+
// 0.47.0 charter item 2: surface the auto-clamp inline. Operators
|
|
614
|
+
// scanning the rendered output should immediately see that the
|
|
615
|
+
// window they got isn't the full audit log. Codex round-8 P2
|
|
616
|
+
// (0.47.0): `clamped_since` is informational, not reproducible —
|
|
617
|
+
// `--since=DUR` anchors at `now`, so a clamp anchored at an older
|
|
618
|
+
// record can't round-trip. Codex round-9 P3 (0.47.0): only a
|
|
619
|
+
// WIDER `--bucket` actually changes the result — pinning the same
|
|
620
|
+
// bucket would just re-trigger the clamp. The remediation
|
|
621
|
+
// suggestion names "wider" explicitly to avoid sending operators
|
|
622
|
+
// down a no-op retry path.
|
|
623
|
+
if (result.clamped_since !== null) {
|
|
624
|
+
lines.push(`note: --since not specified; auto-clamped to newest ${String(MAX_BUCKETS)} buckets ` +
|
|
625
|
+
`(~${result.clamped_since} span at --bucket=${result.bucket.raw}). ` +
|
|
626
|
+
`Pass --since=DUR to anchor at now, or rerun with a WIDER --bucket ` +
|
|
627
|
+
`(current ${result.bucket.raw}) to fit the full log.`);
|
|
628
|
+
lines.push('');
|
|
629
|
+
}
|
|
630
|
+
// Codex round-1 P2 (0.46.0): the zero-events case has two distinct
|
|
631
|
+
// shapes and the renderer must NOT collapse them.
|
|
632
|
+
//
|
|
633
|
+
// - `--since` set + zero events + `buckets.length > 0` — operator
|
|
634
|
+
// asked for an explicit window; we already built the zero-filled
|
|
635
|
+
// bucket lattice in computeAuditTimeline. Show it so silence is
|
|
636
|
+
// visible as flat ▁-less rows rather than a generic
|
|
637
|
+
// "No events" line. That's the WHOLE POINT of the timeline
|
|
638
|
+
// command under --since: distinguish "idle window" from "command
|
|
639
|
+
// never ran".
|
|
640
|
+
// - Otherwise (no --since, or --since with `buckets.length === 0`
|
|
641
|
+
// which means the operator gave us nothing to draw) — render the
|
|
642
|
+
// concise no-events notice. The empty `buckets` path also
|
|
643
|
+
// handles the truly-empty-repo case.
|
|
644
|
+
if (result.total_events === 0 && result.buckets.length === 0) {
|
|
645
|
+
lines.push(result.window.seconds !== null
|
|
646
|
+
? 'No events in the requested window.'
|
|
647
|
+
: 'No events in the audit log.');
|
|
648
|
+
if (result.files_scanned.length === 0) {
|
|
649
|
+
lines.push('(no audit files found — has `rea serve` ever run?)');
|
|
650
|
+
}
|
|
651
|
+
lines.push('');
|
|
652
|
+
return lines.join('\n');
|
|
653
|
+
}
|
|
654
|
+
// Compute peak count for bar scaling. Default to 1 when all buckets
|
|
655
|
+
// are empty so the bar-width math below stays well-defined (0 / 1
|
|
656
|
+
// = 0 → empty bar, which is what we want in the idle-window case).
|
|
657
|
+
const peakCount = result.buckets.reduce((m, b) => (b.count > m ? b.count : m), 0) || 1;
|
|
658
|
+
// Stable timestamp-column width based on the bucket cadence.
|
|
659
|
+
const sampleTs = formatBucketTimestamp(result.buckets[0].start, result.bucket.seconds);
|
|
660
|
+
const tsWidth = sampleTs.length;
|
|
661
|
+
// Stable count-column width — widest count in the result.
|
|
662
|
+
const maxCountWidth = result.buckets.reduce((m, b) => Math.max(m, String(b.count).length), 1);
|
|
663
|
+
for (let i = 0; i < result.buckets.length; i += 1) {
|
|
664
|
+
const b = result.buckets[i];
|
|
665
|
+
const ts = formatBucketTimestamp(b.start, result.bucket.seconds).padEnd(tsWidth);
|
|
666
|
+
const barWidth = b.count === 0
|
|
667
|
+
? 0
|
|
668
|
+
: Math.max(1, Math.round((b.count * MAX_BAR_WIDTH) / peakCount));
|
|
669
|
+
const bar = BAR_CHAR.repeat(barWidth).padEnd(MAX_BAR_WIDTH);
|
|
670
|
+
const count = String(b.count).padStart(maxCountWidth);
|
|
671
|
+
// Codex round-1 P2 (0.46.0) follow-up: peak marker only when
|
|
672
|
+
// there were actual events. peak_index is -1 when total_events
|
|
673
|
+
// is 0, but be defensive — never mark a 0-count bucket as peak.
|
|
674
|
+
const peakMarker = i === result.peak_index && b.count > 0 ? ' ← peak' : '';
|
|
675
|
+
lines.push(`${ts} ${bar} ${count} event${b.count === 1 ? ' ' : 's'}${peakMarker}`);
|
|
676
|
+
}
|
|
677
|
+
lines.push('');
|
|
678
|
+
lines.push(`total: ${String(result.total_events)} events across ${String(result.buckets.length)} bucket${result.buckets.length === 1 ? '' : 's'}`);
|
|
679
|
+
lines.push(`files scanned: ${String(result.files_scanned.length)}`);
|
|
680
|
+
lines.push('');
|
|
681
|
+
return lines.join('\n');
|
|
682
|
+
}
|
|
683
|
+
/** Commander entrypoint. */
|
|
684
|
+
export async function runAuditTimeline(options) {
|
|
685
|
+
let result;
|
|
686
|
+
try {
|
|
687
|
+
result = await computeAuditTimeline({
|
|
688
|
+
...(options.since !== undefined ? { since: options.since } : {}),
|
|
689
|
+
...(options.bucket !== undefined ? { bucket: options.bucket } : {}),
|
|
690
|
+
...(options.now !== undefined ? { now: options.now } : {}),
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
catch (e) {
|
|
694
|
+
if (e instanceof AuditTimelineOptionError) {
|
|
695
|
+
err(`rea audit timeline: ${e.message}`);
|
|
696
|
+
process.exit(1);
|
|
697
|
+
}
|
|
698
|
+
throw e;
|
|
699
|
+
}
|
|
700
|
+
if (options.json === true) {
|
|
701
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
process.stdout.write(renderAuditTimeline(result));
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Register `rea audit timeline` under the `audit` command group.
|
|
708
|
+
*/
|
|
709
|
+
export function registerAuditTimelineCommand(auditCommand) {
|
|
710
|
+
auditCommand
|
|
711
|
+
.command('timeline')
|
|
712
|
+
.description('Time-bucketed event counts — `--bucket=HOUR|DAY` (or duration like `15m`), `--since=DUR`, `--json`. Histogram bar inline. Read-only.')
|
|
713
|
+
.option('--bucket <size>', 'bucket size — `HOUR` (default), `DAY`, or a duration like `15m`, `30m`, `1h`, `1d`')
|
|
714
|
+
.option('--since <duration>', 'filter to records within the last <duration>. Compact form: <N><unit> where unit is s|m|h|d|w (e.g. 24h, 7d).')
|
|
715
|
+
.option('--json', 'emit a JSON document instead of the human-readable histogram')
|
|
716
|
+
.action(async (opts) => {
|
|
717
|
+
await runAuditTimeline({
|
|
718
|
+
...(opts.bucket !== undefined ? { bucket: opts.bucket } : {}),
|
|
719
|
+
...(opts.since !== undefined ? { since: opts.since } : {}),
|
|
720
|
+
...(opts.json === true ? { json: true } : {}),
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
}
|