@calltelemetry/openclaw-linear 0.5.2 → 0.6.1
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 +359 -195
- package/index.ts +10 -10
- package/openclaw.plugin.json +4 -1
- package/package.json +9 -2
- package/src/agent/agent.test.ts +127 -0
- package/src/{agent.ts → agent/agent.ts} +84 -7
- package/src/agent/watchdog.test.ts +266 -0
- package/src/agent/watchdog.ts +176 -0
- package/src/{cli.ts → infra/cli.ts} +32 -5
- package/src/{codex-worktree.ts → infra/codex-worktree.ts} +1 -1
- package/src/infra/doctor.test.ts +399 -0
- package/src/infra/doctor.ts +781 -0
- package/src/infra/notify.test.ts +169 -0
- package/src/{notify.ts → infra/notify.ts} +6 -1
- package/src/pipeline/active-session.test.ts +154 -0
- package/src/pipeline/artifacts.test.ts +383 -0
- package/src/{artifacts.ts → pipeline/artifacts.ts} +9 -1
- package/src/{dispatch-service.ts → pipeline/dispatch-service.ts} +1 -1
- package/src/pipeline/dispatch-state.test.ts +382 -0
- package/src/pipeline/pipeline.test.ts +226 -0
- package/src/{pipeline.ts → pipeline/pipeline.ts} +61 -7
- package/src/{tier-assess.ts → pipeline/tier-assess.ts} +1 -1
- package/src/{webhook.test.ts → pipeline/webhook.test.ts} +1 -1
- package/src/{webhook.ts → pipeline/webhook.ts} +8 -8
- package/src/{claude-tool.ts → tools/claude-tool.ts} +31 -5
- package/src/{cli-shared.ts → tools/cli-shared.ts} +5 -4
- package/src/{code-tool.ts → tools/code-tool.ts} +2 -2
- package/src/{codex-tool.ts → tools/codex-tool.ts} +31 -5
- package/src/{gemini-tool.ts → tools/gemini-tool.ts} +31 -5
- package/src/{orchestration-tools.ts → tools/orchestration-tools.ts} +1 -1
- package/src/client.ts +0 -94
- /package/src/{auth.ts → api/auth.ts} +0 -0
- /package/src/{linear-api.ts → api/linear-api.ts} +0 -0
- /package/src/{oauth-callback.ts → api/oauth-callback.ts} +0 -0
- /package/src/{active-session.ts → pipeline/active-session.ts} +0 -0
- /package/src/{dispatch-state.ts → pipeline/dispatch-state.ts} +0 -0
- /package/src/{tools.ts → tools/tools.ts} +0 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createNoopNotifier,
|
|
4
|
+
createDiscordNotifier,
|
|
5
|
+
type NotifyPayload,
|
|
6
|
+
} from "./notify.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Noop notifier
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
describe("createNoopNotifier", () => {
|
|
13
|
+
it("returns function that resolves without error", async () => {
|
|
14
|
+
const notify = createNoopNotifier();
|
|
15
|
+
await expect(notify("dispatch", {
|
|
16
|
+
identifier: "API-1",
|
|
17
|
+
title: "test",
|
|
18
|
+
status: "dispatched",
|
|
19
|
+
})).resolves.toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Discord notifier
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
describe("createDiscordNotifier", () => {
|
|
28
|
+
const botToken = "test-bot-token";
|
|
29
|
+
const channelId = "123456";
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function stubFetch(): { getCalls: () => { url: string; body: any }[] } {
|
|
36
|
+
const calls: { url: string; body: any }[] = [];
|
|
37
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string, opts: any) => {
|
|
38
|
+
calls.push({ url, body: JSON.parse(opts.body) });
|
|
39
|
+
return { ok: true, status: 200 } as Response;
|
|
40
|
+
}));
|
|
41
|
+
return { getCalls: () => calls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const basePayload: NotifyPayload = {
|
|
45
|
+
identifier: "API-42",
|
|
46
|
+
title: "Fix auth",
|
|
47
|
+
status: "dispatched",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
it("formats dispatch message", async () => {
|
|
51
|
+
const { getCalls } = stubFetch();
|
|
52
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
53
|
+
await notify("dispatch", basePayload);
|
|
54
|
+
expect(getCalls()).toHaveLength(1);
|
|
55
|
+
const msg = getCalls()[0].body.content;
|
|
56
|
+
expect(msg).toContain("**API-42**");
|
|
57
|
+
expect(msg).toContain("dispatched");
|
|
58
|
+
expect(msg).toContain("Fix auth");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("formats working message with attempt", async () => {
|
|
62
|
+
const { getCalls } = stubFetch();
|
|
63
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
64
|
+
await notify("working", { ...basePayload, status: "working", attempt: 1 });
|
|
65
|
+
const msg = getCalls()[0].body.content;
|
|
66
|
+
expect(msg).toContain("worker started");
|
|
67
|
+
expect(msg).toContain("attempt 1");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("formats audit_pass message", async () => {
|
|
71
|
+
const { getCalls } = stubFetch();
|
|
72
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
73
|
+
await notify("audit_pass", { ...basePayload, status: "done", verdict: { pass: true } });
|
|
74
|
+
const msg = getCalls()[0].body.content;
|
|
75
|
+
expect(msg).toContain("passed audit");
|
|
76
|
+
expect(msg).toContain("PR ready");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("formats audit_fail message with gaps", async () => {
|
|
80
|
+
const { getCalls } = stubFetch();
|
|
81
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
82
|
+
await notify("audit_fail", {
|
|
83
|
+
...basePayload,
|
|
84
|
+
status: "working",
|
|
85
|
+
attempt: 1,
|
|
86
|
+
verdict: { pass: false, gaps: ["no tests", "missing validation"] },
|
|
87
|
+
});
|
|
88
|
+
const msg = getCalls()[0].body.content;
|
|
89
|
+
expect(msg).toContain("failed audit");
|
|
90
|
+
expect(msg).toContain("no tests");
|
|
91
|
+
expect(msg).toContain("missing validation");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("formats escalation message", async () => {
|
|
95
|
+
const { getCalls } = stubFetch();
|
|
96
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
97
|
+
await notify("escalation", {
|
|
98
|
+
...basePayload,
|
|
99
|
+
status: "stuck",
|
|
100
|
+
reason: "audit failed 3x",
|
|
101
|
+
});
|
|
102
|
+
const msg = getCalls()[0].body.content;
|
|
103
|
+
expect(msg).toContain("needs human review");
|
|
104
|
+
expect(msg).toContain("audit failed 3x");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("formats watchdog_kill message", async () => {
|
|
108
|
+
const { getCalls } = stubFetch();
|
|
109
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
110
|
+
await notify("watchdog_kill", {
|
|
111
|
+
...basePayload,
|
|
112
|
+
status: "stuck",
|
|
113
|
+
attempt: 0,
|
|
114
|
+
reason: "no I/O for 120s",
|
|
115
|
+
});
|
|
116
|
+
const msg = getCalls()[0].body.content;
|
|
117
|
+
expect(msg).toContain("killed by watchdog");
|
|
118
|
+
expect(msg).toContain("no I/O for 120s");
|
|
119
|
+
expect(msg).toContain("attempt 0");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("handles fetch failure gracefully", async () => {
|
|
123
|
+
vi.stubGlobal("fetch", vi.fn(async () => {
|
|
124
|
+
throw new Error("network error");
|
|
125
|
+
}));
|
|
126
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
127
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
128
|
+
// Should not throw
|
|
129
|
+
await expect(notify("dispatch", basePayload)).resolves.toBeUndefined();
|
|
130
|
+
consoleSpy.mockRestore();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("sends to correct Discord API URL", async () => {
|
|
134
|
+
const { getCalls } = stubFetch();
|
|
135
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
136
|
+
await notify("dispatch", basePayload);
|
|
137
|
+
expect(getCalls()[0].url).toContain(`/channels/${channelId}/messages`);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("formats auditing message", async () => {
|
|
141
|
+
const { getCalls } = stubFetch();
|
|
142
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
143
|
+
await notify("auditing", { ...basePayload, status: "auditing" });
|
|
144
|
+
const msg = getCalls()[0].body.content;
|
|
145
|
+
expect(msg).toContain("audit in progress");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("formats stuck message", async () => {
|
|
149
|
+
const { getCalls } = stubFetch();
|
|
150
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
151
|
+
await notify("stuck", { ...basePayload, status: "stuck", reason: "stale 2h" });
|
|
152
|
+
const msg = getCalls()[0].body.content;
|
|
153
|
+
expect(msg).toContain("stuck");
|
|
154
|
+
expect(msg).toContain("stale 2h");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("handles non-ok response gracefully", async () => {
|
|
158
|
+
vi.stubGlobal("fetch", vi.fn(async () => ({
|
|
159
|
+
ok: false,
|
|
160
|
+
status: 429,
|
|
161
|
+
text: async () => "rate limited",
|
|
162
|
+
})));
|
|
163
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
164
|
+
const notify = createDiscordNotifier(botToken, channelId);
|
|
165
|
+
await expect(notify("dispatch", basePayload)).resolves.toBeUndefined();
|
|
166
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
167
|
+
consoleSpy.mockRestore();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -17,7 +17,8 @@ export type NotifyKind =
|
|
|
17
17
|
| "audit_pass" // audit passed → done
|
|
18
18
|
| "audit_fail" // audit failed → rework
|
|
19
19
|
| "escalation" // 2x fail or stale → stuck
|
|
20
|
-
| "stuck"
|
|
20
|
+
| "stuck" // stale detection
|
|
21
|
+
| "watchdog_kill"; // agent killed by inactivity watchdog
|
|
21
22
|
|
|
22
23
|
export interface NotifyPayload {
|
|
23
24
|
identifier: string;
|
|
@@ -55,6 +56,10 @@ function formatDiscordMessage(kind: NotifyKind, payload: NotifyPayload): string
|
|
|
55
56
|
return `🚨 ${prefix} needs human review — ${payload.reason ?? "audit failed 2x"}`;
|
|
56
57
|
case "stuck":
|
|
57
58
|
return `⏰ ${prefix} stuck — ${payload.reason ?? "stale 2h"}`;
|
|
59
|
+
case "watchdog_kill":
|
|
60
|
+
return `⚡ ${prefix} killed by watchdog (${payload.reason ?? "no I/O for 120s"}). ${
|
|
61
|
+
payload.attempt != null ? `Retrying (attempt ${payload.attempt}).` : "Will retry."
|
|
62
|
+
}`;
|
|
58
63
|
default:
|
|
59
64
|
return `${prefix} — ${kind}: ${payload.status}`;
|
|
60
65
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
setActiveSession,
|
|
7
|
+
clearActiveSession,
|
|
8
|
+
getActiveSession,
|
|
9
|
+
getActiveSessionByIdentifier,
|
|
10
|
+
getCurrentSession,
|
|
11
|
+
getSessionCount,
|
|
12
|
+
hydrateFromDispatchState,
|
|
13
|
+
type ActiveSession,
|
|
14
|
+
} from "./active-session.js";
|
|
15
|
+
|
|
16
|
+
function makeSession(overrides?: Partial<ActiveSession>): ActiveSession {
|
|
17
|
+
return {
|
|
18
|
+
agentSessionId: "sess-1",
|
|
19
|
+
issueIdentifier: "API-100",
|
|
20
|
+
issueId: "uuid-1",
|
|
21
|
+
startedAt: Date.now(),
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Clean up after each test to avoid cross-contamination
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
// Clear all known sessions
|
|
29
|
+
clearActiveSession("uuid-1");
|
|
30
|
+
clearActiveSession("uuid-2");
|
|
31
|
+
clearActiveSession("uuid-3");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("set + get", () => {
|
|
35
|
+
it("round-trip by issueId", () => {
|
|
36
|
+
const session = makeSession();
|
|
37
|
+
setActiveSession(session);
|
|
38
|
+
const found = getActiveSession("uuid-1");
|
|
39
|
+
expect(found).not.toBeNull();
|
|
40
|
+
expect(found!.issueIdentifier).toBe("API-100");
|
|
41
|
+
expect(found!.agentSessionId).toBe("sess-1");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns null for unknown issueId", () => {
|
|
45
|
+
expect(getActiveSession("no-such-id")).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("clearActiveSession", () => {
|
|
50
|
+
it("removes session", () => {
|
|
51
|
+
setActiveSession(makeSession());
|
|
52
|
+
clearActiveSession("uuid-1");
|
|
53
|
+
expect(getActiveSession("uuid-1")).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("getActiveSessionByIdentifier", () => {
|
|
58
|
+
it("finds by identifier string", () => {
|
|
59
|
+
setActiveSession(makeSession({ issueIdentifier: "API-200", issueId: "uuid-2" }));
|
|
60
|
+
const found = getActiveSessionByIdentifier("API-200");
|
|
61
|
+
expect(found).not.toBeNull();
|
|
62
|
+
expect(found!.issueId).toBe("uuid-2");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns null for unknown identifier", () => {
|
|
66
|
+
expect(getActiveSessionByIdentifier("NOPE-999")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("getCurrentSession", () => {
|
|
71
|
+
it("returns session when exactly 1", () => {
|
|
72
|
+
setActiveSession(makeSession());
|
|
73
|
+
const current = getCurrentSession();
|
|
74
|
+
expect(current).not.toBeNull();
|
|
75
|
+
expect(current!.issueId).toBe("uuid-1");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns null when 0 sessions", () => {
|
|
79
|
+
expect(getCurrentSession()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns null when >1 sessions", () => {
|
|
83
|
+
setActiveSession(makeSession({ issueId: "uuid-1" }));
|
|
84
|
+
setActiveSession(makeSession({ issueId: "uuid-2", issueIdentifier: "API-200" }));
|
|
85
|
+
expect(getCurrentSession()).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("getSessionCount", () => {
|
|
90
|
+
it("reflects current count", () => {
|
|
91
|
+
expect(getSessionCount()).toBe(0);
|
|
92
|
+
setActiveSession(makeSession());
|
|
93
|
+
expect(getSessionCount()).toBe(1);
|
|
94
|
+
setActiveSession(makeSession({ issueId: "uuid-2", issueIdentifier: "API-200" }));
|
|
95
|
+
expect(getSessionCount()).toBe(2);
|
|
96
|
+
clearActiveSession("uuid-1");
|
|
97
|
+
expect(getSessionCount()).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("hydrateFromDispatchState", () => {
|
|
102
|
+
it("restores working dispatches from state file", async () => {
|
|
103
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-hydrate-"));
|
|
104
|
+
const statePath = join(dir, "state.json");
|
|
105
|
+
writeFileSync(statePath, JSON.stringify({
|
|
106
|
+
dispatches: {
|
|
107
|
+
active: {
|
|
108
|
+
"API-300": {
|
|
109
|
+
issueId: "uuid-300",
|
|
110
|
+
issueIdentifier: "API-300",
|
|
111
|
+
worktreePath: "/tmp/wt/API-300",
|
|
112
|
+
branch: "codex/API-300",
|
|
113
|
+
tier: "junior",
|
|
114
|
+
model: "test",
|
|
115
|
+
status: "working",
|
|
116
|
+
dispatchedAt: "2026-01-01T00:00:00Z",
|
|
117
|
+
attempt: 0,
|
|
118
|
+
},
|
|
119
|
+
"API-301": {
|
|
120
|
+
issueId: "uuid-301",
|
|
121
|
+
issueIdentifier: "API-301",
|
|
122
|
+
worktreePath: "/tmp/wt/API-301",
|
|
123
|
+
branch: "codex/API-301",
|
|
124
|
+
tier: "junior",
|
|
125
|
+
model: "test",
|
|
126
|
+
status: "done",
|
|
127
|
+
dispatchedAt: "2026-01-01T00:00:00Z",
|
|
128
|
+
attempt: 1,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
completed: {},
|
|
132
|
+
},
|
|
133
|
+
sessionMap: {},
|
|
134
|
+
processedEvents: [],
|
|
135
|
+
}), "utf-8");
|
|
136
|
+
|
|
137
|
+
const restored = await hydrateFromDispatchState(statePath);
|
|
138
|
+
// Only "working" and "dispatched" are restored, not "done"
|
|
139
|
+
expect(restored).toBe(1);
|
|
140
|
+
expect(getActiveSession("uuid-300")).not.toBeNull();
|
|
141
|
+
expect(getActiveSession("uuid-300")!.issueIdentifier).toBe("API-300");
|
|
142
|
+
expect(getActiveSession("uuid-301")).toBeNull();
|
|
143
|
+
|
|
144
|
+
// Cleanup
|
|
145
|
+
clearActiveSession("uuid-300");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns 0 when no active dispatches", async () => {
|
|
149
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-hydrate-"));
|
|
150
|
+
const statePath = join(dir, "state.json");
|
|
151
|
+
const restored = await hydrateFromDispatchState(statePath);
|
|
152
|
+
expect(restored).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
});
|