@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.
- package/package.json +1 -1
- package/src/threads.ts +42 -4
package/package.json
CHANGED
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 {
|
|
133
|
-
|
|
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
|
|
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`);
|