@calltelemetry/openclaw-linear 0.9.11 → 0.9.14
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 +13 -0
- package/package.json +1 -1
- package/prompts.yaml +22 -7
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +44 -32
- package/src/__test__/smoke-linear-api.test.ts +142 -0
- package/src/__test__/webhook-scenarios.test.ts +44 -0
- package/src/agent/agent.ts +18 -4
- package/src/infra/doctor.test.ts +77 -578
- package/src/infra/doctor.ts +106 -0
- package/src/pipeline/pipeline.test.ts +95 -0
- package/src/pipeline/pipeline.ts +37 -2
- package/src/pipeline/webhook.test.ts +1 -0
- package/src/pipeline/webhook.ts +17 -3
- package/src/tools/code-tool.ts +12 -0
- package/src/tools/linear-issues-tool.test.ts +1 -1
- package/src/tools/linear-issues-tool.ts +4 -2
package/src/infra/doctor.test.ts
CHANGED
|
@@ -1630,621 +1630,120 @@ describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
|
1630
1630
|
});
|
|
1631
1631
|
|
|
1632
1632
|
// ---------------------------------------------------------------------------
|
|
1633
|
-
// checkFilesAndDirs —
|
|
1633
|
+
// checkFilesAndDirs — CLAUDE.md and AGENTS.md checks
|
|
1634
1634
|
// ---------------------------------------------------------------------------
|
|
1635
1635
|
|
|
1636
|
-
describe("checkFilesAndDirs —
|
|
1636
|
+
describe("checkFilesAndDirs — CLAUDE.md and AGENTS.md", () => {
|
|
1637
1637
|
let tmpDir: string;
|
|
1638
1638
|
|
|
1639
1639
|
beforeEach(() => {
|
|
1640
|
-
tmpDir = mkdtempSync(join(tmpdir(), "doctor-
|
|
1640
|
+
tmpDir = mkdtempSync(join(tmpdir(), "doctor-md-"));
|
|
1641
|
+
const { execFileSync } = require("node:child_process");
|
|
1642
|
+
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
|
|
1641
1643
|
});
|
|
1642
1644
|
|
|
1643
|
-
it("
|
|
1644
|
-
|
|
1645
|
-
const
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
const staleTime = Date.now() - 60_000; // 60 seconds old (> 30s LOCK_STALE_MS)
|
|
1650
|
-
const { utimesSync } = await import("node:fs");
|
|
1651
|
-
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1652
|
-
|
|
1653
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1654
|
-
dispatches: { active: {}, completed: {} },
|
|
1655
|
-
sessionMap: {},
|
|
1656
|
-
processedEvents: [],
|
|
1657
|
-
});
|
|
1658
|
-
|
|
1659
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1660
|
-
const lockCheck = checks.find((c) => c.label.includes("Stale lock file"));
|
|
1661
|
-
expect(lockCheck?.severity).toBe("warn");
|
|
1662
|
-
expect(lockCheck?.fixable).toBe(true);
|
|
1645
|
+
it("passes when CLAUDE.md exists in base repo", async () => {
|
|
1646
|
+
writeFileSync(join(tmpDir, "CLAUDE.md"), "# My Project\n## Tech Stack\n- TypeScript");
|
|
1647
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1648
|
+
const claudeCheck = checks.find((c) => c.label.includes("CLAUDE.md found"));
|
|
1649
|
+
expect(claudeCheck).toBeDefined();
|
|
1650
|
+
expect(claudeCheck?.severity).toBe("pass");
|
|
1663
1651
|
});
|
|
1664
1652
|
|
|
1665
|
-
it("
|
|
1666
|
-
const
|
|
1667
|
-
const
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
const { utimesSync } = await import("node:fs");
|
|
1672
|
-
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1673
|
-
|
|
1674
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1675
|
-
dispatches: { active: {}, completed: {} },
|
|
1676
|
-
sessionMap: {},
|
|
1677
|
-
processedEvents: [],
|
|
1678
|
-
});
|
|
1679
|
-
|
|
1680
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, true);
|
|
1681
|
-
const lockCheck = checks.find((c) => c.label.includes("Stale lock file removed"));
|
|
1682
|
-
expect(lockCheck?.severity).toBe("pass");
|
|
1683
|
-
expect(existsSync(lockPath)).toBe(false);
|
|
1653
|
+
it("warns when CLAUDE.md is missing from base repo", async () => {
|
|
1654
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1655
|
+
const claudeCheck = checks.find((c) => c.label.includes("No CLAUDE.md"));
|
|
1656
|
+
expect(claudeCheck).toBeDefined();
|
|
1657
|
+
expect(claudeCheck?.severity).toBe("warn");
|
|
1658
|
+
expect(claudeCheck?.fix).toContain("CLAUDE.md");
|
|
1684
1659
|
});
|
|
1685
1660
|
|
|
1686
|
-
it("
|
|
1687
|
-
|
|
1688
|
-
const
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1694
|
-
dispatches: { active: {}, completed: {} },
|
|
1695
|
-
sessionMap: {},
|
|
1696
|
-
processedEvents: [],
|
|
1697
|
-
});
|
|
1698
|
-
|
|
1699
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1700
|
-
const lockCheck = checks.find((c) => c.label.includes("Lock file active"));
|
|
1701
|
-
expect(lockCheck?.severity).toBe("warn");
|
|
1702
|
-
expect(lockCheck?.label).toContain("may be in use");
|
|
1661
|
+
it("passes when AGENTS.md exists in base repo", async () => {
|
|
1662
|
+
writeFileSync(join(tmpDir, "AGENTS.md"), "# Agent Guidelines\n## Code Style");
|
|
1663
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1664
|
+
const agentsCheck = checks.find((c) => c.label.includes("AGENTS.md found"));
|
|
1665
|
+
expect(agentsCheck).toBeDefined();
|
|
1666
|
+
expect(agentsCheck?.severity).toBe("pass");
|
|
1703
1667
|
});
|
|
1704
|
-
});
|
|
1705
|
-
|
|
1706
|
-
// ---------------------------------------------------------------------------
|
|
1707
|
-
// checkFilesAndDirs — prompt variable edge cases
|
|
1708
|
-
// ---------------------------------------------------------------------------
|
|
1709
1668
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
},
|
|
1717
|
-
audit: {
|
|
1718
|
-
system: "ok",
|
|
1719
|
-
task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}", // has all vars
|
|
1720
|
-
},
|
|
1721
|
-
rework: { addendum: "Fix these gaps: {{gaps}}" },
|
|
1722
|
-
} as any);
|
|
1723
|
-
|
|
1724
|
-
const checks = await checkFilesAndDirs();
|
|
1725
|
-
const promptCheck = checks.find((c) => c.label.includes("Prompt issues"));
|
|
1726
|
-
expect(promptCheck?.severity).toBe("fail");
|
|
1727
|
-
expect(promptCheck?.label).toContain("worker.task missing");
|
|
1728
|
-
// Crucially, audit.task should NOT be missing
|
|
1729
|
-
expect(promptCheck?.label).not.toContain("audit.task missing");
|
|
1669
|
+
it("warns when AGENTS.md is missing from base repo", async () => {
|
|
1670
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1671
|
+
const agentsCheck = checks.find((c) => c.label.includes("No AGENTS.md"));
|
|
1672
|
+
expect(agentsCheck).toBeDefined();
|
|
1673
|
+
expect(agentsCheck?.severity).toBe("warn");
|
|
1674
|
+
expect(agentsCheck?.fix).toContain("AGENTS.md");
|
|
1730
1675
|
});
|
|
1731
1676
|
|
|
1732
|
-
it("
|
|
1733
|
-
|
|
1677
|
+
it("passes both when both files exist", async () => {
|
|
1678
|
+
writeFileSync(join(tmpDir, "CLAUDE.md"), "# Project");
|
|
1679
|
+
writeFileSync(join(tmpDir, "AGENTS.md"), "# Guidelines");
|
|
1680
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1681
|
+
const claudeCheck = checks.find((c) => c.label.includes("CLAUDE.md found"));
|
|
1682
|
+
const agentsCheck = checks.find((c) => c.label.includes("AGENTS.md found"));
|
|
1683
|
+
expect(claudeCheck?.severity).toBe("pass");
|
|
1684
|
+
expect(agentsCheck?.severity).toBe("pass");
|
|
1685
|
+
});
|
|
1734
1686
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1687
|
+
it("shows file size in KB when CLAUDE.md exists", async () => {
|
|
1688
|
+
writeFileSync(join(tmpDir, "CLAUDE.md"), "x".repeat(2048));
|
|
1689
|
+
const checks = await checkFilesAndDirs({ codexBaseRepo: tmpDir });
|
|
1690
|
+
const claudeCheck = checks.find((c) => c.label.includes("CLAUDE.md found"));
|
|
1691
|
+
expect(claudeCheck?.label).toContain("2KB");
|
|
1739
1692
|
});
|
|
1740
1693
|
});
|
|
1741
1694
|
|
|
1742
1695
|
// ---------------------------------------------------------------------------
|
|
1743
|
-
// checkFilesAndDirs —
|
|
1696
|
+
// checkFilesAndDirs — multi-repo path validation
|
|
1744
1697
|
// ---------------------------------------------------------------------------
|
|
1745
1698
|
|
|
1746
|
-
describe("checkFilesAndDirs —
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
const
|
|
1752
|
-
|
|
1753
|
-
expect(wtCheck?.detail).toContain("Will be created on first dispatch");
|
|
1699
|
+
describe("checkFilesAndDirs — multi-repo validation", () => {
|
|
1700
|
+
let tmpDir: string;
|
|
1701
|
+
|
|
1702
|
+
beforeEach(() => {
|
|
1703
|
+
tmpDir = mkdtempSync(join(tmpdir(), "doctor-repos-"));
|
|
1704
|
+
const { execFileSync } = require("node:child_process");
|
|
1705
|
+
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
|
|
1754
1706
|
});
|
|
1755
1707
|
|
|
1756
|
-
it("
|
|
1708
|
+
it("passes for valid git repo paths", async () => {
|
|
1709
|
+
const repoDir = join(tmpDir, "myrepo");
|
|
1710
|
+
mkdirSync(repoDir);
|
|
1711
|
+
const { execFileSync } = require("node:child_process");
|
|
1712
|
+
execFileSync("git", ["init"], { cwd: repoDir, stdio: "ignore" });
|
|
1713
|
+
|
|
1757
1714
|
const checks = await checkFilesAndDirs({
|
|
1758
|
-
codexBaseRepo:
|
|
1715
|
+
codexBaseRepo: tmpDir,
|
|
1716
|
+
repos: { frontend: repoDir },
|
|
1759
1717
|
});
|
|
1760
|
-
const repoCheck = checks.find((c) => c.label.includes(
|
|
1761
|
-
expect(repoCheck
|
|
1762
|
-
expect(repoCheck?.
|
|
1718
|
+
const repoCheck = checks.find((c) => c.label.includes('Repo "frontend"'));
|
|
1719
|
+
expect(repoCheck).toBeDefined();
|
|
1720
|
+
expect(repoCheck?.severity).toBe("pass");
|
|
1721
|
+
expect(repoCheck?.label).toContain("valid git repo");
|
|
1763
1722
|
});
|
|
1764
1723
|
|
|
1765
|
-
it("
|
|
1766
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nongit-"));
|
|
1724
|
+
it("fails for non-existent repo paths", async () => {
|
|
1767
1725
|
const checks = await checkFilesAndDirs({
|
|
1768
1726
|
codexBaseRepo: tmpDir,
|
|
1727
|
+
repos: { backend: "/tmp/nonexistent-repo-path-12345" },
|
|
1769
1728
|
});
|
|
1770
|
-
const repoCheck = checks.find((c) => c.label.includes(
|
|
1729
|
+
const repoCheck = checks.find((c) => c.label.includes('Repo "backend"'));
|
|
1730
|
+
expect(repoCheck).toBeDefined();
|
|
1771
1731
|
expect(repoCheck?.severity).toBe("fail");
|
|
1772
|
-
expect(repoCheck?.
|
|
1732
|
+
expect(repoCheck?.label).toContain("does not exist");
|
|
1773
1733
|
});
|
|
1774
|
-
});
|
|
1775
|
-
|
|
1776
|
-
// ---------------------------------------------------------------------------
|
|
1777
|
-
// checkFilesAndDirs — tilde path resolution branches
|
|
1778
|
-
// ---------------------------------------------------------------------------
|
|
1779
|
-
|
|
1780
|
-
describe("checkFilesAndDirs — tilde path resolution", () => {
|
|
1781
|
-
it("resolves ~/... dispatch state path", async () => {
|
|
1782
|
-
vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("ENOENT"));
|
|
1783
1734
|
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
});
|
|
1788
|
-
// The file won't exist (tilde-resolved), so we get the "no file yet" message
|
|
1789
|
-
const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
|
|
1790
|
-
expect(stateCheck).toBeDefined();
|
|
1791
|
-
});
|
|
1735
|
+
it("fails for paths that exist but are not git repos", async () => {
|
|
1736
|
+
// Create a dir outside the git repo so git rev-parse fails
|
|
1737
|
+
const nonGitDir = mkdtempSync(join(tmpdir(), "doctor-nongit-"));
|
|
1792
1738
|
|
|
1793
|
-
it("resolves ~/... worktree base dir path", async () => {
|
|
1794
1739
|
const checks = await checkFilesAndDirs({
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir"));
|
|
1798
|
-
expect(wtCheck).toBeDefined();
|
|
1799
|
-
});
|
|
1800
|
-
});
|
|
1801
|
-
|
|
1802
|
-
// ---------------------------------------------------------------------------
|
|
1803
|
-
// checkDispatchHealth — orphaned worktree singular
|
|
1804
|
-
// ---------------------------------------------------------------------------
|
|
1805
|
-
|
|
1806
|
-
describe("checkDispatchHealth — edge cases", () => {
|
|
1807
|
-
it("reports single orphaned worktree (singular)", async () => {
|
|
1808
|
-
vi.mocked(listWorktrees).mockReturnValueOnce([
|
|
1809
|
-
{ issueIdentifier: "ORPHAN-1", path: "/tmp/wt1" } as any,
|
|
1810
|
-
]);
|
|
1811
|
-
|
|
1812
|
-
const checks = await checkDispatchHealth();
|
|
1813
|
-
const orphanCheck = checks.find((c) => c.label.includes("orphaned worktree"));
|
|
1814
|
-
expect(orphanCheck?.severity).toBe("warn");
|
|
1815
|
-
expect(orphanCheck?.label).toContain("1 orphaned worktree");
|
|
1816
|
-
expect(orphanCheck?.label).not.toContain("worktrees"); // singular, not plural
|
|
1817
|
-
});
|
|
1818
|
-
|
|
1819
|
-
it("prunes multiple old completed dispatches (plural)", async () => {
|
|
1820
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1821
|
-
dispatches: {
|
|
1822
|
-
active: {},
|
|
1823
|
-
completed: {
|
|
1824
|
-
"A-1": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
1825
|
-
"A-2": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
1826
|
-
},
|
|
1827
|
-
},
|
|
1828
|
-
sessionMap: {},
|
|
1829
|
-
processedEvents: [],
|
|
1740
|
+
codexBaseRepo: tmpDir,
|
|
1741
|
+
repos: { api: nonGitDir },
|
|
1830
1742
|
});
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
expect(pruneCheck?.severity).toBe("pass");
|
|
1836
|
-
expect(pruneCheck?.label).toContain("2 old completed dispatches");
|
|
1837
|
-
});
|
|
1838
|
-
|
|
1839
|
-
it("reports single stale dispatch (singular)", async () => {
|
|
1840
|
-
vi.mocked(listStaleDispatches).mockReturnValueOnce([
|
|
1841
|
-
{ issueIdentifier: "API-1", status: "working" } as any,
|
|
1842
|
-
]);
|
|
1843
|
-
|
|
1844
|
-
const checks = await checkDispatchHealth();
|
|
1845
|
-
const staleCheck = checks.find((c) => c.label.includes("stale dispatch"));
|
|
1846
|
-
expect(staleCheck?.severity).toBe("warn");
|
|
1847
|
-
// Singular: "1 stale dispatch" not "1 stale dispatches"
|
|
1848
|
-
expect(staleCheck?.label).toMatch(/1 stale dispatch(?!es)/);
|
|
1849
|
-
});
|
|
1850
|
-
});
|
|
1851
|
-
|
|
1852
|
-
// ---------------------------------------------------------------------------
|
|
1853
|
-
// checkConnectivity — webhook self-test with ok but body !== "ok"
|
|
1854
|
-
// ---------------------------------------------------------------------------
|
|
1855
|
-
|
|
1856
|
-
describe("checkConnectivity — webhook non-ok body", () => {
|
|
1857
|
-
it("warns when webhook returns ok status but body is not 'ok'", async () => {
|
|
1858
|
-
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
1859
|
-
if (url.includes("localhost")) {
|
|
1860
|
-
return { ok: true, text: async () => "pong" };
|
|
1861
|
-
}
|
|
1862
|
-
throw new Error("unexpected");
|
|
1863
|
-
}));
|
|
1864
|
-
|
|
1865
|
-
const checks = await checkConnectivity({}, { viewer: { name: "T" } });
|
|
1866
|
-
const webhookCheck = checks.find((c) => c.label.includes("Webhook self-test:"));
|
|
1867
|
-
// ok is true but body is "pong" not "ok" — the condition is `res.ok && body === "ok"`
|
|
1868
|
-
// Since body !== "ok", it falls into the warn branch
|
|
1869
|
-
expect(webhookCheck?.severity).toBe("warn");
|
|
1870
|
-
});
|
|
1871
|
-
});
|
|
1872
|
-
|
|
1873
|
-
// ---------------------------------------------------------------------------
|
|
1874
|
-
// formatReport — icon function TTY branches
|
|
1875
|
-
// ---------------------------------------------------------------------------
|
|
1876
|
-
|
|
1877
|
-
describe("formatReport — TTY icon rendering", () => {
|
|
1878
|
-
it("renders colored icons when stdout.isTTY is true", () => {
|
|
1879
|
-
const origIsTTY = process.stdout.isTTY;
|
|
1880
|
-
try {
|
|
1881
|
-
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true, configurable: true });
|
|
1882
|
-
|
|
1883
|
-
const report = {
|
|
1884
|
-
sections: [{
|
|
1885
|
-
name: "Test",
|
|
1886
|
-
checks: [
|
|
1887
|
-
{ label: "pass check", severity: "pass" as const },
|
|
1888
|
-
{ label: "warn check", severity: "warn" as const },
|
|
1889
|
-
{ label: "fail check", severity: "fail" as const },
|
|
1890
|
-
],
|
|
1891
|
-
}],
|
|
1892
|
-
summary: { passed: 1, warnings: 1, errors: 1 },
|
|
1893
|
-
};
|
|
1894
|
-
|
|
1895
|
-
const output = formatReport(report);
|
|
1896
|
-
// TTY output includes ANSI escape codes
|
|
1897
|
-
expect(output).toContain("\x1b[32m"); // green for pass
|
|
1898
|
-
expect(output).toContain("\x1b[33m"); // yellow for warn
|
|
1899
|
-
expect(output).toContain("\x1b[31m"); // red for fail
|
|
1900
|
-
} finally {
|
|
1901
|
-
Object.defineProperty(process.stdout, "isTTY", { value: origIsTTY, writable: true, configurable: true });
|
|
1902
|
-
}
|
|
1743
|
+
const repoCheck = checks.find((c) => c.label.includes('Repo "api"'));
|
|
1744
|
+
expect(repoCheck).toBeDefined();
|
|
1745
|
+
expect(repoCheck?.severity).toBe("fail");
|
|
1746
|
+
expect(repoCheck?.label).toContain("not a git repo");
|
|
1903
1747
|
});
|
|
1904
1748
|
});
|
|
1905
1749
|
|
|
1906
|
-
// ---------------------------------------------------------------------------
|
|
1907
|
-
// checkCodingTools — codingTool fallback to "codex" in label
|
|
1908
|
-
// ---------------------------------------------------------------------------
|
|
1909
|
-
|
|
1910
|
-
describe("checkCodingTools — codingTool null fallback", () => {
|
|
1911
|
-
it("shows 'codex' as default when codingTool is undefined but backends exist", () => {
|
|
1912
|
-
vi.mocked(loadCodingConfig).mockReturnValueOnce({
|
|
1913
|
-
codingTool: undefined,
|
|
1914
|
-
backends: { codex: { aliases: ["codex"] } },
|
|
1915
|
-
} as any);
|
|
1916
|
-
|
|
1917
|
-
const checks = checkCodingTools();
|
|
1918
|
-
const configCheck = checks.find((c) => c.label.includes("coding-tools.json loaded"));
|
|
1919
|
-
expect(configCheck?.severity).toBe("pass");
|
|
1920
|
-
expect(configCheck?.label).toContain("codex"); // falls back to "codex" via ??
|
|
1921
|
-
});
|
|
1922
|
-
});
|
|
1923
|
-
|
|
1924
|
-
// ---------------------------------------------------------------------------
|
|
1925
|
-
// checkFilesAndDirs — dispatch state non-Error catch branch
|
|
1926
|
-
// ---------------------------------------------------------------------------
|
|
1927
|
-
|
|
1928
|
-
describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
1929
|
-
it("handles non-Error thrown during dispatch state read", async () => {
|
|
1930
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nonError-"));
|
|
1931
|
-
const statePath = join(tmpDir, "state.json");
|
|
1932
|
-
writeFileSync(statePath, '{}');
|
|
1933
|
-
vi.mocked(readDispatchState).mockRejectedValueOnce("raw string dispatch error");
|
|
1934
|
-
|
|
1935
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath });
|
|
1936
|
-
const stateCheck = checks.find((c) => c.label.includes("Dispatch state corrupt"));
|
|
1937
|
-
expect(stateCheck?.severity).toBe("fail");
|
|
1938
|
-
expect(stateCheck?.detail).toContain("raw string dispatch error");
|
|
1939
|
-
});
|
|
1940
|
-
});
|
|
1941
|
-
|
|
1942
|
-
// ---------------------------------------------------------------------------
|
|
1943
|
-
// checkFilesAndDirs — lock file branches
|
|
1944
|
-
// ---------------------------------------------------------------------------
|
|
1945
|
-
|
|
1946
|
-
describe("checkFilesAndDirs — lock file branches", () => {
|
|
1947
|
-
let tmpDir: string;
|
|
1948
|
-
|
|
1949
|
-
beforeEach(() => {
|
|
1950
|
-
tmpDir = mkdtempSync(join(tmpdir(), "doctor-lock-"));
|
|
1951
|
-
});
|
|
1952
|
-
|
|
1953
|
-
it("warns about stale lock file without --fix", async () => {
|
|
1954
|
-
const statePath = join(tmpDir, "state.json");
|
|
1955
|
-
const lockPath = statePath + ".lock";
|
|
1956
|
-
writeFileSync(statePath, '{}');
|
|
1957
|
-
writeFileSync(lockPath, "locked");
|
|
1958
|
-
// Make the lock file appear stale by backdating its mtime
|
|
1959
|
-
const staleTime = Date.now() - 60_000; // 60 seconds old (> 30s LOCK_STALE_MS)
|
|
1960
|
-
const { utimesSync } = await import("node:fs");
|
|
1961
|
-
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1962
|
-
|
|
1963
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1964
|
-
dispatches: { active: {}, completed: {} },
|
|
1965
|
-
sessionMap: {},
|
|
1966
|
-
processedEvents: [],
|
|
1967
|
-
});
|
|
1968
|
-
|
|
1969
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
1970
|
-
const lockCheck = checks.find((c) => c.label.includes("Stale lock file"));
|
|
1971
|
-
expect(lockCheck?.severity).toBe("warn");
|
|
1972
|
-
expect(lockCheck?.fixable).toBe(true);
|
|
1973
|
-
});
|
|
1974
|
-
|
|
1975
|
-
it("removes stale lock file with --fix", async () => {
|
|
1976
|
-
const statePath = join(tmpDir, "state.json");
|
|
1977
|
-
const lockPath = statePath + ".lock";
|
|
1978
|
-
writeFileSync(statePath, '{}');
|
|
1979
|
-
writeFileSync(lockPath, "locked");
|
|
1980
|
-
const staleTime = Date.now() - 60_000;
|
|
1981
|
-
const { utimesSync } = await import("node:fs");
|
|
1982
|
-
utimesSync(lockPath, staleTime / 1000, staleTime / 1000);
|
|
1983
|
-
|
|
1984
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
1985
|
-
dispatches: { active: {}, completed: {} },
|
|
1986
|
-
sessionMap: {},
|
|
1987
|
-
processedEvents: [],
|
|
1988
|
-
});
|
|
1989
|
-
|
|
1990
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, true);
|
|
1991
|
-
const lockCheck = checks.find((c) => c.label.includes("Stale lock file removed"));
|
|
1992
|
-
expect(lockCheck?.severity).toBe("pass");
|
|
1993
|
-
expect(existsSync(lockPath)).toBe(false);
|
|
1994
|
-
});
|
|
1995
|
-
|
|
1996
|
-
it("warns about active (non-stale) lock file", async () => {
|
|
1997
|
-
const statePath = join(tmpDir, "state.json");
|
|
1998
|
-
const lockPath = statePath + ".lock";
|
|
1999
|
-
writeFileSync(statePath, '{}');
|
|
2000
|
-
writeFileSync(lockPath, "locked");
|
|
2001
|
-
// Lock file is fresh (just created), so lockAge < LOCK_STALE_MS
|
|
2002
|
-
|
|
2003
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
2004
|
-
dispatches: { active: {}, completed: {} },
|
|
2005
|
-
sessionMap: {},
|
|
2006
|
-
processedEvents: [],
|
|
2007
|
-
});
|
|
2008
|
-
|
|
2009
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath }, false);
|
|
2010
|
-
const lockCheck = checks.find((c) => c.label.includes("Lock file active"));
|
|
2011
|
-
expect(lockCheck?.severity).toBe("warn");
|
|
2012
|
-
expect(lockCheck?.label).toContain("may be in use");
|
|
2013
|
-
});
|
|
2014
|
-
});
|
|
2015
|
-
|
|
2016
|
-
// ---------------------------------------------------------------------------
|
|
2017
|
-
// checkFilesAndDirs — prompt variable edge cases
|
|
2018
|
-
// ---------------------------------------------------------------------------
|
|
2019
|
-
|
|
2020
|
-
describe("checkFilesAndDirs — prompt variable edge cases", () => {
|
|
2021
|
-
it("reports when variable missing from worker.task but present in audit.task", async () => {
|
|
2022
|
-
vi.mocked(loadPrompts).mockReturnValueOnce({
|
|
2023
|
-
worker: {
|
|
2024
|
-
system: "ok",
|
|
2025
|
-
task: "Do something", // missing all vars
|
|
2026
|
-
},
|
|
2027
|
-
audit: {
|
|
2028
|
-
system: "ok",
|
|
2029
|
-
task: "Audit {{identifier}} {{title}} {{description}} in {{worktreePath}}", // has all vars
|
|
2030
|
-
},
|
|
2031
|
-
rework: { addendum: "Fix these gaps: {{gaps}}" },
|
|
2032
|
-
} as any);
|
|
2033
|
-
|
|
2034
|
-
const checks = await checkFilesAndDirs();
|
|
2035
|
-
const promptCheck = checks.find((c) => c.label.includes("Prompt issues"));
|
|
2036
|
-
expect(promptCheck?.severity).toBe("fail");
|
|
2037
|
-
expect(promptCheck?.label).toContain("worker.task missing");
|
|
2038
|
-
// Crucially, audit.task should NOT be missing
|
|
2039
|
-
expect(promptCheck?.label).not.toContain("audit.task missing");
|
|
2040
|
-
});
|
|
2041
|
-
|
|
2042
|
-
it("reports loadPrompts throwing non-Error value", async () => {
|
|
2043
|
-
vi.mocked(loadPrompts).mockImplementationOnce(() => { throw "raw string error"; });
|
|
2044
|
-
|
|
2045
|
-
const checks = await checkFilesAndDirs();
|
|
2046
|
-
const promptCheck = checks.find((c) => c.label.includes("Failed to load prompts"));
|
|
2047
|
-
expect(promptCheck?.severity).toBe("fail");
|
|
2048
|
-
expect(promptCheck?.detail).toContain("raw string error");
|
|
2049
|
-
});
|
|
2050
|
-
});
|
|
2051
|
-
|
|
2052
|
-
// ---------------------------------------------------------------------------
|
|
2053
|
-
// checkFilesAndDirs — worktree & base repo edge cases
|
|
2054
|
-
// ---------------------------------------------------------------------------
|
|
2055
|
-
|
|
2056
|
-
describe("checkFilesAndDirs — worktree & base repo edge cases", () => {
|
|
2057
|
-
it("reports worktree base dir does not exist", async () => {
|
|
2058
|
-
const checks = await checkFilesAndDirs({
|
|
2059
|
-
worktreeBaseDir: "/tmp/nonexistent-worktree-dir-" + Date.now(),
|
|
2060
|
-
});
|
|
2061
|
-
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir does not exist"));
|
|
2062
|
-
expect(wtCheck?.severity).toBe("warn");
|
|
2063
|
-
expect(wtCheck?.detail).toContain("Will be created on first dispatch");
|
|
2064
|
-
});
|
|
2065
|
-
|
|
2066
|
-
it("reports base repo does not exist", async () => {
|
|
2067
|
-
const checks = await checkFilesAndDirs({
|
|
2068
|
-
codexBaseRepo: "/tmp/nonexistent-repo-" + Date.now(),
|
|
2069
|
-
});
|
|
2070
|
-
const repoCheck = checks.find((c) => c.label.includes("Base repo does not exist"));
|
|
2071
|
-
expect(repoCheck?.severity).toBe("fail");
|
|
2072
|
-
expect(repoCheck?.fix).toContain("codexBaseRepo");
|
|
2073
|
-
});
|
|
2074
|
-
|
|
2075
|
-
it("reports base repo exists but is not a git repo", async () => {
|
|
2076
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nongit-"));
|
|
2077
|
-
const checks = await checkFilesAndDirs({
|
|
2078
|
-
codexBaseRepo: tmpDir,
|
|
2079
|
-
});
|
|
2080
|
-
const repoCheck = checks.find((c) => c.label.includes("Base repo is not a git repo"));
|
|
2081
|
-
expect(repoCheck?.severity).toBe("fail");
|
|
2082
|
-
expect(repoCheck?.fix).toContain("git init");
|
|
2083
|
-
});
|
|
2084
|
-
});
|
|
2085
|
-
|
|
2086
|
-
// ---------------------------------------------------------------------------
|
|
2087
|
-
// checkFilesAndDirs — tilde path resolution branches
|
|
2088
|
-
// ---------------------------------------------------------------------------
|
|
2089
|
-
|
|
2090
|
-
describe("checkFilesAndDirs — tilde path resolution", () => {
|
|
2091
|
-
it("resolves ~/... dispatch state path", async () => {
|
|
2092
|
-
vi.mocked(readDispatchState).mockRejectedValueOnce(new Error("ENOENT"));
|
|
2093
|
-
|
|
2094
|
-
// Providing a path with ~/ triggers the tilde resolution branch
|
|
2095
|
-
const checks = await checkFilesAndDirs({
|
|
2096
|
-
dispatchStatePath: "~/nonexistent-state-file.json",
|
|
2097
|
-
});
|
|
2098
|
-
// The file won't exist (tilde-resolved), so we get the "no file yet" message
|
|
2099
|
-
const stateCheck = checks.find((c) => c.label.includes("Dispatch state"));
|
|
2100
|
-
expect(stateCheck).toBeDefined();
|
|
2101
|
-
});
|
|
2102
|
-
|
|
2103
|
-
it("resolves ~/... worktree base dir path", async () => {
|
|
2104
|
-
const checks = await checkFilesAndDirs({
|
|
2105
|
-
worktreeBaseDir: "~/nonexistent-worktree-base",
|
|
2106
|
-
});
|
|
2107
|
-
const wtCheck = checks.find((c) => c.label.includes("Worktree base dir"));
|
|
2108
|
-
expect(wtCheck).toBeDefined();
|
|
2109
|
-
});
|
|
2110
|
-
});
|
|
2111
|
-
|
|
2112
|
-
// ---------------------------------------------------------------------------
|
|
2113
|
-
// checkDispatchHealth — orphaned worktree singular
|
|
2114
|
-
// ---------------------------------------------------------------------------
|
|
2115
|
-
|
|
2116
|
-
describe("checkDispatchHealth — edge cases", () => {
|
|
2117
|
-
it("reports single orphaned worktree (singular)", async () => {
|
|
2118
|
-
vi.mocked(listWorktrees).mockReturnValueOnce([
|
|
2119
|
-
{ issueIdentifier: "ORPHAN-1", path: "/tmp/wt1" } as any,
|
|
2120
|
-
]);
|
|
2121
|
-
|
|
2122
|
-
const checks = await checkDispatchHealth();
|
|
2123
|
-
const orphanCheck = checks.find((c) => c.label.includes("orphaned worktree"));
|
|
2124
|
-
expect(orphanCheck?.severity).toBe("warn");
|
|
2125
|
-
expect(orphanCheck?.label).toContain("1 orphaned worktree");
|
|
2126
|
-
expect(orphanCheck?.label).not.toContain("worktrees"); // singular, not plural
|
|
2127
|
-
});
|
|
2128
|
-
|
|
2129
|
-
it("prunes multiple old completed dispatches (plural)", async () => {
|
|
2130
|
-
vi.mocked(readDispatchState).mockResolvedValueOnce({
|
|
2131
|
-
dispatches: {
|
|
2132
|
-
active: {},
|
|
2133
|
-
completed: {
|
|
2134
|
-
"A-1": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
2135
|
-
"A-2": { completedAt: new Date(Date.now() - 10 * 24 * 3_600_000).toISOString() } as any,
|
|
2136
|
-
},
|
|
2137
|
-
},
|
|
2138
|
-
sessionMap: {},
|
|
2139
|
-
processedEvents: [],
|
|
2140
|
-
});
|
|
2141
|
-
vi.mocked(pruneCompleted).mockResolvedValueOnce(2);
|
|
2142
|
-
|
|
2143
|
-
const checks = await checkDispatchHealth(undefined, true);
|
|
2144
|
-
const pruneCheck = checks.find((c) => c.label.includes("Pruned"));
|
|
2145
|
-
expect(pruneCheck?.severity).toBe("pass");
|
|
2146
|
-
expect(pruneCheck?.label).toContain("2 old completed dispatches");
|
|
2147
|
-
});
|
|
2148
|
-
|
|
2149
|
-
it("reports single stale dispatch (singular)", async () => {
|
|
2150
|
-
vi.mocked(listStaleDispatches).mockReturnValueOnce([
|
|
2151
|
-
{ issueIdentifier: "API-1", status: "working" } as any,
|
|
2152
|
-
]);
|
|
2153
|
-
|
|
2154
|
-
const checks = await checkDispatchHealth();
|
|
2155
|
-
const staleCheck = checks.find((c) => c.label.includes("stale dispatch"));
|
|
2156
|
-
expect(staleCheck?.severity).toBe("warn");
|
|
2157
|
-
// Singular: "1 stale dispatch" not "1 stale dispatches"
|
|
2158
|
-
expect(staleCheck?.label).toMatch(/1 stale dispatch(?!es)/);
|
|
2159
|
-
});
|
|
2160
|
-
});
|
|
2161
|
-
|
|
2162
|
-
// ---------------------------------------------------------------------------
|
|
2163
|
-
// checkConnectivity — webhook self-test with ok but body !== "ok"
|
|
2164
|
-
// ---------------------------------------------------------------------------
|
|
2165
|
-
|
|
2166
|
-
describe("checkConnectivity — webhook non-ok body", () => {
|
|
2167
|
-
it("warns when webhook returns ok status but body is not 'ok'", async () => {
|
|
2168
|
-
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
2169
|
-
if (url.includes("localhost")) {
|
|
2170
|
-
return { ok: true, text: async () => "pong" };
|
|
2171
|
-
}
|
|
2172
|
-
throw new Error("unexpected");
|
|
2173
|
-
}));
|
|
2174
|
-
|
|
2175
|
-
const checks = await checkConnectivity({}, { viewer: { name: "T" } });
|
|
2176
|
-
const webhookCheck = checks.find((c) => c.label.includes("Webhook self-test:"));
|
|
2177
|
-
// ok is true but body is "pong" not "ok" — the condition is `res.ok && body === "ok"`
|
|
2178
|
-
// Since body !== "ok", it falls into the warn branch
|
|
2179
|
-
expect(webhookCheck?.severity).toBe("warn");
|
|
2180
|
-
});
|
|
2181
|
-
});
|
|
2182
|
-
|
|
2183
|
-
// ---------------------------------------------------------------------------
|
|
2184
|
-
// formatReport — icon function TTY branches
|
|
2185
|
-
// ---------------------------------------------------------------------------
|
|
2186
|
-
|
|
2187
|
-
describe("formatReport — TTY icon rendering", () => {
|
|
2188
|
-
it("renders colored icons when stdout.isTTY is true", () => {
|
|
2189
|
-
const origIsTTY = process.stdout.isTTY;
|
|
2190
|
-
try {
|
|
2191
|
-
Object.defineProperty(process.stdout, "isTTY", { value: true, writable: true, configurable: true });
|
|
2192
|
-
|
|
2193
|
-
const report = {
|
|
2194
|
-
sections: [{
|
|
2195
|
-
name: "Test",
|
|
2196
|
-
checks: [
|
|
2197
|
-
{ label: "pass check", severity: "pass" as const },
|
|
2198
|
-
{ label: "warn check", severity: "warn" as const },
|
|
2199
|
-
{ label: "fail check", severity: "fail" as const },
|
|
2200
|
-
],
|
|
2201
|
-
}],
|
|
2202
|
-
summary: { passed: 1, warnings: 1, errors: 1 },
|
|
2203
|
-
};
|
|
2204
|
-
|
|
2205
|
-
const output = formatReport(report);
|
|
2206
|
-
// TTY output includes ANSI escape codes
|
|
2207
|
-
expect(output).toContain("\x1b[32m"); // green for pass
|
|
2208
|
-
expect(output).toContain("\x1b[33m"); // yellow for warn
|
|
2209
|
-
expect(output).toContain("\x1b[31m"); // red for fail
|
|
2210
|
-
} finally {
|
|
2211
|
-
Object.defineProperty(process.stdout, "isTTY", { value: origIsTTY, writable: true, configurable: true });
|
|
2212
|
-
}
|
|
2213
|
-
});
|
|
2214
|
-
});
|
|
2215
|
-
|
|
2216
|
-
// ---------------------------------------------------------------------------
|
|
2217
|
-
// checkCodingTools — codingTool fallback to "codex" in label
|
|
2218
|
-
// ---------------------------------------------------------------------------
|
|
2219
|
-
|
|
2220
|
-
describe("checkCodingTools — codingTool null fallback", () => {
|
|
2221
|
-
it("shows 'codex' as default when codingTool is undefined but backends exist", () => {
|
|
2222
|
-
vi.mocked(loadCodingConfig).mockReturnValueOnce({
|
|
2223
|
-
codingTool: undefined,
|
|
2224
|
-
backends: { codex: { aliases: ["codex"] } },
|
|
2225
|
-
} as any);
|
|
2226
|
-
|
|
2227
|
-
const checks = checkCodingTools();
|
|
2228
|
-
const configCheck = checks.find((c) => c.label.includes("coding-tools.json loaded"));
|
|
2229
|
-
expect(configCheck?.severity).toBe("pass");
|
|
2230
|
-
expect(configCheck?.label).toContain("codex"); // falls back to "codex" via ??
|
|
2231
|
-
});
|
|
2232
|
-
});
|
|
2233
|
-
|
|
2234
|
-
// ---------------------------------------------------------------------------
|
|
2235
|
-
// checkFilesAndDirs — dispatch state non-Error catch branch
|
|
2236
|
-
// ---------------------------------------------------------------------------
|
|
2237
|
-
|
|
2238
|
-
describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
2239
|
-
it("handles non-Error thrown during dispatch state read", async () => {
|
|
2240
|
-
const tmpDir = mkdtempSync(join(tmpdir(), "doctor-nonError-"));
|
|
2241
|
-
const statePath = join(tmpDir, "state.json");
|
|
2242
|
-
writeFileSync(statePath, '{}');
|
|
2243
|
-
vi.mocked(readDispatchState).mockRejectedValueOnce("raw string dispatch error");
|
|
2244
|
-
|
|
2245
|
-
const checks = await checkFilesAndDirs({ dispatchStatePath: statePath });
|
|
2246
|
-
const stateCheck = checks.find((c) => c.label.includes("Dispatch state corrupt"));
|
|
2247
|
-
expect(stateCheck?.severity).toBe("fail");
|
|
2248
|
-
expect(stateCheck?.detail).toContain("raw string dispatch error");
|
|
2249
|
-
});
|
|
2250
|
-
});
|