@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,176 @@
1
+ /**
2
+ * watchdog.ts — I/O inactivity watchdog for agent sessions.
3
+ *
4
+ * Resets a countdown timer on every tick(). If no tick arrives within
5
+ * the inactivity threshold, fires onKill(). Also provides a config
6
+ * resolver that reads per-agent timeouts from agent-profiles.json.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Defaults (seconds — matches config units)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const DEFAULT_INACTIVITY_SEC = 120; // 2 min
16
+ export const DEFAULT_MAX_TOTAL_SEC = 7200; // 2 hrs
17
+ export const DEFAULT_TOOL_TIMEOUT_SEC = 600; // 10 min
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Watchdog
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type WatchdogKillReason = "inactivity";
24
+
25
+ export interface WatchdogOptions {
26
+ /** Inactivity threshold in ms. */
27
+ inactivityMs: number;
28
+ /** Label for logging (e.g. "embedded:zoe:session-123"). */
29
+ label: string;
30
+ /** Logger interface. */
31
+ logger: { info(msg: string): void; warn(msg: string): void };
32
+ /** Called when inactivity timeout fires. Must kill the process/abort. */
33
+ onKill: (reason: WatchdogKillReason) => void | Promise<void>;
34
+ }
35
+
36
+ export class InactivityWatchdog {
37
+ private timer: ReturnType<typeof setTimeout> | null = null;
38
+ private readonly inactivityMs: number;
39
+ private readonly label: string;
40
+ private readonly logger: WatchdogOptions["logger"];
41
+ private readonly onKill: WatchdogOptions["onKill"];
42
+ private lastActivityAt: number = Date.now();
43
+ private killed = false;
44
+ private started = false;
45
+
46
+ constructor(opts: WatchdogOptions) {
47
+ this.inactivityMs = opts.inactivityMs;
48
+ this.label = opts.label;
49
+ this.logger = opts.logger;
50
+ this.onKill = opts.onKill;
51
+ }
52
+
53
+ /** Start the watchdog. Call after the process/run is launched. */
54
+ start(): void {
55
+ if (this.started) return;
56
+ this.started = true;
57
+ this.lastActivityAt = Date.now();
58
+ this.scheduleCheck();
59
+ this.logger.info(`Watchdog started: ${this.label} (inactivity=${this.inactivityMs}ms)`);
60
+ }
61
+
62
+ /** Record an I/O activity tick. Resets the inactivity countdown. */
63
+ tick(): void {
64
+ this.lastActivityAt = Date.now();
65
+ }
66
+
67
+ /** Stop the watchdog (normal completion). */
68
+ stop(): void {
69
+ if (this.timer) {
70
+ clearTimeout(this.timer);
71
+ this.timer = null;
72
+ }
73
+ this.started = false;
74
+ }
75
+
76
+ /** Whether the watchdog triggered a kill. */
77
+ get wasKilled(): boolean {
78
+ return this.killed;
79
+ }
80
+
81
+ /** Milliseconds since last activity. */
82
+ get silenceMs(): number {
83
+ return Date.now() - this.lastActivityAt;
84
+ }
85
+
86
+ private scheduleCheck(): void {
87
+ const remaining = Math.max(1000, this.inactivityMs - (Date.now() - this.lastActivityAt));
88
+ this.timer = setTimeout(() => {
89
+ if (this.killed || !this.started) return;
90
+
91
+ const silence = Date.now() - this.lastActivityAt;
92
+ if (silence >= this.inactivityMs) {
93
+ this.killed = true;
94
+ this.logger.warn(
95
+ `Watchdog KILL: ${this.label} — no I/O for ${Math.round(silence / 1000)}s ` +
96
+ `(threshold: ${this.inactivityMs / 1000}s)`,
97
+ );
98
+ try {
99
+ const result = this.onKill("inactivity");
100
+ if (result && typeof (result as Promise<void>).catch === "function") {
101
+ (result as Promise<void>).catch((err) => {
102
+ this.logger.warn(`Watchdog onKill error: ${err}`);
103
+ });
104
+ }
105
+ } catch (err) {
106
+ this.logger.warn(`Watchdog onKill error: ${err}`);
107
+ }
108
+ } else {
109
+ // Activity happened during the wait — reschedule for remaining time
110
+ this.scheduleCheck();
111
+ }
112
+ }, remaining);
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Config resolution
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export interface WatchdogConfig {
121
+ inactivityMs: number;
122
+ maxTotalMs: number;
123
+ toolTimeoutMs: number;
124
+ }
125
+
126
+ interface AgentProfileWatchdog {
127
+ inactivitySec?: number;
128
+ maxTotalSec?: number;
129
+ toolTimeoutSec?: number;
130
+ }
131
+
132
+ const PROFILES_PATH = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
133
+
134
+ function loadProfileWatchdog(agentId: string): AgentProfileWatchdog | null {
135
+ try {
136
+ const raw = readFileSync(PROFILES_PATH, "utf8");
137
+ const profiles = JSON.parse(raw).agents ?? {};
138
+ return profiles[agentId]?.watchdog ?? null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Resolve watchdog config for an agent.
146
+ *
147
+ * Priority: agent-profiles.json → plugin config → hardcoded defaults.
148
+ * All config values are in seconds; output is in ms.
149
+ */
150
+ export function resolveWatchdogConfig(
151
+ agentId: string,
152
+ pluginConfig?: Record<string, unknown>,
153
+ ): WatchdogConfig {
154
+ const profile = loadProfileWatchdog(agentId);
155
+
156
+ const inactivitySec =
157
+ profile?.inactivitySec ??
158
+ (pluginConfig?.inactivitySec as number | undefined) ??
159
+ DEFAULT_INACTIVITY_SEC;
160
+
161
+ const maxTotalSec =
162
+ profile?.maxTotalSec ??
163
+ (pluginConfig?.maxTotalSec as number | undefined) ??
164
+ DEFAULT_MAX_TOTAL_SEC;
165
+
166
+ const toolTimeoutSec =
167
+ profile?.toolTimeoutSec ??
168
+ (pluginConfig?.toolTimeoutSec as number | undefined) ??
169
+ DEFAULT_TOOL_TIMEOUT_SEC;
170
+
171
+ return {
172
+ inactivityMs: inactivitySec * 1000,
173
+ maxTotalMs: maxTotalSec * 1000,
174
+ toolTimeoutMs: toolTimeoutSec * 1000,
175
+ };
176
+ }
@@ -9,10 +9,10 @@ import { readFileSync, writeFileSync } from "node:fs";
9
9
  import { readFileSync as readFileSyncFs, existsSync } from "node:fs";
10
10
  import { join, dirname } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
- import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "./linear-api.js";
13
- import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "./auth.js";
12
+ import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
13
+ import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "../api/auth.js";
14
14
  import { listWorktrees } from "./codex-worktree.js";
15
- import { loadPrompts, clearPromptCache } from "./pipeline.js";
15
+ import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
16
16
 
17
17
  function prompt(question: string): Promise<string> {
18
18
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -262,7 +262,7 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
262
262
  ? customPath.replace("~", process.env.HOME ?? "")
263
263
  : customPath;
264
264
  } else {
265
- const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
265
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
266
266
  resolvedPath = join(pluginRoot, "prompts.yaml");
267
267
  }
268
268
 
@@ -293,7 +293,7 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
293
293
  ? customPath.replace("~", process.env.HOME ?? "")
294
294
  : customPath;
295
295
  } else {
296
- const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
296
+ const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
297
297
  resolvedPath = join(pluginRoot, "prompts.yaml");
298
298
  }
299
299
 
@@ -342,4 +342,31 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
342
342
  process.exitCode = 1;
343
343
  }
344
344
  });
345
+
346
+ // --- openclaw openclaw-linear doctor ---
347
+ linear
348
+ .command("doctor")
349
+ .description("Run comprehensive health checks on the Linear plugin")
350
+ .option("--fix", "Auto-fix safe issues (chmod, stale locks, prune old dispatches)")
351
+ .option("--json", "Output results as JSON")
352
+ .action(async (opts: { fix?: boolean; json?: boolean }) => {
353
+ const { runDoctor, formatReport, formatReportJson } = await import("./doctor.js");
354
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
355
+
356
+ const report = await runDoctor({
357
+ fix: opts.fix ?? false,
358
+ json: opts.json ?? false,
359
+ pluginConfig,
360
+ });
361
+
362
+ if (opts.json) {
363
+ console.log(formatReportJson(report));
364
+ } else {
365
+ console.log(formatReport(report));
366
+ }
367
+
368
+ if (report.summary.errors > 0) {
369
+ process.exitCode = 1;
370
+ }
371
+ });
345
372
  }
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
- import { ensureGitignore } from "./artifacts.js";
5
+ import { ensureGitignore } from "../pipeline/artifacts.js";
6
6
 
7
7
  const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
8
8
  const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
@@ -0,0 +1,399 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+
6
+ // Mock heavy cross-module imports to isolate doctor checks
7
+ vi.mock("../api/linear-api.js", () => ({
8
+ resolveLinearToken: vi.fn(() => ({
9
+ accessToken: "lin_test_token_123",
10
+ refreshToken: "refresh_token",
11
+ expiresAt: Date.now() + 24 * 3_600_000,
12
+ source: "profile" as const,
13
+ })),
14
+ AUTH_PROFILES_PATH: "/tmp/test-auth-profiles.json",
15
+ LINEAR_GRAPHQL_URL: "https://api.linear.app/graphql",
16
+ }));
17
+
18
+ vi.mock("../pipeline/dispatch-state.js", () => ({
19
+ readDispatchState: vi.fn(async () => ({
20
+ dispatches: { active: {}, completed: {} },
21
+ sessionMap: {},
22
+ processedEvents: [],
23
+ })),
24
+ listActiveDispatches: vi.fn(() => []),
25
+ listStaleDispatches: vi.fn(() => []),
26
+ pruneCompleted: vi.fn(async () => 0),
27
+ }));
28
+
29
+ vi.mock("../pipeline/pipeline.js", () => ({
30
+ loadPrompts: vi.fn(() => ({
31
+ worker: {
32
+ system: "You are a worker",
33
+ task: "Fix {{identifier}} {{title}} {{description}} in {{worktreePath}}",
34
+ },
35
+ audit: {
36
+ system: "You are an auditor",
37
+ task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}",
38
+ },
39
+ rework: { addendum: "Fix these gaps: {{gaps}}" },
40
+ })),
41
+ clearPromptCache: vi.fn(),
42
+ }));
43
+
44
+ vi.mock("./codex-worktree.js", () => ({
45
+ listWorktrees: vi.fn(() => []),
46
+ }));
47
+
48
+ vi.mock("../tools/code-tool.js", () => ({
49
+ loadCodingConfig: vi.fn(() => ({
50
+ codingTool: "claude",
51
+ agentCodingTools: {},
52
+ backends: {
53
+ claude: { aliases: ["claude", "anthropic"] },
54
+ codex: { aliases: ["codex", "openai"] },
55
+ gemini: { aliases: ["gemini", "google"] },
56
+ },
57
+ })),
58
+ }));
59
+
60
+ import {
61
+ checkAuth,
62
+ checkAgentConfig,
63
+ checkCodingTools,
64
+ checkFilesAndDirs,
65
+ checkConnectivity,
66
+ checkDispatchHealth,
67
+ runDoctor,
68
+ formatReport,
69
+ formatReportJson,
70
+ } from "./doctor.js";
71
+
72
+ import { resolveLinearToken } from "../api/linear-api.js";
73
+ import { readDispatchState, listStaleDispatches, pruneCompleted } from "../pipeline/dispatch-state.js";
74
+ import { loadPrompts } from "../pipeline/pipeline.js";
75
+ import { listWorktrees } from "./codex-worktree.js";
76
+
77
+ afterEach(() => {
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // checkAuth
83
+ // ---------------------------------------------------------------------------
84
+
85
+ describe("checkAuth", () => {
86
+ it("reports pass when token is found", async () => {
87
+ vi.stubGlobal("fetch", vi.fn(async () => ({
88
+ ok: true,
89
+ json: async () => ({ data: { viewer: { id: "1", name: "Test" }, organization: { name: "TestOrg", urlKey: "test" } } }),
90
+ })));
91
+
92
+ const { checks, ctx } = await checkAuth();
93
+ const tokenCheck = checks.find((c) => c.label.includes("Access token"));
94
+ expect(tokenCheck?.severity).toBe("pass");
95
+ expect(tokenCheck?.label).toContain("profile");
96
+ expect(ctx.viewer?.name).toBe("Test");
97
+ });
98
+
99
+ it("reports fail when no token found", async () => {
100
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
101
+ accessToken: null,
102
+ source: "none",
103
+ });
104
+
105
+ const { checks } = await checkAuth();
106
+ const tokenCheck = checks.find((c) => c.label.includes("access token"));
107
+ expect(tokenCheck?.severity).toBe("fail");
108
+ });
109
+
110
+ it("reports warn when token is expired", async () => {
111
+ vi.mocked(resolveLinearToken).mockReturnValueOnce({
112
+ accessToken: "tok",
113
+ expiresAt: Date.now() - 1000,
114
+ source: "profile",
115
+ });
116
+ vi.stubGlobal("fetch", vi.fn(async () => ({
117
+ ok: true,
118
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
119
+ })));
120
+
121
+ const { checks } = await checkAuth();
122
+ const expiryCheck = checks.find((c) => c.label.includes("expired") || c.label.includes("Token"));
123
+ expect(expiryCheck?.severity).toBe("warn");
124
+ });
125
+
126
+ it("reports pass with time remaining", async () => {
127
+ vi.stubGlobal("fetch", vi.fn(async () => ({
128
+ ok: true,
129
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
130
+ })));
131
+
132
+ const { checks } = await checkAuth();
133
+ const expiryCheck = checks.find((c) => c.label.includes("not expired"));
134
+ expect(expiryCheck?.severity).toBe("pass");
135
+ expect(expiryCheck?.label).toContain("h");
136
+ });
137
+
138
+ it("reports fail on API error", async () => {
139
+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network down"); }));
140
+
141
+ const { checks } = await checkAuth();
142
+ const apiCheck = checks.find((c) => c.label.includes("unreachable") || c.label.includes("API"));
143
+ expect(apiCheck?.severity).toBe("fail");
144
+ });
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // checkAgentConfig
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe("checkAgentConfig", () => {
152
+ let tmpDir: string;
153
+
154
+ beforeEach(() => {
155
+ tmpDir = mkdtempSync(join(tmpdir(), "doctor-agent-"));
156
+ });
157
+
158
+ it("reports pass for valid agent profiles", () => {
159
+ // Mock the AGENT_PROFILES_PATH by writing to the expected location
160
+ // Since the path is hardcoded, we test the function's logic indirectly
161
+ const checks = checkAgentConfig();
162
+ // This tests against the real ~/.openclaw/agent-profiles.json on the system
163
+ // The checks should either pass (if file exists) or fail (if not)
164
+ expect(checks.length).toBeGreaterThan(0);
165
+ });
166
+
167
+ it("detects duplicate mention aliases", () => {
168
+ // Since we can't easily mock the file path, we test the overall behavior
169
+ const checks = checkAgentConfig();
170
+ // Verify the function returns structured results
171
+ for (const check of checks) {
172
+ expect(check).toHaveProperty("label");
173
+ expect(check).toHaveProperty("severity");
174
+ expect(["pass", "warn", "fail"]).toContain(check.severity);
175
+ }
176
+ });
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // checkCodingTools
181
+ // ---------------------------------------------------------------------------
182
+
183
+ describe("checkCodingTools", () => {
184
+ it("reports loaded config with default backend", () => {
185
+ const checks = checkCodingTools();
186
+ const configCheck = checks.find((c) => c.label.includes("coding-tools.json"));
187
+ expect(configCheck?.severity).toBe("pass");
188
+ expect(configCheck?.label).toContain("claude");
189
+ });
190
+
191
+ it("reports warn for missing CLIs", () => {
192
+ const checks = checkCodingTools();
193
+ // Each CLI check should be present
194
+ const cliChecks = checks.filter((c) =>
195
+ c.label.startsWith("codex:") ||
196
+ c.label.startsWith("claude:") ||
197
+ c.label.startsWith("gemini:") ||
198
+ c.label.includes("not found"),
199
+ );
200
+ expect(cliChecks.length).toBeGreaterThan(0);
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // checkFilesAndDirs
206
+ // ---------------------------------------------------------------------------
207
+
208
+ describe("checkFilesAndDirs", () => {
209
+ it("reports dispatch state counts", async () => {
210
+ vi.mocked(readDispatchState).mockResolvedValueOnce({
211
+ dispatches: {
212
+ active: { "API-1": { status: "working" } as any },
213
+ completed: { "API-2": { status: "done" } as any, "API-3": { status: "done" } as any },
214
+ },
215
+ sessionMap: {},
216
+ processedEvents: [],
217
+ });
218
+
219
+ const checks = await checkFilesAndDirs();
220
+ const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
221
+ expect(stateCheck?.severity).toBe("pass");
222
+ expect(stateCheck?.label).toContain("1 active");
223
+ expect(stateCheck?.label).toContain("2 completed");
224
+ });
225
+
226
+ it("reports valid prompts", async () => {
227
+ const checks = await checkFilesAndDirs();
228
+ const promptCheck = checks.find((c) => c.label.includes("Prompts"));
229
+ expect(promptCheck?.severity).toBe("pass");
230
+ expect(promptCheck?.label).toContain("5/5");
231
+ expect(promptCheck?.label).toContain("4/4");
232
+ });
233
+
234
+ it("reports prompt failures when sections missing", async () => {
235
+ vi.mocked(loadPrompts).mockReturnValueOnce({
236
+ worker: { system: "ok", task: "no vars here" },
237
+ audit: { system: "ok", task: "no vars here" },
238
+ rework: { addendum: "" },
239
+ } as any);
240
+
241
+ const checks = await checkFilesAndDirs();
242
+ const promptCheck = checks.find((c) => c.label.includes("Prompt") || c.label.includes("prompt"));
243
+ expect(promptCheck?.severity).toBe("fail");
244
+ });
245
+ });
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // checkConnectivity
249
+ // ---------------------------------------------------------------------------
250
+
251
+ describe("checkConnectivity", () => {
252
+ it("skips Linear API re-check when authCtx provided", async () => {
253
+ const checks = await checkConnectivity(undefined, {
254
+ viewer: { name: "Test" },
255
+ organization: { name: "Org", urlKey: "org" },
256
+ });
257
+ const apiCheck = checks.find((c) => c.label.includes("Linear API"));
258
+ expect(apiCheck?.severity).toBe("pass");
259
+ });
260
+
261
+ it("reports Discord not configured as pass", async () => {
262
+ vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("should not be called"); }));
263
+ const checks = await checkConnectivity({});
264
+ const discordCheck = checks.find((c) => c.label.includes("Discord"));
265
+ expect(discordCheck?.severity).toBe("pass");
266
+ expect(discordCheck?.label).toContain("not configured");
267
+ });
268
+
269
+ it("reports webhook skip when gateway not running", async () => {
270
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
271
+ if (url.includes("localhost")) throw new Error("ECONNREFUSED");
272
+ throw new Error("unexpected");
273
+ }));
274
+
275
+ const checks = await checkConnectivity({}, {
276
+ viewer: { name: "T" },
277
+ organization: { name: "O", urlKey: "o" },
278
+ });
279
+ const webhookCheck = checks.find((c) => c.label.includes("Webhook"));
280
+ expect(webhookCheck?.severity).toBe("warn");
281
+ expect(webhookCheck?.label).toContain("gateway not detected");
282
+ });
283
+ });
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // checkDispatchHealth
287
+ // ---------------------------------------------------------------------------
288
+
289
+ describe("checkDispatchHealth", () => {
290
+ it("reports no active dispatches", async () => {
291
+ const checks = await checkDispatchHealth();
292
+ const activeCheck = checks.find((c) => c.label.includes("active"));
293
+ expect(activeCheck?.severity).toBe("pass");
294
+ });
295
+
296
+ it("reports stale dispatches", async () => {
297
+ vi.mocked(listStaleDispatches).mockReturnValueOnce([
298
+ { issueIdentifier: "API-1", status: "working", dispatchedAt: new Date(Date.now() - 3 * 3_600_000).toISOString() } as any,
299
+ ]);
300
+
301
+ const checks = await checkDispatchHealth();
302
+ const staleCheck = checks.find((c) => c.label.includes("stale"));
303
+ expect(staleCheck?.severity).toBe("warn");
304
+ expect(staleCheck?.label).toContain("API-1");
305
+ });
306
+
307
+ it("prunes old completed with --fix", async () => {
308
+ vi.mocked(readDispatchState).mockResolvedValueOnce({
309
+ dispatches: {
310
+ active: {},
311
+ completed: {
312
+ "API-OLD": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString(), status: "done" } as any,
313
+ },
314
+ },
315
+ sessionMap: {},
316
+ processedEvents: [],
317
+ });
318
+ vi.mocked(pruneCompleted).mockResolvedValueOnce(1);
319
+
320
+ const checks = await checkDispatchHealth(undefined, true);
321
+ const pruneCheck = checks.find((c) => c.label.includes("Pruned") || c.label.includes("prune"));
322
+ expect(pruneCheck).toBeDefined();
323
+ // With fix=true, it should have called pruneCompleted
324
+ expect(pruneCompleted).toHaveBeenCalled();
325
+ });
326
+ });
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // runDoctor (integration)
330
+ // ---------------------------------------------------------------------------
331
+
332
+ describe("runDoctor", () => {
333
+ it("returns all 6 sections", async () => {
334
+ vi.stubGlobal("fetch", vi.fn(async (url: string) => {
335
+ if (url.includes("linear.app")) {
336
+ return {
337
+ ok: true,
338
+ json: async () => ({ data: { viewer: { id: "1", name: "T" }, organization: { name: "O", urlKey: "o" } } }),
339
+ };
340
+ }
341
+ throw new Error("not mocked");
342
+ }));
343
+
344
+ const report = await runDoctor({ fix: false, json: false });
345
+ expect(report.sections).toHaveLength(6);
346
+ expect(report.sections.map((s) => s.name)).toEqual([
347
+ "Authentication & Tokens",
348
+ "Agent Configuration",
349
+ "Coding Tools",
350
+ "Files & Directories",
351
+ "Connectivity",
352
+ "Dispatch Health",
353
+ ]);
354
+ expect(report.summary.passed + report.summary.warnings + report.summary.errors).toBeGreaterThan(0);
355
+ });
356
+ });
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Formatters
360
+ // ---------------------------------------------------------------------------
361
+
362
+ describe("formatReport", () => {
363
+ it("produces readable output with section headers", () => {
364
+ const report = {
365
+ sections: [
366
+ {
367
+ name: "Test Section",
368
+ checks: [
369
+ { label: "Check passed", severity: "pass" as const },
370
+ { label: "Check warned", severity: "warn" as const },
371
+ ],
372
+ },
373
+ ],
374
+ summary: { passed: 1, warnings: 1, errors: 0 },
375
+ };
376
+
377
+ const output = formatReport(report);
378
+ expect(output).toContain("Linear Plugin Doctor");
379
+ expect(output).toContain("Test Section");
380
+ expect(output).toContain("Check passed");
381
+ expect(output).toContain("Check warned");
382
+ expect(output).toContain("1 passed");
383
+ expect(output).toContain("1 warning");
384
+ });
385
+ });
386
+
387
+ describe("formatReportJson", () => {
388
+ it("produces valid JSON", () => {
389
+ const report = {
390
+ sections: [{ name: "Test", checks: [{ label: "ok", severity: "pass" as const }] }],
391
+ summary: { passed: 1, warnings: 0, errors: 0 },
392
+ };
393
+
394
+ const json = formatReportJson(report);
395
+ const parsed = JSON.parse(json);
396
+ expect(parsed.sections).toHaveLength(1);
397
+ expect(parsed.summary.passed).toBe(1);
398
+ });
399
+ });