@faithfulalabi/agent-lens 0.1.1 → 0.3.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/README.md +17 -5
- package/dist/src/archive/paths.js +12 -0
- package/dist/src/cli/commands/schedule.js +248 -19
- package/dist/src/db/read.js +12 -4
- package/dist/src/db/schema.js +6 -2
- package/dist/src/db/write.js +20 -0
- package/dist/src/shared/model-id.js +7 -0
- package/dist/src/shared/pricing.js +1 -3
- package/package.json +1 -1
- package/ui/dist/assets/{index-Chza4fL6.css → index-D9RVohDs.css} +1 -1
- package/ui/dist/assets/{index-BTDb_lwM.js → index-T2tFdorC.js} +61 -61
- package/ui/dist/index.html +2 -2
package/README.md
CHANGED
|
@@ -55,15 +55,27 @@ only by the archive.
|
|
|
55
55
|
agent-lens schedule install
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
That sets up a recurring job that runs `agent-lens archive` every 15 minutes and logs each pass
|
|
59
|
+
where `agent-lens doctor` reads it. Both supported platforms are handled for you: macOS gets a
|
|
60
|
+
launchd agent, Linux gets a systemd **user** timer under `~/.config/systemd/user`. Run it again any
|
|
61
|
+
time — it replaces its own job rather than duplicating it. `agent-lens schedule status` reports the
|
|
62
|
+
job and its last successful pass; `agent-lens schedule disable` turns it off cleanly, leaving the
|
|
63
|
+
archive and its logs untouched. On any other platform, run `agent-lens archive` every ~15 minutes
|
|
64
|
+
yourself from whatever scheduler you have, such as a cron entry.
|
|
63
65
|
|
|
64
66
|
One caveat worth knowing before you rely on an interval: a wall-clock schedule does not fire while
|
|
65
67
|
the machine is asleep. Treat the interval as a bound on _wake_ time, not on elapsed time.
|
|
66
68
|
|
|
69
|
+
One more on Linux, and it is the difference between a green report and a real archive:
|
|
70
|
+
|
|
71
|
+
> **a systemd user timer does not run while you are logged out unless lingering is on for your user**
|
|
72
|
+
|
|
73
|
+
`agent-lens schedule status` reports which state your user is in, and `schedule install` prints the
|
|
74
|
+
one command that changes it — `loginctl enable-linger <uid>`. The command is printed and never run:
|
|
75
|
+
it writes outside everything this tool owns. The two platforms also differ deliberately on catch-up.
|
|
76
|
+
launchd runs a pass at load; the systemd timer uses `OnCalendar` with `Persistent=true`, so a slot
|
|
77
|
+
missed while the machine was off runs once on resume.
|
|
78
|
+
|
|
67
79
|
The pass is safe to run often: an advisory lock means a second concurrent pass copies nothing and
|
|
68
80
|
exits 0, and an unchanged corpus copies zero bytes. When a source has been rewritten, the archived
|
|
69
81
|
bytes are **kept** and the file is marked `diverged` rather than overwritten. `agent-lens archive
|
|
@@ -53,6 +53,18 @@ export function resolveLaunchAgentsDir(homeDir = homedir()) {
|
|
|
53
53
|
export function resolvePlistPath(label, homeDir = homedir()) {
|
|
54
54
|
return join(resolveLaunchAgentsDir(homeDir), `${label}.plist`);
|
|
55
55
|
}
|
|
56
|
+
export function resolveSystemdUserDir(homeDir = homedir(), configHome) {
|
|
57
|
+
const base = configHome !== undefined && configHome !== '' && isAbsolute(configHome)
|
|
58
|
+
? configHome
|
|
59
|
+
: join(homeDir, '.config');
|
|
60
|
+
return join(base, 'systemd', 'user');
|
|
61
|
+
}
|
|
62
|
+
export function resolveSystemdUnitPath(name, homeDir, configHome) {
|
|
63
|
+
return join(resolveSystemdUserDir(homeDir, configHome), name);
|
|
64
|
+
}
|
|
65
|
+
export function resolveSystemdWantsPath(name, homeDir, configHome) {
|
|
66
|
+
return join(resolveSystemdUserDir(homeDir, configHome), 'timers.target.wants', name);
|
|
67
|
+
}
|
|
56
68
|
export function resolveScheduleWrapperPath(dataDir) {
|
|
57
69
|
return join(resolveDataDir(dataDir), 'schedule', 'archive.sh');
|
|
58
70
|
}
|
|
@@ -3,23 +3,40 @@ import { chmodSync, existsSync, mkdirSync, renameSync, rmdirSync, unlinkSync, wr
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { assertNotUnderRoot, lstatSafe, resolveCronLogPath, resolveDataDir, resolvePlistPath, resolveScheduleWrapperPath, resolveTranscriptRoot, TRANSCRIPT_ROOT_LABEL, } from '../../archive/paths.js';
|
|
6
|
+
import { assertNotUnderRoot, lstatSafe, resolveCronLogPath, resolveDataDir, resolvePlistPath, resolveScheduleWrapperPath, resolveSystemdUnitPath, resolveSystemdUserDir, resolveSystemdWantsPath, resolveTranscriptRoot, TRANSCRIPT_ROOT_LABEL, } from '../../archive/paths.js';
|
|
7
7
|
import { parseStringFlag } from './archive.js';
|
|
8
8
|
export const SCHEDULE_LABEL = 'com.agent-lens.archive';
|
|
9
9
|
export const LEGACY_LABEL = 'com.faithful.agent-lens.archive';
|
|
10
10
|
const START_INTERVAL_SECONDS = 900;
|
|
11
11
|
export const CRON_STAMP_FORMAT = '+%Y-%m-%dT%H:%M:%S%z';
|
|
12
12
|
export const WAKE_TIME_CAVEAT = 'a wall-clock schedule does not fire while the machine is asleep — treat the interval as a bound on wake time, not on elapsed time';
|
|
13
|
+
export const LINGER_CAVEAT = 'a systemd user timer does not run while you are logged out unless lingering is on for your user';
|
|
14
|
+
export const SYSTEMD_UNIT_BASE = 'agent-lens-archive';
|
|
15
|
+
const SYSTEMD_SERVICE = `${SYSTEMD_UNIT_BASE}.service`;
|
|
16
|
+
const SYSTEMD_TIMER = `${SYSTEMD_UNIT_BASE}.timer`;
|
|
17
|
+
const SYSTEMD_CALENDAR = '*:0/15';
|
|
18
|
+
const SYSTEMD_TIMER_PROPERTIES = [
|
|
19
|
+
'ActiveState',
|
|
20
|
+
'UnitFileState',
|
|
21
|
+
'NextElapseUSecRealtime',
|
|
22
|
+
'LastTriggerUSec',
|
|
23
|
+
'Result',
|
|
24
|
+
];
|
|
13
25
|
const ACTIONS = 'install | status | disable';
|
|
14
|
-
const
|
|
15
|
-
'every ~15 minutes
|
|
16
|
-
'"Keeping the archive current".'
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
const UNSUPPORTED_PLATFORM_POINTER = 'the recurring job uses launchd on macOS and a systemd user timer on Linux, and this platform ' +
|
|
27
|
+
'is neither. Run `agent-lens archive` every ~15 minutes from whatever scheduler it does have — ' +
|
|
28
|
+
'a cron entry, for instance — per the README section "Keeping the archive current". No cron ' +
|
|
29
|
+
'fallback ships here on purpose: the two backends above are the two this command sets up for you.';
|
|
30
|
+
const BUS_HINT = ' hint: run this from a logged-in session — `systemctl --user` needs a user D-Bus, with ' +
|
|
31
|
+
'XDG_RUNTIME_DIR pointing at /run/user/<uid>';
|
|
32
|
+
function realRunner(bin) {
|
|
33
|
+
return (args) => {
|
|
34
|
+
const result = spawnSync(bin, args, { encoding: 'utf8' });
|
|
35
|
+
return {
|
|
36
|
+
status: result.status,
|
|
37
|
+
stdout: result.stdout ?? '',
|
|
38
|
+
stderr: result.error === undefined ? (result.stderr ?? '') : String(result.error.message),
|
|
39
|
+
};
|
|
23
40
|
};
|
|
24
41
|
}
|
|
25
42
|
function realDeps() {
|
|
@@ -29,7 +46,10 @@ function realDeps() {
|
|
|
29
46
|
homeDir: homedir(),
|
|
30
47
|
uid: process.getuid?.() ?? 0,
|
|
31
48
|
moduleUrl: import.meta.url,
|
|
32
|
-
launchctl:
|
|
49
|
+
launchctl: realRunner('launchctl'),
|
|
50
|
+
systemctl: realRunner('systemctl'),
|
|
51
|
+
loginctl: realRunner('loginctl'),
|
|
52
|
+
configHome: process.env.XDG_CONFIG_HOME,
|
|
33
53
|
now: Date.now,
|
|
34
54
|
};
|
|
35
55
|
}
|
|
@@ -184,6 +204,56 @@ export function buildPlist(opts) {
|
|
|
184
204
|
</plist>
|
|
185
205
|
`;
|
|
186
206
|
}
|
|
207
|
+
export function escapeUnitValue(value) {
|
|
208
|
+
if (/[\n\r]/.test(value)) {
|
|
209
|
+
throw new Error('refusing to write a systemd unit: a line break in a unit value would split the ' +
|
|
210
|
+
`directive — ${JSON.stringify(value)}`);
|
|
211
|
+
}
|
|
212
|
+
return value
|
|
213
|
+
.replace(/\\/g, '\\\\')
|
|
214
|
+
.replace(/"/g, '\\"')
|
|
215
|
+
.replace(/%/g, '%%')
|
|
216
|
+
.replace(/\$/g, '$$$$');
|
|
217
|
+
}
|
|
218
|
+
export function buildSystemdService(opts) {
|
|
219
|
+
return `[Unit]
|
|
220
|
+
Description=agent-lens archive — unattended pass, every ${START_INTERVAL_SECONDS / 60} minutes
|
|
221
|
+
#
|
|
222
|
+
# GENERATED by \`agent-lens schedule\` — do not edit. Turning the job on again
|
|
223
|
+
# rewrites this file in full, which is what keeps it current with the package
|
|
224
|
+
# that shipped it.
|
|
225
|
+
#
|
|
226
|
+
# Coverage is wake-time-bounded: ${escapeUnitValue(WAKE_TIME_CAVEAT)}.
|
|
227
|
+
#
|
|
228
|
+
# Exit codes are the archive command's own: 0 clean, 1 usage error / crash,
|
|
229
|
+
# 3 archive-side errors. 2 is RESERVED product-wide and must never appear.
|
|
230
|
+
|
|
231
|
+
[Service]
|
|
232
|
+
Type=oneshot
|
|
233
|
+
ExecStart=/bin/sh "${escapeUnitValue(opts.wrapperPath)}"
|
|
234
|
+
`;
|
|
235
|
+
}
|
|
236
|
+
export function buildSystemdTimer() {
|
|
237
|
+
return `[Unit]
|
|
238
|
+
Description=agent-lens archive — timer for ${escapeUnitValue(SYSTEMD_SERVICE)}
|
|
239
|
+
#
|
|
240
|
+
# GENERATED by \`agent-lens schedule\` — do not edit. Turning the job on again
|
|
241
|
+
# rewrites this file in full.
|
|
242
|
+
#
|
|
243
|
+
# Coverage is wake-time-bounded: ${escapeUnitValue(WAKE_TIME_CAVEAT)}.
|
|
244
|
+
#
|
|
245
|
+
# And ${escapeUnitValue(LINGER_CAVEAT)} — \`agent-lens schedule status\` reports which.
|
|
246
|
+
|
|
247
|
+
[Timer]
|
|
248
|
+
OnCalendar=${escapeUnitValue(SYSTEMD_CALENDAR)}
|
|
249
|
+
Persistent=true
|
|
250
|
+
AccuracySec=1min
|
|
251
|
+
Unit=${escapeUnitValue(SYSTEMD_SERVICE)}
|
|
252
|
+
|
|
253
|
+
[Install]
|
|
254
|
+
WantedBy=timers.target
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
187
257
|
function ensureDirOutsideCorpus(dir, mode, transcriptRoot) {
|
|
188
258
|
assertNotUnderRoot(dir, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
|
|
189
259
|
mkdirSync(dir, { recursive: true, mode });
|
|
@@ -202,12 +272,22 @@ function removeIfPresent(path, transcriptRoot) {
|
|
|
202
272
|
unlinkSync(path);
|
|
203
273
|
return true;
|
|
204
274
|
}
|
|
275
|
+
function removeEmptyDir(dir, transcriptRoot) {
|
|
276
|
+
assertNotUnderRoot(dir, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
|
|
277
|
+
try {
|
|
278
|
+
rmdirSync(dir);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
}
|
|
282
|
+
}
|
|
205
283
|
function gui(deps, label) {
|
|
206
284
|
return `gui/${deps.uid}/${label}`;
|
|
207
285
|
}
|
|
208
286
|
function install(dataDirFlag, deps) {
|
|
287
|
+
if (deps.platform === 'linux')
|
|
288
|
+
return installSystemd(dataDirFlag, deps);
|
|
209
289
|
if (deps.platform !== 'darwin') {
|
|
210
|
-
console.error(`agent-lens schedule: nothing was set up — ${
|
|
290
|
+
console.error(`agent-lens schedule: nothing was set up — ${UNSUPPORTED_PLATFORM_POINTER}`);
|
|
211
291
|
return 1;
|
|
212
292
|
}
|
|
213
293
|
const dataDir = resolveDataDir(dataDirFlag);
|
|
@@ -247,8 +327,10 @@ function install(dataDirFlag, deps) {
|
|
|
247
327
|
return 0;
|
|
248
328
|
}
|
|
249
329
|
async function status(dataDirFlag, deps) {
|
|
330
|
+
if (deps.platform === 'linux')
|
|
331
|
+
return statusSystemd(dataDirFlag, deps);
|
|
250
332
|
if (deps.platform !== 'darwin') {
|
|
251
|
-
console.log(`agent-lens schedule: ${
|
|
333
|
+
console.log(`agent-lens schedule: ${UNSUPPORTED_PLATFORM_POINTER}`);
|
|
252
334
|
console.log(`note: ${WAKE_TIME_CAVEAT}`);
|
|
253
335
|
return 0;
|
|
254
336
|
}
|
|
@@ -275,8 +357,10 @@ async function status(dataDirFlag, deps) {
|
|
|
275
357
|
return 0;
|
|
276
358
|
}
|
|
277
359
|
function disable(dataDirFlag, deps) {
|
|
360
|
+
if (deps.platform === 'linux')
|
|
361
|
+
return disableSystemd(dataDirFlag, deps);
|
|
278
362
|
if (deps.platform !== 'darwin') {
|
|
279
|
-
console.error(`agent-lens schedule: nothing was removed — ${
|
|
363
|
+
console.error(`agent-lens schedule: nothing was removed — ${UNSUPPORTED_PLATFORM_POINTER}`);
|
|
280
364
|
return 1;
|
|
281
365
|
}
|
|
282
366
|
const transcriptRoot = resolveTranscriptRoot();
|
|
@@ -291,11 +375,7 @@ function disable(dataDirFlag, deps) {
|
|
|
291
375
|
removed.push(plistPath);
|
|
292
376
|
if (removeIfPresent(wrapperPath, transcriptRoot))
|
|
293
377
|
removed.push(wrapperPath);
|
|
294
|
-
|
|
295
|
-
rmdirSync(dirname(wrapperPath));
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
}
|
|
378
|
+
removeEmptyDir(dirname(wrapperPath), transcriptRoot);
|
|
299
379
|
if (removed.length === 0) {
|
|
300
380
|
console.log('recurring archive job: not installed — nothing to remove');
|
|
301
381
|
}
|
|
@@ -305,6 +385,155 @@ function disable(dataDirFlag, deps) {
|
|
|
305
385
|
}
|
|
306
386
|
return 0;
|
|
307
387
|
}
|
|
388
|
+
function systemdPaths(deps) {
|
|
389
|
+
return {
|
|
390
|
+
unitDir: resolveSystemdUserDir(deps.homeDir, deps.configHome),
|
|
391
|
+
servicePath: resolveSystemdUnitPath(SYSTEMD_SERVICE, deps.homeDir, deps.configHome),
|
|
392
|
+
timerPath: resolveSystemdUnitPath(SYSTEMD_TIMER, deps.homeDir, deps.configHome),
|
|
393
|
+
wantsPath: resolveSystemdWantsPath(SYSTEMD_TIMER, deps.homeDir, deps.configHome),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
function detailOf(result) {
|
|
397
|
+
return (result.stderr || result.stdout).trim() || `systemctl exited ${String(result.status)}`;
|
|
398
|
+
}
|
|
399
|
+
function parseProperties(stdout) {
|
|
400
|
+
const properties = new Map();
|
|
401
|
+
for (const line of stdout.split('\n')) {
|
|
402
|
+
const split = line.indexOf('=');
|
|
403
|
+
if (split > 0)
|
|
404
|
+
properties.set(line.slice(0, split).trim(), line.slice(split + 1).trim());
|
|
405
|
+
}
|
|
406
|
+
return properties;
|
|
407
|
+
}
|
|
408
|
+
function reported(value) {
|
|
409
|
+
return value === undefined || value === '' ? 'unknown — systemd reported nothing' : value;
|
|
410
|
+
}
|
|
411
|
+
function readLingerState(deps) {
|
|
412
|
+
const result = deps.loginctl(['show-user', String(deps.uid), '--property=Linger']);
|
|
413
|
+
if (result.status !== 0)
|
|
414
|
+
return 'unknown';
|
|
415
|
+
const value = parseProperties(result.stdout).get('Linger');
|
|
416
|
+
if (value === 'yes')
|
|
417
|
+
return 'on';
|
|
418
|
+
if (value === 'no')
|
|
419
|
+
return 'off';
|
|
420
|
+
return 'unknown';
|
|
421
|
+
}
|
|
422
|
+
function lingerLines(deps) {
|
|
423
|
+
const state = readLingerState(deps);
|
|
424
|
+
if (state === 'on') {
|
|
425
|
+
return [' lingering: on — the timer keeps running while you are logged out'];
|
|
426
|
+
}
|
|
427
|
+
return [
|
|
428
|
+
` lingering: ${state} — ${LINGER_CAVEAT}`,
|
|
429
|
+
` turn lingering on with: loginctl enable-linger ${deps.uid}`,
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
function installSystemd(dataDirFlag, deps) {
|
|
433
|
+
const dataDir = resolveDataDir(dataDirFlag);
|
|
434
|
+
const transcriptRoot = resolveTranscriptRoot();
|
|
435
|
+
const wrapperPath = resolveScheduleWrapperPath(dataDir);
|
|
436
|
+
const cronLogPath = resolveCronLogPath(dataDir);
|
|
437
|
+
const { unitDir, servicePath, timerPath } = systemdPaths(deps);
|
|
438
|
+
for (const path of [wrapperPath, cronLogPath, unitDir, servicePath, timerPath]) {
|
|
439
|
+
assertNotUnderRoot(path, transcriptRoot, TRANSCRIPT_ROOT_LABEL);
|
|
440
|
+
}
|
|
441
|
+
const invocation = resolveArchiveInvocation(deps.moduleUrl);
|
|
442
|
+
const wrapperText = buildWrapperScript({
|
|
443
|
+
nodePath: deps.execPath,
|
|
444
|
+
invocation,
|
|
445
|
+
dataDir,
|
|
446
|
+
cronLogPath,
|
|
447
|
+
});
|
|
448
|
+
const serviceText = buildSystemdService({ wrapperPath });
|
|
449
|
+
const timerText = buildSystemdTimer();
|
|
450
|
+
ensureDirOutsideCorpus(dirname(wrapperPath), 0o700, transcriptRoot);
|
|
451
|
+
ensureDirOutsideCorpus(dirname(cronLogPath), 0o700, transcriptRoot);
|
|
452
|
+
ensureDirOutsideCorpus(unitDir, 0o700, transcriptRoot);
|
|
453
|
+
atomicWrite(wrapperPath, wrapperText, 0o700, transcriptRoot);
|
|
454
|
+
atomicWrite(servicePath, serviceText, 0o644, transcriptRoot);
|
|
455
|
+
atomicWrite(timerPath, timerText, 0o644, transcriptRoot);
|
|
456
|
+
const armed = [
|
|
457
|
+
['--user', 'daemon-reload'],
|
|
458
|
+
['--user', 'enable', SYSTEMD_TIMER],
|
|
459
|
+
['--user', 'restart', SYSTEMD_TIMER],
|
|
460
|
+
];
|
|
461
|
+
for (const argv of armed) {
|
|
462
|
+
const result = deps.systemctl(argv);
|
|
463
|
+
if (result.status !== 0) {
|
|
464
|
+
console.error(`agent-lens schedule: \`systemctl ${argv.join(' ')}\` failed — ${detailOf(result)}`);
|
|
465
|
+
if (/Failed to connect to bus/.test(result.stderr))
|
|
466
|
+
console.error(BUS_HINT);
|
|
467
|
+
return 1;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const firstPass = deps.systemctl(['--user', 'start', '--no-block', SYSTEMD_SERVICE]);
|
|
471
|
+
const lines = [];
|
|
472
|
+
if (firstPass.status !== 0) {
|
|
473
|
+
lines.push(`warning: the first pass could not be started — ${detailOf(firstPass)}`);
|
|
474
|
+
}
|
|
475
|
+
lines.push(`recurring archive job on — ${SYSTEMD_TIMER}, every ` +
|
|
476
|
+
`${START_INTERVAL_SECONDS / 60} minutes (${invocation.kind} layout)`, ` timer ${timerPath}`, ` service ${servicePath}`, ` wrapper ${wrapperPath}`, ` passes ${cronLogPath}`, ` unit log journalctl --user -u ${SYSTEMD_SERVICE}`, `note: ${WAKE_TIME_CAVEAT}`, ...lingerLines(deps));
|
|
477
|
+
console.log(lines.join('\n'));
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
480
|
+
async function statusSystemd(dataDirFlag, deps) {
|
|
481
|
+
const { servicePath, timerPath } = systemdPaths(deps);
|
|
482
|
+
const lines = [];
|
|
483
|
+
if (lstatSafe(timerPath)?.isFile() === true) {
|
|
484
|
+
lines.push(`recurring archive job: installed — ${timerPath}`);
|
|
485
|
+
lines.push(` service ${servicePath}`);
|
|
486
|
+
const shown = deps.systemctl([
|
|
487
|
+
'--user',
|
|
488
|
+
'show',
|
|
489
|
+
SYSTEMD_TIMER,
|
|
490
|
+
`--property=${SYSTEMD_TIMER_PROPERTIES.join(',')}`,
|
|
491
|
+
]);
|
|
492
|
+
const properties = parseProperties(shown.stdout);
|
|
493
|
+
lines.push(` timer state: ${reported(properties.get('ActiveState'))}`, ` unit file: ${reported(properties.get('UnitFileState'))}`, ` next trigger: ${reported(properties.get('NextElapseUSecRealtime'))}`, ` last trigger: ${reported(properties.get('LastTriggerUSec'))}`, ` last result: ${reported(properties.get('Result'))}`);
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
lines.push('recurring archive job: not installed');
|
|
497
|
+
lines.push(' turn it on with: agent-lens schedule install');
|
|
498
|
+
}
|
|
499
|
+
lines.push(...lingerLines(deps));
|
|
500
|
+
const [{ formatLastPassSection }, { readCronLogStatus }] = await Promise.all([
|
|
501
|
+
import('./doctor.js'),
|
|
502
|
+
import('../../archive/cron-log.js'),
|
|
503
|
+
]);
|
|
504
|
+
lines.push(...formatLastPassSection(readCronLogStatus(dataDirFlag), deps.now()));
|
|
505
|
+
lines.push('', `note: ${WAKE_TIME_CAVEAT}`);
|
|
506
|
+
console.log(lines.join('\n'));
|
|
507
|
+
return 0;
|
|
508
|
+
}
|
|
509
|
+
function disableSystemd(dataDirFlag, deps) {
|
|
510
|
+
const transcriptRoot = resolveTranscriptRoot();
|
|
511
|
+
const wrapperPath = resolveScheduleWrapperPath(resolveDataDir(dataDirFlag));
|
|
512
|
+
const { servicePath, timerPath, wantsPath } = systemdPaths(deps);
|
|
513
|
+
const wasInstalled = lstatSafe(timerPath) !== undefined || lstatSafe(servicePath) !== undefined;
|
|
514
|
+
const stopped = deps.systemctl(['--user', 'stop', SYSTEMD_TIMER]);
|
|
515
|
+
const disabled = deps.systemctl(['--user', 'disable', SYSTEMD_TIMER]);
|
|
516
|
+
const removed = [];
|
|
517
|
+
for (const path of [timerPath, servicePath, wantsPath, wrapperPath]) {
|
|
518
|
+
if (removeIfPresent(path, transcriptRoot))
|
|
519
|
+
removed.push(path);
|
|
520
|
+
}
|
|
521
|
+
deps.systemctl(['--user', 'daemon-reload']);
|
|
522
|
+
removeEmptyDir(dirname(wrapperPath), transcriptRoot);
|
|
523
|
+
const lines = [];
|
|
524
|
+
const refused = [stopped, disabled].find((result) => result.status !== 0);
|
|
525
|
+
if (refused !== undefined && wasInstalled) {
|
|
526
|
+
lines.push(`warning: systemctl --user could not stop the timer — ${detailOf(refused)}`, ' the unit files were removed anyway, so nothing is left to fire');
|
|
527
|
+
}
|
|
528
|
+
if (removed.length === 0) {
|
|
529
|
+
lines.push('recurring archive job: not installed — nothing to remove');
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
lines.push('recurring archive job off — removed:', ...removed.map((path) => ` ${path}`), ' cron.log and the archive itself are untouched');
|
|
533
|
+
}
|
|
534
|
+
console.log(lines.join('\n'));
|
|
535
|
+
return 0;
|
|
536
|
+
}
|
|
308
537
|
export async function schedule(args = [], deps = realDeps()) {
|
|
309
538
|
const action = parseScheduleAction(args);
|
|
310
539
|
const dataDir = parseStringFlag(args, 'dataDir');
|
package/dist/src/db/read.js
CHANGED
|
@@ -18,20 +18,28 @@ const SORT_EXPRESSIONS = {
|
|
|
18
18
|
tokens: '(tokens_in + tokens_out)',
|
|
19
19
|
errors: 'error_count',
|
|
20
20
|
};
|
|
21
|
-
const SESSION_LIST_COLUMNS = `id, title, preview, project_path, git_branch, model, harness_version,
|
|
21
|
+
const SESSION_LIST_COLUMNS = `id, title, preview, project_path, git_branch, model, models, harness_version,
|
|
22
22
|
started_at, last_activity_at, turn_count, tool_call_count, error_count,
|
|
23
23
|
tokens_in, tokens_out, tokens_cache_read, tokens_cache_write, est_cost,
|
|
24
24
|
agent_count, sub_tool_call_count, sub_error_count, sub_tokens_in, sub_tokens_out,
|
|
25
|
-
sub_tokens_cache_read, sub_tokens_cache_write, sub_est_cost, rollup_state, drift_json`;
|
|
25
|
+
sub_tokens_cache_read, sub_tokens_cache_write, sub_est_cost, sub_models, rollup_state, drift_json`;
|
|
26
26
|
const TOP_LEVEL_ONLY = 'parent_session_id IS NULL';
|
|
27
27
|
const NO_DRIFT = '{}';
|
|
28
28
|
const Q_PREDICATE = "(COALESCE(title,'') || ' ' || COALESCE(preview,'') || ' ' || project_path) LIKE ? ESCAPE '\\'";
|
|
29
29
|
function likeContains(q) {
|
|
30
30
|
return `%${q.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
|
31
31
|
}
|
|
32
|
+
function modelIdsOf(text) {
|
|
33
|
+
return JSON.parse(text).map(([id]) => id);
|
|
34
|
+
}
|
|
32
35
|
function toSessionRow(row) {
|
|
33
|
-
const { drift_json, ...rest } = row;
|
|
34
|
-
return {
|
|
36
|
+
const { drift_json, models, sub_models, ...rest } = row;
|
|
37
|
+
return {
|
|
38
|
+
...rest,
|
|
39
|
+
models: modelIdsOf(models),
|
|
40
|
+
sub_models: modelIdsOf(sub_models),
|
|
41
|
+
has_drift: drift_json !== NO_DRIFT,
|
|
42
|
+
};
|
|
35
43
|
}
|
|
36
44
|
export function buildSessionListSql(query) {
|
|
37
45
|
const where = [TOP_LEVEL_ONLY];
|
package/dist/src/db/schema.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const SCHEMA_VERSION =
|
|
1
|
+
export const SCHEMA_VERSION = 3;
|
|
2
2
|
export const SCHEMA_DDL = `-- ~/.agent-lens/cache.db
|
|
3
3
|
--
|
|
4
4
|
-- THIS FILE IS A CACHE. Every byte is a pure function of ~/.agent-lens/archive/** --
|
|
@@ -21,7 +21,7 @@ export const SCHEMA_DDL = `-- ~/.agent-lens/cache.db
|
|
|
21
21
|
PRAGMA journal_mode = WAL;
|
|
22
22
|
PRAGMA synchronous = NORMAL;
|
|
23
23
|
PRAGMA foreign_keys = OFF; -- projections are dropped wholesale, per session, by hand
|
|
24
|
-
PRAGMA user_version =
|
|
24
|
+
PRAGMA user_version = 3; -- SCHEMA_VERSION; mismatch => unlink + recreate
|
|
25
25
|
|
|
26
26
|
-- ===================================================================== sessions
|
|
27
27
|
-- The file index AND the session-list row AND the projection freshness header,
|
|
@@ -73,6 +73,8 @@ CREATE TABLE sessions (
|
|
|
73
73
|
project_path TEXT NOT NULL, -- cwd; the list groups and filters on it
|
|
74
74
|
git_branch TEXT,
|
|
75
75
|
model TEXT, -- the model named on the most lines; NOT the last
|
|
76
|
+
models TEXT NOT NULL DEFAULT '[]', -- own-file distinct models, [[id, calls]], calls DESC,
|
|
77
|
+
-- first-seen tiebreak; <synthetic>/NULL excluded
|
|
76
78
|
harness_version TEXT, -- \`version\`; transcript-only, groups the drift report
|
|
77
79
|
title TEXT, -- LAST type:'ai-title' line
|
|
78
80
|
preview TEXT, -- first human prompt, 200 chars
|
|
@@ -102,6 +104,8 @@ CREATE TABLE sessions (
|
|
|
102
104
|
sub_tokens_cache_read INTEGER NOT NULL DEFAULT 0,
|
|
103
105
|
sub_tokens_cache_write INTEGER NOT NULL DEFAULT 0,
|
|
104
106
|
sub_est_cost REAL,
|
|
107
|
+
sub_models TEXT NOT NULL DEFAULT '[]', -- transitive over sidecars, [[id, calls]], written by
|
|
108
|
+
-- recomputeSubagentRollups; '[]' until wave 2
|
|
105
109
|
rollup_state TEXT NOT NULL DEFAULT 'own', -- 'own' | 'complete'
|
|
106
110
|
|
|
107
111
|
-- sidecar linkage (NULL on a top-level session)
|
package/dist/src/db/write.js
CHANGED
|
@@ -3,6 +3,7 @@ import { linkSubagents, } from '../project/subagents.js';
|
|
|
3
3
|
import { resolvePersistedOutput } from '../transcript/spill.js';
|
|
4
4
|
import { PROJECTOR_VERSION } from '../transcript/version.js';
|
|
5
5
|
import { estimateCost } from '../shared/pricing.js';
|
|
6
|
+
import { SYNTHETIC_MODEL } from '../shared/model-id.js';
|
|
6
7
|
const UPSERT_INDEX_SQL = `INSERT INTO sessions
|
|
7
8
|
(id, source_path, archive_path, file_mtime_ms, file_size,
|
|
8
9
|
project_path, started_at, last_activity_at)
|
|
@@ -243,7 +244,13 @@ const ROLLUP_SQL = `UPDATE sessions SET
|
|
|
243
244
|
WHERE id = :id`;
|
|
244
245
|
export function recomputeSessionRollups(db, id) {
|
|
245
246
|
db.prepare(ROLLUP_SQL).run({ id });
|
|
247
|
+
const rows = db.prepare(OWN_MODELS_SQL).all(id, SYNTHETIC_MODEL);
|
|
248
|
+
const models = rows.map((row) => [row.model, row.calls]);
|
|
249
|
+
db.prepare('UPDATE sessions SET models = ? WHERE id = ?').run(JSON.stringify(models), id);
|
|
246
250
|
}
|
|
251
|
+
const OWN_MODELS_SQL = `SELECT model, count(*) AS calls FROM events
|
|
252
|
+
WHERE session_id = ? AND model IS NOT NULL AND model <> ?
|
|
253
|
+
GROUP BY model ORDER BY calls DESC, min(seq) ASC`;
|
|
247
254
|
const SUBAGENT_ROLLUP_SQL = `UPDATE sessions SET
|
|
248
255
|
agent_count = COALESCE((SELECT sum(1 + c.agent_count) FROM sessions c WHERE c.parent_session_id = :id), 0),
|
|
249
256
|
sub_tool_call_count = COALESCE((SELECT sum(c.tool_call_count + c.sub_tool_call_count) FROM sessions c WHERE c.parent_session_id = :id), 0),
|
|
@@ -267,6 +274,19 @@ const SUBAGENT_ROLLUP_SQL = `UPDATE sessions SET
|
|
|
267
274
|
WHERE id = :id`;
|
|
268
275
|
export function recomputeSubagentRollups(db, id) {
|
|
269
276
|
db.prepare(SUBAGENT_ROLLUP_SQL).run({ id });
|
|
277
|
+
const children = db
|
|
278
|
+
.prepare('SELECT models, sub_models FROM sessions WHERE parent_session_id = ?')
|
|
279
|
+
.all(id);
|
|
280
|
+
const calls = new Map();
|
|
281
|
+
for (const child of children) {
|
|
282
|
+
for (const column of [child.models, child.sub_models]) {
|
|
283
|
+
for (const [model, n] of JSON.parse(column)) {
|
|
284
|
+
calls.set(model, (calls.get(model) ?? 0) + n);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const subModels = [...calls].sort(([a, an], [b, bn]) => bn - an || (a < b ? -1 : a > b ? 1 : 0));
|
|
289
|
+
db.prepare('UPDATE sessions SET sub_models = ? WHERE id = ?').run(JSON.stringify(subModels), id);
|
|
270
290
|
}
|
|
271
291
|
export function markRollupComplete(db, id) {
|
|
272
292
|
db.prepare(`UPDATE sessions SET rollup_state = 'complete' WHERE id = ?`).run(id);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const SYNTHETIC_MODEL = '<synthetic>';
|
|
2
|
+
export const BUILD_SUFFIX = /-\d{8}$/;
|
|
3
|
+
export const CONTEXT_TAG = /\[[^\]]*\]$/;
|
|
4
|
+
export const VENDOR_PREFIX = /^(?:[a-z]{2,4}\.)?anthropic\./;
|
|
5
|
+
export function stripModelId(raw) {
|
|
6
|
+
return raw.replace(CONTEXT_TAG, '').replace(VENDOR_PREFIX, '').replace(BUILD_SUFFIX, '');
|
|
7
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { BUILD_SUFFIX, CONTEXT_TAG, VENDOR_PREFIX } from './model-id.js';
|
|
2
3
|
function canonicalJson(value) {
|
|
3
4
|
return JSON.stringify(sortKeys(value));
|
|
4
5
|
}
|
|
@@ -39,9 +40,6 @@ export const PRICING_VERSION = `${PRICING_TABLE_DATE}+${createHash('sha256')
|
|
|
39
40
|
.update(canonicalJson(PRICING_TABLE), 'utf8')
|
|
40
41
|
.digest('hex')
|
|
41
42
|
.slice(0, 8)}`;
|
|
42
|
-
const BUILD_SUFFIX = /-\d{8}$/;
|
|
43
|
-
const CONTEXT_TAG = /\[[^\]]*\]$/;
|
|
44
|
-
const VENDOR_PREFIX = /^(?:[a-z]{2,4}\.)?anthropic\./;
|
|
45
43
|
export function normalizeModelKey(raw) {
|
|
46
44
|
const model = raw.replace(CONTEXT_TAG, '');
|
|
47
45
|
const withoutVendor = model.replace(VENDOR_PREFIX, '');
|