@os-eco/overstory-cli 0.9.4 → 0.11.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/README.md +50 -19
- package/agents/builder.md +19 -9
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +204 -87
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +219 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/overlay.test.ts +60 -4
- package/src/agents/overlay.ts +63 -8
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/commands/agents.ts +9 -0
- package/src/commands/clean.ts +54 -0
- package/src/commands/coordinator.test.ts +254 -0
- package/src/commands/coordinator.ts +273 -8
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +14 -4
- package/src/commands/doctor.ts +3 -1
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +187 -11
- package/src/commands/log.ts +171 -71
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +230 -1
- package/src/commands/merge.ts +68 -12
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +177 -1
- package/src/commands/sling.ts +243 -71
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +255 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/watch.test.ts +43 -0
- package/src/commands/watch.ts +153 -28
- package/src/config.ts +23 -0
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +48 -1
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +57 -6
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.ts +29 -0
- package/src/logging/theme.ts +4 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/resolver.ts +1 -1
- package/src/mulch/client.ts +3 -3
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +657 -29
- package/src/sessions/store.ts +286 -23
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +107 -2
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1607 -376
- package/src/watchdog/daemon.ts +462 -88
- package/src/watchdog/health.test.ts +282 -0
- package/src/watchdog/health.ts +126 -27
- package/src/worktree/manager.test.ts +218 -1
- package/src/worktree/manager.ts +55 -0
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +28 -0
- package/src/worktree/tmux.ts +27 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +5 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
cleanupTempDir,
|
|
7
|
+
commitFile,
|
|
8
|
+
createTempGitRepo,
|
|
9
|
+
getDefaultBranch,
|
|
10
|
+
} from "../test-helpers.ts";
|
|
11
|
+
import type { QualityGate } from "../types.ts";
|
|
12
|
+
import { hasWorkToVerify, runQualityGates } from "./quality-gates.ts";
|
|
13
|
+
|
|
14
|
+
describe("runQualityGates", () => {
|
|
15
|
+
let tempDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
tempDir = await mkdtemp(join(tmpdir(), "qg-test-"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await cleanupTempDir(tempDir);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("returns null when gates list is empty", async () => {
|
|
26
|
+
const result = await runQualityGates([], tempDir);
|
|
27
|
+
expect(result).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("status is 'success' when all gates exit 0", async () => {
|
|
31
|
+
const gates: QualityGate[] = [
|
|
32
|
+
{ name: "True", command: "true", description: "always passes" },
|
|
33
|
+
{ name: "Echo", command: "echo ok", description: "always passes" },
|
|
34
|
+
];
|
|
35
|
+
const result = await runQualityGates(gates, tempDir);
|
|
36
|
+
expect(result).not.toBeNull();
|
|
37
|
+
expect(result?.status).toBe("success");
|
|
38
|
+
expect(result?.results).toHaveLength(2);
|
|
39
|
+
expect(result?.results.every((r) => r.passed)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("status is 'failure' when no gates exit 0", async () => {
|
|
43
|
+
const gates: QualityGate[] = [
|
|
44
|
+
{ name: "False1", command: "false", description: "always fails" },
|
|
45
|
+
{ name: "False2", command: "false", description: "always fails" },
|
|
46
|
+
];
|
|
47
|
+
const result = await runQualityGates(gates, tempDir);
|
|
48
|
+
expect(result).not.toBeNull();
|
|
49
|
+
expect(result?.status).toBe("failure");
|
|
50
|
+
expect(result?.results.every((r) => !r.passed)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("status is 'partial' on mixed exit codes", async () => {
|
|
54
|
+
const gates: QualityGate[] = [
|
|
55
|
+
{ name: "Pass", command: "true", description: "passes" },
|
|
56
|
+
{ name: "Fail", command: "false", description: "fails" },
|
|
57
|
+
];
|
|
58
|
+
const result = await runQualityGates(gates, tempDir);
|
|
59
|
+
expect(result).not.toBeNull();
|
|
60
|
+
expect(result?.status).toBe("partial");
|
|
61
|
+
expect(result?.results.filter((r) => r.passed)).toHaveLength(1);
|
|
62
|
+
expect(result?.results.filter((r) => !r.passed)).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a gate that hangs past the timeout is treated as failed", async () => {
|
|
66
|
+
const gates: QualityGate[] = [
|
|
67
|
+
{ name: "Sleeper", command: "sleep 5", description: "intentionally slow" },
|
|
68
|
+
];
|
|
69
|
+
const result = await runQualityGates(gates, tempDir, { timeoutMs: 200 });
|
|
70
|
+
expect(result).not.toBeNull();
|
|
71
|
+
expect(result?.status).toBe("failure");
|
|
72
|
+
expect(result?.results[0]?.passed).toBe(false);
|
|
73
|
+
expect(result?.results[0]?.exitCode).toBe(-1);
|
|
74
|
+
// Should return well before the 5s the gate would otherwise take
|
|
75
|
+
expect(result?.totalDurationMs).toBeLessThan(2_000);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("captures per-gate duration and exit code", async () => {
|
|
79
|
+
const gates: QualityGate[] = [{ name: "Quick", command: "true", description: "passes fast" }];
|
|
80
|
+
const result = await runQualityGates(gates, tempDir);
|
|
81
|
+
expect(result?.results[0]?.exitCode).toBe(0);
|
|
82
|
+
expect(result?.results[0]?.durationMs).toBeGreaterThanOrEqual(0);
|
|
83
|
+
expect(result?.totalDurationMs).toBeGreaterThanOrEqual(result?.results[0]?.durationMs ?? 0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("hasWorkToVerify", () => {
|
|
88
|
+
let repoDir: string;
|
|
89
|
+
|
|
90
|
+
beforeEach(async () => {
|
|
91
|
+
repoDir = await createTempGitRepo();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
await cleanupTempDir(repoDir);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("returns false on a fresh repo with no commits past base and a clean tree", async () => {
|
|
99
|
+
const branch = await getDefaultBranch(repoDir);
|
|
100
|
+
const result = await hasWorkToVerify(repoDir, branch);
|
|
101
|
+
expect(result).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns true when worktree has uncommitted changes", async () => {
|
|
105
|
+
const branch = await getDefaultBranch(repoDir);
|
|
106
|
+
await Bun.write(join(repoDir, "dirty.txt"), "uncommitted content");
|
|
107
|
+
const result = await hasWorkToVerify(repoDir, branch);
|
|
108
|
+
expect(result).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("returns true when there are commits past base", async () => {
|
|
112
|
+
// Pin a "base-ref" branch at the initial commit, then add a new commit
|
|
113
|
+
// on the working branch so HEAD is one commit ahead of base-ref.
|
|
114
|
+
const proc = Bun.spawn(["git", "branch", "base-ref", "HEAD"], { cwd: repoDir });
|
|
115
|
+
await proc.exited;
|
|
116
|
+
await commitFile(repoDir, "new-file.txt", "second commit", "second commit");
|
|
117
|
+
|
|
118
|
+
const result = await hasWorkToVerify(repoDir, "base-ref");
|
|
119
|
+
expect(result).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("returns true when base ref cannot be resolved (fail open)", async () => {
|
|
123
|
+
const result = await hasWorkToVerify(repoDir, "definitely-not-a-real-ref");
|
|
124
|
+
expect(result).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("defaults baseRef to 'main' when not provided", async () => {
|
|
128
|
+
// On a clean repo with default branch 'main' the function should resolve
|
|
129
|
+
// 'main' successfully and report no work to verify.
|
|
130
|
+
const branch = await getDefaultBranch(repoDir);
|
|
131
|
+
// Skip this assertion if the default branch isn't 'main' (e.g., master on
|
|
132
|
+
// some CI runners) — fall back to passing the explicit branch.
|
|
133
|
+
if (branch === "main") {
|
|
134
|
+
const result = await hasWorkToVerify(repoDir);
|
|
135
|
+
expect(result).toBe(false);
|
|
136
|
+
} else {
|
|
137
|
+
const result = await hasWorkToVerify(repoDir, branch);
|
|
138
|
+
expect(result).toBe(false);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quality-gate runner used at session-end to determine the outcome status
|
|
3
|
+
* threaded into mulch record writes (success / partial / failure).
|
|
4
|
+
*
|
|
5
|
+
* Used by `src/commands/log.ts` session-end handler. Cheap precheck via
|
|
6
|
+
* `hasWorkToVerify()` lets read-only agents (scout/reviewer) skip gate
|
|
7
|
+
* execution entirely when no commits or uncommitted changes exist.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { QualityGate } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
export interface QualityGateResult {
|
|
13
|
+
name: string;
|
|
14
|
+
command: string;
|
|
15
|
+
passed: boolean;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
exitCode: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface QualityGateOutcome {
|
|
21
|
+
status: "success" | "partial" | "failure";
|
|
22
|
+
results: QualityGateResult[];
|
|
23
|
+
totalDurationMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run each configured quality gate against `cwd` and aggregate the result.
|
|
30
|
+
*
|
|
31
|
+
* Returns null when `gates` is empty.
|
|
32
|
+
*
|
|
33
|
+
* - all passed -> "success"
|
|
34
|
+
* - none passed -> "failure"
|
|
35
|
+
* - mixed -> "partial"
|
|
36
|
+
*/
|
|
37
|
+
export async function runQualityGates(
|
|
38
|
+
gates: QualityGate[],
|
|
39
|
+
cwd: string,
|
|
40
|
+
options?: { timeoutMs?: number },
|
|
41
|
+
): Promise<QualityGateOutcome | null> {
|
|
42
|
+
if (gates.length === 0) return null;
|
|
43
|
+
|
|
44
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
45
|
+
const results: QualityGateResult[] = [];
|
|
46
|
+
const totalStart = Date.now();
|
|
47
|
+
|
|
48
|
+
for (const gate of gates) {
|
|
49
|
+
const argv = gate.command.split(/\s+/).filter((s) => s.length > 0);
|
|
50
|
+
if (argv.length === 0) {
|
|
51
|
+
results.push({
|
|
52
|
+
name: gate.name,
|
|
53
|
+
command: gate.command,
|
|
54
|
+
passed: false,
|
|
55
|
+
durationMs: 0,
|
|
56
|
+
exitCode: -1,
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const start = Date.now();
|
|
62
|
+
let proc: ReturnType<typeof Bun.spawn> | undefined;
|
|
63
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
64
|
+
let timedOut = false;
|
|
65
|
+
try {
|
|
66
|
+
proc = Bun.spawn(argv, {
|
|
67
|
+
cwd,
|
|
68
|
+
stdout: "ignore",
|
|
69
|
+
stderr: "ignore",
|
|
70
|
+
});
|
|
71
|
+
timer = setTimeout(() => {
|
|
72
|
+
timedOut = true;
|
|
73
|
+
try {
|
|
74
|
+
proc?.kill();
|
|
75
|
+
} catch {
|
|
76
|
+
// best-effort kill
|
|
77
|
+
}
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
const exitCode = await proc.exited;
|
|
80
|
+
const durationMs = Date.now() - start;
|
|
81
|
+
results.push({
|
|
82
|
+
name: gate.name,
|
|
83
|
+
command: gate.command,
|
|
84
|
+
passed: !timedOut && exitCode === 0,
|
|
85
|
+
durationMs,
|
|
86
|
+
exitCode: timedOut ? -1 : exitCode,
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
results.push({
|
|
90
|
+
name: gate.name,
|
|
91
|
+
command: gate.command,
|
|
92
|
+
passed: false,
|
|
93
|
+
durationMs: Date.now() - start,
|
|
94
|
+
exitCode: -1,
|
|
95
|
+
});
|
|
96
|
+
} finally {
|
|
97
|
+
if (timer) clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const passedCount = results.filter((r) => r.passed).length;
|
|
102
|
+
let status: "success" | "partial" | "failure";
|
|
103
|
+
if (passedCount === results.length) {
|
|
104
|
+
status = "success";
|
|
105
|
+
} else if (passedCount === 0) {
|
|
106
|
+
status = "failure";
|
|
107
|
+
} else {
|
|
108
|
+
status = "partial";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
status,
|
|
113
|
+
results,
|
|
114
|
+
totalDurationMs: Date.now() - totalStart,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Cheap precheck: returns true when the worktree has commits beyond `baseRef`
|
|
120
|
+
* or any uncommitted modifications. Used to skip gate execution for read-only
|
|
121
|
+
* agents that produced no work.
|
|
122
|
+
*
|
|
123
|
+
* Fails open: if HEAD or `baseRef` cannot be resolved, returns true so that
|
|
124
|
+
* gates still run rather than silently skipping.
|
|
125
|
+
*/
|
|
126
|
+
export async function hasWorkToVerify(cwd: string, baseRef = "main"): Promise<boolean> {
|
|
127
|
+
const head = await runGit(cwd, ["rev-parse", "--verify", "HEAD"]);
|
|
128
|
+
const base = await runGit(cwd, ["rev-parse", "--verify", baseRef]);
|
|
129
|
+
if (head.exitCode !== 0 || base.exitCode !== 0) return true;
|
|
130
|
+
|
|
131
|
+
const ahead = await runGit(cwd, ["rev-list", "--count", `${baseRef}..HEAD`]);
|
|
132
|
+
if (ahead.exitCode === 0) {
|
|
133
|
+
const count = Number.parseInt(ahead.stdout.trim(), 10);
|
|
134
|
+
if (Number.isFinite(count) && count > 0) return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const status = await runGit(cwd, ["status", "--porcelain"]);
|
|
138
|
+
if (status.exitCode === 0 && status.stdout.trim().length > 0) return true;
|
|
139
|
+
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function runGit(cwd: string, args: string[]): Promise<{ stdout: string; exitCode: number }> {
|
|
144
|
+
try {
|
|
145
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
146
|
+
cwd,
|
|
147
|
+
stdout: "pipe",
|
|
148
|
+
stderr: "ignore",
|
|
149
|
+
});
|
|
150
|
+
const stdout = await new Response(proc.stdout).text();
|
|
151
|
+
const exitCode = await proc.exited;
|
|
152
|
+
return { stdout, exitCode };
|
|
153
|
+
} catch {
|
|
154
|
+
return { stdout: "", exitCode: -1 };
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/json.ts
CHANGED
|
@@ -22,3 +22,32 @@ export function jsonOutput(command: string, data: Record<string, unknown>): void
|
|
|
22
22
|
export function jsonError(command: string, error: string): void {
|
|
23
23
|
process.stdout.write(`${JSON.stringify({ success: false, command, error })}\n`);
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a JSON success Response for HTTP API handlers.
|
|
28
|
+
* Envelope: { success: true, command: 'serve', data, nextCursor? }
|
|
29
|
+
*/
|
|
30
|
+
export function apiJson(
|
|
31
|
+
data: unknown,
|
|
32
|
+
init?: { status?: number; nextCursor?: string | null },
|
|
33
|
+
): Response {
|
|
34
|
+
const envelope: Record<string, unknown> = { success: true, command: "serve", data };
|
|
35
|
+
if (init?.nextCursor != null) {
|
|
36
|
+
envelope.nextCursor = init.nextCursor;
|
|
37
|
+
}
|
|
38
|
+
return new Response(JSON.stringify(envelope), {
|
|
39
|
+
status: init?.status ?? 200,
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build a JSON error Response for HTTP API handlers.
|
|
46
|
+
* Envelope: { success: false, command: 'serve', error }
|
|
47
|
+
*/
|
|
48
|
+
export function apiError(message: string, status: number): Response {
|
|
49
|
+
return new Response(JSON.stringify({ success: false, command: "serve", error: message }), {
|
|
50
|
+
status,
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/logging/theme.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { brand, color, noColor, visibleLength } from "./color.ts";
|
|
|
14
14
|
/** Maps agent states to their visual color functions. */
|
|
15
15
|
const STATE_COLORS: Record<AgentState, ColorFn> = {
|
|
16
16
|
working: color.green,
|
|
17
|
+
in_turn: color.green,
|
|
18
|
+
between_turns: color.cyan,
|
|
17
19
|
booting: color.yellow,
|
|
18
20
|
stalled: color.red,
|
|
19
21
|
zombie: color.dim,
|
|
@@ -23,6 +25,8 @@ const STATE_COLORS: Record<AgentState, ColorFn> = {
|
|
|
23
25
|
/** Maps agent states to their icon characters. */
|
|
24
26
|
const STATE_ICONS: Record<AgentState, string> = {
|
|
25
27
|
working: ">",
|
|
28
|
+
in_turn: ">",
|
|
29
|
+
between_turns: "~",
|
|
26
30
|
booting: "~",
|
|
27
31
|
stalled: "!",
|
|
28
32
|
zombie: "x",
|
package/src/mail/client.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { MailError } from "../errors.ts";
|
|
10
|
-
import type { MailMessage, MailPayloadMap, MailProtocolType } from "../types.ts";
|
|
10
|
+
import type { MailMessage, MailMessageType, MailPayloadMap, MailProtocolType } from "../types.ts";
|
|
11
11
|
import type { MailStore } from "./store.ts";
|
|
12
12
|
|
|
13
13
|
export interface MailClient {
|
|
@@ -42,7 +42,12 @@ export interface MailClient {
|
|
|
42
42
|
checkInject(agentName: string): string;
|
|
43
43
|
|
|
44
44
|
/** List messages with optional filters. */
|
|
45
|
-
list(filters?: {
|
|
45
|
+
list(filters?: {
|
|
46
|
+
from?: string;
|
|
47
|
+
to?: string;
|
|
48
|
+
unread?: boolean;
|
|
49
|
+
type?: MailMessageType;
|
|
50
|
+
}): MailMessage[];
|
|
46
51
|
|
|
47
52
|
/** Mark a message as read by ID. Returns whether the message was already read. */
|
|
48
53
|
markRead(id: string): { alreadyRead: boolean };
|
|
@@ -50,6 +55,9 @@ export interface MailClient {
|
|
|
50
55
|
/** Reply to a message. Returns the new message ID. */
|
|
51
56
|
reply(messageId: string, body: string, from: string): string;
|
|
52
57
|
|
|
58
|
+
/** Delete a single message by id. Returns true if a row was deleted. */
|
|
59
|
+
deleteById(id: string): boolean;
|
|
60
|
+
|
|
53
61
|
/** Close the underlying store. */
|
|
54
62
|
close(): void;
|
|
55
63
|
}
|
|
@@ -75,6 +83,7 @@ export function parsePayload<T extends MailProtocolType>(
|
|
|
75
83
|
/** Protocol types that represent structured coordination messages. */
|
|
76
84
|
const PROTOCOL_TYPES = new Set<string>([
|
|
77
85
|
"worker_done",
|
|
86
|
+
"worker_died",
|
|
78
87
|
"merge_ready",
|
|
79
88
|
"merged",
|
|
80
89
|
"merge_failed",
|
|
@@ -187,6 +196,10 @@ export function createMailClient(store: MailStore): MailClient {
|
|
|
187
196
|
return { alreadyRead: false };
|
|
188
197
|
},
|
|
189
198
|
|
|
199
|
+
deleteById(id): boolean {
|
|
200
|
+
return store.deleteById(id);
|
|
201
|
+
},
|
|
202
|
+
|
|
190
203
|
reply(messageId, body, from): string {
|
|
191
204
|
const original = store.getById(messageId);
|
|
192
205
|
if (!original) {
|
package/src/mail/store.test.ts
CHANGED
|
@@ -437,6 +437,88 @@ describe("createMailStore", () => {
|
|
|
437
437
|
expect(filtered).toHaveLength(1);
|
|
438
438
|
expect(filtered[0]?.subject).toBe("msg1");
|
|
439
439
|
});
|
|
440
|
+
|
|
441
|
+
test("filters by type", () => {
|
|
442
|
+
store.insert({
|
|
443
|
+
id: "",
|
|
444
|
+
from: "lead-a",
|
|
445
|
+
to: "coordinator",
|
|
446
|
+
subject: "merge_ready: t1",
|
|
447
|
+
body: "ready",
|
|
448
|
+
type: "merge_ready",
|
|
449
|
+
priority: "normal",
|
|
450
|
+
threadId: null,
|
|
451
|
+
});
|
|
452
|
+
store.insert({
|
|
453
|
+
id: "",
|
|
454
|
+
from: "builder-a",
|
|
455
|
+
to: "lead-a",
|
|
456
|
+
subject: "Worker done",
|
|
457
|
+
body: "done",
|
|
458
|
+
type: "worker_done",
|
|
459
|
+
priority: "normal",
|
|
460
|
+
threadId: null,
|
|
461
|
+
});
|
|
462
|
+
store.insert({
|
|
463
|
+
id: "",
|
|
464
|
+
from: "lead-a",
|
|
465
|
+
to: "coordinator",
|
|
466
|
+
subject: "status",
|
|
467
|
+
body: "still going",
|
|
468
|
+
type: "status",
|
|
469
|
+
priority: "normal",
|
|
470
|
+
threadId: null,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const mr = store.getAll({ type: "merge_ready" });
|
|
474
|
+
expect(mr).toHaveLength(1);
|
|
475
|
+
expect(mr[0]?.subject).toBe("merge_ready: t1");
|
|
476
|
+
|
|
477
|
+
const wd = store.getAll({ type: "worker_done" });
|
|
478
|
+
expect(wd).toHaveLength(1);
|
|
479
|
+
expect(wd[0]?.subject).toBe("Worker done");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("combines type with from filter", () => {
|
|
483
|
+
store.insert({
|
|
484
|
+
id: "",
|
|
485
|
+
from: "lead-a",
|
|
486
|
+
to: "coordinator",
|
|
487
|
+
subject: "merge_ready: t1",
|
|
488
|
+
body: "ready",
|
|
489
|
+
type: "merge_ready",
|
|
490
|
+
priority: "normal",
|
|
491
|
+
threadId: null,
|
|
492
|
+
});
|
|
493
|
+
store.insert({
|
|
494
|
+
id: "",
|
|
495
|
+
from: "lead-b",
|
|
496
|
+
to: "coordinator",
|
|
497
|
+
subject: "merge_ready: t2",
|
|
498
|
+
body: "ready",
|
|
499
|
+
type: "merge_ready",
|
|
500
|
+
priority: "normal",
|
|
501
|
+
threadId: null,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const mine = store.getAll({ from: "lead-a", type: "merge_ready" });
|
|
505
|
+
expect(mine).toHaveLength(1);
|
|
506
|
+
expect(mine[0]?.from).toBe("lead-a");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("returns empty array when no rows match the type filter", () => {
|
|
510
|
+
store.insert({
|
|
511
|
+
id: "",
|
|
512
|
+
from: "agent-a",
|
|
513
|
+
to: "orchestrator",
|
|
514
|
+
subject: "msg",
|
|
515
|
+
body: "body",
|
|
516
|
+
type: "status",
|
|
517
|
+
priority: "normal",
|
|
518
|
+
threadId: null,
|
|
519
|
+
});
|
|
520
|
+
expect(store.getAll({ type: "merge_ready" })).toHaveLength(0);
|
|
521
|
+
});
|
|
440
522
|
});
|
|
441
523
|
|
|
442
524
|
describe("getByThread", () => {
|
package/src/mail/store.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { Database } from "bun:sqlite";
|
|
10
10
|
import { MailError } from "../errors.ts";
|
|
11
|
-
import type { MailMessage } from "../types.ts";
|
|
11
|
+
import type { MailMessage, MailMessageType } from "../types.ts";
|
|
12
12
|
import { MAIL_MESSAGE_TYPES } from "../types.ts";
|
|
13
13
|
|
|
14
14
|
export interface MailStore {
|
|
@@ -16,10 +16,18 @@ export interface MailStore {
|
|
|
16
16
|
message: Omit<MailMessage, "read" | "createdAt" | "payload"> & { payload?: string | null },
|
|
17
17
|
): MailMessage;
|
|
18
18
|
getUnread(agentName: string): MailMessage[];
|
|
19
|
-
getAll(filters?: {
|
|
19
|
+
getAll(filters?: {
|
|
20
|
+
from?: string;
|
|
21
|
+
to?: string;
|
|
22
|
+
unread?: boolean;
|
|
23
|
+
type?: MailMessageType;
|
|
24
|
+
limit?: number;
|
|
25
|
+
}): MailMessage[];
|
|
20
26
|
getById(id: string): MailMessage | null;
|
|
21
27
|
getByThread(threadId: string): MailMessage[];
|
|
22
28
|
markRead(id: string): void;
|
|
29
|
+
/** Delete a single message by id. Returns true if a row was deleted. */
|
|
30
|
+
deleteById(id: string): boolean;
|
|
23
31
|
/** Delete messages matching the given criteria. Returns the number of messages deleted. */
|
|
24
32
|
purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number;
|
|
25
33
|
close(): void;
|
|
@@ -84,14 +92,21 @@ function migrateSchema(db: Database): void {
|
|
|
84
92
|
const hasPayloadColumn = row.sql.includes("payload");
|
|
85
93
|
const hasProtocolTypes = row.sql.includes("worker_done");
|
|
86
94
|
const hasDecisionGate = row.sql.includes("decision_gate");
|
|
95
|
+
const hasWorkerDied = row.sql.includes("worker_died");
|
|
87
96
|
|
|
88
97
|
// If schema is fully up to date, nothing to do
|
|
89
|
-
if (
|
|
98
|
+
if (
|
|
99
|
+
hasCheckConstraints &&
|
|
100
|
+
hasPayloadColumn &&
|
|
101
|
+
hasProtocolTypes &&
|
|
102
|
+
hasDecisionGate &&
|
|
103
|
+
hasWorkerDied
|
|
104
|
+
) {
|
|
90
105
|
return;
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
// If only missing the payload column (has correct CHECK constraints), use ALTER TABLE
|
|
94
|
-
if (hasCheckConstraints && hasProtocolTypes && !hasPayloadColumn) {
|
|
109
|
+
if (hasCheckConstraints && hasProtocolTypes && hasWorkerDied && !hasPayloadColumn) {
|
|
95
110
|
db.exec("ALTER TABLE messages ADD COLUMN payload TEXT");
|
|
96
111
|
return;
|
|
97
112
|
}
|
|
@@ -232,11 +247,16 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
232
247
|
UPDATE messages SET read = 1 WHERE id = $id
|
|
233
248
|
`);
|
|
234
249
|
|
|
250
|
+
const deleteByIdStmt = db.prepare<void, { $id: string }>(`
|
|
251
|
+
DELETE FROM messages WHERE id = $id
|
|
252
|
+
`);
|
|
253
|
+
|
|
235
254
|
// Dynamic filter queries are built at call time since the WHERE clause varies
|
|
236
255
|
function buildFilterQuery(filters?: {
|
|
237
256
|
from?: string;
|
|
238
257
|
to?: string;
|
|
239
258
|
unread?: boolean;
|
|
259
|
+
type?: MailMessageType;
|
|
240
260
|
limit?: number;
|
|
241
261
|
}): MailMessage[] {
|
|
242
262
|
const conditions: string[] = [];
|
|
@@ -254,6 +274,10 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
254
274
|
conditions.push("read = $read");
|
|
255
275
|
params.$read = filters.unread ? 0 : 1;
|
|
256
276
|
}
|
|
277
|
+
if (filters?.type !== undefined) {
|
|
278
|
+
conditions.push("type = $type");
|
|
279
|
+
params.$type = filters.type;
|
|
280
|
+
}
|
|
257
281
|
|
|
258
282
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
259
283
|
const limitClause = filters?.limit !== undefined ? ` LIMIT $limit` : "";
|
|
@@ -315,6 +339,7 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
315
339
|
from?: string;
|
|
316
340
|
to?: string;
|
|
317
341
|
unread?: boolean;
|
|
342
|
+
type?: MailMessageType;
|
|
318
343
|
limit?: number;
|
|
319
344
|
}): MailMessage[] {
|
|
320
345
|
return buildFilterQuery(filters);
|
|
@@ -334,6 +359,18 @@ export function createMailStore(dbPath: string): MailStore {
|
|
|
334
359
|
markReadStmt.run({ $id: id });
|
|
335
360
|
},
|
|
336
361
|
|
|
362
|
+
deleteById(id: string): boolean {
|
|
363
|
+
try {
|
|
364
|
+
const result = deleteByIdStmt.run({ $id: id });
|
|
365
|
+
return result.changes > 0;
|
|
366
|
+
} catch (err) {
|
|
367
|
+
throw new MailError(`Failed to delete message: ${id}`, {
|
|
368
|
+
messageId: id,
|
|
369
|
+
cause: err instanceof Error ? err : undefined,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
|
|
337
374
|
purge(options: { all?: boolean; olderThanMs?: number; agent?: string }): number {
|
|
338
375
|
// Count matching rows before deletion so we can report accurate numbers
|
|
339
376
|
if (options.all) {
|