agents-pod 0.1.0 → 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 +28 -0
- package/README.md +209 -22
- package/package.json +1 -1
- package/src/cli.ts +399 -47
- package/src/inbox.ts +75 -9
- package/src/listeners.ts +11 -5
- package/src/outbox.ts +294 -0
- package/src/paths.ts +10 -0
- package/src/sources.ts +278 -0
- package/src/workspace.ts +11 -4
- package/src/wrapper.ts +71 -22
package/src/inbox.ts
CHANGED
|
@@ -1,18 +1,58 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
/**
|
|
4
|
-
* An Inbox Item (ADR 0013): one positional id, the
|
|
4
|
+
* An Inbox Item (ADR 0013): one positional id, the values a Pod Template
|
|
5
5
|
* binds, and the facts the two-pass stability guard compares. `{{path}}` is the
|
|
6
6
|
* item file's absolute path (directory inbox) or the JSONL path plus line.
|
|
7
|
+
*
|
|
8
|
+
* The optional routing fields are the platform's passengers (ADR 0026): they
|
|
9
|
+
* never join the id or the body, they only feed the Delivery Address.
|
|
7
10
|
*/
|
|
8
11
|
export interface InboxItem {
|
|
9
|
-
/** Directory:
|
|
12
|
+
/** Directory: filename. File: line number. Source: the platform's id (ADRs 0013, 0024). */
|
|
10
13
|
id: string;
|
|
11
14
|
title: string;
|
|
12
15
|
body: string;
|
|
16
|
+
/** Empty for a Source item, which has no filesystem attachment (ADR 0024). */
|
|
13
17
|
path: string;
|
|
18
|
+
/** Inbox stability facts only; placeholders are zero for a Source item. */
|
|
14
19
|
mtimeMs: number;
|
|
15
20
|
size: number;
|
|
21
|
+
/** The chat a reply has to land in; without it there is no address. */
|
|
22
|
+
chatId?: string;
|
|
23
|
+
/** The root message: the Conversation's key, propagated by the platform. */
|
|
24
|
+
rootId?: string;
|
|
25
|
+
/** The topic thread, when the platform gave the message one. */
|
|
26
|
+
threadId?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The Delivery Address (ADR 0026): the Conversation's key, the chat, and the
|
|
31
|
+
* optional thread as one opaque token — the string an outbox entry's `to`
|
|
32
|
+
* takes verbatim and a deliverer parses. Core never reads it back. An item with
|
|
33
|
+
* no chat has nowhere to be answered, so it renders the empty string rather
|
|
34
|
+
* than a token with a `chat=` hole; root and thread are passengers, never a
|
|
35
|
+
* substitute for the chat.
|
|
36
|
+
*
|
|
37
|
+
* The affinity is `root_id ?? item id`, exactly as ADR 0026 states it. For a
|
|
38
|
+
* directory or JSONL inbox the id is the item's position, not a platform
|
|
39
|
+
* message id; a Source (ADR 0024) is what makes the id the platform's, and
|
|
40
|
+
* therefore the fallback usable by a deliverer.
|
|
41
|
+
*/
|
|
42
|
+
export function deliveryAddress(
|
|
43
|
+
item: Pick<InboxItem, "id" | "chatId" | "rootId" | "threadId">,
|
|
44
|
+
): string {
|
|
45
|
+
if (item.chatId === undefined) return "";
|
|
46
|
+
const affinity = item.rootId ?? item.id;
|
|
47
|
+
const thread = item.threadId === undefined ? "" : `;thread=${item.threadId}`;
|
|
48
|
+
return `chat=${item.chatId};affinity=${affinity}${thread}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A routing field is a non-empty, trimmed string; anything else is as ignored as an unknown key. */
|
|
52
|
+
function routingField(value: unknown): string | undefined {
|
|
53
|
+
if (typeof value !== "string") return undefined;
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
return trimmed === "" ? undefined : trimmed;
|
|
16
56
|
}
|
|
17
57
|
|
|
18
58
|
/** A malformed JSONL line: skipped, unclaimed, warned on every pass (ADR 0013). */
|
|
@@ -74,8 +114,10 @@ async function scanDirectory(dir: string): Promise<InboxScan> {
|
|
|
74
114
|
|
|
75
115
|
/**
|
|
76
116
|
* A file inbox is JSONL only (ADR 0013): `body` is required, `title` optional,
|
|
77
|
-
*
|
|
78
|
-
*
|
|
117
|
+
* `chat_id`/`root_id`/`thread_id` are read as the item's routing fields
|
|
118
|
+
* (ADR 0026), and every other key is ignored. A line that does not parse, or
|
|
119
|
+
* carries no string body, is skipped and reported — never claimed, never fatal
|
|
120
|
+
* to the pass.
|
|
79
121
|
*/
|
|
80
122
|
async function scanJsonl(file: string): Promise<InboxScan> {
|
|
81
123
|
const info = await stat(file).catch(() => undefined);
|
|
@@ -101,7 +143,13 @@ async function scanJsonl(file: string): Promise<InboxScan> {
|
|
|
101
143
|
bad.push({ line, reason: "invalid JSON (not an object)" });
|
|
102
144
|
continue;
|
|
103
145
|
}
|
|
104
|
-
const record = parsed as {
|
|
146
|
+
const record = parsed as {
|
|
147
|
+
body?: unknown;
|
|
148
|
+
title?: unknown;
|
|
149
|
+
chat_id?: unknown;
|
|
150
|
+
root_id?: unknown;
|
|
151
|
+
thread_id?: unknown;
|
|
152
|
+
};
|
|
105
153
|
if (typeof record.body !== "string") {
|
|
106
154
|
bad.push({ line, reason: "no body" });
|
|
107
155
|
continue;
|
|
@@ -110,6 +158,9 @@ async function scanJsonl(file: string): Promise<InboxScan> {
|
|
|
110
158
|
typeof record.title === "string" && record.title.trim() !== ""
|
|
111
159
|
? record.title
|
|
112
160
|
: firstLine(record.body);
|
|
161
|
+
const chatId = routingField(record.chat_id);
|
|
162
|
+
const rootId = routingField(record.root_id);
|
|
163
|
+
const threadId = routingField(record.thread_id);
|
|
113
164
|
items.push({
|
|
114
165
|
id: String(line),
|
|
115
166
|
title,
|
|
@@ -117,6 +168,9 @@ async function scanJsonl(file: string): Promise<InboxScan> {
|
|
|
117
168
|
path: `${file}#${line}`,
|
|
118
169
|
mtimeMs: info.mtimeMs,
|
|
119
170
|
size: info.size,
|
|
171
|
+
...(chatId === undefined ? {} : { chatId }),
|
|
172
|
+
...(rootId === undefined ? {} : { rootId }),
|
|
173
|
+
...(threadId === undefined ? {} : { threadId }),
|
|
120
174
|
});
|
|
121
175
|
}
|
|
122
176
|
return { items, bad };
|
|
@@ -129,8 +183,19 @@ export async function scanInbox(inbox: string): Promise<InboxScan> {
|
|
|
129
183
|
return info.isDirectory() ? scanDirectory(inbox) : scanJsonl(inbox);
|
|
130
184
|
}
|
|
131
185
|
|
|
132
|
-
/**
|
|
133
|
-
|
|
186
|
+
/**
|
|
187
|
+
* The five template placeholders (ADR 0013, amended by ADR 0026), each one a
|
|
188
|
+
* renderer: adding a sixth is one row, not a set plus two casts.
|
|
189
|
+
*/
|
|
190
|
+
const PLACEHOLDERS: Record<string, (item: InboxItem) => string> = {
|
|
191
|
+
id: (item) => item.id,
|
|
192
|
+
title: (item) => item.title,
|
|
193
|
+
body: (item) => item.body,
|
|
194
|
+
path: (item) => item.path,
|
|
195
|
+
// The address is derived, not a field: an item without routing fields
|
|
196
|
+
// renders empty, so a template stays valid for both kinds of item.
|
|
197
|
+
reply_to: deliveryAddress,
|
|
198
|
+
};
|
|
134
199
|
|
|
135
200
|
/**
|
|
136
201
|
* Render a Pod Template against one item. An unknown `{{...}}` is a
|
|
@@ -144,11 +209,12 @@ export function renderTemplate(
|
|
|
144
209
|
const unknown: string[] = [];
|
|
145
210
|
const goal = template.replace(/\{\{([^{}]*)\}\}/g, (whole: string, raw: string) => {
|
|
146
211
|
const name = raw.trim();
|
|
147
|
-
|
|
212
|
+
const render = PLACEHOLDERS[name];
|
|
213
|
+
if (render === undefined) {
|
|
148
214
|
unknown.push(name);
|
|
149
215
|
return whole;
|
|
150
216
|
}
|
|
151
|
-
return item
|
|
217
|
+
return render(item);
|
|
152
218
|
});
|
|
153
219
|
if (unknown.length > 0) return { error: `unknown placeholder {{${unknown[0]}}}` };
|
|
154
220
|
return { goal };
|
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");
|