@calltelemetry/openclaw-linear 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +834 -536
- package/index.ts +1 -1
- package/openclaw.plugin.json +3 -2
- package/package.json +1 -1
- package/prompts.yaml +46 -6
- package/src/__test__/fixtures/linear-responses.ts +75 -0
- package/src/__test__/fixtures/webhook-payloads.ts +113 -0
- package/src/__test__/helpers.ts +133 -0
- package/src/agent/agent.test.ts +192 -0
- package/src/agent/agent.ts +26 -1
- package/src/api/linear-api.test.ts +93 -1
- package/src/api/linear-api.ts +37 -1
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/infra/cli.ts +176 -1
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +30 -25
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +29 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +26 -15
- package/src/infra/observability.test.ts +85 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/dispatch-state.ts +1 -0
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +478 -0
- package/src/pipeline/intent-classify.test.ts +285 -0
- package/src/pipeline/intent-classify.ts +259 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +47 -18
- package/src/pipeline/planner.test.ts +159 -40
- package/src/pipeline/planner.ts +108 -60
- package/src/pipeline/tier-assess.test.ts +89 -0
- package/src/pipeline/webhook.ts +424 -251
- package/src/tools/claude-tool.ts +6 -0
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/code-tool.ts +2 -2
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/planner-tools.test.ts +1 -1
- package/src/tools/planner-tools.ts +10 -2
|
@@ -300,7 +300,7 @@ describe("LinearAgentApi", () => {
|
|
|
300
300
|
});
|
|
301
301
|
|
|
302
302
|
await expect(api.updateIssue("i1", { estimate: 1 })).rejects.toThrow(
|
|
303
|
-
/Linear API
|
|
303
|
+
/Linear API authentication failed/,
|
|
304
304
|
);
|
|
305
305
|
});
|
|
306
306
|
});
|
|
@@ -464,6 +464,98 @@ describe("LinearAgentApi", () => {
|
|
|
464
464
|
});
|
|
465
465
|
});
|
|
466
466
|
|
|
467
|
+
describe("getTeams", () => {
|
|
468
|
+
it("returns parsed team list", async () => {
|
|
469
|
+
fetchMock.mockResolvedValueOnce(
|
|
470
|
+
okResponse({
|
|
471
|
+
teams: {
|
|
472
|
+
nodes: [
|
|
473
|
+
{ id: "t1", name: "Engineering", key: "ENG" },
|
|
474
|
+
{ id: "t2", name: "Design", key: "DES" },
|
|
475
|
+
],
|
|
476
|
+
},
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
const api = new LinearAgentApi(TOKEN);
|
|
481
|
+
const teams = await api.getTeams();
|
|
482
|
+
expect(teams).toHaveLength(2);
|
|
483
|
+
expect(teams[0]).toEqual({ id: "t1", name: "Engineering", key: "ENG" });
|
|
484
|
+
expect(teams[1]).toEqual({ id: "t2", name: "Design", key: "DES" });
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("handles empty teams list", async () => {
|
|
488
|
+
fetchMock.mockResolvedValueOnce(okResponse({ teams: { nodes: [] } }));
|
|
489
|
+
|
|
490
|
+
const api = new LinearAgentApi(TOKEN);
|
|
491
|
+
const teams = await api.getTeams();
|
|
492
|
+
expect(teams).toEqual([]);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe("createLabel", () => {
|
|
497
|
+
it("sends correct mutation and returns label", async () => {
|
|
498
|
+
fetchMock.mockResolvedValueOnce(
|
|
499
|
+
okResponse({
|
|
500
|
+
issueLabelCreate: {
|
|
501
|
+
success: true,
|
|
502
|
+
issueLabel: { id: "label-1", name: "repo:api" },
|
|
503
|
+
},
|
|
504
|
+
}),
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
const api = new LinearAgentApi(TOKEN);
|
|
508
|
+
const label = await api.createLabel("t1", "repo:api", {
|
|
509
|
+
color: "#5e6ad2",
|
|
510
|
+
description: "Multi-repo dispatch: api",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
expect(label).toEqual({ id: "label-1", name: "repo:api" });
|
|
514
|
+
|
|
515
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
516
|
+
expect(body.query).toContain("issueLabelCreate");
|
|
517
|
+
expect(body.variables.input).toEqual({
|
|
518
|
+
teamId: "t1",
|
|
519
|
+
name: "repo:api",
|
|
520
|
+
color: "#5e6ad2",
|
|
521
|
+
description: "Multi-repo dispatch: api",
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("throws on API failure", async () => {
|
|
526
|
+
fetchMock.mockResolvedValueOnce(
|
|
527
|
+
okResponse({
|
|
528
|
+
issueLabelCreate: { success: false, issueLabel: null },
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
const api = new LinearAgentApi(TOKEN);
|
|
533
|
+
await expect(
|
|
534
|
+
api.createLabel("t1", "repo:bad"),
|
|
535
|
+
).rejects.toThrow(/Failed to create label/);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("omits optional fields when not provided", async () => {
|
|
539
|
+
fetchMock.mockResolvedValueOnce(
|
|
540
|
+
okResponse({
|
|
541
|
+
issueLabelCreate: {
|
|
542
|
+
success: true,
|
|
543
|
+
issueLabel: { id: "label-2", name: "repo:frontend" },
|
|
544
|
+
},
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
const api = new LinearAgentApi(TOKEN);
|
|
549
|
+
await api.createLabel("t1", "repo:frontend");
|
|
550
|
+
|
|
551
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
552
|
+
expect(body.variables.input).toEqual({
|
|
553
|
+
teamId: "t1",
|
|
554
|
+
name: "repo:frontend",
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
467
559
|
describe("createSessionOnIssue", () => {
|
|
468
560
|
it("returns sessionId on success", async () => {
|
|
469
561
|
fetchMock.mockResolvedValueOnce(
|
package/src/api/linear-api.ts
CHANGED
|
@@ -189,7 +189,7 @@ export class LinearAgentApi {
|
|
|
189
189
|
|
|
190
190
|
if (!retryRes.ok) {
|
|
191
191
|
const text = await retryRes.text();
|
|
192
|
-
throw new Error(`Linear API ${retryRes.status}
|
|
192
|
+
throw new Error(`Linear API authentication failed (${retryRes.status}). Your token may have expired. Run: openclaw openclaw-linear auth`);
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
const payload = await retryRes.json();
|
|
@@ -375,6 +375,42 @@ export class LinearAgentApi {
|
|
|
375
375
|
return data.team.labels.nodes;
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
async getTeams(): Promise<Array<{ id: string; name: string; key: string }>> {
|
|
379
|
+
const data = await this.gql<{
|
|
380
|
+
teams: { nodes: Array<{ id: string; name: string; key: string }> };
|
|
381
|
+
}>(
|
|
382
|
+
`query { teams { nodes { id name key } } }`,
|
|
383
|
+
);
|
|
384
|
+
return data.teams.nodes;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async createLabel(
|
|
388
|
+
teamId: string,
|
|
389
|
+
name: string,
|
|
390
|
+
opts?: { color?: string; description?: string },
|
|
391
|
+
): Promise<{ id: string; name: string }> {
|
|
392
|
+
const input: Record<string, string> = { teamId, name };
|
|
393
|
+
if (opts?.color) input.color = opts.color;
|
|
394
|
+
if (opts?.description) input.description = opts.description;
|
|
395
|
+
|
|
396
|
+
const data = await this.gql<{
|
|
397
|
+
issueLabelCreate: { success: boolean; issueLabel: { id: string; name: string } };
|
|
398
|
+
}>(
|
|
399
|
+
`mutation CreateLabel($input: IssueLabelCreateInput!) {
|
|
400
|
+
issueLabelCreate(input: $input) {
|
|
401
|
+
success
|
|
402
|
+
issueLabel { id name }
|
|
403
|
+
}
|
|
404
|
+
}`,
|
|
405
|
+
{ input },
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
if (!data.issueLabelCreate.success) {
|
|
409
|
+
throw new Error(`Failed to create label "${name}"`);
|
|
410
|
+
}
|
|
411
|
+
return data.issueLabelCreate.issueLabel;
|
|
412
|
+
}
|
|
413
|
+
|
|
378
414
|
// ---------------------------------------------------------------------------
|
|
379
415
|
// Planning methods
|
|
380
416
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mock dispatch-state
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const mockReadDispatchState = vi.fn();
|
|
8
|
+
const mockGetActiveDispatch = vi.fn();
|
|
9
|
+
const mockListActiveDispatches = vi.fn();
|
|
10
|
+
const mockTransitionDispatch = vi.fn();
|
|
11
|
+
const mockRemoveActiveDispatch = vi.fn();
|
|
12
|
+
const mockRegisterDispatch = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock("../pipeline/dispatch-state.js", () => ({
|
|
15
|
+
readDispatchState: (...args: any[]) => mockReadDispatchState(...args),
|
|
16
|
+
getActiveDispatch: (...args: any[]) => mockGetActiveDispatch(...args),
|
|
17
|
+
listActiveDispatches: (...args: any[]) => mockListActiveDispatches(...args),
|
|
18
|
+
transitionDispatch: (...args: any[]) => mockTransitionDispatch(...args),
|
|
19
|
+
removeActiveDispatch: (...args: any[]) => mockRemoveActiveDispatch(...args),
|
|
20
|
+
registerDispatch: (...args: any[]) => mockRegisterDispatch(...args),
|
|
21
|
+
TransitionError: class TransitionError extends Error {},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { registerDispatchMethods } from "./dispatch-methods.js";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function createApi() {
|
|
31
|
+
const methods: Record<string, Function> = {};
|
|
32
|
+
return {
|
|
33
|
+
api: {
|
|
34
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
35
|
+
pluginConfig: {},
|
|
36
|
+
registerGatewayMethod: (name: string, handler: Function) => {
|
|
37
|
+
methods[name] = handler;
|
|
38
|
+
},
|
|
39
|
+
} as any,
|
|
40
|
+
methods,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeDispatch(overrides?: Record<string, any>) {
|
|
45
|
+
return {
|
|
46
|
+
issueIdentifier: "CT-100",
|
|
47
|
+
issueId: "issue-id",
|
|
48
|
+
status: "working",
|
|
49
|
+
tier: "senior",
|
|
50
|
+
attempt: 0,
|
|
51
|
+
worktreePath: "/wt/ct-100",
|
|
52
|
+
model: "opus",
|
|
53
|
+
startedAt: new Date().toISOString(),
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeState(active: Record<string, any> = {}, completed: Record<string, any> = {}) {
|
|
59
|
+
return {
|
|
60
|
+
dispatches: { active, completed },
|
|
61
|
+
sessionMap: {},
|
|
62
|
+
processedEvents: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Tests
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe("registerDispatchMethods", () => {
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("registers all 6 methods", () => {
|
|
76
|
+
const { api, methods } = createApi();
|
|
77
|
+
registerDispatchMethods(api);
|
|
78
|
+
expect(Object.keys(methods)).toEqual(
|
|
79
|
+
expect.arrayContaining([
|
|
80
|
+
"dispatch.list",
|
|
81
|
+
"dispatch.get",
|
|
82
|
+
"dispatch.retry",
|
|
83
|
+
"dispatch.escalate",
|
|
84
|
+
"dispatch.cancel",
|
|
85
|
+
"dispatch.stats",
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("dispatch.list", () => {
|
|
92
|
+
beforeEach(() => vi.clearAllMocks());
|
|
93
|
+
|
|
94
|
+
it("returns active and completed dispatches", async () => {
|
|
95
|
+
const { api, methods } = createApi();
|
|
96
|
+
registerDispatchMethods(api);
|
|
97
|
+
|
|
98
|
+
const d = makeDispatch();
|
|
99
|
+
mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }, { "CT-99": { status: "done" } }));
|
|
100
|
+
mockListActiveDispatches.mockReturnValue([d]);
|
|
101
|
+
|
|
102
|
+
const respond = vi.fn();
|
|
103
|
+
await methods["dispatch.list"]({ params: {}, respond });
|
|
104
|
+
|
|
105
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
106
|
+
ok: true,
|
|
107
|
+
active: [d],
|
|
108
|
+
}));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("filters by status", async () => {
|
|
112
|
+
const { api, methods } = createApi();
|
|
113
|
+
registerDispatchMethods(api);
|
|
114
|
+
|
|
115
|
+
const d1 = makeDispatch({ issueIdentifier: "CT-1", status: "working" });
|
|
116
|
+
const d2 = makeDispatch({ issueIdentifier: "CT-2", status: "stuck" });
|
|
117
|
+
mockReadDispatchState.mockResolvedValue(makeState());
|
|
118
|
+
mockListActiveDispatches.mockReturnValue([d1, d2]);
|
|
119
|
+
|
|
120
|
+
const respond = vi.fn();
|
|
121
|
+
await methods["dispatch.list"]({ params: { status: "stuck" }, respond });
|
|
122
|
+
|
|
123
|
+
const result = respond.mock.calls[0][1];
|
|
124
|
+
expect(result.ok).toBe(true);
|
|
125
|
+
expect(result.active).toEqual([d2]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("filters by tier", async () => {
|
|
129
|
+
const { api, methods } = createApi();
|
|
130
|
+
registerDispatchMethods(api);
|
|
131
|
+
|
|
132
|
+
const d1 = makeDispatch({ issueIdentifier: "CT-1", tier: "junior" });
|
|
133
|
+
const d2 = makeDispatch({ issueIdentifier: "CT-2", tier: "senior" });
|
|
134
|
+
mockReadDispatchState.mockResolvedValue(makeState());
|
|
135
|
+
mockListActiveDispatches.mockReturnValue([d1, d2]);
|
|
136
|
+
|
|
137
|
+
const respond = vi.fn();
|
|
138
|
+
await methods["dispatch.list"]({ params: { tier: "senior" }, respond });
|
|
139
|
+
|
|
140
|
+
const result = respond.mock.calls[0][1];
|
|
141
|
+
expect(result.active).toEqual([d2]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("dispatch.get", () => {
|
|
146
|
+
beforeEach(() => vi.clearAllMocks());
|
|
147
|
+
|
|
148
|
+
it("returns active dispatch", async () => {
|
|
149
|
+
const { api, methods } = createApi();
|
|
150
|
+
registerDispatchMethods(api);
|
|
151
|
+
|
|
152
|
+
const d = makeDispatch();
|
|
153
|
+
mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
|
|
154
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
155
|
+
|
|
156
|
+
const respond = vi.fn();
|
|
157
|
+
await methods["dispatch.get"]({ params: { identifier: "CT-100" }, respond });
|
|
158
|
+
|
|
159
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
160
|
+
ok: true,
|
|
161
|
+
dispatch: d,
|
|
162
|
+
source: "active",
|
|
163
|
+
}));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns completed dispatch when not active", async () => {
|
|
167
|
+
const { api, methods } = createApi();
|
|
168
|
+
registerDispatchMethods(api);
|
|
169
|
+
|
|
170
|
+
const completed = { status: "done", tier: "junior" };
|
|
171
|
+
mockReadDispatchState.mockResolvedValue(makeState({}, { "CT-99": completed }));
|
|
172
|
+
mockGetActiveDispatch.mockReturnValue(undefined);
|
|
173
|
+
|
|
174
|
+
const respond = vi.fn();
|
|
175
|
+
await methods["dispatch.get"]({ params: { identifier: "CT-99" }, respond });
|
|
176
|
+
|
|
177
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
178
|
+
ok: true,
|
|
179
|
+
source: "completed",
|
|
180
|
+
}));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("fails when identifier missing", async () => {
|
|
184
|
+
const { api, methods } = createApi();
|
|
185
|
+
registerDispatchMethods(api);
|
|
186
|
+
|
|
187
|
+
const respond = vi.fn();
|
|
188
|
+
await methods["dispatch.get"]({ params: {}, respond });
|
|
189
|
+
|
|
190
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
191
|
+
ok: false,
|
|
192
|
+
error: expect.stringContaining("identifier"),
|
|
193
|
+
}));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("fails when dispatch not found", async () => {
|
|
197
|
+
const { api, methods } = createApi();
|
|
198
|
+
registerDispatchMethods(api);
|
|
199
|
+
|
|
200
|
+
mockReadDispatchState.mockResolvedValue(makeState());
|
|
201
|
+
mockGetActiveDispatch.mockReturnValue(undefined);
|
|
202
|
+
|
|
203
|
+
const respond = vi.fn();
|
|
204
|
+
await methods["dispatch.get"]({ params: { identifier: "NOPE-1" }, respond });
|
|
205
|
+
|
|
206
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
207
|
+
ok: false,
|
|
208
|
+
error: expect.stringContaining("NOPE-1"),
|
|
209
|
+
}));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("dispatch.retry", () => {
|
|
214
|
+
beforeEach(() => vi.clearAllMocks());
|
|
215
|
+
|
|
216
|
+
it("retries stuck dispatch", async () => {
|
|
217
|
+
const { api, methods } = createApi();
|
|
218
|
+
registerDispatchMethods(api);
|
|
219
|
+
|
|
220
|
+
const d = makeDispatch({ status: "stuck", attempt: 1 });
|
|
221
|
+
mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
|
|
222
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
223
|
+
mockRemoveActiveDispatch.mockResolvedValue(undefined);
|
|
224
|
+
mockRegisterDispatch.mockResolvedValue(undefined);
|
|
225
|
+
|
|
226
|
+
const respond = vi.fn();
|
|
227
|
+
await methods["dispatch.retry"]({ params: { identifier: "CT-100" }, respond });
|
|
228
|
+
|
|
229
|
+
const result = respond.mock.calls[0][1];
|
|
230
|
+
expect(result.ok).toBe(true);
|
|
231
|
+
expect(result.dispatch.status).toBe("dispatched");
|
|
232
|
+
expect(result.dispatch.attempt).toBe(2);
|
|
233
|
+
expect(mockRemoveActiveDispatch).toHaveBeenCalledWith("CT-100", undefined);
|
|
234
|
+
expect(mockRegisterDispatch).toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("rejects retry for working dispatch", async () => {
|
|
238
|
+
const { api, methods } = createApi();
|
|
239
|
+
registerDispatchMethods(api);
|
|
240
|
+
|
|
241
|
+
const d = makeDispatch({ status: "working" });
|
|
242
|
+
mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
|
|
243
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
244
|
+
|
|
245
|
+
const respond = vi.fn();
|
|
246
|
+
await methods["dispatch.retry"]({ params: { identifier: "CT-100" }, respond });
|
|
247
|
+
|
|
248
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
249
|
+
ok: false,
|
|
250
|
+
error: expect.stringContaining("working"),
|
|
251
|
+
}));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("rejects retry for missing dispatch", async () => {
|
|
255
|
+
const { api, methods } = createApi();
|
|
256
|
+
registerDispatchMethods(api);
|
|
257
|
+
|
|
258
|
+
mockReadDispatchState.mockResolvedValue(makeState());
|
|
259
|
+
mockGetActiveDispatch.mockReturnValue(undefined);
|
|
260
|
+
|
|
261
|
+
const respond = vi.fn();
|
|
262
|
+
await methods["dispatch.retry"]({ params: { identifier: "CT-100" }, respond });
|
|
263
|
+
|
|
264
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
265
|
+
ok: false,
|
|
266
|
+
}));
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("dispatch.escalate", () => {
|
|
271
|
+
beforeEach(() => vi.clearAllMocks());
|
|
272
|
+
|
|
273
|
+
it("escalates working dispatch to stuck", async () => {
|
|
274
|
+
const { api, methods } = createApi();
|
|
275
|
+
registerDispatchMethods(api);
|
|
276
|
+
|
|
277
|
+
const d = makeDispatch({ status: "working" });
|
|
278
|
+
const updated = { ...d, status: "stuck", stuckReason: "Manual escalation" };
|
|
279
|
+
mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
|
|
280
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
281
|
+
mockTransitionDispatch.mockResolvedValue(updated);
|
|
282
|
+
|
|
283
|
+
const respond = vi.fn();
|
|
284
|
+
await methods["dispatch.escalate"]({ params: { identifier: "CT-100", reason: "Manual escalation" }, respond });
|
|
285
|
+
|
|
286
|
+
const result = respond.mock.calls[0][1];
|
|
287
|
+
expect(result.ok).toBe(true);
|
|
288
|
+
expect(mockTransitionDispatch).toHaveBeenCalledWith("CT-100", "working", "stuck", expect.objectContaining({ stuckReason: "Manual escalation" }), undefined);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("uses default reason when none provided", async () => {
|
|
292
|
+
const { api, methods } = createApi();
|
|
293
|
+
registerDispatchMethods(api);
|
|
294
|
+
|
|
295
|
+
const d = makeDispatch({ status: "auditing" });
|
|
296
|
+
mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
|
|
297
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
298
|
+
mockTransitionDispatch.mockResolvedValue(d);
|
|
299
|
+
|
|
300
|
+
const respond = vi.fn();
|
|
301
|
+
await methods["dispatch.escalate"]({ params: { identifier: "CT-100" }, respond });
|
|
302
|
+
|
|
303
|
+
expect(mockTransitionDispatch).toHaveBeenCalledWith(
|
|
304
|
+
"CT-100", "auditing", "stuck",
|
|
305
|
+
expect.objectContaining({ stuckReason: "Manually escalated via gateway" }),
|
|
306
|
+
undefined,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("rejects escalation for stuck dispatch", async () => {
|
|
311
|
+
const { api, methods } = createApi();
|
|
312
|
+
registerDispatchMethods(api);
|
|
313
|
+
|
|
314
|
+
const d = makeDispatch({ status: "stuck" });
|
|
315
|
+
mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
|
|
316
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
317
|
+
|
|
318
|
+
const respond = vi.fn();
|
|
319
|
+
await methods["dispatch.escalate"]({ params: { identifier: "CT-100" }, respond });
|
|
320
|
+
|
|
321
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
322
|
+
ok: false,
|
|
323
|
+
error: expect.stringContaining("stuck"),
|
|
324
|
+
}));
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("dispatch.cancel", () => {
|
|
329
|
+
beforeEach(() => vi.clearAllMocks());
|
|
330
|
+
|
|
331
|
+
it("removes active dispatch", async () => {
|
|
332
|
+
const { api, methods } = createApi();
|
|
333
|
+
registerDispatchMethods(api);
|
|
334
|
+
|
|
335
|
+
const d = makeDispatch({ status: "working" });
|
|
336
|
+
mockReadDispatchState.mockResolvedValue(makeState({ "CT-100": d }));
|
|
337
|
+
mockGetActiveDispatch.mockReturnValue(d);
|
|
338
|
+
mockRemoveActiveDispatch.mockResolvedValue(undefined);
|
|
339
|
+
|
|
340
|
+
const respond = vi.fn();
|
|
341
|
+
await methods["dispatch.cancel"]({ params: { identifier: "CT-100" }, respond });
|
|
342
|
+
|
|
343
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
344
|
+
ok: true,
|
|
345
|
+
cancelled: "CT-100",
|
|
346
|
+
previousStatus: "working",
|
|
347
|
+
}));
|
|
348
|
+
expect(mockRemoveActiveDispatch).toHaveBeenCalledWith("CT-100", undefined);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("fails for missing dispatch", async () => {
|
|
352
|
+
const { api, methods } = createApi();
|
|
353
|
+
registerDispatchMethods(api);
|
|
354
|
+
|
|
355
|
+
mockReadDispatchState.mockResolvedValue(makeState());
|
|
356
|
+
mockGetActiveDispatch.mockReturnValue(undefined);
|
|
357
|
+
|
|
358
|
+
const respond = vi.fn();
|
|
359
|
+
await methods["dispatch.cancel"]({ params: { identifier: "CT-999" }, respond });
|
|
360
|
+
|
|
361
|
+
expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({
|
|
362
|
+
ok: false,
|
|
363
|
+
}));
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("dispatch.stats", () => {
|
|
368
|
+
beforeEach(() => vi.clearAllMocks());
|
|
369
|
+
|
|
370
|
+
it("returns counts by status and tier", async () => {
|
|
371
|
+
const { api, methods } = createApi();
|
|
372
|
+
registerDispatchMethods(api);
|
|
373
|
+
|
|
374
|
+
const active = [
|
|
375
|
+
makeDispatch({ status: "working", tier: "senior" }),
|
|
376
|
+
makeDispatch({ status: "working", tier: "junior" }),
|
|
377
|
+
makeDispatch({ status: "stuck", tier: "senior" }),
|
|
378
|
+
];
|
|
379
|
+
mockReadDispatchState.mockResolvedValue(makeState({}, { "CT-99": {} }));
|
|
380
|
+
mockListActiveDispatches.mockReturnValue(active);
|
|
381
|
+
|
|
382
|
+
const respond = vi.fn();
|
|
383
|
+
await methods["dispatch.stats"]({ params: {}, respond });
|
|
384
|
+
|
|
385
|
+
const result = respond.mock.calls[0][1];
|
|
386
|
+
expect(result.ok).toBe(true);
|
|
387
|
+
expect(result.activeCount).toBe(3);
|
|
388
|
+
expect(result.completedCount).toBe(1);
|
|
389
|
+
expect(result.byStatus).toEqual({ working: 2, stuck: 1 });
|
|
390
|
+
expect(result.byTier).toEqual({ senior: 2, junior: 1 });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("returns zeros when no dispatches", async () => {
|
|
394
|
+
const { api, methods } = createApi();
|
|
395
|
+
registerDispatchMethods(api);
|
|
396
|
+
|
|
397
|
+
mockReadDispatchState.mockResolvedValue(makeState());
|
|
398
|
+
mockListActiveDispatches.mockReturnValue([]);
|
|
399
|
+
|
|
400
|
+
const respond = vi.fn();
|
|
401
|
+
await methods["dispatch.stats"]({ params: {}, respond });
|
|
402
|
+
|
|
403
|
+
const result = respond.mock.calls[0][1];
|
|
404
|
+
expect(result.activeCount).toBe(0);
|
|
405
|
+
expect(result.completedCount).toBe(0);
|
|
406
|
+
expect(result.byStatus).toEqual({});
|
|
407
|
+
expect(result.byTier).toEqual({});
|
|
408
|
+
});
|
|
409
|
+
});
|