@afokapu/atdd-flow 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/LICENSE +21 -0
- package/README.md +226 -0
- package/bin/atdd-flow +3 -0
- package/conventions/atdd-workflow.workflow/atdd-workflow.workflow.lifecycle.convention.yaml +183 -0
- package/package.json +47 -0
- package/src/adapters.ts +88 -0
- package/src/cli.ts +134 -0
- package/src/core.ts +226 -0
- package/src/judgment.ts +333 -0
- package/src/overview.ts +403 -0
- package/src/reviews.ts +448 -0
- package/src/seats.ts +233 -0
- package/src/tasks.ts +243 -0
- package/src/threads.ts +153 -0
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
type Checkpoint, atomicYaml, canonicalAddress, now, paths, project, readYaml, required, runOutput, seat, taskId, values, words,
|
|
4
|
+
} from "./core";
|
|
5
|
+
|
|
6
|
+
export type TaskStatus = "todo" | "in_progress" | "review" | "done";
|
|
7
|
+
export type DoneWhen = { text: string; proof?: string };
|
|
8
|
+
export type Task = {
|
|
9
|
+
schema: "atdd-workflow/task/v1";
|
|
10
|
+
title: string;
|
|
11
|
+
status: TaskStatus;
|
|
12
|
+
coordinator: string;
|
|
13
|
+
assignee?: string;
|
|
14
|
+
body?: string;
|
|
15
|
+
source?: string;
|
|
16
|
+
depends_on?: string[];
|
|
17
|
+
blocker?: string;
|
|
18
|
+
done_when: DoneWhen[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ListedTask = { id: string; task: Task };
|
|
22
|
+
|
|
23
|
+
const transitions: Record<TaskStatus, TaskStatus[]> = {
|
|
24
|
+
todo: ["in_progress"],
|
|
25
|
+
in_progress: ["review"],
|
|
26
|
+
review: ["in_progress", "done"],
|
|
27
|
+
done: [],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function readTask(root: string, projectName: string, id: string) {
|
|
31
|
+
const record = await readYaml<Task>(paths(root).taskFile(projectName, id));
|
|
32
|
+
if (record.schema !== "atdd-workflow/task/v1") throw new Error(`Unsupported task schema: ${id}`);
|
|
33
|
+
return record;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function allTasks(root: string, projectName: string): Promise<ListedTask[]> {
|
|
37
|
+
const folder = paths(root).tasks(projectName);
|
|
38
|
+
let files: string[];
|
|
39
|
+
try { files = await readdir(folder); }
|
|
40
|
+
catch { return []; }
|
|
41
|
+
return Promise.all(files.filter((file) => file.endsWith(".yaml") && !file.endsWith(".reviews.yaml")).sort().map(async (file) => ({ id: file.slice(0, -5), task: await readTask(root, projectName, file.slice(0, -5)) })));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function seatTasks(root: string, projectName: string, address: string): Promise<ListedTask[]> {
|
|
45
|
+
return (await allTasks(root, projectName)).filter((entry) =>
|
|
46
|
+
entry.task.assignee === address || entry.task.coordinator === address
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function ready(root: string, projectName: string, task: Task) {
|
|
51
|
+
const dependencies = await Promise.all((task.depends_on ?? []).map(async (id) => ({ id, task: await readTask(root, projectName, id) })));
|
|
52
|
+
return dependencies.filter((entry) => entry.task.status !== "done").map((entry) => entry.id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function allProofs(task: Task) {
|
|
56
|
+
return task.done_when.every((entry) => Boolean(entry.proof));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function writeTask(root: string, projectName: string, id: string, task: Task) {
|
|
60
|
+
await atomicYaml(paths(root).taskFile(projectName, id), task);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function retireAssignee(root: string, projectName: string, id: string, task: Task) {
|
|
64
|
+
const assignee = required(task.assignee, `an assignee for task ${id}`);
|
|
65
|
+
const unfinished = (await allTasks(root, projectName))
|
|
66
|
+
.filter((entry) => entry.id !== id && entry.task.assignee === assignee && entry.task.status !== "done")
|
|
67
|
+
.map((entry) => entry.id);
|
|
68
|
+
if (unfinished.length) throw new Error(`Cannot retire ${assignee}; it still owns unfinished tasks: ${unfinished.join(", ")}.`);
|
|
69
|
+
|
|
70
|
+
const owner = await seat(root, assignee);
|
|
71
|
+
try {
|
|
72
|
+
const summary = await runOutput(["atdd-bun", "worktree", "finish", "--delete-branch"], owner.worktree);
|
|
73
|
+
owner.retired = { task: `${projectName}/${id}`, completed_at: now(), summary };
|
|
74
|
+
await atomicYaml(paths(root).seatFile(assignee), owner);
|
|
75
|
+
const checkpoint: Checkpoint = {
|
|
76
|
+
schema: "atdd-workflow/checkpoint/v1", seat: assignee, status: "complete", updated_at: now(),
|
|
77
|
+
summary: `Retired after ${projectName}/${id}: ${summary}`,
|
|
78
|
+
next_action: "No active worktree remains; spawn a new seat before assigning new work.",
|
|
79
|
+
references: [`task:${projectName}/${id}`],
|
|
80
|
+
};
|
|
81
|
+
await atomicYaml(paths(root).checkpointFile(assignee), checkpoint);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const checkpoint: Checkpoint = {
|
|
84
|
+
schema: "atdd-workflow/checkpoint/v1", seat: assignee, status: "blocked", updated_at: now(),
|
|
85
|
+
summary: `Housekeeping after ${projectName}/${id} failed: ${(error as Error).message}`,
|
|
86
|
+
next_action: "Inspect the worktree and rerun task completion only after resolving the cleanup failure.",
|
|
87
|
+
references: [`task:${projectName}/${id}`],
|
|
88
|
+
};
|
|
89
|
+
await atomicYaml(paths(root).checkpointFile(assignee), checkpoint);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function add(root: string, projectName: string, id: string, args: string[]) {
|
|
95
|
+
await project(root, projectName);
|
|
96
|
+
const coordinator = await canonicalAddress(root, required(words(args, "--coordinator"), "--coordinator"));
|
|
97
|
+
const assigneeValue = words(args, "--assignee");
|
|
98
|
+
const assignee = assigneeValue ? await canonicalAddress(root, assigneeValue) : undefined;
|
|
99
|
+
await seat(root, coordinator);
|
|
100
|
+
if (assignee) await seat(root, assignee);
|
|
101
|
+
const doneWhen = values(args, "--done-when").map((text) => ({ text }));
|
|
102
|
+
if (!doneWhen.length) throw new Error("A task needs at least one --done-when criterion.");
|
|
103
|
+
const dependsOn = words(args, "--depends-on")?.split(",").filter(Boolean).map(taskId);
|
|
104
|
+
const task: Task = {
|
|
105
|
+
schema: "atdd-workflow/task/v1",
|
|
106
|
+
title: required(words(args, "--title"), "--title"),
|
|
107
|
+
status: "todo",
|
|
108
|
+
coordinator,
|
|
109
|
+
...(assignee ? { assignee } : {}),
|
|
110
|
+
...(words(args, "--body") ? { body: words(args, "--body") } : {}),
|
|
111
|
+
...(words(args, "--source") ? { source: words(args, "--source") } : {}),
|
|
112
|
+
...(dependsOn?.length ? { depends_on: dependsOn } : {}),
|
|
113
|
+
done_when: doneWhen,
|
|
114
|
+
};
|
|
115
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
116
|
+
console.log(id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function amend(root: string, projectName: string, id: string, args: string[]) {
|
|
120
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
121
|
+
const title = words(args, "--title");
|
|
122
|
+
const body = words(args, "--body");
|
|
123
|
+
const source = words(args, "--source");
|
|
124
|
+
const dependencies = words(args, "--depends-on");
|
|
125
|
+
if (!title && !body && !source && !dependencies) {
|
|
126
|
+
throw new Error("Provide --title, --body, --source, or --depends-on.");
|
|
127
|
+
}
|
|
128
|
+
if (title) task.title = title;
|
|
129
|
+
if (body) task.body = body;
|
|
130
|
+
if (source) task.source = source;
|
|
131
|
+
if (dependencies) task.depends_on = dependencies.split(",").filter(Boolean).map(taskId);
|
|
132
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
133
|
+
console.log(`${id} amended`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Records an already-completed task during a migration. Unlike `done`, this
|
|
138
|
+
* does not invent an assignee or a live lifecycle transition: the supplied
|
|
139
|
+
* evidence is the sole basis for the historical completion record.
|
|
140
|
+
*/
|
|
141
|
+
export async function importCompleted(root: string, projectName: string, id: string, args: string[]) {
|
|
142
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
143
|
+
const proofs = values(args, "--proof");
|
|
144
|
+
if (!proofs.length) throw new Error("An imported completion needs at least one --proof reference.");
|
|
145
|
+
|
|
146
|
+
const criteria = values(args, "--done-when");
|
|
147
|
+
const texts = criteria.length ? criteria : task.done_when.map((entry) => entry.text);
|
|
148
|
+
if (texts.length !== proofs.length) {
|
|
149
|
+
throw new Error("Provide one --proof for every existing or supplied --done-when criterion.");
|
|
150
|
+
}
|
|
151
|
+
const doneWhen = texts.map((text, index) => ({ text, proof: proofs[index] }));
|
|
152
|
+
|
|
153
|
+
const source = words(args, "--source");
|
|
154
|
+
if (source) task.source = source;
|
|
155
|
+
task.status = "done";
|
|
156
|
+
task.done_when = doneWhen;
|
|
157
|
+
delete task.blocker;
|
|
158
|
+
await writeTask(root, projectName, taskId(id), task);
|
|
159
|
+
console.log(`${id} imported done`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function transition(root: string, projectName: string, id: string, next: TaskStatus, by: string, retire = false) {
|
|
163
|
+
const task = await readTask(root, projectName, id);
|
|
164
|
+
const actor = await canonicalAddress(root, by);
|
|
165
|
+
if (!transitions[task.status].includes(next)) throw new Error(`Task ${id} cannot move from ${task.status} to ${next}.`);
|
|
166
|
+
if (next === "in_progress" && task.status === "todo") {
|
|
167
|
+
if (!task.assignee) throw new Error(`Task ${id} has no assignee.`);
|
|
168
|
+
if (actor !== task.assignee) throw new Error(`Only ${task.assignee} may start task ${id}.`);
|
|
169
|
+
const waiting = await ready(root, projectName, task);
|
|
170
|
+
if (waiting.length) throw new Error(`Task ${id} is waiting on: ${waiting.join(", ")}.`);
|
|
171
|
+
}
|
|
172
|
+
if (next === "in_progress" && task.status === "review" && actor !== task.coordinator) {
|
|
173
|
+
throw new Error(`Only ${task.coordinator} may return task ${id} to work.`);
|
|
174
|
+
}
|
|
175
|
+
if (next === "review") {
|
|
176
|
+
if (!task.assignee || actor !== task.assignee) throw new Error(`Only ${task.assignee ?? "the assignee"} may submit task ${id} for review.`);
|
|
177
|
+
if (!allProofs(task)) throw new Error(`Task ${id} is missing proof for: ${task.done_when.filter((entry) => !entry.proof).map((entry) => entry.text).join("; ")}`);
|
|
178
|
+
}
|
|
179
|
+
if (next === "done") {
|
|
180
|
+
if (actor !== task.coordinator) throw new Error(`Only ${task.coordinator} may complete task ${id}.`);
|
|
181
|
+
if (!allProofs(task)) throw new Error(`Task ${id} is missing completion proof.`);
|
|
182
|
+
const { assertAcceptedBehavioralReview } = await import("./reviews");
|
|
183
|
+
await assertAcceptedBehavioralReview(root, projectName, id, task);
|
|
184
|
+
if (retire) await retireAssignee(root, projectName, id, task);
|
|
185
|
+
}
|
|
186
|
+
task.status = next;
|
|
187
|
+
if (next !== "in_progress") delete task.blocker;
|
|
188
|
+
await writeTask(root, projectName, id, task);
|
|
189
|
+
console.log(`${id} ${next}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function start(root: string, projectName: string, id: string, args: string[]) {
|
|
193
|
+
return transition(root, projectName, taskId(id), "in_progress", required(words(args, "--by"), "--by"));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function review(root: string, projectName: string, id: string, args: string[]) {
|
|
197
|
+
return transition(root, projectName, taskId(id), "review", required(words(args, "--by"), "--by"));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function done(root: string, projectName: string, id: string, args: string[]) {
|
|
201
|
+
return transition(root, projectName, taskId(id), "done", required(words(args, "--by"), "--by"), args.includes("--retire-assignee"));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function returnToWork(root: string, projectName: string, id: string, args: string[]) {
|
|
205
|
+
return transition(root, projectName, taskId(id), "in_progress", required(words(args, "--by"), "--by"));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function prove(root: string, projectName: string, id: string, args: string[]) {
|
|
209
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
210
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
211
|
+
if (!task.assignee || actor !== task.assignee) throw new Error(`Only ${task.assignee ?? "the assignee"} may add proof to task ${id}.`);
|
|
212
|
+
if (task.status !== "in_progress") throw new Error(`Task ${id} must be in_progress before proof is added.`);
|
|
213
|
+
const item = Number(required(words(args, "--item"), "--item"));
|
|
214
|
+
if (!Number.isInteger(item) || item < 1 || item > task.done_when.length) throw new Error(`--item must be between 1 and ${task.done_when.length}.`);
|
|
215
|
+
task.done_when[item - 1].proof = required(words(args, "--proof"), "--proof");
|
|
216
|
+
await writeTask(root, projectName, id, task);
|
|
217
|
+
console.log(`${id} proof ${item}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function block(root: string, projectName: string, id: string, args: string[]) {
|
|
221
|
+
const task = await readTask(root, projectName, taskId(id));
|
|
222
|
+
const actor = await canonicalAddress(root, required(words(args, "--by"), "--by"));
|
|
223
|
+
if (actor !== task.assignee && actor !== task.coordinator) throw new Error(`Only the assignee or coordinator may block task ${id}.`);
|
|
224
|
+
if (task.status === "done") throw new Error(`Completed task ${id} cannot be blocked.`);
|
|
225
|
+
task.blocker = required(words(args, "--reason"), "--reason");
|
|
226
|
+
await writeTask(root, projectName, id, task);
|
|
227
|
+
console.log(`${id} blocked`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function list(root: string, projectName: string, args: string[]) {
|
|
231
|
+
const coordinator = words(args, "--coordinator") ? await canonicalAddress(root, required(words(args, "--coordinator"), "--coordinator")) : undefined;
|
|
232
|
+
const assignee = words(args, "--assignee") ? await canonicalAddress(root, required(words(args, "--assignee"), "--assignee")) : undefined;
|
|
233
|
+
for (const entry of await allTasks(root, projectName)) {
|
|
234
|
+
if (coordinator && entry.task.coordinator !== coordinator || assignee && entry.task.assignee !== assignee) continue;
|
|
235
|
+
const waiting = entry.task.status === "todo" ? await ready(root, projectName, entry.task) : [];
|
|
236
|
+
const proof = `${entry.task.done_when.filter((item) => item.proof).length}/${entry.task.done_when.length}`;
|
|
237
|
+
console.log(`${entry.id} ${entry.task.status} proof:${proof} ${entry.task.title}${waiting.length ? ` waiting:${waiting.join(",")}` : ""}${entry.task.blocker ? ` blocked:${entry.task.blocker}` : ""}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function open(root: string, projectName: string, id: string) {
|
|
242
|
+
console.log(Bun.YAML.stringify(await readTask(root, projectName, taskId(id))));
|
|
243
|
+
}
|
package/src/threads.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { notify } from "./adapters";
|
|
3
|
+
import {
|
|
4
|
+
atomicYaml, canonicalAddress, has, id, now, paths, project, readYaml,
|
|
5
|
+
required, seat, words,
|
|
6
|
+
} from "./core";
|
|
7
|
+
|
|
8
|
+
export type Thread = { schema: string; id: string; subject: string; participants: string[]; state: "open" | "closed"; summary?: string; task?: string };
|
|
9
|
+
export type Kind = "message" | "receipt" | "result";
|
|
10
|
+
export type Message = {
|
|
11
|
+
schema: string;
|
|
12
|
+
id: string;
|
|
13
|
+
from: string;
|
|
14
|
+
to: "all" | string[];
|
|
15
|
+
kind: Kind;
|
|
16
|
+
in_reply_to?: string;
|
|
17
|
+
expects_result?: boolean;
|
|
18
|
+
created_at: string;
|
|
19
|
+
body: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function thread(root: string, threadId: string) {
|
|
23
|
+
return readYaml<Thread>(paths(root).threadFile(threadId));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function messages(root: string, threadId: string) {
|
|
27
|
+
const folder = paths(root).thread(threadId);
|
|
28
|
+
const files = (await readdir(folder)).filter((file) => file.startsWith("M-") && file.endsWith(".yaml")).sort();
|
|
29
|
+
return Promise.all(files.map((file) => readYaml<Message>(`${folder}/${file}`)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function replyTarget(root: string, threadId: string, messageId: string, from: string, needsResult: boolean) {
|
|
33
|
+
const record = await thread(root, threadId);
|
|
34
|
+
const target = (await messages(root, threadId)).find((message) => message.id === messageId);
|
|
35
|
+
if (!target) throw new Error(`Message ${messageId} does not exist in thread ${threadId}.`);
|
|
36
|
+
const recipients = target.to === "all" ? record.participants.filter((address) => address !== target.from) : target.to;
|
|
37
|
+
if (!recipients.includes(from)) throw new Error(`${from} was not a recipient of message ${messageId}.`);
|
|
38
|
+
if (needsResult && !target.expects_result) throw new Error(`Message ${messageId} does not expect a result.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function seatsByRole(root: string, projectName: string, role: string) {
|
|
42
|
+
const folder = paths(root).seats(projectName);
|
|
43
|
+
const locals = await readdir(folder);
|
|
44
|
+
const all = await Promise.all(locals.map((local) => seat(root, `${local}@${projectName}`)));
|
|
45
|
+
return all.filter((entry) => entry.role === role).map((entry) => entry.address);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function resolveRecipients(root: string, value: string, participants: string[]) {
|
|
49
|
+
if (value === "all") return participants;
|
|
50
|
+
const requested = value.split(",").filter(Boolean);
|
|
51
|
+
const expanded = await Promise.all(requested.map(async (address) => {
|
|
52
|
+
const resolved = await canonicalAddress(root, address);
|
|
53
|
+
const marker = resolved.lastIndexOf("@");
|
|
54
|
+
const projectName = resolved.slice(marker + 1);
|
|
55
|
+
const config = await project(root, projectName);
|
|
56
|
+
const group = config.groups?.[resolved];
|
|
57
|
+
if (!group) return [resolved];
|
|
58
|
+
if (group.members) return group.members;
|
|
59
|
+
return seatsByRole(root, projectName, required(group.role, `role for group ${resolved}`));
|
|
60
|
+
}));
|
|
61
|
+
return [...new Set(expanded.flat())];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function inject(root: string, address: string, message: Message, threadId: string) {
|
|
65
|
+
const target = await seat(root, address);
|
|
66
|
+
const runtime = target.runtime;
|
|
67
|
+
if (!runtime) return;
|
|
68
|
+
const nativeAddress = runtime.addresses[runtime.application];
|
|
69
|
+
if (!nativeAddress) return;
|
|
70
|
+
const file = paths(root).message(threadId, message.id);
|
|
71
|
+
const notice = `SYSTEM: new thread mail ${message.id} from ${message.from}. Read ${file}`;
|
|
72
|
+
try { await notify(runtime.application, nativeAddress, notice); }
|
|
73
|
+
catch (error) { console.warn(`Notification for ${address} was not delivered: ${(error as Error).message}`); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function post(root: string, threadId: string, args: string[], overrides: Partial<Message> = {}) {
|
|
77
|
+
const record = await thread(root, threadId);
|
|
78
|
+
const from = await canonicalAddress(root, required(overrides.from ?? words(args, "--from"), "--from"));
|
|
79
|
+
if (!record.participants.includes(from)) throw new Error(`${from} is not a thread participant.`);
|
|
80
|
+
const toValue = overrides.to ?? words(args, "--to") ?? "all";
|
|
81
|
+
const recipients = Array.isArray(toValue) ? toValue : await resolveRecipients(root, toValue, record.participants);
|
|
82
|
+
if (recipients.some((address) => !record.participants.includes(address))) throw new Error("Recipients must be thread participants.");
|
|
83
|
+
const message: Message = {
|
|
84
|
+
schema: "atdd-workflow/message/v1", id: id("M"), from, to: toValue === "all" ? "all" : recipients,
|
|
85
|
+
kind: overrides.kind ?? "message",
|
|
86
|
+
...(overrides.in_reply_to ? { in_reply_to: overrides.in_reply_to } : {}),
|
|
87
|
+
...(overrides.expects_result || has(args, "--expects-result") ? { expects_result: true } : {}),
|
|
88
|
+
created_at: now(), body: required(overrides.body ?? words(args, "--body"), "--body"),
|
|
89
|
+
};
|
|
90
|
+
await atomicYaml(paths(root).message(threadId, message.id), message);
|
|
91
|
+
await Promise.all(recipients.filter((address) => address !== from).map((recipient) => inject(root, recipient, message, threadId)));
|
|
92
|
+
console.log(message.id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function startThread(root: string, args: string[]) {
|
|
96
|
+
const participants = await resolveRecipients(root, required(words(args, "--with"), "--with"), []);
|
|
97
|
+
if (participants.length < 2) throw new Error("A thread needs at least two participants.");
|
|
98
|
+
const record: Thread = {
|
|
99
|
+
schema: "atdd-workflow/thread/v1", id: id("T"), participants,
|
|
100
|
+
subject: required(words(args, "--subject"), "--subject"), state: "open",
|
|
101
|
+
...(words(args, "--task") ? { task: words(args, "--task") } : {}),
|
|
102
|
+
};
|
|
103
|
+
await atomicYaml(paths(root).threadFile(record.id), record);
|
|
104
|
+
console.log(record.id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function addParticipant(root: string, threadId: string, address: string) {
|
|
108
|
+
const record = await thread(root, threadId);
|
|
109
|
+
const resolved = await canonicalAddress(root, address);
|
|
110
|
+
if (!record.participants.includes(resolved)) record.participants.push(resolved);
|
|
111
|
+
await atomicYaml(paths(root).threadFile(threadId), record);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function openThread(root: string, threadId: string) {
|
|
115
|
+
const record = await thread(root, threadId);
|
|
116
|
+
const all = await messages(root, threadId);
|
|
117
|
+
console.log(Bun.YAML.stringify({ ...record, messages: all }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function receipt(root: string, threadId: string, messageId: string, args: string[]) {
|
|
121
|
+
const from = await canonicalAddress(root, required(words(args, "--from"), "--from"));
|
|
122
|
+
await replyTarget(root, threadId, messageId, from, false);
|
|
123
|
+
return post(root, threadId, args, { from, kind: "receipt", in_reply_to: messageId, body: words(args, "--body") ?? `Received ${messageId}.` });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function result(root: string, threadId: string, messageId: string, args: string[]) {
|
|
127
|
+
const from = await canonicalAddress(root, required(words(args, "--from"), "--from"));
|
|
128
|
+
await replyTarget(root, threadId, messageId, from, true);
|
|
129
|
+
return post(root, threadId, args, { from, kind: "result", in_reply_to: messageId, body: required(words(args, "--body"), "--body") });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function status(root: string) {
|
|
133
|
+
const threadIds = (await readdir(paths(root).threads)).sort();
|
|
134
|
+
for (const threadId of threadIds) {
|
|
135
|
+
const record = await thread(root, threadId);
|
|
136
|
+
const all = await messages(root, threadId);
|
|
137
|
+
const pending = all.flatMap((message) => {
|
|
138
|
+
if (!message.expects_result) return [];
|
|
139
|
+
const responders = message.to === "all" ? record.participants.filter((address) => address !== message.from) : message.to;
|
|
140
|
+
return responders.filter((address) => !all.some((reply) => reply.kind === "result" && reply.in_reply_to === message.id && reply.from === address)).map((address) => `${message.id}@${address}`);
|
|
141
|
+
});
|
|
142
|
+
console.log(`${threadId} ${record.state} ${record.subject}${pending.length ? ` waiting:${pending.join(",")}` : ""}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function threadsForSeat(root: string, address: string) {
|
|
147
|
+
const resolved = await canonicalAddress(root, address);
|
|
148
|
+
const threadIds = await readdir(paths(root).threads);
|
|
149
|
+
const records = await Promise.all(threadIds.map(async (threadId) => ({ threadId, record: await thread(root, threadId) })));
|
|
150
|
+
return records.filter(({ record }) => record.participants.includes(resolved));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { post };
|