indus-swarms 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/AGENTS.md +14 -0
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/docs/claude-parity.md +151 -0
- package/docs/field-notes-teams-setup.md +107 -0
- package/docs/smoke-test-plan.md +146 -0
- package/eslint.config.js +74 -0
- package/extensions/teams/README.md +23 -0
- package/extensions/teams/activity-tracker.ts +234 -0
- package/extensions/teams/cleanup.ts +31 -0
- package/extensions/teams/fs-lock.ts +87 -0
- package/extensions/teams/hooks.ts +363 -0
- package/extensions/teams/index.ts +18 -0
- package/extensions/teams/leader-attach-commands.ts +221 -0
- package/extensions/teams/leader-inbox.ts +214 -0
- package/extensions/teams/leader-info-commands.ts +140 -0
- package/extensions/teams/leader-lifecycle-commands.ts +559 -0
- package/extensions/teams/leader-messaging-commands.ts +148 -0
- package/extensions/teams/leader-plan-commands.ts +95 -0
- package/extensions/teams/leader-spawn-command.ts +149 -0
- package/extensions/teams/leader-task-commands.ts +435 -0
- package/extensions/teams/leader-team-command.ts +382 -0
- package/extensions/teams/leader-teams-tool.ts +1075 -0
- package/extensions/teams/leader.ts +925 -0
- package/extensions/teams/mailbox.ts +131 -0
- package/extensions/teams/model-policy.ts +142 -0
- package/extensions/teams/names.ts +121 -0
- package/extensions/teams/paths.ts +37 -0
- package/extensions/teams/protocol.ts +241 -0
- package/extensions/teams/spawn-types.ts +36 -0
- package/extensions/teams/task-store.ts +544 -0
- package/extensions/teams/team-attach-claim.ts +205 -0
- package/extensions/teams/team-config.ts +335 -0
- package/extensions/teams/team-discovery.ts +59 -0
- package/extensions/teams/teammate-rpc.ts +261 -0
- package/extensions/teams/teams-panel.ts +1186 -0
- package/extensions/teams/teams-style.ts +322 -0
- package/extensions/teams/teams-ui-shared.ts +89 -0
- package/extensions/teams/teams-widget.ts +212 -0
- package/extensions/teams/worker.ts +605 -0
- package/extensions/teams/worktree.ts +103 -0
- package/package.json +53 -0
- package/scripts/e2e-rpc-test.mjs +277 -0
- package/scripts/integration-claim-test.mts +157 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +398 -0
- package/scripts/integration-todo-test.mts +533 -0
- package/scripts/lib/pi-workers.ts +105 -0
- package/scripts/smoke-test.mts +764 -0
- package/scripts/start-tmux-team.sh +91 -0
- package/skills/agent-teams/SKILL.md +180 -0
- package/tsconfig.strict.json +22 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { withLock } from "./fs-lock.js";
|
|
4
|
+
import { sanitizeName } from "./names.js";
|
|
5
|
+
|
|
6
|
+
export type TaskStatus = "pending" | "in_progress" | "completed";
|
|
7
|
+
|
|
8
|
+
export interface TeamTask {
|
|
9
|
+
id: string; // stringified integer (Claude-style)
|
|
10
|
+
subject: string;
|
|
11
|
+
description: string;
|
|
12
|
+
owner?: string; // agent name
|
|
13
|
+
status: TaskStatus;
|
|
14
|
+
blocks: string[];
|
|
15
|
+
blockedBy: string[];
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getTaskListDir(teamDir: string, taskListId: string): string {
|
|
22
|
+
return path.join(teamDir, "tasks", sanitizeName(taskListId));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function taskPath(taskListDir: string, taskId: string): string {
|
|
26
|
+
return path.join(taskListDir, `${sanitizeName(taskId)}.json`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function ensureDir(p: string): Promise<void> {
|
|
30
|
+
await fs.promises.mkdir(p, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
34
|
+
return typeof v === "object" && v !== null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toStringArray(v: unknown): string[] {
|
|
38
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
|
|
42
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readJson(file: string): Promise<unknown | null> {
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fs.promises.readFile(file, "utf8");
|
|
48
|
+
const parsed: unknown = JSON.parse(raw);
|
|
49
|
+
return parsed;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function writeJsonAtomic(file: string, data: unknown): Promise<void> {
|
|
56
|
+
await ensureDir(path.dirname(file));
|
|
57
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`;
|
|
58
|
+
await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
59
|
+
await fs.promises.rename(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isStatus(s: unknown): s is TaskStatus {
|
|
63
|
+
return s === "pending" || s === "in_progress" || s === "completed";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function coerceTask(obj: unknown): TeamTask | null {
|
|
67
|
+
if (!isRecord(obj)) return null;
|
|
68
|
+
if (typeof obj.id !== "string") return null;
|
|
69
|
+
if (typeof obj.subject !== "string") return null;
|
|
70
|
+
if (typeof obj.description !== "string") return null;
|
|
71
|
+
if (!isStatus(obj.status)) return null;
|
|
72
|
+
|
|
73
|
+
const now = new Date().toISOString();
|
|
74
|
+
return {
|
|
75
|
+
id: obj.id,
|
|
76
|
+
subject: obj.subject,
|
|
77
|
+
description: obj.description,
|
|
78
|
+
owner: typeof obj.owner === "string" ? obj.owner : undefined,
|
|
79
|
+
status: obj.status,
|
|
80
|
+
blocks: toStringArray(obj.blocks),
|
|
81
|
+
blockedBy: toStringArray(obj.blockedBy),
|
|
82
|
+
metadata: isRecord(obj.metadata) ? obj.metadata : undefined,
|
|
83
|
+
createdAt: typeof obj.createdAt === "string" ? obj.createdAt : now,
|
|
84
|
+
updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : now,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function allocateTaskId(taskListDir: string): Promise<string> {
|
|
89
|
+
await ensureDir(taskListDir);
|
|
90
|
+
|
|
91
|
+
const highwater = path.join(taskListDir, ".highwatermark");
|
|
92
|
+
const lock = `${highwater}.lock`;
|
|
93
|
+
|
|
94
|
+
return await withLock(
|
|
95
|
+
lock,
|
|
96
|
+
async () => {
|
|
97
|
+
let n = 0;
|
|
98
|
+
try {
|
|
99
|
+
const raw = await fs.promises.readFile(highwater, "utf8");
|
|
100
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
101
|
+
if (Number.isFinite(parsed) && parsed > 0) n = parsed;
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
n += 1;
|
|
106
|
+
await fs.promises.writeFile(highwater, `${n}\n`, "utf8");
|
|
107
|
+
return String(n);
|
|
108
|
+
},
|
|
109
|
+
{ label: "tasks:allocate" },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function shortTaskId(id: string): string {
|
|
114
|
+
return id;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function formatTaskLine(t: TeamTask, opts: { blocked?: boolean } = {}): string {
|
|
118
|
+
const blocked = Boolean(opts.blocked);
|
|
119
|
+
const status = blocked && t.status === "pending" ? "blocked" : t.status;
|
|
120
|
+
|
|
121
|
+
const deps = t.blockedBy?.length ?? 0;
|
|
122
|
+
const blocks = t.blocks?.length ?? 0;
|
|
123
|
+
|
|
124
|
+
const who = t.owner ? `@${t.owner}` : "";
|
|
125
|
+
const head = `${t.id.padStart(3, " ")} ${status.padEnd(11)} ${who}`.trimEnd();
|
|
126
|
+
|
|
127
|
+
const tags: string[] = [];
|
|
128
|
+
if (blocked && t.status === "in_progress") tags.push("blocked");
|
|
129
|
+
if (deps) tags.push(`deps:${deps}`);
|
|
130
|
+
if (blocks) tags.push(`blocks:${blocks}`);
|
|
131
|
+
const tagText = tags.length ? ` [${tags.join(" ")}]` : "";
|
|
132
|
+
|
|
133
|
+
const preview = t.subject.length > 80 ? `${t.subject.slice(0, 80)}…` : t.subject;
|
|
134
|
+
return `${head}${tagText} ${preview}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function listTasks(teamDir: string, taskListId: string): Promise<TeamTask[]> {
|
|
138
|
+
const dir = getTaskListDir(teamDir, taskListId);
|
|
139
|
+
try {
|
|
140
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
141
|
+
const files = entries
|
|
142
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
143
|
+
.map((e) => e.name)
|
|
144
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
145
|
+
|
|
146
|
+
const out: TeamTask[] = [];
|
|
147
|
+
for (const f of files) {
|
|
148
|
+
const obj = await readJson(path.join(dir, f));
|
|
149
|
+
const task = coerceTask(obj);
|
|
150
|
+
if (task) out.push(task);
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function getTask(teamDir: string, taskListId: string, taskId: string): Promise<TeamTask | null> {
|
|
159
|
+
const dir = getTaskListDir(teamDir, taskListId);
|
|
160
|
+
const obj = await readJson(taskPath(dir, taskId));
|
|
161
|
+
return coerceTask(obj);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function createTask(
|
|
165
|
+
teamDir: string,
|
|
166
|
+
taskListId: string,
|
|
167
|
+
input: { subject: string; description: string; owner?: string },
|
|
168
|
+
): Promise<TeamTask> {
|
|
169
|
+
const dir = getTaskListDir(teamDir, taskListId);
|
|
170
|
+
const id = await allocateTaskId(dir);
|
|
171
|
+
const now = new Date().toISOString();
|
|
172
|
+
const task: TeamTask = {
|
|
173
|
+
id,
|
|
174
|
+
subject: input.subject,
|
|
175
|
+
description: input.description,
|
|
176
|
+
owner: input.owner,
|
|
177
|
+
status: "pending",
|
|
178
|
+
blocks: [],
|
|
179
|
+
blockedBy: [],
|
|
180
|
+
metadata: {},
|
|
181
|
+
createdAt: now,
|
|
182
|
+
updatedAt: now,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
await writeJsonAtomic(taskPath(dir, id), task);
|
|
186
|
+
return task;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function updateTask(
|
|
190
|
+
teamDir: string,
|
|
191
|
+
taskListId: string,
|
|
192
|
+
taskId: string,
|
|
193
|
+
updater: (current: TeamTask) => TeamTask,
|
|
194
|
+
): Promise<TeamTask | null> {
|
|
195
|
+
const dir = getTaskListDir(teamDir, taskListId);
|
|
196
|
+
const file = taskPath(dir, taskId);
|
|
197
|
+
const lock = `${file}.lock`;
|
|
198
|
+
|
|
199
|
+
await ensureDir(dir);
|
|
200
|
+
|
|
201
|
+
return await withLock(
|
|
202
|
+
lock,
|
|
203
|
+
async () => {
|
|
204
|
+
const curObj = await readJson(file);
|
|
205
|
+
const cur = coerceTask(curObj);
|
|
206
|
+
if (!cur) return null;
|
|
207
|
+
const next = updater({ ...cur });
|
|
208
|
+
next.updatedAt = new Date().toISOString();
|
|
209
|
+
await writeJsonAtomic(file, next);
|
|
210
|
+
return next;
|
|
211
|
+
},
|
|
212
|
+
{ label: `tasks:update:${taskId}` },
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function isTaskBlocked(teamDir: string, taskListId: string, task: TeamTask): Promise<boolean> {
|
|
217
|
+
if (!task.blockedBy?.length) return false;
|
|
218
|
+
for (const depId of task.blockedBy) {
|
|
219
|
+
const dep = await getTask(teamDir, taskListId, depId);
|
|
220
|
+
if (!dep) return true;
|
|
221
|
+
if (dep.status !== "completed") return true;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function agentHasActiveTask(teamDir: string, taskListId: string, agentName: string): Promise<boolean> {
|
|
227
|
+
const tasks = await listTasks(teamDir, taskListId);
|
|
228
|
+
return tasks.some((t) => t.owner === agentName && t.status === "in_progress");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Claim a specific task (owner must be empty).
|
|
233
|
+
* Returns the updated task if claim succeeded, otherwise null.
|
|
234
|
+
*/
|
|
235
|
+
export async function claimTask(
|
|
236
|
+
teamDir: string,
|
|
237
|
+
taskListId: string,
|
|
238
|
+
taskId: string,
|
|
239
|
+
agentName: string,
|
|
240
|
+
opts: { checkAgentBusy?: boolean } = {},
|
|
241
|
+
): Promise<TeamTask | null> {
|
|
242
|
+
if (opts.checkAgentBusy) {
|
|
243
|
+
const busy = await agentHasActiveTask(teamDir, taskListId, agentName);
|
|
244
|
+
if (busy) return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return await updateTask(teamDir, taskListId, taskId, (cur) => {
|
|
248
|
+
// Not claimable
|
|
249
|
+
if (cur.status !== "pending") return cur;
|
|
250
|
+
if (cur.owner) return cur;
|
|
251
|
+
return {
|
|
252
|
+
...cur,
|
|
253
|
+
owner: agentName,
|
|
254
|
+
status: "in_progress",
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Start an assigned task (owner matches), marking it in_progress.
|
|
261
|
+
*/
|
|
262
|
+
export async function startAssignedTask(
|
|
263
|
+
teamDir: string,
|
|
264
|
+
taskListId: string,
|
|
265
|
+
taskId: string,
|
|
266
|
+
agentName: string,
|
|
267
|
+
): Promise<TeamTask | null> {
|
|
268
|
+
return await updateTask(teamDir, taskListId, taskId, (cur) => {
|
|
269
|
+
if (cur.owner !== agentName) return cur;
|
|
270
|
+
if (cur.status !== "pending") return cur;
|
|
271
|
+
return { ...cur, status: "in_progress" };
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function completeTask(
|
|
276
|
+
teamDir: string,
|
|
277
|
+
taskListId: string,
|
|
278
|
+
taskId: string,
|
|
279
|
+
agentName: string,
|
|
280
|
+
result?: string,
|
|
281
|
+
): Promise<TeamTask | null> {
|
|
282
|
+
return await updateTask(teamDir, taskListId, taskId, (cur) => {
|
|
283
|
+
if (cur.owner !== agentName) return cur;
|
|
284
|
+
if (cur.status === "completed") return cur;
|
|
285
|
+
const metadata = { ...(cur.metadata ?? {}) };
|
|
286
|
+
if (result) metadata.result = result;
|
|
287
|
+
metadata.completedAt = new Date().toISOString();
|
|
288
|
+
return { ...cur, status: "completed", metadata };
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function unassignTask(
|
|
293
|
+
teamDir: string,
|
|
294
|
+
taskListId: string,
|
|
295
|
+
taskId: string,
|
|
296
|
+
agentName: string,
|
|
297
|
+
reason?: string,
|
|
298
|
+
extraMetadata?: Record<string, unknown>,
|
|
299
|
+
): Promise<TeamTask | null> {
|
|
300
|
+
return await updateTask(teamDir, taskListId, taskId, (cur) => {
|
|
301
|
+
if (cur.owner !== agentName) return cur;
|
|
302
|
+
if (cur.status === "completed") return cur;
|
|
303
|
+
|
|
304
|
+
const metadata = { ...(cur.metadata ?? {}) };
|
|
305
|
+
if (reason) metadata.unassignedReason = reason;
|
|
306
|
+
metadata.unassignedAt = new Date().toISOString();
|
|
307
|
+
if (extraMetadata) Object.assign(metadata, extraMetadata);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
...cur,
|
|
311
|
+
owner: undefined,
|
|
312
|
+
status: "pending",
|
|
313
|
+
metadata,
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Reset all non-completed tasks owned by agent back to pending + unowned. */
|
|
319
|
+
export async function unassignTasksForAgent(
|
|
320
|
+
teamDir: string,
|
|
321
|
+
taskListId: string,
|
|
322
|
+
agentName: string,
|
|
323
|
+
reason?: string,
|
|
324
|
+
): Promise<number> {
|
|
325
|
+
const tasks = await listTasks(teamDir, taskListId);
|
|
326
|
+
let changed = 0;
|
|
327
|
+
for (const t of tasks) {
|
|
328
|
+
if (t.owner !== agentName) continue;
|
|
329
|
+
if (t.status === "completed") continue;
|
|
330
|
+
const updated = await updateTask(teamDir, taskListId, t.id, (cur) => {
|
|
331
|
+
// Re-check ownership under the per-task lock to avoid races with other claimers.
|
|
332
|
+
if (cur.owner !== agentName) return cur;
|
|
333
|
+
if (cur.status === "completed") return cur;
|
|
334
|
+
|
|
335
|
+
const metadata = { ...(cur.metadata ?? {}) };
|
|
336
|
+
if (reason) metadata.unassignedReason = reason;
|
|
337
|
+
metadata.unassignedAt = new Date().toISOString();
|
|
338
|
+
return {
|
|
339
|
+
...cur,
|
|
340
|
+
owner: undefined,
|
|
341
|
+
status: "pending",
|
|
342
|
+
metadata,
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
if (updated) changed += 1;
|
|
346
|
+
}
|
|
347
|
+
return changed;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Find and claim the first available task:
|
|
352
|
+
* - pending
|
|
353
|
+
* - unowned
|
|
354
|
+
* - unblocked
|
|
355
|
+
*/
|
|
356
|
+
export async function claimNextAvailableTask(
|
|
357
|
+
teamDir: string,
|
|
358
|
+
taskListId: string,
|
|
359
|
+
agentName: string,
|
|
360
|
+
opts: { checkAgentBusy?: boolean } = {},
|
|
361
|
+
): Promise<TeamTask | null> {
|
|
362
|
+
if (opts.checkAgentBusy) {
|
|
363
|
+
const busy = await agentHasActiveTask(teamDir, taskListId, agentName);
|
|
364
|
+
if (busy) return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const tasks = await listTasks(teamDir, taskListId);
|
|
368
|
+
for (const t of tasks) {
|
|
369
|
+
if (t.status !== "pending") continue;
|
|
370
|
+
if (t.owner) continue;
|
|
371
|
+
if (await isTaskBlocked(teamDir, taskListId, t)) continue;
|
|
372
|
+
|
|
373
|
+
const claimed = await claimTask(teamDir, taskListId, t.id, agentName, { checkAgentBusy: false });
|
|
374
|
+
if (claimed && claimed.owner === agentName && claimed.status === "in_progress") return claimed;
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export type TaskDependencyOpResult =
|
|
380
|
+
| { ok: true; task: TeamTask; dependency: TeamTask }
|
|
381
|
+
| { ok: false; error: string };
|
|
382
|
+
|
|
383
|
+
function uniqStrings(xs: string[]): string[] {
|
|
384
|
+
const out: string[] = [];
|
|
385
|
+
const seen = new Set<string>();
|
|
386
|
+
for (const x of xs) {
|
|
387
|
+
if (seen.has(x)) continue;
|
|
388
|
+
seen.add(x);
|
|
389
|
+
out.push(x);
|
|
390
|
+
}
|
|
391
|
+
return out;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Add a dependency edge: taskId is blockedBy depId (and depId blocks taskId).
|
|
396
|
+
*/
|
|
397
|
+
export async function addTaskDependency(
|
|
398
|
+
teamDir: string,
|
|
399
|
+
taskListId: string,
|
|
400
|
+
taskId: string,
|
|
401
|
+
depId: string,
|
|
402
|
+
): Promise<TaskDependencyOpResult> {
|
|
403
|
+
if (!taskId || !depId) return { ok: false, error: "Missing task id or dependency id" };
|
|
404
|
+
if (taskId === depId) return { ok: false, error: "Task cannot depend on itself" };
|
|
405
|
+
|
|
406
|
+
const task = await getTask(teamDir, taskListId, taskId);
|
|
407
|
+
if (!task) return { ok: false, error: `Task not found: ${taskId}` };
|
|
408
|
+
const dep = await getTask(teamDir, taskListId, depId);
|
|
409
|
+
if (!dep) return { ok: false, error: `Dependency task not found: ${depId}` };
|
|
410
|
+
|
|
411
|
+
const updatedTask = await updateTask(teamDir, taskListId, taskId, (cur) => ({
|
|
412
|
+
...cur,
|
|
413
|
+
blockedBy: uniqStrings([...(cur.blockedBy ?? []), depId]),
|
|
414
|
+
}));
|
|
415
|
+
if (!updatedTask) return { ok: false, error: `Task not found: ${taskId}` };
|
|
416
|
+
|
|
417
|
+
const updatedDep = await updateTask(teamDir, taskListId, depId, (cur) => ({
|
|
418
|
+
...cur,
|
|
419
|
+
blocks: uniqStrings([...(cur.blocks ?? []), taskId]),
|
|
420
|
+
}));
|
|
421
|
+
if (!updatedDep) return { ok: false, error: `Dependency task not found: ${depId}` };
|
|
422
|
+
|
|
423
|
+
return { ok: true, task: updatedTask, dependency: updatedDep };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Remove dependency edge: taskId no longer blockedBy depId (and depId no longer blocks taskId).
|
|
428
|
+
*/
|
|
429
|
+
export async function removeTaskDependency(
|
|
430
|
+
teamDir: string,
|
|
431
|
+
taskListId: string,
|
|
432
|
+
taskId: string,
|
|
433
|
+
depId: string,
|
|
434
|
+
): Promise<TaskDependencyOpResult> {
|
|
435
|
+
if (!taskId || !depId) return { ok: false, error: "Missing task id or dependency id" };
|
|
436
|
+
if (taskId === depId) return { ok: false, error: "Task cannot remove itself as a dependency" };
|
|
437
|
+
|
|
438
|
+
const task = await getTask(teamDir, taskListId, taskId);
|
|
439
|
+
if (!task) return { ok: false, error: `Task not found: ${taskId}` };
|
|
440
|
+
const dep = await getTask(teamDir, taskListId, depId);
|
|
441
|
+
if (!dep) return { ok: false, error: `Dependency task not found: ${depId}` };
|
|
442
|
+
|
|
443
|
+
const updatedTask = await updateTask(teamDir, taskListId, taskId, (cur) => ({
|
|
444
|
+
...cur,
|
|
445
|
+
blockedBy: (cur.blockedBy ?? []).filter((x) => x !== depId),
|
|
446
|
+
}));
|
|
447
|
+
if (!updatedTask) return { ok: false, error: `Task not found: ${taskId}` };
|
|
448
|
+
|
|
449
|
+
const updatedDep = await updateTask(teamDir, taskListId, depId, (cur) => ({
|
|
450
|
+
...cur,
|
|
451
|
+
blocks: (cur.blocks ?? []).filter((x) => x !== taskId),
|
|
452
|
+
}));
|
|
453
|
+
if (!updatedDep) return { ok: false, error: `Dependency task not found: ${depId}` };
|
|
454
|
+
|
|
455
|
+
return { ok: true, task: updatedTask, dependency: updatedDep };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export type TaskClearMode = "completed" | "all";
|
|
459
|
+
|
|
460
|
+
export interface ClearTasksResult {
|
|
461
|
+
mode: TaskClearMode;
|
|
462
|
+
taskListId: string;
|
|
463
|
+
taskListDir: string;
|
|
464
|
+
deletedTaskIds: string[];
|
|
465
|
+
skippedTaskIds: string[];
|
|
466
|
+
errors: Array<{ file: string; error: string }>;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Delete task JSON files from the task list directory.
|
|
471
|
+
*
|
|
472
|
+
* Safety properties:
|
|
473
|
+
* - Only deletes `*.json` files inside `<teamDir>/tasks/<taskListId>/`.
|
|
474
|
+
* - Refuses to operate if the resolved task list directory is not within `teamDir`.
|
|
475
|
+
*/
|
|
476
|
+
export async function clearTasks(
|
|
477
|
+
teamDir: string,
|
|
478
|
+
taskListId: string,
|
|
479
|
+
mode: TaskClearMode = "completed",
|
|
480
|
+
): Promise<ClearTasksResult> {
|
|
481
|
+
const taskListDir = getTaskListDir(teamDir, taskListId);
|
|
482
|
+
|
|
483
|
+
// Path safety: ensure the taskListDir is inside teamDir (prevents path traversal accidents).
|
|
484
|
+
const teamAbs = path.resolve(teamDir);
|
|
485
|
+
const listAbs = path.resolve(taskListDir);
|
|
486
|
+
if (!(listAbs === teamAbs || listAbs.startsWith(teamAbs + path.sep))) {
|
|
487
|
+
throw new Error(`Refusing to clear tasks outside teamDir. teamDir=${teamAbs} taskListDir=${listAbs}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let entries: fs.Dirent[] = [];
|
|
491
|
+
try {
|
|
492
|
+
entries = await fs.promises.readdir(taskListDir, { withFileTypes: true });
|
|
493
|
+
} catch (err: unknown) {
|
|
494
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
495
|
+
return { mode, taskListId, taskListDir, deletedTaskIds: [], skippedTaskIds: [], errors: [] };
|
|
496
|
+
}
|
|
497
|
+
throw err;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const deletedTaskIds: string[] = [];
|
|
501
|
+
const skippedTaskIds: string[] = [];
|
|
502
|
+
const errors: Array<{ file: string; error: string }> = [];
|
|
503
|
+
|
|
504
|
+
for (const e of entries) {
|
|
505
|
+
if (!e.isFile()) continue;
|
|
506
|
+
if (!e.name.endsWith(".json")) continue;
|
|
507
|
+
|
|
508
|
+
const file = path.join(taskListDir, e.name);
|
|
509
|
+
const fileAbs = path.resolve(file);
|
|
510
|
+
if (!fileAbs.startsWith(listAbs + path.sep)) {
|
|
511
|
+
errors.push({ file, error: "Refusing to delete file outside taskListDir" });
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let shouldDelete = false;
|
|
516
|
+
let taskIdFromName = e.name.slice(0, -".json".length);
|
|
517
|
+
|
|
518
|
+
if (mode === "all") {
|
|
519
|
+
shouldDelete = true;
|
|
520
|
+
} else {
|
|
521
|
+
const obj = await readJson(file);
|
|
522
|
+
const task = coerceTask(obj);
|
|
523
|
+
if (task && task.status === "completed") {
|
|
524
|
+
shouldDelete = true;
|
|
525
|
+
taskIdFromName = task.id;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!shouldDelete) {
|
|
530
|
+
skippedTaskIds.push(taskIdFromName);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
await fs.promises.unlink(file);
|
|
536
|
+
deletedTaskIds.push(taskIdFromName);
|
|
537
|
+
} catch (err: unknown) {
|
|
538
|
+
if (isErrnoException(err) && err.code === "ENOENT") continue;
|
|
539
|
+
errors.push({ file, error: err instanceof Error ? err.message : String(err) });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { mode, taskListId, taskListDir, deletedTaskIds, skippedTaskIds, errors };
|
|
544
|
+
}
|