chainlesschain 0.45.4 → 0.45.6
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/README.md +54 -0
- package/package.json +1 -1
- package/src/commands/orchestrate.js +513 -0
- package/src/commands/ui.js +6 -1
- package/src/index.js +6 -0
- package/src/lib/agent-router.js +397 -0
- package/src/lib/claude-code-bridge.js +353 -0
- package/src/lib/notifiers/dingtalk.js +99 -0
- package/src/lib/notifiers/feishu.js +112 -0
- package/src/lib/notifiers/index.js +183 -0
- package/src/lib/notifiers/telegram.js +131 -0
- package/src/lib/notifiers/websocket.js +67 -0
- package/src/lib/notifiers/wecom.js +74 -0
- package/src/lib/orchestrator.js +438 -0
- package/src/lib/web-ui-server.js +118 -3
- package/src/lib/ws-server.js +87 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code Bridge — spawns Claude Code CLI processes as execution agents.
|
|
3
|
+
*
|
|
4
|
+
* ChainlessChain (orchestrator) dispatches coding tasks to one or more
|
|
5
|
+
* `claude` CLI sub-processes that run non-interactively, capturing their
|
|
6
|
+
* output and returning structured results.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const pool = new ClaudeCodePool({ maxParallel: 3 });
|
|
10
|
+
* const results = await pool.dispatch([
|
|
11
|
+
* { id: "t1", description: "Fix the null check in auth.js" },
|
|
12
|
+
* { id: "t2", description: "Add unit tests for login flow" },
|
|
13
|
+
* ], { cwd: "/path/to/project" });
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn, execSync } from "child_process";
|
|
17
|
+
import { EventEmitter } from "events";
|
|
18
|
+
|
|
19
|
+
/* ---------- _deps injection (Vitest CJS mock pattern) ---------- */
|
|
20
|
+
export const _deps = { spawn, execSync };
|
|
21
|
+
|
|
22
|
+
// ─── Agent status constants ───────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export const AGENT_STATUS = {
|
|
25
|
+
IDLE: "idle",
|
|
26
|
+
RUNNING: "running",
|
|
27
|
+
COMPLETED: "completed",
|
|
28
|
+
FAILED: "failed",
|
|
29
|
+
TIMEOUT: "timeout",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ─── Detection ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if the `claude` CLI is installed and return version info.
|
|
36
|
+
* Returns { found: boolean, version?: string, path?: string }.
|
|
37
|
+
*/
|
|
38
|
+
export function detectClaudeCode() {
|
|
39
|
+
try {
|
|
40
|
+
const version = _deps
|
|
41
|
+
.execSync("claude --version", { encoding: "utf-8", timeout: 5000 })
|
|
42
|
+
.trim();
|
|
43
|
+
return { found: true, version };
|
|
44
|
+
} catch (_err) {
|
|
45
|
+
return { found: false };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if the `codex` CLI (GitHub Copilot Coding Agent) is installed.
|
|
51
|
+
* Returns { found: boolean, version?: string }.
|
|
52
|
+
*/
|
|
53
|
+
export function detectCodex() {
|
|
54
|
+
try {
|
|
55
|
+
const version = _deps
|
|
56
|
+
.execSync("codex --version", { encoding: "utf-8", timeout: 5000 })
|
|
57
|
+
.trim();
|
|
58
|
+
return { found: true, version };
|
|
59
|
+
} catch (_err) {
|
|
60
|
+
return { found: false };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Single Agent ─────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A single Claude Code CLI execution agent.
|
|
68
|
+
* Wraps `claude -p "<task>" --output-format stream-json` as a child process.
|
|
69
|
+
*/
|
|
70
|
+
export class ClaudeCodeAgent extends EventEmitter {
|
|
71
|
+
constructor(options = {}) {
|
|
72
|
+
super();
|
|
73
|
+
this.id =
|
|
74
|
+
options.id ||
|
|
75
|
+
`cc-agent-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
76
|
+
this.cliCommand = options.cliCommand || "claude"; // "claude" or "codex"
|
|
77
|
+
this.model = options.model || null;
|
|
78
|
+
this.status = AGENT_STATUS.IDLE;
|
|
79
|
+
this.currentTask = null;
|
|
80
|
+
this._proc = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Execute a coding task non-interactively.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} taskDescription - Natural language task for Claude Code
|
|
87
|
+
* @param {object} options
|
|
88
|
+
* @param {string} options.cwd - Project root directory
|
|
89
|
+
* @param {number} options.timeout - Max ms to wait (default 300_000 = 5 min)
|
|
90
|
+
* @param {string} options.context - Extra context prepended to task
|
|
91
|
+
* @param {string} options.allowedTools - Comma-separated tool allow-list
|
|
92
|
+
* @returns {Promise<{success, output, exitCode, duration, taskId}>}
|
|
93
|
+
*/
|
|
94
|
+
async executeTask(taskDescription, options = {}) {
|
|
95
|
+
const {
|
|
96
|
+
cwd = process.cwd(),
|
|
97
|
+
timeout = 300_000,
|
|
98
|
+
context = "",
|
|
99
|
+
allowedTools = null,
|
|
100
|
+
} = options;
|
|
101
|
+
|
|
102
|
+
const fullPrompt = context
|
|
103
|
+
? `Context:\n${context}\n\nTask:\n${taskDescription}`
|
|
104
|
+
: taskDescription;
|
|
105
|
+
|
|
106
|
+
this.status = AGENT_STATUS.RUNNING;
|
|
107
|
+
this.currentTask = taskDescription;
|
|
108
|
+
this.emit("task:start", { agentId: this.id, task: taskDescription });
|
|
109
|
+
|
|
110
|
+
const args = ["-p", fullPrompt, "--output-format", "stream-json"];
|
|
111
|
+
if (this.model) {
|
|
112
|
+
args.push("--model", this.model);
|
|
113
|
+
}
|
|
114
|
+
if (allowedTools) {
|
|
115
|
+
args.push("--allowedTools", allowedTools);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
const startTime = Date.now();
|
|
120
|
+
const outputChunks = [];
|
|
121
|
+
const errorChunks = [];
|
|
122
|
+
let timedOut = false;
|
|
123
|
+
|
|
124
|
+
const proc = _deps.spawn(this.cliCommand, args, {
|
|
125
|
+
cwd,
|
|
126
|
+
env: { ...process.env },
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
});
|
|
129
|
+
this._proc = proc;
|
|
130
|
+
|
|
131
|
+
const timer = setTimeout(() => {
|
|
132
|
+
timedOut = true;
|
|
133
|
+
proc.kill("SIGTERM");
|
|
134
|
+
setTimeout(() => proc.kill("SIGKILL"), 3000);
|
|
135
|
+
}, timeout);
|
|
136
|
+
|
|
137
|
+
proc.stdout.on("data", (data) => {
|
|
138
|
+
const chunk = data.toString("utf8");
|
|
139
|
+
outputChunks.push(chunk);
|
|
140
|
+
this.emit("output", { agentId: this.id, chunk });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
proc.stderr.on("data", (data) => {
|
|
144
|
+
errorChunks.push(data.toString("utf8"));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
proc.on("close", (code) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
this._proc = null;
|
|
150
|
+
const duration = Date.now() - startTime;
|
|
151
|
+
const rawOutput = outputChunks.join("");
|
|
152
|
+
const status = timedOut
|
|
153
|
+
? AGENT_STATUS.TIMEOUT
|
|
154
|
+
: code === 0
|
|
155
|
+
? AGENT_STATUS.COMPLETED
|
|
156
|
+
: AGENT_STATUS.FAILED;
|
|
157
|
+
|
|
158
|
+
this.status = status;
|
|
159
|
+
this.currentTask = null;
|
|
160
|
+
|
|
161
|
+
// Parse stream-json output: last assistant message is the result
|
|
162
|
+
const parsedOutput = _parseStreamJson(rawOutput);
|
|
163
|
+
|
|
164
|
+
const result = {
|
|
165
|
+
success: code === 0 && !timedOut,
|
|
166
|
+
output: parsedOutput || rawOutput.slice(-4000), // last 4K chars fallback
|
|
167
|
+
rawOutput,
|
|
168
|
+
exitCode: code,
|
|
169
|
+
duration,
|
|
170
|
+
timedOut,
|
|
171
|
+
agentId: this.id,
|
|
172
|
+
stderr: errorChunks.join("").slice(-2000),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
this.emit("task:complete", result);
|
|
176
|
+
resolve(result);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
proc.on("error", (err) => {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
this._proc = null;
|
|
182
|
+
this.status = AGENT_STATUS.FAILED;
|
|
183
|
+
this.currentTask = null;
|
|
184
|
+
const result = {
|
|
185
|
+
success: false,
|
|
186
|
+
output: "",
|
|
187
|
+
exitCode: -1,
|
|
188
|
+
duration: Date.now() - startTime,
|
|
189
|
+
timedOut: false,
|
|
190
|
+
agentId: this.id,
|
|
191
|
+
error: err.message,
|
|
192
|
+
};
|
|
193
|
+
this.emit("task:complete", result);
|
|
194
|
+
resolve(result);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Abort the currently running task. */
|
|
200
|
+
abort() {
|
|
201
|
+
if (this._proc) {
|
|
202
|
+
this._proc.kill("SIGTERM");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
toJSON() {
|
|
207
|
+
return {
|
|
208
|
+
id: this.id,
|
|
209
|
+
status: this.status,
|
|
210
|
+
cliCommand: this.cliCommand,
|
|
211
|
+
currentTask: this.currentTask,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Agent Pool ───────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Manages a pool of Claude Code agents for parallel task dispatch.
|
|
220
|
+
*/
|
|
221
|
+
export class ClaudeCodePool extends EventEmitter {
|
|
222
|
+
/**
|
|
223
|
+
* @param {object} options
|
|
224
|
+
* @param {number} options.maxParallel - Max concurrent agents (default 3)
|
|
225
|
+
* @param {string} options.cliCommand - CLI to use: "claude" or "codex"
|
|
226
|
+
* @param {string} options.model - Model override
|
|
227
|
+
* @param {number} options.agentTimeout - Per-agent timeout ms
|
|
228
|
+
*/
|
|
229
|
+
constructor(options = {}) {
|
|
230
|
+
super();
|
|
231
|
+
this.maxParallel = options.maxParallel || 3;
|
|
232
|
+
this.cliCommand = options.cliCommand || "claude";
|
|
233
|
+
this.model = options.model || null;
|
|
234
|
+
this.agentTimeout = options.agentTimeout || 300_000;
|
|
235
|
+
|
|
236
|
+
/** @type {Map<string, ClaudeCodeAgent>} */
|
|
237
|
+
this._agents = new Map();
|
|
238
|
+
this._completed = [];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Dispatch an array of tasks to agents in parallel batches.
|
|
243
|
+
*
|
|
244
|
+
* @param {Array<{id, description, context?, allowedTools?}>} tasks
|
|
245
|
+
* @param {object} options
|
|
246
|
+
* @param {string} options.cwd - Shared working directory for all tasks
|
|
247
|
+
* @returns {Promise<Array<{taskId, agentId, success, output, duration}>>}
|
|
248
|
+
*/
|
|
249
|
+
async dispatch(tasks, options = {}) {
|
|
250
|
+
const { cwd = process.cwd() } = options;
|
|
251
|
+
const results = [];
|
|
252
|
+
|
|
253
|
+
// Process in batches of maxParallel
|
|
254
|
+
for (let i = 0; i < tasks.length; i += this.maxParallel) {
|
|
255
|
+
const batch = tasks.slice(i, i + this.maxParallel);
|
|
256
|
+
this.emit("batch:start", {
|
|
257
|
+
batchIndex: i / this.maxParallel,
|
|
258
|
+
count: batch.length,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const batchResults = await Promise.all(
|
|
262
|
+
batch.map((task) => this._runTask(task, { cwd })),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
results.push(...batchResults);
|
|
266
|
+
this.emit("batch:complete", {
|
|
267
|
+
batchIndex: i / this.maxParallel,
|
|
268
|
+
results: batchResults,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return results;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async _runTask(task, { cwd }) {
|
|
276
|
+
const agent = new ClaudeCodeAgent({
|
|
277
|
+
id: `agent-${task.id}`,
|
|
278
|
+
cliCommand: this.cliCommand,
|
|
279
|
+
model: this.model,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
this._agents.set(agent.id, agent);
|
|
283
|
+
agent.on("output", (ev) => this.emit("agent:output", ev));
|
|
284
|
+
|
|
285
|
+
const result = await agent.executeTask(task.description, {
|
|
286
|
+
cwd,
|
|
287
|
+
timeout: this.agentTimeout,
|
|
288
|
+
context: task.context || "",
|
|
289
|
+
allowedTools: task.allowedTools || null,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
this._agents.delete(agent.id);
|
|
293
|
+
this._completed.push({ ...result, taskId: task.id });
|
|
294
|
+
|
|
295
|
+
this.emit("agent:complete", { taskId: task.id, ...result });
|
|
296
|
+
return { taskId: task.id, ...result };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Current pool status snapshot. */
|
|
300
|
+
status() {
|
|
301
|
+
const active = [...this._agents.values()].map((a) => a.toJSON());
|
|
302
|
+
return {
|
|
303
|
+
active,
|
|
304
|
+
activeCount: active.length,
|
|
305
|
+
maxParallel: this.maxParallel,
|
|
306
|
+
cliCommand: this.cliCommand,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Abort all running agents. */
|
|
311
|
+
abortAll() {
|
|
312
|
+
for (const agent of this._agents.values()) {
|
|
313
|
+
agent.abort();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ─── Helpers ──────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Parse Claude Code stream-json output and extract the last assistant text.
|
|
322
|
+
* Stream-json lines look like: {"type":"assistant","message":{...}}
|
|
323
|
+
*/
|
|
324
|
+
function _parseStreamJson(raw) {
|
|
325
|
+
if (!raw) return "";
|
|
326
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
327
|
+
let lastText = "";
|
|
328
|
+
|
|
329
|
+
for (const line of lines) {
|
|
330
|
+
try {
|
|
331
|
+
const obj = JSON.parse(line);
|
|
332
|
+
// stream-json: result message has type "result"
|
|
333
|
+
if (obj.type === "result" && obj.result) {
|
|
334
|
+
return typeof obj.result === "string"
|
|
335
|
+
? obj.result
|
|
336
|
+
: JSON.stringify(obj.result);
|
|
337
|
+
}
|
|
338
|
+
// assistant message blocks
|
|
339
|
+
if (obj.type === "assistant" && obj.message?.content) {
|
|
340
|
+
const blocks = obj.message.content;
|
|
341
|
+
const textBlocks = blocks
|
|
342
|
+
.filter((b) => b.type === "text")
|
|
343
|
+
.map((b) => b.text)
|
|
344
|
+
.join("\n");
|
|
345
|
+
if (textBlocks) lastText = textBlocks;
|
|
346
|
+
}
|
|
347
|
+
} catch (_err) {
|
|
348
|
+
// Non-JSON line (progress text) — ignore
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return lastText;
|
|
353
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DingTalk (钉钉) Notifier — sends notifications via 钉钉群机器人 Webhook.
|
|
3
|
+
*
|
|
4
|
+
* Configure via env:
|
|
5
|
+
* DINGTALK_WEBHOOK_URL=https://oapi.dingtalk.com/robot/send?access_token=xxx
|
|
6
|
+
* DINGTALK_SECRET=SECxxx (optional, if "加签" is enabled)
|
|
7
|
+
*
|
|
8
|
+
* If DINGTALK_SECRET is set, each request is signed with HMAC-SHA256.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from "crypto";
|
|
12
|
+
|
|
13
|
+
export class DingTalkNotifier {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.webhookUrl =
|
|
16
|
+
options.webhookUrl || process.env.DINGTALK_WEBHOOK_URL || "";
|
|
17
|
+
this.secret = options.secret || process.env.DINGTALK_SECRET || "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get isConfigured() {
|
|
21
|
+
return Boolean(this.webhookUrl);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Compute timestamp + sign query params (required when 加签 is on). */
|
|
25
|
+
_signedUrl() {
|
|
26
|
+
if (!this.secret) return this.webhookUrl;
|
|
27
|
+
const timestamp = Date.now();
|
|
28
|
+
const stringToSign = `${timestamp}\n${this.secret}`;
|
|
29
|
+
const sign = crypto
|
|
30
|
+
.createHmac("sha256", this.secret)
|
|
31
|
+
.update(stringToSign)
|
|
32
|
+
.digest("base64");
|
|
33
|
+
const encodedSign = encodeURIComponent(sign);
|
|
34
|
+
const sep = this.webhookUrl.includes("?") ? "&" : "?";
|
|
35
|
+
return `${this.webhookUrl}${sep}timestamp=${timestamp}&sign=${encodedSign}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Send a markdown card message.
|
|
40
|
+
* @param {string} title
|
|
41
|
+
* @param {string} text - Markdown body
|
|
42
|
+
*/
|
|
43
|
+
async send(title, text) {
|
|
44
|
+
if (!this.isConfigured) return { ok: false, reason: "not configured" };
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(this._signedUrl(), {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
msgtype: "markdown",
|
|
51
|
+
markdown: { title, text },
|
|
52
|
+
at: { isAtAll: false },
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
return { ok: data.errcode === 0, data };
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return { ok: false, reason: err.message };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async notifySuccess(summary) {
|
|
63
|
+
const { taskId, description, agentCount = 1, duration } = summary;
|
|
64
|
+
const mins = duration ? Math.round(duration / 60_000) : "?";
|
|
65
|
+
return this.send(
|
|
66
|
+
"✅ CI 通过",
|
|
67
|
+
`## ✅ CI 通过\n` +
|
|
68
|
+
`**任务**: ${description.slice(0, 100)}\n` +
|
|
69
|
+
`**Agent**: ${agentCount} 个 **耗时**: ${mins}m\n` +
|
|
70
|
+
`**ID**: ${taskId}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async notifyFailure(summary) {
|
|
75
|
+
const { taskId, description, errors = [], retryNumber = 1 } = summary;
|
|
76
|
+
const errLines = errors
|
|
77
|
+
.slice(0, 3)
|
|
78
|
+
.map((e) => `- ${e.slice(0, 120)}`)
|
|
79
|
+
.join("\n");
|
|
80
|
+
return this.send(
|
|
81
|
+
`❌ CI 失败 (重试 #${retryNumber})`,
|
|
82
|
+
`## ❌ CI 失败\n` +
|
|
83
|
+
`**任务**: ${description.slice(0, 100)}\n` +
|
|
84
|
+
(errLines ? `**错误**:\n${errLines}\n` : "") +
|
|
85
|
+
`**ID**: ${taskId}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async notifyStart(summary) {
|
|
90
|
+
const { taskId, description, subtaskCount = 1 } = summary;
|
|
91
|
+
return this.send(
|
|
92
|
+
"🚀 开始编排",
|
|
93
|
+
`## 🚀 开始编排\n` +
|
|
94
|
+
`**任务**: ${description.slice(0, 100)}\n` +
|
|
95
|
+
`**子任务数**: ${subtaskCount}\n` +
|
|
96
|
+
`**ID**: ${taskId}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu (飞书) Notifier — sends notifications via 飞书群机器人 Webhook.
|
|
3
|
+
*
|
|
4
|
+
* Configure via env:
|
|
5
|
+
* FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxx
|
|
6
|
+
* FEISHU_SECRET=xxx (optional, if 签名校验 is enabled)
|
|
7
|
+
*
|
|
8
|
+
* Sends rich "interactive card" messages for good formatting.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from "crypto";
|
|
12
|
+
|
|
13
|
+
export class FeishuNotifier {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.webhookUrl =
|
|
16
|
+
options.webhookUrl || process.env.FEISHU_WEBHOOK_URL || "";
|
|
17
|
+
this.secret = options.secret || process.env.FEISHU_SECRET || "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get isConfigured() {
|
|
21
|
+
return Boolean(this.webhookUrl);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Compute sign for 签名校验 mode. */
|
|
25
|
+
_sign(timestamp) {
|
|
26
|
+
if (!this.secret) return undefined;
|
|
27
|
+
const str = `${timestamp}\n${this.secret}`;
|
|
28
|
+
return crypto.createHmac("sha256", str).update("").digest("base64");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Send an interactive card message.
|
|
33
|
+
* @param {string} header - Card header title
|
|
34
|
+
* @param {string[]} lines - Content lines (markdown-ish)
|
|
35
|
+
* @param {"green"|"red"|"blue"|"yellow"} color - Header color
|
|
36
|
+
*/
|
|
37
|
+
async send(header, lines, color = "blue") {
|
|
38
|
+
if (!this.isConfigured) return { ok: false, reason: "not configured" };
|
|
39
|
+
|
|
40
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
41
|
+
const sign = this._sign(timestamp);
|
|
42
|
+
|
|
43
|
+
const payload = {
|
|
44
|
+
timestamp,
|
|
45
|
+
...(sign ? { sign } : {}),
|
|
46
|
+
msg_type: "interactive",
|
|
47
|
+
card: {
|
|
48
|
+
header: {
|
|
49
|
+
title: { tag: "plain_text", content: header },
|
|
50
|
+
template: color,
|
|
51
|
+
},
|
|
52
|
+
elements: lines.map((line) => ({
|
|
53
|
+
tag: "div",
|
|
54
|
+
text: { tag: "lark_md", content: line },
|
|
55
|
+
})),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch(this.webhookUrl, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/json" },
|
|
63
|
+
body: JSON.stringify(payload),
|
|
64
|
+
});
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
return { ok: data.code === 0 || data.StatusCode === 0, data };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return { ok: false, reason: err.message };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async notifySuccess(summary) {
|
|
73
|
+
const { taskId, description, agentCount = 1, duration } = summary;
|
|
74
|
+
const mins = duration ? Math.round(duration / 60_000) : "?";
|
|
75
|
+
return this.send(
|
|
76
|
+
"✅ CI 通过",
|
|
77
|
+
[
|
|
78
|
+
`**任务**: ${description.slice(0, 100)}`,
|
|
79
|
+
`**Agent 数**: ${agentCount} **耗时**: ${mins}m`,
|
|
80
|
+
`**ID**: \`${taskId}\``,
|
|
81
|
+
],
|
|
82
|
+
"green",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async notifyFailure(summary) {
|
|
87
|
+
const { taskId, description, errors = [], retryNumber = 1 } = summary;
|
|
88
|
+
const errLines = errors.slice(0, 3).map((e) => `- ${e.slice(0, 120)}`);
|
|
89
|
+
return this.send(
|
|
90
|
+
`❌ CI 失败(第 ${retryNumber} 次重试)`,
|
|
91
|
+
[
|
|
92
|
+
`**任务**: ${description.slice(0, 100)}`,
|
|
93
|
+
errors.length ? `**错误**:\n${errLines.join("\n")}` : "",
|
|
94
|
+
`**ID**: \`${taskId}\``,
|
|
95
|
+
].filter(Boolean),
|
|
96
|
+
"red",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async notifyStart(summary) {
|
|
101
|
+
const { taskId, description, subtaskCount = 1 } = summary;
|
|
102
|
+
return this.send(
|
|
103
|
+
"🚀 开始编排",
|
|
104
|
+
[
|
|
105
|
+
`**任务**: ${description.slice(0, 100)}`,
|
|
106
|
+
`**子任务数**: ${subtaskCount}`,
|
|
107
|
+
`**ID**: \`${taskId}\``,
|
|
108
|
+
],
|
|
109
|
+
"blue",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|