@gethmy/agent 1.0.8 → 1.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/README.md +67 -16
- package/dist/__tests__/budget.test.d.ts +1 -0
- package/dist/__tests__/budget.test.js +94 -0
- package/dist/__tests__/config-validation.test.d.ts +1 -0
- package/dist/__tests__/config-validation.test.js +65 -0
- package/dist/__tests__/dev-server-readiness.test.d.ts +1 -0
- package/dist/__tests__/dev-server-readiness.test.js +26 -0
- package/dist/__tests__/http-server.test.d.ts +1 -0
- package/dist/__tests__/http-server.test.js +115 -0
- package/dist/__tests__/log.test.d.ts +1 -0
- package/dist/__tests__/log.test.js +115 -0
- package/dist/__tests__/process-group.test.d.ts +1 -0
- package/dist/__tests__/process-group.test.js +68 -0
- package/dist/__tests__/progress-tracker.test.js +2 -1
- package/dist/__tests__/reconcile-heartbeat.test.d.ts +1 -0
- package/dist/__tests__/reconcile-heartbeat.test.js +116 -0
- package/dist/__tests__/recovery.test.d.ts +1 -0
- package/dist/__tests__/recovery.test.js +126 -0
- package/dist/__tests__/review-parser.test.d.ts +1 -0
- package/dist/__tests__/review-parser.test.js +65 -0
- package/dist/__tests__/state-store.test.d.ts +1 -0
- package/dist/__tests__/state-store.test.js +132 -0
- package/dist/__tests__/transitions.test.d.ts +1 -0
- package/dist/__tests__/transitions.test.js +130 -0
- package/dist/__tests__/worktree-gc.test.d.ts +1 -0
- package/dist/__tests__/worktree-gc.test.js +137 -0
- package/dist/budget.d.ts +45 -0
- package/dist/budget.js +94 -0
- package/dist/cli.d.ts +15 -1
- package/dist/cli.js +239 -1
- package/dist/completion.d.ts +9 -0
- package/dist/completion.js +28 -2
- package/dist/config-validation.d.ts +18 -0
- package/dist/config-validation.js +66 -0
- package/dist/config.js +12 -0
- package/dist/http-server.d.ts +79 -0
- package/dist/http-server.js +115 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +125 -10
- package/dist/log.d.ts +29 -5
- package/dist/log.js +80 -15
- package/dist/pool.d.ts +27 -2
- package/dist/pool.js +69 -4
- package/dist/process-group.d.ts +26 -0
- package/dist/process-group.js +72 -0
- package/dist/progress-tracker.js +3 -1
- package/dist/queue.d.ts +2 -0
- package/dist/queue.js +4 -0
- package/dist/reconcile.d.ts +15 -1
- package/dist/reconcile.js +63 -2
- package/dist/recovery.d.ts +30 -0
- package/dist/recovery.js +136 -0
- package/dist/review-completion.d.ts +12 -4
- package/dist/review-completion.js +158 -49
- package/dist/review-worker.d.ts +9 -2
- package/dist/review-worker.js +182 -78
- package/dist/run-log.d.ts +6 -0
- package/dist/run-log.js +19 -0
- package/dist/state-store.d.ts +72 -0
- package/dist/state-store.js +216 -0
- package/dist/transitions.d.ts +57 -0
- package/dist/transitions.js +131 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.js +19 -1
- package/dist/verification.d.ts +17 -0
- package/dist/verification.js +71 -10
- package/dist/watcher.d.ts +2 -0
- package/dist/watcher.js +11 -0
- package/dist/worker.d.ts +9 -2
- package/dist/worker.js +168 -47
- package/dist/worktree-gc.d.ts +39 -0
- package/dist/worktree-gc.js +139 -0
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
vi.mock("../worktree.js", () => ({
|
|
6
|
+
cleanupWorktree: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("../board-helpers.js", () => ({
|
|
9
|
+
moveCardToColumn: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
addLabelByName: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
buildLabelMap: () => new Map(),
|
|
12
|
+
hasLabel: () => false,
|
|
13
|
+
resolveCardLabels: () => [],
|
|
14
|
+
}));
|
|
15
|
+
import { Reconciler } from "../reconcile.js";
|
|
16
|
+
import { newRunId, StateStore } from "../state-store.js";
|
|
17
|
+
import { DEFAULT_AGENT_CONFIG } from "../types.js";
|
|
18
|
+
function makeRun(overrides = {}) {
|
|
19
|
+
return {
|
|
20
|
+
runId: newRunId(),
|
|
21
|
+
cardId: "card-z",
|
|
22
|
+
cardShortId: 7,
|
|
23
|
+
pipeline: "implement",
|
|
24
|
+
workerId: 0,
|
|
25
|
+
sessionId: null,
|
|
26
|
+
worktreePath: null,
|
|
27
|
+
branchName: null,
|
|
28
|
+
daemonPid: 999_999_999,
|
|
29
|
+
phase: "running",
|
|
30
|
+
startedAt: Date.now(),
|
|
31
|
+
lastHeartbeatAt: Date.now(),
|
|
32
|
+
endedAt: null,
|
|
33
|
+
status: "active",
|
|
34
|
+
costCents: 0,
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
describe("Reconciler stale-heartbeat detection", () => {
|
|
39
|
+
let dir;
|
|
40
|
+
let store;
|
|
41
|
+
let pool;
|
|
42
|
+
let client;
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
dir = mkdtempSync(join(tmpdir(), "reconcile-"));
|
|
45
|
+
store = new StateStore(join(dir, "state.json"));
|
|
46
|
+
pool = {
|
|
47
|
+
knownCardIds: () => new Set(),
|
|
48
|
+
isCardActive: vi.fn().mockReturnValue(false),
|
|
49
|
+
isCardKnown: vi.fn().mockReturnValue(false),
|
|
50
|
+
enqueue: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
removeCard: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
};
|
|
53
|
+
client = {
|
|
54
|
+
getBoard: vi.fn().mockResolvedValue({
|
|
55
|
+
cards: [],
|
|
56
|
+
columns: [{ id: "col-todo", name: "To Do" }],
|
|
57
|
+
labels: [],
|
|
58
|
+
}),
|
|
59
|
+
endAgentSession: vi.fn().mockResolvedValue(undefined),
|
|
60
|
+
getCard: vi.fn().mockResolvedValue({
|
|
61
|
+
card: {
|
|
62
|
+
id: "card-z",
|
|
63
|
+
short_id: 7,
|
|
64
|
+
project_id: "p",
|
|
65
|
+
column_id: "col-todo",
|
|
66
|
+
labelIds: [],
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
rmSync(dir, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
it("recovers foreign-daemon runs whose PID is dead", async () => {
|
|
75
|
+
await store.insertRun(makeRun({ daemonPid: 999_999_999 }));
|
|
76
|
+
const reconciler = new Reconciler(client, pool, "p", "agent-user", ["To Do"], [], "", 60_000, store, DEFAULT_AGENT_CONFIG);
|
|
77
|
+
// Exercise a single tick manually.
|
|
78
|
+
await reconciler.tick();
|
|
79
|
+
expect(client.endAgentSession).toHaveBeenCalledWith("card-z", expect.objectContaining({ status: "paused" }));
|
|
80
|
+
expect(store.getActiveRuns()).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
it("recovers our own runs whose worker lost track of the card", async () => {
|
|
83
|
+
// daemonPid === process.pid but pool does NOT have the card active
|
|
84
|
+
// and heartbeat is old. That's a zombie — should recover.
|
|
85
|
+
await store.insertRun(makeRun({
|
|
86
|
+
daemonPid: process.pid,
|
|
87
|
+
lastHeartbeatAt: Date.now() - 10 * 60_000, // 10 minutes ago
|
|
88
|
+
}));
|
|
89
|
+
const reconciler = new Reconciler(client, pool, "p", "agent-user", ["To Do"], [], "", 60_000, store, DEFAULT_AGENT_CONFIG);
|
|
90
|
+
await reconciler.tick();
|
|
91
|
+
expect(client.endAgentSession).toHaveBeenCalled();
|
|
92
|
+
expect(store.getActiveRuns()).toHaveLength(0);
|
|
93
|
+
});
|
|
94
|
+
it("does not touch our own runs that the pool still holds", async () => {
|
|
95
|
+
pool.isCardActive = vi.fn().mockReturnValue(true);
|
|
96
|
+
await store.insertRun(makeRun({
|
|
97
|
+
daemonPid: process.pid,
|
|
98
|
+
lastHeartbeatAt: Date.now() - 10 * 60_000,
|
|
99
|
+
}));
|
|
100
|
+
const reconciler = new Reconciler(client, pool, "p", "agent-user", ["To Do"], [], "", 60_000, store, DEFAULT_AGENT_CONFIG);
|
|
101
|
+
await reconciler.tick();
|
|
102
|
+
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
103
|
+
expect(store.getActiveRuns()).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
it("does not touch fresh runs even if foreign-pid checks match", async () => {
|
|
106
|
+
// Foreign daemon but pid is our own (alive) — not dead.
|
|
107
|
+
await store.insertRun(makeRun({
|
|
108
|
+
daemonPid: process.pid,
|
|
109
|
+
lastHeartbeatAt: Date.now(), // fresh
|
|
110
|
+
}));
|
|
111
|
+
pool.isCardActive = vi.fn().mockReturnValue(true);
|
|
112
|
+
const reconciler = new Reconciler(client, pool, "p", "agent-user", ["To Do"], [], "", 60_000, store, DEFAULT_AGENT_CONFIG);
|
|
113
|
+
await reconciler.tick();
|
|
114
|
+
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { recoverOrphans } from "../recovery.js";
|
|
6
|
+
import { newRunId, StateStore } from "../state-store.js";
|
|
7
|
+
import { DEFAULT_AGENT_CONFIG } from "../types.js";
|
|
8
|
+
// Mock worktree cleanup so tests don't shell out.
|
|
9
|
+
vi.mock("../worktree.js", () => ({
|
|
10
|
+
cleanupWorktree: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
// Mock board helpers so we can stub out network.
|
|
13
|
+
vi.mock("../board-helpers.js", () => ({
|
|
14
|
+
moveCardToColumn: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
addLabelByName: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
}));
|
|
17
|
+
import { addLabelByName, moveCardToColumn } from "../board-helpers.js";
|
|
18
|
+
import { cleanupWorktree } from "../worktree.js";
|
|
19
|
+
function makeClient(overrides = {}) {
|
|
20
|
+
return {
|
|
21
|
+
endAgentSession: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
getCard: vi.fn().mockResolvedValue({
|
|
23
|
+
card: {
|
|
24
|
+
id: "card-1",
|
|
25
|
+
short_id: 42,
|
|
26
|
+
project_id: "proj-1",
|
|
27
|
+
title: "test",
|
|
28
|
+
column_id: "col-1",
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function sampleRun(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
runId: newRunId(),
|
|
37
|
+
cardId: "card-1",
|
|
38
|
+
cardShortId: 42,
|
|
39
|
+
pipeline: "implement",
|
|
40
|
+
workerId: 0,
|
|
41
|
+
sessionId: null,
|
|
42
|
+
worktreePath: "/tmp/wt",
|
|
43
|
+
branchName: "agent/42-thing",
|
|
44
|
+
daemonPid: 999_999_999, // almost certainly not alive
|
|
45
|
+
phase: "running",
|
|
46
|
+
startedAt: Date.now(),
|
|
47
|
+
lastHeartbeatAt: Date.now(),
|
|
48
|
+
endedAt: null,
|
|
49
|
+
status: "active",
|
|
50
|
+
costCents: 0,
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
describe("recoverOrphans", () => {
|
|
55
|
+
let dir;
|
|
56
|
+
let store;
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
dir = mkdtempSync(join(tmpdir(), "recovery-"));
|
|
59
|
+
store = new StateStore(join(dir, "state.json"));
|
|
60
|
+
vi.clearAllMocks();
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
it("no-ops when there are no active runs", async () => {
|
|
66
|
+
const client = makeClient();
|
|
67
|
+
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
68
|
+
expect(outcomes).toEqual([]);
|
|
69
|
+
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
it("ends session, moves implement card, labels, and cleans worktree", async () => {
|
|
72
|
+
const run = sampleRun();
|
|
73
|
+
await store.insertRun(run);
|
|
74
|
+
const client = makeClient();
|
|
75
|
+
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
76
|
+
expect(outcomes).toHaveLength(1);
|
|
77
|
+
expect(outcomes[0].errors).toEqual([]);
|
|
78
|
+
expect(client.endAgentSession).toHaveBeenCalledWith("card-1", expect.objectContaining({ status: "paused" }));
|
|
79
|
+
expect(moveCardToColumn).toHaveBeenCalledWith(client, expect.objectContaining({ id: "card-1" }), DEFAULT_AGENT_CONFIG.pickupColumns[0]);
|
|
80
|
+
expect(addLabelByName).toHaveBeenCalledWith(client, expect.objectContaining({ id: "card-1" }), "agent-recovered", expect.any(String));
|
|
81
|
+
expect(cleanupWorktree).toHaveBeenCalledWith("/tmp/wt", "agent/42-thing");
|
|
82
|
+
expect(store.getRun(run.runId).status).toBe("orphaned");
|
|
83
|
+
});
|
|
84
|
+
it("does not move review cards back to a pickup column", async () => {
|
|
85
|
+
const run = sampleRun({ pipeline: "review" });
|
|
86
|
+
await store.insertRun(run);
|
|
87
|
+
const client = makeClient();
|
|
88
|
+
await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
89
|
+
expect(moveCardToColumn).not.toHaveBeenCalled();
|
|
90
|
+
expect(addLabelByName).toHaveBeenCalled();
|
|
91
|
+
expect(cleanupWorktree).toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
it("still closes out state when the card is not reachable", async () => {
|
|
94
|
+
const run = sampleRun();
|
|
95
|
+
await store.insertRun(run);
|
|
96
|
+
const client = makeClient({
|
|
97
|
+
getCard: vi.fn().mockRejectedValue(new Error("404")),
|
|
98
|
+
});
|
|
99
|
+
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
100
|
+
expect(outcomes[0].actions).toContain("card not reachable — local cleanup only");
|
|
101
|
+
expect(cleanupWorktree).toHaveBeenCalled();
|
|
102
|
+
expect(store.getRun(run.runId).status).toBe("orphaned");
|
|
103
|
+
});
|
|
104
|
+
it("skips runs that claim a live daemon pid", async () => {
|
|
105
|
+
const run = sampleRun({ daemonPid: process.pid });
|
|
106
|
+
await store.insertRun(run);
|
|
107
|
+
const client = makeClient();
|
|
108
|
+
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
109
|
+
expect(outcomes[0].actions).toContain("skipped: daemon pid still alive");
|
|
110
|
+
expect(client.endAgentSession).not.toHaveBeenCalled();
|
|
111
|
+
expect(store.getRun(run.runId).status).toBe("active");
|
|
112
|
+
});
|
|
113
|
+
it("continues past individual API failures and records them", async () => {
|
|
114
|
+
const run = sampleRun();
|
|
115
|
+
await store.insertRun(run);
|
|
116
|
+
const client = makeClient({
|
|
117
|
+
endAgentSession: vi.fn().mockRejectedValue(new Error("boom")),
|
|
118
|
+
});
|
|
119
|
+
const outcomes = await recoverOrphans(store, client, DEFAULT_AGENT_CONFIG);
|
|
120
|
+
expect(outcomes[0].errors).toEqual([
|
|
121
|
+
expect.stringContaining("endAgentSession: boom"),
|
|
122
|
+
]);
|
|
123
|
+
expect(cleanupWorktree).toHaveBeenCalled();
|
|
124
|
+
expect(store.getRun(run.runId).status).toBe("orphaned");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseReviewOutput } from "../review-completion.js";
|
|
3
|
+
describe("parseReviewOutput", () => {
|
|
4
|
+
it("parses verdict from ```json fenced block (strategy 1)", () => {
|
|
5
|
+
const out = `
|
|
6
|
+
Some preamble text.
|
|
7
|
+
|
|
8
|
+
\`\`\`json
|
|
9
|
+
{
|
|
10
|
+
"verdict": "approved",
|
|
11
|
+
"summary": "All good",
|
|
12
|
+
"findings": []
|
|
13
|
+
}
|
|
14
|
+
\`\`\`
|
|
15
|
+
`;
|
|
16
|
+
const r = parseReviewOutput(out);
|
|
17
|
+
expect(r.verdict).toBe("approved");
|
|
18
|
+
expect(r.summary).toBe("All good");
|
|
19
|
+
});
|
|
20
|
+
it("parses last fenced block when multiple are present", () => {
|
|
21
|
+
const out = `
|
|
22
|
+
\`\`\`json
|
|
23
|
+
{ "verdict": "rejected", "summary": "first" }
|
|
24
|
+
\`\`\`
|
|
25
|
+
|
|
26
|
+
Later correction:
|
|
27
|
+
|
|
28
|
+
\`\`\`json
|
|
29
|
+
{ "verdict": "approved", "summary": "final" }
|
|
30
|
+
\`\`\`
|
|
31
|
+
`;
|
|
32
|
+
const r = parseReviewOutput(out);
|
|
33
|
+
expect(r.verdict).toBe("approved");
|
|
34
|
+
expect(r.summary).toBe("final");
|
|
35
|
+
});
|
|
36
|
+
it("parses bare JSON object without fences (strategy 2)", () => {
|
|
37
|
+
const out = `Thinking...
|
|
38
|
+
{ "unrelated": true }
|
|
39
|
+
Result:
|
|
40
|
+
{ "verdict": "rejected", "summary": "bugs", "findings": [{ "title": "x", "severity": "major" }] }
|
|
41
|
+
`;
|
|
42
|
+
const r = parseReviewOutput(out);
|
|
43
|
+
expect(r.verdict).toBe("rejected");
|
|
44
|
+
expect(r.findings).toHaveLength(1);
|
|
45
|
+
expect(r.findings[0].title).toBe("x");
|
|
46
|
+
});
|
|
47
|
+
it("falls back to regex when JSON is malformed (strategy 3)", () => {
|
|
48
|
+
const out = `Claude rambling without proper JSON close
|
|
49
|
+
"verdict": "approved", "summary": "but missing braces everywhere
|
|
50
|
+
`;
|
|
51
|
+
const r = parseReviewOutput(out);
|
|
52
|
+
expect(r.verdict).toBe("approved");
|
|
53
|
+
expect(r.findings).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
it("returns error verdict when nothing parseable (strategy 4)", () => {
|
|
56
|
+
const out = "Just prose. No JSON. No verdict keyword.";
|
|
57
|
+
const r = parseReviewOutput(out);
|
|
58
|
+
expect(r.verdict).toBe("error");
|
|
59
|
+
});
|
|
60
|
+
it("handles empty output gracefully", () => {
|
|
61
|
+
const r = parseReviewOutput("");
|
|
62
|
+
expect(r.verdict).toBe("error");
|
|
63
|
+
expect(r.summary).toBe("");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { newRunId, StateStore } from "../state-store.js";
|
|
6
|
+
describe("StateStore", () => {
|
|
7
|
+
let dir;
|
|
8
|
+
let path;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), "agent-state-"));
|
|
11
|
+
path = join(dir, "state.json");
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(dir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
function sampleRun(overrides = {}) {
|
|
17
|
+
return {
|
|
18
|
+
runId: newRunId(),
|
|
19
|
+
cardId: "card-1",
|
|
20
|
+
cardShortId: 42,
|
|
21
|
+
pipeline: "implement",
|
|
22
|
+
workerId: 0,
|
|
23
|
+
sessionId: "sess-1",
|
|
24
|
+
worktreePath: "/tmp/wt",
|
|
25
|
+
branchName: "agent/42-thing",
|
|
26
|
+
daemonPid: process.pid,
|
|
27
|
+
phase: "preparing",
|
|
28
|
+
startedAt: Date.now(),
|
|
29
|
+
lastHeartbeatAt: Date.now(),
|
|
30
|
+
endedAt: null,
|
|
31
|
+
status: "active",
|
|
32
|
+
costCents: 0,
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
it("loads empty state when file does not exist", () => {
|
|
37
|
+
const store = new StateStore(path);
|
|
38
|
+
expect(store.getActiveRuns()).toEqual([]);
|
|
39
|
+
expect(store.getDaemon()).toEqual({
|
|
40
|
+
daemonId: null,
|
|
41
|
+
daemonPid: null,
|
|
42
|
+
daemonStartedAt: null,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
it("persists daemon metadata atomically", async () => {
|
|
46
|
+
const store = new StateStore(path);
|
|
47
|
+
await store.setDaemon("daemon-abc", 12345);
|
|
48
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
49
|
+
expect(raw.daemonId).toBe("daemon-abc");
|
|
50
|
+
expect(raw.daemonPid).toBe(12345);
|
|
51
|
+
});
|
|
52
|
+
it("inserts, heartbeats, and ends a run", async () => {
|
|
53
|
+
const store = new StateStore(path);
|
|
54
|
+
const run = sampleRun();
|
|
55
|
+
await store.insertRun(run);
|
|
56
|
+
expect(store.getActiveRuns()).toHaveLength(1);
|
|
57
|
+
expect(store.getRun(run.runId)?.cardId).toBe("card-1");
|
|
58
|
+
const before = store.getRun(run.runId).lastHeartbeatAt;
|
|
59
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
60
|
+
await store.heartbeat(run.runId);
|
|
61
|
+
expect(store.getRun(run.runId).lastHeartbeatAt).toBeGreaterThan(before);
|
|
62
|
+
await store.endRun(run.runId, "completed");
|
|
63
|
+
expect(store.getActiveRuns()).toHaveLength(0);
|
|
64
|
+
expect(store.getRun(run.runId).status).toBe("completed");
|
|
65
|
+
expect(store.getRun(run.runId).endedAt).not.toBeNull();
|
|
66
|
+
});
|
|
67
|
+
it("reloads persisted state after reopen", async () => {
|
|
68
|
+
const store = new StateStore(path);
|
|
69
|
+
await store.setDaemon("d1", 1);
|
|
70
|
+
await store.insertRun(sampleRun({ cardId: "card-7" }));
|
|
71
|
+
const reopened = new StateStore(path);
|
|
72
|
+
expect(reopened.getDaemon().daemonId).toBe("d1");
|
|
73
|
+
expect(reopened.getActiveRuns()[0].cardId).toBe("card-7");
|
|
74
|
+
});
|
|
75
|
+
it("tracks card attempts and resets on success", async () => {
|
|
76
|
+
const store = new StateStore(path);
|
|
77
|
+
expect(await store.incrementAttempt("c1")).toBe(1);
|
|
78
|
+
expect(await store.incrementAttempt("c1")).toBe(2);
|
|
79
|
+
await store.recordOutcome("c1", "failure");
|
|
80
|
+
expect(store.getCard("c1").attempts).toBe(2);
|
|
81
|
+
await store.recordOutcome("c1", "success");
|
|
82
|
+
expect(store.getCard("c1").attempts).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
it("aggregates cost per card and per UTC day", async () => {
|
|
85
|
+
const store = new StateStore(path);
|
|
86
|
+
await store.addCost("c1", 100);
|
|
87
|
+
await store.addCost("c1", 250);
|
|
88
|
+
await store.addCost("c2", 50);
|
|
89
|
+
expect(store.getCard("c1").totalCostCents).toBe(350);
|
|
90
|
+
expect(store.getCard("c2").totalCostCents).toBe(50);
|
|
91
|
+
expect(store.getDailyCostCents()).toBe(400);
|
|
92
|
+
});
|
|
93
|
+
it("manages DLQ status", async () => {
|
|
94
|
+
const store = new StateStore(path);
|
|
95
|
+
expect(store.isDlq("c1")).toBe(false);
|
|
96
|
+
await store.markDlq("c1", "build failed 3x");
|
|
97
|
+
expect(store.isDlq("c1")).toBe(true);
|
|
98
|
+
expect(store.listDlq()).toHaveLength(1);
|
|
99
|
+
expect(store.getCard("c1").dlqReason).toBe("build failed 3x");
|
|
100
|
+
await store.clearDlq("c1");
|
|
101
|
+
expect(store.isDlq("c1")).toBe(false);
|
|
102
|
+
expect(store.listDlq()).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
it("serializes concurrent writes via the write queue", async () => {
|
|
105
|
+
const store = new StateStore(path);
|
|
106
|
+
const run = sampleRun();
|
|
107
|
+
await store.insertRun(run);
|
|
108
|
+
await Promise.all([
|
|
109
|
+
store.heartbeat(run.runId),
|
|
110
|
+
store.updateRun(run.runId, { phase: "running" }),
|
|
111
|
+
store.addCost(run.cardId, 10),
|
|
112
|
+
store.addCost(run.cardId, 20),
|
|
113
|
+
]);
|
|
114
|
+
await store.flush();
|
|
115
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
116
|
+
expect(raw.runs[0].phase).toBe("running");
|
|
117
|
+
expect(raw.cards.find((c) => c.cardId === run.cardId)
|
|
118
|
+
.totalCostCents).toBe(30);
|
|
119
|
+
});
|
|
120
|
+
it("handles corrupted state files by starting fresh", () => {
|
|
121
|
+
const brokenPath = join(dir, "broken.json");
|
|
122
|
+
require("node:fs").writeFileSync(brokenPath, "{not valid json", "utf-8");
|
|
123
|
+
const store = new StateStore(brokenPath);
|
|
124
|
+
expect(store.getActiveRuns()).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
it("migrates away from unknown schema versions", () => {
|
|
127
|
+
const future = join(dir, "future.json");
|
|
128
|
+
require("node:fs").writeFileSync(future, JSON.stringify({ version: 99, runs: [{}] }), "utf-8");
|
|
129
|
+
const store = new StateStore(future);
|
|
130
|
+
expect(store.getActiveRuns()).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { runTransition, TransitionError } from "../transitions.js";
|
|
3
|
+
function makeBoard() {
|
|
4
|
+
return {
|
|
5
|
+
columns: [
|
|
6
|
+
{ id: "col-todo", name: "To Do" },
|
|
7
|
+
{ id: "col-progress", name: "In Progress" },
|
|
8
|
+
{ id: "col-review", name: "Review" },
|
|
9
|
+
{ id: "col-done", name: "Done" },
|
|
10
|
+
],
|
|
11
|
+
labels: [
|
|
12
|
+
{ id: "lbl-agent", name: "agent" },
|
|
13
|
+
{ id: "lbl-dlq", name: "dlq" },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function makeCard(overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
id: "card-1",
|
|
20
|
+
short_id: 42,
|
|
21
|
+
project_id: "proj",
|
|
22
|
+
title: "t",
|
|
23
|
+
column_id: "col-todo",
|
|
24
|
+
labelIds: [],
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function makeClient(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
getBoard: vi.fn().mockResolvedValue(makeBoard()),
|
|
31
|
+
moveCard: vi.fn().mockResolvedValue({}),
|
|
32
|
+
addLabelToCard: vi.fn().mockResolvedValue({}),
|
|
33
|
+
removeLabelFromCard: vi.fn().mockResolvedValue({}),
|
|
34
|
+
createLabel: vi.fn().mockResolvedValue({ label: { id: "lbl-new" } }),
|
|
35
|
+
updateCard: vi.fn().mockResolvedValue({}),
|
|
36
|
+
endAgentSession: vi.fn().mockResolvedValue({}),
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
describe("runTransition", () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
});
|
|
44
|
+
it("moves, labels, and ends session in order", async () => {
|
|
45
|
+
const client = makeClient();
|
|
46
|
+
const card = makeCard();
|
|
47
|
+
await runTransition(client, card, {
|
|
48
|
+
move: { columnName: "In Progress" },
|
|
49
|
+
addLabels: [{ name: "agent" }],
|
|
50
|
+
endSession: { status: "completed", progressPercent: 100 },
|
|
51
|
+
});
|
|
52
|
+
expect(client.moveCard).toHaveBeenCalledWith("card-1", "col-progress");
|
|
53
|
+
expect(client.addLabelToCard).toHaveBeenCalledWith("card-1", "lbl-agent");
|
|
54
|
+
expect(client.endAgentSession).toHaveBeenCalledWith("card-1", {
|
|
55
|
+
status: "completed",
|
|
56
|
+
progressPercent: 100,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
it("is idempotent when the card is already in the target column", async () => {
|
|
60
|
+
const client = makeClient();
|
|
61
|
+
const card = makeCard({ column_id: "col-progress" });
|
|
62
|
+
await runTransition(client, card, { move: { columnName: "In Progress" } });
|
|
63
|
+
expect(client.moveCard).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
it("skips adding a label the card already has", async () => {
|
|
66
|
+
const client = makeClient();
|
|
67
|
+
const card = makeCard({ labelIds: ["lbl-agent"] });
|
|
68
|
+
await runTransition(client, card, { addLabels: [{ name: "agent" }] });
|
|
69
|
+
expect(client.addLabelToCard).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
it("creates missing labels on demand", async () => {
|
|
72
|
+
const client = makeClient();
|
|
73
|
+
const card = makeCard();
|
|
74
|
+
await runTransition(client, card, {
|
|
75
|
+
addLabels: [{ name: "agent-recovered", color: "#ff0000" }],
|
|
76
|
+
});
|
|
77
|
+
expect(client.createLabel).toHaveBeenCalledWith("proj", {
|
|
78
|
+
name: "agent-recovered",
|
|
79
|
+
color: "#ff0000",
|
|
80
|
+
});
|
|
81
|
+
expect(client.addLabelToCard).toHaveBeenCalledWith("card-1", "lbl-new");
|
|
82
|
+
});
|
|
83
|
+
it("removes labels idempotently", async () => {
|
|
84
|
+
const client = makeClient();
|
|
85
|
+
const card = makeCard({ labelIds: ["lbl-agent", "lbl-dlq"] });
|
|
86
|
+
await runTransition(client, card, { removeLabels: ["agent", "nope"] });
|
|
87
|
+
expect(client.removeLabelFromCard).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(client.removeLabelFromCard).toHaveBeenCalledWith("card-1", "lbl-agent");
|
|
89
|
+
});
|
|
90
|
+
it("retries a flaky step before giving up", async () => {
|
|
91
|
+
const moveCard = vi
|
|
92
|
+
.fn()
|
|
93
|
+
.mockRejectedValueOnce(new Error("502"))
|
|
94
|
+
.mockRejectedValueOnce(new Error("502"))
|
|
95
|
+
.mockResolvedValueOnce({});
|
|
96
|
+
const client = makeClient({ moveCard });
|
|
97
|
+
await runTransition(client, makeCard(), { move: { columnName: "Review" } }, { retries: 3, backoffMs: 1 });
|
|
98
|
+
expect(moveCard).toHaveBeenCalledTimes(3);
|
|
99
|
+
});
|
|
100
|
+
it("throws TransitionError with the failing step after retries exhausted", async () => {
|
|
101
|
+
const endAgentSession = vi
|
|
102
|
+
.fn()
|
|
103
|
+
.mockRejectedValue(new Error("upstream down"));
|
|
104
|
+
const client = makeClient({ endAgentSession });
|
|
105
|
+
await expect(runTransition(client, makeCard(), { endSession: { status: "paused" } }, { retries: 2, backoffMs: 1 })).rejects.toMatchObject({
|
|
106
|
+
name: "TransitionError",
|
|
107
|
+
step: "endSession",
|
|
108
|
+
attempts: 2,
|
|
109
|
+
});
|
|
110
|
+
expect(endAgentSession).toHaveBeenCalledTimes(2);
|
|
111
|
+
});
|
|
112
|
+
it("warns but continues when target column is missing in non-strict mode", async () => {
|
|
113
|
+
const client = makeClient();
|
|
114
|
+
await expect(runTransition(client, makeCard(), {
|
|
115
|
+
move: { columnName: "No Such Column" },
|
|
116
|
+
})).resolves.toBeUndefined();
|
|
117
|
+
expect(client.moveCard).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
it("throws when target column is missing in strict mode", async () => {
|
|
120
|
+
const client = makeClient();
|
|
121
|
+
await expect(runTransition(client, makeCard(), { move: { columnName: "No Such Column" } }, { strictColumn: true })).rejects.toBeInstanceOf(TransitionError);
|
|
122
|
+
});
|
|
123
|
+
it("heartbeats the state store when wired", async () => {
|
|
124
|
+
const client = makeClient();
|
|
125
|
+
const heartbeat = vi.fn().mockResolvedValue(undefined);
|
|
126
|
+
const store = { heartbeat };
|
|
127
|
+
await runTransition(client, makeCard(), { move: { columnName: "Done" } }, { store, runId: "r_1" });
|
|
128
|
+
expect(heartbeat).toHaveBeenCalledWith("r_1");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|