@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
package/src/infra/notify.ts
CHANGED
|
@@ -8,21 +8,24 @@
|
|
|
8
8
|
* Modeled on DevClaw's notify.ts pattern — the runtime handles token resolution,
|
|
9
9
|
* formatting differences (markdown vs mrkdwn), and delivery per channel.
|
|
10
10
|
*/
|
|
11
|
-
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
11
|
+
import type { PluginRuntime, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
12
|
+
import { emitDiagnostic } from "./observability.js";
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Types
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
16
17
|
|
|
17
18
|
export type NotifyKind =
|
|
18
|
-
| "dispatch"
|
|
19
|
-
| "working"
|
|
20
|
-
| "auditing"
|
|
21
|
-
| "audit_pass"
|
|
22
|
-
| "audit_fail"
|
|
23
|
-
| "escalation"
|
|
24
|
-
| "stuck"
|
|
25
|
-
| "watchdog_kill"
|
|
19
|
+
| "dispatch" // issue dispatched to worker
|
|
20
|
+
| "working" // worker started
|
|
21
|
+
| "auditing" // audit triggered
|
|
22
|
+
| "audit_pass" // audit passed → done
|
|
23
|
+
| "audit_fail" // audit failed → rework
|
|
24
|
+
| "escalation" // 2x fail or stale → stuck
|
|
25
|
+
| "stuck" // stale detection
|
|
26
|
+
| "watchdog_kill" // agent killed by inactivity watchdog
|
|
27
|
+
| "project_progress" // DAG dispatch progress update
|
|
28
|
+
| "project_complete"; // all project issues dispatched
|
|
26
29
|
|
|
27
30
|
export interface NotifyPayload {
|
|
28
31
|
identifier: string;
|
|
@@ -51,6 +54,26 @@ export interface NotifyTarget {
|
|
|
51
54
|
export interface NotificationsConfig {
|
|
52
55
|
targets?: NotifyTarget[];
|
|
53
56
|
events?: Partial<Record<NotifyKind, boolean>>;
|
|
57
|
+
/** Opt-in: send rich embeds (Discord) and HTML (Telegram) instead of plain text. */
|
|
58
|
+
richFormat?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Rich message types (Discord embeds + Telegram HTML)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export interface DiscordEmbed {
|
|
66
|
+
title?: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
color?: number;
|
|
69
|
+
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
|
70
|
+
footer?: { text: string };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RichMessage {
|
|
74
|
+
text: string;
|
|
75
|
+
discord?: { embeds: DiscordEmbed[] };
|
|
76
|
+
telegram?: { html: string };
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
// ---------------------------------------------------------------------------
|
|
@@ -59,58 +82,134 @@ export interface NotificationsConfig {
|
|
|
59
82
|
|
|
60
83
|
export function formatMessage(kind: NotifyKind, payload: NotifyPayload): string {
|
|
61
84
|
const id = payload.identifier;
|
|
85
|
+
const attempt = (payload.attempt ?? 0) + 1; // 1-based for humans
|
|
62
86
|
switch (kind) {
|
|
63
87
|
case "dispatch":
|
|
64
|
-
return `${id}
|
|
88
|
+
return `${id} started — ${payload.title}`;
|
|
65
89
|
case "working":
|
|
66
|
-
return `${id}
|
|
90
|
+
return `${id} working on it (attempt ${attempt})`;
|
|
67
91
|
case "auditing":
|
|
68
|
-
return `${id}
|
|
92
|
+
return `${id} checking the work...`;
|
|
69
93
|
case "audit_pass":
|
|
70
|
-
return
|
|
94
|
+
return `✅ ${id} done! Ready for review.`;
|
|
71
95
|
case "audit_fail": {
|
|
72
|
-
const
|
|
73
|
-
return `${id}
|
|
96
|
+
const issues = payload.verdict?.gaps?.join(", ") ?? "unspecified";
|
|
97
|
+
return `${id} needs more work (attempt ${attempt}). Issues: ${issues}`;
|
|
74
98
|
}
|
|
75
99
|
case "escalation":
|
|
76
|
-
return `🚨 ${id} needs
|
|
100
|
+
return `🚨 ${id} needs your help — couldn't fix it after ${attempt} ${attempt === 1 ? "try" : "tries"}`;
|
|
77
101
|
case "stuck":
|
|
78
|
-
return `⏰ ${id} stuck — ${payload.reason ?? "
|
|
102
|
+
return `⏰ ${id} stuck — ${payload.reason ?? "inactive for 2h"}`;
|
|
79
103
|
case "watchdog_kill":
|
|
80
|
-
return `⚡ ${id}
|
|
81
|
-
payload.attempt != null ? `Retrying (attempt ${
|
|
104
|
+
return `⚡ ${id} timed out (${payload.reason ?? "no activity for 120s"}). ${
|
|
105
|
+
payload.attempt != null ? `Retrying (attempt ${attempt}).` : "Will retry."
|
|
82
106
|
}`;
|
|
107
|
+
case "project_progress":
|
|
108
|
+
return `📊 ${payload.title} (${id}): ${payload.status}`;
|
|
109
|
+
case "project_complete":
|
|
110
|
+
return `✅ ${payload.title} (${id}): ${payload.status}`;
|
|
83
111
|
default:
|
|
84
112
|
return `${id} — ${kind}: ${payload.status}`;
|
|
85
113
|
}
|
|
86
114
|
}
|
|
87
115
|
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Rich message formatter (Discord embeds + Telegram HTML)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
const EVENT_COLORS: Record<string, number> = {
|
|
121
|
+
dispatch: 0x3498db, // blue
|
|
122
|
+
working: 0x3498db, // blue
|
|
123
|
+
auditing: 0xf39c12, // yellow
|
|
124
|
+
audit_pass: 0x2ecc71, // green
|
|
125
|
+
audit_fail: 0xe74c3c, // red
|
|
126
|
+
escalation: 0xe74c3c, // red
|
|
127
|
+
stuck: 0xe67e22, // orange
|
|
128
|
+
watchdog_kill: 0x9b59b6, // purple
|
|
129
|
+
project_progress: 0x3498db,
|
|
130
|
+
project_complete: 0x2ecc71,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export function formatRichMessage(kind: NotifyKind, payload: NotifyPayload): RichMessage {
|
|
134
|
+
const text = formatMessage(kind, payload);
|
|
135
|
+
const color = EVENT_COLORS[kind] ?? 0x95a5a6;
|
|
136
|
+
|
|
137
|
+
// Discord embed
|
|
138
|
+
const fields: DiscordEmbed["fields"] = [];
|
|
139
|
+
if (payload.attempt != null) fields.push({ name: "Attempt", value: String((payload.attempt ?? 0) + 1), inline: true });
|
|
140
|
+
if (payload.status) fields.push({ name: "Status", value: payload.status, inline: true });
|
|
141
|
+
if (payload.verdict?.gaps?.length) {
|
|
142
|
+
fields.push({ name: "Issues to fix", value: payload.verdict.gaps.join("\n").slice(0, 1024) });
|
|
143
|
+
}
|
|
144
|
+
if (payload.reason) fields.push({ name: "Reason", value: payload.reason });
|
|
145
|
+
|
|
146
|
+
const embed: DiscordEmbed = {
|
|
147
|
+
title: `${payload.identifier} — ${kind.replace(/_/g, " ")}`,
|
|
148
|
+
description: payload.title,
|
|
149
|
+
color,
|
|
150
|
+
fields: fields.length > 0 ? fields : undefined,
|
|
151
|
+
footer: { text: `Linear Agent • ${kind}` },
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Telegram HTML
|
|
155
|
+
const htmlParts: string[] = [
|
|
156
|
+
`<b>${escapeHtml(payload.identifier)}</b> — ${escapeHtml(kind.replace(/_/g, " "))}`,
|
|
157
|
+
`<i>${escapeHtml(payload.title)}</i>`,
|
|
158
|
+
];
|
|
159
|
+
if (payload.attempt != null) htmlParts.push(`Attempt: <code>${(payload.attempt ?? 0) + 1}</code>`);
|
|
160
|
+
if (payload.status) htmlParts.push(`Status: <code>${escapeHtml(payload.status)}</code>`);
|
|
161
|
+
if (payload.verdict?.gaps?.length) {
|
|
162
|
+
htmlParts.push(`Issues to fix:\n${payload.verdict.gaps.map(g => `• ${escapeHtml(g)}`).join("\n")}`);
|
|
163
|
+
}
|
|
164
|
+
if (payload.reason) htmlParts.push(`Reason: ${escapeHtml(payload.reason)}`);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
text,
|
|
168
|
+
discord: { embeds: [embed] },
|
|
169
|
+
telegram: { html: htmlParts.join("\n") },
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function escapeHtml(s: string): string {
|
|
174
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
175
|
+
}
|
|
176
|
+
|
|
88
177
|
// ---------------------------------------------------------------------------
|
|
89
178
|
// Unified send — routes to OpenClaw runtime channel API
|
|
90
179
|
// ---------------------------------------------------------------------------
|
|
91
180
|
|
|
92
181
|
export async function sendToTarget(
|
|
93
182
|
target: NotifyTarget,
|
|
94
|
-
message: string,
|
|
183
|
+
message: string | RichMessage,
|
|
95
184
|
runtime: PluginRuntime,
|
|
96
185
|
): Promise<void> {
|
|
97
186
|
const ch = target.channel;
|
|
98
187
|
const to = target.target;
|
|
188
|
+
const isRich = typeof message !== "string";
|
|
189
|
+
const plainText = isRich ? message.text : message;
|
|
99
190
|
|
|
100
191
|
if (ch === "discord") {
|
|
101
|
-
|
|
192
|
+
if (isRich && message.discord) {
|
|
193
|
+
await runtime.channel.discord.sendMessageDiscord(to, plainText, { embeds: message.discord.embeds });
|
|
194
|
+
} else {
|
|
195
|
+
await runtime.channel.discord.sendMessageDiscord(to, plainText);
|
|
196
|
+
}
|
|
102
197
|
} else if (ch === "slack") {
|
|
103
|
-
await runtime.channel.slack.sendMessageSlack(to,
|
|
198
|
+
await runtime.channel.slack.sendMessageSlack(to, plainText, {
|
|
104
199
|
accountId: target.accountId,
|
|
105
200
|
});
|
|
106
201
|
} else if (ch === "telegram") {
|
|
107
|
-
|
|
202
|
+
if (isRich && message.telegram) {
|
|
203
|
+
await runtime.channel.telegram.sendMessageTelegram(to, message.telegram.html, { silent: true, textMode: "html" });
|
|
204
|
+
} else {
|
|
205
|
+
await runtime.channel.telegram.sendMessageTelegram(to, plainText, { silent: true });
|
|
206
|
+
}
|
|
108
207
|
} else if (ch === "signal") {
|
|
109
|
-
await runtime.channel.signal.sendMessageSignal(to,
|
|
208
|
+
await runtime.channel.signal.sendMessageSignal(to, plainText);
|
|
110
209
|
} else {
|
|
111
210
|
// Fallback: use CLI for any channel the runtime doesn't expose directly
|
|
112
211
|
const { execFileSync } = await import("node:child_process");
|
|
113
|
-
execFileSync("openclaw", ["message", "send", "--channel", ch, "--target", to, "--message",
|
|
212
|
+
execFileSync("openclaw", ["message", "send", "--channel", ch, "--target", to, "--message", plainText, "--json"], {
|
|
114
213
|
timeout: 30_000,
|
|
115
214
|
stdio: "ignore",
|
|
116
215
|
});
|
|
@@ -131,6 +230,7 @@ export function parseNotificationsConfig(
|
|
|
131
230
|
return {
|
|
132
231
|
targets: raw?.targets ?? [],
|
|
133
232
|
events: raw?.events ?? {},
|
|
233
|
+
richFormat: raw?.richFormat ?? false,
|
|
134
234
|
};
|
|
135
235
|
}
|
|
136
236
|
|
|
@@ -143,16 +243,19 @@ export function parseNotificationsConfig(
|
|
|
143
243
|
export function createNotifierFromConfig(
|
|
144
244
|
pluginConfig: Record<string, unknown> | undefined,
|
|
145
245
|
runtime: PluginRuntime,
|
|
246
|
+
api?: OpenClawPluginApi,
|
|
146
247
|
): NotifyFn {
|
|
147
248
|
const config = parseNotificationsConfig(pluginConfig);
|
|
148
249
|
|
|
149
250
|
if (!config.targets?.length) return createNoopNotifier();
|
|
150
251
|
|
|
252
|
+
const useRich = config.richFormat === true;
|
|
253
|
+
|
|
151
254
|
return async (kind, payload) => {
|
|
152
255
|
// Check event toggle — default is enabled (true)
|
|
153
256
|
if (config.events?.[kind] === false) return;
|
|
154
257
|
|
|
155
|
-
const message = formatMessage(kind, payload);
|
|
258
|
+
const message = useRich ? formatRichMessage(kind, payload) : formatMessage(kind, payload);
|
|
156
259
|
|
|
157
260
|
await Promise.allSettled(
|
|
158
261
|
config.targets!.map(async (target) => {
|
|
@@ -160,6 +263,14 @@ export function createNotifierFromConfig(
|
|
|
160
263
|
await sendToTarget(target, message, runtime);
|
|
161
264
|
} catch (err) {
|
|
162
265
|
console.error(`Notify error (${target.channel}:${target.target}):`, err);
|
|
266
|
+
if (api) {
|
|
267
|
+
emitDiagnostic(api, {
|
|
268
|
+
event: "notify_failed",
|
|
269
|
+
identifier: payload.identifier,
|
|
270
|
+
phase: kind,
|
|
271
|
+
error: String(err),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
163
274
|
}
|
|
164
275
|
}),
|
|
165
276
|
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { emitDiagnostic, type DiagnosticPayload } from "./observability.ts";
|
|
3
|
+
|
|
4
|
+
function makeApi(infoFn = vi.fn()) {
|
|
5
|
+
return { logger: { info: infoFn } } as any;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("emitDiagnostic", () => {
|
|
9
|
+
it("emits JSON with [linear:diagnostic] prefix via api.logger.info", () => {
|
|
10
|
+
const info = vi.fn();
|
|
11
|
+
const api = makeApi(info);
|
|
12
|
+
emitDiagnostic(api, { event: "webhook_received", identifier: "ISS-42" });
|
|
13
|
+
expect(info).toHaveBeenCalledOnce();
|
|
14
|
+
const line = info.mock.calls[0][0] as string;
|
|
15
|
+
expect(line).toMatch(/^\[linear:diagnostic\] \{/);
|
|
16
|
+
const json = JSON.parse(line.replace("[linear:diagnostic] ", ""));
|
|
17
|
+
expect(json.event).toBe("webhook_received");
|
|
18
|
+
expect(json.identifier).toBe("ISS-42");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("includes all payload fields in JSON output", () => {
|
|
22
|
+
const info = vi.fn();
|
|
23
|
+
const api = makeApi(info);
|
|
24
|
+
const payload: DiagnosticPayload = {
|
|
25
|
+
event: "dispatch_started",
|
|
26
|
+
identifier: "ISS-99",
|
|
27
|
+
issueId: "abc-123",
|
|
28
|
+
phase: "planning",
|
|
29
|
+
from: "triage",
|
|
30
|
+
to: "execution",
|
|
31
|
+
attempt: 2,
|
|
32
|
+
tier: "gold",
|
|
33
|
+
webhookType: "Comment",
|
|
34
|
+
webhookAction: "create",
|
|
35
|
+
channel: "discord",
|
|
36
|
+
target: "kaylee",
|
|
37
|
+
error: "none",
|
|
38
|
+
durationMs: 1234,
|
|
39
|
+
};
|
|
40
|
+
emitDiagnostic(api, payload);
|
|
41
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
42
|
+
expect(json).toMatchObject(payload);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("works with partial payload (only event + identifier)", () => {
|
|
46
|
+
const info = vi.fn();
|
|
47
|
+
const api = makeApi(info);
|
|
48
|
+
emitDiagnostic(api, { event: "health_check" });
|
|
49
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
50
|
+
expect(json.event).toBe("health_check");
|
|
51
|
+
expect(json.identifier).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("never throws even if logger throws", () => {
|
|
55
|
+
const api = makeApi(() => { throw new Error("logger exploded"); });
|
|
56
|
+
expect(() => {
|
|
57
|
+
emitDiagnostic(api, { event: "notify_failed", identifier: "ISS-1" });
|
|
58
|
+
}).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("includes timestamp-relevant fields — payload is faithfully serialized", () => {
|
|
62
|
+
const info = vi.fn();
|
|
63
|
+
const api = makeApi(info);
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
emitDiagnostic(api, { event: "phase_transition", identifier: "ISS-7", timestamp: now } as any);
|
|
66
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
67
|
+
expect(json.timestamp).toBe(now);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles payload with special characters", () => {
|
|
71
|
+
const info = vi.fn();
|
|
72
|
+
const api = makeApi(info);
|
|
73
|
+
emitDiagnostic(api, {
|
|
74
|
+
event: "notify_sent",
|
|
75
|
+
identifier: 'ISS-"special"',
|
|
76
|
+
error: "line1\nline2\ttab",
|
|
77
|
+
channel: "<script>alert('xss')</script>",
|
|
78
|
+
});
|
|
79
|
+
expect(info).toHaveBeenCalledOnce();
|
|
80
|
+
const json = JSON.parse((info.mock.calls[0][0] as string).replace("[linear:diagnostic] ", ""));
|
|
81
|
+
expect(json.identifier).toBe('ISS-"special"');
|
|
82
|
+
expect(json.error).toBe("line1\nline2\ttab");
|
|
83
|
+
expect(json.channel).toBe("<script>alert('xss')</script>");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* observability.ts — Structured diagnostic event logging.
|
|
3
|
+
*
|
|
4
|
+
* Emits structured JSON log lines via api.logger for lifecycle telemetry.
|
|
5
|
+
* Consumers (log aggregators, monitoring) can parse these for dashboards.
|
|
6
|
+
*
|
|
7
|
+
* Pattern: `[linear:diagnostic] {...json...}`
|
|
8
|
+
*/
|
|
9
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
10
|
+
|
|
11
|
+
export type DiagnosticEvent =
|
|
12
|
+
| "webhook_received"
|
|
13
|
+
| "dispatch_started"
|
|
14
|
+
| "phase_transition"
|
|
15
|
+
| "audit_triggered"
|
|
16
|
+
| "verdict_processed"
|
|
17
|
+
| "watchdog_kill"
|
|
18
|
+
| "notify_sent"
|
|
19
|
+
| "notify_failed"
|
|
20
|
+
| "health_check";
|
|
21
|
+
|
|
22
|
+
export interface DiagnosticPayload {
|
|
23
|
+
event: DiagnosticEvent;
|
|
24
|
+
identifier?: string;
|
|
25
|
+
issueId?: string;
|
|
26
|
+
phase?: string;
|
|
27
|
+
from?: string;
|
|
28
|
+
to?: string;
|
|
29
|
+
attempt?: number;
|
|
30
|
+
tier?: string;
|
|
31
|
+
webhookType?: string;
|
|
32
|
+
webhookAction?: string;
|
|
33
|
+
channel?: string;
|
|
34
|
+
target?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
durationMs?: number;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PREFIX = "[linear:diagnostic]";
|
|
41
|
+
|
|
42
|
+
export function emitDiagnostic(api: OpenClawPluginApi, payload: DiagnosticPayload): void {
|
|
43
|
+
try {
|
|
44
|
+
api.logger.info(`${PREFIX} ${JSON.stringify(payload)}`);
|
|
45
|
+
} catch {
|
|
46
|
+
// Never throw from telemetry
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createRetryPolicy,
|
|
4
|
+
createCircuitBreaker,
|
|
5
|
+
withResilience,
|
|
6
|
+
resetDefaultPolicy,
|
|
7
|
+
} from "./resilience.js";
|
|
8
|
+
import { BrokenCircuitError } from "cockatiel";
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
resetDefaultPolicy();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("createRetryPolicy", () => {
|
|
15
|
+
it("retries on transient failure then succeeds", async () => {
|
|
16
|
+
let calls = 0;
|
|
17
|
+
const policy = createRetryPolicy({ attempts: 3, initialDelay: 10, maxDelay: 20 });
|
|
18
|
+
const result = await policy.execute(async () => {
|
|
19
|
+
calls++;
|
|
20
|
+
if (calls < 3) throw new Error("transient");
|
|
21
|
+
return "ok";
|
|
22
|
+
});
|
|
23
|
+
expect(result).toBe("ok");
|
|
24
|
+
expect(calls).toBe(3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("throws after exhausting retries", async () => {
|
|
28
|
+
const policy = createRetryPolicy({ attempts: 2, initialDelay: 10, maxDelay: 20 });
|
|
29
|
+
await expect(
|
|
30
|
+
policy.execute(async () => {
|
|
31
|
+
throw new Error("permanent");
|
|
32
|
+
}),
|
|
33
|
+
).rejects.toThrow("permanent");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("createCircuitBreaker", () => {
|
|
38
|
+
it("opens after consecutive failures", async () => {
|
|
39
|
+
const breaker = createCircuitBreaker({ threshold: 3, halfOpenAfter: 60_000 });
|
|
40
|
+
|
|
41
|
+
// Fail 3 times to trip the breaker
|
|
42
|
+
for (let i = 0; i < 3; i++) {
|
|
43
|
+
try {
|
|
44
|
+
await breaker.execute(async () => {
|
|
45
|
+
throw new Error("fail");
|
|
46
|
+
});
|
|
47
|
+
} catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Next call should fail fast with BrokenCircuitError
|
|
51
|
+
await expect(
|
|
52
|
+
breaker.execute(async () => "should not run"),
|
|
53
|
+
).rejects.toThrow(BrokenCircuitError);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("allows calls when under threshold", async () => {
|
|
57
|
+
const breaker = createCircuitBreaker({ threshold: 5, halfOpenAfter: 60_000 });
|
|
58
|
+
|
|
59
|
+
// Fail twice then succeed — should not trip
|
|
60
|
+
let calls = 0;
|
|
61
|
+
for (let i = 0; i < 2; i++) {
|
|
62
|
+
try {
|
|
63
|
+
await breaker.execute(async () => {
|
|
64
|
+
throw new Error("fail");
|
|
65
|
+
});
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const result = await breaker.execute(async () => {
|
|
70
|
+
calls++;
|
|
71
|
+
return "ok";
|
|
72
|
+
});
|
|
73
|
+
expect(result).toBe("ok");
|
|
74
|
+
expect(calls).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("withResilience", () => {
|
|
79
|
+
it("returns result on success", async () => {
|
|
80
|
+
const result = await withResilience(async () => 42);
|
|
81
|
+
expect(result).toBe(42);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("retries transient failures", async () => {
|
|
85
|
+
let calls = 0;
|
|
86
|
+
const result = await withResilience(async () => {
|
|
87
|
+
calls++;
|
|
88
|
+
if (calls < 2) throw new Error("transient");
|
|
89
|
+
return "recovered";
|
|
90
|
+
});
|
|
91
|
+
expect(result).toBe("recovered");
|
|
92
|
+
expect(calls).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resilience.ts — Retry + circuit breaker for external API calls.
|
|
3
|
+
*
|
|
4
|
+
* Wraps functions with exponential backoff retry and a circuit breaker
|
|
5
|
+
* that opens after consecutive failures to prevent cascading overload.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
retry,
|
|
9
|
+
handleAll,
|
|
10
|
+
ExponentialBackoff,
|
|
11
|
+
CircuitBreakerPolicy,
|
|
12
|
+
circuitBreaker,
|
|
13
|
+
ConsecutiveBreaker,
|
|
14
|
+
wrap,
|
|
15
|
+
type IPolicy,
|
|
16
|
+
} from "cockatiel";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Retry policy
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const DEFAULT_RETRY_ATTEMPTS = 3;
|
|
23
|
+
const DEFAULT_BACKOFF = { initialDelay: 500, maxDelay: 5_000 };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a retry policy with exponential backoff.
|
|
27
|
+
*/
|
|
28
|
+
export function createRetryPolicy(opts?: {
|
|
29
|
+
attempts?: number;
|
|
30
|
+
initialDelay?: number;
|
|
31
|
+
maxDelay?: number;
|
|
32
|
+
}): IPolicy {
|
|
33
|
+
const attempts = opts?.attempts ?? DEFAULT_RETRY_ATTEMPTS;
|
|
34
|
+
const initialDelay = opts?.initialDelay ?? DEFAULT_BACKOFF.initialDelay;
|
|
35
|
+
const maxDelay = opts?.maxDelay ?? DEFAULT_BACKOFF.maxDelay;
|
|
36
|
+
|
|
37
|
+
return retry(handleAll, {
|
|
38
|
+
maxAttempts: attempts,
|
|
39
|
+
backoff: new ExponentialBackoff({
|
|
40
|
+
initialDelay,
|
|
41
|
+
maxDelay,
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Circuit breaker
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const DEFAULT_BREAKER_THRESHOLD = 5;
|
|
51
|
+
const DEFAULT_HALF_OPEN_AFTER = 30_000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a circuit breaker that opens after consecutive failures.
|
|
55
|
+
*/
|
|
56
|
+
export function createCircuitBreaker(opts?: {
|
|
57
|
+
threshold?: number;
|
|
58
|
+
halfOpenAfter?: number;
|
|
59
|
+
}): CircuitBreakerPolicy {
|
|
60
|
+
const threshold = opts?.threshold ?? DEFAULT_BREAKER_THRESHOLD;
|
|
61
|
+
const halfOpenAfter = opts?.halfOpenAfter ?? DEFAULT_HALF_OPEN_AFTER;
|
|
62
|
+
|
|
63
|
+
return circuitBreaker(handleAll, {
|
|
64
|
+
breaker: new ConsecutiveBreaker(threshold),
|
|
65
|
+
halfOpenAfter,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Combined policy
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
let _defaultPolicy: IPolicy | null = null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get the default combined retry + circuit breaker policy (singleton).
|
|
77
|
+
* 3 retries with exponential backoff (500ms → 5s) + circuit breaker
|
|
78
|
+
* (opens after 5 consecutive failures, half-opens after 30s).
|
|
79
|
+
*/
|
|
80
|
+
export function getDefaultPolicy(): IPolicy {
|
|
81
|
+
if (!_defaultPolicy) {
|
|
82
|
+
const retryPolicy = createRetryPolicy();
|
|
83
|
+
const breaker = createCircuitBreaker();
|
|
84
|
+
_defaultPolicy = wrap(retryPolicy, breaker);
|
|
85
|
+
}
|
|
86
|
+
return _defaultPolicy;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Execute a function with the default retry + circuit breaker policy.
|
|
91
|
+
*/
|
|
92
|
+
export async function withResilience<T>(fn: () => Promise<T>): Promise<T> {
|
|
93
|
+
return getDefaultPolicy().execute(fn);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reset the default policy singleton (for testing).
|
|
98
|
+
*/
|
|
99
|
+
export function resetDefaultPolicy(): void {
|
|
100
|
+
_defaultPolicy = null;
|
|
101
|
+
}
|
|
@@ -337,11 +337,13 @@ describe("buildSummaryFromArtifacts", () => {
|
|
|
337
337
|
// ---------------------------------------------------------------------------
|
|
338
338
|
|
|
339
339
|
describe("writeDispatchMemory", () => {
|
|
340
|
-
it("creates memory/ dir and writes file", () => {
|
|
340
|
+
it("creates memory/ dir and writes file with frontmatter", () => {
|
|
341
341
|
const tmp = makeTmpDir();
|
|
342
342
|
writeDispatchMemory("API-100", "summary content", tmp);
|
|
343
343
|
const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
|
|
344
|
-
expect(content).
|
|
344
|
+
expect(content).toContain("---\n");
|
|
345
|
+
expect(content).toContain('issue: "API-100"');
|
|
346
|
+
expect(content).toContain("summary content");
|
|
345
347
|
});
|
|
346
348
|
|
|
347
349
|
it("overwrites on second call", () => {
|
|
@@ -349,7 +351,28 @@ describe("writeDispatchMemory", () => {
|
|
|
349
351
|
writeDispatchMemory("API-100", "first", tmp);
|
|
350
352
|
writeDispatchMemory("API-100", "second", tmp);
|
|
351
353
|
const content = readFileSync(join(tmp, "memory", "dispatch-API-100.md"), "utf-8");
|
|
352
|
-
expect(content).
|
|
354
|
+
expect(content).toContain("second");
|
|
355
|
+
expect(content).not.toContain("first");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("includes custom metadata in frontmatter", () => {
|
|
359
|
+
const tmp = makeTmpDir();
|
|
360
|
+
writeDispatchMemory("CT-50", "done summary", tmp, {
|
|
361
|
+
title: "Fix login bug",
|
|
362
|
+
tier: "senior",
|
|
363
|
+
status: "done",
|
|
364
|
+
project: "Auth",
|
|
365
|
+
attempts: 2,
|
|
366
|
+
model: "kimi-k2.5",
|
|
367
|
+
});
|
|
368
|
+
const content = readFileSync(join(tmp, "memory", "dispatch-CT-50.md"), "utf-8");
|
|
369
|
+
expect(content).toContain('title: "Fix login bug"');
|
|
370
|
+
expect(content).toContain('tier: "senior"');
|
|
371
|
+
expect(content).toContain('status: "done"');
|
|
372
|
+
expect(content).toContain('project: "Auth"');
|
|
373
|
+
expect(content).toContain("attempts: 2");
|
|
374
|
+
expect(content).toContain('model: "kimi-k2.5"');
|
|
375
|
+
expect(content).toContain("done summary");
|
|
353
376
|
});
|
|
354
377
|
});
|
|
355
378
|
|