@afokapu/atdd-flow 0.3.7 → 0.3.8

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/src/threads.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
2
3
  import { notify } from "./adapters";
3
4
  import {
4
5
  atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
5
- required, seat, words,
6
+ required, runtimeAddress, seat, words,
6
7
  } from "./core";
7
8
 
8
9
  export type Thread = { schema: string; id: string; subject: string; participants: string[]; state: "open" | "closed"; summary?: string; task?: string };
@@ -74,18 +75,135 @@ async function assertRoute(root: string, from: string, recipients: string[]) {
74
75
  }
75
76
  }
76
77
 
77
- async function inject(root: string, address: string, message: Message, threadId: string) {
78
+ type PendingReference = { thread: string; message: string; created_at: string; published?: boolean };
79
+ type PendingSegment = { schema: "atdd-flow/pi-inbox-segment/v1"; entries: PendingReference[]; next?: string };
80
+ type PendingQueue = { schema: "atdd-flow/pi-inbox-queue/v1"; head?: string; tail?: string };
81
+
82
+ function inboxDirectory(root: string, address: string) {
83
+ return join(root, ".atdd-flow", "pi-inbox", encodeURIComponent(address));
84
+ }
85
+
86
+ const pause = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
87
+
88
+ /** mkdir is atomic across CLI processes; stale locks are recoverable after a crashed writer. */
89
+ async function withInboxLock<T>(directory: string, action: () => Promise<T>) {
90
+ await mkdir(directory, { recursive: true });
91
+ const lock = join(directory, "queue.lock");
92
+ for (let attempt = 0; attempt < 200; attempt += 1) {
93
+ try {
94
+ await mkdir(lock, { recursive: false });
95
+ try { return await action(); }
96
+ finally { await rm(lock, { recursive: true, force: true }); }
97
+ } catch (error) {
98
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
99
+ try {
100
+ if (Date.now() - (await stat(lock)).mtimeMs > 30_000) await rm(lock, { recursive: true, force: true });
101
+ else await pause(5);
102
+ } catch { await pause(5); }
103
+ }
104
+ }
105
+ throw new Error(`Timed out waiting for inbox queue lock ${directory}`);
106
+ }
107
+
108
+ /** The queue is a bounded linked index: recovery reads only its head segment, never Desk history. */
109
+ type YamlWriter = (path: string, value: unknown) => Promise<void>;
110
+
111
+ export async function enqueueNativeMail(root: string, address: string, reference: PendingReference, writeYaml: YamlWriter = atomicYaml) {
112
+ const directory = inboxDirectory(root, address);
113
+ return withInboxLock(directory, async () => {
114
+ const statePath = join(directory, "queue.yaml");
115
+ let queue: PendingQueue;
116
+ try { queue = await readYaml<PendingQueue>(statePath); }
117
+ catch { queue = { schema: "atdd-flow/pi-inbox-queue/v1" }; }
118
+ const entriesPath = (segment: string) => join(directory, "pending", `${segment}.yaml`);
119
+ const compare = (left: PendingReference, right: PendingReference) => left.created_at.localeCompare(right.created_at) || left.message.localeCompare(right.message);
120
+ const capacity = 32;
121
+ if (!queue.head) {
122
+ const segment = `S-${reference.message}`;
123
+ await writeYaml(entriesPath(segment), { schema: "atdd-flow/pi-inbox-segment/v1", entries: [reference] } satisfies PendingSegment);
124
+ queue.head = segment;
125
+ queue.tail = segment;
126
+ await writeYaml(statePath, queue);
127
+ return segment;
128
+ }
129
+ // Insert under the same process-safe lock. A full segment spills its latest reference forward, preserving global created_at/message order across rollover.
130
+ let pending = reference;
131
+ let current = queue.head;
132
+ let referenceSegment: string | undefined;
133
+ while (current) {
134
+ const record = await readYaml<PendingSegment>(entriesPath(current));
135
+ record.entries.push(pending);
136
+ record.entries.sort(compare);
137
+ if (record.entries.some((entry) => entry.message === reference.message)) referenceSegment = current;
138
+ if (record.entries.length <= capacity) {
139
+ await writeYaml(entriesPath(current), record);
140
+ return referenceSegment ?? current;
141
+ }
142
+ pending = record.entries.pop()!;
143
+ if (pending.message === reference.message) referenceSegment = undefined;
144
+ if (record.next) {
145
+ await writeYaml(entriesPath(current), record);
146
+ current = record.next;
147
+ continue;
148
+ }
149
+ const next = `S-${pending.message}`;
150
+ // A child must be durable before its parent publishes the link. A crash or
151
+ // failed write can leave an unlinked orphan, but never a reachable hole.
152
+ await writeYaml(entriesPath(next), { schema: "atdd-flow/pi-inbox-segment/v1", entries: [pending] } satisfies PendingSegment);
153
+ record.next = next;
154
+ await writeYaml(entriesPath(current), record);
155
+ queue.tail = next;
156
+ await writeYaml(statePath, queue);
157
+ return referenceSegment ?? next;
158
+ }
159
+ throw new Error("Pending inbox queue has no reachable tail");
160
+ });
161
+ }
162
+
163
+ async function publishNativeMail(root: string, address: string, _segment: string, message: string) {
164
+ const directory = inboxDirectory(root, address);
165
+ await withInboxLock(directory, async () => {
166
+ const queue = await readYaml<PendingQueue>(join(directory, "queue.yaml"));
167
+ let segment = queue.head;
168
+ while (segment) {
169
+ const path = join(directory, "pending", `${segment}.yaml`);
170
+ const record = await readYaml<PendingSegment>(path);
171
+ const entry = record.entries.find((candidate) => candidate.message === message);
172
+ if (entry) {
173
+ entry.published = true;
174
+ await atomicYaml(path, record);
175
+ return;
176
+ }
177
+ segment = record.next;
178
+ }
179
+ // A reconciler can consume an authoritative M-file reference between message persistence and this advisory marker; that is already a successful, exactly-once delivery.
180
+ });
181
+ }
182
+
183
+ async function prepareNativeMail(root: string, address: string, message: Message, threadId: string) {
184
+ const target = await seat(root, address);
185
+ if (target.runtime?.wake !== "native" && target.agent !== "pi") return;
186
+ const segment = await enqueueNativeMail(root, address, { thread: threadId, message: message.id, created_at: message.created_at });
187
+ return { address, segment };
188
+ }
189
+
190
+ async function inject(root: string, address: string, message: Message, threadId: string, queued = false) {
78
191
  const target = await seat(root, address);
79
192
  const runtime = target.runtime;
193
+ if (runtime?.wake === "native" || target.agent === "pi") {
194
+ if (!queued) await enqueueNativeMail(root, address, { thread: threadId, message: message.id, created_at: message.created_at, published: true });
195
+ if (!runtime || runtime.wake === "native") return;
196
+ }
80
197
  if (!runtime) return;
81
- if (runtime.wake === "native") return;
82
- const nativeAddress = runtime.addresses[runtime.application];
83
- if (!nativeAddress) return;
198
+ const storedAddress = runtime.addresses[runtime.application];
199
+ if (!storedAddress) return;
200
+ const config = await desk(root);
201
+ const locator = runtimeAddress(runtime.application, storedAddress, config.herdr_session);
84
202
  const record = await thread(root, threadId);
85
203
  const recipients = message.to === "all" ? "all" : message.to.join(", ");
86
204
  const subject = record.subject.replace(/\s+/g, " ").trim();
87
205
  const notice = `SYSTEM: Flow mail ${message.id} | thread ${threadId} (${subject}) | ${message.from} → ${recipients}. Read: atdd-flow message read ${message.id}`;
88
- try { await notify(runtime.application, nativeAddress, notice, (await desk(root)).herdr_session); }
206
+ try { await notify(runtime.application, locator.address, notice, locator.session); }
89
207
  catch (error) { console.warn(`Notification for ${address} was not delivered: ${(error as Error).message}`); }
90
208
  }
91
209
 
@@ -97,24 +215,31 @@ async function post(root: string, threadId: string, args: string[], overrides: P
97
215
  const recipients = Array.isArray(toValue) ? toValue : await resolveRecipients(root, toValue, record.participants);
98
216
  if (recipients.some((address) => !record.participants.includes(address))) throw new Error("Recipients must be thread participants.");
99
217
  await assertRoute(root, from, recipients.filter((address) => address !== from));
218
+ const kind = overrides.kind ?? "message";
219
+ // Labels are an explicit, non-sensitive operator hint; message bodies never enter durable IDs.
220
+ const label = words(args, "--label") ?? kind;
100
221
  const message: Message = {
101
- schema: "atdd-workflow/message/v1", id: id("M"), from, to: toValue === "all" ? "all" : recipients,
102
- kind: overrides.kind ?? "message",
222
+ schema: "atdd-workflow/message/v1", id: id("M", label), from, to: toValue === "all" ? "all" : recipients,
223
+ kind,
103
224
  ...(overrides.in_reply_to ? { in_reply_to: overrides.in_reply_to } : {}),
104
225
  ...(overrides.expects_result || has(args, "--expects-result") ? { expects_result: true } : {}),
105
226
  created_at: now(), body: required(overrides.body ?? words(args, "--body"), "--body"),
106
227
  };
228
+ const prepared = await Promise.all(recipients.filter((address) => address !== from).map((recipient) => prepareNativeMail(root, recipient, message, threadId)));
229
+ // The durable queue is published before the authoritative message: a crash can leave only a harmless uncommitted reference, never durable mail without recovery.
107
230
  await atomicYaml(paths(root).message(threadId, message.id), message);
108
- await Promise.all(recipients.filter((address) => address !== from).map((recipient) => inject(root, recipient, message, threadId)));
231
+ await Promise.all(prepared.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)).map((entry) => publishNativeMail(root, entry.address, entry.segment, message.id)));
232
+ await Promise.all(recipients.filter((address) => address !== from).map((recipient) => inject(root, recipient, message, threadId, prepared.some((entry) => entry?.address === recipient))));
109
233
  console.log(message.id);
110
234
  }
111
235
 
112
236
  export async function startThread(root: string, args: string[]) {
113
237
  const participants = await resolveRecipients(root, required(words(args, "--with"), "--with"), []);
114
238
  if (participants.length < 2) throw new Error("A thread needs at least two participants.");
239
+ const subject = required(words(args, "--subject"), "--subject");
115
240
  const record: Thread = {
116
- schema: "atdd-workflow/thread/v1", id: id("T"), participants,
117
- subject: required(words(args, "--subject"), "--subject"), state: "open",
241
+ schema: "atdd-workflow/thread/v1", id: id("T", subject), participants,
242
+ subject, state: "open",
118
243
  ...(words(args, "--task") ? { task: words(args, "--task") } : {}),
119
244
  };
120
245
  await atomicYaml(paths(root).threadFile(record.id), record);