@dungle-scrubs/tallow 0.8.25 → 0.8.26
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/dist/auth-hardening.d.ts +12 -0
- package/dist/auth-hardening.d.ts.map +1 -1
- package/dist/auth-hardening.js +30 -7
- package/dist/auth-hardening.js.map +1 -1
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/install.js +2 -2
- package/dist/install.js.map +1 -1
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +119 -7
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/model-metadata-overrides.d.ts +19 -0
- package/dist/model-metadata-overrides.d.ts.map +1 -0
- package/dist/model-metadata-overrides.js +38 -0
- package/dist/model-metadata-overrides.js.map +1 -0
- package/dist/sdk.d.ts +2 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +28 -1
- package/dist/sdk.js.map +1 -1
- package/extensions/__integration__/teams-runtime.test.ts +22 -1
- package/extensions/_shared/__tests__/shell-policy.test.ts +197 -0
- package/extensions/_shared/shell-policy.ts +27 -0
- package/extensions/background-task-tool/index.ts +2 -1
- package/extensions/bash-tool-enhanced/index.ts +2 -1
- package/extensions/custom-footer/__tests__/index.test.ts +29 -0
- package/extensions/custom-footer/context-display.ts +49 -0
- package/extensions/custom-footer/index.ts +10 -23
- package/extensions/permissions/index.ts +31 -10
- package/extensions/plan-mode-tool/__tests__/index.test.ts +32 -2
- package/extensions/plan-mode-tool/index.ts +6 -1
- package/extensions/slash-command-bridge/index.ts +30 -1
- package/extensions/subagent-tool/__tests__/process-liveness.test.ts +42 -3
- package/extensions/subagent-tool/process.ts +132 -21
- package/extensions/tasks/__tests__/store.test.ts +26 -2
- package/extensions/tasks/commands/register-tasks-extension.ts +2 -2
- package/extensions/tasks/index.ts +5 -5
- package/extensions/tasks/state/index.ts +90 -36
- package/extensions/teams-tool/__tests__/archive-store.test.ts +98 -0
- package/extensions/teams-tool/__tests__/peer-messaging.test.ts +26 -0
- package/extensions/teams-tool/archive-store.ts +200 -0
- package/extensions/teams-tool/sessions/spawn.ts +244 -71
- package/extensions/teams-tool/tools/register-extension.ts +146 -105
- package/extensions/teams-tool/tools/teammate-tools.ts +43 -1
- package/package.json +4 -4
- package/skills/tallow-expert/SKILL.md +1 -1
- package/templates/agents/architect.md +13 -5
- package/templates/agents/debug.md +3 -3
- package/templates/agents/explore.md +9 -2
- package/templates/agents/refactor.md +2 -2
- package/templates/agents/scout.md +3 -2
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - Three states: pending (☐), in-progress (◉), completed (☑)
|
|
6
6
|
* - Bidirectional dependency tracking (blocks/blockedBy)
|
|
7
7
|
* - Comments for cross-session handoff context
|
|
8
|
-
* -
|
|
8
|
+
* - Shared task groups via ~/.tallow/task-groups/{group-name}/tasks/
|
|
9
9
|
* - Multi-session coordination via fs.watch
|
|
10
10
|
* - One file per task (avoids write conflicts)
|
|
11
11
|
* - Status widget with dynamic sizing
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* NOTE: This extension only runs in the main Pi process, not in subagent workers.
|
|
16
16
|
*
|
|
17
17
|
* This file is the composition root: it constructs shared infrastructure
|
|
18
|
-
* (
|
|
18
|
+
* (shared task-group name, file store) and delegates all registration to
|
|
19
19
|
* {@link registerTasksExtension}. Domain logic lives in sibling modules.
|
|
20
20
|
*/
|
|
21
21
|
|
|
@@ -43,10 +43,10 @@ export { shouldClearOnAgentEnd } from "./state/index.js";
|
|
|
43
43
|
export default function tasksExtension(pi: ExtensionAPI): void {
|
|
44
44
|
const isSubagent = process.env.PI_IS_SUBAGENT === "1";
|
|
45
45
|
|
|
46
|
-
// Auto-generate a
|
|
47
|
-
//
|
|
46
|
+
// Auto-generate a shared task-group name so subagents can coordinate via a
|
|
47
|
+
// file-backed store. PI_TEAM_NAME stays as the env var for backward compatibility.
|
|
48
48
|
const teamName =
|
|
49
|
-
process.env.PI_TEAM_NAME ?? (isSubagent ? null : `
|
|
49
|
+
process.env.PI_TEAM_NAME ?? (isSubagent ? null : `task-group-${randomUUID().slice(0, 8)}`);
|
|
50
50
|
if (teamName && !process.env.PI_TEAM_NAME) {
|
|
51
51
|
// Set on process.env so child subagents inherit it automatically
|
|
52
52
|
process.env.PI_TEAM_NAME = teamName;
|
|
@@ -12,13 +12,14 @@ import {
|
|
|
12
12
|
mkdirSync,
|
|
13
13
|
readdirSync,
|
|
14
14
|
readFileSync,
|
|
15
|
+
renameSync,
|
|
15
16
|
rmdirSync,
|
|
16
17
|
rmSync,
|
|
17
18
|
statSync,
|
|
18
19
|
unlinkSync,
|
|
19
20
|
watch,
|
|
20
21
|
} from "node:fs";
|
|
21
|
-
import { join } from "node:path";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
22
23
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
23
24
|
import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
|
|
24
25
|
import { atomicWriteFileSync } from "../../_shared/atomic-write.js";
|
|
@@ -26,8 +27,11 @@ import { getTallowPath } from "../../_shared/tallow-paths.js";
|
|
|
26
27
|
|
|
27
28
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
28
29
|
|
|
29
|
-
/** Directory root for
|
|
30
|
-
export const
|
|
30
|
+
/** Directory root for shared task-group state used by tasks/subagents. */
|
|
31
|
+
export const TASK_GROUPS_DIR = getTallowPath("task-groups");
|
|
32
|
+
|
|
33
|
+
/** Legacy directory root from older builds before task groups were split from teams. */
|
|
34
|
+
export const LEGACY_TEAMS_DIR = getTallowPath("teams");
|
|
31
35
|
|
|
32
36
|
/** Max age for team directories before cleanup (7 days in ms). */
|
|
33
37
|
export const TEAM_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -120,6 +124,30 @@ export function nextTaskId(state: TasksState): string {
|
|
|
120
124
|
return id;
|
|
121
125
|
}
|
|
122
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Move a legacy shared-task directory into the new task-groups root.
|
|
129
|
+
*
|
|
130
|
+
* Older builds stored shared task state under `~/.tallow/teams/`, which
|
|
131
|
+
* collided conceptually with the teams runtime. New builds use
|
|
132
|
+
* `~/.tallow/task-groups/`. Migration is best-effort and intentionally silent.
|
|
133
|
+
*
|
|
134
|
+
* @param currentDirPath - New task directory path under task-groups/
|
|
135
|
+
* @param legacyDirPath - Legacy task directory path under teams/
|
|
136
|
+
* @returns void
|
|
137
|
+
*/
|
|
138
|
+
function migrateLegacyTaskGroupDir(currentDirPath: string, legacyDirPath: string): void {
|
|
139
|
+
if (existsSync(currentDirPath) || !existsSync(legacyDirPath)) return;
|
|
140
|
+
const currentTeamDir = dirname(currentDirPath);
|
|
141
|
+
const legacyTeamDir = dirname(legacyDirPath);
|
|
142
|
+
mkdirSync(dirname(currentTeamDir), { recursive: true });
|
|
143
|
+
try {
|
|
144
|
+
renameSync(legacyTeamDir, currentTeamDir);
|
|
145
|
+
} catch {
|
|
146
|
+
// Best-effort migration only. If this fails, the session will recreate the
|
|
147
|
+
// new directory and continue without blocking startup.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
123
151
|
// ── Message helpers ──────────────────────────────────────────────────────────
|
|
124
152
|
|
|
125
153
|
/**
|
|
@@ -150,15 +178,17 @@ export function getTextContent(message: AssistantMessage): string {
|
|
|
150
178
|
/**
|
|
151
179
|
* Persistent, file-backed task store for cross-session sharing.
|
|
152
180
|
*
|
|
153
|
-
* Each
|
|
154
|
-
* one JSON file per
|
|
155
|
-
*
|
|
181
|
+
* Each shared task group gets a directory at
|
|
182
|
+
* `~/.tallow/task-groups/{group-name}/tasks/` containing one JSON file per
|
|
183
|
+
* task. `fs.watch` on the directory detects changes from other sessions
|
|
184
|
+
* sharing the same task group.
|
|
156
185
|
*
|
|
157
186
|
* Without a team name, this store is inactive and the extension falls back
|
|
158
187
|
* to session-entry persistence.
|
|
159
188
|
*/
|
|
160
189
|
export class TaskListStore {
|
|
161
190
|
private readonly dirPath: string | null;
|
|
191
|
+
private readonly legacyDirPath: string | null;
|
|
162
192
|
private watcher: FSWatcher | null = null;
|
|
163
193
|
private onChange: (() => void) | null = null;
|
|
164
194
|
/** Debounce timer to coalesce rapid file change events. */
|
|
@@ -172,10 +202,13 @@ export class TaskListStore {
|
|
|
172
202
|
constructor(teamName: string | null) {
|
|
173
203
|
if (teamName) {
|
|
174
204
|
const safeName = teamName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
175
|
-
this.dirPath = join(
|
|
205
|
+
this.dirPath = join(TASK_GROUPS_DIR, safeName, "tasks");
|
|
206
|
+
this.legacyDirPath = join(LEGACY_TEAMS_DIR, safeName, "tasks");
|
|
207
|
+
migrateLegacyTaskGroupDir(this.dirPath, this.legacyDirPath);
|
|
176
208
|
mkdirSync(this.dirPath, { recursive: true });
|
|
177
209
|
} else {
|
|
178
210
|
this.dirPath = null;
|
|
211
|
+
this.legacyDirPath = null;
|
|
179
212
|
}
|
|
180
213
|
}
|
|
181
214
|
|
|
@@ -196,14 +229,23 @@ export class TaskListStore {
|
|
|
196
229
|
*/
|
|
197
230
|
loadAll(): Task[] | null {
|
|
198
231
|
if (!this.dirPath) return null;
|
|
199
|
-
|
|
200
|
-
|
|
232
|
+
const hasCurrentDir = existsSync(this.dirPath);
|
|
233
|
+
const hasLegacyDir = this.legacyDirPath ? existsSync(this.legacyDirPath) : false;
|
|
234
|
+
if (!hasCurrentDir && !hasLegacyDir) return [];
|
|
235
|
+
|
|
236
|
+
const currentFiles = hasCurrentDir
|
|
237
|
+
? readdirSync(this.dirPath).filter((fileName) => fileName.endsWith(".json"))
|
|
238
|
+
: [];
|
|
239
|
+
const readDirPath =
|
|
240
|
+
currentFiles.length > 0 || !hasLegacyDir || !this.legacyDirPath
|
|
241
|
+
? this.dirPath
|
|
242
|
+
: this.legacyDirPath;
|
|
201
243
|
const tasks: Task[] = [];
|
|
202
244
|
try {
|
|
203
|
-
const files = readdirSync(
|
|
245
|
+
const files = readdirSync(readDirPath).filter((f) => f.endsWith(".json"));
|
|
204
246
|
for (const file of files) {
|
|
205
247
|
try {
|
|
206
|
-
const raw = readFileSync(join(
|
|
248
|
+
const raw = readFileSync(join(readDirPath, file), "utf-8");
|
|
207
249
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
208
250
|
// Migrate old schema: title → subject, dependencies → blockedBy
|
|
209
251
|
if (parsed.title && !parsed.subject) {
|
|
@@ -386,37 +428,49 @@ export class TaskListStore {
|
|
|
386
428
|
}
|
|
387
429
|
|
|
388
430
|
/**
|
|
389
|
-
* Remove
|
|
431
|
+
* Remove stale task-group directories from one root.
|
|
390
432
|
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
433
|
+
* @param rootDir - Root directory containing per-group subdirectories
|
|
434
|
+
* @param currentSafeName - Sanitized active task-group name to preserve
|
|
435
|
+
* @returns void
|
|
436
|
+
*/
|
|
437
|
+
function cleanupStaleTaskGroupRoot(rootDir: string, currentSafeName: string | null): void {
|
|
438
|
+
if (!existsSync(rootDir)) return;
|
|
439
|
+
const now = Date.now();
|
|
440
|
+
for (const entry of readdirSync(rootDir, { withFileTypes: true })) {
|
|
441
|
+
if (!entry.isDirectory()) continue;
|
|
442
|
+
if (entry.name === currentSafeName) continue;
|
|
443
|
+
|
|
444
|
+
const taskGroupPath = join(rootDir, entry.name);
|
|
445
|
+
try {
|
|
446
|
+
// Check tasks/ subdir mtime — that's where writes happen.
|
|
447
|
+
const tasksPath = join(taskGroupPath, "tasks");
|
|
448
|
+
const target = existsSync(tasksPath) ? tasksPath : taskGroupPath;
|
|
449
|
+
const { mtimeMs } = statSync(target);
|
|
450
|
+
if (now - mtimeMs > TEAM_MAX_AGE_MS) {
|
|
451
|
+
rmSync(taskGroupPath, { recursive: true, force: true });
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
// Skip individual failures (permissions, race conditions).
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Remove task-group directories older than {@link TEAM_MAX_AGE_MS}.
|
|
461
|
+
*
|
|
462
|
+
* Skips the current task group (if any) to avoid deleting an active session.
|
|
463
|
+
* Runs once per session start. The legacy `~/.tallow/teams/` root is also
|
|
464
|
+
* cleaned so older builds do not leave permanent clutter behind.
|
|
393
465
|
*
|
|
394
|
-
* @param currentTeamName - The active
|
|
466
|
+
* @param currentTeamName - The active shared task-group name to preserve, or null
|
|
395
467
|
*/
|
|
396
468
|
export function cleanupStaleTeams(currentTeamName: string | null): void {
|
|
397
469
|
try {
|
|
398
|
-
if (!existsSync(TEAMS_DIR)) return;
|
|
399
|
-
const now = Date.now();
|
|
400
470
|
const currentSafeName = currentTeamName?.replace(/[^a-zA-Z0-9._-]/g, "_") ?? null;
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (!entry.isDirectory()) continue;
|
|
404
|
-
if (entry.name === currentSafeName) continue;
|
|
405
|
-
|
|
406
|
-
const teamPath = join(TEAMS_DIR, entry.name);
|
|
407
|
-
try {
|
|
408
|
-
// Check tasks/ subdir mtime — that's where writes happen
|
|
409
|
-
const tasksPath = join(teamPath, "tasks");
|
|
410
|
-
const target = existsSync(tasksPath) ? tasksPath : teamPath;
|
|
411
|
-
const { mtimeMs } = statSync(target);
|
|
412
|
-
if (now - mtimeMs > TEAM_MAX_AGE_MS) {
|
|
413
|
-
rmSync(teamPath, { recursive: true, force: true });
|
|
414
|
-
}
|
|
415
|
-
} catch {
|
|
416
|
-
// Skip individual failures (permissions, race conditions)
|
|
417
|
-
}
|
|
418
|
-
}
|
|
471
|
+
cleanupStaleTaskGroupRoot(TASK_GROUPS_DIR, currentSafeName);
|
|
472
|
+
cleanupStaleTaskGroupRoot(LEGACY_TEAMS_DIR, currentSafeName);
|
|
419
473
|
} catch {
|
|
420
|
-
//
|
|
474
|
+
// Task-group roots don't exist or aren't readable — nothing to clean.
|
|
421
475
|
}
|
|
422
476
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
deleteArchivedTeamFromDisk,
|
|
7
|
+
getTeamArchivesDir,
|
|
8
|
+
loadAllArchivedTeamsFromDisk,
|
|
9
|
+
loadArchivedTeamFromDisk,
|
|
10
|
+
writeArchivedTeamToDisk,
|
|
11
|
+
} from "../archive-store.js";
|
|
12
|
+
import {
|
|
13
|
+
addTaskToBoard,
|
|
14
|
+
addTeamMessage,
|
|
15
|
+
archiveTeam,
|
|
16
|
+
createTeamStore,
|
|
17
|
+
getArchivedTeams,
|
|
18
|
+
getTeams,
|
|
19
|
+
} from "../store.js";
|
|
20
|
+
|
|
21
|
+
let originalTallowHome: string | undefined;
|
|
22
|
+
let tmpHome: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
originalTallowHome = process.env.TALLOW_CODING_AGENT_DIR;
|
|
26
|
+
tmpHome = mkdtempSync(join(tmpdir(), "tallow-team-archives-"));
|
|
27
|
+
process.env.TALLOW_CODING_AGENT_DIR = tmpHome;
|
|
28
|
+
getArchivedTeams().clear();
|
|
29
|
+
getTeams().clear();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
getArchivedTeams().clear();
|
|
34
|
+
getTeams().clear();
|
|
35
|
+
if (originalTallowHome === undefined) {
|
|
36
|
+
delete process.env.TALLOW_CODING_AGENT_DIR;
|
|
37
|
+
} else {
|
|
38
|
+
process.env.TALLOW_CODING_AGENT_DIR = originalTallowHome;
|
|
39
|
+
}
|
|
40
|
+
rmSync(tmpHome, { force: true, recursive: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("team archive persistence", () => {
|
|
44
|
+
it("writes and reloads archived teams from disk", () => {
|
|
45
|
+
const team = createTeamStore("alpha");
|
|
46
|
+
addTaskToBoard(team, "Investigate", "Read files", []);
|
|
47
|
+
const message = addTeamMessage(team, "alice", "bob", "hello");
|
|
48
|
+
message.readBy.add("bob");
|
|
49
|
+
|
|
50
|
+
const archived = archiveTeam("alpha");
|
|
51
|
+
expect(archived).toBeDefined();
|
|
52
|
+
if (!archived) return;
|
|
53
|
+
|
|
54
|
+
writeArchivedTeamToDisk(archived);
|
|
55
|
+
|
|
56
|
+
const loaded = loadArchivedTeamFromDisk("alpha");
|
|
57
|
+
expect(loaded).toBeDefined();
|
|
58
|
+
expect(loaded?.name).toBe("alpha");
|
|
59
|
+
expect(loaded?.tasks).toHaveLength(1);
|
|
60
|
+
expect(loaded?.messages).toHaveLength(1);
|
|
61
|
+
expect(loaded?.messages[0].readBy.has("bob")).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("lists archives newest-first", () => {
|
|
65
|
+
const first = createTeamStore("first");
|
|
66
|
+
addTaskToBoard(first, "One", "", []);
|
|
67
|
+
const archivedFirst = archiveTeam("first");
|
|
68
|
+
expect(archivedFirst).toBeDefined();
|
|
69
|
+
if (!archivedFirst) return;
|
|
70
|
+
archivedFirst.archivedAt = 1;
|
|
71
|
+
writeArchivedTeamToDisk(archivedFirst);
|
|
72
|
+
|
|
73
|
+
const second = createTeamStore("second");
|
|
74
|
+
addTaskToBoard(second, "Two", "", []);
|
|
75
|
+
const archivedSecond = archiveTeam("second");
|
|
76
|
+
expect(archivedSecond).toBeDefined();
|
|
77
|
+
if (!archivedSecond) return;
|
|
78
|
+
archivedSecond.archivedAt = 2;
|
|
79
|
+
writeArchivedTeamToDisk(archivedSecond);
|
|
80
|
+
|
|
81
|
+
const names = loadAllArchivedTeamsFromDisk().map((archive) => archive.name);
|
|
82
|
+
expect(names).toEqual(["second", "first"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("deletes persisted archives", () => {
|
|
86
|
+
createTeamStore("gone");
|
|
87
|
+
const archived = archiveTeam("gone");
|
|
88
|
+
expect(archived).toBeDefined();
|
|
89
|
+
if (!archived) return;
|
|
90
|
+
writeArchivedTeamToDisk(archived);
|
|
91
|
+
expect(loadArchivedTeamFromDisk("gone")?.name).toBe("gone");
|
|
92
|
+
|
|
93
|
+
deleteArchivedTeamFromDisk("gone");
|
|
94
|
+
|
|
95
|
+
expect(loadArchivedTeamFromDisk("gone")).toBeUndefined();
|
|
96
|
+
expect(getTeamArchivesDir().startsWith(tmpHome)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -258,4 +258,30 @@ describe("teammate task board operations", () => {
|
|
|
258
258
|
expect(team.tasks[0].status).toBe("completed");
|
|
259
259
|
expect(team.tasks[0].result).toBe("Found 388 files");
|
|
260
260
|
});
|
|
261
|
+
|
|
262
|
+
it("rejects completing a task owned by another teammate", async () => {
|
|
263
|
+
const team = freshTeam();
|
|
264
|
+
const { mate: alice } = mockTeammate("alice");
|
|
265
|
+
const { mate: bob } = mockTeammate("bob");
|
|
266
|
+
team.teammates.set("alice", alice);
|
|
267
|
+
team.teammates.set("bob", bob);
|
|
268
|
+
|
|
269
|
+
const { addTaskToBoard } = await import("../store");
|
|
270
|
+
const task = addTaskToBoard(team, "Count files", "Count .ts files", []);
|
|
271
|
+
task.status = "claimed";
|
|
272
|
+
task.assignee = "alice";
|
|
273
|
+
|
|
274
|
+
const bobTools = createTeammateTools(team, "bob");
|
|
275
|
+
const tasksTool = findTool(bobTools, "team_tasks");
|
|
276
|
+
const result = await tasksTool.execute("c4", {
|
|
277
|
+
action: "complete",
|
|
278
|
+
taskId: "1",
|
|
279
|
+
result: "I should not be allowed",
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(result.isError).toBe(true);
|
|
283
|
+
expect(result.content[0].text).toContain("Only the assignee can complete it");
|
|
284
|
+
expect(team.tasks[0].status).toBe("claimed");
|
|
285
|
+
expect(team.tasks[0].result).toBeNull();
|
|
286
|
+
});
|
|
261
287
|
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent archive storage for teams-tool.
|
|
3
|
+
*
|
|
4
|
+
* Archives are stored on disk so `team_resume` survives process restarts and
|
|
5
|
+
* session shutdown. Runtime state still uses the in-memory store; this module
|
|
6
|
+
* only handles serialization and persistence of archived snapshots.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { atomicWriteFileSync } from "../_shared/atomic-write.js";
|
|
12
|
+
import { getTallowPath } from "../_shared/tallow-paths.js";
|
|
13
|
+
import type { ArchivedTeam, TeamMessage } from "./store.js";
|
|
14
|
+
|
|
15
|
+
interface SerializedTeamMessage {
|
|
16
|
+
readonly content: string;
|
|
17
|
+
readonly from: string;
|
|
18
|
+
readonly readBy: readonly string[];
|
|
19
|
+
readonly timestamp: number;
|
|
20
|
+
readonly to: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SerializedArchivedTeam {
|
|
24
|
+
readonly archivedAt: number;
|
|
25
|
+
readonly messages: readonly SerializedTeamMessage[];
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly taskCounter: number;
|
|
28
|
+
readonly tasks: ArchivedTeam["tasks"];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the directory that stores archived teams.
|
|
33
|
+
*
|
|
34
|
+
* @returns Absolute archive directory path under the active tallow home
|
|
35
|
+
*/
|
|
36
|
+
export function getTeamArchivesDir(): string {
|
|
37
|
+
return getTallowPath("team-archives");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ensure the archive directory exists before reading or writing.
|
|
42
|
+
*
|
|
43
|
+
* @returns Archive directory path
|
|
44
|
+
*/
|
|
45
|
+
function ensureTeamArchivesDir(): string {
|
|
46
|
+
const dir = getTeamArchivesDir();
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the archive file path for one team name.
|
|
53
|
+
*
|
|
54
|
+
* @param teamName - Team name to encode into a stable file name
|
|
55
|
+
* @returns Absolute JSON file path
|
|
56
|
+
*/
|
|
57
|
+
function getArchiveFilePath(teamName: string): string {
|
|
58
|
+
return join(ensureTeamArchivesDir(), `${encodeURIComponent(teamName)}.json`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convert a runtime TeamMessage into a JSON-safe form.
|
|
63
|
+
*
|
|
64
|
+
* @param message - Runtime message with Set-based read tracking
|
|
65
|
+
* @returns Serializable message record
|
|
66
|
+
*/
|
|
67
|
+
function serializeTeamMessage(message: TeamMessage): SerializedTeamMessage {
|
|
68
|
+
return {
|
|
69
|
+
content: message.content,
|
|
70
|
+
from: message.from,
|
|
71
|
+
readBy: [...message.readBy].sort(),
|
|
72
|
+
timestamp: message.timestamp,
|
|
73
|
+
to: message.to,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Convert an archived team into a JSON-safe form.
|
|
79
|
+
*
|
|
80
|
+
* @param archived - Archived team snapshot from the runtime store
|
|
81
|
+
* @returns Serializable archive payload
|
|
82
|
+
*/
|
|
83
|
+
function serializeArchivedTeam(archived: ArchivedTeam): SerializedArchivedTeam {
|
|
84
|
+
return {
|
|
85
|
+
archivedAt: archived.archivedAt,
|
|
86
|
+
messages: archived.messages.map(serializeTeamMessage),
|
|
87
|
+
name: archived.name,
|
|
88
|
+
taskCounter: archived.taskCounter,
|
|
89
|
+
tasks: archived.tasks,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convert a serialized message back into the runtime representation.
|
|
95
|
+
*
|
|
96
|
+
* @param message - JSON-parsed message payload
|
|
97
|
+
* @returns Runtime message with Set-based read tracking
|
|
98
|
+
*/
|
|
99
|
+
function deserializeTeamMessage(message: SerializedTeamMessage): TeamMessage {
|
|
100
|
+
return {
|
|
101
|
+
content: message.content,
|
|
102
|
+
from: message.from,
|
|
103
|
+
readBy: new Set(message.readBy),
|
|
104
|
+
timestamp: message.timestamp,
|
|
105
|
+
to: message.to,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse an archived-team JSON payload.
|
|
111
|
+
*
|
|
112
|
+
* @param raw - Raw JSON string from disk
|
|
113
|
+
* @returns Parsed archive, or undefined when malformed
|
|
114
|
+
*/
|
|
115
|
+
function deserializeArchivedTeam(raw: string): ArchivedTeam | undefined {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(raw) as Partial<SerializedArchivedTeam>;
|
|
118
|
+
if (
|
|
119
|
+
typeof parsed.name !== "string" ||
|
|
120
|
+
typeof parsed.archivedAt !== "number" ||
|
|
121
|
+
typeof parsed.taskCounter !== "number" ||
|
|
122
|
+
!Array.isArray(parsed.tasks) ||
|
|
123
|
+
!Array.isArray(parsed.messages)
|
|
124
|
+
) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
archivedAt: parsed.archivedAt,
|
|
129
|
+
messages: parsed.messages.map((message) => deserializeTeamMessage(message)),
|
|
130
|
+
name: parsed.name,
|
|
131
|
+
taskCounter: parsed.taskCounter,
|
|
132
|
+
tasks: parsed.tasks,
|
|
133
|
+
};
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Persist one archived team snapshot to disk.
|
|
141
|
+
*
|
|
142
|
+
* @param archived - Archived team snapshot to write
|
|
143
|
+
* @returns Nothing
|
|
144
|
+
*/
|
|
145
|
+
export function writeArchivedTeamToDisk(archived: ArchivedTeam): void {
|
|
146
|
+
const filePath = getArchiveFilePath(archived.name);
|
|
147
|
+
atomicWriteFileSync(filePath, JSON.stringify(serializeArchivedTeam(archived), null, 2));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Delete one archived team snapshot from disk.
|
|
152
|
+
*
|
|
153
|
+
* @param teamName - Team whose archive should be removed
|
|
154
|
+
* @returns Nothing
|
|
155
|
+
*/
|
|
156
|
+
export function deleteArchivedTeamFromDisk(teamName: string): void {
|
|
157
|
+
const filePath = getArchiveFilePath(teamName);
|
|
158
|
+
if (!existsSync(filePath)) return;
|
|
159
|
+
unlinkSync(filePath);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Load one archived team snapshot from disk.
|
|
164
|
+
*
|
|
165
|
+
* @param teamName - Team whose archive should be read
|
|
166
|
+
* @returns Archived snapshot, or undefined when missing or malformed
|
|
167
|
+
*/
|
|
168
|
+
export function loadArchivedTeamFromDisk(teamName: string): ArchivedTeam | undefined {
|
|
169
|
+
const filePath = getArchiveFilePath(teamName);
|
|
170
|
+
if (!existsSync(filePath)) return undefined;
|
|
171
|
+
try {
|
|
172
|
+
return deserializeArchivedTeam(readFileSync(filePath, "utf-8"));
|
|
173
|
+
} catch {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Load all archived team snapshots from disk.
|
|
180
|
+
*
|
|
181
|
+
* Malformed files are skipped rather than crashing archive discovery.
|
|
182
|
+
* Results are sorted newest-first for status listings.
|
|
183
|
+
*
|
|
184
|
+
* @returns Archived team snapshots persisted on disk
|
|
185
|
+
*/
|
|
186
|
+
export function loadAllArchivedTeamsFromDisk(): ArchivedTeam[] {
|
|
187
|
+
const dir = ensureTeamArchivesDir();
|
|
188
|
+
const archives: ArchivedTeam[] = [];
|
|
189
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
190
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
191
|
+
try {
|
|
192
|
+
const archive = deserializeArchivedTeam(readFileSync(join(dir, entry.name), "utf-8"));
|
|
193
|
+
if (!archive) continue;
|
|
194
|
+
archives.push(archive);
|
|
195
|
+
} catch {
|
|
196
|
+
// Skip unreadable archive files instead of breaking discovery.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return archives.sort((left, right) => right.archivedAt - left.archivedAt);
|
|
200
|
+
}
|