@katyella/legio 0.1.3 → 0.2.2
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/CHANGELOG.md +61 -3
- package/README.md +21 -10
- package/agents/builder.md +11 -10
- package/agents/coordinator.md +36 -27
- package/agents/cto.md +9 -8
- package/agents/gateway.md +28 -12
- package/agents/lead.md +45 -30
- package/agents/merger.md +4 -4
- package/agents/monitor.md +10 -9
- package/agents/reviewer.md +8 -8
- package/agents/scout.md +10 -10
- package/agents/supervisor.md +60 -45
- package/package.json +2 -2
- package/src/agents/hooks-deployer.test.ts +46 -41
- package/src/agents/hooks-deployer.ts +10 -9
- package/src/agents/manifest.test.ts +6 -2
- package/src/agents/overlay.test.ts +9 -7
- package/src/agents/overlay.ts +29 -7
- package/src/commands/agents.test.ts +1 -5
- package/src/commands/clean.test.ts +2 -5
- package/src/commands/clean.ts +25 -1
- package/src/commands/completions.test.ts +1 -1
- package/src/commands/completions.ts +26 -7
- package/src/commands/coordinator.test.ts +87 -82
- package/src/commands/coordinator.ts +94 -48
- package/src/commands/costs.test.ts +2 -6
- package/src/commands/dashboard.test.ts +2 -5
- package/src/commands/doctor.test.ts +2 -6
- package/src/commands/down.ts +3 -3
- package/src/commands/errors.test.ts +2 -6
- package/src/commands/feed.test.ts +2 -6
- package/src/commands/gateway.test.ts +43 -17
- package/src/commands/gateway.ts +101 -11
- package/src/commands/hooks.test.ts +2 -5
- package/src/commands/init.test.ts +4 -13
- package/src/commands/inspect.test.ts +2 -6
- package/src/commands/log.test.ts +2 -6
- package/src/commands/logs.test.ts +2 -9
- package/src/commands/mail.test.ts +76 -215
- package/src/commands/mail.ts +43 -187
- package/src/commands/metrics.test.ts +3 -10
- package/src/commands/nudge.ts +15 -0
- package/src/commands/prime.test.ts +4 -11
- package/src/commands/replay.test.ts +2 -6
- package/src/commands/server.test.ts +1 -5
- package/src/commands/server.ts +1 -1
- package/src/commands/sling.test.ts +6 -1
- package/src/commands/sling.ts +42 -17
- package/src/commands/spec.test.ts +2 -5
- package/src/commands/status.test.ts +2 -4
- package/src/commands/stop.test.ts +2 -5
- package/src/commands/supervisor.ts +6 -6
- package/src/commands/trace.test.ts +2 -6
- package/src/commands/up.test.ts +43 -9
- package/src/commands/up.ts +15 -11
- package/src/commands/watchman.ts +327 -0
- package/src/commands/worktree.test.ts +2 -6
- package/src/config.test.ts +34 -104
- package/src/config.ts +120 -32
- package/src/doctor/agents.test.ts +52 -2
- package/src/doctor/agents.ts +4 -2
- package/src/doctor/config-check.test.ts +7 -2
- package/src/doctor/consistency.test.ts +7 -2
- package/src/doctor/databases.test.ts +6 -2
- package/src/doctor/dependencies.test.ts +18 -13
- package/src/doctor/dependencies.ts +23 -94
- package/src/doctor/logs.test.ts +7 -2
- package/src/doctor/merge-queue.test.ts +6 -2
- package/src/doctor/structure.test.ts +7 -2
- package/src/doctor/version.test.ts +7 -2
- package/src/e2e/init-sling-lifecycle.test.ts +2 -5
- package/src/index.ts +7 -7
- package/src/mail/pending.ts +120 -0
- package/src/mail/store.test.ts +89 -0
- package/src/mail/store.ts +11 -0
- package/src/merge/resolver.test.ts +518 -489
- package/src/server/index.ts +33 -2
- package/src/server/public/app.js +3 -3
- package/src/server/public/components/message-bubble.js +11 -1
- package/src/server/public/components/terminal-panel.js +66 -74
- package/src/server/public/views/chat.js +18 -2
- package/src/server/public/views/costs.js +5 -5
- package/src/server/public/views/dashboard.js +80 -51
- package/src/server/public/views/gateway-chat.js +37 -131
- package/src/server/public/views/inspect.js +16 -4
- package/src/server/public/views/issues.js +16 -12
- package/src/server/routes.test.ts +55 -39
- package/src/server/routes.ts +38 -26
- package/src/test-helpers.ts +6 -3
- package/src/tracker/beads.ts +159 -0
- package/src/tracker/exec.ts +44 -0
- package/src/tracker/factory.test.ts +283 -0
- package/src/tracker/factory.ts +59 -0
- package/src/tracker/seeds.ts +156 -0
- package/src/tracker/types.ts +46 -0
- package/src/types.ts +11 -2
- package/src/{watchdog → watchman}/daemon.test.ts +421 -515
- package/src/watchman/daemon.ts +940 -0
- package/src/worktree/tmux.test.ts +2 -1
- package/src/worktree/tmux.ts +4 -4
- package/templates/hooks.json.tmpl +17 -17
- package/src/beads/client.test.ts +0 -210
- package/src/commands/merge.test.ts +0 -676
- package/src/commands/watch.test.ts +0 -152
- package/src/commands/watch.ts +0 -238
- package/src/test-helpers.test.ts +0 -97
- package/src/watchdog/daemon.ts +0 -533
- package/src/watchdog/health.test.ts +0 -371
- package/src/watchdog/triage.test.ts +0 -162
- package/src/worktree/manager.test.ts +0 -444
- /package/src/{watchdog → watchman}/health.ts +0 -0
- /package/src/{watchdog → watchman}/triage.ts +0 -0
|
@@ -24,16 +24,20 @@ describe("checkMergeQueue", () => {
|
|
|
24
24
|
maxDepth: 2,
|
|
25
25
|
},
|
|
26
26
|
worktrees: { baseDir: "" },
|
|
27
|
-
|
|
27
|
+
taskTracker: { backend: "auto" as const, enabled: true },
|
|
28
28
|
mulch: { enabled: true, domains: [], primeFormat: "markdown" },
|
|
29
29
|
merge: { aiResolveEnabled: false, reimagineEnabled: false },
|
|
30
|
-
|
|
30
|
+
watchman: {
|
|
31
31
|
tier0Enabled: true,
|
|
32
32
|
tier0IntervalMs: 30000,
|
|
33
33
|
tier1Enabled: false,
|
|
34
34
|
tier2Enabled: false,
|
|
35
35
|
zombieThresholdMs: 600000,
|
|
36
36
|
nudgeIntervalMs: 60000,
|
|
37
|
+
mailIntervalMs: 5_000,
|
|
38
|
+
reNudgeIntervalMs: 10_000,
|
|
39
|
+
warnAfterMs: 60_000,
|
|
40
|
+
beaconNudgeMs: 20_000,
|
|
37
41
|
},
|
|
38
42
|
models: {},
|
|
39
43
|
logging: { verbose: false, redactSecrets: true },
|
|
@@ -37,7 +37,8 @@ describe("checkStructure", () => {
|
|
|
37
37
|
worktrees: {
|
|
38
38
|
baseDir: ".legio/worktrees",
|
|
39
39
|
},
|
|
40
|
-
|
|
40
|
+
taskTracker: {
|
|
41
|
+
backend: "auto" as const,
|
|
41
42
|
enabled: true,
|
|
42
43
|
},
|
|
43
44
|
mulch: {
|
|
@@ -49,13 +50,17 @@ describe("checkStructure", () => {
|
|
|
49
50
|
aiResolveEnabled: false,
|
|
50
51
|
reimagineEnabled: false,
|
|
51
52
|
},
|
|
52
|
-
|
|
53
|
+
watchman: {
|
|
53
54
|
tier0Enabled: true,
|
|
54
55
|
tier0IntervalMs: 30000,
|
|
55
56
|
tier1Enabled: false,
|
|
56
57
|
tier2Enabled: false,
|
|
57
58
|
zombieThresholdMs: 600000,
|
|
58
59
|
nudgeIntervalMs: 60000,
|
|
60
|
+
mailIntervalMs: 5_000,
|
|
61
|
+
reNudgeIntervalMs: 10_000,
|
|
62
|
+
warnAfterMs: 60_000,
|
|
63
|
+
beaconNudgeMs: 20_000,
|
|
59
64
|
},
|
|
60
65
|
models: {},
|
|
61
66
|
logging: {
|
|
@@ -19,7 +19,8 @@ const mockConfig: LegioConfig = {
|
|
|
19
19
|
worktrees: {
|
|
20
20
|
baseDir: "/tmp/.legio/worktrees",
|
|
21
21
|
},
|
|
22
|
-
|
|
22
|
+
taskTracker: {
|
|
23
|
+
backend: "auto" as const,
|
|
23
24
|
enabled: false,
|
|
24
25
|
},
|
|
25
26
|
mulch: {
|
|
@@ -31,13 +32,17 @@ const mockConfig: LegioConfig = {
|
|
|
31
32
|
aiResolveEnabled: false,
|
|
32
33
|
reimagineEnabled: false,
|
|
33
34
|
},
|
|
34
|
-
|
|
35
|
+
watchman: {
|
|
35
36
|
tier0Enabled: false,
|
|
36
37
|
tier0IntervalMs: 30000,
|
|
37
38
|
tier1Enabled: false,
|
|
38
39
|
tier2Enabled: false,
|
|
39
40
|
zombieThresholdMs: 600000,
|
|
40
41
|
nudgeIntervalMs: 60000,
|
|
42
|
+
mailIntervalMs: 5_000,
|
|
43
|
+
reNudgeIntervalMs: 10_000,
|
|
44
|
+
warnAfterMs: 60_000,
|
|
45
|
+
beaconNudgeMs: 20_000,
|
|
41
46
|
},
|
|
42
47
|
models: {},
|
|
43
48
|
logging: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
4
4
|
|
|
5
5
|
/** Test helper: check whether a file exists using Node.js fs/promises. */
|
|
6
6
|
async function fileExists(path: string): Promise<boolean> {
|
|
@@ -48,13 +48,11 @@ const EXPECTED_AGENT_DEFS = [
|
|
|
48
48
|
|
|
49
49
|
describe("E2E: init→sling lifecycle on external project", () => {
|
|
50
50
|
let tempDir: string;
|
|
51
|
-
let originalCwd: string;
|
|
52
51
|
let originalWrite: typeof process.stdout.write;
|
|
53
52
|
|
|
54
53
|
beforeEach(async () => {
|
|
55
54
|
tempDir = await createTempGitRepo();
|
|
56
|
-
|
|
57
|
-
process.chdir(tempDir);
|
|
55
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
58
56
|
|
|
59
57
|
// Suppress stdout noise from initCommand
|
|
60
58
|
originalWrite = process.stdout.write;
|
|
@@ -62,7 +60,6 @@ describe("E2E: init→sling lifecycle on external project", () => {
|
|
|
62
60
|
});
|
|
63
61
|
|
|
64
62
|
afterEach(async () => {
|
|
65
|
-
process.chdir(originalCwd);
|
|
66
63
|
process.stdout.write = originalWrite;
|
|
67
64
|
await cleanupTempDir(tempDir);
|
|
68
65
|
});
|
package/src/index.ts
CHANGED
|
@@ -40,12 +40,12 @@ import { stopCommand } from "./commands/stop.ts";
|
|
|
40
40
|
import { supervisorCommand } from "./commands/supervisor.ts";
|
|
41
41
|
import { traceCommand } from "./commands/trace.ts";
|
|
42
42
|
import { upCommand } from "./commands/up.ts";
|
|
43
|
-
import {
|
|
43
|
+
import { watchmanCommand } from "./commands/watchman.ts";
|
|
44
44
|
import { worktreeCommand } from "./commands/worktree.ts";
|
|
45
45
|
import { LegioError, WorktreeError } from "./errors.ts";
|
|
46
46
|
import { setQuiet } from "./logging/color.ts";
|
|
47
47
|
|
|
48
|
-
const VERSION = "0.
|
|
48
|
+
const VERSION = "0.2.2";
|
|
49
49
|
|
|
50
50
|
const HELP = `legio v${VERSION} — Multi-agent orchestration for Claude Code
|
|
51
51
|
|
|
@@ -74,7 +74,7 @@ Commands:
|
|
|
74
74
|
worktree <sub> Manage worktrees (list/clean)
|
|
75
75
|
log <event> Log a hook event
|
|
76
76
|
logs [options] Query NDJSON logs across agents
|
|
77
|
-
|
|
77
|
+
watchman <sub> Unified daemon — health + mail + beacon (start/stop/status)
|
|
78
78
|
feed [options] Unified real-time event stream across all agents
|
|
79
79
|
trace <target> Chronological event timeline for agent/bead
|
|
80
80
|
errors [options] Aggregated error view across agents
|
|
@@ -111,13 +111,13 @@ const COMMANDS = [
|
|
|
111
111
|
"hooks",
|
|
112
112
|
"monitor",
|
|
113
113
|
"mail",
|
|
114
|
+
"watchman",
|
|
114
115
|
"merge",
|
|
115
116
|
"nudge",
|
|
116
117
|
"group",
|
|
117
118
|
"worktree",
|
|
118
119
|
"log",
|
|
119
120
|
"logs",
|
|
120
|
-
"watch",
|
|
121
121
|
"trace",
|
|
122
122
|
"feed",
|
|
123
123
|
"errors",
|
|
@@ -251,6 +251,9 @@ async function main(): Promise<void> {
|
|
|
251
251
|
case "mail":
|
|
252
252
|
await mailCommand(commandArgs);
|
|
253
253
|
break;
|
|
254
|
+
case "watchman":
|
|
255
|
+
await watchmanCommand(commandArgs);
|
|
256
|
+
break;
|
|
254
257
|
case "merge":
|
|
255
258
|
await mergeCommand(commandArgs);
|
|
256
259
|
break;
|
|
@@ -269,9 +272,6 @@ async function main(): Promise<void> {
|
|
|
269
272
|
case "logs":
|
|
270
273
|
await logsCommand(commandArgs);
|
|
271
274
|
break;
|
|
272
|
-
case "watch":
|
|
273
|
-
await watchCommand(commandArgs);
|
|
274
|
-
break;
|
|
275
275
|
case "trace":
|
|
276
276
|
await traceCommand(commandArgs);
|
|
277
277
|
break;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending nudge marker utilities for inter-agent mail delivery.
|
|
3
|
+
*
|
|
4
|
+
* Instead of sending tmux keys (which corrupt tool I/O), auto-nudge writes
|
|
5
|
+
* a JSON marker file per agent. The `mail check --inject` flow reads and
|
|
6
|
+
* clears these markers, prepending a priority banner to the injected output.
|
|
7
|
+
*
|
|
8
|
+
* Extracted from src/commands/mail.ts for shared use by the watchman daemon.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** Shape of a pending nudge marker file. */
|
|
15
|
+
export interface PendingNudge {
|
|
16
|
+
from: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
subject: string;
|
|
19
|
+
messageId: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Directory where pending nudge markers are stored. */
|
|
24
|
+
export function pendingNudgeDir(cwd: string): string {
|
|
25
|
+
return join(cwd, ".legio", "pending-nudges");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Write a pending nudge marker for an agent.
|
|
30
|
+
*
|
|
31
|
+
* Creates `.legio/pending-nudges/{agent}.json` so that the next
|
|
32
|
+
* `mail check --inject` call surfaces a priority banner for this message.
|
|
33
|
+
* Overwrites any existing marker (only the latest nudge matters).
|
|
34
|
+
*/
|
|
35
|
+
export async function writePendingNudge(
|
|
36
|
+
cwd: string,
|
|
37
|
+
agentName: string,
|
|
38
|
+
nudge: Omit<PendingNudge, "createdAt">,
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
const dir = pendingNudgeDir(cwd);
|
|
41
|
+
await mkdir(dir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
const marker: PendingNudge = {
|
|
44
|
+
...nudge,
|
|
45
|
+
createdAt: new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
const filePath = join(dir, `${agentName}.json`);
|
|
48
|
+
const tmpPath = `${filePath}.tmp`;
|
|
49
|
+
await writeFile(tmpPath, `${JSON.stringify(marker, null, "\t")}\n`);
|
|
50
|
+
await rename(tmpPath, filePath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read and clear pending nudge markers for an agent.
|
|
55
|
+
*
|
|
56
|
+
* Returns the pending nudge (if any) and removes the marker file.
|
|
57
|
+
* Called by `mail check --inject` to prepend a priority banner.
|
|
58
|
+
*/
|
|
59
|
+
export async function readAndClearPendingNudge(
|
|
60
|
+
cwd: string,
|
|
61
|
+
agentName: string,
|
|
62
|
+
): Promise<PendingNudge | null> {
|
|
63
|
+
const filePath = join(pendingNudgeDir(cwd), `${agentName}.json`);
|
|
64
|
+
try {
|
|
65
|
+
await access(filePath);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const text = await readFile(filePath, "utf-8");
|
|
71
|
+
const nudge = JSON.parse(text) as PendingNudge;
|
|
72
|
+
await unlink(filePath);
|
|
73
|
+
return nudge;
|
|
74
|
+
} catch {
|
|
75
|
+
// Corrupt or race condition — clear it and move on
|
|
76
|
+
try {
|
|
77
|
+
await unlink(filePath);
|
|
78
|
+
} catch {
|
|
79
|
+
// Already gone
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if a pending nudge marker exists for an agent.
|
|
87
|
+
*/
|
|
88
|
+
export async function pendingNudgeExists(cwd: string, agentName: string): Promise<boolean> {
|
|
89
|
+
const filePath = join(pendingNudgeDir(cwd), `${agentName}.json`);
|
|
90
|
+
try {
|
|
91
|
+
await access(filePath);
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if an agent is idle (not actively executing a tool).
|
|
100
|
+
*
|
|
101
|
+
* An agent is considered idle when `.legio/agent-busy/{agentName}` does NOT exist
|
|
102
|
+
* or when the marker is stale (older than 5 minutes, indicating a crashed agent).
|
|
103
|
+
* The busy marker contains an ISO timestamp written by hooks during active tool execution.
|
|
104
|
+
* Idle agents can receive a direct tmux nudge; busy agents only get the pending marker.
|
|
105
|
+
*/
|
|
106
|
+
export async function isAgentIdle(cwd: string, agentName: string): Promise<boolean> {
|
|
107
|
+
const busyPath = join(cwd, ".legio", "agent-busy", agentName);
|
|
108
|
+
try {
|
|
109
|
+
const timestamp = await readFile(busyPath, "utf-8");
|
|
110
|
+
const age = Date.now() - new Date(timestamp.trim()).getTime();
|
|
111
|
+
if (age > 5 * 60 * 1000) {
|
|
112
|
+
// Stale marker from crashed agent — clean up
|
|
113
|
+
await unlink(busyPath).catch(() => {});
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false; // busy marker present and fresh — agent is actively working
|
|
117
|
+
} catch {
|
|
118
|
+
return true; // no busy marker — agent is idle
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/mail/store.test.ts
CHANGED
|
@@ -658,6 +658,95 @@ describe("createMailStore", () => {
|
|
|
658
658
|
});
|
|
659
659
|
});
|
|
660
660
|
|
|
661
|
+
describe("getAgentsWithUnread", () => {
|
|
662
|
+
test("returns empty array when no messages", () => {
|
|
663
|
+
const agents = store.getAgentsWithUnread();
|
|
664
|
+
expect(agents).toHaveLength(0);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("returns empty array when all messages are read", () => {
|
|
668
|
+
const msg = store.insert({
|
|
669
|
+
id: "",
|
|
670
|
+
from: "agent-a",
|
|
671
|
+
to: "orchestrator",
|
|
672
|
+
subject: "test",
|
|
673
|
+
body: "body",
|
|
674
|
+
type: "status",
|
|
675
|
+
priority: "normal",
|
|
676
|
+
threadId: null,
|
|
677
|
+
});
|
|
678
|
+
store.markRead(msg.id);
|
|
679
|
+
|
|
680
|
+
const agents = store.getAgentsWithUnread();
|
|
681
|
+
expect(agents).toHaveLength(0);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("returns distinct agent names with unread mail", () => {
|
|
685
|
+
store.insert({
|
|
686
|
+
id: "",
|
|
687
|
+
from: "agent-a",
|
|
688
|
+
to: "orchestrator",
|
|
689
|
+
subject: "msg1",
|
|
690
|
+
body: "body",
|
|
691
|
+
type: "status",
|
|
692
|
+
priority: "normal",
|
|
693
|
+
threadId: null,
|
|
694
|
+
});
|
|
695
|
+
store.insert({
|
|
696
|
+
id: "",
|
|
697
|
+
from: "agent-b",
|
|
698
|
+
to: "orchestrator",
|
|
699
|
+
subject: "msg2",
|
|
700
|
+
body: "body",
|
|
701
|
+
type: "status",
|
|
702
|
+
priority: "normal",
|
|
703
|
+
threadId: null,
|
|
704
|
+
});
|
|
705
|
+
store.insert({
|
|
706
|
+
id: "",
|
|
707
|
+
from: "agent-a",
|
|
708
|
+
to: "builder-1",
|
|
709
|
+
subject: "msg3",
|
|
710
|
+
body: "body",
|
|
711
|
+
type: "status",
|
|
712
|
+
priority: "normal",
|
|
713
|
+
threadId: null,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
const agents = store.getAgentsWithUnread();
|
|
717
|
+
expect(agents).toHaveLength(2);
|
|
718
|
+
expect(agents.sort()).toEqual(["builder-1", "orchestrator"]);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("does not include agents whose mail is all read", () => {
|
|
722
|
+
const msg1 = store.insert({
|
|
723
|
+
id: "",
|
|
724
|
+
from: "agent-a",
|
|
725
|
+
to: "orchestrator",
|
|
726
|
+
subject: "msg1",
|
|
727
|
+
body: "body",
|
|
728
|
+
type: "status",
|
|
729
|
+
priority: "normal",
|
|
730
|
+
threadId: null,
|
|
731
|
+
});
|
|
732
|
+
store.insert({
|
|
733
|
+
id: "",
|
|
734
|
+
from: "agent-a",
|
|
735
|
+
to: "builder-1",
|
|
736
|
+
subject: "msg2",
|
|
737
|
+
body: "body",
|
|
738
|
+
type: "status",
|
|
739
|
+
priority: "normal",
|
|
740
|
+
threadId: null,
|
|
741
|
+
});
|
|
742
|
+
store.markRead(msg1.id);
|
|
743
|
+
|
|
744
|
+
const agents = store.getAgentsWithUnread();
|
|
745
|
+
expect(agents).toHaveLength(1);
|
|
746
|
+
expect(agents[0]).toBe("builder-1");
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
|
|
661
750
|
describe("audience column", () => {
|
|
662
751
|
test("defaults audience to agent when not provided", () => {
|
|
663
752
|
const msg = store.insert({
|
package/src/mail/store.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface MailStore {
|
|
|
28
28
|
getById(id: string): MailMessage | null;
|
|
29
29
|
getByThread(threadId: string): MailMessage[];
|
|
30
30
|
markRead(id: string): void;
|
|
31
|
+
/** Get distinct agent names that have unread messages. */
|
|
32
|
+
getAgentsWithUnread(): string[];
|
|
31
33
|
/** Delete messages matching the given criteria. Returns the number of messages deleted. */
|
|
32
34
|
purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number;
|
|
33
35
|
close(): void;
|
|
@@ -243,6 +245,10 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
243
245
|
UPDATE messages SET read = 1 WHERE id = $id
|
|
244
246
|
`);
|
|
245
247
|
|
|
248
|
+
const getAgentsWithUnreadStmt = db.prepare(`
|
|
249
|
+
SELECT DISTINCT to_agent FROM messages WHERE read = 0
|
|
250
|
+
`);
|
|
251
|
+
|
|
246
252
|
// Dynamic filter queries are built at call time since the WHERE clause varies
|
|
247
253
|
function buildFilterQuery(filters?: {
|
|
248
254
|
from?: string;
|
|
@@ -348,6 +354,11 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
348
354
|
markReadStmt.run({ id });
|
|
349
355
|
},
|
|
350
356
|
|
|
357
|
+
getAgentsWithUnread(): string[] {
|
|
358
|
+
const rows = getAgentsWithUnreadStmt.all() as Array<{ to_agent: string }>;
|
|
359
|
+
return rows.map((r) => r.to_agent);
|
|
360
|
+
},
|
|
361
|
+
|
|
351
362
|
purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number {
|
|
352
363
|
// Count matching rows before deletion so we can report accurate numbers
|
|
353
364
|
if (options.all) {
|