@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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -17
  3. package/index.ts +57 -22
  4. package/openclaw.plugin.json +37 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +47 -0
  7. package/src/api/linear-api.test.ts +494 -0
  8. package/src/api/linear-api.ts +193 -19
  9. package/src/gateway/dispatch-methods.ts +243 -0
  10. package/src/infra/cli.ts +284 -29
  11. package/src/infra/codex-worktree.ts +83 -0
  12. package/src/infra/commands.ts +156 -0
  13. package/src/infra/doctor.test.ts +4 -4
  14. package/src/infra/doctor.ts +7 -29
  15. package/src/infra/file-lock.test.ts +61 -0
  16. package/src/infra/file-lock.ts +49 -0
  17. package/src/infra/multi-repo.ts +85 -0
  18. package/src/infra/notify.test.ts +357 -108
  19. package/src/infra/notify.ts +222 -43
  20. package/src/infra/observability.ts +48 -0
  21. package/src/infra/resilience.test.ts +94 -0
  22. package/src/infra/resilience.ts +101 -0
  23. package/src/pipeline/artifacts.ts +38 -2
  24. package/src/pipeline/dag-dispatch.test.ts +553 -0
  25. package/src/pipeline/dag-dispatch.ts +390 -0
  26. package/src/pipeline/dispatch-service.ts +48 -1
  27. package/src/pipeline/dispatch-state.ts +2 -42
  28. package/src/pipeline/pipeline.ts +91 -17
  29. package/src/pipeline/planner.test.ts +334 -0
  30. package/src/pipeline/planner.ts +287 -0
  31. package/src/pipeline/planning-state.test.ts +236 -0
  32. package/src/pipeline/planning-state.ts +178 -0
  33. package/src/pipeline/tier-assess.test.ts +175 -0
  34. package/src/pipeline/webhook.ts +90 -17
  35. package/src/tools/dispatch-history-tool.ts +201 -0
  36. package/src/tools/orchestration-tools.test.ts +158 -0
  37. package/src/tools/planner-tools.test.ts +535 -0
  38. package/src/tools/planner-tools.ts +450 -0
@@ -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>(query: string, variables?: Record<string, unknown>): Promise<T> {
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 res = await fetch(LINEAR_GRAPHQL_URL, {
155
- method: "POST",
156
- headers: {
157
- "Content-Type": "application/json",
158
- Authorization: this.authHeader(),
159
- },
160
- body: JSON.stringify({ query, variables }),
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 retry = await fetch(LINEAR_GRAPHQL_URL, {
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 (!retry.ok) {
178
- const text = await retry.text();
179
- throw new Error(`Linear API ${retry.status} (after refresh): ${text}`);
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 retry.json();
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
+ }