@calltelemetry/openclaw-linear 0.6.1 → 0.7.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/LICENSE +21 -0
- package/README.md +115 -17
- package/index.ts +57 -22
- package/openclaw.plugin.json +37 -4
- package/package.json +2 -1
- package/prompts.yaml +47 -0
- package/src/api/linear-api.test.ts +494 -0
- package/src/api/linear-api.ts +193 -19
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +284 -29
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/doctor.test.ts +4 -4
- package/src/infra/doctor.ts +7 -29
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.ts +85 -0
- package/src/infra/notify.test.ts +357 -108
- package/src/infra/notify.ts +222 -43
- 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.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 +2 -42
- package/src/pipeline/pipeline.ts +91 -17
- package/src/pipeline/planner.test.ts +334 -0
- package/src/pipeline/planner.ts +287 -0
- package/src/pipeline/planning-state.test.ts +236 -0
- package/src/pipeline/planning-state.ts +178 -0
- package/src/pipeline/tier-assess.test.ts +175 -0
- package/src/pipeline/webhook.ts +90 -17
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
- package/src/tools/planner-tools.test.ts +535 -0
- package/src/tools/planner-tools.ts +450 -0
package/src/api/linear-api.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { refreshLinearToken } from "./auth.js";
|
|
4
|
+
import { withResilience } from "../infra/resilience.js";
|
|
4
5
|
|
|
5
6
|
export const LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
|
|
6
7
|
export const AUTH_PROFILES_PATH = join(
|
|
@@ -148,38 +149,50 @@ export class LinearAgentApi {
|
|
|
148
149
|
return this.refreshToken ? `Bearer ${this.accessToken}` : this.accessToken;
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
private async gql<T = unknown>(
|
|
152
|
+
private async gql<T = unknown>(
|
|
153
|
+
query: string,
|
|
154
|
+
variables?: Record<string, unknown>,
|
|
155
|
+
extraHeaders?: Record<string, string>,
|
|
156
|
+
): Promise<T> {
|
|
152
157
|
await this.ensureValidToken();
|
|
153
158
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
const headers: Record<string, string> = {
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
Authorization: this.authHeader(),
|
|
162
|
+
...extraHeaders,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const res = await withResilience(() =>
|
|
166
|
+
fetch(LINEAR_GRAPHQL_URL, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers,
|
|
169
|
+
body: JSON.stringify({ query, variables }),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
162
172
|
|
|
163
|
-
// If 401, try refreshing token once
|
|
173
|
+
// If 401, try refreshing token once (outside resilience — own retry semantics)
|
|
164
174
|
if (res.status === 401 && this.refreshToken && this.clientId && this.clientSecret) {
|
|
165
175
|
this.expiresAt = 0; // force refresh
|
|
166
176
|
await this.ensureValidToken();
|
|
167
177
|
|
|
168
|
-
const
|
|
178
|
+
const retryHeaders: Record<string, string> = {
|
|
179
|
+
"Content-Type": "application/json",
|
|
180
|
+
Authorization: this.authHeader(),
|
|
181
|
+
...extraHeaders,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const retryRes = await fetch(LINEAR_GRAPHQL_URL, {
|
|
169
185
|
method: "POST",
|
|
170
|
-
headers:
|
|
171
|
-
"Content-Type": "application/json",
|
|
172
|
-
Authorization: this.authHeader(),
|
|
173
|
-
},
|
|
186
|
+
headers: retryHeaders,
|
|
174
187
|
body: JSON.stringify({ query, variables }),
|
|
175
188
|
});
|
|
176
189
|
|
|
177
|
-
if (!
|
|
178
|
-
const text = await
|
|
179
|
-
throw new Error(`Linear API ${
|
|
190
|
+
if (!retryRes.ok) {
|
|
191
|
+
const text = await retryRes.text();
|
|
192
|
+
throw new Error(`Linear API ${retryRes.status} (after refresh): ${text}`);
|
|
180
193
|
}
|
|
181
194
|
|
|
182
|
-
const payload = await
|
|
195
|
+
const payload = await retryRes.json();
|
|
183
196
|
if (payload.errors?.length) {
|
|
184
197
|
throw new Error(`Linear GraphQL: ${JSON.stringify(payload.errors)}`);
|
|
185
198
|
}
|
|
@@ -298,6 +311,9 @@ export class LinearAgentApi {
|
|
|
298
311
|
labels: { nodes: Array<{ id: string; name: string }> };
|
|
299
312
|
team: { id: string; name: string; issueEstimationType: string };
|
|
300
313
|
comments: { nodes: Array<{ body: string; user: { name: string } | null; createdAt: string }> };
|
|
314
|
+
project: { id: string; name: string } | null;
|
|
315
|
+
parent: { id: string; identifier: string } | null;
|
|
316
|
+
relations: { nodes: Array<{ type: string; relatedIssue: { id: string; identifier: string; title: string } }> };
|
|
301
317
|
}> {
|
|
302
318
|
const data = await this.gql<{ issue: unknown }>(
|
|
303
319
|
`query Issue($id: String!) {
|
|
@@ -318,6 +334,9 @@ export class LinearAgentApi {
|
|
|
318
334
|
createdAt
|
|
319
335
|
}
|
|
320
336
|
}
|
|
337
|
+
project { id name }
|
|
338
|
+
parent { id identifier }
|
|
339
|
+
relations { nodes { type relatedIssue { id identifier title } } }
|
|
321
340
|
}
|
|
322
341
|
}`,
|
|
323
342
|
{ id: issueId },
|
|
@@ -356,6 +375,161 @@ export class LinearAgentApi {
|
|
|
356
375
|
return data.team.labels.nodes;
|
|
357
376
|
}
|
|
358
377
|
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Planning methods
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
async createIssue(input: {
|
|
383
|
+
teamId: string;
|
|
384
|
+
title: string;
|
|
385
|
+
description?: string;
|
|
386
|
+
projectId?: string;
|
|
387
|
+
parentId?: string;
|
|
388
|
+
priority?: number;
|
|
389
|
+
estimate?: number;
|
|
390
|
+
labelIds?: string[];
|
|
391
|
+
stateId?: string;
|
|
392
|
+
assigneeId?: string;
|
|
393
|
+
}): Promise<{ id: string; identifier: string }> {
|
|
394
|
+
// Sub-issues require the GraphQL-Features header
|
|
395
|
+
const extra = input.parentId ? { "GraphQL-Features": "sub_issues" } : undefined;
|
|
396
|
+
const data = await this.gql<{
|
|
397
|
+
issueCreate: { success: boolean; issue: { id: string; identifier: string } };
|
|
398
|
+
}>(
|
|
399
|
+
`mutation IssueCreate($input: IssueCreateInput!) {
|
|
400
|
+
issueCreate(input: $input) {
|
|
401
|
+
success
|
|
402
|
+
issue { id identifier }
|
|
403
|
+
}
|
|
404
|
+
}`,
|
|
405
|
+
{ input },
|
|
406
|
+
extra,
|
|
407
|
+
);
|
|
408
|
+
return data.issueCreate.issue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async createIssueRelation(input: {
|
|
412
|
+
issueId: string;
|
|
413
|
+
relatedIssueId: string;
|
|
414
|
+
type: "blocks" | "blocked_by" | "related" | "duplicate";
|
|
415
|
+
}): Promise<{ id: string }> {
|
|
416
|
+
const data = await this.gql<{
|
|
417
|
+
issueRelationCreate: { success: boolean; issueRelation: { id: string } };
|
|
418
|
+
}>(
|
|
419
|
+
`mutation IssueRelationCreate($input: IssueRelationCreateInput!) {
|
|
420
|
+
issueRelationCreate(input: $input) {
|
|
421
|
+
success
|
|
422
|
+
issueRelation { id }
|
|
423
|
+
}
|
|
424
|
+
}`,
|
|
425
|
+
{ input },
|
|
426
|
+
);
|
|
427
|
+
return data.issueRelationCreate.issueRelation;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async getProject(projectId: string): Promise<{
|
|
431
|
+
id: string;
|
|
432
|
+
name: string;
|
|
433
|
+
description: string;
|
|
434
|
+
state: string;
|
|
435
|
+
teams: { nodes: Array<{ id: string; name: string }> };
|
|
436
|
+
}> {
|
|
437
|
+
const data = await this.gql<{ project: unknown }>(
|
|
438
|
+
`query Project($id: String!) {
|
|
439
|
+
project(id: $id) {
|
|
440
|
+
id
|
|
441
|
+
name
|
|
442
|
+
description
|
|
443
|
+
state
|
|
444
|
+
teams { nodes { id name } }
|
|
445
|
+
}
|
|
446
|
+
}`,
|
|
447
|
+
{ id: projectId },
|
|
448
|
+
);
|
|
449
|
+
return data.project as any;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async getProjectIssues(projectId: string): Promise<Array<{
|
|
453
|
+
id: string;
|
|
454
|
+
identifier: string;
|
|
455
|
+
title: string;
|
|
456
|
+
description: string | null;
|
|
457
|
+
estimate: number | null;
|
|
458
|
+
priority: number;
|
|
459
|
+
state: { name: string; type: string };
|
|
460
|
+
parent: { id: string; identifier: string } | null;
|
|
461
|
+
labels: { nodes: Array<{ id: string; name: string }> };
|
|
462
|
+
relations: { nodes: Array<{ type: string; relatedIssue: { id: string; identifier: string; title: string } }> };
|
|
463
|
+
}>> {
|
|
464
|
+
const data = await this.gql<{
|
|
465
|
+
project: { issues: { nodes: unknown[] } };
|
|
466
|
+
}>(
|
|
467
|
+
`query ProjectIssues($id: String!) {
|
|
468
|
+
project(id: $id) {
|
|
469
|
+
issues {
|
|
470
|
+
nodes {
|
|
471
|
+
id
|
|
472
|
+
identifier
|
|
473
|
+
title
|
|
474
|
+
description
|
|
475
|
+
estimate
|
|
476
|
+
priority
|
|
477
|
+
state { name type }
|
|
478
|
+
parent { id identifier }
|
|
479
|
+
labels { nodes { id name } }
|
|
480
|
+
relations { nodes { type relatedIssue { id identifier title } } }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}`,
|
|
485
|
+
{ id: projectId },
|
|
486
|
+
);
|
|
487
|
+
return data.project.issues.nodes as any;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async getTeamStates(teamId: string): Promise<Array<{
|
|
491
|
+
id: string;
|
|
492
|
+
name: string;
|
|
493
|
+
type: string;
|
|
494
|
+
}>> {
|
|
495
|
+
const data = await this.gql<{
|
|
496
|
+
team: { states: { nodes: Array<{ id: string; name: string; type: string }> } };
|
|
497
|
+
}>(
|
|
498
|
+
`query TeamStates($id: String!) {
|
|
499
|
+
team(id: $id) {
|
|
500
|
+
states: workflowStates { nodes { id name type } }
|
|
501
|
+
}
|
|
502
|
+
}`,
|
|
503
|
+
{ id: teamId },
|
|
504
|
+
);
|
|
505
|
+
return data.team.states.nodes;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async updateIssueExtended(issueId: string, input: {
|
|
509
|
+
title?: string;
|
|
510
|
+
description?: string;
|
|
511
|
+
estimate?: number;
|
|
512
|
+
labelIds?: string[];
|
|
513
|
+
stateId?: string;
|
|
514
|
+
priority?: number;
|
|
515
|
+
projectId?: string;
|
|
516
|
+
parentId?: string;
|
|
517
|
+
assigneeId?: string;
|
|
518
|
+
dueDate?: string;
|
|
519
|
+
}): Promise<boolean> {
|
|
520
|
+
const data = await this.gql<{
|
|
521
|
+
issueUpdate: { success: boolean };
|
|
522
|
+
}>(
|
|
523
|
+
`mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
|
|
524
|
+
issueUpdate(id: $id, input: $input) {
|
|
525
|
+
success
|
|
526
|
+
}
|
|
527
|
+
}`,
|
|
528
|
+
{ id: issueId, input },
|
|
529
|
+
);
|
|
530
|
+
return data.issueUpdate.success;
|
|
531
|
+
}
|
|
532
|
+
|
|
359
533
|
async getAppNotifications(count: number = 5): Promise<Array<{
|
|
360
534
|
id: string;
|
|
361
535
|
type: string;
|
|
@@ -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
|
+
}
|