@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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. 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
+ }