@calltelemetry/openclaw-linear 0.6.0 → 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/package.json +1 -1
- package/src/infra/cli.ts +27 -0
- package/src/infra/doctor.test.ts +399 -0
- package/src/infra/doctor.ts +781 -0
package/package.json
CHANGED
package/src/infra/cli.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor.ts — Comprehensive health checks for the Linear plugin.
|
|
3
|
+
*
|
|
4
|
+
* Usage: openclaw openclaw-linear doctor [--fix] [--json]
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync, statSync, accessSync, unlinkSync, chmodSync, constants } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
import { resolveLinearToken, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
|
|
12
|
+
import { readDispatchState, listActiveDispatches, listStaleDispatches, pruneCompleted, type DispatchState } from "../pipeline/dispatch-state.js";
|
|
13
|
+
import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
|
|
14
|
+
import { listWorktrees } from "./codex-worktree.js";
|
|
15
|
+
import { loadCodingConfig, type CodingBackend } from "../tools/code-tool.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export type CheckSeverity = "pass" | "warn" | "fail";
|
|
22
|
+
|
|
23
|
+
export interface CheckResult {
|
|
24
|
+
label: string;
|
|
25
|
+
severity: CheckSeverity;
|
|
26
|
+
detail?: string;
|
|
27
|
+
fixable?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CheckSection {
|
|
31
|
+
name: string;
|
|
32
|
+
checks: CheckResult[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DoctorReport {
|
|
36
|
+
sections: CheckSection[];
|
|
37
|
+
summary: { passed: number; warnings: number; errors: number };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DoctorOptions {
|
|
41
|
+
fix: boolean;
|
|
42
|
+
json: boolean;
|
|
43
|
+
pluginConfig?: Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Constants
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const AGENT_PROFILES_PATH = join(homedir(), ".openclaw", "agent-profiles.json");
|
|
51
|
+
const VALID_BACKENDS: readonly string[] = ["claude", "codex", "gemini"];
|
|
52
|
+
const CLI_BINS: [string, string][] = [
|
|
53
|
+
["codex", "/home/claw/.npm-global/bin/codex"],
|
|
54
|
+
["claude", "/home/claw/.npm-global/bin/claude"],
|
|
55
|
+
["gemini", "/home/claw/.npm-global/bin/gemini"],
|
|
56
|
+
];
|
|
57
|
+
const STALE_DISPATCH_MS = 2 * 60 * 60_000; // 2 hours
|
|
58
|
+
const OLD_COMPLETED_MS = 7 * 24 * 60 * 60_000; // 7 days
|
|
59
|
+
const LOCK_STALE_MS = 30_000; // 30 seconds
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Helpers
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function pass(label: string, detail?: string): CheckResult {
|
|
66
|
+
return { label, severity: "pass", detail };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function warn(label: string, detail?: string, fixable = false): CheckResult {
|
|
70
|
+
return { label, severity: "warn", detail, fixable: fixable || undefined };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fail(label: string, detail?: string): CheckResult {
|
|
74
|
+
return { label, severity: "fail", detail };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveDispatchStatePath(pluginConfig?: Record<string, unknown>): string {
|
|
78
|
+
const custom = pluginConfig?.dispatchStatePath as string | undefined;
|
|
79
|
+
if (!custom) return join(homedir(), ".openclaw", "linear-dispatch-state.json");
|
|
80
|
+
if (custom.startsWith("~/")) return custom.replace("~", homedir());
|
|
81
|
+
return custom;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveWorktreeBaseDir(pluginConfig?: Record<string, unknown>): string {
|
|
85
|
+
const custom = pluginConfig?.worktreeBaseDir as string | undefined;
|
|
86
|
+
if (!custom) return join(homedir(), ".openclaw", "worktrees");
|
|
87
|
+
if (custom.startsWith("~/")) return custom.replace("~", homedir());
|
|
88
|
+
return custom;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveBaseRepo(pluginConfig?: Record<string, unknown>): string {
|
|
92
|
+
return (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface AgentProfile {
|
|
96
|
+
label?: string;
|
|
97
|
+
mentionAliases?: string[];
|
|
98
|
+
isDefault?: boolean;
|
|
99
|
+
[key: string]: unknown;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function loadAgentProfiles(): Record<string, AgentProfile> {
|
|
103
|
+
try {
|
|
104
|
+
const raw = readFileSync(AGENT_PROFILES_PATH, "utf8");
|
|
105
|
+
return JSON.parse(raw).agents ?? {};
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Section 1: Authentication & Tokens
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
interface AuthContext {
|
|
116
|
+
viewer?: { name: string };
|
|
117
|
+
organization?: { name: string; urlKey: string };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function checkAuth(pluginConfig?: Record<string, unknown>): Promise<{ checks: CheckResult[]; ctx: AuthContext }> {
|
|
121
|
+
const checks: CheckResult[] = [];
|
|
122
|
+
const ctx: AuthContext = {};
|
|
123
|
+
|
|
124
|
+
// Token existence
|
|
125
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
126
|
+
if (tokenInfo.accessToken) {
|
|
127
|
+
checks.push(pass(`Access token found (source: ${tokenInfo.source})`));
|
|
128
|
+
} else {
|
|
129
|
+
checks.push(fail("No access token found", "Run: openclaw openclaw-linear auth"));
|
|
130
|
+
// Can't check further without token
|
|
131
|
+
return { checks, ctx };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Token expiry
|
|
135
|
+
if (tokenInfo.expiresAt) {
|
|
136
|
+
const remaining = tokenInfo.expiresAt - Date.now();
|
|
137
|
+
if (remaining <= 0) {
|
|
138
|
+
checks.push(warn("Token expired", "Restart gateway to trigger auto-refresh"));
|
|
139
|
+
} else {
|
|
140
|
+
const hours = Math.floor(remaining / 3_600_000);
|
|
141
|
+
const mins = Math.floor((remaining % 3_600_000) / 60_000);
|
|
142
|
+
if (remaining < 3_600_000) {
|
|
143
|
+
checks.push(warn(`Token expires soon (${mins}m remaining)`));
|
|
144
|
+
} else {
|
|
145
|
+
checks.push(pass(`Token not expired (${hours}h ${mins}m remaining)`));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// API connectivity
|
|
151
|
+
try {
|
|
152
|
+
const authHeader = tokenInfo.refreshToken
|
|
153
|
+
? `Bearer ${tokenInfo.accessToken}`
|
|
154
|
+
: tokenInfo.accessToken;
|
|
155
|
+
|
|
156
|
+
const res = await fetch(LINEAR_GRAPHQL_URL, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: {
|
|
159
|
+
"Content-Type": "application/json",
|
|
160
|
+
Authorization: authHeader,
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify({
|
|
163
|
+
query: `{ viewer { id name } organization { name urlKey } }`,
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
checks.push(fail(`API returned ${res.status} ${res.statusText}`));
|
|
169
|
+
} else {
|
|
170
|
+
const payload = await res.json() as any;
|
|
171
|
+
if (payload.errors?.length) {
|
|
172
|
+
checks.push(fail(`API error: ${payload.errors[0].message}`));
|
|
173
|
+
} else {
|
|
174
|
+
const { viewer, organization } = payload.data;
|
|
175
|
+
ctx.viewer = viewer;
|
|
176
|
+
ctx.organization = organization;
|
|
177
|
+
checks.push(pass(`API connectivity (user: ${viewer.name}, workspace: ${organization.name})`));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
checks.push(fail(`API unreachable: ${err instanceof Error ? err.message : String(err)}`));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// auth-profiles.json permissions
|
|
185
|
+
try {
|
|
186
|
+
const stat = statSync(AUTH_PROFILES_PATH);
|
|
187
|
+
const mode = stat.mode & 0o777;
|
|
188
|
+
if (mode === 0o600) {
|
|
189
|
+
checks.push(pass("auth-profiles.json permissions (600)"));
|
|
190
|
+
} else {
|
|
191
|
+
checks.push(warn(
|
|
192
|
+
`auth-profiles.json permissions (${mode.toString(8)}, expected 600)`,
|
|
193
|
+
"Run: chmod 600 ~/.openclaw/auth-profiles.json",
|
|
194
|
+
true,
|
|
195
|
+
));
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
if (tokenInfo.source === "profile") {
|
|
199
|
+
checks.push(warn("auth-profiles.json not found (but token resolved from profile?)"));
|
|
200
|
+
}
|
|
201
|
+
// If token is from config/env, no auth-profiles.json is fine
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// OAuth credentials
|
|
205
|
+
const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
|
|
206
|
+
const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
|
|
207
|
+
if (clientId && clientSecret) {
|
|
208
|
+
checks.push(pass("OAuth credentials configured"));
|
|
209
|
+
} else {
|
|
210
|
+
checks.push(warn(
|
|
211
|
+
"OAuth credentials not configured",
|
|
212
|
+
"Set LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET env vars or plugin config",
|
|
213
|
+
));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { checks, ctx };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Section 2: Agent Configuration
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export function checkAgentConfig(pluginConfig?: Record<string, unknown>): CheckResult[] {
|
|
224
|
+
const checks: CheckResult[] = [];
|
|
225
|
+
|
|
226
|
+
// Load profiles
|
|
227
|
+
let profiles: Record<string, AgentProfile>;
|
|
228
|
+
try {
|
|
229
|
+
if (!existsSync(AGENT_PROFILES_PATH)) {
|
|
230
|
+
checks.push(fail(
|
|
231
|
+
"agent-profiles.json not found",
|
|
232
|
+
`Expected at: ${AGENT_PROFILES_PATH}`,
|
|
233
|
+
));
|
|
234
|
+
return checks;
|
|
235
|
+
}
|
|
236
|
+
const raw = readFileSync(AGENT_PROFILES_PATH, "utf8");
|
|
237
|
+
const parsed = JSON.parse(raw);
|
|
238
|
+
profiles = parsed.agents ?? {};
|
|
239
|
+
} catch (err) {
|
|
240
|
+
checks.push(fail(
|
|
241
|
+
"agent-profiles.json invalid JSON",
|
|
242
|
+
err instanceof Error ? err.message : String(err),
|
|
243
|
+
));
|
|
244
|
+
return checks;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const agentCount = Object.keys(profiles).length;
|
|
248
|
+
if (agentCount === 0) {
|
|
249
|
+
checks.push(fail("agent-profiles.json has no agents"));
|
|
250
|
+
return checks;
|
|
251
|
+
}
|
|
252
|
+
checks.push(pass(`agent-profiles.json loaded (${agentCount} agent${agentCount > 1 ? "s" : ""})`));
|
|
253
|
+
|
|
254
|
+
// Default agent
|
|
255
|
+
const defaultEntry = Object.entries(profiles).find(([, p]) => p.isDefault);
|
|
256
|
+
if (defaultEntry) {
|
|
257
|
+
checks.push(pass(`Default agent: ${defaultEntry[0]}`));
|
|
258
|
+
} else {
|
|
259
|
+
checks.push(warn("No agent has isDefault: true"));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Required fields
|
|
263
|
+
const missing: string[] = [];
|
|
264
|
+
for (const [id, profile] of Object.entries(profiles)) {
|
|
265
|
+
if (!profile.label) missing.push(`${id}: missing label`);
|
|
266
|
+
if (!Array.isArray(profile.mentionAliases) || profile.mentionAliases.length === 0) {
|
|
267
|
+
missing.push(`${id}: missing or empty mentionAliases`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (missing.length === 0) {
|
|
271
|
+
checks.push(pass("All agents have required fields"));
|
|
272
|
+
} else {
|
|
273
|
+
checks.push(fail(`Agent field issues: ${missing.join("; ")}`));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// defaultAgentId match
|
|
277
|
+
const configAgentId = pluginConfig?.defaultAgentId as string | undefined;
|
|
278
|
+
if (configAgentId) {
|
|
279
|
+
if (profiles[configAgentId]) {
|
|
280
|
+
checks.push(pass(`defaultAgentId "${configAgentId}" matches a profile`));
|
|
281
|
+
} else {
|
|
282
|
+
checks.push(warn(
|
|
283
|
+
`defaultAgentId "${configAgentId}" not found in agent-profiles.json`,
|
|
284
|
+
`Available: ${Object.keys(profiles).join(", ")}`,
|
|
285
|
+
));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Duplicate aliases
|
|
290
|
+
const aliasMap = new Map<string, string>();
|
|
291
|
+
const dupes: string[] = [];
|
|
292
|
+
for (const [id, profile] of Object.entries(profiles)) {
|
|
293
|
+
for (const alias of profile.mentionAliases ?? []) {
|
|
294
|
+
const lower = alias.toLowerCase();
|
|
295
|
+
if (aliasMap.has(lower)) {
|
|
296
|
+
dupes.push(`"${alias}" (${aliasMap.get(lower)} and ${id})`);
|
|
297
|
+
} else {
|
|
298
|
+
aliasMap.set(lower, id);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (dupes.length === 0) {
|
|
303
|
+
checks.push(pass("No duplicate mention aliases"));
|
|
304
|
+
} else {
|
|
305
|
+
checks.push(warn(`Duplicate aliases: ${dupes.join(", ")}`));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return checks;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Section 3: Coding Tools
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
export function checkCodingTools(): CheckResult[] {
|
|
316
|
+
const checks: CheckResult[] = [];
|
|
317
|
+
|
|
318
|
+
// Load config
|
|
319
|
+
const config = loadCodingConfig();
|
|
320
|
+
const hasConfig = !!config.codingTool || !!config.backends;
|
|
321
|
+
if (hasConfig) {
|
|
322
|
+
checks.push(pass(`coding-tools.json loaded (default: ${config.codingTool ?? "claude"})`));
|
|
323
|
+
} else {
|
|
324
|
+
checks.push(warn("coding-tools.json not found or empty (using defaults)"));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Validate default backend
|
|
328
|
+
const defaultBackend = config.codingTool ?? "claude";
|
|
329
|
+
if (VALID_BACKENDS.includes(defaultBackend)) {
|
|
330
|
+
// already reported in the line above
|
|
331
|
+
} else {
|
|
332
|
+
checks.push(fail(`Unknown default backend: "${defaultBackend}" (valid: ${VALID_BACKENDS.join(", ")})`));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Validate per-agent overrides
|
|
336
|
+
if (config.agentCodingTools) {
|
|
337
|
+
for (const [agentId, backend] of Object.entries(config.agentCodingTools)) {
|
|
338
|
+
if (!VALID_BACKENDS.includes(backend)) {
|
|
339
|
+
checks.push(warn(`Agent "${agentId}" override "${backend}" is not a valid backend`));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// CLI availability
|
|
345
|
+
for (const [name, bin] of CLI_BINS) {
|
|
346
|
+
try {
|
|
347
|
+
const raw = execFileSync(bin, ["--version"], {
|
|
348
|
+
encoding: "utf8",
|
|
349
|
+
timeout: 15_000,
|
|
350
|
+
env: { ...process.env, CLAUDECODE: undefined } as any,
|
|
351
|
+
}).trim();
|
|
352
|
+
checks.push(pass(`${name}: ${raw || "installed"}`));
|
|
353
|
+
} catch {
|
|
354
|
+
try {
|
|
355
|
+
accessSync(bin, constants.X_OK);
|
|
356
|
+
checks.push(pass(`${name}: installed (version check skipped)`));
|
|
357
|
+
} catch {
|
|
358
|
+
checks.push(warn(`${name}: not found at ${bin}`));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return checks;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Section 4: Files & Directories
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
export async function checkFilesAndDirs(pluginConfig?: Record<string, unknown>, fix = false): Promise<CheckResult[]> {
|
|
371
|
+
const checks: CheckResult[] = [];
|
|
372
|
+
|
|
373
|
+
// Dispatch state
|
|
374
|
+
const statePath = resolveDispatchStatePath(pluginConfig);
|
|
375
|
+
let dispatchState: DispatchState | null = null;
|
|
376
|
+
if (existsSync(statePath)) {
|
|
377
|
+
try {
|
|
378
|
+
dispatchState = await readDispatchState(pluginConfig?.dispatchStatePath as string | undefined);
|
|
379
|
+
const activeCount = Object.keys(dispatchState.dispatches.active).length;
|
|
380
|
+
const completedCount = Object.keys(dispatchState.dispatches.completed).length;
|
|
381
|
+
checks.push(pass(`Dispatch state: ${activeCount} active, ${completedCount} completed`));
|
|
382
|
+
} catch (err) {
|
|
383
|
+
checks.push(fail(
|
|
384
|
+
"Dispatch state corrupt",
|
|
385
|
+
err instanceof Error ? err.message : String(err),
|
|
386
|
+
));
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
checks.push(pass("Dispatch state: no file yet (will be created on first dispatch)"));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Stale lock files
|
|
393
|
+
const lockPath = statePath + ".lock";
|
|
394
|
+
if (existsSync(lockPath)) {
|
|
395
|
+
try {
|
|
396
|
+
const lockStat = statSync(lockPath);
|
|
397
|
+
const lockAge = Date.now() - lockStat.mtimeMs;
|
|
398
|
+
if (lockAge > LOCK_STALE_MS) {
|
|
399
|
+
if (fix) {
|
|
400
|
+
unlinkSync(lockPath);
|
|
401
|
+
checks.push(pass("Stale lock file removed (--fix)"));
|
|
402
|
+
} else {
|
|
403
|
+
checks.push(warn(
|
|
404
|
+
`Stale lock file (${Math.round(lockAge / 1000)}s old)`,
|
|
405
|
+
"Use --fix to remove",
|
|
406
|
+
true,
|
|
407
|
+
));
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
checks.push(warn(`Lock file active (${Math.round(lockAge / 1000)}s old, may be in use)`));
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
checks.push(pass("No stale lock files"));
|
|
414
|
+
}
|
|
415
|
+
} else {
|
|
416
|
+
checks.push(pass("No stale lock files"));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Worktree base dir
|
|
420
|
+
const wtBaseDir = resolveWorktreeBaseDir(pluginConfig);
|
|
421
|
+
if (existsSync(wtBaseDir)) {
|
|
422
|
+
try {
|
|
423
|
+
accessSync(wtBaseDir, constants.W_OK);
|
|
424
|
+
checks.push(pass("Worktree base dir writable"));
|
|
425
|
+
} catch {
|
|
426
|
+
checks.push(fail(`Worktree base dir not writable: ${wtBaseDir}`));
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
checks.push(warn(`Worktree base dir does not exist: ${wtBaseDir}`, "Will be created on first dispatch"));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Base git repo
|
|
433
|
+
const baseRepo = resolveBaseRepo(pluginConfig);
|
|
434
|
+
if (existsSync(baseRepo)) {
|
|
435
|
+
try {
|
|
436
|
+
execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
437
|
+
cwd: baseRepo,
|
|
438
|
+
encoding: "utf8",
|
|
439
|
+
timeout: 5_000,
|
|
440
|
+
});
|
|
441
|
+
checks.push(pass("Base repo is valid git repo"));
|
|
442
|
+
} catch {
|
|
443
|
+
checks.push(fail(`Base repo is not a git repo: ${baseRepo}`));
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
checks.push(fail(`Base repo does not exist: ${baseRepo}`));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Prompts
|
|
450
|
+
try {
|
|
451
|
+
clearPromptCache();
|
|
452
|
+
const loaded = loadPrompts(pluginConfig);
|
|
453
|
+
const errors: string[] = [];
|
|
454
|
+
|
|
455
|
+
const sections = [
|
|
456
|
+
["worker.system", loaded.worker?.system],
|
|
457
|
+
["worker.task", loaded.worker?.task],
|
|
458
|
+
["audit.system", loaded.audit?.system],
|
|
459
|
+
["audit.task", loaded.audit?.task],
|
|
460
|
+
["rework.addendum", loaded.rework?.addendum],
|
|
461
|
+
] as const;
|
|
462
|
+
|
|
463
|
+
let sectionCount = 0;
|
|
464
|
+
for (const [name, value] of sections) {
|
|
465
|
+
if (value) sectionCount++;
|
|
466
|
+
else errors.push(`Missing ${name}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const requiredVars = ["{{identifier}}", "{{title}}", "{{description}}", "{{worktreePath}}"];
|
|
470
|
+
let varCount = 0;
|
|
471
|
+
for (const v of requiredVars) {
|
|
472
|
+
const inWorker = loaded.worker?.task?.includes(v);
|
|
473
|
+
const inAudit = loaded.audit?.task?.includes(v);
|
|
474
|
+
if (inWorker && inAudit) {
|
|
475
|
+
varCount++;
|
|
476
|
+
} else {
|
|
477
|
+
if (!inWorker) errors.push(`worker.task missing ${v}`);
|
|
478
|
+
if (!inAudit) errors.push(`audit.task missing ${v}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (errors.length === 0) {
|
|
483
|
+
checks.push(pass(`Prompts valid (${sectionCount}/5 sections, ${varCount}/4 variables)`));
|
|
484
|
+
} else {
|
|
485
|
+
checks.push(fail(`Prompt issues: ${errors.join("; ")}`));
|
|
486
|
+
}
|
|
487
|
+
} catch (err) {
|
|
488
|
+
checks.push(fail(
|
|
489
|
+
"Failed to load prompts",
|
|
490
|
+
err instanceof Error ? err.message : String(err),
|
|
491
|
+
));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return checks;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// Section 5: Connectivity
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
export async function checkConnectivity(pluginConfig?: Record<string, unknown>, authCtx?: AuthContext): Promise<CheckResult[]> {
|
|
502
|
+
const checks: CheckResult[] = [];
|
|
503
|
+
|
|
504
|
+
// Linear API (share result from auth check if available)
|
|
505
|
+
if (authCtx?.viewer) {
|
|
506
|
+
checks.push(pass("Linear API: connected"));
|
|
507
|
+
} else {
|
|
508
|
+
// Re-check if auth context wasn't passed
|
|
509
|
+
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
510
|
+
if (tokenInfo.accessToken) {
|
|
511
|
+
try {
|
|
512
|
+
const authHeader = tokenInfo.refreshToken
|
|
513
|
+
? `Bearer ${tokenInfo.accessToken}`
|
|
514
|
+
: tokenInfo.accessToken;
|
|
515
|
+
const res = await fetch(LINEAR_GRAPHQL_URL, {
|
|
516
|
+
method: "POST",
|
|
517
|
+
headers: { "Content-Type": "application/json", Authorization: authHeader },
|
|
518
|
+
body: JSON.stringify({ query: `{ viewer { id } }` }),
|
|
519
|
+
});
|
|
520
|
+
if (res.ok) {
|
|
521
|
+
checks.push(pass("Linear API: connected"));
|
|
522
|
+
} else {
|
|
523
|
+
checks.push(fail(`Linear API: ${res.status} ${res.statusText}`));
|
|
524
|
+
}
|
|
525
|
+
} catch (err) {
|
|
526
|
+
checks.push(fail(`Linear API: unreachable (${err instanceof Error ? err.message : String(err)})`));
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
checks.push(fail("Linear API: no token available"));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Discord notifications
|
|
534
|
+
const flowDiscordChannel = pluginConfig?.flowDiscordChannel as string | undefined;
|
|
535
|
+
if (!flowDiscordChannel) {
|
|
536
|
+
checks.push(pass("Discord notifications: not configured (skipped)"));
|
|
537
|
+
} else {
|
|
538
|
+
// Read Discord bot token from openclaw.json
|
|
539
|
+
let discordBotToken: string | undefined;
|
|
540
|
+
try {
|
|
541
|
+
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
|
542
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
543
|
+
discordBotToken = config?.channels?.discord?.token as string | undefined;
|
|
544
|
+
} catch {
|
|
545
|
+
// Can't read config
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!discordBotToken) {
|
|
549
|
+
checks.push(warn("Discord notifications: no bot token found in openclaw.json"));
|
|
550
|
+
} else {
|
|
551
|
+
try {
|
|
552
|
+
const res = await fetch(`https://discord.com/api/v10/channels/${flowDiscordChannel}`, {
|
|
553
|
+
headers: { Authorization: `Bot ${discordBotToken}` },
|
|
554
|
+
});
|
|
555
|
+
if (res.ok) {
|
|
556
|
+
checks.push(pass("Discord notifications: enabled"));
|
|
557
|
+
} else {
|
|
558
|
+
checks.push(warn(`Discord channel check: ${res.status} ${res.statusText}`));
|
|
559
|
+
}
|
|
560
|
+
} catch (err) {
|
|
561
|
+
checks.push(warn(`Discord API unreachable: ${err instanceof Error ? err.message : String(err)}`));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Webhook self-test
|
|
567
|
+
const gatewayPort = process.env.OPENCLAW_GATEWAY_PORT ?? "18789";
|
|
568
|
+
try {
|
|
569
|
+
const res = await fetch(`http://localhost:${gatewayPort}/linear/webhook`, {
|
|
570
|
+
method: "POST",
|
|
571
|
+
headers: { "Content-Type": "application/json" },
|
|
572
|
+
body: JSON.stringify({ type: "test", action: "ping" }),
|
|
573
|
+
});
|
|
574
|
+
const body = await res.text();
|
|
575
|
+
if (res.ok && body === "ok") {
|
|
576
|
+
checks.push(pass("Webhook self-test: responds OK"));
|
|
577
|
+
} else {
|
|
578
|
+
checks.push(warn(`Webhook self-test: ${res.status} — ${body.slice(0, 100)}`));
|
|
579
|
+
}
|
|
580
|
+
} catch {
|
|
581
|
+
checks.push(warn(`Webhook self-test: skipped (gateway not detected on :${gatewayPort})`));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return checks;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// Section 6: Dispatch Health
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
export async function checkDispatchHealth(pluginConfig?: Record<string, unknown>, fix = false): Promise<CheckResult[]> {
|
|
592
|
+
const checks: CheckResult[] = [];
|
|
593
|
+
|
|
594
|
+
const statePath = resolveDispatchStatePath(pluginConfig);
|
|
595
|
+
let state: DispatchState;
|
|
596
|
+
try {
|
|
597
|
+
state = await readDispatchState(pluginConfig?.dispatchStatePath as string | undefined);
|
|
598
|
+
} catch {
|
|
599
|
+
checks.push(pass("Dispatch health: no state file (nothing to check)"));
|
|
600
|
+
return checks;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Active dispatches by status
|
|
604
|
+
const active = listActiveDispatches(state);
|
|
605
|
+
if (active.length === 0) {
|
|
606
|
+
checks.push(pass("No active dispatches"));
|
|
607
|
+
} else {
|
|
608
|
+
const byStatus = new Map<string, number>();
|
|
609
|
+
for (const d of active) {
|
|
610
|
+
byStatus.set(d.status, (byStatus.get(d.status) ?? 0) + 1);
|
|
611
|
+
}
|
|
612
|
+
const parts = Array.from(byStatus.entries()).map(([s, n]) => `${n} ${s}`);
|
|
613
|
+
const hasStuck = byStatus.has("stuck");
|
|
614
|
+
if (hasStuck) {
|
|
615
|
+
checks.push(warn(`Active dispatches: ${parts.join(", ")}`));
|
|
616
|
+
} else {
|
|
617
|
+
checks.push(pass(`Active dispatches: ${parts.join(", ")}`));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Stale dispatches
|
|
622
|
+
const stale = listStaleDispatches(state, STALE_DISPATCH_MS);
|
|
623
|
+
if (stale.length === 0) {
|
|
624
|
+
checks.push(pass("No stale dispatches"));
|
|
625
|
+
} else {
|
|
626
|
+
const ids = stale.map((d) => d.issueIdentifier).join(", ");
|
|
627
|
+
checks.push(warn(`${stale.length} stale dispatch${stale.length > 1 ? "es" : ""}: ${ids}`));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Orphaned worktrees
|
|
631
|
+
try {
|
|
632
|
+
const worktrees = listWorktrees({ baseDir: resolveWorktreeBaseDir(pluginConfig) });
|
|
633
|
+
const activeIds = new Set(Object.keys(state.dispatches.active));
|
|
634
|
+
const orphaned = worktrees.filter((wt) => !activeIds.has(wt.issueIdentifier));
|
|
635
|
+
if (orphaned.length === 0) {
|
|
636
|
+
checks.push(pass("No orphaned worktrees"));
|
|
637
|
+
} else {
|
|
638
|
+
checks.push(warn(
|
|
639
|
+
`${orphaned.length} orphaned worktree${orphaned.length > 1 ? "s" : ""} (not in active dispatches)`,
|
|
640
|
+
orphaned.map((w) => w.path).join(", "),
|
|
641
|
+
));
|
|
642
|
+
}
|
|
643
|
+
} catch {
|
|
644
|
+
// Worktree listing may fail if dir doesn't exist — that's fine
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Old completed dispatches
|
|
648
|
+
const completed = Object.values(state.dispatches.completed);
|
|
649
|
+
const now = Date.now();
|
|
650
|
+
const old = completed.filter((c) => {
|
|
651
|
+
const age = now - new Date(c.completedAt).getTime();
|
|
652
|
+
return age > OLD_COMPLETED_MS;
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
if (old.length === 0) {
|
|
656
|
+
checks.push(pass("No old completed dispatches"));
|
|
657
|
+
} else {
|
|
658
|
+
if (fix) {
|
|
659
|
+
const pruned = await pruneCompleted(OLD_COMPLETED_MS, pluginConfig?.dispatchStatePath as string | undefined);
|
|
660
|
+
checks.push(pass(`Pruned ${pruned} old completed dispatch${pruned > 1 ? "es" : ""} (--fix)`));
|
|
661
|
+
} else {
|
|
662
|
+
checks.push(warn(
|
|
663
|
+
`${old.length} completed dispatch${old.length > 1 ? "es" : ""} older than 7 days`,
|
|
664
|
+
"Use --fix to prune",
|
|
665
|
+
true,
|
|
666
|
+
));
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return checks;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// Main entry point
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
|
|
678
|
+
const sections: CheckSection[] = [];
|
|
679
|
+
|
|
680
|
+
// 1. Auth (also captures context for connectivity)
|
|
681
|
+
const auth = await checkAuth(opts.pluginConfig);
|
|
682
|
+
sections.push({ name: "Authentication & Tokens", checks: auth.checks });
|
|
683
|
+
|
|
684
|
+
// 2. Agent config
|
|
685
|
+
sections.push({ name: "Agent Configuration", checks: checkAgentConfig(opts.pluginConfig) });
|
|
686
|
+
|
|
687
|
+
// 3. Coding tools
|
|
688
|
+
sections.push({ name: "Coding Tools", checks: checkCodingTools() });
|
|
689
|
+
|
|
690
|
+
// 4. Files & dirs
|
|
691
|
+
sections.push({
|
|
692
|
+
name: "Files & Directories",
|
|
693
|
+
checks: await checkFilesAndDirs(opts.pluginConfig, opts.fix),
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// 5. Connectivity (pass auth context to avoid double API call)
|
|
697
|
+
sections.push({
|
|
698
|
+
name: "Connectivity",
|
|
699
|
+
checks: await checkConnectivity(opts.pluginConfig, auth.ctx),
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// 6. Dispatch health
|
|
703
|
+
sections.push({
|
|
704
|
+
name: "Dispatch Health",
|
|
705
|
+
checks: await checkDispatchHealth(opts.pluginConfig, opts.fix),
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Fix: chmod auth-profiles.json if needed
|
|
709
|
+
if (opts.fix) {
|
|
710
|
+
const permCheck = auth.checks.find((c) => c.fixable && c.label.includes("permissions"));
|
|
711
|
+
if (permCheck) {
|
|
712
|
+
try {
|
|
713
|
+
chmodSync(AUTH_PROFILES_PATH, 0o600);
|
|
714
|
+
permCheck.severity = "pass";
|
|
715
|
+
permCheck.label = "auth-profiles.json permissions fixed to 600 (--fix)";
|
|
716
|
+
permCheck.fixable = undefined;
|
|
717
|
+
} catch { /* best effort */ }
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Build summary
|
|
722
|
+
let passed = 0, warnings = 0, errors = 0;
|
|
723
|
+
for (const section of sections) {
|
|
724
|
+
for (const check of section.checks) {
|
|
725
|
+
switch (check.severity) {
|
|
726
|
+
case "pass": passed++; break;
|
|
727
|
+
case "warn": warnings++; break;
|
|
728
|
+
case "fail": errors++; break;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return { sections, summary: { passed, warnings, errors } };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
// Formatters
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
function icon(severity: CheckSeverity): string {
|
|
741
|
+
const isTTY = process.stdout?.isTTY;
|
|
742
|
+
switch (severity) {
|
|
743
|
+
case "pass": return isTTY ? "\x1b[32m✓\x1b[0m" : "✓";
|
|
744
|
+
case "warn": return isTTY ? "\x1b[33m⚠\x1b[0m" : "⚠";
|
|
745
|
+
case "fail": return isTTY ? "\x1b[31m✗\x1b[0m" : "✗";
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function formatReport(report: DoctorReport): string {
|
|
750
|
+
const lines: string[] = [];
|
|
751
|
+
const bar = "═".repeat(40);
|
|
752
|
+
|
|
753
|
+
lines.push("");
|
|
754
|
+
lines.push("Linear Plugin Doctor");
|
|
755
|
+
lines.push(bar);
|
|
756
|
+
|
|
757
|
+
for (const section of report.sections) {
|
|
758
|
+
lines.push("");
|
|
759
|
+
lines.push(section.name);
|
|
760
|
+
for (const check of section.checks) {
|
|
761
|
+
lines.push(` ${icon(check.severity)} ${check.label}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
lines.push("");
|
|
766
|
+
lines.push(bar);
|
|
767
|
+
|
|
768
|
+
const { passed, warnings, errors } = report.summary;
|
|
769
|
+
const parts: string[] = [];
|
|
770
|
+
parts.push(`${passed} passed`);
|
|
771
|
+
if (warnings > 0) parts.push(`${warnings} warning${warnings > 1 ? "s" : ""}`);
|
|
772
|
+
if (errors > 0) parts.push(`${errors} error${errors > 1 ? "s" : ""}`);
|
|
773
|
+
lines.push(`Results: ${parts.join(", ")}`);
|
|
774
|
+
lines.push("");
|
|
775
|
+
|
|
776
|
+
return lines.join("\n");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export function formatReportJson(report: DoctorReport): string {
|
|
780
|
+
return JSON.stringify(report, null, 2);
|
|
781
|
+
}
|