@calltelemetry/openclaw-linear 0.8.4 → 0.8.6
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 +318 -2
- package/openclaw.plugin.json +3 -1
- package/package.json +1 -1
- package/prompts.yaml +24 -12
- package/src/__test__/fixtures/linear-responses.ts +2 -1
- package/src/__test__/fixtures/webhook-payloads.ts +9 -2
- package/src/__test__/webhook-scenarios.test.ts +150 -0
- package/src/api/linear-api.ts +2 -0
- package/src/infra/cli.ts +27 -0
- package/src/infra/doctor.test.ts +130 -3
- package/src/infra/doctor.ts +216 -11
- package/src/pipeline/guidance.test.ts +222 -0
- package/src/pipeline/guidance.ts +156 -0
- package/src/pipeline/pipeline.ts +23 -2
- package/src/pipeline/webhook.ts +150 -30
- package/src/tools/linear-issues-tool.test.ts +453 -0
- package/src/tools/linear-issues-tool.ts +338 -0
- package/src/tools/tools.test.ts +36 -7
- package/src/tools/tools.ts +9 -2
|
@@ -628,4 +628,154 @@ describe("webhook scenario tests — full handler flows", () => {
|
|
|
628
628
|
expect(mockGetIssueDetails).not.toHaveBeenCalled();
|
|
629
629
|
});
|
|
630
630
|
});
|
|
631
|
+
|
|
632
|
+
describe("Guidance integration", () => {
|
|
633
|
+
it("created: appends guidance to agent prompt", async () => {
|
|
634
|
+
const api = createApi();
|
|
635
|
+
const payload = makeAgentSessionEventCreated({
|
|
636
|
+
guidance: "Always use the main branch. Run make test before closing.",
|
|
637
|
+
});
|
|
638
|
+
await postWebhook(api, payload);
|
|
639
|
+
|
|
640
|
+
await waitForMock(mockClearActiveSession);
|
|
641
|
+
|
|
642
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
643
|
+
const runArgs = mockRunAgent.mock.calls[0][0];
|
|
644
|
+
expect(runArgs.message).toContain("Additional Guidance");
|
|
645
|
+
expect(runArgs.message).toContain("Always use the main branch");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("created: guidance is NOT used as user message", async () => {
|
|
649
|
+
const api = createApi();
|
|
650
|
+
const payload = makeAgentSessionEventCreated({
|
|
651
|
+
guidance: "Always use the main branch. Run make test before closing.",
|
|
652
|
+
previousComments: [
|
|
653
|
+
{ body: "Please fix the routing bug", userId: "user-1", createdAt: new Date().toISOString() },
|
|
654
|
+
],
|
|
655
|
+
});
|
|
656
|
+
await postWebhook(api, payload);
|
|
657
|
+
|
|
658
|
+
await waitForMock(mockClearActiveSession);
|
|
659
|
+
|
|
660
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
661
|
+
const msg = mockRunAgent.mock.calls[0][0].message;
|
|
662
|
+
|
|
663
|
+
// Guidance text should appear in the appendix section, not as the user's comment
|
|
664
|
+
const userMsgSection = msg.split("Additional Guidance")[0];
|
|
665
|
+
expect(userMsgSection).toContain("Please fix the routing bug");
|
|
666
|
+
// The guidance string itself should not appear before the appendix
|
|
667
|
+
expect(userMsgSection).not.toContain("Always use the main branch");
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("prompted: includes guidance from promptContext", async () => {
|
|
671
|
+
const api = createApi();
|
|
672
|
+
const payload = makeAgentSessionEventPrompted({
|
|
673
|
+
agentActivity: { content: { body: "Can you also fix the tests?" } },
|
|
674
|
+
promptContext: "## Issue\nENG-123\n\n## Guidance\nUse TypeScript strict mode.\n\n## Comments\nThread.",
|
|
675
|
+
});
|
|
676
|
+
await postWebhook(api, payload);
|
|
677
|
+
|
|
678
|
+
await waitForMock(mockClearActiveSession);
|
|
679
|
+
|
|
680
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
681
|
+
const msg = mockRunAgent.mock.calls[0][0].message;
|
|
682
|
+
expect(msg).toContain("Can you also fix the tests?");
|
|
683
|
+
expect(msg).toContain("Additional Guidance");
|
|
684
|
+
expect(msg).toContain("Use TypeScript strict mode");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("guidance disabled via config: no guidance section in prompt", async () => {
|
|
688
|
+
const api = createApi();
|
|
689
|
+
(api as any).pluginConfig = { defaultAgentId: "mal", enableGuidance: false };
|
|
690
|
+
const payload = makeAgentSessionEventCreated({
|
|
691
|
+
guidance: "Should not appear in prompt",
|
|
692
|
+
});
|
|
693
|
+
await postWebhook(api, payload);
|
|
694
|
+
|
|
695
|
+
await waitForMock(mockClearActiveSession);
|
|
696
|
+
|
|
697
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
698
|
+
const msg = mockRunAgent.mock.calls[0][0].message;
|
|
699
|
+
expect(msg).not.toContain("Additional Guidance");
|
|
700
|
+
expect(msg).not.toContain("Should not appear in prompt");
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("team override disables guidance for specific team", async () => {
|
|
704
|
+
const api = createApi();
|
|
705
|
+
(api as any).pluginConfig = {
|
|
706
|
+
defaultAgentId: "mal",
|
|
707
|
+
enableGuidance: true,
|
|
708
|
+
teamGuidanceOverrides: { "team-1": false },
|
|
709
|
+
};
|
|
710
|
+
const payload = makeAgentSessionEventCreated({
|
|
711
|
+
guidance: "Should be suppressed for team-1",
|
|
712
|
+
});
|
|
713
|
+
await postWebhook(api, payload);
|
|
714
|
+
|
|
715
|
+
await waitForMock(mockClearActiveSession);
|
|
716
|
+
|
|
717
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
718
|
+
const msg = mockRunAgent.mock.calls[0][0].message;
|
|
719
|
+
expect(msg).not.toContain("Additional Guidance");
|
|
720
|
+
expect(msg).not.toContain("Should be suppressed");
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("comment handler uses cached guidance from prior session event", async () => {
|
|
724
|
+
// Step 1: Trigger a created event to cache guidance
|
|
725
|
+
const api = createApi();
|
|
726
|
+
const sessionPayload = makeAgentSessionEventCreated({
|
|
727
|
+
guidance: "Cached guidance from session event",
|
|
728
|
+
});
|
|
729
|
+
await postWebhook(api, sessionPayload);
|
|
730
|
+
await waitForMock(mockClearActiveSession);
|
|
731
|
+
|
|
732
|
+
// Reset mocks for the next webhook
|
|
733
|
+
vi.clearAllMocks();
|
|
734
|
+
mockGetViewerId.mockResolvedValue("viewer-bot-1");
|
|
735
|
+
mockGetIssueDetails.mockResolvedValue(makeIssueDetails());
|
|
736
|
+
mockCreateComment.mockResolvedValue("comment-new-id");
|
|
737
|
+
mockEmitActivity.mockResolvedValue(undefined);
|
|
738
|
+
mockUpdateSession.mockResolvedValue(undefined);
|
|
739
|
+
mockUpdateIssue.mockResolvedValue(true);
|
|
740
|
+
mockCreateSessionOnIssue.mockResolvedValue({ sessionId: "session-mock-2" });
|
|
741
|
+
mockGetTeamLabels.mockResolvedValue([]);
|
|
742
|
+
mockGetTeamStates.mockResolvedValue([
|
|
743
|
+
{ id: "st-backlog", name: "Backlog", type: "backlog" },
|
|
744
|
+
{ id: "st-started", name: "In Progress", type: "started" },
|
|
745
|
+
{ id: "st-done", name: "Done", type: "completed" },
|
|
746
|
+
]);
|
|
747
|
+
mockRunAgent.mockResolvedValue({ success: true, output: "Agent response text" });
|
|
748
|
+
mockClassifyIntent.mockResolvedValue({
|
|
749
|
+
intent: "ask_agent",
|
|
750
|
+
agentId: "mal",
|
|
751
|
+
reasoning: "user requesting help",
|
|
752
|
+
fromFallback: false,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// Step 2: Now send a comment — it should pick up cached guidance
|
|
756
|
+
const commentPayload = makeCommentCreate({
|
|
757
|
+
data: {
|
|
758
|
+
id: "comment-guidance-1",
|
|
759
|
+
body: "Can you investigate further?",
|
|
760
|
+
user: { id: "user-human", name: "Human" },
|
|
761
|
+
issue: {
|
|
762
|
+
id: "issue-1",
|
|
763
|
+
identifier: "ENG-123",
|
|
764
|
+
title: "Fix webhook routing",
|
|
765
|
+
team: { id: "team-1" },
|
|
766
|
+
project: null,
|
|
767
|
+
},
|
|
768
|
+
createdAt: new Date().toISOString(),
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
await postWebhook(api, commentPayload);
|
|
772
|
+
|
|
773
|
+
await waitForMock(mockClearActiveSession);
|
|
774
|
+
|
|
775
|
+
expect(mockRunAgent).toHaveBeenCalledOnce();
|
|
776
|
+
const msg = mockRunAgent.mock.calls[0][0].message;
|
|
777
|
+
expect(msg).toContain("Additional Guidance");
|
|
778
|
+
expect(msg).toContain("Cached guidance from session event");
|
|
779
|
+
});
|
|
780
|
+
});
|
|
631
781
|
});
|
package/src/api/linear-api.ts
CHANGED
|
@@ -307,6 +307,7 @@ export class LinearAgentApi {
|
|
|
307
307
|
description: string | null;
|
|
308
308
|
estimate: number | null;
|
|
309
309
|
state: { name: string; type: string };
|
|
310
|
+
creator: { name: string; email: string | null } | null;
|
|
310
311
|
assignee: { name: string } | null;
|
|
311
312
|
labels: { nodes: Array<{ id: string; name: string }> };
|
|
312
313
|
team: { id: string; name: string; issueEstimationType: string };
|
|
@@ -324,6 +325,7 @@ export class LinearAgentApi {
|
|
|
324
325
|
description
|
|
325
326
|
estimate
|
|
326
327
|
state { name type }
|
|
328
|
+
creator { name email }
|
|
327
329
|
assignee { name }
|
|
328
330
|
labels { nodes { id name } }
|
|
329
331
|
team { id name issueEstimationType }
|
package/src/infra/cli.ts
CHANGED
|
@@ -645,6 +645,33 @@ export function registerCli(program: Command, api: OpenClawPluginApi): void {
|
|
|
645
645
|
}
|
|
646
646
|
});
|
|
647
647
|
|
|
648
|
+
// --- openclaw openclaw-linear code-run ---
|
|
649
|
+
const codeRunCmd = linear
|
|
650
|
+
.command("code-run")
|
|
651
|
+
.description("Manage and diagnose coding tool backends");
|
|
652
|
+
|
|
653
|
+
codeRunCmd
|
|
654
|
+
.command("doctor")
|
|
655
|
+
.description("Deep health check: verify each coding backend (Claude, Codex, Gemini) is callable")
|
|
656
|
+
.option("--json", "Output results as JSON")
|
|
657
|
+
.action(async (opts: { json?: boolean }) => {
|
|
658
|
+
const { checkCodeRunDeep, buildSummary, formatReport, formatReportJson } = await import("./doctor.js");
|
|
659
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
660
|
+
|
|
661
|
+
const sections = await checkCodeRunDeep(pluginConfig);
|
|
662
|
+
const report = { sections, summary: buildSummary(sections) };
|
|
663
|
+
|
|
664
|
+
if (opts.json) {
|
|
665
|
+
console.log(formatReportJson(report));
|
|
666
|
+
} else {
|
|
667
|
+
console.log(formatReport(report));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (report.summary.errors > 0) {
|
|
671
|
+
process.exitCode = 1;
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
|
|
648
675
|
// --- openclaw openclaw-linear webhooks ---
|
|
649
676
|
const webhooksCmd = linear
|
|
650
677
|
.command("webhooks")
|
package/src/infra/doctor.test.ts
CHANGED
|
@@ -50,14 +50,18 @@ vi.mock("./codex-worktree.js", () => ({
|
|
|
50
50
|
|
|
51
51
|
vi.mock("../tools/code-tool.js", () => ({
|
|
52
52
|
loadCodingConfig: vi.fn(() => ({
|
|
53
|
-
codingTool: "
|
|
54
|
-
agentCodingTools: {},
|
|
53
|
+
codingTool: "codex",
|
|
54
|
+
agentCodingTools: { inara: "claude" },
|
|
55
55
|
backends: {
|
|
56
56
|
claude: { aliases: ["claude", "anthropic"] },
|
|
57
57
|
codex: { aliases: ["codex", "openai"] },
|
|
58
58
|
gemini: { aliases: ["gemini", "google"] },
|
|
59
59
|
},
|
|
60
60
|
})),
|
|
61
|
+
resolveCodingBackend: vi.fn((config: any, agentId?: string) => {
|
|
62
|
+
if (agentId && config?.agentCodingTools?.[agentId]) return config.agentCodingTools[agentId];
|
|
63
|
+
return config?.codingTool ?? "codex";
|
|
64
|
+
}),
|
|
61
65
|
}));
|
|
62
66
|
|
|
63
67
|
vi.mock("./webhook-provision.js", () => ({
|
|
@@ -78,6 +82,8 @@ import {
|
|
|
78
82
|
checkConnectivity,
|
|
79
83
|
checkDispatchHealth,
|
|
80
84
|
checkWebhooks,
|
|
85
|
+
checkCodeRunDeep,
|
|
86
|
+
buildSummary,
|
|
81
87
|
runDoctor,
|
|
82
88
|
formatReport,
|
|
83
89
|
formatReportJson,
|
|
@@ -199,7 +205,7 @@ describe("checkCodingTools", () => {
|
|
|
199
205
|
const checks = checkCodingTools();
|
|
200
206
|
const configCheck = checks.find((c) => c.label.includes("coding-tools.json"));
|
|
201
207
|
expect(configCheck?.severity).toBe("pass");
|
|
202
|
-
expect(configCheck?.label).toContain("
|
|
208
|
+
expect(configCheck?.label).toContain("codex");
|
|
203
209
|
});
|
|
204
210
|
|
|
205
211
|
it("reports warn for missing CLIs", () => {
|
|
@@ -431,3 +437,124 @@ describe("formatReportJson", () => {
|
|
|
431
437
|
expect(parsed.summary.passed).toBe(1);
|
|
432
438
|
});
|
|
433
439
|
});
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// buildSummary
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
describe("buildSummary", () => {
|
|
446
|
+
it("counts pass/warn/fail across sections", () => {
|
|
447
|
+
const sections = [
|
|
448
|
+
{ name: "A", checks: [{ label: "ok", severity: "pass" as const }, { label: "meh", severity: "warn" as const }] },
|
|
449
|
+
{ name: "B", checks: [{ label: "bad", severity: "fail" as const }] },
|
|
450
|
+
];
|
|
451
|
+
const summary = buildSummary(sections);
|
|
452
|
+
expect(summary).toEqual({ passed: 1, warnings: 1, errors: 1 });
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// checkCodeRunDeep
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
describe("checkCodeRunDeep", () => {
|
|
461
|
+
// Run a single invocation and share results across assertions to avoid
|
|
462
|
+
// repeated 30s live CLI calls (the live test spawns all 3 backends).
|
|
463
|
+
let sections: Awaited<ReturnType<typeof checkCodeRunDeep>>;
|
|
464
|
+
|
|
465
|
+
beforeEach(async () => {
|
|
466
|
+
if (!sections) {
|
|
467
|
+
sections = await checkCodeRunDeep();
|
|
468
|
+
}
|
|
469
|
+
}, 120_000);
|
|
470
|
+
|
|
471
|
+
it("returns 4 sections (3 backends + routing)", () => {
|
|
472
|
+
expect(sections).toHaveLength(4);
|
|
473
|
+
expect(sections.map((s) => s.name)).toEqual([
|
|
474
|
+
"Code Run: Claude Code (Anthropic)",
|
|
475
|
+
"Code Run: Codex (OpenAI)",
|
|
476
|
+
"Code Run: Gemini CLI (Google)",
|
|
477
|
+
"Code Run: Routing",
|
|
478
|
+
]);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("each backend section has binary, API key, and live test checks", () => {
|
|
482
|
+
for (const section of sections.slice(0, 3)) {
|
|
483
|
+
const labels = section.checks.map((c) => c.label);
|
|
484
|
+
expect(labels.some((l) => l.includes("Binary:"))).toBe(true);
|
|
485
|
+
expect(labels.some((l) => l.includes("API key:"))).toBe(true);
|
|
486
|
+
expect(labels.some((l) => l.includes("Live test:"))).toBe(true);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("all check results have valid severity", () => {
|
|
491
|
+
for (const section of sections) {
|
|
492
|
+
for (const check of section.checks) {
|
|
493
|
+
expect(check).toHaveProperty("label");
|
|
494
|
+
expect(check).toHaveProperty("severity");
|
|
495
|
+
expect(["pass", "warn", "fail"]).toContain(check.severity);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("shows routing with default backend and callable count", () => {
|
|
501
|
+
const routing = sections.find((s) => s.name === "Code Run: Routing")!;
|
|
502
|
+
expect(routing).toBeDefined();
|
|
503
|
+
|
|
504
|
+
const defaultCheck = routing.checks.find((c) => c.label.includes("Default backend:"));
|
|
505
|
+
expect(defaultCheck?.severity).toBe("pass");
|
|
506
|
+
expect(defaultCheck?.label).toContain("codex");
|
|
507
|
+
|
|
508
|
+
const callableCheck = routing.checks.find((c) => c.label.includes("Callable backends:"));
|
|
509
|
+
expect(callableCheck?.severity).toBe("pass");
|
|
510
|
+
expect(callableCheck?.label).toMatch(/Callable backends: \d+\/3/);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("shows agent override routing for inara", () => {
|
|
514
|
+
const routing = sections.find((s) => s.name === "Code Run: Routing")!;
|
|
515
|
+
const inaraCheck = routing.checks.find((c) => c.label.toLowerCase().includes("inara"));
|
|
516
|
+
if (inaraCheck) {
|
|
517
|
+
expect(inaraCheck.label).toContain("claude");
|
|
518
|
+
expect(inaraCheck.label).toContain("override");
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// API key tests — still call checkCodeRunDeep which runs live CLI checks
|
|
523
|
+
it("detects API key from plugin config", async () => {
|
|
524
|
+
const origKey = process.env.ANTHROPIC_API_KEY;
|
|
525
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
const result = await checkCodeRunDeep({ claudeApiKey: "sk-from-config" });
|
|
529
|
+
const claudeKey = result[0].checks.find((c) => c.label.includes("API key:"));
|
|
530
|
+
expect(claudeKey?.severity).toBe("pass");
|
|
531
|
+
expect(claudeKey?.label).toContain("claudeApiKey");
|
|
532
|
+
} finally {
|
|
533
|
+
if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
|
|
534
|
+
else delete process.env.ANTHROPIC_API_KEY;
|
|
535
|
+
}
|
|
536
|
+
}, 120_000);
|
|
537
|
+
|
|
538
|
+
it("warns when API key missing", async () => {
|
|
539
|
+
const origKeys = {
|
|
540
|
+
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
|
541
|
+
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
|
542
|
+
GOOGLE_GENAI_API_KEY: process.env.GOOGLE_GENAI_API_KEY,
|
|
543
|
+
};
|
|
544
|
+
delete process.env.GEMINI_API_KEY;
|
|
545
|
+
delete process.env.GOOGLE_API_KEY;
|
|
546
|
+
delete process.env.GOOGLE_GENAI_API_KEY;
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
const result = await checkCodeRunDeep();
|
|
550
|
+
const geminiKey = result[2].checks.find((c) => c.label.includes("API key:"));
|
|
551
|
+
expect(geminiKey?.severity).toBe("warn");
|
|
552
|
+
expect(geminiKey?.label).toContain("not found");
|
|
553
|
+
} finally {
|
|
554
|
+
for (const [k, v] of Object.entries(origKeys)) {
|
|
555
|
+
if (v) process.env[k] = v;
|
|
556
|
+
else delete process.env[k];
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}, 120_000);
|
|
560
|
+
});
|
package/src/infra/doctor.ts
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
import { existsSync, readFileSync, statSync, accessSync, unlinkSync, chmodSync, constants } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
10
|
|
|
11
11
|
import { resolveLinearToken, LinearAgentApi, AUTH_PROFILES_PATH, LINEAR_GRAPHQL_URL } from "../api/linear-api.js";
|
|
12
12
|
import { readDispatchState, listActiveDispatches, listStaleDispatches, pruneCompleted, type DispatchState } from "../pipeline/dispatch-state.js";
|
|
13
13
|
import { loadPrompts, clearPromptCache } from "../pipeline/pipeline.js";
|
|
14
14
|
import { listWorktrees } from "./codex-worktree.js";
|
|
15
|
-
import { loadCodingConfig, type CodingBackend } from "../tools/code-tool.js";
|
|
15
|
+
import { loadCodingConfig, resolveCodingBackend, type CodingBackend } from "../tools/code-tool.js";
|
|
16
16
|
import { getWebhookStatus, provisionWebhook, REQUIRED_RESOURCE_TYPES } from "./webhook-provision.js";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
@@ -767,19 +767,209 @@ export async function runDoctor(opts: DoctorOptions): Promise<DoctorReport> {
|
|
|
767
767
|
}
|
|
768
768
|
}
|
|
769
769
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
770
|
+
return { sections, summary: buildSummary(sections) };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
// Code Run Deep Checks
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
interface BackendSpec {
|
|
778
|
+
id: CodingBackend;
|
|
779
|
+
label: string;
|
|
780
|
+
bin: string;
|
|
781
|
+
/** CLI args for a minimal live invocation test */
|
|
782
|
+
testArgs: string[];
|
|
783
|
+
/** Environment variable names that provide an API key */
|
|
784
|
+
envKeys: string[];
|
|
785
|
+
/** Plugin config key for API key (if any) */
|
|
786
|
+
configKey?: string;
|
|
787
|
+
/** Env vars to unset before spawning (e.g. CLAUDECODE) */
|
|
788
|
+
unsetEnv?: string[];
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const BACKEND_SPECS: BackendSpec[] = [
|
|
792
|
+
{
|
|
793
|
+
id: "claude",
|
|
794
|
+
label: "Claude Code (Anthropic)",
|
|
795
|
+
bin: "/home/claw/.npm-global/bin/claude",
|
|
796
|
+
testArgs: ["--print", "-p", "Respond with the single word hello", "--output-format", "stream-json", "--max-turns", "1", "--dangerously-skip-permissions"],
|
|
797
|
+
envKeys: ["ANTHROPIC_API_KEY"],
|
|
798
|
+
configKey: "claudeApiKey",
|
|
799
|
+
unsetEnv: ["CLAUDECODE"],
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
id: "codex",
|
|
803
|
+
label: "Codex (OpenAI)",
|
|
804
|
+
bin: "/home/claw/.npm-global/bin/codex",
|
|
805
|
+
testArgs: ["exec", "--json", "--ephemeral", "--full-auto", "echo hello"],
|
|
806
|
+
envKeys: ["OPENAI_API_KEY"],
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
id: "gemini",
|
|
810
|
+
label: "Gemini CLI (Google)",
|
|
811
|
+
bin: "/home/claw/.npm-global/bin/gemini",
|
|
812
|
+
testArgs: ["-p", "Respond with the single word hello", "-o", "stream-json", "--yolo"],
|
|
813
|
+
envKeys: ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENAI_API_KEY"],
|
|
814
|
+
},
|
|
815
|
+
];
|
|
816
|
+
|
|
817
|
+
function checkBackendBinary(spec: BackendSpec): { installed: boolean; checks: CheckResult[] } {
|
|
818
|
+
const checks: CheckResult[] = [];
|
|
819
|
+
|
|
820
|
+
// Binary existence
|
|
821
|
+
try {
|
|
822
|
+
accessSync(spec.bin, constants.X_OK);
|
|
823
|
+
} catch {
|
|
824
|
+
checks.push(fail(
|
|
825
|
+
`Binary: not found at ${spec.bin}`,
|
|
826
|
+
undefined,
|
|
827
|
+
`Install ${spec.id}: npm install -g <package>`,
|
|
828
|
+
));
|
|
829
|
+
return { installed: false, checks };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Version check
|
|
833
|
+
try {
|
|
834
|
+
const env = { ...process.env } as Record<string, string | undefined>;
|
|
835
|
+
for (const key of spec.unsetEnv ?? []) delete env[key];
|
|
836
|
+
const raw = execFileSync(spec.bin, ["--version"], {
|
|
837
|
+
encoding: "utf8",
|
|
838
|
+
timeout: 15_000,
|
|
839
|
+
env: env as NodeJS.ProcessEnv,
|
|
840
|
+
}).trim();
|
|
841
|
+
checks.push(pass(`Binary: ${raw || "installed"} (${spec.bin})`));
|
|
842
|
+
} catch {
|
|
843
|
+
checks.push(pass(`Binary: installed (${spec.bin})`));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return { installed: true, checks };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function checkBackendApiKey(spec: BackendSpec, pluginConfig?: Record<string, unknown>): CheckResult {
|
|
850
|
+
// Check plugin config first
|
|
851
|
+
if (spec.configKey) {
|
|
852
|
+
const configVal = pluginConfig?.[spec.configKey];
|
|
853
|
+
if (typeof configVal === "string" && configVal) {
|
|
854
|
+
return pass(`API key: configured (${spec.configKey} in plugin config)`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Check env vars
|
|
859
|
+
for (const envKey of spec.envKeys) {
|
|
860
|
+
if (process.env[envKey]) {
|
|
861
|
+
return pass(`API key: configured (${envKey})`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return warn(
|
|
866
|
+
`API key: not found`,
|
|
867
|
+
`Checked: ${spec.envKeys.join(", ")}${spec.configKey ? `, pluginConfig.${spec.configKey}` : ""}`,
|
|
868
|
+
{ fix: `Set ${spec.envKeys[0]} environment variable or configure in plugin config` },
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function checkBackendLive(spec: BackendSpec, pluginConfig?: Record<string, unknown>): CheckResult {
|
|
873
|
+
const env = { ...process.env } as Record<string, string | undefined>;
|
|
874
|
+
for (const key of spec.unsetEnv ?? []) delete env[key];
|
|
875
|
+
|
|
876
|
+
// Pass API key from plugin config if available (Claude-specific)
|
|
877
|
+
if (spec.configKey) {
|
|
878
|
+
const configVal = pluginConfig?.[spec.configKey] as string | undefined;
|
|
879
|
+
if (configVal && spec.envKeys[0]) {
|
|
880
|
+
env[spec.envKeys[0]] = configVal;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const start = Date.now();
|
|
885
|
+
try {
|
|
886
|
+
const result = spawnSync(spec.bin, spec.testArgs, {
|
|
887
|
+
encoding: "utf8",
|
|
888
|
+
timeout: 30_000,
|
|
889
|
+
env: env as NodeJS.ProcessEnv,
|
|
890
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
894
|
+
|
|
895
|
+
if (result.error) {
|
|
896
|
+
const msg = result.error.message ?? String(result.error);
|
|
897
|
+
if (msg.includes("ETIMEDOUT") || msg.includes("timed out")) {
|
|
898
|
+
return warn(`Live test: timed out after 30s`);
|
|
778
899
|
}
|
|
900
|
+
return warn(`Live test: spawn error — ${msg.slice(0, 200)}`);
|
|
779
901
|
}
|
|
902
|
+
|
|
903
|
+
if (result.status === 0) {
|
|
904
|
+
return pass(`Live test: responded in ${elapsed}s`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Non-zero exit
|
|
908
|
+
const stderr = (result.stderr ?? "").trim().slice(0, 200);
|
|
909
|
+
const stdout = (result.stdout ?? "").trim().slice(0, 200);
|
|
910
|
+
const detail = stderr || stdout || "(no output)";
|
|
911
|
+
return warn(`Live test: exit code ${result.status} (${elapsed}s) — ${detail}`);
|
|
912
|
+
} catch (err) {
|
|
913
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
914
|
+
return warn(`Live test: error (${elapsed}s) — ${err instanceof Error ? err.message.slice(0, 200) : String(err)}`);
|
|
780
915
|
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Deep health checks for coding tool backends.
|
|
920
|
+
*
|
|
921
|
+
* Verifies binary installation, API key configuration, and live callability
|
|
922
|
+
* for each backend (Claude, Codex, Gemini). Also shows agent routing.
|
|
923
|
+
*
|
|
924
|
+
* Usage: openclaw openclaw-linear code-run doctor [--json]
|
|
925
|
+
*/
|
|
926
|
+
export async function checkCodeRunDeep(
|
|
927
|
+
pluginConfig?: Record<string, unknown>,
|
|
928
|
+
): Promise<CheckSection[]> {
|
|
929
|
+
const sections: CheckSection[] = [];
|
|
930
|
+
const config = loadCodingConfig();
|
|
931
|
+
let callableCount = 0;
|
|
932
|
+
|
|
933
|
+
for (const spec of BACKEND_SPECS) {
|
|
934
|
+
const checks: CheckResult[] = [];
|
|
935
|
+
|
|
936
|
+
// 1. Binary check
|
|
937
|
+
const { installed, checks: binChecks } = checkBackendBinary(spec);
|
|
938
|
+
checks.push(...binChecks);
|
|
939
|
+
|
|
940
|
+
if (installed) {
|
|
941
|
+
// 2. API key check
|
|
942
|
+
checks.push(checkBackendApiKey(spec, pluginConfig));
|
|
943
|
+
|
|
944
|
+
// 3. Live invocation test
|
|
945
|
+
const liveResult = checkBackendLive(spec, pluginConfig);
|
|
946
|
+
checks.push(liveResult);
|
|
781
947
|
|
|
782
|
-
|
|
948
|
+
if (liveResult.severity === "pass") callableCount++;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
sections.push({ name: `Code Run: ${spec.label}`, checks });
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Routing summary section
|
|
955
|
+
const routingChecks: CheckResult[] = [];
|
|
956
|
+
const defaultBackend = resolveCodingBackend(config);
|
|
957
|
+
routingChecks.push(pass(`Default backend: ${defaultBackend}`));
|
|
958
|
+
|
|
959
|
+
const profiles = loadAgentProfiles();
|
|
960
|
+
for (const [agentId, profile] of Object.entries(profiles)) {
|
|
961
|
+
const resolved = resolveCodingBackend(config, agentId);
|
|
962
|
+
const isOverride = config.agentCodingTools?.[agentId] != null;
|
|
963
|
+
const label = profile.label ?? agentId;
|
|
964
|
+
routingChecks.push(pass(
|
|
965
|
+
`${label} → ${resolved}${isOverride ? " (override)" : " (default)"}`,
|
|
966
|
+
));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
routingChecks.push(pass(`Callable backends: ${callableCount}/${BACKEND_SPECS.length}`));
|
|
970
|
+
sections.push({ name: "Code Run: Routing", checks: routingChecks });
|
|
971
|
+
|
|
972
|
+
return sections;
|
|
783
973
|
}
|
|
784
974
|
|
|
785
975
|
// ---------------------------------------------------------------------------
|
|
@@ -831,3 +1021,18 @@ export function formatReport(report: DoctorReport): string {
|
|
|
831
1021
|
export function formatReportJson(report: DoctorReport): string {
|
|
832
1022
|
return JSON.stringify(report, null, 2);
|
|
833
1023
|
}
|
|
1024
|
+
|
|
1025
|
+
/** Build a summary by counting pass/warn/fail across sections. */
|
|
1026
|
+
export function buildSummary(sections: CheckSection[]): { passed: number; warnings: number; errors: number } {
|
|
1027
|
+
let passed = 0, warnings = 0, errors = 0;
|
|
1028
|
+
for (const section of sections) {
|
|
1029
|
+
for (const check of section.checks) {
|
|
1030
|
+
switch (check.severity) {
|
|
1031
|
+
case "pass": passed++; break;
|
|
1032
|
+
case "warn": warnings++; break;
|
|
1033
|
+
case "fail": errors++; break;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return { passed, warnings, errors };
|
|
1038
|
+
}
|