Layer 2 MCP Watcher v0 scaffold
Per spec/agent-watcher.md §4. TypeScript/Node implementation living in
mcp-watcher/ subdirectory, parallel to Layer 1 Collector at repo root.
What lands:
- Core MCP server (src/server.ts) with experimental['claude/channel']:{}
+ tools:{} capability declarations, stdio transport, channel-event
notifier wired through the inbox watcher.
- Identity resolution mirroring agent-ping's layered model
(PING_AGENT_IDENTITY env, $CLAUDE_HOME/ping-agent, ~/.ping-agent).
- Inbox reader with HWM tracking, sentinel deferral (warn-after-3),
atomic HWM writes via tmp+rename.
- chokidar-backed file watcher with coalesced drain, urgent-first
ordering, recentEvents map for tool sender lookup.
- Three reply tools (ack / respond / mark_handled) with cross-host
write discipline (writes to local inbox files; Syncthing replicates).
- Sentinel file (.<agent>.watcher-active) for hook coexistence per
spec §4.3 — agent-ping hook stands down when the watcher is in
charge of delivery on this host. Sentinel + hwm in .stignore.
- 35 unit tests passing (vitest): inbox parsing, HWM round-trip,
sentinel deferral semantics, identity layers, tool I/O, watcher
drain + ordering + restart-from-hwm.
- install.sh (Angus-executed, rule-2 compliant) installs deps,
builds, symlinks ~/.local/bin/agent-watcher-mcp, prints mcp.json
registration snippet for paste.
- README documents launch flag, sandbox CLAUDE_HOME pattern,
hook coexistence, observability, v2 limitations.
Not yet:
- Integration test against a real Claude Code session — gated on
Angus spinning up a sandbox CC session on the VPS with
CLAUDE_HOME=~/.claude-sandbox.
- agent-ping hook update to read the sentinel and stand down.
Separate small PR against agent-ping.
Interface contract with Layer 1: the inbox JSONL line shape from
inbox.ts::PingEvent matches inbox.Event in the Collector — bit-
identical reads regardless of source.
This commit is contained in:
parent
3eda72df28
commit
c22558c67a
18 changed files with 4786 additions and 0 deletions
65
mcp-watcher/test/identity.test.ts
Normal file
65
mcp-watcher/test/identity.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveIdentity } from "../src/identity.js";
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "watcher-id-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("resolveIdentity", () => {
|
||||
it("uses PING_AGENT_IDENTITY env var first", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), "default-agent\n");
|
||||
const r = resolveIdentity({
|
||||
HOME: dir,
|
||||
PING_AGENT_IDENTITY: "envname",
|
||||
});
|
||||
expect(r.agent).toBe("envname");
|
||||
expect(r.source).toMatch(/env/i);
|
||||
});
|
||||
|
||||
it("uses $CLAUDE_HOME/ping-agent next", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), "default-agent\n");
|
||||
const claudeHome = join(dir, "claude-sandbox");
|
||||
mkdirSync(claudeHome);
|
||||
writeFileSync(join(claudeHome, "ping-agent"), "sandbox\n");
|
||||
const r = resolveIdentity({
|
||||
HOME: dir,
|
||||
CLAUDE_HOME: claudeHome,
|
||||
PING_AGENT_IDENTITY: "",
|
||||
});
|
||||
expect(r.agent).toBe("sandbox");
|
||||
expect(r.source).toContain(claudeHome);
|
||||
});
|
||||
|
||||
it("falls through to ~/.ping-agent", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), "bob\n");
|
||||
const r = resolveIdentity({ HOME: dir });
|
||||
expect(r.agent).toBe("bob");
|
||||
expect(r.source).toContain(".ping-agent");
|
||||
});
|
||||
|
||||
it("throws when nothing resolves", () => {
|
||||
expect(() => resolveIdentity({ HOME: dir })).toThrow(/no identity/);
|
||||
});
|
||||
|
||||
it("ignores empty CLAUDE_HOME/ping-agent and falls through", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), "bob\n");
|
||||
const claudeHome = join(dir, "claude-sandbox");
|
||||
mkdirSync(claudeHome);
|
||||
writeFileSync(join(claudeHome, "ping-agent"), "");
|
||||
const r = resolveIdentity({ HOME: dir, CLAUDE_HOME: claudeHome });
|
||||
expect(r.agent).toBe("bob");
|
||||
});
|
||||
|
||||
it("trims whitespace from identity files", () => {
|
||||
writeFileSync(join(dir, ".ping-agent"), " bob \n\n");
|
||||
const r = resolveIdentity({ HOME: dir });
|
||||
expect(r.agent).toBe("bob");
|
||||
});
|
||||
});
|
||||
175
mcp-watcher/test/inbox.test.ts
Normal file
175
mcp-watcher/test/inbox.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
readInbox,
|
||||
readHwm,
|
||||
writeHwm,
|
||||
unreadSinceHwm,
|
||||
DEFER_LIMIT,
|
||||
type PingEvent,
|
||||
type HwmState,
|
||||
} from "../src/inbox.js";
|
||||
|
||||
const ev = (over: Partial<PingEvent>): PingEvent => ({
|
||||
ts: "2026-05-06T10:00:00Z",
|
||||
id: "ping-aaa",
|
||||
from: "foreman",
|
||||
to: "bob",
|
||||
type: "INFO",
|
||||
payload: "test",
|
||||
...over,
|
||||
});
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "watcher-test-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("readInbox", () => {
|
||||
it("returns empty for missing file", () => {
|
||||
expect(readInbox(join(dir, "missing.inbox"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses JSONL, skips blank + malformed lines", () => {
|
||||
const path = join(dir, "x.inbox");
|
||||
writeFileSync(
|
||||
path,
|
||||
[
|
||||
JSON.stringify(ev({ id: "ping-1" })),
|
||||
"",
|
||||
"{ not json",
|
||||
JSON.stringify(ev({ id: "ping-2", ts: "2026-05-06T11:00:00Z" })),
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const r = readInbox(path);
|
||||
expect(r.map((e) => e.id)).toEqual(["ping-1", "ping-2"]);
|
||||
});
|
||||
|
||||
it("requires id and ts on each line", () => {
|
||||
const path = join(dir, "x.inbox");
|
||||
writeFileSync(path, [
|
||||
JSON.stringify({ ts: "x", payload: "missing id" }),
|
||||
JSON.stringify({ id: "missing-ts", payload: "x" }),
|
||||
JSON.stringify(ev({ id: "good" })),
|
||||
].join("\n"));
|
||||
expect(readInbox(path).map((e) => e.id)).toEqual(["good"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readHwm + writeHwm", () => {
|
||||
it("returns empty state for missing file", () => {
|
||||
const r = readHwm(join(dir, "missing.hwm"));
|
||||
expect(r).toEqual({ last_delivered_ts: "", pending_attempts: {} });
|
||||
});
|
||||
|
||||
it("round-trips through atomic write", () => {
|
||||
const path = join(dir, ".hwm");
|
||||
const state: HwmState = {
|
||||
last_delivered_ts: "2026-05-06T12:00:00Z",
|
||||
pending_attempts: { "ping-x": 2 },
|
||||
};
|
||||
writeHwm(path, state);
|
||||
expect(existsSync(path)).toBe(true);
|
||||
expect(existsSync(path + ".tmp")).toBe(false);
|
||||
expect(readHwm(path)).toEqual(state);
|
||||
});
|
||||
|
||||
it("recovers from corrupt file with empty state", () => {
|
||||
const path = join(dir, ".hwm");
|
||||
writeFileSync(path, "{ corrupt");
|
||||
expect(readHwm(path)).toEqual({ last_delivered_ts: "", pending_attempts: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreadSinceHwm", () => {
|
||||
const yes = () => true;
|
||||
const no = () => false;
|
||||
|
||||
it("returns all events when HWM is empty", () => {
|
||||
const events = [ev({ id: "a", ts: "2026-05-06T10:00:00Z" })];
|
||||
const r = unreadSinceHwm(events, { last_delivered_ts: "", pending_attempts: {} }, yes);
|
||||
expect(r.deliverable.map((d) => d.event.id)).toEqual(["a"]);
|
||||
expect(r.deferred).toEqual([]);
|
||||
expect(r.nextHwm.last_delivered_ts).toBe("2026-05-06T10:00:00Z");
|
||||
});
|
||||
|
||||
it("filters out events with ts <= HWM", () => {
|
||||
const events = [
|
||||
ev({ id: "old", ts: "2026-05-06T09:00:00Z" }),
|
||||
ev({ id: "new", ts: "2026-05-06T11:00:00Z" }),
|
||||
];
|
||||
const r = unreadSinceHwm(
|
||||
events,
|
||||
{ last_delivered_ts: "2026-05-06T10:00:00Z", pending_attempts: {} },
|
||||
yes,
|
||||
);
|
||||
expect(r.deliverable.map((d) => d.event.id)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("defers events whose sentinel is missing, increments attempts", () => {
|
||||
const events = [ev({ id: "a", sentinel: "/nope" })];
|
||||
const r = unreadSinceHwm(events, { last_delivered_ts: "", pending_attempts: {} }, no);
|
||||
expect(r.deliverable).toEqual([]);
|
||||
expect(r.deferred.map((e) => e.id)).toEqual(["a"]);
|
||||
expect(r.nextHwm.pending_attempts).toEqual({ a: 1 });
|
||||
// HWM does NOT advance past the deferred event
|
||||
expect(r.nextHwm.last_delivered_ts).toBe("");
|
||||
});
|
||||
|
||||
it("delivers with warning on the DEFER_LIMIT-th attempt", () => {
|
||||
const events = [ev({ id: "a", sentinel: "/nope" })];
|
||||
const r = unreadSinceHwm(
|
||||
events,
|
||||
{ last_delivered_ts: "", pending_attempts: { a: DEFER_LIMIT - 1 } },
|
||||
no,
|
||||
);
|
||||
expect(r.deliverable.length).toBe(1);
|
||||
expect(r.deliverable[0].warning).toMatch(/hasn't synced/);
|
||||
expect(r.deferred).toEqual([]);
|
||||
// attempts cleared once delivered
|
||||
expect(r.nextHwm.pending_attempts).toEqual({});
|
||||
expect(r.nextHwm.last_delivered_ts).toBe("2026-05-06T10:00:00Z");
|
||||
});
|
||||
|
||||
it("delivers immediately when sentinel exists", () => {
|
||||
const events = [ev({ id: "a", sentinel: "/exists" })];
|
||||
const r = unreadSinceHwm(events, { last_delivered_ts: "", pending_attempts: {} }, yes);
|
||||
expect(r.deliverable.length).toBe(1);
|
||||
expect(r.deliverable[0].warning).toBeUndefined();
|
||||
expect(r.nextHwm.pending_attempts).toEqual({});
|
||||
});
|
||||
|
||||
it("HWM does not advance past deferred event even when later events deliver", () => {
|
||||
const events = [
|
||||
ev({ id: "deferred", ts: "2026-05-06T10:00:00Z", sentinel: "/nope" }),
|
||||
ev({ id: "later", ts: "2026-05-06T11:00:00Z" }),
|
||||
];
|
||||
const sentinelExists = (p: string) => p !== "/nope";
|
||||
const r = unreadSinceHwm(
|
||||
events,
|
||||
{ last_delivered_ts: "", pending_attempts: {} },
|
||||
sentinelExists,
|
||||
);
|
||||
expect(r.deliverable.map((d) => d.event.id)).toEqual(["later"]);
|
||||
expect(r.deferred.map((e) => e.id)).toEqual(["deferred"]);
|
||||
// HWM stays empty so the deferred event is reconsidered next read
|
||||
expect(r.nextHwm.last_delivered_ts).toBe("");
|
||||
});
|
||||
|
||||
it("clears pending_attempts when sentinel finally lands", () => {
|
||||
const events = [ev({ id: "a", sentinel: "/now-ok" })];
|
||||
const r = unreadSinceHwm(
|
||||
events,
|
||||
{ last_delivered_ts: "", pending_attempts: { a: 2 } },
|
||||
yes,
|
||||
);
|
||||
expect(r.deliverable.length).toBe(1);
|
||||
expect(r.nextHwm.pending_attempts).toEqual({});
|
||||
});
|
||||
});
|
||||
159
mcp-watcher/test/tools.test.ts
Normal file
159
mcp-watcher/test/tools.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { makePaths } from "../src/paths.js";
|
||||
import { callTool, toolDefinitions } from "../src/tools.js";
|
||||
import type { PingEvent } from "../src/inbox.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "watcher-tools-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const setupDeps = (recent: Map<string, PingEvent>) => ({
|
||||
paths: makePaths(dir),
|
||||
agent: "bob",
|
||||
recentEvents: () => recent,
|
||||
});
|
||||
|
||||
const sampleEvent = (over: Partial<PingEvent> = {}): PingEvent => ({
|
||||
ts: "2026-05-06T10:00:00Z",
|
||||
id: "ping-orig",
|
||||
from: "foreman",
|
||||
to: "bob",
|
||||
type: "NEEDS-RESPONSE",
|
||||
payload: "do the thing",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("toolDefinitions", () => {
|
||||
it("declares ack, respond, and mark_handled with required ping_id", () => {
|
||||
const tools = toolDefinitions();
|
||||
const names = tools.map((t) => t.name).sort();
|
||||
expect(names).toEqual(["ack", "mark_handled", "respond"]);
|
||||
for (const t of tools) {
|
||||
expect(t.inputSchema.required).toContain("ping_id");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("callTool — ack", () => {
|
||||
it("appends to acks/log with ack_kind=delivered + original_type", () => {
|
||||
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
|
||||
const deps = setupDeps(recent);
|
||||
const r = callTool("ack", { ping_id: "ping-orig" }, deps);
|
||||
expect(r.ok).toBe(true);
|
||||
const log = readFileSync(deps.paths.acksLog, "utf8").trim();
|
||||
const obj = JSON.parse(log);
|
||||
expect(obj.acked_by).toBe("bob");
|
||||
expect(obj.ping_id).toBe("ping-orig");
|
||||
expect(obj.original_sender).toBe("foreman");
|
||||
expect(obj.ack_kind).toBe("delivered");
|
||||
expect(obj.original_type).toBe("NEEDS-RESPONSE");
|
||||
});
|
||||
|
||||
it("rejects missing ping_id", () => {
|
||||
const r = callTool("ack", {}, setupDeps(new Map()));
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callTool — respond", () => {
|
||||
it("writes a JSONL line to <sender>.inbox + ack with ack_kind=responded", () => {
|
||||
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
|
||||
const deps = setupDeps(recent);
|
||||
const r = callTool(
|
||||
"respond",
|
||||
{ ping_id: "ping-orig", payload: "ok done", type: "INFO" },
|
||||
deps,
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
const inboxPath = deps.paths.inbox("foreman");
|
||||
const lines = readFileSync(inboxPath, "utf8").trim().split("\n");
|
||||
expect(lines).toHaveLength(1);
|
||||
const ev = JSON.parse(lines[0]);
|
||||
expect(ev.from).toBe("bob");
|
||||
expect(ev.to).toBe("foreman");
|
||||
expect(ev.type).toBe("INFO");
|
||||
expect(ev.payload).toBe("ok done");
|
||||
expect(ev.id).toMatch(/^ping-[a-f0-9]{8}$/);
|
||||
|
||||
const ack = JSON.parse(readFileSync(deps.paths.acksLog, "utf8").trim());
|
||||
expect(ack.ack_kind).toBe("responded");
|
||||
expect(ack.response_id).toBe(ev.id);
|
||||
});
|
||||
|
||||
it("defaults missing type to INFO + missing priority to normal", () => {
|
||||
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
|
||||
const deps = setupDeps(recent);
|
||||
callTool("respond", { ping_id: "ping-orig", payload: "x" }, deps);
|
||||
const lines = readFileSync(deps.paths.inbox("foreman"), "utf8").trim().split("\n");
|
||||
const ev = JSON.parse(lines[0]);
|
||||
expect(ev.type).toBe("INFO");
|
||||
expect(ev.priority).toBe("normal");
|
||||
});
|
||||
|
||||
it("rejects unknown ping_id", () => {
|
||||
const r = callTool(
|
||||
"respond",
|
||||
{ ping_id: "ping-unknown", payload: "x" },
|
||||
setupDeps(new Map()),
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.text).toContain("not found");
|
||||
});
|
||||
|
||||
it("rejects invalid type", () => {
|
||||
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
|
||||
const r = callTool(
|
||||
"respond",
|
||||
{ ping_id: "ping-orig", payload: "x", type: "bogus" },
|
||||
setupDeps(recent),
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callTool — mark_handled", () => {
|
||||
it("writes an ack with explanatory message", () => {
|
||||
const recent = new Map<string, PingEvent>([["ping-orig", sampleEvent()]]);
|
||||
const deps = setupDeps(recent);
|
||||
const r = callTool("mark_handled", { ping_id: "ping-orig" }, deps);
|
||||
expect(r.ok).toBe(true);
|
||||
const ack = JSON.parse(readFileSync(deps.paths.acksLog, "utf8").trim());
|
||||
expect(ack.ack_kind).toBe("delivered");
|
||||
expect(ack.message).toMatch(/handled/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callTool — unknown tool", () => {
|
||||
it("returns ok=false on unknown tool name", () => {
|
||||
const r = callTool("bogus", { ping_id: "x" }, setupDeps(new Map()));
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.text).toContain("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ack does not crash on unknown ping_id", () => {
|
||||
it("still writes an ack with original_sender=null", () => {
|
||||
const deps = setupDeps(new Map());
|
||||
const r = callTool("ack", { ping_id: "ping-unknown" }, deps);
|
||||
expect(r.ok).toBe(true);
|
||||
const ack = JSON.parse(readFileSync(deps.paths.acksLog, "utf8").trim());
|
||||
expect(ack.original_sender).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("paths writability through tools", () => {
|
||||
it("creates the acks dir on first ack", () => {
|
||||
const deps = setupDeps(new Map([["p1", sampleEvent({ id: "p1" })]]));
|
||||
expect(existsSync(deps.paths.acksLog)).toBe(false);
|
||||
callTool("ack", { ping_id: "p1" }, deps);
|
||||
expect(existsSync(deps.paths.acksLog)).toBe(true);
|
||||
});
|
||||
});
|
||||
155
mcp-watcher/test/watcher.test.ts
Normal file
155
mcp-watcher/test/watcher.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, appendFileSync, rmSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { makePaths } from "../src/paths.js";
|
||||
import { InboxWatcher } from "../src/watcher.js";
|
||||
import type { PingEvent } from "../src/inbox.js";
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "watcher-watch-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ev = (over: Partial<PingEvent>): PingEvent => ({
|
||||
ts: "2026-05-06T10:00:00Z",
|
||||
id: "ping-" + Math.random().toString(36).slice(2, 10),
|
||||
from: "foreman",
|
||||
to: "bob",
|
||||
type: "INFO",
|
||||
payload: "test",
|
||||
...over,
|
||||
});
|
||||
|
||||
const writeInbox = (path: string, events: PingEvent[]) => {
|
||||
mkdirSync(join(path, "..").replace(/[^/]*\/?$/, ""), { recursive: true });
|
||||
writeFileSync(path, events.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
||||
};
|
||||
|
||||
describe("InboxWatcher initial drain", () => {
|
||||
it("emits all unread events on start, then advances HWM", async () => {
|
||||
const paths = makePaths(dir);
|
||||
mkdirSync(paths.pingsDir, { recursive: true });
|
||||
writeInbox(paths.inbox("bob"), [
|
||||
ev({ id: "a", ts: "2026-05-06T10:00:00Z" }),
|
||||
ev({ id: "b", ts: "2026-05-06T11:00:00Z" }),
|
||||
]);
|
||||
|
||||
const delivered: { event: PingEvent; warning?: string }[] = [];
|
||||
const w = new InboxWatcher({
|
||||
paths,
|
||||
agent: "bob",
|
||||
notify: async (event, warning) => {
|
||||
delivered.push({ event, warning });
|
||||
},
|
||||
// No-op chokidar substitute
|
||||
startWatcher: () => ({ async close() {} }),
|
||||
});
|
||||
await w.start();
|
||||
await w.stop();
|
||||
|
||||
expect(delivered.map((d) => d.event.id)).toEqual(["a", "b"]);
|
||||
const hwm = JSON.parse(readFileSync(paths.hwm("bob"), "utf8"));
|
||||
expect(hwm.last_delivered_ts).toBe("2026-05-06T11:00:00Z");
|
||||
});
|
||||
|
||||
it("orders urgent before normal regardless of timestamp", async () => {
|
||||
const paths = makePaths(dir);
|
||||
mkdirSync(paths.pingsDir, { recursive: true });
|
||||
writeInbox(paths.inbox("bob"), [
|
||||
ev({ id: "older-normal", ts: "2026-05-06T10:00:00Z", priority: "normal" }),
|
||||
ev({ id: "newer-urgent", ts: "2026-05-06T11:00:00Z", priority: "urgent" }),
|
||||
]);
|
||||
|
||||
const delivered: PingEvent[] = [];
|
||||
const w = new InboxWatcher({
|
||||
paths, agent: "bob",
|
||||
notify: async (e) => { delivered.push(e); },
|
||||
startWatcher: () => ({ async close() {} }),
|
||||
});
|
||||
await w.start();
|
||||
await w.stop();
|
||||
expect(delivered.map((d) => d.id)).toEqual(["newer-urgent", "older-normal"]);
|
||||
});
|
||||
|
||||
it("defers a sentinel-missing ping and re-attempts on next drain", async () => {
|
||||
const paths = makePaths(dir);
|
||||
mkdirSync(paths.pingsDir, { recursive: true });
|
||||
writeInbox(paths.inbox("bob"), [ev({ id: "a", sentinel: "/nope" })]);
|
||||
|
||||
const delivered: PingEvent[] = [];
|
||||
let triggerChange: () => void = () => {};
|
||||
const w = new InboxWatcher({
|
||||
paths, agent: "bob",
|
||||
notify: async (e) => { delivered.push(e); },
|
||||
sentinelExists: () => false,
|
||||
startWatcher: (_path, onChange) => {
|
||||
triggerChange = onChange;
|
||||
return { async close() {} };
|
||||
},
|
||||
});
|
||||
await w.start();
|
||||
expect(delivered).toEqual([]);
|
||||
// hwm has pending_attempts.a = 1 but ts not advanced
|
||||
let hwm = JSON.parse(readFileSync(paths.hwm("bob"), "utf8"));
|
||||
expect(hwm.pending_attempts).toEqual({ a: 1 });
|
||||
expect(hwm.last_delivered_ts).toBe("");
|
||||
|
||||
// Two more triggers — third delivery emits with warning
|
||||
triggerChange();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
triggerChange();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
expect(delivered.map((d) => d.id)).toEqual(["a"]);
|
||||
hwm = JSON.parse(readFileSync(paths.hwm("bob"), "utf8"));
|
||||
expect(hwm.pending_attempts).toEqual({});
|
||||
expect(hwm.last_delivered_ts).toBe("2026-05-06T10:00:00Z");
|
||||
await w.stop();
|
||||
});
|
||||
|
||||
it("populates recentEvents map for tools to look up sender", async () => {
|
||||
const paths = makePaths(dir);
|
||||
mkdirSync(paths.pingsDir, { recursive: true });
|
||||
writeInbox(paths.inbox("bob"), [
|
||||
ev({ id: "ping-x", from: "mom" }),
|
||||
ev({ id: "ping-y", from: "foreman" }),
|
||||
]);
|
||||
const w = new InboxWatcher({
|
||||
paths, agent: "bob",
|
||||
notify: async () => {},
|
||||
startWatcher: () => ({ async close() {} }),
|
||||
});
|
||||
await w.start();
|
||||
expect(w.recentEvents().get("ping-x")?.from).toBe("mom");
|
||||
expect(w.recentEvents().get("ping-y")?.from).toBe("foreman");
|
||||
await w.stop();
|
||||
});
|
||||
|
||||
it("skips pings already covered by HWM on restart", async () => {
|
||||
const paths = makePaths(dir);
|
||||
mkdirSync(paths.pingsDir, { recursive: true });
|
||||
writeInbox(paths.inbox("bob"), [
|
||||
ev({ id: "a", ts: "2026-05-06T10:00:00Z" }),
|
||||
ev({ id: "b", ts: "2026-05-06T11:00:00Z" }),
|
||||
]);
|
||||
writeFileSync(
|
||||
paths.hwm("bob"),
|
||||
JSON.stringify({ last_delivered_ts: "2026-05-06T10:00:00Z", pending_attempts: {} }),
|
||||
);
|
||||
|
||||
const delivered: PingEvent[] = [];
|
||||
const w = new InboxWatcher({
|
||||
paths, agent: "bob",
|
||||
notify: async (e) => { delivered.push(e); },
|
||||
startWatcher: () => ({ async close() {} }),
|
||||
});
|
||||
await w.start();
|
||||
await w.stop();
|
||||
expect(delivered.map((d) => d.id)).toEqual(["b"]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue