@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,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 "
|
|
13
|
-
import { LINEAR_OAUTH_AUTH_URL, LINEAR_OAUTH_TOKEN_URL, LINEAR_AGENT_SCOPES } from "
|
|
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 "
|
|
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 "
|
|
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
|
+
});
|