ac-teams 0.0.14 → 0.0.16
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/README.md +1 -1
- package/docs/operations.md +11 -3
- package/docs/usage.md +21 -21
- package/package.json +1 -1
- package/src/coordinator.ts +32 -16
- package/src/graph.ts +13 -4
- package/src/group.ts +134 -0
- package/src/index.ts +37 -12
- package/src/panel.ts +114 -45
- package/src/render.ts +12 -12
- package/src/resources.ts +9 -9
- package/src/schemas.ts +12 -3
- package/src/store.ts +76 -20
- package/src/types.ts +27 -4
package/src/store.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { stateRoot } from "./config.ts";
|
|
5
5
|
import { validatePolicy } from "./egress.ts";
|
|
6
6
|
import { stripVTControlCharacters } from "node:util";
|
|
7
|
-
import { isDescendant,
|
|
7
|
+
import { isDescendant, isMain, memberName, memberNamePattern, memberNameMaxLength, taskLabelMaxLength, taskLabelPattern } from "./types.ts";
|
|
8
8
|
import type { TeamState, TaskArgs, MessageArgs, Task, TaskView, Message, Worktree } from "./types.ts";
|
|
9
9
|
|
|
10
10
|
// UI envelopes add task context after the public 16,000-character input limit.
|
|
@@ -58,7 +58,15 @@ function taskLabel(title: string): string {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
function migrate(state: unknown): boolean {
|
|
61
|
-
if (!object(state) || ![1, 2, 3].includes(state.version as number)) return false;
|
|
61
|
+
if (!object(state) || ![1, 2, 3, 4].includes(state.version as number)) return false;
|
|
62
|
+
if (state.version !== 4) migrateRoles(state);
|
|
63
|
+
// Schema 5: a post's former text becomes its summary, unchanged.
|
|
64
|
+
requireValue(Array.isArray(state.messages), "Invalid legacy messages");
|
|
65
|
+
for (const message of state.messages) if (object(message) && Object.hasOwn(message, "text")) { message.summary = message.text; delete message.text; }
|
|
66
|
+
state.version = 5;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
function migrateRoles(state: Record<string, unknown>): void {
|
|
62
70
|
if (state.version !== 3) migrateTasks(state);
|
|
63
71
|
// Schema 4: roles replace the special root ID; the creator's record becomes a main session.
|
|
64
72
|
const root = state.root ?? "root";
|
|
@@ -67,7 +75,6 @@ function migrate(state: unknown): boolean {
|
|
|
67
75
|
delete state.root;
|
|
68
76
|
state.key = randomUUID();
|
|
69
77
|
state.version = 4;
|
|
70
|
-
return true;
|
|
71
78
|
}
|
|
72
79
|
function migrateTasks(state: Record<string, unknown>): void {
|
|
73
80
|
requireValue(object(state.tasks), "Invalid legacy state tasks");
|
|
@@ -89,7 +96,7 @@ function migrateTasks(state: Record<string, unknown>): void {
|
|
|
89
96
|
function validate(state: unknown): asserts state is TeamState {
|
|
90
97
|
requireValue(object(state), "Invalid state snapshot");
|
|
91
98
|
id(state.id);
|
|
92
|
-
requireValue(state.version ===
|
|
99
|
+
requireValue(state.version === 5 && text(state.key) && integer(state.limit, 1) && integer(state.depth) && object(state.members) && object(state.tasks) && Array.isArray(state.messages), "Invalid state snapshot schema");
|
|
93
100
|
const members = state.members;
|
|
94
101
|
const names = new Set<string>(["main", "root", ...Object.keys(members)]);
|
|
95
102
|
for (const [key, member] of Object.entries(members)) {
|
|
@@ -108,6 +115,7 @@ function validate(state: unknown): asserts state is TeamState {
|
|
|
108
115
|
requireValue(member.role !== "main" || member.parent === undefined, "A main session has no parent");
|
|
109
116
|
requireValue(member.handover === undefined || ["busy", "idle", "input"].includes(String(member.handover)), "Invalid member handover state");
|
|
110
117
|
requireValue(member.promotion === undefined || (object(member.promotion) && ["stopped", "moved", "role", "entry"].includes(String(member.promotion.step)) && text(member.promotion.sessionFile)), "Invalid member promotion");
|
|
118
|
+
requireValue(member.waiting === undefined || (object(member.waiting) && Array.isArray(member.waiting.tasks) && member.waiting.tasks.every(task => typeof task === "string" && Object.hasOwn(state.tasks as object, task))), "Invalid member wait");
|
|
111
119
|
requireValue(member.terminal === undefined || typeof member.terminal === "boolean", "Invalid member terminal flag");
|
|
112
120
|
requireValue(member.needsInput === undefined || typeof member.needsInput === "boolean", "Invalid member input flag");
|
|
113
121
|
requireValue(member.pid === undefined || integer(member.pid, 1), "Invalid member PID");
|
|
@@ -132,7 +140,8 @@ function validate(state: unknown): asserts state is TeamState {
|
|
|
132
140
|
requireValue(task.completedAt === undefined || integer(task.completedAt), "Invalid task completion time");
|
|
133
141
|
requireValue(task.dependencies.every(dep => typeof dep === "string" && dep !== key && Object.hasOwn(tasks, dep)) && new Set(task.dependencies).size === task.dependencies.length, "Invalid task dependencies");
|
|
134
142
|
requireValue(task.owner === undefined || (typeof task.owner === "string" && Object.hasOwn(members, task.owner)), "Invalid task owner");
|
|
135
|
-
requireValue(task.creator === undefined || (typeof
|
|
143
|
+
requireValue([task.creator, task.delegator].every(member => member === undefined || (typeof member === "string" && Object.hasOwn(members, member))), "Invalid task creator or delegator");
|
|
144
|
+
requireValue(task.outcome === undefined || (task.status === "done" && state.messages.some(message => object(message) && message.id === task.outcome && message.task === key)), "Invalid task outcome");
|
|
136
145
|
requireValue(task.status !== "in_progress" || task.owner !== undefined, "In-progress task requires an owner");
|
|
137
146
|
requireValue(task.status === "todo" || task.dependencies.every(dep => (tasks[dep] as Record<string, unknown>).status === "done"), "Active or done task has unfinished dependencies");
|
|
138
147
|
edges[key] = task.dependencies;
|
|
@@ -150,11 +159,44 @@ function validate(state: unknown): asserts state is TeamState {
|
|
|
150
159
|
let last = 0;
|
|
151
160
|
const messageIds = new Set<string>();
|
|
152
161
|
for (const message of state.messages) {
|
|
153
|
-
requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.
|
|
162
|
+
requireValue(object(message) && integer(message.seq, last + 1) && text(message.id) && !messageIds.has(message.id) && typeof message.from === "string" && Object.hasOwn(members, message.from) && Array.isArray(message.recipients) && message.recipients.every(recipient => typeof recipient === "string" && Object.hasOwn(members, recipient)) && new Set(message.recipients).size === message.recipients.length && text(message.summary, maxMessageText) && typeof message.time === "string" && Number.isFinite(Date.parse(message.time)), "Invalid state message");
|
|
163
|
+
requireValue((message.detail === undefined || text(message.detail)) && (message.task === undefined || (typeof message.task === "string" && Object.hasOwn(tasks, message.task))) && (message.replyTo === undefined || (typeof message.replyTo === "string" && messageIds.has(message.replyTo))) && (message.kind === undefined || (message.kind === "outcome" && message.task !== undefined)), "Invalid message detail or link");
|
|
154
164
|
last = message.seq; messageIds.add(message.id);
|
|
155
165
|
}
|
|
156
166
|
}
|
|
157
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Appends a post. Attention goes to explicit recipients, the linked task's owner and delegator (else creator),
|
|
170
|
+
* and the replied-to post's author; never the sender, and never an implicit broadcast.
|
|
171
|
+
*/
|
|
172
|
+
function post(state: TeamState, actor: string, args: Pick<MessageArgs, "recipients" | "summary" | "detail" | "task" | "replyTo">, user = false): Message {
|
|
173
|
+
// A user's request is authored in a session but addressed to that session's own model.
|
|
174
|
+
const limit = user ? maxMessageText : 16000;
|
|
175
|
+
requireValue(text(args.summary, limit), `A post needs a nonempty summary of at most ${limit} characters`);
|
|
176
|
+
requireValue(args.detail === undefined || text(args.detail), "Detail must be nonempty and at most 16000 characters");
|
|
177
|
+
const original = args.replyTo === undefined ? undefined : state.messages.find(message => message.id === args.replyTo);
|
|
178
|
+
requireValue(args.replyTo === undefined || original, "Unknown post to reply to");
|
|
179
|
+
requireValue(!args.task || !original?.task || args.task === original.task, "A reply stays on the replied-to post's task");
|
|
180
|
+
const taskId = args.task ?? original?.task;
|
|
181
|
+
const task = taskId === undefined ? undefined : state.tasks[taskId];
|
|
182
|
+
requireValue(taskId === undefined || task, "Unknown task");
|
|
183
|
+
requireValue(args.recipients === undefined || Array.isArray(args.recipients), "Recipients must be unique known member names or IDs");
|
|
184
|
+
const members = Object.values(state.members);
|
|
185
|
+
// Names resolve to durable IDs; a stopped member still receives its post on resume.
|
|
186
|
+
const explicit = (args.recipients ?? []).map(recipient => typeof recipient === "string" ? members.find(member => member.id === recipient || member.name === recipient)?.id : undefined);
|
|
187
|
+
requireValue(new Set(explicit).size === explicit.length && explicit.every((recipient): recipient is string => recipient !== undefined), "Recipients must be unique known member names or IDs");
|
|
188
|
+
requireValue(explicit.length > 0 || task || original, "Name recipients, a task, or a post to reply to; posts are never broadcast");
|
|
189
|
+
const waiters = taskId === undefined ? [] : Object.values(state.members).filter(member => member.waiting?.tasks.includes(taskId)).map(member => member.id);
|
|
190
|
+
const routed = [...explicit, task?.owner, task?.delegator ?? task?.creator, original?.from, ...waiters];
|
|
191
|
+
const recipients = [...new Set(routed.filter((id): id is string => !!id && (id !== actor || (user && explicit.includes(id)))))];
|
|
192
|
+
const message: Message = { seq: (state.messages.at(-1)?.seq ?? 0) + 1, id: randomUUID(), from: actor, recipients, summary: args.summary, time: new Date().toISOString() };
|
|
193
|
+
if (args.detail !== undefined) message.detail = args.detail;
|
|
194
|
+
if (taskId !== undefined) message.task = taskId;
|
|
195
|
+
if (original) message.replyTo = original.id;
|
|
196
|
+
state.messages.push(message);
|
|
197
|
+
return message;
|
|
198
|
+
}
|
|
199
|
+
|
|
158
200
|
// Check every component rather than following a symlink into another home.
|
|
159
201
|
function ensureHome(path: string): void {
|
|
160
202
|
const parent = dirname(path);
|
|
@@ -183,6 +225,11 @@ export function readState(dir: string): TeamState {
|
|
|
183
225
|
} finally { closeSync(fd); }
|
|
184
226
|
}
|
|
185
227
|
|
|
228
|
+
/** Display names for members and tasks; their IDs are distinct UUIDs, so one map names both. */
|
|
229
|
+
export function stateLabels(state: TeamState): Record<string, string> {
|
|
230
|
+
return Object.fromEntries([...Object.values(state.members).map(member => [member.id, memberName(member)]), ...Object.values(state.tasks).map(task => [task.id, `#${task.number} ${task.title}`])]);
|
|
231
|
+
}
|
|
232
|
+
|
|
186
233
|
/** Cache only display names, not histories; atomic snapshot replacements invalidate the cache. */
|
|
187
234
|
export function createMemberLabels(): (dir: string) => Record<string, string> {
|
|
188
235
|
let key: string | undefined, labels: Record<string, string>;
|
|
@@ -190,7 +237,7 @@ export function createMemberLabels(): (dir: string) => Record<string, string> {
|
|
|
190
237
|
const file = join(dir, "state.json"), stat = lstatSync(file, { bigint: true });
|
|
191
238
|
const next = `${file}:${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.size}:${stat.mode}:${stat.uid}:${stat.nlink}`;
|
|
192
239
|
if (next !== key) {
|
|
193
|
-
labels =
|
|
240
|
+
labels = stateLabels(readState(dir));
|
|
194
241
|
key = next;
|
|
195
242
|
}
|
|
196
243
|
return labels;
|
|
@@ -325,6 +372,12 @@ export class Store {
|
|
|
325
372
|
}
|
|
326
373
|
requireValue(args.id && Object.hasOwn(state.tasks, args.id), "Unknown task");
|
|
327
374
|
const task = state.tasks[args.id];
|
|
375
|
+
if (args.action === "complete" && task.outcome) {
|
|
376
|
+
// A retried completion returns the original; a different outcome is a conflict, not a second post.
|
|
377
|
+
const published = state.messages.find(message => message.id === task.outcome)!;
|
|
378
|
+
requireValue(published.from === actor && published.summary === args.summary && published.detail === args.detail, "Task is already done with a different outcome; reopen it before completing again");
|
|
379
|
+
return taskView(state, task);
|
|
380
|
+
}
|
|
328
381
|
requireValue(args.revision === undefined || (integer(args.revision) && args.revision === task.revision), "Stale or invalid task revision; read the task before retrying");
|
|
329
382
|
if (args.action === "edit") {
|
|
330
383
|
requireValue(task.status === "todo", "Only to-do tasks can be edited");
|
|
@@ -348,6 +401,10 @@ export class Store {
|
|
|
348
401
|
requireValue(task.owner === actor, "Only the owner can complete this task");
|
|
349
402
|
requireValue(task.status === "in_progress", "Only in-progress tasks can be completed; start it first");
|
|
350
403
|
requireValue(args.acceptance === true, "Completion requires acceptance=true after checking acceptance criteria");
|
|
404
|
+
requireValue(text(args.summary), "Completion requires an outcome summary: what was done, how it was checked, and any caveats");
|
|
405
|
+
const outcome = post(state, actor, { summary: args.summary, detail: args.detail, task: task.id });
|
|
406
|
+
outcome.kind = "outcome";
|
|
407
|
+
task.outcome = outcome.id;
|
|
351
408
|
task.status = "done";
|
|
352
409
|
task.completedAt = Date.now();
|
|
353
410
|
} else if (args.action === "release") {
|
|
@@ -362,7 +419,7 @@ export class Store {
|
|
|
362
419
|
const dependents = Object.values(state.tasks).filter(other => other.dependencies.includes(task.id) && other.status !== "todo");
|
|
363
420
|
requireValue(dependents.length === 0, `Cannot reopen task with active or done dependents: ${dependents.map(t => t.id).join(", ")}`);
|
|
364
421
|
task.status = "todo";
|
|
365
|
-
delete task.completedAt;
|
|
422
|
+
delete task.completedAt; delete task.outcome;
|
|
366
423
|
} else throw new Error("Unknown task action");
|
|
367
424
|
task.revision++;
|
|
368
425
|
return taskView(state, task);
|
|
@@ -371,17 +428,16 @@ export class Store {
|
|
|
371
428
|
|
|
372
429
|
message(actor: string, args: MessageArgs, enveloped = false): Message | Message[] {
|
|
373
430
|
requireValue(!this.closed && Object.hasOwn(this.state.members, actor), "Unknown actor or closed store");
|
|
374
|
-
if (args.action === "send") return this.update(state =>
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return structuredClone(message);
|
|
431
|
+
if (args.action === "send") return this.update(state => structuredClone(post(state, actor, args, enveloped)));
|
|
432
|
+
if (args.action === "wait") return this.update(state => {
|
|
433
|
+
const tasks = args.tasks ?? [];
|
|
434
|
+
requireValue(Array.isArray(tasks) && tasks.length <= 100 && tasks.every(task => Object.hasOwn(state.tasks, task)), "Wait tasks must be existing task IDs");
|
|
435
|
+
// Register and recheck in one mutation, so a post committed after the caller last looked still wakes it.
|
|
436
|
+
const after = args.after ?? state.members[actor].notified;
|
|
437
|
+
const ready = state.messages.filter(message => message.seq > after && (message.recipients.includes(actor) || (message.task !== undefined && tasks.includes(message.task))));
|
|
438
|
+
if (ready.length) delete state.members[actor].waiting;
|
|
439
|
+
else state.members[actor].waiting = { tasks: [...new Set(tasks)] };
|
|
440
|
+
return structuredClone(ready);
|
|
385
441
|
});
|
|
386
442
|
requireValue(args.action === "history" || args.action === "search", "Unknown message action");
|
|
387
443
|
const after = args.after ?? 0, limit = args.limit ?? 20;
|
|
@@ -389,7 +445,7 @@ export class Store {
|
|
|
389
445
|
if (args.action === "search") requireValue(text(args.query, 500), "Search requires a nonempty query (max 500)");
|
|
390
446
|
const results = [];
|
|
391
447
|
for (const message of this.state.messages) {
|
|
392
|
-
if (message.seq > after && (args.action !== "search" || message.
|
|
448
|
+
if (message.seq > after && (args.action !== "search" || (message.summary.includes(args.query!) || !!message.detail?.includes(args.query!)))) results.push(message);
|
|
393
449
|
if (results.length === limit) break;
|
|
394
450
|
}
|
|
395
451
|
return structuredClone(results);
|
package/src/types.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface Member {
|
|
|
47
47
|
role?: "main" | "member";
|
|
48
48
|
/** State recorded when the coordinator owner stopped this member; the next owner's resume pass consumes it. */
|
|
49
49
|
handover?: "busy" | "idle" | "input";
|
|
50
|
+
/** Registered by a yield: posts on these tasks reach this member and wake it. Cleared when it next works. */
|
|
51
|
+
waiting?: { tasks: string[] };
|
|
50
52
|
/** Promotion in progress: the last completed step and what a rollback restores. */
|
|
51
53
|
promotion?: { step: "stopped" | "moved" | "role" | "entry"; sessionFile: string; parent?: string; access?: Access; provider?: string };
|
|
52
54
|
}
|
|
@@ -74,6 +76,10 @@ export interface Task {
|
|
|
74
76
|
completedAt?: number;
|
|
75
77
|
owner?: string;
|
|
76
78
|
creator?: string;
|
|
79
|
+
/** Who handed this task to its owner; receives its activity. Routing falls back to the creator. */
|
|
80
|
+
delegator?: string;
|
|
81
|
+
/** The post that published this task's completion outcome. */
|
|
82
|
+
outcome?: string;
|
|
77
83
|
}
|
|
78
84
|
export interface TaskView extends Task {
|
|
79
85
|
blockedBy: string[];
|
|
@@ -83,11 +89,18 @@ export interface Message {
|
|
|
83
89
|
id: string;
|
|
84
90
|
from: string;
|
|
85
91
|
recipients: string[];
|
|
86
|
-
|
|
92
|
+
/** Delivered directly in notices; complete enough to act on. */
|
|
93
|
+
summary: string;
|
|
94
|
+
/** Read on demand. */
|
|
95
|
+
detail?: string;
|
|
96
|
+
task?: string;
|
|
97
|
+
replyTo?: string;
|
|
98
|
+
/** "outcome": the post that completed its task. */
|
|
99
|
+
kind?: "outcome";
|
|
87
100
|
time: string;
|
|
88
101
|
}
|
|
89
102
|
export interface TeamState {
|
|
90
|
-
version:
|
|
103
|
+
version: 5;
|
|
91
104
|
id: string;
|
|
92
105
|
/** Lets an unsandboxed session of the same user add itself as a main session. */
|
|
93
106
|
key: string;
|
|
@@ -115,11 +128,19 @@ export interface TaskArgs {
|
|
|
115
128
|
label?: string;
|
|
116
129
|
description?: string;
|
|
117
130
|
dependencies?: string[];
|
|
131
|
+
/** Complete: the outcome published with the completion. */
|
|
132
|
+
summary?: string;
|
|
133
|
+
detail?: string;
|
|
118
134
|
}
|
|
119
135
|
export interface MessageArgs {
|
|
120
|
-
action: "send" | "history" | "search";
|
|
136
|
+
action: "send" | "history" | "search" | "wait";
|
|
137
|
+
/** Wait: also wake on posts linked to these tasks. */
|
|
138
|
+
tasks?: string[];
|
|
121
139
|
recipients?: string[];
|
|
122
|
-
|
|
140
|
+
summary?: string;
|
|
141
|
+
detail?: string;
|
|
142
|
+
task?: string;
|
|
143
|
+
replyTo?: string;
|
|
123
144
|
query?: string;
|
|
124
145
|
after?: number;
|
|
125
146
|
limit?: number;
|
|
@@ -138,6 +159,8 @@ export interface AgentArgs {
|
|
|
138
159
|
limit?: number;
|
|
139
160
|
depth?: number;
|
|
140
161
|
access?: AccessRequest;
|
|
162
|
+
/** Launch: an existing task the new member owns, with the requester as its delegator. */
|
|
163
|
+
task?: string;
|
|
141
164
|
}
|
|
142
165
|
export interface UserAction {
|
|
143
166
|
action: "task_request" | "feedback" | "resume" | "promote" | "leave";
|