agent-yes 1.169.1 → 1.170.1
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/dist/{SUPPORTED_CLIS-Bwg9VqLI.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
- package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
- package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
- package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
- package/dist/cli.js +10 -212
- package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
- package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
- package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
- package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/notifyDaemon-D5jtpCAR.js +591 -0
- package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
- package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
- package/dist/pidStore-CLxwp0qI.js +5 -0
- package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
- package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
- package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
- package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
- package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
- package/dist/rustBinary-DsO0IlNq.js +228 -0
- package/dist/{schedule-CtVvSyaN.js → schedule-ChTPIcXf.js} +7 -7
- package/dist/{serve-CLmk5ujF.js → serve-De1gAWNI.js} +120 -26
- package/dist/{setup-CYbXOcpH.js → setup-nlMqyEPB.js} +3 -3
- package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
- package/dist/spawnGate-BFhva-2F.js +5 -0
- package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
- package/dist/{subcommands--EUush_X.js → subcommands-BFlpV23b.js} +1049 -44
- package/dist/subcommands-DLDpwrfG.js +9 -0
- package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
- package/dist/{ts-Bbhf5JbN.js → ts-DsxejviJ.js} +8 -8
- package/dist/{versionChecker-BmWkWhcJ.js → versionChecker-Czk3aHqy.js} +2 -2
- package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
- package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
- package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
- package/lab/ui/console-logic.js +79 -10
- package/lab/ui/index.html +174 -8
- package/lab/ui/landing.html +11 -1
- package/package.json +1 -1
- package/scripts/build-rs.ts +9 -5
- package/ts/badges.spec.ts +51 -1
- package/ts/badges.ts +35 -1
- package/ts/notifyDaemon.spec.ts +276 -0
- package/ts/notifyDaemon.ts +698 -0
- package/ts/notifyInbox.spec.ts +192 -0
- package/ts/notifyInbox.ts +294 -0
- package/ts/notifyRouter.spec.ts +355 -0
- package/ts/notifyRouter.ts +288 -0
- package/ts/notifyStore.spec.ts +308 -0
- package/ts/notifyStore.ts +439 -0
- package/ts/reaper.spec.ts +18 -1
- package/ts/reaper.ts +33 -1
- package/ts/rustBinary.ts +30 -0
- package/ts/serve.ts +180 -14
- package/ts/subcommands.spec.ts +341 -0
- package/ts/subcommands.ts +764 -29
- package/ts/workspaceConfig.spec.ts +48 -0
- package/ts/workspaceConfig.ts +46 -3
- package/dist/SUPPORTED_CLIS-DiL99G3j.js +0 -8
- package/dist/pidStore-5qyLX3Fh.js +0 -5
- package/dist/spawnGate-CQ1Il3Xk.js +0 -5
- package/dist/subcommands-1ZcaHYkb.js +0 -9
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import {
|
|
4
|
+
type NotifyEvent,
|
|
5
|
+
notifyDir,
|
|
6
|
+
filterSinceSeq,
|
|
7
|
+
filterSinceTs,
|
|
8
|
+
filterUnread,
|
|
9
|
+
inboxPath,
|
|
10
|
+
inboxesToGC,
|
|
11
|
+
liveWatcherPids,
|
|
12
|
+
maxSeq,
|
|
13
|
+
nextSeq,
|
|
14
|
+
parseCursor,
|
|
15
|
+
parseInboxText,
|
|
16
|
+
rotateKeep,
|
|
17
|
+
serializeCursor,
|
|
18
|
+
serializeEvent,
|
|
19
|
+
} from "./notifyInbox.ts";
|
|
20
|
+
|
|
21
|
+
const ev = (over: Partial<NotifyEvent> = {}): NotifyEvent => ({
|
|
22
|
+
seq: 1,
|
|
23
|
+
ts: 1_000,
|
|
24
|
+
host: "h1",
|
|
25
|
+
parent_pid: 1,
|
|
26
|
+
child_pid: 100,
|
|
27
|
+
cli: "claude",
|
|
28
|
+
cwd: "/repo",
|
|
29
|
+
edge: "idle",
|
|
30
|
+
prev_state: "active",
|
|
31
|
+
state: "idle",
|
|
32
|
+
question: null,
|
|
33
|
+
...over,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("notifyInbox — paths", () => {
|
|
37
|
+
it("namespaces the inbox by host and parent pid", () => {
|
|
38
|
+
const p = inboxPath("my-host", 42);
|
|
39
|
+
expect(p).toContain("notify");
|
|
40
|
+
expect(p.endsWith("42.ndjson")).toBe(true);
|
|
41
|
+
expect(p).toContain("my-host");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("sanitizes an unsafe host so the path stays inside the notify dir", () => {
|
|
45
|
+
const p = path.resolve(inboxPath("../../etc", 1));
|
|
46
|
+
// Separators are stripped, so the resolved path can't climb out of notify/
|
|
47
|
+
// even though the dots survive as harmless literal filename chars.
|
|
48
|
+
expect(p.startsWith(path.resolve(notifyDir()) + path.sep)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("notifyInbox — NDJSON round-trip + torn-line tolerance", () => {
|
|
53
|
+
it("serializes and re-parses an event", () => {
|
|
54
|
+
const line = serializeEvent(ev({ seq: 7 }));
|
|
55
|
+
const [got] = parseInboxText(line);
|
|
56
|
+
expect(got!.seq).toBe(7);
|
|
57
|
+
expect(got!.edge).toBe("idle");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("skips a torn final line from a mid-append writer", () => {
|
|
61
|
+
const text = serializeEvent(ev({ seq: 1 })) + "\n" + serializeEvent(ev({ seq: 2 })) + "\n{ \"seq\": 3, \"ed";
|
|
62
|
+
const got = parseInboxText(text);
|
|
63
|
+
expect(got.map((e) => e.seq)).toEqual([1, 2]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("ignores blank lines and non-event JSON", () => {
|
|
67
|
+
const text = ["", serializeEvent(ev({ seq: 1 })), " ", "42", '{"foo":"bar"}'].join("\n");
|
|
68
|
+
const got = parseInboxText(text);
|
|
69
|
+
expect(got.map((e) => e.seq)).toEqual([1]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("drops a partial event missing correlation fields (full validation)", () => {
|
|
73
|
+
const partial = JSON.stringify({ seq: 2, edge: "idle" }); // no parent_pid/child_pid/cwd
|
|
74
|
+
const badEdge = JSON.stringify({ ...ev({ seq: 3 }), edge: "bogus" });
|
|
75
|
+
const text = [serializeEvent(ev({ seq: 1 })), partial, badEdge].join("\n");
|
|
76
|
+
// Only the fully-formed event survives to a consumer's output.
|
|
77
|
+
expect(parseInboxText(text).map((e) => e.seq)).toEqual([1]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("notifyInbox — seq allocation", () => {
|
|
82
|
+
it("nextSeq increments the last stored seq", () => {
|
|
83
|
+
expect(nextSeq(0)).toBe(1);
|
|
84
|
+
expect(nextSeq(41)).toBe(42);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("nextSeq treats a missing/garbage counter as 0", () => {
|
|
88
|
+
expect(nextSeq(NaN)).toBe(1);
|
|
89
|
+
expect(nextSeq(-5)).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("maxSeq finds the highest seq in an inbox (0 when empty)", () => {
|
|
93
|
+
expect(maxSeq([])).toBe(0);
|
|
94
|
+
expect(maxSeq([ev({ seq: 3 }), ev({ seq: 9 }), ev({ seq: 5 })])).toBe(9);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("notifyInbox — watermark filtering", () => {
|
|
99
|
+
const events = [ev({ seq: 1, ts: 100 }), ev({ seq: 2, ts: 200 }), ev({ seq: 3, ts: 300 })];
|
|
100
|
+
|
|
101
|
+
it("filterSinceSeq returns strictly-greater seqs", () => {
|
|
102
|
+
expect(filterSinceSeq(events, 1).map((e) => e.seq)).toEqual([2, 3]);
|
|
103
|
+
expect(filterSinceSeq(events, 0).map((e) => e.seq)).toEqual([1, 2, 3]);
|
|
104
|
+
expect(filterSinceSeq(events, undefined).map((e) => e.seq)).toEqual([1, 2, 3]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("filterSinceTs returns events at/after a wall-clock bound", () => {
|
|
108
|
+
expect(filterSinceTs(events, 200).map((e) => e.seq)).toEqual([2, 3]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("filterUnread returns seqs above the cursor", () => {
|
|
112
|
+
expect(filterUnread(events, 2).map((e) => e.seq)).toEqual([3]);
|
|
113
|
+
expect(filterUnread(events, 0).map((e) => e.seq)).toEqual([1, 2, 3]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("notifyInbox — cursor", () => {
|
|
118
|
+
it("round-trips a cursor", () => {
|
|
119
|
+
expect(parseCursor(serializeCursor(5))).toEqual({ seq: 5 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reads a missing/garbage cursor as seq 0", () => {
|
|
123
|
+
expect(parseCursor(null)).toEqual({ seq: 0 });
|
|
124
|
+
expect(parseCursor("not json")).toEqual({ seq: 0 });
|
|
125
|
+
expect(parseCursor('{"seq":-1}')).toEqual({ seq: 0 });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("notifyInbox — retention", () => {
|
|
130
|
+
it("GCs an inbox whose parent is dead and unreferenced by any live child", () => {
|
|
131
|
+
const gc = inboxesToGC([1, 2, 3], new Set([2]), new Set([3]));
|
|
132
|
+
// parent 1: dead + no live child → GC. parent 2: alive → keep. parent 3:
|
|
133
|
+
// referenced by a live child → keep.
|
|
134
|
+
expect(gc).toEqual([1]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("keeps everything when all parents are alive", () => {
|
|
138
|
+
expect(inboxesToGC([1, 2], new Set([1, 2]), new Set())).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("notifyInbox — rotation", () => {
|
|
143
|
+
const many = () => Array.from({ length: 500 }, (_, i) => ev({ seq: i + 1 }));
|
|
144
|
+
|
|
145
|
+
it("keeps EVERYTHING when nothing is acked (min cursor 0) — at-least-once", () => {
|
|
146
|
+
// Every event is unacked, so none may be dropped even under a tiny cap. The
|
|
147
|
+
// inbox only shrinks once a consumer acks (advances its cursor).
|
|
148
|
+
const kept = rotateKeep(many(), 1, 0, 10);
|
|
149
|
+
expect(kept.length).toBe(500);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("trims ACKED events (below the min cursor) under the byte cap", () => {
|
|
153
|
+
// seq 1..495 acked (min cursor 495), 496..500 unacked → only acked ones are
|
|
154
|
+
// eligible for eviction under the tiny cap.
|
|
155
|
+
const kept = rotateKeep(many(), 1, 495, 5);
|
|
156
|
+
expect(kept.length).toBeLessThan(500);
|
|
157
|
+
expect(kept[kept.length - 1]!.seq).toBe(500); // newest retained
|
|
158
|
+
expect(kept.some((e) => e.seq === 1)).toBe(false); // old acked evicted
|
|
159
|
+
for (let seq = 496; seq <= 500; seq++) expect(kept.some((e) => e.seq === seq)).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("NEVER evicts an unacked event above the min cursor (at-least-once)", () => {
|
|
163
|
+
// protectAboveSeq=490 → every event with seq>490 must survive even under a
|
|
164
|
+
// tiny cap and a small minKeep. This is the guarantee codex flagged.
|
|
165
|
+
const kept = rotateKeep(many(), 1, 490, 5);
|
|
166
|
+
for (let seq = 491; seq <= 500; seq++) {
|
|
167
|
+
expect(kept.some((e) => e.seq === seq)).toBe(true);
|
|
168
|
+
}
|
|
169
|
+
expect(kept.length).toBeLessThan(500); // older, acked events still trimmed
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns everything when under minKeep", () => {
|
|
173
|
+
const events = [ev({ seq: 1 }), ev({ seq: 2 })];
|
|
174
|
+
expect(rotateKeep(events, 1, 0, 100).map((e) => e.seq)).toEqual([1, 2]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("notifyInbox — watcher liveness", () => {
|
|
179
|
+
it("counts only heartbeats within the TTL", () => {
|
|
180
|
+
const now = 100_000;
|
|
181
|
+
const live = liveWatcherPids(
|
|
182
|
+
[
|
|
183
|
+
{ pid: 1, ts: now - 1_000 }, // fresh
|
|
184
|
+
{ pid: 2, ts: now - 999_999 }, // stale
|
|
185
|
+
{ pid: 3, ts: now }, // fresh
|
|
186
|
+
],
|
|
187
|
+
now,
|
|
188
|
+
15_000,
|
|
189
|
+
);
|
|
190
|
+
expect([...live].sort()).toEqual([1, 3]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, fs-free core for the subagent→parent notification inbox — the storage
|
|
3
|
+
* and watermark layer under `ay notify` / `ay notifyd`.
|
|
4
|
+
*
|
|
5
|
+
* Motivation (real incident): a parent agent fanned out sub-agents that finished
|
|
6
|
+
* their work but sat at an idle `❯` prompt WITHOUT exiting (claude-yes does not
|
|
7
|
+
* exit on idle). Claude Code's background-task notification only fires on process
|
|
8
|
+
* EXIT, so the parent never learned the children went idle and left them parked
|
|
9
|
+
* 16 minutes. `ay ls --watch` already streams transitions, but it is PULL: the
|
|
10
|
+
* parent must run the watch loop. The whole point of this pain is the parent is
|
|
11
|
+
* NOT watching. So we accumulate qualifying edges into a per-parent append-only
|
|
12
|
+
* inbox that the parent drains on its own schedule (its Monitor loop), with a
|
|
13
|
+
* persisted cursor so a parent that restarts reads only the unread edges.
|
|
14
|
+
*
|
|
15
|
+
* This module is the pure part (path math, NDJSON (de)serialization, seq/cursor
|
|
16
|
+
* filtering, retention decisions) so it is trivially unit-testable, mirroring
|
|
17
|
+
* `lsWatch.ts` / `needsInput.ts` / `resultEnvelope.ts`. The fs read/write + lock
|
|
18
|
+
* + CLI live in `subcommands.ts`; the detection/debounce lives in
|
|
19
|
+
* `notifyRouter.ts`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import path from "path";
|
|
23
|
+
import { agentYesHome } from "./agentYesHome.ts";
|
|
24
|
+
|
|
25
|
+
/** The three edges we deliver — the inverse of "background notify on EXIT only". */
|
|
26
|
+
export type NotifyEdge = "needs_input" | "idle" | "exited";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One notification, as stored (one NDJSON line) and streamed. `seq` is the
|
|
30
|
+
* authoritative monotonic watermark within a single parent inbox; `ts` is a
|
|
31
|
+
* display/filter convenience (clock skew means it is NOT the ordering key).
|
|
32
|
+
*/
|
|
33
|
+
export interface NotifyEvent {
|
|
34
|
+
/** Monotonic per-inbox sequence; the watermark a cursor compares against. */
|
|
35
|
+
seq: number;
|
|
36
|
+
ts: number;
|
|
37
|
+
/** Local host id (pid namespace is per-host); also encoded in the path. */
|
|
38
|
+
host: string;
|
|
39
|
+
/** The parent this edge is addressed to (parent wrapper pid). */
|
|
40
|
+
parent_pid: number;
|
|
41
|
+
/** Parent's start time, to reject a pid-reuse mismatch on read. Optional. */
|
|
42
|
+
parent_started_at?: number;
|
|
43
|
+
/** The child that transitioned. */
|
|
44
|
+
child_pid: number;
|
|
45
|
+
child_wrapper_pid?: number;
|
|
46
|
+
/** Child's start time — guards startup-reconcile seeding against pid reuse. */
|
|
47
|
+
child_started_at?: number;
|
|
48
|
+
cli: string;
|
|
49
|
+
cwd: string;
|
|
50
|
+
edge: NotifyEdge;
|
|
51
|
+
/** The state the child was in before this edge (for context). */
|
|
52
|
+
prev_state: string | null;
|
|
53
|
+
/** The child's state now (redundant with `edge` but explicit). */
|
|
54
|
+
state: string;
|
|
55
|
+
/** Compact question text when edge === "needs_input", else null. */
|
|
56
|
+
question: string | null;
|
|
57
|
+
/** Best-effort recent output tail (last few lines), so the parent needn't tail. */
|
|
58
|
+
tail?: string | null;
|
|
59
|
+
/** Best-effort short git HEAD of the child's cwd. */
|
|
60
|
+
git_head?: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Root dir for all notification state. */
|
|
64
|
+
export function notifyDir(): string {
|
|
65
|
+
return path.join(agentYesHome(), "notify");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Per-host inbox directory (pids are only unique within a host). */
|
|
69
|
+
export function inboxDir(host: string): string {
|
|
70
|
+
return path.join(notifyDir(), "inbox", sanitizeHost(host));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Absolute path of one parent's append-only NDJSON inbox. */
|
|
74
|
+
export function inboxPath(host: string, parentPid: number): string {
|
|
75
|
+
return path.join(inboxDir(host), `${parentPid}.ndjson`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Sidecar seq counter for a parent inbox (last allocated seq). */
|
|
79
|
+
export function seqPath(host: string, parentPid: number): string {
|
|
80
|
+
return path.join(inboxDir(host), `${parentPid}.seq`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Per-consumer cursor directory for a parent inbox. */
|
|
84
|
+
export function cursorDir(host: string, parentPid: number): string {
|
|
85
|
+
return path.join(notifyDir(), "cursors", sanitizeHost(host), String(parentPid));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Absolute path of one consumer's cursor file for a parent inbox. */
|
|
89
|
+
export function cursorPath(host: string, parentPid: number, consumer = "parent"): string {
|
|
90
|
+
return path.join(cursorDir(host, parentPid), `${sanitizeConsumer(consumer)}.json`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The daemon singleton lock dir (mkdir-based, like pidStore). */
|
|
94
|
+
export function daemonLockDir(): string {
|
|
95
|
+
return path.join(notifyDir(), "notifyd.lock");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Owner metadata inside the lock dir ({pid, started_at, ts}) — the SINGLE source
|
|
100
|
+
* of truth for the running daemon's identity, used for stale-lock steal and for
|
|
101
|
+
* `notifyd status`/`stop` (a running daemon refreshes `ts` each tick).
|
|
102
|
+
*/
|
|
103
|
+
export function daemonLockOwnerPath(): string {
|
|
104
|
+
return path.join(daemonLockDir(), "owner.json");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Registry dir of parents currently running `ay notify watch` (heartbeats). */
|
|
108
|
+
export function watchersDir(): string {
|
|
109
|
+
return path.join(notifyDir(), "watchers");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Heartbeat file for one watching parent. */
|
|
113
|
+
export function watcherPath(parentPid: number): string {
|
|
114
|
+
return path.join(watchersDir(), `${parentPid}.json`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A watcher heartbeat is "live" if refreshed within this window. */
|
|
118
|
+
export const WATCHER_TTL_MS = 15_000;
|
|
119
|
+
|
|
120
|
+
/** Parse a set of live parent pids from watcher heartbeat file contents. */
|
|
121
|
+
export function liveWatcherPids(
|
|
122
|
+
entries: { pid: number; ts: number }[],
|
|
123
|
+
now: number,
|
|
124
|
+
ttlMs = WATCHER_TTL_MS,
|
|
125
|
+
): Set<number> {
|
|
126
|
+
const out = new Set<number>();
|
|
127
|
+
for (const e of entries) if (now - e.ts <= ttlMs) out.add(e.pid);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Path-segment hygiene: a hostname or consumer label must never escape the
|
|
132
|
+
// notify dir or collide via separators. Keep it filesystem-safe and stable, and
|
|
133
|
+
// reject the reserved "." / ".." segments (which dot-permitting sanitization
|
|
134
|
+
// would otherwise pass through as a real relative-path component).
|
|
135
|
+
function sanitizeSegment(s: string, fallback: string): string {
|
|
136
|
+
const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
137
|
+
return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
|
|
138
|
+
}
|
|
139
|
+
function sanitizeHost(host: string): string {
|
|
140
|
+
return sanitizeSegment(host, "localhost");
|
|
141
|
+
}
|
|
142
|
+
function sanitizeConsumer(consumer: string): string {
|
|
143
|
+
return sanitizeSegment(consumer, "parent");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Serialize one event to its NDJSON line (no trailing newline). */
|
|
147
|
+
export function serializeEvent(ev: NotifyEvent): string {
|
|
148
|
+
return JSON.stringify(ev);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse an inbox's raw text into events. Tolerant by design: a torn final line
|
|
153
|
+
* (a writer mid-append) or any unparseable line is skipped, never throws — a
|
|
154
|
+
* reader must survive a concurrent writer. Order is file order (= seq order,
|
|
155
|
+
* since a single locked writer appends in increasing seq).
|
|
156
|
+
*/
|
|
157
|
+
export function parseInboxText(text: string): NotifyEvent[] {
|
|
158
|
+
const out: NotifyEvent[] = [];
|
|
159
|
+
for (const line of text.split("\n")) {
|
|
160
|
+
const t = line.trim();
|
|
161
|
+
if (!t) continue;
|
|
162
|
+
try {
|
|
163
|
+
const ev = JSON.parse(t) as NotifyEvent;
|
|
164
|
+
if (isValidEvent(ev)) out.push(ev);
|
|
165
|
+
} catch {
|
|
166
|
+
/* torn / malformed line — skip */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const EDGES = new Set<string>(["needs_input", "idle", "exited"]);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Full shape validation at the storage boundary — a torn/partial line missing
|
|
176
|
+
* the correlation fields must never flow through to a consumer's output. Every
|
|
177
|
+
* event we surface has the fields a reader relies on.
|
|
178
|
+
*/
|
|
179
|
+
function isValidEvent(ev: unknown): ev is NotifyEvent {
|
|
180
|
+
if (!ev || typeof ev !== "object") return false;
|
|
181
|
+
const e = ev as Record<string, unknown>;
|
|
182
|
+
return (
|
|
183
|
+
typeof e.seq === "number" &&
|
|
184
|
+
typeof e.ts === "number" &&
|
|
185
|
+
typeof e.host === "string" &&
|
|
186
|
+
typeof e.parent_pid === "number" &&
|
|
187
|
+
typeof e.child_pid === "number" &&
|
|
188
|
+
typeof e.cli === "string" &&
|
|
189
|
+
typeof e.cwd === "string" &&
|
|
190
|
+
typeof e.edge === "string" &&
|
|
191
|
+
EDGES.has(e.edge) &&
|
|
192
|
+
typeof e.state === "string" &&
|
|
193
|
+
(e.question === null || typeof e.question === "string")
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Next seq to allocate given the last stored seq counter (or the inbox max). */
|
|
198
|
+
export function nextSeq(lastSeq: number): number {
|
|
199
|
+
return (Number.isFinite(lastSeq) && lastSeq > 0 ? lastSeq : 0) + 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Highest seq present in a parsed inbox (0 when empty). */
|
|
203
|
+
export function maxSeq(events: NotifyEvent[]): number {
|
|
204
|
+
let m = 0;
|
|
205
|
+
for (const e of events) if (e.seq > m) m = e.seq;
|
|
206
|
+
return m;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Filter events for `--since <seq>`: strictly-greater than the given seq. A
|
|
211
|
+
* `since` of 0/undefined returns everything.
|
|
212
|
+
*/
|
|
213
|
+
export function filterSinceSeq(events: NotifyEvent[], sinceSeq: number | undefined): NotifyEvent[] {
|
|
214
|
+
if (!sinceSeq || sinceSeq <= 0) return events.slice();
|
|
215
|
+
return events.filter((e) => e.seq > sinceSeq);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Filter events after a wall-clock `--since <ts>` (inclusive lower bound). */
|
|
219
|
+
export function filterSinceTs(events: NotifyEvent[], sinceTs: number | undefined): NotifyEvent[] {
|
|
220
|
+
if (!sinceTs || sinceTs <= 0) return events.slice();
|
|
221
|
+
return events.filter((e) => e.ts >= sinceTs);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Unread = seq strictly greater than the consumer's cursor. */
|
|
225
|
+
export function filterUnread(events: NotifyEvent[], cursorSeq: number): NotifyEvent[] {
|
|
226
|
+
return events.filter((e) => e.seq > (cursorSeq > 0 ? cursorSeq : 0));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface Cursor {
|
|
230
|
+
seq: number;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Parse a cursor file's text; a missing/garbage cursor reads as seq 0. */
|
|
234
|
+
export function parseCursor(text: string | null | undefined): Cursor {
|
|
235
|
+
if (!text) return { seq: 0 };
|
|
236
|
+
try {
|
|
237
|
+
const c = JSON.parse(text) as Cursor;
|
|
238
|
+
return { seq: Number.isFinite(c?.seq) && c.seq > 0 ? c.seq : 0 };
|
|
239
|
+
} catch {
|
|
240
|
+
return { seq: 0 };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function serializeCursor(seq: number): string {
|
|
245
|
+
return JSON.stringify({ seq: Math.max(0, Math.floor(seq)) } satisfies Cursor);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Retention decision (pure): which parent inboxes are eligible for GC. An inbox
|
|
250
|
+
* is collectable when its parent pid is no longer alive AND no live child still
|
|
251
|
+
* references it as a parent — then nobody will read or write it again. The
|
|
252
|
+
* caller supplies the live-pid set and the live child→parent references it
|
|
253
|
+
* gathered from the registry.
|
|
254
|
+
*/
|
|
255
|
+
export function inboxesToGC(
|
|
256
|
+
inboxParentPids: number[],
|
|
257
|
+
livePids: Set<number>,
|
|
258
|
+
liveChildParentPids: Set<number>,
|
|
259
|
+
): number[] {
|
|
260
|
+
return inboxParentPids.filter((p) => !livePids.has(p) && !liveChildParentPids.has(p));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Rotation decision (pure): given an inbox's events, a byte cap, and the minimum
|
|
265
|
+
* un-acked cursor across ALL consumers, return the events to KEEP after
|
|
266
|
+
* rotating. Critically, an event with `seq > protectAboveSeq` is NEVER dropped —
|
|
267
|
+
* that is the at-least-once guarantee: we must not evict an edge no consumer has
|
|
268
|
+
* acknowledged yet, even under the byte cap. Below that watermark we keep the
|
|
269
|
+
* newest events that fit the cap (and at least `minKeep`).
|
|
270
|
+
*/
|
|
271
|
+
export function rotateKeep(
|
|
272
|
+
events: NotifyEvent[],
|
|
273
|
+
capBytes: number,
|
|
274
|
+
protectAboveSeq = 0,
|
|
275
|
+
minKeep = 100,
|
|
276
|
+
): NotifyEvent[] {
|
|
277
|
+
if (events.length <= minKeep) return events.slice();
|
|
278
|
+
const kept: NotifyEvent[] = [];
|
|
279
|
+
let bytes = 0;
|
|
280
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
281
|
+
const e = events[i]!;
|
|
282
|
+
const line = serializeEvent(e) + "\n";
|
|
283
|
+
bytes += line.length;
|
|
284
|
+
// Always retain unacked events (above the min cursor) and the newest minKeep.
|
|
285
|
+
if (e.seq > protectAboveSeq || kept.length < minKeep || bytes <= capBytes) {
|
|
286
|
+
kept.push(e);
|
|
287
|
+
}
|
|
288
|
+
// Once we're past the cap AND past minKeep AND below the protection
|
|
289
|
+
// watermark, older events can be dropped — stop scanning.
|
|
290
|
+
else break;
|
|
291
|
+
}
|
|
292
|
+
kept.reverse();
|
|
293
|
+
return kept;
|
|
294
|
+
}
|