agents-pod 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +283 -25
- package/examples/feishu/.env.example +23 -0
- package/examples/feishu/README.md +92 -0
- package/examples/feishu/feishu_client.py +220 -0
- package/examples/feishu/feishu_deliver.py +136 -0
- package/examples/feishu/feishu_drain.py +305 -0
- package/package.json +2 -1
- package/src/cli.ts +472 -47
- package/src/doctor.ts +551 -0
- package/src/inbox.ts +75 -9
- package/src/listeners.ts +11 -5
- package/src/outbox.ts +294 -0
- package/src/paths.ts +15 -0
- package/src/sources.ts +312 -0
- package/src/workspace.ts +11 -4
- package/src/wrapper.ts +71 -22
package/src/listeners.ts
CHANGED
|
@@ -11,9 +11,10 @@ import { listenersFile } from "./paths.ts";
|
|
|
11
11
|
export type ListenerMode = "template" | "pod";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* A consumed item, by position
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* A consumed item, by position for an inbox or platform id for a Source
|
|
15
|
+
* (ADRs 0013, 0024): the claim namespace is the origin, so `inbox` + `item` is
|
|
16
|
+
* the key across listeners. `spawnedPodId` is what the template materialized;
|
|
17
|
+
* `blocked` is a reason materialization will not happen
|
|
17
18
|
* (a configuration error, or an item that vanished). `appended` is ADR 0014's
|
|
18
19
|
* one boolean for a bound standing pod: the block is in the pod's `## Inbox`.
|
|
19
20
|
* Beyond that the document is the truth — there is no per-item lifecycle.
|
|
@@ -21,6 +22,8 @@ export type ListenerMode = "template" | "pod";
|
|
|
21
22
|
export interface ClaimRecord {
|
|
22
23
|
inbox: string;
|
|
23
24
|
item: string;
|
|
25
|
+
/** Source claims retain the current and previous successful drain (ADR 0024). */
|
|
26
|
+
claimedAt?: number;
|
|
24
27
|
spawnedPodId?: string;
|
|
25
28
|
blocked?: string;
|
|
26
29
|
appended?: boolean;
|
|
@@ -35,13 +38,16 @@ export interface SeenFact {
|
|
|
35
38
|
|
|
36
39
|
export interface ListenerRecord {
|
|
37
40
|
id: string;
|
|
38
|
-
/**
|
|
39
|
-
inbox
|
|
41
|
+
/** Exactly one origin: an absolute inbox path or a Source name (ADR 0024). */
|
|
42
|
+
inbox?: string;
|
|
43
|
+
source?: string;
|
|
40
44
|
/** P0's rule: a plain substring; absent means every item matches. */
|
|
41
45
|
match?: string;
|
|
42
46
|
mode: ListenerMode;
|
|
43
47
|
/** Template mode: the goal template file. */
|
|
44
48
|
template?: string;
|
|
49
|
+
/** Template mode: materialize under this project instead of the tick's cwd (ADR 0007). */
|
|
50
|
+
project?: string;
|
|
45
51
|
/** Pod mode: the standing pod whose `## Inbox` the items join (ADR 0014). */
|
|
46
52
|
podId?: string;
|
|
47
53
|
maxConcurrent: number;
|
package/src/outbox.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
appendFile,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
readdir,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { cronLogPath, delivererFile, podDir } from "./paths.ts";
|
|
15
|
+
|
|
16
|
+
/** The single operator delivery command (ADR 0025). */
|
|
17
|
+
export interface DelivererRecord {
|
|
18
|
+
name: string;
|
|
19
|
+
cmd: string;
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const OUTBOX_FLUSH_MAX_ENTRIES = 10;
|
|
24
|
+
export const OUTBOX_FLUSH_BUDGET_MS = 10_000;
|
|
25
|
+
export const OUTBOX_DEAD_AFTER_MS = 3_600_000;
|
|
26
|
+
|
|
27
|
+
export async function readDeliverer(): Promise<DelivererRecord | undefined> {
|
|
28
|
+
try {
|
|
29
|
+
const parsed: unknown = JSON.parse(await readFile(delivererFile(), "utf8"));
|
|
30
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
31
|
+
? (parsed as DelivererRecord)
|
|
32
|
+
: undefined;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The single record has its own atomic write, beside sources.json (ADR 0025). */
|
|
40
|
+
export async function writeDeliverer(deliverer: DelivererRecord): Promise<void> {
|
|
41
|
+
const file = delivererFile();
|
|
42
|
+
await mkdir(dirname(file), { recursive: true });
|
|
43
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
44
|
+
await writeFile(tmp, `${JSON.stringify(deliverer, null, 2)}\n`);
|
|
45
|
+
await rename(tmp, file);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The filename is the uuid's third input, so a later pass can re-derive it
|
|
50
|
+
* without registry state (ADR 0025, amended by #14). Fifty hex chars fit the
|
|
51
|
+
* delivery platform's uuid limit.
|
|
52
|
+
*/
|
|
53
|
+
export function outboxUuid(podId: string, wakeup: number, filename: string): string {
|
|
54
|
+
return createHash("sha256")
|
|
55
|
+
.update(podId)
|
|
56
|
+
.update("\0")
|
|
57
|
+
.update(String(wakeup))
|
|
58
|
+
.update("\0")
|
|
59
|
+
.update(filename)
|
|
60
|
+
.digest("hex")
|
|
61
|
+
.slice(0, 50);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function logOutbox(line: string): Promise<void> {
|
|
65
|
+
const file = cronLogPath();
|
|
66
|
+
await mkdir(dirname(file), { recursive: true });
|
|
67
|
+
await appendFile(file, `${line}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validEntry(raw: string): boolean {
|
|
71
|
+
try {
|
|
72
|
+
const parsed: unknown = JSON.parse(raw);
|
|
73
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
|
|
74
|
+
const entry = parsed as { to?: unknown; text?: unknown };
|
|
75
|
+
// The shape is fixed by ADR 0025: two strings, the address and the words.
|
|
76
|
+
return typeof entry.to === "string" && entry.to !== "" && typeof entry.text === "string";
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Exit 0 is the only acknowledgement; every other outcome leaves the file. */
|
|
83
|
+
async function deliverOne(
|
|
84
|
+
deliverer: DelivererRecord,
|
|
85
|
+
podId: string,
|
|
86
|
+
project: string,
|
|
87
|
+
wakeup: number,
|
|
88
|
+
filename: string,
|
|
89
|
+
raw: string,
|
|
90
|
+
timeoutMs: number,
|
|
91
|
+
): Promise<string | undefined> {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
const child = spawn(deliverer.cmd, [], {
|
|
94
|
+
cwd: project,
|
|
95
|
+
env: {
|
|
96
|
+
...process.env,
|
|
97
|
+
AGENTS_POD_POD_ID: podId,
|
|
98
|
+
AGENTS_POD_WAKEUP_N: String(wakeup),
|
|
99
|
+
AGENTS_POD_OUTBOX_ENTRY: filename,
|
|
100
|
+
AGENTS_POD_OUTBOX_UUID: outboxUuid(podId, wakeup, filename),
|
|
101
|
+
},
|
|
102
|
+
// A delivery command can leave a child behind; its own process group
|
|
103
|
+
// makes the invocation timeout a hard bound (ADR 0025).
|
|
104
|
+
detached: true,
|
|
105
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
106
|
+
});
|
|
107
|
+
const kill = (): void => {
|
|
108
|
+
if (child.pid === undefined) {
|
|
109
|
+
child.kill("SIGKILL");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
process.kill(-child.pid, "SIGKILL");
|
|
114
|
+
} catch {
|
|
115
|
+
child.kill("SIGKILL");
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
let timedOut = false;
|
|
119
|
+
let spawnError: string | undefined;
|
|
120
|
+
const timer = setTimeout(() => {
|
|
121
|
+
timedOut = true;
|
|
122
|
+
kill();
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
child.on("error", (error) => {
|
|
125
|
+
spawnError = error.message;
|
|
126
|
+
});
|
|
127
|
+
child.on("close", (code, signal) => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
if (timedOut) return resolve(`timed out after ${timeoutMs / 1000}s`);
|
|
130
|
+
if (spawnError !== undefined) return resolve(spawnError);
|
|
131
|
+
if (code !== 0) {
|
|
132
|
+
return resolve(
|
|
133
|
+
signal === null ? `exit ${code ?? "unknown"}` : `terminated by ${signal}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
resolve(undefined);
|
|
137
|
+
});
|
|
138
|
+
// A command may exit without reading stdin; that is its exit status, not a
|
|
139
|
+
// wrapper-level EPIPE failure.
|
|
140
|
+
child.stdin.on("error", () => undefined);
|
|
141
|
+
child.stdin.end(raw);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The leading filename field is the entry's immutable wake-up attribution. */
|
|
146
|
+
function entryWakeup(filename: string): number | undefined {
|
|
147
|
+
const match = /^([1-9]\d*)-/.exec(filename);
|
|
148
|
+
if (match === null) return undefined;
|
|
149
|
+
const wakeup = Number(match[1]);
|
|
150
|
+
return Number.isSafeInteger(wakeup) ? wakeup : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Dead-lettering changes the name, never the contents, and keeps the evidence. */
|
|
154
|
+
async function deadLetter(podId: string, file: string, name: string, reason: string): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
await rename(file, `${file}.dead`);
|
|
157
|
+
await logOutbox(`outbox ${podId}: ${name} dead-lettered (${reason}); kept as ${name}.dead`);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
await logOutbox(
|
|
160
|
+
`outbox ${podId}: ${name} dead-letter rename failed (${(error as Error).message}); file kept`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The wrapper and reconcile share one bounded flush (ADR 0025). A wrapper
|
|
167
|
+
* touches only its own wake-up's entries: older ones belong to pass retries.
|
|
168
|
+
* A pass derives every uuid from the filename, so retries keep their original
|
|
169
|
+
* wake-up number. Failures are audit lines, never outcome, policy, or
|
|
170
|
+
* notification changes.
|
|
171
|
+
*/
|
|
172
|
+
async function flushEntries(
|
|
173
|
+
podId: string,
|
|
174
|
+
project: string,
|
|
175
|
+
currentWakeup?: number,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
const dir = join(podDir(project, podId), "outbox");
|
|
178
|
+
let names: string[];
|
|
179
|
+
try {
|
|
180
|
+
names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
183
|
+
await logOutbox(`outbox ${podId}: unreadable (${(error as Error).message})`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (names.length === 0) return;
|
|
187
|
+
|
|
188
|
+
let deliverer: DelivererRecord | undefined;
|
|
189
|
+
try {
|
|
190
|
+
deliverer = await readDeliverer();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
await logOutbox(`outbox ${podId}: deliverer unreadable (${(error as Error).message})`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// The unconfigured wrapper flush stays a no-op; the pass owns waiting and
|
|
196
|
+
// aging so each pass can make the missing configuration audible once.
|
|
197
|
+
if (deliverer === undefined && currentWakeup !== undefined) return;
|
|
198
|
+
|
|
199
|
+
const startedAt = Date.now();
|
|
200
|
+
let invocations = 0;
|
|
201
|
+
let waiting = 0;
|
|
202
|
+
for (const name of names) {
|
|
203
|
+
const elapsed = Date.now() - startedAt;
|
|
204
|
+
if (
|
|
205
|
+
deliverer !== undefined &&
|
|
206
|
+
(invocations >= OUTBOX_FLUSH_MAX_ENTRIES || elapsed >= OUTBOX_FLUSH_BUDGET_MS)
|
|
207
|
+
) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const file = join(dir, name);
|
|
211
|
+
const wakeup = entryWakeup(name);
|
|
212
|
+
if (wakeup === undefined) {
|
|
213
|
+
await deadLetter(podId, file, name, "unparseable wake-up");
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (currentWakeup !== undefined) {
|
|
217
|
+
// An earlier wake-up's failed entry must survive later wrapper flushes;
|
|
218
|
+
// the pass retries it with the same uuid until the one-hour boundary.
|
|
219
|
+
if (wakeup < currentWakeup) continue;
|
|
220
|
+
if (wakeup > currentWakeup) {
|
|
221
|
+
await deadLetter(podId, file, name, `wake-up ${wakeup} is after ${currentWakeup}`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const info = await stat(file).catch(() => undefined);
|
|
227
|
+
if (info === undefined) {
|
|
228
|
+
await logOutbox(`outbox ${podId}: ${name} unreadable; skipped`);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (Date.now() - info.mtimeMs > OUTBOX_DEAD_AFTER_MS) {
|
|
232
|
+
await deadLetter(podId, file, name, "older than one hour");
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let raw: string;
|
|
237
|
+
try {
|
|
238
|
+
raw = await readFile(file, "utf8");
|
|
239
|
+
} catch (error) {
|
|
240
|
+
await logOutbox(`outbox ${podId}: ${name} unreadable (${(error as Error).message}); skipped`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!validEntry(raw)) {
|
|
244
|
+
await deadLetter(podId, file, name, "invalid entry (expected JSON with to and text)");
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (deliverer === undefined) {
|
|
248
|
+
waiting += 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const remainingMs = OUTBOX_FLUSH_BUDGET_MS - (Date.now() - startedAt);
|
|
253
|
+
if (remainingMs <= 0) return;
|
|
254
|
+
invocations += 1;
|
|
255
|
+
const error = await deliverOne(
|
|
256
|
+
deliverer,
|
|
257
|
+
podId,
|
|
258
|
+
project,
|
|
259
|
+
wakeup,
|
|
260
|
+
name,
|
|
261
|
+
raw,
|
|
262
|
+
Math.min(deliverer.timeoutMs, remainingMs),
|
|
263
|
+
);
|
|
264
|
+
if (error === undefined) {
|
|
265
|
+
try {
|
|
266
|
+
// Deletion is the acknowledgement; no completion marker exists.
|
|
267
|
+
await rm(file, { force: true });
|
|
268
|
+
} catch (deleteError) {
|
|
269
|
+
await logOutbox(
|
|
270
|
+
`outbox ${podId}: ${name} delivered but delete failed (${(deleteError as Error).message})`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
await logOutbox(`outbox ${podId}: ${name} delivery failed (${error}); file kept`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (deliverer === undefined && waiting > 0) {
|
|
278
|
+
await logOutbox(`outbox ${podId}: no deliverer configured; ${waiting} entries waiting`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Flush this wake-up's outbox after adjudication and before idle (ADR 0025). */
|
|
283
|
+
export async function flushOutbox(
|
|
284
|
+
podId: string,
|
|
285
|
+
project: string,
|
|
286
|
+
wakeup: number,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
await flushEntries(podId, project, wakeup);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Retry one non-active Pod's outbox during reconcile (ADR 0025). */
|
|
292
|
+
export async function retryOutbox(podId: string, project: string): Promise<void> {
|
|
293
|
+
await flushEntries(podId, project);
|
|
294
|
+
}
|
package/src/paths.ts
CHANGED
|
@@ -31,6 +31,16 @@ export function listenersFile(): string {
|
|
|
31
31
|
return join(storeRoot(), "listeners.json");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Sources and their committed cursors, beside rather than inside the registry (ADR 0024). */
|
|
35
|
+
export function sourcesFile(): string {
|
|
36
|
+
return join(storeRoot(), "sources.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The single outbound delivery command, beside the Source records (ADR 0025). */
|
|
40
|
+
export function delivererFile(): string {
|
|
41
|
+
return join(storeRoot(), "deliverer.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
/** The pass's single-flight advisory lock, one for the whole registry (ADR 0018). */
|
|
35
45
|
export function reconcileLockPath(): string {
|
|
36
46
|
return join(storeRoot(), "reconcile.lock");
|
|
@@ -46,6 +56,11 @@ export function notifyConfigFile(): string {
|
|
|
46
56
|
return join(storeRoot(), "config.json");
|
|
47
57
|
}
|
|
48
58
|
|
|
59
|
+
/** When the last pass finished (ADR 0027): "no pass ever ran" is its own fact. */
|
|
60
|
+
export function lastPassFile(): string {
|
|
61
|
+
return join(storeRoot(), "last-pass.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
/** The short-lived registry mutex that makes state transitions CAS (ADR 0017, 0018). */
|
|
50
65
|
export function registryLockPath(): string {
|
|
51
66
|
return join(storeRoot(), "registry.lock");
|
package/src/sources.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import type { BadInboxLine, InboxItem } from "./inbox.ts";
|
|
6
|
+
import type { ListenerRecord } from "./listeners.ts";
|
|
7
|
+
import { sourcesFile } from "./paths.ts";
|
|
8
|
+
|
|
9
|
+
/** A Source outlives its Listeners; its opaque cursor lives beside them (ADR 0024). */
|
|
10
|
+
export interface SourceRecord {
|
|
11
|
+
name: string;
|
|
12
|
+
cmd: string;
|
|
13
|
+
timeoutMs: number;
|
|
14
|
+
cursor?: string;
|
|
15
|
+
/** The previous successful drain is the claim-retention horizon (ADR 0024). */
|
|
16
|
+
lastDrainAt?: number;
|
|
17
|
+
/**
|
|
18
|
+
* That drain's yield (ADR 0027): zero is a fact, not a gap — a pull that runs
|
|
19
|
+
* and delivers nothing is the shape of a conversation that never reaches it.
|
|
20
|
+
*/
|
|
21
|
+
lastDrainItems?: number;
|
|
22
|
+
/** A failed drain's fact (ADR 0027): a report cannot say `ok` while every pass fails. */
|
|
23
|
+
lastFailureAt?: number;
|
|
24
|
+
lastError?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SourceDrain {
|
|
28
|
+
name: string;
|
|
29
|
+
items: InboxItem[];
|
|
30
|
+
bad: BadInboxLine[];
|
|
31
|
+
cursor?: string;
|
|
32
|
+
/** Successful drains carry their generation and the preceding one's boundary. */
|
|
33
|
+
drainedAt?: number;
|
|
34
|
+
previousDrainAt?: number;
|
|
35
|
+
/** A failed drain contributes nothing and leaves the cursor untouched. */
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One MiB is the whole successful stdout budget (ADR 0024). */
|
|
40
|
+
export const DRAIN_STDOUT_CAP = 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
export async function readSources(): Promise<SourceRecord[]> {
|
|
43
|
+
try {
|
|
44
|
+
const parsed: unknown = JSON.parse(await readFile(sourcesFile(), "utf8"));
|
|
45
|
+
return Array.isArray(parsed) ? (parsed as SourceRecord[]) : [];
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The cursor's own atomic write, separate from listeners.json (ADR 0024). */
|
|
53
|
+
export async function writeSources(sources: SourceRecord[]): Promise<void> {
|
|
54
|
+
const file = sourcesFile();
|
|
55
|
+
await mkdir(dirname(file), { recursive: true });
|
|
56
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
57
|
+
await writeFile(tmp, `${JSON.stringify(sources, null, 2)}\n`);
|
|
58
|
+
await rename(tmp, file);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function routingField(value: unknown): string | undefined {
|
|
62
|
+
if (typeof value !== "string") return undefined;
|
|
63
|
+
const trimmed = value.trim();
|
|
64
|
+
return trimmed === "" ? undefined : trimmed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A stream item is complete when its line parses; no two-pass guard applies. */
|
|
68
|
+
export function parseDrainOutput(name: string, text: string): SourceDrain {
|
|
69
|
+
const items: InboxItem[] = [];
|
|
70
|
+
const bad: BadInboxLine[] = [];
|
|
71
|
+
let cursor: string | undefined;
|
|
72
|
+
const lines = text.split("\n")
|
|
73
|
+
.map((raw, index) => ({ raw, line: index + 1 }))
|
|
74
|
+
.filter(({ raw }) => raw.trim() !== "");
|
|
75
|
+
|
|
76
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
77
|
+
const { raw, line } = lines[index]!;
|
|
78
|
+
let parsed: unknown;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
bad.push({ line, reason: "invalid JSON" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
86
|
+
bad.push({ line, reason: "invalid JSON (not an object)" });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const record = parsed as {
|
|
90
|
+
id?: unknown;
|
|
91
|
+
body?: unknown;
|
|
92
|
+
title?: unknown;
|
|
93
|
+
cursor?: unknown;
|
|
94
|
+
chat_id?: unknown;
|
|
95
|
+
root_id?: unknown;
|
|
96
|
+
thread_id?: unknown;
|
|
97
|
+
};
|
|
98
|
+
if (
|
|
99
|
+
index === lines.length - 1 &&
|
|
100
|
+
"cursor" in record &&
|
|
101
|
+
record.id === undefined &&
|
|
102
|
+
record.body === undefined
|
|
103
|
+
) {
|
|
104
|
+
if (typeof record.cursor !== "string") {
|
|
105
|
+
bad.push({ line, reason: "invalid cursor" });
|
|
106
|
+
} else {
|
|
107
|
+
cursor = record.cursor;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (typeof record.id !== "string" || record.id.trim() === "") {
|
|
112
|
+
bad.push({ line, reason: "no id" });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (typeof record.body !== "string") {
|
|
116
|
+
bad.push({ line, reason: "no body" });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const title =
|
|
120
|
+
typeof record.title === "string" && record.title.trim() !== ""
|
|
121
|
+
? record.title
|
|
122
|
+
: record.body.split("\n", 1)[0]!.trim();
|
|
123
|
+
const chatId = routingField(record.chat_id);
|
|
124
|
+
const rootId = routingField(record.root_id);
|
|
125
|
+
const threadId = routingField(record.thread_id);
|
|
126
|
+
items.push({
|
|
127
|
+
id: record.id,
|
|
128
|
+
title,
|
|
129
|
+
body: record.body,
|
|
130
|
+
path: "",
|
|
131
|
+
mtimeMs: 0,
|
|
132
|
+
size: 0,
|
|
133
|
+
...(chatId === undefined ? {} : { chatId }),
|
|
134
|
+
...(rootId === undefined ? {} : { rootId }),
|
|
135
|
+
...(threadId === undefined ? {} : { threadId }),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return { name, items, bad, ...(cursor === undefined ? {} : { cursor }) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function drainOne(source: SourceRecord, project: string): Promise<SourceDrain> {
|
|
142
|
+
const empty = { name: source.name, items: [], bad: [] };
|
|
143
|
+
const result = await new Promise<{ stdout?: string; error?: string }>((resolve) => {
|
|
144
|
+
const child = spawn(source.cmd, [], {
|
|
145
|
+
cwd: project,
|
|
146
|
+
env: {
|
|
147
|
+
...process.env,
|
|
148
|
+
AGENTS_POD_SOURCE_ID: source.name,
|
|
149
|
+
AGENTS_POD_CURSOR: source.cursor ?? "",
|
|
150
|
+
},
|
|
151
|
+
// A shell drain can leave a child holding stdout after the shell dies.
|
|
152
|
+
// Its own process group makes the timeout and output cap real bounds.
|
|
153
|
+
detached: true,
|
|
154
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
155
|
+
});
|
|
156
|
+
const kill = (): void => {
|
|
157
|
+
if (child.pid === undefined) {
|
|
158
|
+
child.kill("SIGKILL");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
process.kill(-child.pid, "SIGKILL");
|
|
163
|
+
} catch {
|
|
164
|
+
child.kill("SIGKILL");
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const chunks: Buffer[] = [];
|
|
168
|
+
let bytes = 0;
|
|
169
|
+
let oversized = false;
|
|
170
|
+
let timedOut = false;
|
|
171
|
+
let spawnError: string | undefined;
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
timedOut = true;
|
|
174
|
+
kill();
|
|
175
|
+
}, source.timeoutMs);
|
|
176
|
+
|
|
177
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
178
|
+
bytes += chunk.length;
|
|
179
|
+
if (bytes > DRAIN_STDOUT_CAP) {
|
|
180
|
+
if (!oversized) {
|
|
181
|
+
oversized = true;
|
|
182
|
+
kill();
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
chunks.push(chunk);
|
|
187
|
+
});
|
|
188
|
+
child.on("error", (error) => {
|
|
189
|
+
spawnError = error.message;
|
|
190
|
+
});
|
|
191
|
+
child.on("close", (code, signal) => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
if (oversized) return resolve({ error: "stdout exceeded 1 MiB" });
|
|
194
|
+
if (timedOut) return resolve({ error: `timed out after ${source.timeoutMs / 1000}s` });
|
|
195
|
+
if (spawnError !== undefined) return resolve({ error: spawnError });
|
|
196
|
+
if (code !== 0) {
|
|
197
|
+
return resolve({
|
|
198
|
+
error: signal === null ? `exit ${code ?? "unknown"}` : `terminated by ${signal}`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
resolve({ stdout: Buffer.concat(chunks).toString("utf8") });
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
if (result.error !== undefined) return { ...empty, error: result.error };
|
|
205
|
+
// Distinct generations need distinct boundaries even when two local drains
|
|
206
|
+
// finish within one wall-clock millisecond.
|
|
207
|
+
const drainedAt = Math.max(Date.now(), (source.lastDrainAt ?? 0) + 1);
|
|
208
|
+
return {
|
|
209
|
+
...parseDrainOutput(source.name, result.stdout ?? ""),
|
|
210
|
+
drainedAt,
|
|
211
|
+
...(source.lastDrainAt === undefined ? {} : { previousDrainAt: source.lastDrainAt }),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Drain each distinct Source once, before the registry lock or any Pod work (ADR 0024). */
|
|
216
|
+
export async function drainSources(
|
|
217
|
+
project: string,
|
|
218
|
+
listeners: ListenerRecord[],
|
|
219
|
+
): Promise<SourceDrain[]> {
|
|
220
|
+
const sources = await readSources();
|
|
221
|
+
const names = [...new Set(listeners.flatMap((listener) => listener.source ?? []))];
|
|
222
|
+
const drained: SourceDrain[] = [];
|
|
223
|
+
for (const name of names) {
|
|
224
|
+
const source = sources.find((candidate) => candidate.name === name);
|
|
225
|
+
drained.push(
|
|
226
|
+
source === undefined
|
|
227
|
+
? { name, items: [], bad: [], error: "source record is missing" }
|
|
228
|
+
: await drainOne(source, project),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return drained;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Two generations are enough: after this drain, anything older than the prior
|
|
236
|
+
* successful one is behind the committed cursor and cannot arrive again. The
|
|
237
|
+
* store is transformed without I/O; the caller writes it inside the registry
|
|
238
|
+
* lock before advancing the Source records (ADR 0024). Legacy claims without a
|
|
239
|
+
* timestamp are retained because their generation cannot be proven.
|
|
240
|
+
*/
|
|
241
|
+
export function pruneSourceClaims(
|
|
242
|
+
listeners: ListenerRecord[],
|
|
243
|
+
drained: SourceDrain[],
|
|
244
|
+
): { listeners: ListenerRecord[]; changed: boolean } {
|
|
245
|
+
const horizons = new Map<string, number>();
|
|
246
|
+
for (const drain of drained) {
|
|
247
|
+
if (drain.error === undefined && drain.previousDrainAt !== undefined) {
|
|
248
|
+
horizons.set(`source:${drain.name}`, drain.previousDrainAt);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (horizons.size === 0) return { listeners, changed: false };
|
|
252
|
+
|
|
253
|
+
let changed = false;
|
|
254
|
+
const pruned = listeners.map((listener) => {
|
|
255
|
+
const claimed = listener.claimed.filter((claim) => {
|
|
256
|
+
const horizon = horizons.get(claim.inbox);
|
|
257
|
+
return (
|
|
258
|
+
horizon === undefined || claim.claimedAt === undefined || claim.claimedAt >= horizon
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
if (claimed.length === listener.claimed.length) return listener;
|
|
262
|
+
changed = true;
|
|
263
|
+
return { ...listener, claimed };
|
|
264
|
+
});
|
|
265
|
+
return { listeners: pruned, changed };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Claims and pruning land before this cursor/retention-horizon write (ADR 0024). */
|
|
269
|
+
export async function commitSourceCursors(drained: SourceDrain[]): Promise<void> {
|
|
270
|
+
const sources = await readSources();
|
|
271
|
+
let changed = false;
|
|
272
|
+
for (const drain of drained) {
|
|
273
|
+
if (drain.error !== undefined || drain.drainedAt === undefined) continue;
|
|
274
|
+
const source = sources.find((candidate) => candidate.name === drain.name);
|
|
275
|
+
if (source === undefined) continue;
|
|
276
|
+
if (drain.cursor !== undefined && source.cursor !== drain.cursor) {
|
|
277
|
+
source.cursor = drain.cursor;
|
|
278
|
+
changed = true;
|
|
279
|
+
}
|
|
280
|
+
if (source.lastDrainAt !== drain.drainedAt) {
|
|
281
|
+
source.lastDrainAt = drain.drainedAt;
|
|
282
|
+
changed = true;
|
|
283
|
+
}
|
|
284
|
+
// The yield is recorded even when it is zero (ADR 0027): doctor reports a
|
|
285
|
+
// silent pull only because the count exists.
|
|
286
|
+
if (source.lastDrainItems !== drain.items.length) {
|
|
287
|
+
source.lastDrainItems = drain.items.length;
|
|
288
|
+
changed = true;
|
|
289
|
+
}
|
|
290
|
+
// A success clears the failure it disproved (ADR 0027).
|
|
291
|
+
if (source.lastError !== undefined) {
|
|
292
|
+
delete source.lastError;
|
|
293
|
+
delete source.lastFailureAt;
|
|
294
|
+
changed = true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (changed) await writeSources(sources);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A failed drain leaves the cursor and the yield alone, so its only trace is a
|
|
302
|
+
* `cron.log` line — invisible to anyone not tailing the log. The record keeps
|
|
303
|
+
* the fact for the report (ADR 0027), and a later success clears it.
|
|
304
|
+
*/
|
|
305
|
+
export async function recordSourceFailure(name: string, error: string): Promise<void> {
|
|
306
|
+
const sources = await readSources();
|
|
307
|
+
const source = sources.find((candidate) => candidate.name === name);
|
|
308
|
+
if (source === undefined) return;
|
|
309
|
+
source.lastFailureAt = Date.now();
|
|
310
|
+
source.lastError = error;
|
|
311
|
+
await writeSources(sources);
|
|
312
|
+
}
|