@nanmicoder/dsh-agent-teams 0.1.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 +72 -0
- package/assets/agent-teams/action-celebrating.png +0 -0
- package/assets/agent-teams/action-reporting.png +0 -0
- package/assets/agent-teams/action-sending.png +0 -0
- package/assets/agent-teams/action-sleeping.png +0 -0
- package/assets/agent-teams/action-thinking.png +0 -0
- package/assets/agent-teams/action-working.png +0 -0
- package/assets/agent-teams/data-analyst.png +0 -0
- package/assets/agent-teams/designer.png +0 -0
- package/assets/agent-teams/docs-coordinator.png +0 -0
- package/assets/agent-teams/engineer.png +0 -0
- package/assets/agent-teams/qa-engineer.png +0 -0
- package/assets/agent-teams/researcher.png +0 -0
- package/assets/agent-teams/security-reviewer.png +0 -0
- package/assets/agent-teams/team-lead.png +0 -0
- package/cordis.patch.yml +21 -0
- package/lib/client/ActivityPanel.js +340 -0
- package/lib/client/AgentTeamsCard.js +74 -0
- package/lib/client/activity-model.js +70 -0
- package/lib/client/agent-teams-card-definition.js +85 -0
- package/lib/client/artwork.js +40 -0
- package/lib/client/index.js +33 -0
- package/lib/client.js +1235 -0
- package/lib/client.js.map +1 -0
- package/lib/event-types.js +12 -0
- package/lib/events.js +60 -0
- package/lib/index.js +172 -0
- package/lib/members.js +168 -0
- package/lib/snapshot.js +155 -0
- package/lib/state.js +461 -0
- package/lib/tools.js +749 -0
- package/lib/types/client/ActivityPanel.d.ts +64 -0
- package/lib/types/client/AgentTeamsCard.d.ts +24 -0
- package/lib/types/client/activity-model.d.ts +31 -0
- package/lib/types/client/agent-teams-card-definition.d.ts +44 -0
- package/lib/types/client/artwork.d.ts +19 -0
- package/lib/types/client/index.d.ts +11 -0
- package/lib/types/event-types.d.ts +103 -0
- package/lib/types/events.d.ts +37 -0
- package/lib/types/index.d.ts +42 -0
- package/lib/types/members.d.ts +86 -0
- package/lib/types/snapshot.d.ts +83 -0
- package/lib/types/state.d.ts +144 -0
- package/lib/types/tools.d.ts +40 -0
- package/lib/types/types.d.ts +73 -0
- package/lib/types.js +11 -0
- package/package.json +108 -0
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team activity snapshot assembly for the activity panel.
|
|
3
|
+
*
|
|
4
|
+
* Server-side assembly mirrors the Claude Code desktop teamWatcher: read the
|
|
5
|
+
* durable team files (the truth source) and enrich with live subagent
|
|
6
|
+
* activity, so the panel always reflects the on-disk state even when a model
|
|
7
|
+
* skipped a tool "ritual" (e.g. not calling update_task on completion).
|
|
8
|
+
* @module dsh-agent-teams/snapshot
|
|
9
|
+
*/
|
|
10
|
+
import { readdir } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { CAPTAIN_KEY, listArchivedTeamIds, readArchivedTeam, readMailbox, readTeam, taskDepthsById, taskVisualState, } from "./state.js";
|
|
13
|
+
/** The current task of a member: its first unfinished owned task. */
|
|
14
|
+
function currentTaskOf(memberName, tasks) {
|
|
15
|
+
for (const task of tasks) {
|
|
16
|
+
if (task.status === 'in_progress' && task.assignee === memberName)
|
|
17
|
+
return task.id;
|
|
18
|
+
}
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Assemble one team snapshot from its durable files plus live activity.
|
|
23
|
+
* @param ctx - the plugin context (injects `subagents`, used for activity).
|
|
24
|
+
* @param stateRoot - resolved absolute state root of the owning workspace.
|
|
25
|
+
* @param workspace - display name of the owning workspace.
|
|
26
|
+
* @param state - the durable team record.
|
|
27
|
+
* @returns the panel snapshot.
|
|
28
|
+
*/
|
|
29
|
+
export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state) {
|
|
30
|
+
const tasks = state.tasks;
|
|
31
|
+
const depths = taskDepthsById(tasks);
|
|
32
|
+
const byName = new Map(state.members.filter((m) => m.status !== 'removed').map((m) => [m.name, m]));
|
|
33
|
+
const activity = new Map();
|
|
34
|
+
try {
|
|
35
|
+
const children = await ctx.subagents.listChildren(state.captainSessionId);
|
|
36
|
+
for (const entry of children) {
|
|
37
|
+
if (entry.kind === 'child')
|
|
38
|
+
activity.set(entry.id, entry.activity);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
ctx.logger.warn(`agent-teams: activity listing failed for ${state.name}: ${String(error)}`);
|
|
43
|
+
}
|
|
44
|
+
const unreadByMember = new Map();
|
|
45
|
+
for (const member of state.members.filter((candidate) => candidate.status !== 'removed')) {
|
|
46
|
+
try {
|
|
47
|
+
unreadByMember.set(member.name, (await readMailbox(stateRoot, state.id, member.name)).length);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
ctx.logger.warn(`agent-teams: mailbox read failed for ${member.name}: ${String(error)}`);
|
|
51
|
+
unreadByMember.set(member.name, 0);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const members = state.members
|
|
55
|
+
.filter((member) => member.status !== 'removed')
|
|
56
|
+
.map((member) => {
|
|
57
|
+
const owned = tasks.filter((task) => task.assignee === member.name);
|
|
58
|
+
const done = owned.filter((task) => task.status === 'completed').length;
|
|
59
|
+
return {
|
|
60
|
+
id: member.id,
|
|
61
|
+
name: member.name,
|
|
62
|
+
role: member.role ?? '',
|
|
63
|
+
activity: member.id !== '' ? (activity.get(member.id) === 'running' ? 'working' : activity.get(member.id) === 'inactive' ? 'idle' : 'unknown') : 'unknown',
|
|
64
|
+
progress: owned.length === 0 ? 0 : Math.round((done / owned.length) * 100),
|
|
65
|
+
done,
|
|
66
|
+
total: owned.length,
|
|
67
|
+
currentTask: currentTaskOf(member.name, tasks),
|
|
68
|
+
unread: unreadByMember.get(member.name) ?? 0,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
const captainInbox = await readMailbox(stateRoot, state.id, CAPTAIN_KEY);
|
|
72
|
+
return {
|
|
73
|
+
workspace,
|
|
74
|
+
teamId: state.id,
|
|
75
|
+
name: state.name,
|
|
76
|
+
...state.description !== undefined ? { description: state.description } : {},
|
|
77
|
+
captainSessionId: state.captainSessionId,
|
|
78
|
+
members,
|
|
79
|
+
tasks: tasks.map((task) => ({
|
|
80
|
+
id: task.id,
|
|
81
|
+
subject: task.subject,
|
|
82
|
+
status: task.status,
|
|
83
|
+
state: taskVisualState(task.status, task.dependencies, tasks),
|
|
84
|
+
assignee: task.assignee ?? '',
|
|
85
|
+
dependencies: task.dependencies,
|
|
86
|
+
depth: depths.get(task.id) ?? 0,
|
|
87
|
+
})),
|
|
88
|
+
messageCount: captainInbox.length
|
|
89
|
+
+ members.reduce((count, member) => count + member.unread, 0),
|
|
90
|
+
captainInbox: captainInbox.slice(-5).map((message) => ({
|
|
91
|
+
from: message.from,
|
|
92
|
+
content: message.content,
|
|
93
|
+
})),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Collect every team under the given workspace state roots.
|
|
98
|
+
* @param ctx - the plugin context.
|
|
99
|
+
* @param roots - `{ workspace, stateRoot }` pairs (resolved absolute roots).
|
|
100
|
+
* @returns the snapshots in stable order (workspace, then team id).
|
|
101
|
+
*/
|
|
102
|
+
export async function collectTeamsActivity(ctx, roots) {
|
|
103
|
+
const snapshots = [];
|
|
104
|
+
for (const root of roots) {
|
|
105
|
+
let entries;
|
|
106
|
+
try {
|
|
107
|
+
entries = await readdir(root.stateRoot, { withFileTypes: true });
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.isDirectory())
|
|
117
|
+
continue;
|
|
118
|
+
try {
|
|
119
|
+
const state = await readTeam(root.stateRoot, entry.name);
|
|
120
|
+
if (state === undefined)
|
|
121
|
+
continue;
|
|
122
|
+
snapshots.push(await assembleTeamSnapshot(ctx, root.stateRoot, root.workspace, state));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
ctx.logger.warn(`agent-teams: skipped unreadable team state "${entry.name}" in workspace "${root.workspace}"`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return snapshots;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Collect every archived team under the given workspace state roots (the
|
|
133
|
+
* `archive/` subdirectory of each state root). Used by the historic panel
|
|
134
|
+
* path to restore full team detail after deletion.
|
|
135
|
+
* @param ctx - the plugin context.
|
|
136
|
+
* @param roots - `{ workspace, stateRoot }` pairs.
|
|
137
|
+
* @returns the archived snapshots in stable order.
|
|
138
|
+
*/
|
|
139
|
+
export async function collectArchivedTeamsActivity(ctx, roots) {
|
|
140
|
+
const snapshots = [];
|
|
141
|
+
for (const root of roots) {
|
|
142
|
+
for (const teamId of await listArchivedTeamIds(root.stateRoot)) {
|
|
143
|
+
try {
|
|
144
|
+
const state = await readArchivedTeam(root.stateRoot, teamId);
|
|
145
|
+
if (state === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
snapshots.push(await assembleTeamSnapshot(ctx, join(root.stateRoot, 'archive'), root.workspace, state));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
ctx.logger.warn(`agent-teams: skipped unreadable archived team "${teamId}" in workspace "${root.workspace}"`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return snapshots;
|
|
155
|
+
}
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team state persistence and pure team-logic rules.
|
|
3
|
+
*
|
|
4
|
+
* State lives on disk under `<workspace>/<stateDir>/<teamId>/`:
|
|
5
|
+
* - `team.json` — the durable {@link TeamState} record
|
|
6
|
+
* - `inbox/<agentKey>.jsonl` — one JSONL mailbox per agent (`captain` or a
|
|
7
|
+
* member name), mirroring the Claude Code AgentTeams mailbox layout
|
|
8
|
+
*
|
|
9
|
+
* All mutations run through an in-process per-team queue so read-modify-write
|
|
10
|
+
* stays serial; `fs/promises` is used directly because the plugin owns this
|
|
11
|
+
* bookkeeping (host-plane state, like session persistence) and the abstract
|
|
12
|
+
* `fs` service offers no directory deletion.
|
|
13
|
+
* @module dsh-agent-teams/state
|
|
14
|
+
*/
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
/** Mailbox key of the captain. */
|
|
19
|
+
export const CAPTAIN_KEY = 'captain';
|
|
20
|
+
/** In-process per-team mutation queues (promise chains). */
|
|
21
|
+
const locks = new Map();
|
|
22
|
+
/**
|
|
23
|
+
* Serialize mutations of one team across the whole process.
|
|
24
|
+
* @param key - the team id (or any mutation scope).
|
|
25
|
+
* @param fn - the mutation to run exclusively.
|
|
26
|
+
* @returns the mutation's result.
|
|
27
|
+
*/
|
|
28
|
+
export async function withTeamLock(key, fn) {
|
|
29
|
+
const previous = locks.get(key) ?? Promise.resolve();
|
|
30
|
+
let release;
|
|
31
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
32
|
+
locks.set(key, previous.then(() => gate));
|
|
33
|
+
await previous;
|
|
34
|
+
try {
|
|
35
|
+
return await fn();
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
release();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Fold a free-form name into a safe path/key segment.
|
|
43
|
+
* @param name - any user-supplied name.
|
|
44
|
+
* @returns lowercase `[a-z0-9-]` key, never empty.
|
|
45
|
+
*/
|
|
46
|
+
export function sanitizeKey(name) {
|
|
47
|
+
const cleaned = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
48
|
+
return cleaned === '' ? 'team' : cleaned;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Whether `dependencies` are all satisfied (every named task exists and
|
|
52
|
+
* completed) for the given task list.
|
|
53
|
+
* @param tasks - the team's tasks.
|
|
54
|
+
* @param dependencies - task ids the candidate depends on.
|
|
55
|
+
* @returns the ids that are still unsatisfied, empty when claimable.
|
|
56
|
+
*/
|
|
57
|
+
export function unsatisfiedDependencies(tasks, dependencies) {
|
|
58
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
59
|
+
return dependencies.filter((id) => byId.get(id)?.status !== 'completed');
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The allowed task status transitions, keyed by current status.
|
|
63
|
+
* Terminal statuses have no outgoing transitions.
|
|
64
|
+
*/
|
|
65
|
+
export const TASK_TRANSITIONS = {
|
|
66
|
+
pending: ['claimed', 'cancelled'],
|
|
67
|
+
claimed: ['in_progress', 'failed', 'cancelled'],
|
|
68
|
+
in_progress: ['completed', 'failed', 'cancelled'],
|
|
69
|
+
completed: [],
|
|
70
|
+
failed: [],
|
|
71
|
+
cancelled: [],
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Validate one task status transition.
|
|
75
|
+
* @param current - the task's current status.
|
|
76
|
+
* @param next - the requested status.
|
|
77
|
+
* @returns the transition error, or undefined when allowed.
|
|
78
|
+
*/
|
|
79
|
+
export function transitionError(current, next) {
|
|
80
|
+
if (current === next)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (!TASK_TRANSITIONS[current].includes(next)) {
|
|
83
|
+
return `task status cannot move from "${current}" to "${next}"`;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Create the team directory structure and the initial team record.
|
|
89
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
90
|
+
* @param state - the initial team record.
|
|
91
|
+
*/
|
|
92
|
+
export async function createTeamDir(stateRoot, state) {
|
|
93
|
+
const dir = join(stateRoot, state.id);
|
|
94
|
+
await mkdir(join(dir, 'inbox'), { recursive: true });
|
|
95
|
+
await atomicWriteText(join(dir, 'team.json'), JSON.stringify(state, null, 2));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Read one team record; `undefined` when absent.
|
|
99
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
100
|
+
* @param teamId - the team's sanitized id.
|
|
101
|
+
*/
|
|
102
|
+
export async function readTeam(stateRoot, teamId) {
|
|
103
|
+
try {
|
|
104
|
+
const raw = await readFile(join(stateRoot, teamId, 'team.json'), 'utf8');
|
|
105
|
+
const value = JSON.parse(stripLeadingBom(raw));
|
|
106
|
+
if (!isTeamState(value, teamId)) {
|
|
107
|
+
throw new Error(`invalid AgentTeams state in team "${teamId}"`);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Persist one team record (inside the caller's lock).
|
|
120
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
121
|
+
* @param state - the record to persist.
|
|
122
|
+
*/
|
|
123
|
+
export async function writeTeam(stateRoot, state) {
|
|
124
|
+
await atomicWriteText(join(stateRoot, state.id, 'team.json'), JSON.stringify(state, null, 2));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Find the team owned by one captain session (at most one per captain).
|
|
128
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
129
|
+
* @param captainSessionId - the owning session id.
|
|
130
|
+
* @returns the team record, or undefined when the captain leads no team.
|
|
131
|
+
*/
|
|
132
|
+
export async function findTeamByCaptain(stateRoot, captainSessionId) {
|
|
133
|
+
let entries;
|
|
134
|
+
try {
|
|
135
|
+
entries = await readdir(stateRoot, { withFileTypes: true });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
let found;
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
if (!entry.isDirectory())
|
|
146
|
+
continue;
|
|
147
|
+
const team = await readTeam(stateRoot, entry.name);
|
|
148
|
+
if (team?.captainSessionId === captainSessionId) {
|
|
149
|
+
if (found !== undefined && found.id !== team.id) {
|
|
150
|
+
throw new Error(`captain session leads multiple active teams ("${found.id}", "${team.id}"); archive one before continuing`);
|
|
151
|
+
}
|
|
152
|
+
found = team;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Find the team in which one session is an active participant.
|
|
159
|
+
* Captains match `captainSessionId`; members match their durable child session
|
|
160
|
+
* id. Removed members no longer have access to team-scoped tools.
|
|
161
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
162
|
+
* @param agentSessionId - calling captain/member session id.
|
|
163
|
+
* @returns the team record, or undefined when the caller belongs to no team.
|
|
164
|
+
*/
|
|
165
|
+
export async function findTeamByParticipant(stateRoot, agentSessionId) {
|
|
166
|
+
let entries;
|
|
167
|
+
try {
|
|
168
|
+
entries = await readdir(stateRoot, { withFileTypes: true });
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
let found;
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
if (!entry.isDirectory())
|
|
179
|
+
continue;
|
|
180
|
+
const team = await readTeam(stateRoot, entry.name);
|
|
181
|
+
const participates = team?.captainSessionId === agentSessionId
|
|
182
|
+
|| team?.members.some((member) => member.id === agentSessionId && member.status !== 'removed') === true;
|
|
183
|
+
if (participates && team !== undefined) {
|
|
184
|
+
if (found !== undefined && found.id !== team.id) {
|
|
185
|
+
throw new Error(`agent session belongs to multiple active teams ("${found.id}", "${team.id}"); the target team is ambiguous`);
|
|
186
|
+
}
|
|
187
|
+
found = team;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return found;
|
|
191
|
+
}
|
|
192
|
+
/** Build a fresh message record. */
|
|
193
|
+
export function createMessage(from, to, content) {
|
|
194
|
+
return { id: randomUUID(), from, to, content, ts: Date.now() };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Append one message to an agent's mailbox (JSONL).
|
|
198
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
199
|
+
* @param teamId - the team id.
|
|
200
|
+
* @param agentKey - `captain` or a member name.
|
|
201
|
+
* @param message - the message to append.
|
|
202
|
+
*/
|
|
203
|
+
export async function appendMailbox(stateRoot, teamId, agentKey, message) {
|
|
204
|
+
const file = join(stateRoot, teamId, 'inbox', `${sanitizeKey(agentKey)}.jsonl`);
|
|
205
|
+
await mkdir(join(stateRoot, teamId, 'inbox'), { recursive: true });
|
|
206
|
+
let existing = '';
|
|
207
|
+
try {
|
|
208
|
+
existing = await readFile(file, 'utf8');
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const separator = existing !== '' && !existing.endsWith('\n') ? '\n' : '';
|
|
216
|
+
await atomicWriteText(file, `${existing}${separator}${JSON.stringify(message)}\n`);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Read one agent's whole mailbox, oldest first.
|
|
220
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
221
|
+
* @param teamId - the team id.
|
|
222
|
+
* @param agentKey - `captain` or a member name.
|
|
223
|
+
* @param onMalformedLine - optional diagnostic hook; malformed records are
|
|
224
|
+
* skipped so one manually damaged line cannot make the whole team unreadable.
|
|
225
|
+
* @returns the messages, empty when the mailbox does not exist yet.
|
|
226
|
+
*/
|
|
227
|
+
export async function readMailbox(stateRoot, teamId, agentKey, onMalformedLine) {
|
|
228
|
+
const file = join(stateRoot, teamId, 'inbox', `${sanitizeKey(agentKey)}.jsonl`);
|
|
229
|
+
try {
|
|
230
|
+
const raw = await readFile(file, 'utf8');
|
|
231
|
+
const messages = [];
|
|
232
|
+
for (const [index, rawLine] of raw.split('\n').entries()) {
|
|
233
|
+
const line = stripLeadingBom(rawLine);
|
|
234
|
+
if (line.trim() === '')
|
|
235
|
+
continue;
|
|
236
|
+
let value;
|
|
237
|
+
try {
|
|
238
|
+
value = JSON.parse(line);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
onMalformedLine?.(index + 1, new Error('invalid JSON'));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!isTeamMessage(value)) {
|
|
245
|
+
onMalformedLine?.(index + 1, new Error('invalid message shape'));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
messages.push(value);
|
|
249
|
+
}
|
|
250
|
+
return messages;
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Remove the optional UTF-8 BOM some editors prepend to JSON text. */
|
|
260
|
+
function stripLeadingBom(value) {
|
|
261
|
+
return value.charCodeAt(0) === 0xFEFF ? value.slice(1) : value;
|
|
262
|
+
}
|
|
263
|
+
/** Atomically replace one UTF-8 state file from a same-directory temp file. */
|
|
264
|
+
async function atomicWriteText(file, content) {
|
|
265
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
266
|
+
try {
|
|
267
|
+
await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
|
|
268
|
+
await rename(temporary, file);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Whether a parsed JSON value is a plain record. */
|
|
276
|
+
function isRecord(value) {
|
|
277
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
278
|
+
}
|
|
279
|
+
/** Whether a value is an optional string. */
|
|
280
|
+
function isOptionalString(value) {
|
|
281
|
+
return value === undefined || typeof value === 'string';
|
|
282
|
+
}
|
|
283
|
+
/** Whether a value is a finite timestamp/counter number. */
|
|
284
|
+
function isFiniteNumber(value) {
|
|
285
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
286
|
+
}
|
|
287
|
+
/** Validate one member record at the durable JSON boundary. */
|
|
288
|
+
function isTeamMember(value) {
|
|
289
|
+
if (!isRecord(value))
|
|
290
|
+
return false;
|
|
291
|
+
return typeof value['id'] === 'string'
|
|
292
|
+
&& typeof value['name'] === 'string'
|
|
293
|
+
&& value['name'].trim() !== ''
|
|
294
|
+
&& isOptionalString(value['role'])
|
|
295
|
+
&& isOptionalString(value['model'])
|
|
296
|
+
&& isFiniteNumber(value['joinedAt'])
|
|
297
|
+
&& (value['status'] === 'idle' || value['status'] === 'working' || value['status'] === 'removed');
|
|
298
|
+
}
|
|
299
|
+
/** Validate one task record at the durable JSON boundary. */
|
|
300
|
+
function isTeamTask(value) {
|
|
301
|
+
if (!isRecord(value))
|
|
302
|
+
return false;
|
|
303
|
+
return typeof value['id'] === 'string'
|
|
304
|
+
&& typeof value['subject'] === 'string'
|
|
305
|
+
&& isOptionalString(value['description'])
|
|
306
|
+
&& (value['status'] === 'pending'
|
|
307
|
+
|| value['status'] === 'claimed'
|
|
308
|
+
|| value['status'] === 'in_progress'
|
|
309
|
+
|| value['status'] === 'completed'
|
|
310
|
+
|| value['status'] === 'failed'
|
|
311
|
+
|| value['status'] === 'cancelled')
|
|
312
|
+
&& isOptionalString(value['assignee'])
|
|
313
|
+
&& Array.isArray(value['dependencies'])
|
|
314
|
+
&& value['dependencies'].every((dependency) => typeof dependency === 'string')
|
|
315
|
+
&& isOptionalString(value['output'])
|
|
316
|
+
&& isFiniteNumber(value['createdAt'])
|
|
317
|
+
&& isFiniteNumber(value['updatedAt']);
|
|
318
|
+
}
|
|
319
|
+
/** Validate the full team record before it can participate in authorization. */
|
|
320
|
+
function isTeamState(value, expectedId) {
|
|
321
|
+
if (!isRecord(value))
|
|
322
|
+
return false;
|
|
323
|
+
const validShape = value['id'] === expectedId
|
|
324
|
+
&& typeof value['name'] === 'string'
|
|
325
|
+
&& value['name'].trim() !== ''
|
|
326
|
+
&& isOptionalString(value['description'])
|
|
327
|
+
&& typeof value['captainSessionId'] === 'string'
|
|
328
|
+
&& value['captainSessionId'] !== ''
|
|
329
|
+
&& isFiniteNumber(value['createdAt'])
|
|
330
|
+
&& Array.isArray(value['members'])
|
|
331
|
+
&& value['members'].every(isTeamMember)
|
|
332
|
+
&& Array.isArray(value['tasks'])
|
|
333
|
+
&& value['tasks'].every(isTeamTask)
|
|
334
|
+
&& Number.isSafeInteger(value['taskSeq'])
|
|
335
|
+
&& value['taskSeq'] >= 0;
|
|
336
|
+
if (!validShape)
|
|
337
|
+
return false;
|
|
338
|
+
const members = value['members'];
|
|
339
|
+
const tasks = value['tasks'];
|
|
340
|
+
const memberIds = new Set();
|
|
341
|
+
const memberKeys = new Set();
|
|
342
|
+
for (const member of members) {
|
|
343
|
+
const key = sanitizeKey(member.name);
|
|
344
|
+
if (member.id === '' || key === CAPTAIN_KEY || memberIds.has(member.id) || memberKeys.has(key))
|
|
345
|
+
return false;
|
|
346
|
+
memberIds.add(member.id);
|
|
347
|
+
memberKeys.add(key);
|
|
348
|
+
}
|
|
349
|
+
const taskIds = new Set();
|
|
350
|
+
for (const task of tasks) {
|
|
351
|
+
if (task.id === '' || taskIds.has(task.id))
|
|
352
|
+
return false;
|
|
353
|
+
taskIds.add(task.id);
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
/** Validate a mailbox record so later rendering cannot crash on `{}`/`null`. */
|
|
358
|
+
function isTeamMessage(value) {
|
|
359
|
+
if (!isRecord(value))
|
|
360
|
+
return false;
|
|
361
|
+
return typeof value['id'] === 'string'
|
|
362
|
+
&& typeof value['from'] === 'string'
|
|
363
|
+
&& typeof value['to'] === 'string'
|
|
364
|
+
&& typeof value['content'] === 'string'
|
|
365
|
+
&& isFiniteNumber(value['ts']);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Remove a team's whole directory (members should be interrupted first).
|
|
369
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
370
|
+
* @param teamId - the team id.
|
|
371
|
+
*/
|
|
372
|
+
export async function removeTeamDir(stateRoot, teamId) {
|
|
373
|
+
await rm(join(stateRoot, teamId), { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Archive a team instead of deleting it: the whole directory (team.json with
|
|
377
|
+
* tasks and dependency graph, plus the mailboxes) moves under
|
|
378
|
+
* `<stateRoot>/archive/<teamId>/` so later sessions can review how tasks were
|
|
379
|
+
* planned and rebuild dependency relationships. The archive directory has no
|
|
380
|
+
* team.json of its own, so the live activity scan skips it naturally.
|
|
381
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
382
|
+
* @param teamId - the team id.
|
|
383
|
+
*/
|
|
384
|
+
export async function archiveTeamDir(stateRoot, teamId) {
|
|
385
|
+
const archiveRoot = join(stateRoot, 'archive');
|
|
386
|
+
await mkdir(archiveRoot, { recursive: true });
|
|
387
|
+
await rename(join(stateRoot, teamId), join(archiveRoot, teamId));
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Read one archived team (already moved under `archive/`), or undefined when
|
|
391
|
+
* it was never archived.
|
|
392
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
393
|
+
* @param teamId - the team id.
|
|
394
|
+
*/
|
|
395
|
+
export async function readArchivedTeam(stateRoot, teamId) {
|
|
396
|
+
return readTeam(join(stateRoot, 'archive'), teamId);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* List every archived team id under the state root.
|
|
400
|
+
* @param stateRoot - resolved absolute state root directory.
|
|
401
|
+
* @returns the archived team ids, empty when the archive does not exist.
|
|
402
|
+
*/
|
|
403
|
+
export async function listArchivedTeamIds(stateRoot) {
|
|
404
|
+
try {
|
|
405
|
+
const entries = await readdir(join(stateRoot, 'archive'), { withFileTypes: true });
|
|
406
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The visual state of one task: `running` while in_progress, `completed`
|
|
417
|
+
* when done, `blocked` while any dependency is unfinished, else `open`.
|
|
418
|
+
*/
|
|
419
|
+
export function taskVisualState(status, dependencies, tasks) {
|
|
420
|
+
if (status === 'completed')
|
|
421
|
+
return 'completed';
|
|
422
|
+
if (status === 'in_progress')
|
|
423
|
+
return 'running';
|
|
424
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
425
|
+
const openDependency = dependencies.some((dependencyId) => {
|
|
426
|
+
const dependency = byId.get(dependencyId);
|
|
427
|
+
return dependency !== undefined && dependency.status !== 'completed';
|
|
428
|
+
});
|
|
429
|
+
return openDependency ? 'blocked' : 'open';
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Longest dependency path depth per task id (each depth = one lane column).
|
|
433
|
+
*/
|
|
434
|
+
export function taskDepthsById(tasks) {
|
|
435
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
436
|
+
const depths = new Map();
|
|
437
|
+
const visiting = new Set();
|
|
438
|
+
const depthOf = (taskId) => {
|
|
439
|
+
const cached = depths.get(taskId);
|
|
440
|
+
if (cached !== undefined)
|
|
441
|
+
return cached;
|
|
442
|
+
if (visiting.has(taskId))
|
|
443
|
+
return 0;
|
|
444
|
+
const task = byId.get(taskId);
|
|
445
|
+
if (task === undefined)
|
|
446
|
+
return 0;
|
|
447
|
+
visiting.add(taskId);
|
|
448
|
+
const dependencies = task.dependencies
|
|
449
|
+
.filter((dependencyId) => byId.has(dependencyId))
|
|
450
|
+
.sort();
|
|
451
|
+
const depth = dependencies.length === 0
|
|
452
|
+
? 0
|
|
453
|
+
: 1 + Math.max(...dependencies.map(depthOf));
|
|
454
|
+
visiting.delete(taskId);
|
|
455
|
+
depths.set(taskId, depth);
|
|
456
|
+
return depth;
|
|
457
|
+
};
|
|
458
|
+
for (const task of tasks)
|
|
459
|
+
depthOf(task.id);
|
|
460
|
+
return depths;
|
|
461
|
+
}
|