@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.
Files changed (37) hide show
  1. package/README.md +359 -195
  2. package/index.ts +10 -10
  3. package/openclaw.plugin.json +4 -1
  4. package/package.json +9 -2
  5. package/src/agent/agent.test.ts +127 -0
  6. package/src/{agent.ts → agent/agent.ts} +84 -7
  7. package/src/agent/watchdog.test.ts +266 -0
  8. package/src/agent/watchdog.ts +176 -0
  9. package/src/{cli.ts → infra/cli.ts} +32 -5
  10. package/src/{codex-worktree.ts → infra/codex-worktree.ts} +1 -1
  11. package/src/infra/doctor.test.ts +399 -0
  12. package/src/infra/doctor.ts +781 -0
  13. package/src/infra/notify.test.ts +169 -0
  14. package/src/{notify.ts → infra/notify.ts} +6 -1
  15. package/src/pipeline/active-session.test.ts +154 -0
  16. package/src/pipeline/artifacts.test.ts +383 -0
  17. package/src/{artifacts.ts → pipeline/artifacts.ts} +9 -1
  18. package/src/{dispatch-service.ts → pipeline/dispatch-service.ts} +1 -1
  19. package/src/pipeline/dispatch-state.test.ts +382 -0
  20. package/src/pipeline/pipeline.test.ts +226 -0
  21. package/src/{pipeline.ts → pipeline/pipeline.ts} +61 -7
  22. package/src/{tier-assess.ts → pipeline/tier-assess.ts} +1 -1
  23. package/src/{webhook.test.ts → pipeline/webhook.test.ts} +1 -1
  24. package/src/{webhook.ts → pipeline/webhook.ts} +8 -8
  25. package/src/{claude-tool.ts → tools/claude-tool.ts} +31 -5
  26. package/src/{cli-shared.ts → tools/cli-shared.ts} +5 -4
  27. package/src/{code-tool.ts → tools/code-tool.ts} +2 -2
  28. package/src/{codex-tool.ts → tools/codex-tool.ts} +31 -5
  29. package/src/{gemini-tool.ts → tools/gemini-tool.ts} +31 -5
  30. package/src/{orchestration-tools.ts → tools/orchestration-tools.ts} +1 -1
  31. package/src/client.ts +0 -94
  32. /package/src/{auth.ts → api/auth.ts} +0 -0
  33. /package/src/{linear-api.ts → api/linear-api.ts} +0 -0
  34. /package/src/{oauth-callback.ts → api/oauth-callback.ts} +0 -0
  35. /package/src/{active-session.ts → pipeline/active-session.ts} +0 -0
  36. /package/src/{dispatch-state.ts → pipeline/dispatch-state.ts} +0 -0
  37. /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"; // stale detection
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
+ });