@phnx-labs/agents-cli 1.20.90 → 1.20.92
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 +276 -0
- package/README.md +1 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/activity.d.ts +72 -6
- package/dist/commands/activity.js +198 -49
- package/dist/commands/beta.js +1 -0
- package/dist/commands/doctor.js +4 -2
- package/dist/commands/exec.d.ts +14 -0
- package/dist/commands/exec.js +144 -14
- package/dist/commands/feed.js +77 -4
- package/dist/commands/hooks.js +22 -6
- package/dist/commands/perf.d.ts +14 -0
- package/dist/commands/perf.js +221 -0
- package/dist/commands/projects.d.ts +12 -0
- package/dist/commands/projects.js +358 -0
- package/dist/commands/routines.js +30 -24
- package/dist/commands/secrets.d.ts +43 -4
- package/dist/commands/secrets.js +217 -32
- package/dist/commands/send.d.ts +5 -1
- package/dist/commands/send.js +1 -1
- package/dist/commands/sessions-picker.d.ts +15 -0
- package/dist/commands/sessions-picker.js +107 -13
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +9 -1
- package/dist/commands/sessions.d.ts +10 -5
- package/dist/commands/sessions.js +65 -27
- package/dist/index.js +19 -3
- package/dist/lib/activity.d.ts +80 -13
- package/dist/lib/activity.js +418 -74
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/catchup.d.ts +105 -0
- package/dist/lib/catchup.js +160 -0
- package/dist/lib/channels/providers/desktop.d.ts +49 -0
- package/dist/lib/channels/providers/desktop.js +132 -0
- package/dist/lib/channels/providers/index.js +2 -0
- package/dist/lib/daemon.js +74 -13
- package/dist/lib/devices/registry.d.ts +14 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/events.d.ts +12 -0
- package/dist/lib/events.js +122 -9
- package/dist/lib/exec.js +10 -0
- package/dist/lib/feed-broadcast.d.ts +47 -0
- package/dist/lib/feed-broadcast.js +65 -1
- package/dist/lib/feed-post.d.ts +10 -0
- package/dist/lib/feed-post.js +9 -3
- package/dist/lib/feed.d.ts +47 -1
- package/dist/lib/feed.js +38 -0
- package/dist/lib/hooks/cache.d.ts +2 -0
- package/dist/lib/hooks/cache.js +24 -4
- package/dist/lib/hosts/remote-cmd.js +4 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +14 -4
- package/dist/lib/menubar/install-menubar.js +20 -6
- package/dist/lib/overdue.d.ts +14 -0
- package/dist/lib/overdue.js +37 -1
- package/dist/lib/perf/db.d.ts +25 -0
- package/dist/lib/perf/db.js +290 -0
- package/dist/lib/perf/spool.d.ts +18 -0
- package/dist/lib/perf/spool.js +79 -0
- package/dist/lib/perf/types.d.ts +45 -0
- package/dist/lib/perf/types.js +2 -0
- package/dist/lib/project-key.d.ts +44 -0
- package/dist/lib/project-key.js +79 -0
- package/dist/lib/project-root.js +16 -0
- package/dist/lib/project-status.d.ts +69 -0
- package/dist/lib/project-status.js +101 -0
- package/dist/lib/projects.d.ts +138 -0
- package/dist/lib/projects.js +301 -0
- package/dist/lib/remote-agents-json.d.ts +9 -0
- package/dist/lib/remote-agents-json.js +11 -5
- package/dist/lib/routines-project.js +6 -0
- package/dist/lib/routines.d.ts +30 -1
- package/dist/lib/routines.js +11 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/list-filter.d.ts +94 -0
- package/dist/lib/secrets/list-filter.js +245 -0
- package/dist/lib/session/bash-command.d.ts +53 -0
- package/dist/lib/session/bash-command.js +364 -0
- package/dist/lib/session/digest.d.ts +13 -0
- package/dist/lib/session/digest.js +48 -1
- package/dist/lib/session/discover.d.ts +1 -2
- package/dist/lib/session/discover.js +7 -24
- package/dist/lib/session/highlights.d.ts +82 -0
- package/dist/lib/session/highlights.js +251 -0
- package/dist/lib/session/parse.js +23 -1
- package/dist/lib/session/relative-time.d.ts +37 -0
- package/dist/lib/session/relative-time.js +96 -8
- package/dist/lib/session/remote-list.js +5 -2
- package/dist/lib/session/render.d.ts +9 -9
- package/dist/lib/session/render.js +112 -73
- package/dist/lib/session/types.d.ts +4 -1
- package/dist/lib/ssh-exec.d.ts +6 -0
- package/dist/lib/ssh-exec.js +10 -1
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/state.d.ts +11 -0
- package/dist/lib/state.js +16 -0
- package/dist/lib/terminal/backends/index.d.ts +10 -2
- package/dist/lib/terminal/backends/index.js +14 -2
- package/dist/lib/terminal/backends/terminal-app.d.ts +13 -0
- package/dist/lib/terminal/backends/terminal-app.js +73 -0
- package/dist/lib/terminal/index.d.ts +2 -1
- package/dist/lib/terminal/index.js +2 -1
- package/dist/lib/terminal/preferred.d.ts +89 -0
- package/dist/lib/terminal/preferred.js +87 -0
- package/dist/lib/terminal/run-surface.d.ts +82 -0
- package/dist/lib/terminal/run-surface.js +146 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/types.d.ts +1 -1
- package/package.json +4 -1
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import * as fs from 'fs';
|
|
20
20
|
import * as path from 'path';
|
|
21
|
-
import { getHookCacheDir, getHookShimsDir, getLogsDir } from '../state.js';
|
|
21
|
+
import { getHookCacheDir, getHookShimsDir, getLogsDir, getPerfDir } from '../state.js';
|
|
22
22
|
/**
|
|
23
23
|
* Parse a `cache:` value from hooks.yaml into the canonical config form.
|
|
24
24
|
* Accepts the shorthand string ("5m", "30s-bg") or the full object form.
|
|
@@ -116,8 +116,9 @@ export function generateHookShim(args) {
|
|
|
116
116
|
const shimsDir = args.paths?.shimsDir ?? getHookShimsDir();
|
|
117
117
|
const cacheDir = args.paths?.cacheDir ?? getHookCacheDir();
|
|
118
118
|
const logsDir = args.paths?.logsDir ?? getLogsDir();
|
|
119
|
+
const perfDir = args.paths?.perfDir ?? getPerfDir();
|
|
119
120
|
const shimPath = resolveContainedHookShimPath(shimsDir, args.name);
|
|
120
|
-
const content = renderShim(args.name, args.scriptPath, args.cache ?? null, args.matches, { cacheDir, logsDir });
|
|
121
|
+
const content = renderShim(args.name, args.scriptPath, args.cache ?? null, args.matches, { cacheDir, logsDir, perfDir });
|
|
121
122
|
fs.mkdirSync(shimsDir, { recursive: true });
|
|
122
123
|
let existing = null;
|
|
123
124
|
if (fs.existsSync(shimPath)) {
|
|
@@ -303,6 +304,12 @@ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
|
303
304
|
LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
|
|
304
305
|
printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%d}\\n' \\
|
|
305
306
|
"$TS" "$HOOK_NAME" "$MS" "none" "$EXIT" >>"$LOG_FILE" 2>/dev/null || true
|
|
307
|
+
# Disposable perf spool → drained into ~/.agents/.cache/perf/perf.db on next agents perf/open.
|
|
308
|
+
mkdir -p "$PERF_DIR" 2>/dev/null || true
|
|
309
|
+
TS_MS=$("$PY" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0)
|
|
310
|
+
HOST=$(hostname 2>/dev/null || echo unknown)
|
|
311
|
+
printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":%d,"cache":"%s","exit_code":%d,"hostname":"%s"}\\n' \\
|
|
312
|
+
"$TS_MS" "$HOOK_NAME" "$MS" "none" "$EXIT" "$HOST" >>"$PERF_SPOOL" 2>/dev/null || true
|
|
306
313
|
|
|
307
314
|
exit "$EXIT"`;
|
|
308
315
|
/**
|
|
@@ -322,7 +329,8 @@ function renderShim(name, scriptPath, cache, matches, paths) {
|
|
|
322
329
|
const ttl = cache ? (typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0)) : 0;
|
|
323
330
|
const key = cache?.key ?? 'global';
|
|
324
331
|
const prefetch = cache?.prefetch ?? 'none';
|
|
325
|
-
const { cacheDir, logsDir } = paths;
|
|
332
|
+
const { cacheDir, logsDir, perfDir } = paths;
|
|
333
|
+
const perfSpool = path.join(perfDir, 'spool.jsonl');
|
|
326
334
|
const hasMatches = matches != null && Object.keys(matches).length > 0;
|
|
327
335
|
const matchesJson = hasMatches ? JSON.stringify(matches) : '';
|
|
328
336
|
// sh-escape: wrap in single quotes, escape any embedded single quotes.
|
|
@@ -342,12 +350,14 @@ HOOK_NAME=${q(name)}
|
|
|
342
350
|
SOURCE=${q(scriptPath)}
|
|
343
351
|
CACHE_DIR=${q(cacheDir)}
|
|
344
352
|
LOGS_DIR=${q(logsDir)}
|
|
353
|
+
PERF_DIR=${q(perfDir)}
|
|
354
|
+
PERF_SPOOL=${q(perfSpool)}
|
|
345
355
|
TTL=${ttl}
|
|
346
356
|
PREFETCH=${q(prefetch)}
|
|
347
357
|
KEY_MODE=${q(key)}
|
|
348
358
|
MATCHES_JSON=${q(matchesJson)}
|
|
349
359
|
|
|
350
|
-
mkdir -p "$CACHE_DIR" "$LOGS_DIR"
|
|
360
|
+
mkdir -p "$CACHE_DIR" "$LOGS_DIR" "$PERF_DIR"
|
|
351
361
|
|
|
352
362
|
# Resolve a real Python. On Windows, bare python3 is often a Microsoft Store
|
|
353
363
|
# app-execution alias stub that prints to stderr and exits non-zero (0 bytes on
|
|
@@ -380,6 +390,11 @@ if [ -n "$MATCHES_JSON" ]; then
|
|
|
380
390
|
_LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
|
|
381
391
|
printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":0,"cache":"skip","exit":0}\\n' \\
|
|
382
392
|
"$_TS" "$HOOK_NAME" >>"$_LOG_FILE" 2>/dev/null || true
|
|
393
|
+
mkdir -p "$PERF_DIR" 2>/dev/null || true
|
|
394
|
+
_TS_MS=$("$PY" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0)
|
|
395
|
+
_HOST=$(hostname 2>/dev/null || echo unknown)
|
|
396
|
+
printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":0,"cache":"skip","exit_code":0,"hostname":"%s"}\\n' \\
|
|
397
|
+
"$_TS_MS" "$HOOK_NAME" "$_HOST" >>"$PERF_SPOOL" 2>/dev/null || true
|
|
383
398
|
exit 0
|
|
384
399
|
fi
|
|
385
400
|
fi
|
|
@@ -477,6 +492,11 @@ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
|
477
492
|
LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
|
|
478
493
|
printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%d}\\n' \\
|
|
479
494
|
"$TS" "$HOOK_NAME" "$MS" "$CACHE_STATUS" "$EXIT" >>"$LOG_FILE" 2>/dev/null || true
|
|
495
|
+
# Disposable perf spool → drained into perf.db (see lib/perf/db.ts). Soft keys only.
|
|
496
|
+
TS_MS=$("$PY" -c 'import time; print(int(time.time()*1000))' 2>/dev/null || echo 0)
|
|
497
|
+
HOST=$(hostname 2>/dev/null || echo unknown)
|
|
498
|
+
printf '{"ts_ms":%s,"kind":"hook.fire","label":"%s","duration_ms":%d,"cache":"%s","exit_code":%d,"hostname":"%s"}\\n' \\
|
|
499
|
+
"$TS_MS" "$HOOK_NAME" "$MS" "$CACHE_STATUS" "$EXIT" "$HOST" >>"$PERF_SPOOL" 2>/dev/null || true
|
|
480
500
|
|
|
481
501
|
exit "$EXIT"
|
|
482
502
|
`;
|
|
@@ -88,6 +88,7 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
88
88
|
autoSecrets: 'forward', // workflow frontmatter secrets resolve on the REMOTE keychain
|
|
89
89
|
emitSessionId: 'forward', // remote prints its session id as a stdout sentinel the launcher captures (session-marker.ts)
|
|
90
90
|
// rejected — cannot cross the SSH boundary; fail loud, never degrade
|
|
91
|
+
terminal: 'reject', // opens a tab on THIS machine's desktop; a remote tab is a different request
|
|
91
92
|
secrets: 'reject',
|
|
92
93
|
secretsKeys: 'reject',
|
|
93
94
|
allowExpired: 'reject',
|
|
@@ -126,6 +127,9 @@ export const RUN_OPTION_FORWARDING = {
|
|
|
126
127
|
};
|
|
127
128
|
/** Actionable messages for value-aware rejections, keyed by attribute name. */
|
|
128
129
|
export const RUN_OPTION_REJECT_MESSAGES = {
|
|
130
|
+
terminal: '--terminal opens a tab on THIS machine; it cannot be combined with --host. ' +
|
|
131
|
+
'Drop --terminal to dispatch to the host, or drop --host to open the tab here. ' +
|
|
132
|
+
'To watch a remote run in a terminal, dispatch it and follow with `agents sessions focus <id>`.',
|
|
129
133
|
secrets: '--secrets cannot cross the SSH boundary — Keychain values are never sent to a host implicitly. ' +
|
|
130
134
|
'Provision the bundle on the host first (agents secrets export --host <name>), then run without --secrets; ' +
|
|
131
135
|
'workflow frontmatter secrets resolve from the HOST\'s own keychain.',
|
|
Binary file
|
|
@@ -30,7 +30,16 @@ export declare function menubarServiceInstalled(): boolean;
|
|
|
30
30
|
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
31
31
|
export declare function codesignVerifies(appPath: string): boolean;
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
33
|
+
* True when Gatekeeper will let the bundle execute on this machine.
|
|
34
|
+
* A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
|
|
35
|
+
* which macOS surfaces as "the app is damaged" and can crash AppKit during
|
|
36
|
+
* launch. This is separate from `codesign --verify`: a signature can be valid
|
|
37
|
+
* while Gatekeeper still refuses to run it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function gatekeeperAssesses(appPath: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
|
|
42
|
+
* machine.
|
|
34
43
|
*
|
|
35
44
|
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
36
45
|
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
@@ -42,9 +51,10 @@ export declare function codesignVerifies(appPath: string): boolean;
|
|
|
42
51
|
* matching cdhash, which the kernel accepts.
|
|
43
52
|
*
|
|
44
53
|
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
45
|
-
* still verifies —
|
|
46
|
-
*
|
|
47
|
-
*
|
|
54
|
+
* still verifies — but if the release was not notarized, Gatekeeper rejects it.
|
|
55
|
+
* In that case we strip the quarantine xattr and re-sign ad-hoc so the helper
|
|
56
|
+
* can launch locally. The stable fix is to notarize the release build; this
|
|
57
|
+
* fallback just prevents a crash-loop while the user is on an un-notarized cut.
|
|
48
58
|
*/
|
|
49
59
|
export declare function ensureValidSignature(appPath: string): boolean;
|
|
50
60
|
/**
|
|
@@ -177,7 +177,19 @@ export function codesignVerifies(appPath) {
|
|
|
177
177
|
return r.status === 0;
|
|
178
178
|
}
|
|
179
179
|
/**
|
|
180
|
-
*
|
|
180
|
+
* True when Gatekeeper will let the bundle execute on this machine.
|
|
181
|
+
* A Developer-ID-signed but un-notarized app is rejected by `spctl --assess`,
|
|
182
|
+
* which macOS surfaces as "the app is damaged" and can crash AppKit during
|
|
183
|
+
* launch. This is separate from `codesign --verify`: a signature can be valid
|
|
184
|
+
* while Gatekeeper still refuses to run it.
|
|
185
|
+
*/
|
|
186
|
+
export function gatekeeperAssesses(appPath) {
|
|
187
|
+
const r = spawnSync('spctl', ['--assess', '--type', 'exec', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
188
|
+
return r.status === 0;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Guarantee the installed bundle has a signature Gatekeeper will accept on THIS
|
|
192
|
+
* machine.
|
|
181
193
|
*
|
|
182
194
|
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
183
195
|
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
@@ -189,15 +201,17 @@ export function codesignVerifies(appPath) {
|
|
|
189
201
|
* matching cdhash, which the kernel accepts.
|
|
190
202
|
*
|
|
191
203
|
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
192
|
-
* still verifies —
|
|
193
|
-
*
|
|
194
|
-
*
|
|
204
|
+
* still verifies — but if the release was not notarized, Gatekeeper rejects it.
|
|
205
|
+
* In that case we strip the quarantine xattr and re-sign ad-hoc so the helper
|
|
206
|
+
* can launch locally. The stable fix is to notarize the release build; this
|
|
207
|
+
* fallback just prevents a crash-loop while the user is on an un-notarized cut.
|
|
195
208
|
*/
|
|
196
209
|
export function ensureValidSignature(appPath) {
|
|
197
|
-
if (codesignVerifies(appPath))
|
|
210
|
+
if (codesignVerifies(appPath) && gatekeeperAssesses(appPath))
|
|
198
211
|
return true;
|
|
199
212
|
// Drop any quarantine/xattrs the tarball round-trip added (they can break
|
|
200
|
-
// codesign), then re-sign ad-hoc under the helper's
|
|
213
|
+
// both codesign and Gatekeeper), then re-sign ad-hoc under the helper's
|
|
214
|
+
// stable bundle identifier.
|
|
201
215
|
spawnSync('xattr', ['-cr', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
202
216
|
spawnSync('codesign', ['--force', '--sign', '-', '--identifier', SERVICE_LABEL, appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
203
217
|
return codesignVerifies(appPath);
|
package/dist/lib/overdue.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Surfaced two ways: a desktop notification on daemon startup, and a
|
|
12
12
|
* `agents routines catchup` command that runs them on demand.
|
|
13
13
|
*/
|
|
14
|
+
import { type JobConfig } from './routines.js';
|
|
14
15
|
export interface OverdueJob {
|
|
15
16
|
name: string;
|
|
16
17
|
/** Most recent expected fire time per the cron expression. */
|
|
@@ -18,6 +19,19 @@ export interface OverdueJob {
|
|
|
18
19
|
/** Start time of the most recent recorded run, or null if never run. */
|
|
19
20
|
lastRanAt: Date | null;
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* When a routine started existing, and therefore the earliest fire it can
|
|
24
|
+
* sensibly be judged against.
|
|
25
|
+
*
|
|
26
|
+
* `createdAt` is stamped by `writeJob`. Routines written before that field
|
|
27
|
+
* existed have none, so the file's own mtime stands in — it is the closest
|
|
28
|
+
* honest answer available on disk, and it only ever moves the floor later,
|
|
29
|
+
* never earlier, so it cannot manufacture a false "overdue".
|
|
30
|
+
*
|
|
31
|
+
* Returns null when neither is available, which leaves the routine unfloored
|
|
32
|
+
* (previous behaviour) rather than silently skipping it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function routineEffectiveStart(job: JobConfig): Date | null;
|
|
21
35
|
/** Return every enabled, recurring job whose most recent expected fire was
|
|
22
36
|
* missed. One-shot jobs are excluded — they fire at most once. */
|
|
23
37
|
export declare function detectOverdueJobs(now?: Date): OverdueJob[];
|
package/dist/lib/overdue.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* Surfaced two ways: a desktop notification on daemon startup, and a
|
|
12
12
|
* `agents routines catchup` command that runs them on demand.
|
|
13
13
|
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
14
15
|
import { Cron } from 'croner';
|
|
15
|
-
import { listJobs, getLatestRun, jobRunsOnThisDevice } from './routines.js';
|
|
16
|
+
import { listJobs, getLatestRun, getJobPath, jobRunsOnThisDevice } from './routines.js';
|
|
16
17
|
import { notifyDesktop } from './menubar/notify-desktop.js';
|
|
17
18
|
// Tolerance between "expected fire" and "recorded run start" — accounts for
|
|
18
19
|
// the small gap between the cron tick and when the runner writes meta.json.
|
|
@@ -36,6 +37,34 @@ function previousExpectedFire(cron, now) {
|
|
|
36
37
|
}
|
|
37
38
|
return last;
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* When a routine started existing, and therefore the earliest fire it can
|
|
42
|
+
* sensibly be judged against.
|
|
43
|
+
*
|
|
44
|
+
* `createdAt` is stamped by `writeJob`. Routines written before that field
|
|
45
|
+
* existed have none, so the file's own mtime stands in — it is the closest
|
|
46
|
+
* honest answer available on disk, and it only ever moves the floor later,
|
|
47
|
+
* never earlier, so it cannot manufacture a false "overdue".
|
|
48
|
+
*
|
|
49
|
+
* Returns null when neither is available, which leaves the routine unfloored
|
|
50
|
+
* (previous behaviour) rather than silently skipping it.
|
|
51
|
+
*/
|
|
52
|
+
export function routineEffectiveStart(job) {
|
|
53
|
+
if (job.createdAt) {
|
|
54
|
+
const stamped = new Date(job.createdAt);
|
|
55
|
+
if (!isNaN(stamped.getTime()))
|
|
56
|
+
return stamped;
|
|
57
|
+
}
|
|
58
|
+
const path = getJobPath(job.name);
|
|
59
|
+
if (!path)
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
return new Date(fs.statSync(path).mtimeMs);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
39
68
|
/** Return every enabled, recurring job whose most recent expected fire was
|
|
40
69
|
* missed. One-shot jobs are excluded — they fire at most once. */
|
|
41
70
|
export function detectOverdueJobs(now = new Date()) {
|
|
@@ -66,6 +95,13 @@ export function detectOverdueJobs(now = new Date()) {
|
|
|
66
95
|
}
|
|
67
96
|
if (!expected)
|
|
68
97
|
continue;
|
|
98
|
+
// A fire that predates the routine never could have happened, so it is not
|
|
99
|
+
// a miss. Without this, any newly created routine on a daily/weekly cron is
|
|
100
|
+
// instantly "overdue" for the previous occurrence — and with auto-catchup
|
|
101
|
+
// that means `agents routines add` runs the routine once, immediately.
|
|
102
|
+
const start = routineEffectiveStart(job);
|
|
103
|
+
if (start && expected.getTime() < start.getTime())
|
|
104
|
+
continue;
|
|
69
105
|
const latest = getLatestRun(job.name);
|
|
70
106
|
const lastRanAt = latest ? new Date(latest.startedAt) : null;
|
|
71
107
|
const isOverdue = !lastRanAt || lastRanAt.getTime() < expected.getTime() - GRACE_MS;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disposable performance warehouse — SQLite under ~/.agents/.cache/perf/.
|
|
3
|
+
*
|
|
4
|
+
* Opened only by `agents perf` / `hooks profile` (read path). Writers use
|
|
5
|
+
* {@link recordSample} in `./spool.ts` (NDJSON, no SQLite).
|
|
6
|
+
*/
|
|
7
|
+
import Database from '../sqlite.js';
|
|
8
|
+
import type { AggregateOptions, PerfAggregateRow } from './types.js';
|
|
9
|
+
export type { AggregateOptions, PerfAggregateRow, PerfSample } from './types.js';
|
|
10
|
+
export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
|
|
11
|
+
export declare const PERF_SCHEMA_VERSION = 1;
|
|
12
|
+
export declare const DEFAULT_RETENTION_DAYS = 30;
|
|
13
|
+
/** Test seam — redirect the warehouse path (like AGENTS_EVENTS_PATH). */
|
|
14
|
+
export declare function _resetPerfDbForTest(overridePath?: string | null): void;
|
|
15
|
+
/** Drain the NDJSON spool into samples. Idempotent; truncates on success. */
|
|
16
|
+
export declare function drainSpool(db?: Database.Database): number;
|
|
17
|
+
/** Percentile of a sorted-ascending array. p in [0,100]. */
|
|
18
|
+
export declare function percentile(sorted: number[], p: number): number;
|
|
19
|
+
/**
|
|
20
|
+
* Aggregate samples by (kind, label) with p50/p99. Drains the spool first.
|
|
21
|
+
*/
|
|
22
|
+
export declare function aggregateSamples(opts?: AggregateOptions): PerfAggregateRow[];
|
|
23
|
+
export declare function perfDbPath(): string;
|
|
24
|
+
export declare function perfSpoolPath(): string;
|
|
25
|
+
export declare function ensurePerfDir(): string;
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disposable performance warehouse — SQLite under ~/.agents/.cache/perf/.
|
|
3
|
+
*
|
|
4
|
+
* Opened only by `agents perf` / `hooks profile` (read path). Writers use
|
|
5
|
+
* {@link recordSample} in `./spool.ts` (NDJSON, no SQLite).
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import Database from '../sqlite.js';
|
|
10
|
+
import { getPerfDbPath, getPerfDir } from '../state.js';
|
|
11
|
+
import { localMachineId } from '../session/origin-machine.js';
|
|
12
|
+
import { resolveSpoolPath, shortSessionId, _resetPerfSpoolForTest } from './spool.js';
|
|
13
|
+
export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
|
|
14
|
+
export const PERF_SCHEMA_VERSION = 1;
|
|
15
|
+
export const DEFAULT_RETENTION_DAYS = 30;
|
|
16
|
+
const SCHEMA = `
|
|
17
|
+
CREATE TABLE IF NOT EXISTS samples (
|
|
18
|
+
id INTEGER PRIMARY KEY,
|
|
19
|
+
ts_ms INTEGER NOT NULL,
|
|
20
|
+
kind TEXT NOT NULL,
|
|
21
|
+
label TEXT NOT NULL,
|
|
22
|
+
duration_ms REAL NOT NULL,
|
|
23
|
+
session_id TEXT,
|
|
24
|
+
session_short TEXT,
|
|
25
|
+
agent TEXT,
|
|
26
|
+
agent_version TEXT,
|
|
27
|
+
machine TEXT,
|
|
28
|
+
hostname TEXT,
|
|
29
|
+
actor TEXT,
|
|
30
|
+
cwd TEXT,
|
|
31
|
+
cache TEXT,
|
|
32
|
+
exit_code INTEGER,
|
|
33
|
+
status TEXT,
|
|
34
|
+
meta_json TEXT
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_perf_ts ON samples(ts_ms);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_perf_kind_ts ON samples(kind, ts_ms);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_perf_label_ts ON samples(label, ts_ms);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_perf_machine_ts ON samples(machine, ts_ms);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_perf_session ON samples(session_id);
|
|
41
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
42
|
+
key TEXT PRIMARY KEY,
|
|
43
|
+
value TEXT NOT NULL
|
|
44
|
+
);
|
|
45
|
+
`;
|
|
46
|
+
let _db = null;
|
|
47
|
+
let _dbPath = null;
|
|
48
|
+
let _disabled = false;
|
|
49
|
+
/** Test seam — redirect the warehouse path (like AGENTS_EVENTS_PATH). */
|
|
50
|
+
export function _resetPerfDbForTest(overridePath) {
|
|
51
|
+
if (_db) {
|
|
52
|
+
try {
|
|
53
|
+
_db.close();
|
|
54
|
+
}
|
|
55
|
+
catch { /* ignore */ }
|
|
56
|
+
}
|
|
57
|
+
_db = null;
|
|
58
|
+
_dbPath = overridePath === undefined ? null : overridePath;
|
|
59
|
+
_disabled = false;
|
|
60
|
+
if (overridePath) {
|
|
61
|
+
_resetPerfSpoolForTest(path.join(path.dirname(overridePath), 'spool.jsonl'));
|
|
62
|
+
}
|
|
63
|
+
else if (overridePath === null) {
|
|
64
|
+
_resetPerfSpoolForTest(null);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function resolveDbPath() {
|
|
68
|
+
return process.env.AGENTS_PERF_DB || _dbPath || getPerfDbPath();
|
|
69
|
+
}
|
|
70
|
+
function isDisabled() {
|
|
71
|
+
if (_disabled)
|
|
72
|
+
return true;
|
|
73
|
+
const v = process.env.AGENTS_DISABLE_PERF;
|
|
74
|
+
return v === '1' || v === 'true';
|
|
75
|
+
}
|
|
76
|
+
function openDb() {
|
|
77
|
+
if (isDisabled())
|
|
78
|
+
return null;
|
|
79
|
+
const dbPath = resolveDbPath();
|
|
80
|
+
if (_db && _dbPath === dbPath)
|
|
81
|
+
return _db;
|
|
82
|
+
if (_db) {
|
|
83
|
+
try {
|
|
84
|
+
_db.close();
|
|
85
|
+
}
|
|
86
|
+
catch { /* ignore */ }
|
|
87
|
+
_db = null;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 });
|
|
91
|
+
const db = new Database(dbPath);
|
|
92
|
+
db.pragma('journal_mode = WAL');
|
|
93
|
+
db.pragma('synchronous = NORMAL');
|
|
94
|
+
db.pragma('busy_timeout = 2000');
|
|
95
|
+
db.exec(SCHEMA);
|
|
96
|
+
const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
|
|
97
|
+
if (!row) {
|
|
98
|
+
db.prepare(`INSERT INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(PERF_SCHEMA_VERSION));
|
|
99
|
+
}
|
|
100
|
+
_db = db;
|
|
101
|
+
_dbPath = dbPath;
|
|
102
|
+
drainSpool(db);
|
|
103
|
+
maybeRetain(db);
|
|
104
|
+
return db;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
_disabled = true;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Drain the NDJSON spool into samples. Idempotent; truncates on success. */
|
|
112
|
+
export function drainSpool(db) {
|
|
113
|
+
const spool = resolveSpoolPath();
|
|
114
|
+
if (!fs.existsSync(spool))
|
|
115
|
+
return 0;
|
|
116
|
+
let raw;
|
|
117
|
+
try {
|
|
118
|
+
raw = fs.readFileSync(spool, 'utf-8');
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
if (!raw.trim()) {
|
|
124
|
+
try {
|
|
125
|
+
fs.writeFileSync(spool, '');
|
|
126
|
+
}
|
|
127
|
+
catch { /* ignore */ }
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
const target = db ?? openDb();
|
|
131
|
+
if (!target)
|
|
132
|
+
return 0;
|
|
133
|
+
let n = 0;
|
|
134
|
+
const insert = target.prepare(`
|
|
135
|
+
INSERT INTO samples (
|
|
136
|
+
ts_ms, kind, label, duration_ms,
|
|
137
|
+
session_id, session_short, agent, agent_version,
|
|
138
|
+
machine, hostname, actor, cwd,
|
|
139
|
+
cache, exit_code, status, meta_json
|
|
140
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
141
|
+
`);
|
|
142
|
+
const txn = target.transaction((lines) => {
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
if (!line.trim())
|
|
145
|
+
continue;
|
|
146
|
+
let o;
|
|
147
|
+
try {
|
|
148
|
+
o = JSON.parse(line);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const label = String(o.label ?? o.hook ?? '');
|
|
154
|
+
const durationMs = Number(o.duration_ms ?? o.durationMs ?? o.ms);
|
|
155
|
+
if (!label || !Number.isFinite(durationMs))
|
|
156
|
+
continue;
|
|
157
|
+
const sessionId = o.session_id != null ? String(o.session_id)
|
|
158
|
+
: o.sessionId != null ? String(o.sessionId) : null;
|
|
159
|
+
const tsMs = Number(o.ts_ms ?? o.tsMs) || (typeof o.ts === 'string' ? Date.parse(o.ts) : Date.now());
|
|
160
|
+
insert.run(Number.isFinite(tsMs) ? tsMs : Date.now(), String(o.kind ?? 'hook.fire'), label, durationMs, sessionId, o.session_short != null ? String(o.session_short) : shortSessionId(sessionId) ?? null, o.agent != null ? String(o.agent) : null, o.agent_version != null ? String(o.agent_version) : o.agentVersion != null ? String(o.agentVersion) : null, o.machine != null ? String(o.machine) : localMachineId(), o.hostname != null ? String(o.hostname) : null, o.actor != null ? String(o.actor) : null, o.cwd != null ? String(o.cwd) : null, o.cache != null ? String(o.cache) : null, o.exit_code != null ? Number(o.exit_code) : o.exit != null ? Number(o.exit) : null, o.status != null ? String(o.status) : null, o.meta_json != null ? String(o.meta_json) : null);
|
|
161
|
+
n++;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
txn(raw.split('\n'));
|
|
166
|
+
fs.writeFileSync(spool, '');
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
return n;
|
|
172
|
+
}
|
|
173
|
+
function maybeRetain(db) {
|
|
174
|
+
try {
|
|
175
|
+
const last = db.prepare(`SELECT value FROM meta WHERE key = 'last_retain_ms'`).get();
|
|
176
|
+
const lastMs = last ? parseInt(last.value, 10) : 0;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
if (now - lastMs < 3_600_000)
|
|
179
|
+
return;
|
|
180
|
+
const cutoff = now - DEFAULT_RETENTION_DAYS * 86_400_000;
|
|
181
|
+
db.prepare(`DELETE FROM samples WHERE ts_ms < ?`).run(cutoff);
|
|
182
|
+
db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('last_retain_ms', ?)`).run(String(now));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// ignore
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Percentile of a sorted-ascending array. p in [0,100]. */
|
|
189
|
+
export function percentile(sorted, p) {
|
|
190
|
+
if (sorted.length === 0)
|
|
191
|
+
return 0;
|
|
192
|
+
if (sorted.length === 1)
|
|
193
|
+
return sorted[0];
|
|
194
|
+
const rank = (p / 100) * (sorted.length - 1);
|
|
195
|
+
const lo = Math.floor(rank);
|
|
196
|
+
const hi = Math.ceil(rank);
|
|
197
|
+
if (lo === hi)
|
|
198
|
+
return sorted[lo];
|
|
199
|
+
const frac = rank - lo;
|
|
200
|
+
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Aggregate samples by (kind, label) with p50/p99. Drains the spool first.
|
|
204
|
+
*/
|
|
205
|
+
export function aggregateSamples(opts = {}) {
|
|
206
|
+
const db = openDb();
|
|
207
|
+
if (!db)
|
|
208
|
+
return [];
|
|
209
|
+
drainSpool(db);
|
|
210
|
+
const days = opts.days ?? 7;
|
|
211
|
+
const sinceMs = Date.now() - days * 86_400_000;
|
|
212
|
+
const minN = opts.minN ?? 1;
|
|
213
|
+
const clauses = ['ts_ms >= ?'];
|
|
214
|
+
const params = [sinceMs];
|
|
215
|
+
if (opts.kinds && opts.kinds.length > 0) {
|
|
216
|
+
clauses.push(`kind IN (${opts.kinds.map(() => '?').join(',')})`);
|
|
217
|
+
params.push(...opts.kinds);
|
|
218
|
+
}
|
|
219
|
+
if (opts.label) {
|
|
220
|
+
clauses.push('label = ?');
|
|
221
|
+
params.push(opts.label);
|
|
222
|
+
}
|
|
223
|
+
if (opts.machine) {
|
|
224
|
+
clauses.push('machine = ?');
|
|
225
|
+
params.push(opts.machine);
|
|
226
|
+
}
|
|
227
|
+
if (opts.agent) {
|
|
228
|
+
clauses.push('agent = ?');
|
|
229
|
+
params.push(opts.agent);
|
|
230
|
+
}
|
|
231
|
+
const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code
|
|
232
|
+
FROM samples WHERE ${clauses.join(' AND ')}`).all(...params);
|
|
233
|
+
const map = new Map();
|
|
234
|
+
for (const r of rows) {
|
|
235
|
+
const key = `${r.kind}\0${r.label}`;
|
|
236
|
+
let b = map.get(key);
|
|
237
|
+
if (!b) {
|
|
238
|
+
b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0 };
|
|
239
|
+
map.set(key, b);
|
|
240
|
+
}
|
|
241
|
+
b.durations.push(Number(r.duration_ms));
|
|
242
|
+
if (r.cache === 'hit')
|
|
243
|
+
b.hits++;
|
|
244
|
+
else if (r.cache === 'stale-prefetch')
|
|
245
|
+
b.stale++;
|
|
246
|
+
else if (r.cache === 'miss' || r.cache === 'none')
|
|
247
|
+
b.misses++;
|
|
248
|
+
if (typeof r.exit_code === 'number' && r.exit_code !== 0)
|
|
249
|
+
b.errors++;
|
|
250
|
+
}
|
|
251
|
+
const out = [];
|
|
252
|
+
for (const b of map.values()) {
|
|
253
|
+
if (b.durations.length < minN)
|
|
254
|
+
continue;
|
|
255
|
+
const sorted = b.durations.slice().sort((a, c) => a - c);
|
|
256
|
+
const n = sorted.length;
|
|
257
|
+
const sum = sorted.reduce((a, c) => a + c, 0);
|
|
258
|
+
const row = {
|
|
259
|
+
kind: b.kind,
|
|
260
|
+
label: b.label,
|
|
261
|
+
n,
|
|
262
|
+
p50Ms: Math.round(percentile(sorted, 50)),
|
|
263
|
+
p99Ms: Math.round(percentile(sorted, 99)),
|
|
264
|
+
meanMs: Math.round(sum / n),
|
|
265
|
+
maxMs: sorted[n - 1],
|
|
266
|
+
minMs: sorted[0],
|
|
267
|
+
};
|
|
268
|
+
if (b.hits + b.stale + b.misses > 0) {
|
|
269
|
+
row.cacheHitPct = Math.round((b.hits / n) * 100);
|
|
270
|
+
row.cacheStalePct = Math.round((b.stale / n) * 100);
|
|
271
|
+
row.cacheMissPct = Math.round((b.misses / n) * 100);
|
|
272
|
+
}
|
|
273
|
+
if (b.errors > 0)
|
|
274
|
+
row.errorCount = b.errors;
|
|
275
|
+
out.push(row);
|
|
276
|
+
}
|
|
277
|
+
out.sort((a, b) => b.p99Ms - a.p99Ms);
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
export function perfDbPath() {
|
|
281
|
+
return resolveDbPath();
|
|
282
|
+
}
|
|
283
|
+
export function perfSpoolPath() {
|
|
284
|
+
return resolveSpoolPath();
|
|
285
|
+
}
|
|
286
|
+
export function ensurePerfDir() {
|
|
287
|
+
const dir = process.env.AGENTS_PERF_DIR || (_dbPath ? path.dirname(_dbPath) : getPerfDir());
|
|
288
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
289
|
+
return dir;
|
|
290
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hot-path perf writers — append-only NDJSON spool, no SQLite.
|
|
3
|
+
*
|
|
4
|
+
* Loaded from the CLI root `postAction` and from `events.ts` timing helpers.
|
|
5
|
+
* Must stay free of `../sqlite.js` so ordinary commands never load node:sqlite
|
|
6
|
+
* (which emits ExperimentalWarning on stderr).
|
|
7
|
+
*/
|
|
8
|
+
import type { PerfSample } from './types.js';
|
|
9
|
+
export type { PerfSample } from './types.js';
|
|
10
|
+
/** Test seam — pair with db._resetPerfDbForTest. */
|
|
11
|
+
export declare function _resetPerfSpoolForTest(spoolPath?: string | null): void;
|
|
12
|
+
export declare function resolveSpoolPath(): string;
|
|
13
|
+
/** Short session id: first 8 chars (sessions.short_id shape). */
|
|
14
|
+
export declare function shortSessionId(sessionId: string | undefined | null): string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Append one sample to the spool. Never throws. Never opens SQLite.
|
|
17
|
+
*/
|
|
18
|
+
export declare function recordSample(sample: PerfSample): void;
|