@calltelemetry/openclaw-linear 0.9.4 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recorded API responses from sub-issue decomposition smoke test.
|
|
3
|
+
*
|
|
4
|
+
* Initially seeded with placeholder data. Overwritten with real API responses
|
|
5
|
+
* when the smoke test runs: npx vitest run src/__test__/smoke-linear-api.test.ts
|
|
6
|
+
*
|
|
7
|
+
* Last recorded: seed (placeholder)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const RECORDED = {
|
|
11
|
+
teamStates: [
|
|
12
|
+
{ id: "state-backlog", name: "Backlog", type: "backlog" },
|
|
13
|
+
{ id: "state-started", name: "In Progress", type: "started" },
|
|
14
|
+
{ id: "state-done", name: "Done", type: "completed" },
|
|
15
|
+
{ id: "state-canceled", name: "Canceled", type: "canceled" },
|
|
16
|
+
],
|
|
17
|
+
createParent: { id: "parent-001", identifier: "UAT-100" },
|
|
18
|
+
createSubIssue1: { id: "sub1-001", identifier: "UAT-101" },
|
|
19
|
+
createSubIssue2: { id: "sub2-001", identifier: "UAT-102" },
|
|
20
|
+
parentDetails: {
|
|
21
|
+
id: "parent-001",
|
|
22
|
+
identifier: "UAT-100",
|
|
23
|
+
title: "[SMOKE TEST] Sub-Issue Parent: Search Feature",
|
|
24
|
+
description:
|
|
25
|
+
"Auto-generated by smoke test to verify sub-issue decomposition.\n\n" +
|
|
26
|
+
"This parent issue should have two sub-issues created under it.",
|
|
27
|
+
estimate: null,
|
|
28
|
+
state: { name: "Backlog", type: "backlog" },
|
|
29
|
+
creator: { name: "Test User", email: null as string | null },
|
|
30
|
+
assignee: null as { name: string } | null,
|
|
31
|
+
labels: { nodes: [] as Array<{ id: string; name: string }> },
|
|
32
|
+
team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
|
|
33
|
+
comments: {
|
|
34
|
+
nodes: [] as Array<{
|
|
35
|
+
body: string;
|
|
36
|
+
user: { name: string } | null;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
}>,
|
|
39
|
+
},
|
|
40
|
+
project: null as { id: string; name: string } | null,
|
|
41
|
+
parent: null as { id: string; identifier: string } | null,
|
|
42
|
+
relations: {
|
|
43
|
+
nodes: [] as Array<{
|
|
44
|
+
type: string;
|
|
45
|
+
relatedIssue: { id: string; identifier: string; title: string };
|
|
46
|
+
}>,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
subIssue1Details: {
|
|
50
|
+
id: "sub1-001",
|
|
51
|
+
identifier: "UAT-101",
|
|
52
|
+
title: "[SMOKE TEST] Sub-Issue 1: Backend API",
|
|
53
|
+
description:
|
|
54
|
+
"Implement the backend search API endpoint.\n\n" +
|
|
55
|
+
"Given a search query, when the API is called, then matching results are returned.",
|
|
56
|
+
estimate: 2,
|
|
57
|
+
state: { name: "Backlog", type: "backlog" },
|
|
58
|
+
creator: { name: "Test User", email: null as string | null },
|
|
59
|
+
assignee: null as { name: string } | null,
|
|
60
|
+
labels: { nodes: [] as Array<{ id: string; name: string }> },
|
|
61
|
+
team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
|
|
62
|
+
comments: {
|
|
63
|
+
nodes: [] as Array<{
|
|
64
|
+
body: string;
|
|
65
|
+
user: { name: string } | null;
|
|
66
|
+
createdAt: string;
|
|
67
|
+
}>,
|
|
68
|
+
},
|
|
69
|
+
project: null as { id: string; name: string } | null,
|
|
70
|
+
parent: { id: "parent-001", identifier: "UAT-100" },
|
|
71
|
+
relations: {
|
|
72
|
+
nodes: [] as Array<{
|
|
73
|
+
type: string;
|
|
74
|
+
relatedIssue: { id: string; identifier: string; title: string };
|
|
75
|
+
}>,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
subIssue2Details: {
|
|
79
|
+
id: "sub2-001",
|
|
80
|
+
identifier: "UAT-102",
|
|
81
|
+
title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
|
|
82
|
+
description:
|
|
83
|
+
"Build the frontend search UI component.\n\n" +
|
|
84
|
+
"Given the search page loads, when the user types a query, then results display in real-time.",
|
|
85
|
+
estimate: 3,
|
|
86
|
+
state: { name: "Backlog", type: "backlog" },
|
|
87
|
+
creator: { name: "Test User", email: null as string | null },
|
|
88
|
+
assignee: null as { name: string } | null,
|
|
89
|
+
labels: { nodes: [] as Array<{ id: string; name: string }> },
|
|
90
|
+
team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
|
|
91
|
+
comments: {
|
|
92
|
+
nodes: [] as Array<{
|
|
93
|
+
body: string;
|
|
94
|
+
user: { name: string } | null;
|
|
95
|
+
createdAt: string;
|
|
96
|
+
}>,
|
|
97
|
+
},
|
|
98
|
+
project: null as { id: string; name: string } | null,
|
|
99
|
+
parent: { id: "parent-001", identifier: "UAT-100" },
|
|
100
|
+
relations: {
|
|
101
|
+
nodes: [] as Array<{
|
|
102
|
+
type: string;
|
|
103
|
+
relatedIssue: { id: string; identifier: string; title: string };
|
|
104
|
+
}>,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
createRelation: { id: "rel-001" },
|
|
108
|
+
subIssue1WithRelation: {
|
|
109
|
+
id: "sub1-001",
|
|
110
|
+
identifier: "UAT-101",
|
|
111
|
+
title: "[SMOKE TEST] Sub-Issue 1: Backend API",
|
|
112
|
+
description:
|
|
113
|
+
"Implement the backend search API endpoint.\n\n" +
|
|
114
|
+
"Given a search query, when the API is called, then matching results are returned.",
|
|
115
|
+
estimate: 2,
|
|
116
|
+
state: { name: "Backlog", type: "backlog" },
|
|
117
|
+
creator: { name: "Test User", email: null as string | null },
|
|
118
|
+
assignee: null as { name: string } | null,
|
|
119
|
+
labels: { nodes: [] as Array<{ id: string; name: string }> },
|
|
120
|
+
team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
|
|
121
|
+
comments: {
|
|
122
|
+
nodes: [] as Array<{
|
|
123
|
+
body: string;
|
|
124
|
+
user: { name: string } | null;
|
|
125
|
+
createdAt: string;
|
|
126
|
+
}>,
|
|
127
|
+
},
|
|
128
|
+
project: null as { id: string; name: string } | null,
|
|
129
|
+
parent: { id: "parent-001", identifier: "UAT-100" },
|
|
130
|
+
relations: {
|
|
131
|
+
nodes: [
|
|
132
|
+
{
|
|
133
|
+
type: "blocks",
|
|
134
|
+
relatedIssue: {
|
|
135
|
+
id: "sub2-001",
|
|
136
|
+
identifier: "UAT-102",
|
|
137
|
+
title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
subIssue2WithRelation: {
|
|
144
|
+
id: "sub2-001",
|
|
145
|
+
identifier: "UAT-102",
|
|
146
|
+
title: "[SMOKE TEST] Sub-Issue 2: Frontend UI",
|
|
147
|
+
description:
|
|
148
|
+
"Build the frontend search UI component.\n\n" +
|
|
149
|
+
"Given the search page loads, when the user types a query, then results display in real-time.",
|
|
150
|
+
estimate: 3,
|
|
151
|
+
state: { name: "Backlog", type: "backlog" },
|
|
152
|
+
creator: { name: "Test User", email: null as string | null },
|
|
153
|
+
assignee: null as { name: string } | null,
|
|
154
|
+
labels: { nodes: [] as Array<{ id: string; name: string }> },
|
|
155
|
+
team: { id: "team-uat", name: "UAT", issueEstimationType: "notUsed" },
|
|
156
|
+
comments: {
|
|
157
|
+
nodes: [] as Array<{
|
|
158
|
+
body: string;
|
|
159
|
+
user: { name: string } | null;
|
|
160
|
+
createdAt: string;
|
|
161
|
+
}>,
|
|
162
|
+
},
|
|
163
|
+
project: null as { id: string; name: string } | null,
|
|
164
|
+
parent: { id: "parent-001", identifier: "UAT-100" },
|
|
165
|
+
relations: {
|
|
166
|
+
nodes: [] as Array<{
|
|
167
|
+
type: string;
|
|
168
|
+
relatedIssue: { id: string; identifier: string; title: string };
|
|
169
|
+
}>,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
package/src/agent/watchdog.ts
CHANGED
package/src/infra/doctor.test.ts
CHANGED
|
@@ -1290,7 +1290,7 @@ describe("buildSummary — additional branches", () => {
|
|
|
1290
1290
|
// ---------------------------------------------------------------------------
|
|
1291
1291
|
|
|
1292
1292
|
describe("runDoctor — additional branches", () => {
|
|
1293
|
-
|
|
1293
|
+
it("applies --fix to auth-profiles.json permissions when fixable check exists", async () => {
|
|
1294
1294
|
// We need a scenario where checkAuth produces a fixable permissions check
|
|
1295
1295
|
// The AUTH_PROFILES_PATH is mocked to /tmp/test-auth-profiles.json
|
|
1296
1296
|
// Write a file there with wrong permissions so statSync succeeds
|
|
@@ -1938,3 +1938,313 @@ describe("checkFilesAndDirs — dispatch state non-Error exception", () => {
|
|
|
1938
1938
|
expect(stateCheck?.detail).toContain("raw string dispatch error");
|
|
1939
1939
|
});
|
|
1940
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
|
+
});
|
|
@@ -24,9 +24,6 @@ const {
|
|
|
24
24
|
resetGuidanceCacheMock,
|
|
25
25
|
setActiveSessionMock,
|
|
26
26
|
clearActiveSessionMock,
|
|
27
|
-
getIssueAffinityMock,
|
|
28
|
-
configureAffinityTtlMock,
|
|
29
|
-
resetAffinityForTestingMock,
|
|
30
27
|
readDispatchStateMock,
|
|
31
28
|
getActiveDispatchMock,
|
|
32
29
|
registerDispatchMock,
|
|
@@ -100,9 +97,6 @@ const {
|
|
|
100
97
|
resetGuidanceCacheMock: vi.fn(),
|
|
101
98
|
setActiveSessionMock: vi.fn(),
|
|
102
99
|
clearActiveSessionMock: vi.fn(),
|
|
103
|
-
getIssueAffinityMock: vi.fn().mockReturnValue(null),
|
|
104
|
-
configureAffinityTtlMock: vi.fn(),
|
|
105
|
-
resetAffinityForTestingMock: vi.fn(),
|
|
106
100
|
readDispatchStateMock: vi.fn().mockResolvedValue({ activeDispatches: {} }),
|
|
107
101
|
getActiveDispatchMock: vi.fn().mockReturnValue(null),
|
|
108
102
|
registerDispatchMock: vi.fn().mockResolvedValue(undefined),
|
|
@@ -113,7 +107,7 @@ const {
|
|
|
113
107
|
createWorktreeMock: vi.fn().mockReturnValue({ path: "/tmp/worktree", branch: "codex/ENG-123", resumed: false }),
|
|
114
108
|
createMultiWorktreeMock: vi.fn().mockReturnValue({ parentPath: "/tmp/multi", worktrees: [] }),
|
|
115
109
|
prepareWorkspaceMock: vi.fn().mockReturnValue({ pulled: true, submodulesInitialized: false, errors: [] }),
|
|
116
|
-
resolveReposMock: vi.fn().mockReturnValue({ repos: [{ name: "main", path: "/
|
|
110
|
+
resolveReposMock: vi.fn().mockReturnValue({ repos: [{ name: "main", path: "/home/claw/ai-workspace" }], source: "config_default" }),
|
|
117
111
|
isMultiRepoMock: vi.fn().mockReturnValue(false),
|
|
118
112
|
ensureClawDirMock: vi.fn(),
|
|
119
113
|
writeManifestMock: vi.fn(),
|
|
@@ -173,9 +167,9 @@ vi.mock("./guidance.js", () => ({
|
|
|
173
167
|
vi.mock("./active-session.js", () => ({
|
|
174
168
|
setActiveSession: setActiveSessionMock,
|
|
175
169
|
clearActiveSession: clearActiveSessionMock,
|
|
176
|
-
getIssueAffinity:
|
|
177
|
-
_configureAffinityTtl:
|
|
178
|
-
_resetAffinityForTesting:
|
|
170
|
+
getIssueAffinity: vi.fn().mockReturnValue(null),
|
|
171
|
+
_configureAffinityTtl: vi.fn(),
|
|
172
|
+
_resetAffinityForTesting: vi.fn(),
|
|
179
173
|
}));
|
|
180
174
|
|
|
181
175
|
vi.mock("./dispatch-state.js", () => ({
|
|
@@ -364,9 +358,6 @@ afterEach(() => {
|
|
|
364
358
|
isGuidanceEnabledMock.mockReset().mockReturnValue(false);
|
|
365
359
|
setActiveSessionMock.mockReset();
|
|
366
360
|
clearActiveSessionMock.mockReset();
|
|
367
|
-
getIssueAffinityMock.mockReset().mockReturnValue(null);
|
|
368
|
-
configureAffinityTtlMock.mockReset();
|
|
369
|
-
resetAffinityMock.mockReset();
|
|
370
361
|
readDispatchStateMock.mockReset().mockResolvedValue({ activeDispatches: {} });
|
|
371
362
|
getActiveDispatchMock.mockReset().mockReturnValue(null);
|
|
372
363
|
registerDispatchMock.mockReset().mockResolvedValue(undefined);
|
|
@@ -375,7 +366,7 @@ afterEach(() => {
|
|
|
375
366
|
assessTierMock.mockReset().mockResolvedValue({ tier: "medium", model: "anthropic/claude-sonnet-4-6", reasoning: "moderate complexity" });
|
|
376
367
|
createWorktreeMock.mockReset().mockReturnValue({ path: "/tmp/worktree", branch: "codex/ENG-123", resumed: false });
|
|
377
368
|
prepareWorkspaceMock.mockReset().mockReturnValue({ pulled: true, submodulesInitialized: false, errors: [] });
|
|
378
|
-
resolveReposMock.mockReset().mockReturnValue({ repos: [{ name: "main", path: "/
|
|
369
|
+
resolveReposMock.mockReset().mockReturnValue({ repos: [{ name: "main", path: "/home/claw/ai-workspace" }], source: "config_default" });
|
|
379
370
|
isMultiRepoMock.mockReset().mockReturnValue(false);
|
|
380
371
|
ensureClawDirMock.mockReset();
|
|
381
372
|
writeManifestMock.mockReset();
|
|
@@ -4276,8 +4267,8 @@ describe("handleDispatch multi-repo and .catch/.finally", () => {
|
|
|
4276
4267
|
});
|
|
4277
4268
|
resolveReposMock.mockReturnValue({
|
|
4278
4269
|
repos: [
|
|
4279
|
-
{ name: "api", path: "/
|
|
4280
|
-
{ name: "spa", path: "/
|
|
4270
|
+
{ name: "api", path: "/home/claw/api" },
|
|
4271
|
+
{ name: "spa", path: "/home/claw/spa" },
|
|
4281
4272
|
],
|
|
4282
4273
|
source: "issue_markers",
|
|
4283
4274
|
});
|
|
@@ -4842,3 +4833,194 @@ describe("handleDispatch error via Issue.update .catch wrapper", () => {
|
|
|
4842
4833
|
expect(errorCalls.some((msg: string) => msg.includes("Dispatch pipeline error"))).toBe(true);
|
|
4843
4834
|
});
|
|
4844
4835
|
});
|
|
4836
|
+
|
|
4837
|
+
// ---------------------------------------------------------------------------
|
|
4838
|
+
// Session affinity routing
|
|
4839
|
+
// ---------------------------------------------------------------------------
|
|
4840
|
+
|
|
4841
|
+
describe("session affinity routing", () => {
|
|
4842
|
+
it("request_work uses affinity agent instead of default", async () => {
|
|
4843
|
+
getIssueAffinityMock.mockReturnValue("kaylee");
|
|
4844
|
+
classifyIntentMock.mockResolvedValue({
|
|
4845
|
+
intent: "request_work",
|
|
4846
|
+
reasoning: "User wants work done",
|
|
4847
|
+
fromFallback: false,
|
|
4848
|
+
});
|
|
4849
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
4850
|
+
id: "issue-affinity-rw",
|
|
4851
|
+
identifier: "ENG-AFF-RW",
|
|
4852
|
+
title: "Affinity Request Work",
|
|
4853
|
+
description: "desc",
|
|
4854
|
+
state: { name: "Backlog", type: "backlog" },
|
|
4855
|
+
team: { id: "team-aff" },
|
|
4856
|
+
comments: { nodes: [] },
|
|
4857
|
+
});
|
|
4858
|
+
|
|
4859
|
+
const result = await postWebhook({
|
|
4860
|
+
type: "Comment",
|
|
4861
|
+
action: "create",
|
|
4862
|
+
data: {
|
|
4863
|
+
id: "comment-affinity-rw",
|
|
4864
|
+
body: "Please implement this",
|
|
4865
|
+
user: { id: "human-aff", name: "Human" },
|
|
4866
|
+
issue: { id: "issue-affinity-rw", identifier: "ENG-AFF-RW" },
|
|
4867
|
+
},
|
|
4868
|
+
});
|
|
4869
|
+
|
|
4870
|
+
expect(result.status).toBe(200);
|
|
4871
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
4872
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
4873
|
+
expect(infoCalls.some((msg: string) => msg.includes("request_work") && msg.includes("kaylee"))).toBe(true);
|
|
4874
|
+
});
|
|
4875
|
+
|
|
4876
|
+
it("null affinity falls through to default agent", async () => {
|
|
4877
|
+
getIssueAffinityMock.mockReturnValue(null);
|
|
4878
|
+
classifyIntentMock.mockResolvedValue({
|
|
4879
|
+
intent: "request_work",
|
|
4880
|
+
reasoning: "User wants work done",
|
|
4881
|
+
fromFallback: false,
|
|
4882
|
+
});
|
|
4883
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
4884
|
+
id: "issue-no-aff",
|
|
4885
|
+
identifier: "ENG-NO-AFF",
|
|
4886
|
+
title: "No Affinity",
|
|
4887
|
+
description: "desc",
|
|
4888
|
+
state: { name: "Backlog", type: "backlog" },
|
|
4889
|
+
team: { id: "team-noaff" },
|
|
4890
|
+
comments: { nodes: [] },
|
|
4891
|
+
});
|
|
4892
|
+
|
|
4893
|
+
const result = await postWebhook({
|
|
4894
|
+
type: "Comment",
|
|
4895
|
+
action: "create",
|
|
4896
|
+
data: {
|
|
4897
|
+
id: "comment-no-aff",
|
|
4898
|
+
body: "Do something",
|
|
4899
|
+
user: { id: "human-noaff", name: "Human" },
|
|
4900
|
+
issue: { id: "issue-no-aff", identifier: "ENG-NO-AFF" },
|
|
4901
|
+
},
|
|
4902
|
+
});
|
|
4903
|
+
|
|
4904
|
+
expect(result.status).toBe(200);
|
|
4905
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
4906
|
+
// Default agent is "mal" (from loadAgentProfilesMock isDefault: true)
|
|
4907
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
4908
|
+
expect(infoCalls.some((msg: string) => msg.includes("request_work") && msg.includes("mal"))).toBe(true);
|
|
4909
|
+
});
|
|
4910
|
+
|
|
4911
|
+
it("AgentSessionEvent.created uses affinity when no @mention", async () => {
|
|
4912
|
+
getIssueAffinityMock.mockReturnValue("kaylee");
|
|
4913
|
+
|
|
4914
|
+
const result = await postWebhook({
|
|
4915
|
+
type: "AgentSessionEvent",
|
|
4916
|
+
action: "created",
|
|
4917
|
+
agentSession: {
|
|
4918
|
+
id: "sess-aff-created",
|
|
4919
|
+
issue: { id: "issue-aff-created", identifier: "ENG-AFF-C" },
|
|
4920
|
+
},
|
|
4921
|
+
previousComments: [
|
|
4922
|
+
{ body: "Can you investigate?", user: { name: "Dev" } },
|
|
4923
|
+
],
|
|
4924
|
+
});
|
|
4925
|
+
|
|
4926
|
+
expect(result.status).toBe(200);
|
|
4927
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
4928
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
4929
|
+
expect(infoCalls.some((msg: string) => msg.includes("session affinity") && msg.includes("kaylee"))).toBe(true);
|
|
4930
|
+
});
|
|
4931
|
+
|
|
4932
|
+
it("@mention overrides affinity in AgentSessionEvent.created", async () => {
|
|
4933
|
+
getIssueAffinityMock.mockReturnValue("kaylee");
|
|
4934
|
+
resolveAgentFromAliasMock.mockReturnValue({ agentId: "mal", profile: { label: "Mal" } });
|
|
4935
|
+
|
|
4936
|
+
const result = await postWebhook({
|
|
4937
|
+
type: "AgentSessionEvent",
|
|
4938
|
+
action: "created",
|
|
4939
|
+
agentSession: {
|
|
4940
|
+
id: "sess-mention-override",
|
|
4941
|
+
issue: { id: "issue-mention-override", identifier: "ENG-MO" },
|
|
4942
|
+
},
|
|
4943
|
+
previousComments: [
|
|
4944
|
+
{ body: "@mal please fix this", user: { name: "Dev" } },
|
|
4945
|
+
],
|
|
4946
|
+
});
|
|
4947
|
+
|
|
4948
|
+
expect(result.status).toBe(200);
|
|
4949
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
4950
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
4951
|
+
// @mention should win over affinity
|
|
4952
|
+
expect(infoCalls.some((msg: string) => msg.includes("routed to mal via @mal mention"))).toBe(true);
|
|
4953
|
+
// Affinity should NOT appear in log because @mention took priority
|
|
4954
|
+
expect(infoCalls.some((msg: string) => msg.includes("session affinity"))).toBe(false);
|
|
4955
|
+
});
|
|
4956
|
+
|
|
4957
|
+
it("close_issue uses affinity agent", async () => {
|
|
4958
|
+
getIssueAffinityMock.mockReturnValue("kaylee");
|
|
4959
|
+
classifyIntentMock.mockResolvedValue({
|
|
4960
|
+
intent: "close_issue",
|
|
4961
|
+
reasoning: "User wants to close",
|
|
4962
|
+
fromFallback: false,
|
|
4963
|
+
});
|
|
4964
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
4965
|
+
id: "issue-aff-close",
|
|
4966
|
+
identifier: "ENG-AFF-CL",
|
|
4967
|
+
title: "Affinity Close",
|
|
4968
|
+
description: "desc",
|
|
4969
|
+
state: { name: "In Progress", type: "started" },
|
|
4970
|
+
team: { id: "team-aff-cl" },
|
|
4971
|
+
comments: { nodes: [] },
|
|
4972
|
+
});
|
|
4973
|
+
|
|
4974
|
+
const result = await postWebhook({
|
|
4975
|
+
type: "Comment",
|
|
4976
|
+
action: "create",
|
|
4977
|
+
data: {
|
|
4978
|
+
id: "comment-aff-close",
|
|
4979
|
+
body: "close this please",
|
|
4980
|
+
user: { id: "human-aff-cl", name: "Human" },
|
|
4981
|
+
issue: { id: "issue-aff-close", identifier: "ENG-AFF-CL" },
|
|
4982
|
+
},
|
|
4983
|
+
});
|
|
4984
|
+
|
|
4985
|
+
expect(result.status).toBe(200);
|
|
4986
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
4987
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
4988
|
+
expect(infoCalls.some((msg: string) => msg.includes("close_issue") && msg.includes("kaylee"))).toBe(true);
|
|
4989
|
+
});
|
|
4990
|
+
|
|
4991
|
+
it("ask_agent with explicit agentId overrides affinity", async () => {
|
|
4992
|
+
getIssueAffinityMock.mockReturnValue("kaylee");
|
|
4993
|
+
classifyIntentMock.mockResolvedValue({
|
|
4994
|
+
intent: "ask_agent",
|
|
4995
|
+
agentId: "mal",
|
|
4996
|
+
reasoning: "User asked mal explicitly",
|
|
4997
|
+
fromFallback: false,
|
|
4998
|
+
});
|
|
4999
|
+
mockLinearApiInstance.getIssueDetails.mockResolvedValue({
|
|
5000
|
+
id: "issue-ask-override",
|
|
5001
|
+
identifier: "ENG-ASK-O",
|
|
5002
|
+
title: "Ask Agent Override",
|
|
5003
|
+
description: "desc",
|
|
5004
|
+
state: { name: "Backlog", type: "backlog" },
|
|
5005
|
+
team: { id: "team-ask-o" },
|
|
5006
|
+
comments: { nodes: [] },
|
|
5007
|
+
});
|
|
5008
|
+
|
|
5009
|
+
const result = await postWebhook({
|
|
5010
|
+
type: "Comment",
|
|
5011
|
+
action: "create",
|
|
5012
|
+
data: {
|
|
5013
|
+
id: "comment-ask-override",
|
|
5014
|
+
body: "@mal what do you think?",
|
|
5015
|
+
user: { id: "human-ask-o", name: "Human" },
|
|
5016
|
+
issue: { id: "issue-ask-override", identifier: "ENG-ASK-O" },
|
|
5017
|
+
},
|
|
5018
|
+
});
|
|
5019
|
+
|
|
5020
|
+
expect(result.status).toBe(200);
|
|
5021
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
5022
|
+
const infoCalls = (result.api.logger.info as any).mock.calls.map((c: any[]) => c[0]);
|
|
5023
|
+
// ask_agent uses intentResult.agentId directly, not affinity
|
|
5024
|
+
expect(infoCalls.some((msg: string) => msg.includes("ask_agent") && msg.includes("mal"))).toBe(true);
|
|
5025
|
+
});
|
|
5026
|
+
});
|