@gethmy/agent 1.3.0 → 1.4.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/dist/cli.js +0 -0
- package/dist/review-worker.js +25 -0
- package/package.json +2 -2
- package/dist/__tests__/budget.test.d.ts +0 -1
- package/dist/__tests__/budget.test.js +0 -94
- package/dist/__tests__/config-validation.test.d.ts +0 -1
- package/dist/__tests__/config-validation.test.js +0 -65
- package/dist/__tests__/dev-server-readiness.test.d.ts +0 -1
- package/dist/__tests__/dev-server-readiness.test.js +0 -26
- package/dist/__tests__/http-server.test.d.ts +0 -1
- package/dist/__tests__/http-server.test.js +0 -115
- package/dist/__tests__/log.test.d.ts +0 -1
- package/dist/__tests__/log.test.js +0 -115
- package/dist/__tests__/merge-monitor.test.d.ts +0 -1
- package/dist/__tests__/merge-monitor.test.js +0 -107
- package/dist/__tests__/process-group.test.d.ts +0 -1
- package/dist/__tests__/process-group.test.js +0 -88
- package/dist/__tests__/progress-tracker.test.d.ts +0 -1
- package/dist/__tests__/progress-tracker.test.js +0 -247
- package/dist/__tests__/reconcile-heartbeat.test.d.ts +0 -1
- package/dist/__tests__/reconcile-heartbeat.test.js +0 -116
- package/dist/__tests__/recovery.test.d.ts +0 -1
- package/dist/__tests__/recovery.test.js +0 -126
- package/dist/__tests__/review-parser.test.d.ts +0 -1
- package/dist/__tests__/review-parser.test.js +0 -65
- package/dist/__tests__/state-store.test.d.ts +0 -1
- package/dist/__tests__/state-store.test.js +0 -132
- package/dist/__tests__/stream-parser-selftest.test.d.ts +0 -1
- package/dist/__tests__/stream-parser-selftest.test.js +0 -23
- package/dist/__tests__/stream-parser.test.d.ts +0 -1
- package/dist/__tests__/stream-parser.test.js +0 -199
- package/dist/__tests__/transitions.test.d.ts +0 -1
- package/dist/__tests__/transitions.test.js +0 -130
- package/dist/__tests__/worktree-gc.test.d.ts +0 -1
- package/dist/__tests__/worktree-gc.test.js +0 -137
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/review-worker.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
|
|
3
4
|
import { buildTokenPayload } from "./completion.js";
|
|
4
5
|
import { log } from "./log.js";
|
|
@@ -214,6 +215,30 @@ export class ReviewWorker {
|
|
|
214
215
|
};
|
|
215
216
|
const systemPrompt = buildReviewSystemPrompt();
|
|
216
217
|
const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diff, this.config.worktree.baseBranch);
|
|
218
|
+
// AGP P2: persist a session-linked snapshot of the review system
|
|
219
|
+
// prompt so outcome feedback can credit / penalise the framing.
|
|
220
|
+
// Best-effort — never block review on logging.
|
|
221
|
+
try {
|
|
222
|
+
const sessionResp = await this.client.getAgentSession(card.id);
|
|
223
|
+
const reviewSession = sessionResp.session;
|
|
224
|
+
const reviewSessionId = reviewSession?.id ?? null;
|
|
225
|
+
const contentHash = createHash("sha256")
|
|
226
|
+
.update(systemPrompt)
|
|
227
|
+
.digest("hex");
|
|
228
|
+
await this.client.recordPromptHistory({
|
|
229
|
+
cardId: card.id,
|
|
230
|
+
generatedPrompt: systemPrompt,
|
|
231
|
+
variant: "execute",
|
|
232
|
+
contextIncluded: { source: "review-knowledge", mode: "review" },
|
|
233
|
+
sessionId: reviewSessionId,
|
|
234
|
+
contentHash,
|
|
235
|
+
templateVersion: 1,
|
|
236
|
+
confidence: 0.5,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
log.warn(this.tag, `prompt_history persistence skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
241
|
+
}
|
|
217
242
|
await this.client.updateAgentProgress(card.id, {
|
|
218
243
|
agentIdentifier: agentIdentifier(this.id),
|
|
219
244
|
agentName: `${AGENT_NAME} (Review)`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Push-based agent daemon for Harmony — watches board assignments and spawns Claude CLI workers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"scripts": {
|
|
39
39
|
"start": "node dist/index.js",
|
|
40
40
|
"prebuild": "cd ../harmony-shared && bun run build && cd ../memory && bun run build",
|
|
41
|
-
"build": "tsc",
|
|
41
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
42
42
|
"typecheck": "tsc --noEmit",
|
|
43
43
|
"prepublishOnly": "npm run build",
|
|
44
44
|
"test": "vitest run"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,94 +0,0 @@
|
|
|
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 { BudgetGuard } from "../budget.js";
|
|
6
|
-
import { StateStore } from "../state-store.js";
|
|
7
|
-
import { DEFAULT_AGENT_CONFIG } from "../types.js";
|
|
8
|
-
describe("BudgetGuard", () => {
|
|
9
|
-
let dir;
|
|
10
|
-
let store;
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
dir = mkdtempSync(join(tmpdir(), "budget-"));
|
|
13
|
-
store = new StateStore(join(dir, "state.json"));
|
|
14
|
-
});
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
rmSync(dir, { recursive: true, force: true });
|
|
17
|
-
});
|
|
18
|
-
function makeGuard(overrides = {}) {
|
|
19
|
-
return new BudgetGuard({ ...DEFAULT_AGENT_CONFIG.budget, ...overrides }, store);
|
|
20
|
-
}
|
|
21
|
-
it("allows a fresh card with no history", () => {
|
|
22
|
-
const guard = makeGuard();
|
|
23
|
-
expect(guard.check("c1")).toEqual({ allow: true });
|
|
24
|
-
});
|
|
25
|
-
it("blocks a card already marked DLQ", async () => {
|
|
26
|
-
await store.markDlq("c1", "upstream test");
|
|
27
|
-
const guard = makeGuard();
|
|
28
|
-
const decision = guard.check("c1");
|
|
29
|
-
expect(decision).toMatchObject({ allow: false, reason: "dlq" });
|
|
30
|
-
});
|
|
31
|
-
it("blocks after attempts >= max", async () => {
|
|
32
|
-
await store.incrementAttempt("c1");
|
|
33
|
-
await store.incrementAttempt("c1");
|
|
34
|
-
await store.incrementAttempt("c1");
|
|
35
|
-
const guard = makeGuard({ maxAttemptsPerCard: 3 });
|
|
36
|
-
const decision = guard.check("c1");
|
|
37
|
-
expect(decision).toMatchObject({
|
|
38
|
-
allow: false,
|
|
39
|
-
reason: "max_attempts",
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
it("allows again after recordOutcome('success') resets attempts", async () => {
|
|
43
|
-
await store.incrementAttempt("c1");
|
|
44
|
-
await store.incrementAttempt("c1");
|
|
45
|
-
await store.recordOutcome("c1", "success");
|
|
46
|
-
const guard = makeGuard({ maxAttemptsPerCard: 2 });
|
|
47
|
-
expect(guard.check("c1")).toEqual({ allow: true });
|
|
48
|
-
});
|
|
49
|
-
it("blocks on per-card cost cap", async () => {
|
|
50
|
-
await store.addCost("c1", 600);
|
|
51
|
-
const guard = makeGuard({ maxCentsPerCard: 500 });
|
|
52
|
-
expect(guard.check("c1")).toMatchObject({
|
|
53
|
-
allow: false,
|
|
54
|
-
reason: "card_cost_cap",
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
it("blocks on daily budget exhaustion", async () => {
|
|
58
|
-
await store.addCost("c1", 5000);
|
|
59
|
-
const guard = makeGuard({ dailyBudgetCents: 5000 });
|
|
60
|
-
// Different card, but daily aggregate already at cap.
|
|
61
|
-
expect(guard.check("c2")).toMatchObject({
|
|
62
|
-
allow: false,
|
|
63
|
-
reason: "daily_budget",
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
it("treats DLQ, max_attempts, card_cost_cap as terminal (daily_budget is not)", () => {
|
|
67
|
-
const guard = makeGuard();
|
|
68
|
-
expect(guard.isTerminal("dlq")).toBe(true);
|
|
69
|
-
expect(guard.isTerminal("max_attempts")).toBe(true);
|
|
70
|
-
expect(guard.isTerminal("card_cost_cap")).toBe(true);
|
|
71
|
-
expect(guard.isTerminal("daily_budget")).toBe(false);
|
|
72
|
-
});
|
|
73
|
-
it("markDlq persists state + applies label via runTransition", async () => {
|
|
74
|
-
const guard = makeGuard();
|
|
75
|
-
const client = {
|
|
76
|
-
getBoard: vi.fn().mockResolvedValue({
|
|
77
|
-
columns: [{ id: "col-1", name: "To Do" }],
|
|
78
|
-
labels: [],
|
|
79
|
-
}),
|
|
80
|
-
createLabel: vi.fn().mockResolvedValue({ label: { id: "lbl-dlq" } }),
|
|
81
|
-
addLabelToCard: vi.fn().mockResolvedValue({}),
|
|
82
|
-
};
|
|
83
|
-
const card = {
|
|
84
|
-
id: "c1",
|
|
85
|
-
short_id: 99,
|
|
86
|
-
project_id: "p",
|
|
87
|
-
column_id: "col-1",
|
|
88
|
-
labelIds: [],
|
|
89
|
-
};
|
|
90
|
-
await guard.markDlq(client, card, "max_attempts", "3 attempts");
|
|
91
|
-
expect(store.isDlq("c1")).toBe(true);
|
|
92
|
-
expect(client.addLabelToCard).toHaveBeenCalledWith("c1", "lbl-dlq");
|
|
93
|
-
});
|
|
94
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { ConfigValidationError, validateColumnReferences, } from "../config-validation.js";
|
|
3
|
-
import { DEFAULT_AGENT_CONFIG } from "../types.js";
|
|
4
|
-
function makeClient(columns) {
|
|
5
|
-
return {
|
|
6
|
-
getBoard: vi.fn().mockResolvedValue({
|
|
7
|
-
columns: columns.map((name, i) => ({ id: `col-${i}`, name })),
|
|
8
|
-
labels: [],
|
|
9
|
-
}),
|
|
10
|
-
};
|
|
11
|
-
}
|
|
12
|
-
describe("validateColumnReferences", () => {
|
|
13
|
-
it("passes when all configured columns exist (case-insensitive)", async () => {
|
|
14
|
-
const client = makeClient([
|
|
15
|
-
"to do",
|
|
16
|
-
"In Progress",
|
|
17
|
-
"REVIEW",
|
|
18
|
-
"Done",
|
|
19
|
-
"Needs Fix",
|
|
20
|
-
]);
|
|
21
|
-
await expect(validateColumnReferences(client, "proj", DEFAULT_AGENT_CONFIG)).resolves.toBeUndefined();
|
|
22
|
-
});
|
|
23
|
-
it("throws a typed error listing every missing column", async () => {
|
|
24
|
-
const client = makeClient(["To Do", "Review", "Done"]);
|
|
25
|
-
const config = {
|
|
26
|
-
...DEFAULT_AGENT_CONFIG,
|
|
27
|
-
verification: {
|
|
28
|
-
...DEFAULT_AGENT_CONFIG.verification,
|
|
29
|
-
failColumn: "Needs Fix",
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
try {
|
|
33
|
-
await validateColumnReferences(client, "proj", config);
|
|
34
|
-
throw new Error("expected rejection");
|
|
35
|
-
}
|
|
36
|
-
catch (err) {
|
|
37
|
-
expect(err).toBeInstanceOf(ConfigValidationError);
|
|
38
|
-
const issues = err.issues;
|
|
39
|
-
expect(issues.some((i) => i.includes("Needs Fix"))).toBe(true);
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
it("skips review column checks when review is disabled", async () => {
|
|
43
|
-
const client = makeClient(["To Do", "Review", "Needs Fix"]);
|
|
44
|
-
const config = {
|
|
45
|
-
...DEFAULT_AGENT_CONFIG,
|
|
46
|
-
completion: {
|
|
47
|
-
...DEFAULT_AGENT_CONFIG.completion,
|
|
48
|
-
moveToColumn: "Review",
|
|
49
|
-
},
|
|
50
|
-
review: { ...DEFAULT_AGENT_CONFIG.review, enabled: false },
|
|
51
|
-
};
|
|
52
|
-
await expect(validateColumnReferences(client, "proj", config)).resolves.toBeUndefined();
|
|
53
|
-
});
|
|
54
|
-
it("reports the missing column along with the config path", async () => {
|
|
55
|
-
const client = makeClient(["To Do", "In Progress", "Review", "Done"]);
|
|
56
|
-
const config = {
|
|
57
|
-
...DEFAULT_AGENT_CONFIG,
|
|
58
|
-
verification: {
|
|
59
|
-
...DEFAULT_AGENT_CONFIG.verification,
|
|
60
|
-
failColumn: "DOES NOT EXIST",
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
await expect(validateColumnReferences(client, "proj", config)).rejects.toThrow(/verification\.failColumn: column "DOES NOT EXIST"/);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { DevServerReadinessError, waitForDevServer } from "../verification.js";
|
|
4
|
-
describe("waitForDevServer", () => {
|
|
5
|
-
it("resolves when readiness signal appears on stdout", async () => {
|
|
6
|
-
// node -e "console.log('server ready on localhost:4200'); setTimeout(()=>{}, 1000)"
|
|
7
|
-
const proc = spawn(process.execPath, [
|
|
8
|
-
"-e",
|
|
9
|
-
"console.log('server ready on localhost:4200'); setTimeout(() => {}, 1000);",
|
|
10
|
-
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
11
|
-
await expect(waitForDevServer(proc, 2000)).resolves.toBeUndefined();
|
|
12
|
-
proc.kill("SIGKILL");
|
|
13
|
-
});
|
|
14
|
-
it("rejects (does NOT resolve) on timeout", async () => {
|
|
15
|
-
// Silent child; no readiness signal.
|
|
16
|
-
const proc = spawn(process.execPath, ["-e", "setTimeout(() => {}, 5000);"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
17
|
-
await expect(waitForDevServer(proc, 100)).rejects.toBeInstanceOf(DevServerReadinessError);
|
|
18
|
-
proc.kill("SIGKILL");
|
|
19
|
-
});
|
|
20
|
-
it("rejects if the process exits before becoming ready", async () => {
|
|
21
|
-
const proc = spawn(process.execPath, ["-e", "process.exit(7);"], {
|
|
22
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
23
|
-
});
|
|
24
|
-
await expect(waitForDevServer(proc, 5000)).rejects.toBeInstanceOf(DevServerReadinessError);
|
|
25
|
-
});
|
|
26
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { HttpServer, } from "../http-server.js";
|
|
3
|
-
function baseStatus() {
|
|
4
|
-
return {
|
|
5
|
-
daemonId: "d-1",
|
|
6
|
-
daemonPid: 1234,
|
|
7
|
-
startedAt: Date.now(),
|
|
8
|
-
uptimeMs: 0,
|
|
9
|
-
workers: [],
|
|
10
|
-
implQueue: [],
|
|
11
|
-
reviewQueue: [],
|
|
12
|
-
dlq: [],
|
|
13
|
-
budget: { todayCents: 0, dailyCapCents: 5000 },
|
|
14
|
-
};
|
|
15
|
-
}
|
|
16
|
-
describe("HttpServer", () => {
|
|
17
|
-
let server;
|
|
18
|
-
let port;
|
|
19
|
-
let getHealth;
|
|
20
|
-
let handleCommand;
|
|
21
|
-
let clearDlq;
|
|
22
|
-
beforeEach(async () => {
|
|
23
|
-
// Ephemeral port via 0 doesn't round-trip through the options — use
|
|
24
|
-
// a random high port and retry on collision (rare in CI).
|
|
25
|
-
port = 40000 + Math.floor(Math.random() * 10000);
|
|
26
|
-
getHealth = () => ({ healthy: true, checks: { watcher: true } });
|
|
27
|
-
handleCommand = vi.fn().mockResolvedValue(undefined);
|
|
28
|
-
clearDlq = vi.fn().mockResolvedValue(undefined);
|
|
29
|
-
server = new HttpServer({
|
|
30
|
-
port,
|
|
31
|
-
bindAddr: "127.0.0.1",
|
|
32
|
-
getHealth: () => getHealth(),
|
|
33
|
-
getStatus: () => baseStatus(),
|
|
34
|
-
handleCommand: (c, id) => handleCommand(c, id),
|
|
35
|
-
clearDlq: (id) => clearDlq(id),
|
|
36
|
-
});
|
|
37
|
-
await server.start();
|
|
38
|
-
});
|
|
39
|
-
afterEach(async () => {
|
|
40
|
-
await server.stop();
|
|
41
|
-
});
|
|
42
|
-
async function get(path) {
|
|
43
|
-
return fetch(`http://127.0.0.1:${port}${path}`);
|
|
44
|
-
}
|
|
45
|
-
async function post(path) {
|
|
46
|
-
return fetch(`http://127.0.0.1:${port}${path}`, { method: "POST" });
|
|
47
|
-
}
|
|
48
|
-
it("GET /health returns 200 when healthy", async () => {
|
|
49
|
-
const res = await get("/health");
|
|
50
|
-
expect(res.status).toBe(200);
|
|
51
|
-
const body = await res.json();
|
|
52
|
-
expect(body.healthy).toBe(true);
|
|
53
|
-
});
|
|
54
|
-
it("GET /health returns 503 when unhealthy", async () => {
|
|
55
|
-
getHealth = () => ({
|
|
56
|
-
healthy: false,
|
|
57
|
-
checks: { watcher: false, reconciler: true },
|
|
58
|
-
});
|
|
59
|
-
const res = await get("/health");
|
|
60
|
-
expect(res.status).toBe(503);
|
|
61
|
-
const body = await res.json();
|
|
62
|
-
expect(body.checks.watcher).toBe(false);
|
|
63
|
-
});
|
|
64
|
-
it("GET /status returns the snapshot shape", async () => {
|
|
65
|
-
const res = await get("/status");
|
|
66
|
-
expect(res.status).toBe(200);
|
|
67
|
-
const body = await res.json();
|
|
68
|
-
expect(body).toHaveProperty("daemonId", "d-1");
|
|
69
|
-
expect(body).toHaveProperty("workers");
|
|
70
|
-
expect(body).toHaveProperty("implQueue");
|
|
71
|
-
expect(body).toHaveProperty("dlq");
|
|
72
|
-
expect(body).toHaveProperty("budget");
|
|
73
|
-
});
|
|
74
|
-
it("POST /pause/:cardId dispatches to handleCommand", async () => {
|
|
75
|
-
const res = await post("/pause/abc-123");
|
|
76
|
-
expect(res.status).toBe(200);
|
|
77
|
-
expect(handleCommand).toHaveBeenCalledWith("pause", "abc-123");
|
|
78
|
-
});
|
|
79
|
-
it("POST /resume/:cardId dispatches correctly", async () => {
|
|
80
|
-
const res = await post("/resume/xyz");
|
|
81
|
-
expect(res.status).toBe(200);
|
|
82
|
-
expect(handleCommand).toHaveBeenCalledWith("resume", "xyz");
|
|
83
|
-
});
|
|
84
|
-
it("POST /stop/:cardId dispatches correctly", async () => {
|
|
85
|
-
const res = await post("/stop/c1");
|
|
86
|
-
expect(res.status).toBe(200);
|
|
87
|
-
expect(handleCommand).toHaveBeenCalledWith("stop", "c1");
|
|
88
|
-
});
|
|
89
|
-
it("returns 404 for unknown paths", async () => {
|
|
90
|
-
const res = await get("/does-not-exist");
|
|
91
|
-
expect(res.status).toBe(404);
|
|
92
|
-
});
|
|
93
|
-
it("POST /dlq/clear/:cardId routes through the daemon", async () => {
|
|
94
|
-
const res = await post("/dlq/clear/card-42");
|
|
95
|
-
expect(res.status).toBe(200);
|
|
96
|
-
expect(clearDlq).toHaveBeenCalledWith("card-42");
|
|
97
|
-
const body = await res.json();
|
|
98
|
-
expect(body.ok).toBe(true);
|
|
99
|
-
expect(body.cardId).toBe("card-42");
|
|
100
|
-
});
|
|
101
|
-
it("POST /dlq/clear returns 500 with detail on failure", async () => {
|
|
102
|
-
clearDlq = vi.fn().mockRejectedValue(new Error("state store locked"));
|
|
103
|
-
const res = await post("/dlq/clear/card-99");
|
|
104
|
-
expect(res.status).toBe(500);
|
|
105
|
-
const body = await res.json();
|
|
106
|
-
expect(body.detail).toContain("state store locked");
|
|
107
|
-
});
|
|
108
|
-
it("returns 500 with detail when handleCommand throws", async () => {
|
|
109
|
-
handleCommand = vi.fn().mockRejectedValue(new Error("worker gone"));
|
|
110
|
-
const res = await post("/stop/c1");
|
|
111
|
-
expect(res.status).toBe(500);
|
|
112
|
-
const body = await res.json();
|
|
113
|
-
expect(body.detail).toContain("worker gone");
|
|
114
|
-
});
|
|
115
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
describe("log", () => {
|
|
3
|
-
let writes;
|
|
4
|
-
let origWrite;
|
|
5
|
-
let origArgv;
|
|
6
|
-
beforeEach(() => {
|
|
7
|
-
writes = [];
|
|
8
|
-
origWrite = process.stderr.write;
|
|
9
|
-
origArgv = process.argv;
|
|
10
|
-
process.stderr.write = ((chunk) => {
|
|
11
|
-
writes.push(chunk);
|
|
12
|
-
return true;
|
|
13
|
-
});
|
|
14
|
-
delete process.env.HARMONY_AGENT_PRETTY;
|
|
15
|
-
delete process.env.HARMONY_AGENT_JSON;
|
|
16
|
-
delete process.env.DEBUG;
|
|
17
|
-
vi.resetModules();
|
|
18
|
-
});
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
process.stderr.write = origWrite;
|
|
21
|
-
process.argv = origArgv;
|
|
22
|
-
});
|
|
23
|
-
it("emits JSON lines when stderr is not a TTY", async () => {
|
|
24
|
-
// Vitest stderr is not a TTY by default; no need to force.
|
|
25
|
-
const { log } = await import("../log.js");
|
|
26
|
-
log.info("worker", "hello", { cardId: "c1", runId: "r1" });
|
|
27
|
-
const parsed = JSON.parse(writes[0]);
|
|
28
|
-
expect(parsed).toMatchObject({
|
|
29
|
-
level: "info",
|
|
30
|
-
tag: "worker",
|
|
31
|
-
msg: "hello",
|
|
32
|
-
cardId: "c1",
|
|
33
|
-
runId: "r1",
|
|
34
|
-
});
|
|
35
|
-
expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
36
|
-
});
|
|
37
|
-
it("emits pretty output when stderr is a TTY", async () => {
|
|
38
|
-
const originalIsTTY = process.stderr.isTTY;
|
|
39
|
-
Object.defineProperty(process.stderr, "isTTY", {
|
|
40
|
-
value: true,
|
|
41
|
-
configurable: true,
|
|
42
|
-
});
|
|
43
|
-
try {
|
|
44
|
-
const { log } = await import("../log.js");
|
|
45
|
-
log.info("daemon", "up");
|
|
46
|
-
expect(() => JSON.parse(writes[0])).toThrow();
|
|
47
|
-
expect(writes[0]).toContain("INFO");
|
|
48
|
-
expect(writes[0]).toContain("[daemon]");
|
|
49
|
-
}
|
|
50
|
-
finally {
|
|
51
|
-
Object.defineProperty(process.stderr, "isTTY", {
|
|
52
|
-
value: originalIsTTY,
|
|
53
|
-
configurable: true,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
it("--json forces JSON even on a TTY", async () => {
|
|
58
|
-
const originalIsTTY = process.stderr.isTTY;
|
|
59
|
-
Object.defineProperty(process.stderr, "isTTY", {
|
|
60
|
-
value: true,
|
|
61
|
-
configurable: true,
|
|
62
|
-
});
|
|
63
|
-
process.argv = [...process.argv, "--json"];
|
|
64
|
-
try {
|
|
65
|
-
const { log } = await import("../log.js");
|
|
66
|
-
log.info("daemon", "up");
|
|
67
|
-
const parsed = JSON.parse(writes[0]);
|
|
68
|
-
expect(parsed.msg).toBe("up");
|
|
69
|
-
}
|
|
70
|
-
finally {
|
|
71
|
-
Object.defineProperty(process.stderr, "isTTY", {
|
|
72
|
-
value: originalIsTTY,
|
|
73
|
-
configurable: true,
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
it("HARMONY_AGENT_JSON=1 beats HARMONY_AGENT_PRETTY=1", async () => {
|
|
78
|
-
process.env.HARMONY_AGENT_PRETTY = "1";
|
|
79
|
-
process.env.HARMONY_AGENT_JSON = "1";
|
|
80
|
-
const { log } = await import("../log.js");
|
|
81
|
-
log.info("daemon", "hi");
|
|
82
|
-
const parsed = JSON.parse(writes[0]);
|
|
83
|
-
expect(parsed.msg).toBe("hi");
|
|
84
|
-
});
|
|
85
|
-
it("suppresses debug when DEBUG is unset", async () => {
|
|
86
|
-
const { log } = await import("../log.js");
|
|
87
|
-
log.debug("x", "quiet");
|
|
88
|
-
expect(writes).toEqual([]);
|
|
89
|
-
});
|
|
90
|
-
it("emits debug when DEBUG is set", async () => {
|
|
91
|
-
process.env.DEBUG = "1";
|
|
92
|
-
const { log } = await import("../log.js");
|
|
93
|
-
log.debug("x", "loud");
|
|
94
|
-
expect(writes).toHaveLength(1);
|
|
95
|
-
const parsed = JSON.parse(writes[0]);
|
|
96
|
-
expect(parsed.level).toBe("debug");
|
|
97
|
-
});
|
|
98
|
-
it("log.event attaches the event field", async () => {
|
|
99
|
-
const { log } = await import("../log.js");
|
|
100
|
-
log.event("pool", "card_enqueued", { cardId: "c2" });
|
|
101
|
-
const parsed = JSON.parse(writes[0]);
|
|
102
|
-
expect(parsed.event).toBe("card_enqueued");
|
|
103
|
-
expect(parsed.msg).toBe("card_enqueued");
|
|
104
|
-
expect(parsed.cardId).toBe("c2");
|
|
105
|
-
});
|
|
106
|
-
it("switches to ANSI pretty output under HARMONY_AGENT_PRETTY=1", async () => {
|
|
107
|
-
process.env.HARMONY_AGENT_PRETTY = "1";
|
|
108
|
-
const { log } = await import("../log.js");
|
|
109
|
-
log.warn("daemon", "heads up");
|
|
110
|
-
// Pretty output is not JSON — should fail parse and contain WARN label
|
|
111
|
-
expect(() => JSON.parse(writes[0])).toThrow();
|
|
112
|
-
expect(writes[0]).toContain("WARN");
|
|
113
|
-
expect(writes[0]).toContain("[daemon]");
|
|
114
|
-
});
|
|
115
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { MergeMonitor } from "../merge-monitor.js";
|
|
3
|
-
vi.mock("../git-pr.js", () => ({
|
|
4
|
-
detectGitProvider: () => "github",
|
|
5
|
-
extractPrUrl: () => "https://example/pr/1",
|
|
6
|
-
checkPrMergeStatus: vi.fn().mockResolvedValue("merged"),
|
|
7
|
-
}));
|
|
8
|
-
vi.mock("node:child_process", () => ({
|
|
9
|
-
execFile: (_a, _b, _c, cb) => cb(null),
|
|
10
|
-
}));
|
|
11
|
-
function makeCard(overrides = {}) {
|
|
12
|
-
return {
|
|
13
|
-
id: "card-1",
|
|
14
|
-
short_id: 42,
|
|
15
|
-
project_id: "proj",
|
|
16
|
-
title: "Card",
|
|
17
|
-
description: "branch: agent/foo\n\nhttps://example/pr/1",
|
|
18
|
-
column_id: "col-review",
|
|
19
|
-
labelIds: ["lbl-approved"],
|
|
20
|
-
done: false,
|
|
21
|
-
archived_at: null,
|
|
22
|
-
...overrides,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
function makeConfig() {
|
|
26
|
-
return {
|
|
27
|
-
review: {
|
|
28
|
-
enabled: true,
|
|
29
|
-
pickupColumns: ["Review"],
|
|
30
|
-
moveToColumn: "Done",
|
|
31
|
-
failColumn: "To Do",
|
|
32
|
-
devServerPort: 4300,
|
|
33
|
-
maxTimeout: 600_000,
|
|
34
|
-
postFindings: true,
|
|
35
|
-
maxReviewCycles: 3,
|
|
36
|
-
createPR: true,
|
|
37
|
-
approvedLabel: "Ready to Merge",
|
|
38
|
-
approvedLabelColor: "#22c55e",
|
|
39
|
-
mergeMonitor: true,
|
|
40
|
-
mergedLabel: "Merged",
|
|
41
|
-
mergedLabelColor: "#6366f1",
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
function makeBoard(markCardsDone) {
|
|
46
|
-
return {
|
|
47
|
-
columns: [
|
|
48
|
-
{ id: "col-review", name: "Review", mark_cards_done: false },
|
|
49
|
-
{ id: "col-done", name: "Done", mark_cards_done: markCardsDone },
|
|
50
|
-
],
|
|
51
|
-
labels: [
|
|
52
|
-
{ id: "lbl-approved", name: "Ready to Merge" },
|
|
53
|
-
{ id: "lbl-merged", name: "Merged" },
|
|
54
|
-
],
|
|
55
|
-
cards: [makeCard()],
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
function makeClient(markCardsDone) {
|
|
59
|
-
return {
|
|
60
|
-
getBoard: vi.fn().mockResolvedValue(makeBoard(markCardsDone)),
|
|
61
|
-
moveCard: vi.fn().mockResolvedValue({}),
|
|
62
|
-
addLabelToCard: vi.fn().mockResolvedValue({}),
|
|
63
|
-
removeLabelFromCard: vi.fn().mockResolvedValue({}),
|
|
64
|
-
createLabel: vi.fn().mockResolvedValue({ label: { id: "lbl-new" } }),
|
|
65
|
-
updateCard: vi.fn().mockResolvedValue({}),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
function approvedLabel() {
|
|
69
|
-
return { id: "lbl-approved", name: "Ready to Merge" };
|
|
70
|
-
}
|
|
71
|
-
function asPrivate(monitor) {
|
|
72
|
-
return monitor;
|
|
73
|
-
}
|
|
74
|
-
describe("MergeMonitor.completeMergedCard", () => {
|
|
75
|
-
beforeEach(() => {
|
|
76
|
-
vi.clearAllMocks();
|
|
77
|
-
});
|
|
78
|
-
it("does NOT force done:true when Done column has mark_cards_done=false", async () => {
|
|
79
|
-
const client = makeClient(false);
|
|
80
|
-
const monitor = asPrivate(new MergeMonitor(client, "proj", makeConfig()));
|
|
81
|
-
await monitor.completeMergedCard(makeCard(), [approvedLabel()]);
|
|
82
|
-
const updateCalls = client.updateCard.mock.calls;
|
|
83
|
-
for (const [, payload] of updateCalls) {
|
|
84
|
-
expect(payload).not.toHaveProperty("done");
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
it("moves to the configured Done column and updates description with merge timestamp", async () => {
|
|
88
|
-
const client = makeClient(true);
|
|
89
|
-
const monitor = asPrivate(new MergeMonitor(client, "proj", makeConfig()));
|
|
90
|
-
await monitor.completeMergedCard(makeCard(), [approvedLabel()]);
|
|
91
|
-
expect(client.moveCard).toHaveBeenCalledWith("card-1", "col-done");
|
|
92
|
-
const descUpdate = client.updateCard.mock.calls.find(([, p]) => typeof p.description === "string" &&
|
|
93
|
-
p.description.includes("Merged at"));
|
|
94
|
-
expect(descUpdate).toBeDefined();
|
|
95
|
-
});
|
|
96
|
-
it("does not re-append merge timestamp when description already has one", async () => {
|
|
97
|
-
const client = makeClient(true);
|
|
98
|
-
const monitor = asPrivate(new MergeMonitor(client, "proj", makeConfig()));
|
|
99
|
-
const card = makeCard({
|
|
100
|
-
description: "branch: agent/foo\n\nMerged at 2026-01-01T00:00:00Z",
|
|
101
|
-
});
|
|
102
|
-
await monitor.completeMergedCard(card, [approvedLabel()]);
|
|
103
|
-
for (const [, payload] of client.updateCard.mock.calls) {
|
|
104
|
-
expect(payload).not.toHaveProperty("description");
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|