@calltelemetry/openclaw-linear 0.7.0 → 0.8.0
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/LICENSE +21 -0
- package/README.md +719 -539
- package/index.ts +40 -1
- package/openclaw.plugin.json +4 -4
- package/package.json +2 -1
- package/prompts.yaml +19 -5
- 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 +143 -0
- package/src/api/linear-api.test.ts +586 -0
- package/src/api/linear-api.ts +50 -11
- package/src/gateway/dispatch-methods.test.ts +409 -0
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +273 -30
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.test.ts +276 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +19 -0
- package/src/infra/doctor.ts +28 -23
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.test.ts +163 -0
- package/src/infra/multi-repo.ts +114 -0
- package/src/infra/notify.test.ts +155 -16
- package/src/infra/notify.ts +137 -26
- package/src/infra/observability.test.ts +85 -0
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.test.ts +26 -3
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +3 -42
- package/src/pipeline/e2e-dispatch.test.ts +584 -0
- package/src/pipeline/e2e-planning.test.ts +455 -0
- package/src/pipeline/pipeline.test.ts +69 -0
- package/src/pipeline/pipeline.ts +132 -29
- package/src/pipeline/planner.test.ts +1 -1
- package/src/pipeline/planner.ts +18 -31
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +264 -0
- package/src/pipeline/webhook.ts +134 -36
- package/src/tools/cli-shared.test.ts +155 -0
- package/src/tools/code-tool.test.ts +210 -0
- package/src/tools/dispatch-history-tool.test.ts +315 -0
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch-methods.ts — Gateway RPC methods for dispatch operations.
|
|
3
|
+
*
|
|
4
|
+
* Registers methods on the OpenClaw gateway that allow clients (UI, CLI, other
|
|
5
|
+
* plugins) to inspect and manage the dispatch pipeline via the standard
|
|
6
|
+
* gateway request/respond protocol.
|
|
7
|
+
*
|
|
8
|
+
* Methods:
|
|
9
|
+
* dispatch.list — List active + completed dispatches (filterable)
|
|
10
|
+
* dispatch.get — Full details for a single dispatch
|
|
11
|
+
* dispatch.retry — Re-dispatch a stuck issue
|
|
12
|
+
* dispatch.escalate — Force a working/auditing dispatch into stuck
|
|
13
|
+
* dispatch.cancel — Remove an active dispatch entirely
|
|
14
|
+
* dispatch.stats — Aggregate counts by status and tier
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
18
|
+
import {
|
|
19
|
+
readDispatchState,
|
|
20
|
+
getActiveDispatch,
|
|
21
|
+
listActiveDispatches,
|
|
22
|
+
transitionDispatch,
|
|
23
|
+
removeActiveDispatch,
|
|
24
|
+
registerDispatch,
|
|
25
|
+
TransitionError,
|
|
26
|
+
type ActiveDispatch,
|
|
27
|
+
type DispatchState,
|
|
28
|
+
type DispatchStatus,
|
|
29
|
+
type CompletedDispatch,
|
|
30
|
+
} from "../pipeline/dispatch-state.js";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function ok(data: Record<string, unknown> = {}): Record<string, unknown> {
|
|
37
|
+
return { ok: true, ...data };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fail(error: string): Record<string, unknown> {
|
|
41
|
+
return { ok: false, error };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Registration
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export function registerDispatchMethods(api: OpenClawPluginApi): void {
|
|
49
|
+
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
50
|
+
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
51
|
+
|
|
52
|
+
// ---- dispatch.list -------------------------------------------------------
|
|
53
|
+
api.registerGatewayMethod("dispatch.list", async ({ params, respond }) => {
|
|
54
|
+
try {
|
|
55
|
+
const statusFilter = params.status as DispatchStatus | undefined;
|
|
56
|
+
const tierFilter = params.tier as string | undefined;
|
|
57
|
+
|
|
58
|
+
const state = await readDispatchState(statePath);
|
|
59
|
+
let active = listActiveDispatches(state);
|
|
60
|
+
|
|
61
|
+
if (statusFilter) {
|
|
62
|
+
active = active.filter((d) => d.status === statusFilter);
|
|
63
|
+
}
|
|
64
|
+
if (tierFilter) {
|
|
65
|
+
active = active.filter((d) => d.tier === tierFilter);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const completed = Object.values(state.dispatches.completed);
|
|
69
|
+
|
|
70
|
+
respond(true, ok({ active, completed }));
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
respond(true, fail(err.message ?? String(err)));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---- dispatch.get --------------------------------------------------------
|
|
77
|
+
api.registerGatewayMethod("dispatch.get", async ({ params, respond }) => {
|
|
78
|
+
try {
|
|
79
|
+
const identifier = params.identifier as string | undefined;
|
|
80
|
+
if (!identifier) {
|
|
81
|
+
respond(true, fail("Missing required param: identifier"));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const state = await readDispatchState(statePath);
|
|
86
|
+
const active = getActiveDispatch(state, identifier);
|
|
87
|
+
if (active) {
|
|
88
|
+
respond(true, ok({ dispatch: active, source: "active" }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const completed = state.dispatches.completed[identifier];
|
|
93
|
+
if (completed) {
|
|
94
|
+
respond(true, ok({ dispatch: completed, source: "completed" }));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
respond(true, fail(`No dispatch found for identifier: ${identifier}`));
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
respond(true, fail(err.message ?? String(err)));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---- dispatch.retry ------------------------------------------------------
|
|
105
|
+
// Stuck dispatches are terminal in VALID_TRANSITIONS, so we cannot use
|
|
106
|
+
// transitionDispatch. Instead, remove the active dispatch and re-register
|
|
107
|
+
// it with status reset to "dispatched" and an incremented attempt counter.
|
|
108
|
+
api.registerGatewayMethod("dispatch.retry", async ({ params, respond }) => {
|
|
109
|
+
try {
|
|
110
|
+
const identifier = params.identifier as string | undefined;
|
|
111
|
+
if (!identifier) {
|
|
112
|
+
respond(true, fail("Missing required param: identifier"));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const state = await readDispatchState(statePath);
|
|
117
|
+
const dispatch = getActiveDispatch(state, identifier);
|
|
118
|
+
if (!dispatch) {
|
|
119
|
+
respond(true, fail(`No active dispatch for identifier: ${identifier}`));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (dispatch.status !== "stuck") {
|
|
124
|
+
respond(true, fail(`Cannot retry dispatch in status "${dispatch.status}" — only "stuck" dispatches can be retried`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Capture current state, remove, then re-register with reset status
|
|
129
|
+
const retryDispatch: ActiveDispatch = {
|
|
130
|
+
...dispatch,
|
|
131
|
+
status: "dispatched",
|
|
132
|
+
attempt: dispatch.attempt + 1,
|
|
133
|
+
stuckReason: undefined,
|
|
134
|
+
workerSessionKey: undefined,
|
|
135
|
+
auditSessionKey: undefined,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
await removeActiveDispatch(identifier, statePath);
|
|
139
|
+
await registerDispatch(identifier, retryDispatch, statePath);
|
|
140
|
+
|
|
141
|
+
api.logger.info(`dispatch.retry: ${identifier} re-dispatched (attempt ${retryDispatch.attempt})`);
|
|
142
|
+
respond(true, ok({ dispatch: retryDispatch }));
|
|
143
|
+
} catch (err: any) {
|
|
144
|
+
respond(true, fail(err.message ?? String(err)));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- dispatch.escalate ---------------------------------------------------
|
|
149
|
+
api.registerGatewayMethod("dispatch.escalate", async ({ params, respond }) => {
|
|
150
|
+
try {
|
|
151
|
+
const identifier = params.identifier as string | undefined;
|
|
152
|
+
if (!identifier) {
|
|
153
|
+
respond(true, fail("Missing required param: identifier"));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const reason = (params.reason as string) || "Manually escalated via gateway";
|
|
158
|
+
|
|
159
|
+
const state = await readDispatchState(statePath);
|
|
160
|
+
const dispatch = getActiveDispatch(state, identifier);
|
|
161
|
+
if (!dispatch) {
|
|
162
|
+
respond(true, fail(`No active dispatch for identifier: ${identifier}`));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (dispatch.status !== "working" && dispatch.status !== "auditing") {
|
|
167
|
+
respond(true, fail(`Cannot escalate dispatch in status "${dispatch.status}" — only "working" or "auditing" dispatches can be escalated`));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const updated = await transitionDispatch(
|
|
172
|
+
identifier,
|
|
173
|
+
dispatch.status,
|
|
174
|
+
"stuck",
|
|
175
|
+
{ stuckReason: reason },
|
|
176
|
+
statePath,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
api.logger.info(`dispatch.escalate: ${identifier} escalated to stuck (was ${dispatch.status}, reason: ${reason})`);
|
|
180
|
+
respond(true, ok({ dispatch: updated }));
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
if (err instanceof TransitionError) {
|
|
183
|
+
respond(true, fail(`Transition conflict: ${err.message}`));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
respond(true, fail(err.message ?? String(err)));
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ---- dispatch.cancel -----------------------------------------------------
|
|
191
|
+
api.registerGatewayMethod("dispatch.cancel", async ({ params, respond }) => {
|
|
192
|
+
try {
|
|
193
|
+
const identifier = params.identifier as string | undefined;
|
|
194
|
+
if (!identifier) {
|
|
195
|
+
respond(true, fail("Missing required param: identifier"));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const state = await readDispatchState(statePath);
|
|
200
|
+
const dispatch = getActiveDispatch(state, identifier);
|
|
201
|
+
if (!dispatch) {
|
|
202
|
+
respond(true, fail(`No active dispatch for identifier: ${identifier}`));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await removeActiveDispatch(identifier, statePath);
|
|
207
|
+
|
|
208
|
+
api.logger.info(`dispatch.cancel: ${identifier} removed (was ${dispatch.status})`);
|
|
209
|
+
respond(true, ok({ cancelled: identifier, previousStatus: dispatch.status }));
|
|
210
|
+
} catch (err: any) {
|
|
211
|
+
respond(true, fail(err.message ?? String(err)));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---- dispatch.stats ------------------------------------------------------
|
|
216
|
+
api.registerGatewayMethod("dispatch.stats", async ({ params, respond }) => {
|
|
217
|
+
try {
|
|
218
|
+
const state = await readDispatchState(statePath);
|
|
219
|
+
const active = listActiveDispatches(state);
|
|
220
|
+
|
|
221
|
+
const byStatus: Record<string, number> = {};
|
|
222
|
+
const byTier: Record<string, number> = {};
|
|
223
|
+
|
|
224
|
+
for (const d of active) {
|
|
225
|
+
byStatus[d.status] = (byStatus[d.status] ?? 0) + 1;
|
|
226
|
+
byTier[d.tier] = (byTier[d.tier] ?? 0) + 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const completedCount = Object.keys(state.dispatches.completed).length;
|
|
230
|
+
|
|
231
|
+
respond(true, ok({
|
|
232
|
+
activeCount: active.length,
|
|
233
|
+
completedCount,
|
|
234
|
+
byStatus,
|
|
235
|
+
byTier,
|
|
236
|
+
}));
|
|
237
|
+
} catch (err: any) {
|
|
238
|
+
respond(true, fail(err.message ?? String(err)));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
api.logger.info("Dispatch gateway methods registered (dispatch.list, dispatch.get, dispatch.retry, dispatch.escalate, dispatch.cancel, dispatch.stats)");
|
|
243
|
+
}
|