@afokapu/atdd-flow 0.3.28 → 0.3.30

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/threads.ts +42 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-flow",
3
- "version": "0.3.28",
3
+ "version": "0.3.30",
4
4
  "description": "Filesystem-first Desk registry for projects, seats, tasks, threads, and handoffs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/threads.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readdir, rm, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { notify } from "./adapters";
4
+ import { seatTasks } from "./tasks";
4
5
  import {
5
6
  atomicYaml, canonicalAddress, desk, has, id, now, paths, project, readYaml,
6
7
  required, runtimeAddress, seat, words,
@@ -82,6 +83,13 @@ async function assertRoute(root: string, from: string, recipients: string[]) {
82
83
  throw new Error(`${from} may not directly address operator@desk; route through the responsible coordinator and main seat.`);
83
84
  }
84
85
  if (sender.role === "driver" && targets.some((target) => target.role !== "coordinator")) {
86
+ // A driver may also reach the actual coordinator of its own non-done task in the same project (e.g. main).
87
+ const owners = new Set((await seatTasks(root, sender.project, from))
88
+ .filter(({ task }) => task.assignee === from && task.status !== "done")
89
+ .map(({ task }) => task.coordinator));
90
+ const permitted = (target: { address: string; role: string; project: string }) =>
91
+ target.role === "coordinator" || (target.project === sender.project && owners.has(target.address));
92
+ if (targets.every(permitted)) return;
85
93
  throw new Error(`${from} may message only a coordinator; coordinators and main seats handle further escalation.`);
86
94
  }
87
95
  }
@@ -90,6 +98,18 @@ type PendingReference = { thread: string; message: string; created_at: string; p
90
98
  type PendingSegment = { schema: "atdd-flow/pi-inbox-segment/v1"; entries: PendingReference[]; next?: string };
91
99
  type PendingQueue = { schema: "atdd-flow/pi-inbox-queue/v1"; head?: string; tail?: string };
92
100
 
101
+ function isPendingQueue(value: unknown): value is Required<PendingQueue> {
102
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
103
+ const queue = value as PendingQueue;
104
+ return queue.schema === "atdd-flow/pi-inbox-queue/v1"
105
+ && typeof queue.head === "string" && queue.head.length > 0
106
+ && typeof queue.tail === "string" && queue.tail.length > 0;
107
+ }
108
+
109
+ function missingFile(error: unknown) {
110
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
111
+ }
112
+
93
113
  function inboxDirectory(root: string, address: string) {
94
114
  return join(root, ".atdd-flow", "pi-inbox", encodeURIComponent(address));
95
115
  }
@@ -129,8 +149,16 @@ export async function enqueueNativeMail(root: string, address: string, reference
129
149
  return withInboxLock(directory, async () => {
130
150
  const statePath = join(directory, "queue.yaml");
131
151
  let queue: PendingQueue;
132
- try { queue = await readYaml<PendingQueue>(statePath); }
133
- catch { queue = { schema: "atdd-flow/pi-inbox-queue/v1" }; }
152
+ try {
153
+ queue = await readYaml<PendingQueue>(statePath);
154
+ if (!isPendingQueue(queue)) throw new Error(`Malformed pending inbox queue ${statePath}`);
155
+ } catch (error) {
156
+ // A reconciler deletes the index once it drains the final segment. Only
157
+ // that absent-index state may be initialized by normal enqueue; parse,
158
+ // permission, and competing-state failures remain fail-closed.
159
+ if (!missingFile(error)) throw error;
160
+ queue = { schema: "atdd-flow/pi-inbox-queue/v1" };
161
+ }
134
162
  const entriesPath = (segment: string) => join(directory, "pending", `${segment}.yaml`);
135
163
  const compare = (left: PendingReference, right: PendingReference) => left.created_at.localeCompare(right.created_at) || left.message.localeCompare(right.message);
136
164
  const capacity = 32;
@@ -176,10 +204,20 @@ export async function enqueueNativeMail(root: string, address: string, reference
176
204
  });
177
205
  }
178
206
 
179
- async function publishNativeMail(root: string, address: string, _segment: string, message: string) {
207
+ export async function publishNativeMail(root: string, address: string, _segment: string, message: string) {
180
208
  const directory = inboxDirectory(root, address);
181
209
  await withInboxLock(directory, async () => {
182
- const queue = await readYaml<PendingQueue>(join(directory, "queue.yaml"));
210
+ const statePath = join(directory, "queue.yaml");
211
+ let queue: PendingQueue;
212
+ try { queue = await readYaml<PendingQueue>(statePath); }
213
+ catch (error) {
214
+ // A native reconciler may consume the newly authoritative message and
215
+ // drain the queue between prepare and this advisory publish marker.
216
+ // Its deletion is a successful exactly-once delivery, not an error.
217
+ if (missingFile(error)) return;
218
+ throw error;
219
+ }
220
+ if (!isPendingQueue(queue)) throw new Error(`Malformed pending inbox queue ${statePath}`);
183
221
  let segment = queue.head;
184
222
  while (segment) {
185
223
  const path = join(directory, "pending", `${segment}.yaml`);