agents-pod 0.0.0-stage → 0.2.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/CHANGELOG.md +83 -0
- package/LICENSE +661 -0
- package/README.md +530 -2
- package/bin/agents-pod.mjs +15 -0
- package/bin/ts-loader.mjs +66 -0
- package/bin/wrapper.mjs +12 -0
- package/index.mjs +11 -0
- package/package.json +39 -4
- package/src/agents-store.ts +88 -0
- package/src/cli.ts +1864 -0
- package/src/extension/handoff.ts +57 -0
- package/src/extension/index.ts +34 -0
- package/src/extension/management.ts +149 -0
- package/src/extension/self.ts +80 -0
- package/src/inbox.ts +221 -0
- package/src/listeners.ts +84 -0
- package/src/liveness.ts +191 -0
- package/src/locks.ts +74 -0
- package/src/notify/config.ts +125 -0
- package/src/notify/dispatch.ts +63 -0
- package/src/notify/email.ts +72 -0
- package/src/notify/format.ts +42 -0
- package/src/notify/system.ts +79 -0
- package/src/notify/types.ts +96 -0
- package/src/notify/webhook.ts +39 -0
- package/src/outbox.ts +294 -0
- package/src/paths.ts +67 -0
- package/src/projects.ts +49 -0
- package/src/registry.ts +103 -0
- package/src/sources.ts +278 -0
- package/src/workspace.ts +131 -0
- package/src/wrapper.ts +533 -0
package/src/liveness.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { uptime } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { podDir } from "./paths.ts";
|
|
6
|
+
import type { PodRecord } from "./registry.ts";
|
|
7
|
+
import type { DocFacts } from "./workspace.ts";
|
|
8
|
+
|
|
9
|
+
/** The heartbeat staleness line: 3 minutes — six missed 30-second beats (ADR 0017). */
|
|
10
|
+
export const HEARTBEAT_STALE_MS = 180_000;
|
|
11
|
+
|
|
12
|
+
/** The wedged escalation line: 10 stale minutes with a live wrapper (ADR 0017). */
|
|
13
|
+
export const WEDGED_ESCALATE_MS = 600_000;
|
|
14
|
+
|
|
15
|
+
/** ADR 0017's four quadrants; `status` reports one per pod. */
|
|
16
|
+
export type Liveness = "healthy" | "lost" | "wedged-pending" | "wedged";
|
|
17
|
+
|
|
18
|
+
/** Why a pod is `lost`; the detail text and the signal decision follow from it. */
|
|
19
|
+
export type LostCause = "ghost" | "reboot" | "wrapper-gone";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The wrapper's identity record (ADR 0003): who runs this wake-up, probeable.
|
|
23
|
+
* The spawn-time facts (ADR 0020) — `pidStart`, the process start time as `ps`
|
|
24
|
+
* renders it, and `bootTime`, the wall-clock boot moment — are written once at
|
|
25
|
+
* spawn. A record without them cannot be trusted for a signal: absence is a
|
|
26
|
+
* mismatch, never a wildcard.
|
|
27
|
+
*/
|
|
28
|
+
export interface WrapperRecord {
|
|
29
|
+
pid: number;
|
|
30
|
+
pgid: number;
|
|
31
|
+
wakeup: number;
|
|
32
|
+
startedAt: number;
|
|
33
|
+
pidStart?: string;
|
|
34
|
+
bootTime?: number;
|
|
35
|
+
/**
|
|
36
|
+
* The document store's mtime+size at wake-up start (ADR 0019): the pass
|
|
37
|
+
* compares this baseline against the present to judge progress. Absent means
|
|
38
|
+
* no baseline — never "unchanged".
|
|
39
|
+
*/
|
|
40
|
+
docs?: Record<string, DocFacts>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The machine's identity (ADR 0020): the wall-clock moment of boot. Two
|
|
45
|
+
* readings differ by `os.uptime`'s resolution (whole seconds on macOS), so a
|
|
46
|
+
* real reboot — which moves the value by the whole previous uptime — is the
|
|
47
|
+
* only thing that exceeds the tolerance.
|
|
48
|
+
*/
|
|
49
|
+
export const BOOT_TIME_TOLERANCE_MS = 3_000;
|
|
50
|
+
|
|
51
|
+
export function currentBootTime(): number {
|
|
52
|
+
return Date.now() - uptime() * 1000;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function bootTimeMatches(recorded: number | undefined): boolean {
|
|
56
|
+
return (
|
|
57
|
+
typeof recorded === "number" &&
|
|
58
|
+
Math.abs(currentBootTime() - recorded) <= BOOT_TIME_TOLERANCE_MS
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `ps -o lstart=` for a pid: one call answers both questions ADR 0020 asks of
|
|
64
|
+
* a kill — is the pid live, and when did it start. `LC_ALL=C` keeps the
|
|
65
|
+
* rendering identical to the string the wrapper recorded. Undefined means the
|
|
66
|
+
* pid is gone (or unreadable), which is never a failure in itself.
|
|
67
|
+
*/
|
|
68
|
+
export function psStart(pid: number): Promise<string | undefined> {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
execFile(
|
|
71
|
+
"ps",
|
|
72
|
+
["-o", "lstart=", "-p", String(pid)],
|
|
73
|
+
{ env: { ...process.env, LC_ALL: "C" } },
|
|
74
|
+
(error, stdout) => {
|
|
75
|
+
if (error) {
|
|
76
|
+
resolve(undefined);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const start = stdout.trim();
|
|
80
|
+
resolve(start.length > 0 ? start : undefined);
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* ADR 0020's three-way verification of a recorded wrapper: the boot time is
|
|
88
|
+
* this boot's, the pid is live, and its start time is the recorded one. Any
|
|
89
|
+
* mismatch means the recorded process is long gone — a pid is just a number
|
|
90
|
+
* after a reboot.
|
|
91
|
+
*/
|
|
92
|
+
export async function verifyProcess(record: WrapperRecord): Promise<boolean> {
|
|
93
|
+
if (!bootTimeMatches(record.bootTime)) return false;
|
|
94
|
+
const start = await psStart(record.pid);
|
|
95
|
+
if (start === undefined || typeof record.pidStart !== "string") return false;
|
|
96
|
+
return start === record.pidStart.trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* ADR 0020's reaping gate: the recorded wrapper is verifiably dead — this is
|
|
101
|
+
* not a reboot, and no process answers at its pid — so its process group may
|
|
102
|
+
* be reaped. A live pid fails this check: that case is the wedged path, which
|
|
103
|
+
* needs `verifyProcess`.
|
|
104
|
+
*/
|
|
105
|
+
export async function wrapperVerifiablyDead(record: WrapperRecord): Promise<boolean> {
|
|
106
|
+
if (!bootTimeMatches(record.bootTime)) return false;
|
|
107
|
+
return (await psStart(record.pid)) === undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function wrapperJsonPath(project: string, podId: string): string {
|
|
111
|
+
return join(podDir(project, podId), "wrapper.json");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function readWrapperRecord(
|
|
115
|
+
pod: Pick<PodRecord, "id" | "project">,
|
|
116
|
+
): Promise<WrapperRecord | undefined> {
|
|
117
|
+
const body = await readFile(wrapperJsonPath(pod.project, pod.id), "utf8").catch(
|
|
118
|
+
() => undefined,
|
|
119
|
+
);
|
|
120
|
+
if (body === undefined) return undefined;
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(body) as WrapperRecord;
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A dead pid is the ordinary crash; EPERM still means alive. */
|
|
129
|
+
export function pidDead(pid: number): boolean {
|
|
130
|
+
try {
|
|
131
|
+
process.kill(pid, 0);
|
|
132
|
+
return false;
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return (error as NodeJS.ErrnoException).code === "ESRCH";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function heartbeatAgeMs(
|
|
139
|
+
pod: Pick<PodRecord, "id" | "project">,
|
|
140
|
+
): Promise<number | undefined> {
|
|
141
|
+
const info = await stat(join(podDir(pod.project, pod.id), "heartbeat")).catch(
|
|
142
|
+
() => undefined,
|
|
143
|
+
);
|
|
144
|
+
return info === undefined ? undefined : Date.now() - info.mtimeMs;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface LivenessFacts {
|
|
148
|
+
quadrant: Liveness;
|
|
149
|
+
wrapper?: WrapperRecord;
|
|
150
|
+
beatAgeMs?: number;
|
|
151
|
+
/** Only for `lost`: which fact condemned the wake-up. */
|
|
152
|
+
lostCause?: LostCause;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The three-way rule (ADR 0017), from owned facts only — pid plus heartbeat
|
|
157
|
+
* age, never elapsed run time:
|
|
158
|
+
* 1. pid live + beat fresh → healthy
|
|
159
|
+
* 2. pid dead + beat stale → lost
|
|
160
|
+
* 3. pid live + beat stale → wedged, with the ten-minute escalation shown as
|
|
161
|
+
* wedged-pending until the two-pass kill exists
|
|
162
|
+
* A record that says active with no wrapper identity at all is a ghost
|
|
163
|
+
* (ADR 0002). Anything not active has nothing running: healthy.
|
|
164
|
+
*
|
|
165
|
+
* Identity comes first (ADR 0020): a boot time that is not this boot's means
|
|
166
|
+
* the machine restarted since the wake-up began, so the pod is lost at once
|
|
167
|
+
* and no recorded pid is probed or trusted — after a reboot those numbers
|
|
168
|
+
* belong to someone else.
|
|
169
|
+
*/
|
|
170
|
+
export async function livenessFacts(
|
|
171
|
+
pod: Pick<PodRecord, "id" | "project" | "state">,
|
|
172
|
+
): Promise<LivenessFacts> {
|
|
173
|
+
if (pod.state !== "active") return { quadrant: "healthy" };
|
|
174
|
+
const wrapper = await readWrapperRecord(pod);
|
|
175
|
+
const beatAgeMs = await heartbeatAgeMs(pod);
|
|
176
|
+
if (wrapper === undefined) return { quadrant: "lost", beatAgeMs, lostCause: "ghost" };
|
|
177
|
+
if (!bootTimeMatches(wrapper.bootTime)) {
|
|
178
|
+
return { quadrant: "lost", wrapper, beatAgeMs, lostCause: "reboot" };
|
|
179
|
+
}
|
|
180
|
+
if (pidDead(wrapper.pid)) {
|
|
181
|
+
if (beatAgeMs !== undefined && beatAgeMs >= HEARTBEAT_STALE_MS) {
|
|
182
|
+
return { quadrant: "lost", wrapper, beatAgeMs, lostCause: "wrapper-gone" };
|
|
183
|
+
}
|
|
184
|
+
return { quadrant: "wedged", wrapper, beatAgeMs };
|
|
185
|
+
}
|
|
186
|
+
if (beatAgeMs === undefined || beatAgeMs >= WEDGED_ESCALATE_MS) {
|
|
187
|
+
return { quadrant: "wedged-pending", wrapper, beatAgeMs };
|
|
188
|
+
}
|
|
189
|
+
if (beatAgeMs >= HEARTBEAT_STALE_MS) return { quadrant: "wedged", wrapper, beatAgeMs };
|
|
190
|
+
return { quadrant: "healthy", wrapper, beatAgeMs };
|
|
191
|
+
}
|
package/src/locks.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The advisory lock primitives (ADR 0018): `wx` is the only race-free acquire
|
|
6
|
+
* — the kernel's exclusive create is the arbitration — and a lock is released
|
|
7
|
+
* only by its owner, so a stolen (inert zombie) pass cannot delete the file
|
|
8
|
+
* of the pass that stole it.
|
|
9
|
+
*/
|
|
10
|
+
export interface LockRecord {
|
|
11
|
+
pid: number;
|
|
12
|
+
startedAt: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function acquireLock(file: string, record: LockRecord): Promise<boolean> {
|
|
16
|
+
await mkdir(dirname(file), { recursive: true });
|
|
17
|
+
try {
|
|
18
|
+
await writeFile(file, `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 });
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A lock whose contents cannot be parsed is `undefined`, never a crash. */
|
|
27
|
+
export async function readLock(file: string): Promise<LockRecord | undefined> {
|
|
28
|
+
try {
|
|
29
|
+
const parsed: unknown = JSON.parse(await readFile(file, "utf8"));
|
|
30
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
31
|
+
const { pid, startedAt } = parsed as Partial<LockRecord>;
|
|
32
|
+
if (typeof pid !== "number" || typeof startedAt !== "number") return undefined;
|
|
33
|
+
return { pid, startedAt };
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Idempotent and owner-checked: safe in a `finally` (ADR 0018). */
|
|
40
|
+
export async function releaseLock(file: string): Promise<void> {
|
|
41
|
+
const held = await readLock(file);
|
|
42
|
+
if (held?.pid !== process.pid) return;
|
|
43
|
+
await unlink(file).catch(() => undefined);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* ADR 0017's registry-lock shape: a 5-second budget, 25 ms fixed retries, two
|
|
48
|
+
* rounds. Two lost budgets are a wedged holder, not a slow one, so the lock is
|
|
49
|
+
* broken once — the same TTL reasoning the pass lock applies (ADR 0018); pid
|
|
50
|
+
* liveness is never the judge.
|
|
51
|
+
*/
|
|
52
|
+
const RETRY_BUDGET_MS = 5_000;
|
|
53
|
+
const RETRY_INTERVAL_MS = 25;
|
|
54
|
+
const RETRY_ROUNDS = 2;
|
|
55
|
+
|
|
56
|
+
export function sleep(ms: number): Promise<void> {
|
|
57
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function acquireWithRetry(
|
|
61
|
+
file: string,
|
|
62
|
+
record: LockRecord,
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
for (let round = 0; round < RETRY_ROUNDS; round += 1) {
|
|
65
|
+
const deadline = Date.now() + RETRY_BUDGET_MS;
|
|
66
|
+
for (;;) {
|
|
67
|
+
if (await acquireLock(file, record)) return true;
|
|
68
|
+
if (Date.now() >= deadline) break;
|
|
69
|
+
await sleep(RETRY_INTERVAL_MS);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await unlink(file).catch(() => undefined);
|
|
73
|
+
return acquireLock(file, record);
|
|
74
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { notifyConfigFile } from "../paths.ts";
|
|
3
|
+
import type {
|
|
4
|
+
EmailChannel,
|
|
5
|
+
NotificationChannel,
|
|
6
|
+
NotificationsConfig,
|
|
7
|
+
NotifyLevel,
|
|
8
|
+
SystemChannel,
|
|
9
|
+
WebhookChannel,
|
|
10
|
+
} from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The default is `always`, not agents-cron's `error` (ADR 0021): work
|
|
14
|
+
* measured in days makes completion the product, so it is announced too.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_NOTIFY_LEVEL: NotifyLevel = "always";
|
|
17
|
+
|
|
18
|
+
/** Where config warnings go: reconcile routes them to cron.log (ADR 0021). */
|
|
19
|
+
export type ConfigWarning = (message: string) => void;
|
|
20
|
+
|
|
21
|
+
const stderrWarning: ConfigWarning = (message) => {
|
|
22
|
+
process.stderr.write(`[agents-pod] ${message}\n`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads the store's `config.json` (ADR 0021). The file is optional and
|
|
27
|
+
* user-authored: missing means "no channels", and anything malformed degrades
|
|
28
|
+
* to defaults with a warning — a bad config must never take down a pass.
|
|
29
|
+
*/
|
|
30
|
+
export async function loadNotifyConfig(
|
|
31
|
+
file: string = notifyConfigFile(),
|
|
32
|
+
warn: ConfigWarning = stderrWarning,
|
|
33
|
+
): Promise<NotificationsConfig> {
|
|
34
|
+
let raw: string;
|
|
35
|
+
try {
|
|
36
|
+
raw = await readFile(file, "utf8");
|
|
37
|
+
} catch {
|
|
38
|
+
return { on: DEFAULT_NOTIFY_LEVEL, channels: [] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
43
|
+
return normalize(parsed, file, warn);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
warn(
|
|
46
|
+
`${file}: not valid JSON (${err instanceof Error ? err.message : String(err)}); using notification defaults`,
|
|
47
|
+
);
|
|
48
|
+
return { on: DEFAULT_NOTIFY_LEVEL, channels: [] };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalize(
|
|
53
|
+
parsed: Record<string, unknown>,
|
|
54
|
+
file: string,
|
|
55
|
+
warn: ConfigWarning,
|
|
56
|
+
): NotificationsConfig {
|
|
57
|
+
const notifications = (parsed.notifications ?? {}) as Record<string, unknown>;
|
|
58
|
+
const on = isNotifyLevel(notifications.on) ? notifications.on : DEFAULT_NOTIFY_LEVEL;
|
|
59
|
+
if (notifications.on !== undefined && !isNotifyLevel(notifications.on)) {
|
|
60
|
+
warn(
|
|
61
|
+
`${file}: notifications.on "${String(notifications.on)}" is not one of always|error|never; using "${on}"`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const channels: NotificationChannel[] = [];
|
|
66
|
+
for (const entry of Array.isArray(notifications.channels) ? notifications.channels : []) {
|
|
67
|
+
const channel = asChannel(entry);
|
|
68
|
+
if (channel) channels.push(channel);
|
|
69
|
+
else warn(`${file}: dropping invalid notification channel ${JSON.stringify(entry)}`);
|
|
70
|
+
}
|
|
71
|
+
return { on, channels };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function asChannel(entry: unknown): NotificationChannel | undefined {
|
|
75
|
+
if (typeof entry !== "object" || entry === null) return undefined;
|
|
76
|
+
const c = entry as Record<string, unknown>;
|
|
77
|
+
if (typeof c.id !== "string" || !c.id.trim()) return undefined;
|
|
78
|
+
|
|
79
|
+
if (c.type === "webhook") {
|
|
80
|
+
if (typeof c.url !== "string" || !/^https?:\/\//.test(c.url)) return undefined;
|
|
81
|
+
const headers =
|
|
82
|
+
c.headers && typeof c.headers === "object" && Object.values(c.headers).every((v) => typeof v === "string")
|
|
83
|
+
? (c.headers as Record<string, string>)
|
|
84
|
+
: undefined;
|
|
85
|
+
const format = c.format === "text" || c.format === "feishu" ? c.format : "json";
|
|
86
|
+
return {
|
|
87
|
+
id: c.id,
|
|
88
|
+
type: "webhook",
|
|
89
|
+
url: c.url,
|
|
90
|
+
...(headers ? { headers } : {}),
|
|
91
|
+
...(format !== "json" ? { format } : {}),
|
|
92
|
+
} satisfies WebhookChannel;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (c.type === "email") {
|
|
96
|
+
if (typeof c.host !== "string" || !c.host) return undefined;
|
|
97
|
+
if (typeof c.from !== "string" || !c.from) return undefined;
|
|
98
|
+
if (typeof c.to !== "string" || !c.to) return undefined;
|
|
99
|
+
const port = typeof c.port === "number" ? c.port : 465;
|
|
100
|
+
const channel: EmailChannel = {
|
|
101
|
+
id: c.id,
|
|
102
|
+
type: "email",
|
|
103
|
+
host: c.host,
|
|
104
|
+
port,
|
|
105
|
+
from: c.from,
|
|
106
|
+
to: c.to,
|
|
107
|
+
...(typeof c.secure === "boolean" ? { secure: c.secure } : { secure: port === 465 }),
|
|
108
|
+
...(typeof c.user === "string" && c.user ? { user: c.user } : {}),
|
|
109
|
+
...(typeof c.passwordEnv === "string" && c.passwordEnv ? { passwordEnv: c.passwordEnv } : {}),
|
|
110
|
+
...(typeof c.subjectPrefix === "string" && c.subjectPrefix ? { subjectPrefix: c.subjectPrefix } : {}),
|
|
111
|
+
};
|
|
112
|
+
return channel;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (c.type === "system") {
|
|
116
|
+
if (typeof c.sound !== "string" || !c.sound) return { id: c.id, type: "system" } satisfies SystemChannel;
|
|
117
|
+
return { id: c.id, type: "system", sound: c.sound } satisfies SystemChannel;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isNotifyLevel(value: unknown): value is NotifyLevel {
|
|
124
|
+
return value === "always" || value === "error" || value === "never";
|
|
125
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { emailNotifier } from "./email.ts";
|
|
2
|
+
import { systemNotifier } from "./system.ts";
|
|
3
|
+
import { webhookNotifier } from "./webhook.ts";
|
|
4
|
+
import type {
|
|
5
|
+
NotificationChannel,
|
|
6
|
+
NotificationEvent,
|
|
7
|
+
NotificationsConfig,
|
|
8
|
+
Notifier,
|
|
9
|
+
NotifyLevel,
|
|
10
|
+
PodNotification,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether a pod moment warrants a notification at this level (ADR 0021):
|
|
15
|
+
* `error` is park-only, `always` is park + done, `never` is silent. There is
|
|
16
|
+
* no per-outcome inventory — failed, lost, more, and cancelled never reach
|
|
17
|
+
* this function at all.
|
|
18
|
+
*/
|
|
19
|
+
export function shouldNotify(level: NotifyLevel, event: NotificationEvent): boolean {
|
|
20
|
+
if (level === "never") return false;
|
|
21
|
+
if (level === "error") return event === "park";
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolves configured channels into notifiers. */
|
|
26
|
+
export function notifiersFor(config: NotificationsConfig): Notifier[] {
|
|
27
|
+
return config.channels.map(notifierFor);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function notifierFor(channel: NotificationChannel): Notifier {
|
|
31
|
+
if (channel.type === "webhook") return webhookNotifier(channel);
|
|
32
|
+
if (channel.type === "email") return emailNotifier(channel);
|
|
33
|
+
return systemNotifier(channel);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Offers one pod moment to every configured channel, best effort: the
|
|
38
|
+
* channels run in parallel inside their ten-second budgets, and every failure
|
|
39
|
+
* becomes a note for cron.log instead of a throw — a broken webhook must never
|
|
40
|
+
* break the pass, and a failed delivery is logged, never retried (ADR 0021).
|
|
41
|
+
*/
|
|
42
|
+
export async function dispatchNotification(
|
|
43
|
+
config: NotificationsConfig,
|
|
44
|
+
notification: PodNotification,
|
|
45
|
+
): Promise<string[]> {
|
|
46
|
+
if (config.channels.length === 0) {
|
|
47
|
+
// Unconfigured is a valid state, not an error: the moment is consumed and
|
|
48
|
+
// the log says where the announcement would have gone.
|
|
49
|
+
return [
|
|
50
|
+
`no notification channels configured; ${notification.event} of ${notification.podId} not announced (add channels to config.json)`,
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const notifiers = notifiersFor(config);
|
|
55
|
+
const settled = await Promise.allSettled(
|
|
56
|
+
notifiers.map((notifier) => notifier.send(notification)),
|
|
57
|
+
);
|
|
58
|
+
return settled.flatMap((result, index) => {
|
|
59
|
+
if (result.status === "fulfilled") return [];
|
|
60
|
+
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
61
|
+
return [`channel "${notifiers[index]!.id}" failed for ${notification.podId}: ${reason}`];
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { bodyOf, subjectOf } from "./format.ts";
|
|
2
|
+
import type { EmailChannel, Notifier, PodNotification } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** What nodemailer's transport needs; injectable so tests never touch SMTP. */
|
|
5
|
+
export type MailTransport = {
|
|
6
|
+
sendMail(options: {
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
subject: string;
|
|
10
|
+
text: string;
|
|
11
|
+
}): Promise<unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type TransportFactory = (channel: EmailChannel, password?: string) => Promise<MailTransport>;
|
|
15
|
+
|
|
16
|
+
type NodemailerModule = {
|
|
17
|
+
createTransport(options: {
|
|
18
|
+
host: string;
|
|
19
|
+
port: number;
|
|
20
|
+
secure: boolean;
|
|
21
|
+
auth?: { user: string; pass: string };
|
|
22
|
+
}): MailTransport;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds the real transport by lazily importing nodemailer, which is an
|
|
27
|
+
* optional dependency: only users who configure an email channel pay for it,
|
|
28
|
+
* and everyone else keeps the zero-SMTP footprint. The specifier is a variable
|
|
29
|
+
* so typecheck never requires the package to be installed.
|
|
30
|
+
*/
|
|
31
|
+
const nodemailerTransport: TransportFactory = async (channel, password) => {
|
|
32
|
+
const specifier = "nodemailer";
|
|
33
|
+
let nodemailer: NodemailerModule;
|
|
34
|
+
try {
|
|
35
|
+
nodemailer = (await import(specifier)) as NodemailerModule;
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`email channel "${channel.id}" needs the optional nodemailer package: run "npm install nodemailer" (or reinstall agents-pod)`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return nodemailer.createTransport({
|
|
42
|
+
host: channel.host,
|
|
43
|
+
port: channel.port ?? 465,
|
|
44
|
+
secure: channel.secure ?? (channel.port ?? 465) === 465,
|
|
45
|
+
...(channel.user ? { auth: { user: channel.user, pass: password ?? "" } } : {}),
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function emailNotifier(
|
|
50
|
+
channel: EmailChannel,
|
|
51
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
52
|
+
createTransport: TransportFactory = nodemailerTransport,
|
|
53
|
+
): Notifier {
|
|
54
|
+
return {
|
|
55
|
+
id: channel.id,
|
|
56
|
+
async send(n: PodNotification): Promise<void> {
|
|
57
|
+
const password = env[channel.passwordEnv ?? "AGENTS_POD_SMTP_PASSWORD"];
|
|
58
|
+
if (channel.user && !password) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`email channel "${channel.id}" authenticates as ${channel.user}, but ${channel.passwordEnv ?? "AGENTS_POD_SMTP_PASSWORD"} is not set`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const transport = await createTransport(channel, password);
|
|
64
|
+
await transport.sendMail({
|
|
65
|
+
from: channel.from,
|
|
66
|
+
to: channel.to,
|
|
67
|
+
subject: `${channel.subjectPrefix ?? "[agents-pod] "}${subjectOf(n)}`,
|
|
68
|
+
text: bodyOf(n),
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { NotificationEvent, PodNotification } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const ICONS: Record<NotificationEvent, string> = {
|
|
4
|
+
park: "■",
|
|
5
|
+
done: "✓",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** One-line human summary, used as the email subject and the text body's head. */
|
|
9
|
+
export function subjectOf(n: PodNotification): string {
|
|
10
|
+
return `${ICONS[n.event]} ${n.event}: ${n.goal}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The one pointer line: enough to decide whether to look, fix, or re-wake. */
|
|
14
|
+
export function pointerOf(n: PodNotification): string {
|
|
15
|
+
return `agents-pod status ${n.podId} · agents-pod logs ${n.podId}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Plain-text body: the verdict a human needs, and nothing else. */
|
|
19
|
+
export function bodyOf(n: PodNotification): string {
|
|
20
|
+
if (n.event === "done") {
|
|
21
|
+
return [
|
|
22
|
+
`pod: ${n.podId}`,
|
|
23
|
+
`goal: ${n.goal}`,
|
|
24
|
+
`handoff: ${n.handoffStatus ?? "status: done"}`,
|
|
25
|
+
"",
|
|
26
|
+
pointerOf(n),
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
`pod: ${n.podId}`,
|
|
32
|
+
`goal: ${n.goal}`,
|
|
33
|
+
...(n.reason ? [`reason: ${n.reason}`] : []),
|
|
34
|
+
`consecutive failures: ${n.consecutiveFailures ?? 0}`,
|
|
35
|
+
...(n.failures ?? []).map(
|
|
36
|
+
(failure) =>
|
|
37
|
+
`- wake-up ${failure.n} ${failure.outcome}${failure.detail ? `: ${failure.detail}` : ""}`,
|
|
38
|
+
),
|
|
39
|
+
"",
|
|
40
|
+
pointerOf(n),
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { Notifier, PodNotification, SystemChannel } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** Same delivery budget as a webhook: a hung helper must not stall a pass (ADR 0021). */
|
|
5
|
+
const TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
/** Notifications carry a goal line; never let it grow past a banner's size. */
|
|
8
|
+
const MAX_DETAIL = 500;
|
|
9
|
+
|
|
10
|
+
/** Injectable process runner so tests never pop real notifications. */
|
|
11
|
+
export type CommandRunner = (command: string, args: string[]) => Promise<{ status: number | null; stderr: string }>;
|
|
12
|
+
|
|
13
|
+
const spawnRunner: CommandRunner = (command, args) =>
|
|
14
|
+
new Promise((resolve, reject) => {
|
|
15
|
+
const child = spawn(command, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
16
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
|
|
17
|
+
let stderr = "";
|
|
18
|
+
child.stderr?.on("data", (chunk) => (stderr += chunk));
|
|
19
|
+
child.on("error", (err) => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
reject(err);
|
|
22
|
+
});
|
|
23
|
+
child.on("close", (status) => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolve({ status, stderr });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Announces a pod moment on the desktop: macOS Notification Center via
|
|
31
|
+
* osascript, Linux via notify-send. The goal is user text, so it is
|
|
32
|
+
* length-capped and AppleScript-escaped before it ever reaches a script.
|
|
33
|
+
*/
|
|
34
|
+
export function systemNotifier(
|
|
35
|
+
channel: SystemChannel,
|
|
36
|
+
platform: NodeJS.Platform = process.platform,
|
|
37
|
+
run: CommandRunner = spawnRunner,
|
|
38
|
+
): Notifier {
|
|
39
|
+
return {
|
|
40
|
+
id: channel.id,
|
|
41
|
+
async send(n: PodNotification): Promise<void> {
|
|
42
|
+
const title = `agents-pod ${n.event}`;
|
|
43
|
+
const detail = cap(n.goal);
|
|
44
|
+
if (platform === "darwin") {
|
|
45
|
+
const result = await run("osascript", [
|
|
46
|
+
"-e",
|
|
47
|
+
[
|
|
48
|
+
`display notification "${escape(detail)}"`,
|
|
49
|
+
`with title "${escape(title)}"`,
|
|
50
|
+
`subtitle "${escape(n.podId)}"`,
|
|
51
|
+
...(channel.sound ? [`sound name "${escape(channel.sound)}"`] : []),
|
|
52
|
+
].join(" "),
|
|
53
|
+
]);
|
|
54
|
+
requireSuccess("osascript", result);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (platform === "linux") {
|
|
58
|
+
const result = await run("notify-send", [title, detail]);
|
|
59
|
+
requireSuccess("notify-send", result);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`system channel "${channel.id}" supports macOS and Linux, not ${platform}`);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A helper that exited non-zero (or was killed) failed — surface its exit status and stderr. */
|
|
68
|
+
function requireSuccess(command: string, result: { status: number | null; stderr: string }): void {
|
|
69
|
+
if (result.status !== 0) throw new Error(`${command} exited ${result.status}: ${result.stderr.trim()}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** AppleScript string literal: only backslash and double quote are special. */
|
|
73
|
+
function escape(text: string): string {
|
|
74
|
+
return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cap(text: string): string {
|
|
78
|
+
return text.length > MAX_DETAIL ? `${text.slice(0, MAX_DETAIL)}…` : text;
|
|
79
|
+
}
|