@calltelemetry/openclaw-linear 0.5.2 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +359 -195
- package/index.ts +10 -10
- package/openclaw.plugin.json +4 -1
- package/package.json +9 -2
- package/src/agent/agent.test.ts +127 -0
- package/src/{agent.ts → agent/agent.ts} +84 -7
- package/src/agent/watchdog.test.ts +266 -0
- package/src/agent/watchdog.ts +176 -0
- package/src/{cli.ts → infra/cli.ts} +32 -5
- package/src/{codex-worktree.ts → infra/codex-worktree.ts} +1 -1
- package/src/infra/doctor.test.ts +399 -0
- package/src/infra/doctor.ts +781 -0
- package/src/infra/notify.test.ts +169 -0
- package/src/{notify.ts → infra/notify.ts} +6 -1
- package/src/pipeline/active-session.test.ts +154 -0
- package/src/pipeline/artifacts.test.ts +383 -0
- package/src/{artifacts.ts → pipeline/artifacts.ts} +9 -1
- package/src/{dispatch-service.ts → pipeline/dispatch-service.ts} +1 -1
- package/src/pipeline/dispatch-state.test.ts +382 -0
- package/src/pipeline/pipeline.test.ts +226 -0
- package/src/{pipeline.ts → pipeline/pipeline.ts} +61 -7
- package/src/{tier-assess.ts → pipeline/tier-assess.ts} +1 -1
- package/src/{webhook.test.ts → pipeline/webhook.test.ts} +1 -1
- package/src/{webhook.ts → pipeline/webhook.ts} +8 -8
- package/src/{claude-tool.ts → tools/claude-tool.ts} +31 -5
- package/src/{cli-shared.ts → tools/cli-shared.ts} +5 -4
- package/src/{code-tool.ts → tools/code-tool.ts} +2 -2
- package/src/{codex-tool.ts → tools/codex-tool.ts} +31 -5
- package/src/{gemini-tool.ts → tools/gemini-tool.ts} +31 -5
- package/src/{orchestration-tools.ts → tools/orchestration-tools.ts} +1 -1
- package/src/client.ts +0 -94
- /package/src/{auth.ts → api/auth.ts} +0 -0
- /package/src/{linear-api.ts → api/linear-api.ts} +0 -0
- /package/src/{oauth-callback.ts → api/oauth-callback.ts} +0 -0
- /package/src/{active-session.ts → pipeline/active-session.ts} +0 -0
- /package/src/{dispatch-state.ts → pipeline/dispatch-state.ts} +0 -0
- /package/src/{tools.ts → tools/tools.ts} +0 -0
|
@@ -0,0 +1,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
|
+
}
|