ework-web 0.1.1 → 0.1.4
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/package.json +1 -1
- package/src/config.ts +8 -0
- package/src/db.ts +9 -0
- package/src/giteaApi.ts +15 -2
- package/src/index.ts +4 -0
- package/src/opencode.ts +38 -3
- package/src/schema.sql +3 -0
- package/src/store.ts +68 -16
- package/src/webhooks.ts +60 -17
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -78,6 +78,12 @@ export const configSchema = z.object({
|
|
|
78
78
|
daemonBotLogin: z.string().default(""),
|
|
79
79
|
daemonWebhookUrl: z.string().default(""),
|
|
80
80
|
daemonWebhookSecret: z.string().default(""),
|
|
81
|
+
autowireActive: z.coerce.boolean().default(true),
|
|
82
|
+
webhookMaxConcurrent: z.preprocess((v) => {
|
|
83
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
84
|
+
if (!Number.isFinite(n) || n < 1 || n > 64) return 6;
|
|
85
|
+
return Math.floor(n);
|
|
86
|
+
}, z.number().int().min(1).max(64)),
|
|
81
87
|
});
|
|
82
88
|
|
|
83
89
|
export type Config = z.infer<typeof configSchema>;
|
|
@@ -160,6 +166,8 @@ export function loadConfig(): Config {
|
|
|
160
166
|
daemonBotLogin: process.env.WORK_DAEMON_BOT_LOGIN ?? "",
|
|
161
167
|
daemonWebhookUrl: process.env.WORK_DAEMON_WEBHOOK_URL ?? "",
|
|
162
168
|
daemonWebhookSecret: process.env.WORK_DAEMON_WEBHOOK_SECRET ?? "",
|
|
169
|
+
autowireActive: process.env.WORK_AUTOWIRE_ACTIVE !== "false",
|
|
170
|
+
webhookMaxConcurrent: Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
|
|
163
171
|
});
|
|
164
172
|
}
|
|
165
173
|
|
package/src/db.ts
CHANGED
|
@@ -57,6 +57,14 @@ function migrateProjectsTable(db: Database): void {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function migrateIssuesTable(db: Database): void {
|
|
61
|
+
const have = tableColumns(db, "issues");
|
|
62
|
+
if (have.size === 0) return;
|
|
63
|
+
if (!have.has("closed_at")) {
|
|
64
|
+
db.exec("ALTER TABLE issues ADD COLUMN closed_at TEXT");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
function db(): Database {
|
|
61
69
|
if (_db) return _db;
|
|
62
70
|
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
@@ -72,6 +80,7 @@ function db(): Database {
|
|
|
72
80
|
migrateUsersTable(_db);
|
|
73
81
|
migratePatTable(_db);
|
|
74
82
|
migrateProjectsTable(_db);
|
|
83
|
+
migrateIssuesTable(_db);
|
|
75
84
|
_db.exec(readFileSync(join(import.meta.dir, "schema.sql"), "utf8"));
|
|
76
85
|
return _db;
|
|
77
86
|
}
|
package/src/giteaApi.ts
CHANGED
|
@@ -160,8 +160,16 @@ export async function handleGiteaApi(
|
|
|
160
160
|
const title = asString(body.title);
|
|
161
161
|
if (!title) return giteaError(400, "title required");
|
|
162
162
|
const text = asString(body.body) ?? "";
|
|
163
|
-
const created = createIssue(project.id, title, text, user.login
|
|
163
|
+
const created = createIssue(project.id, title, text, user.login, {
|
|
164
|
+
createdAt: asString(body.created_at),
|
|
165
|
+
updatedAt: asString(body.updated_at),
|
|
166
|
+
state: asIssueState(body.state) ?? "open",
|
|
167
|
+
closedAt: asString(body.closed_at) || undefined,
|
|
168
|
+
});
|
|
164
169
|
emitIssueEvent(project.id, created.id, "opened", origin);
|
|
170
|
+
if (created.state === "closed") {
|
|
171
|
+
emitIssueEvent(project.id, created.id, "closed", origin);
|
|
172
|
+
}
|
|
165
173
|
return { status: 201, body: buildIssuePayload(created, project, 0, origin) };
|
|
166
174
|
} catch (e) {
|
|
167
175
|
return giteaError(e instanceof StoreError ? e.status : 500, e instanceof Error ? e.message : "error");
|
|
@@ -190,6 +198,8 @@ export async function handleGiteaApi(
|
|
|
190
198
|
title: asString(body.title),
|
|
191
199
|
body: asString(body.body),
|
|
192
200
|
state: asIssueState(body.state),
|
|
201
|
+
closedAt: asString(body.closed_at) || undefined,
|
|
202
|
+
updatedAt: asString(body.updated_at),
|
|
193
203
|
});
|
|
194
204
|
const after = getIssueById(issue.id);
|
|
195
205
|
if (!after) return giteaError(404, "issue vanished mid-edit");
|
|
@@ -229,7 +239,10 @@ export async function handleGiteaApi(
|
|
|
229
239
|
const body = await readJson(req);
|
|
230
240
|
const text = asString(body.body);
|
|
231
241
|
if (text === undefined) return giteaError(400, "body required");
|
|
232
|
-
const created = postComment(issue.id, text, user.login
|
|
242
|
+
const created = postComment(issue.id, text, user.login, {
|
|
243
|
+
createdAt: asString(body.created_at),
|
|
244
|
+
updatedAt: asString(body.updated_at),
|
|
245
|
+
});
|
|
233
246
|
emitCommentEvent(project.id, issue.id, created.id, origin);
|
|
234
247
|
return { status: 201, body: buildCommentPayload(issue, created, project, origin) };
|
|
235
248
|
}
|
package/src/index.ts
CHANGED
|
@@ -89,6 +89,10 @@ const cfg: Config = loadConfig();
|
|
|
89
89
|
const opencode = new OpencodeClient(cfg);
|
|
90
90
|
|
|
91
91
|
function autoWireDaemon(projectId: number, origin: string): void {
|
|
92
|
+
if (!cfg.autowireActive) {
|
|
93
|
+
log.info("autoWireDaemon: skipped (WORK_AUTOWIRE_ACTIVE=false)", { projectId });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
92
96
|
const botLogin = cfg.daemonBotLogin.trim();
|
|
93
97
|
const hookUrl = cfg.daemonWebhookUrl.trim();
|
|
94
98
|
if (botLogin) {
|
package/src/opencode.ts
CHANGED
|
@@ -170,11 +170,16 @@ export class OpencodeClient {
|
|
|
170
170
|
}
|
|
171
171
|
const text = stdout.trim();
|
|
172
172
|
if (!text) return null;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
} catch {
|
|
173
|
+
const jsonText = stripNonJsonPreamble(text);
|
|
174
|
+
if (jsonText === null) {
|
|
176
175
|
throw new OpencodeError(`opencode ${args.join(" ")}: non-JSON output`, 502);
|
|
177
176
|
}
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(jsonText);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
181
|
+
throw new OpencodeError(`opencode ${args.join(" ")}: malformed JSON (${msg})`, 502);
|
|
182
|
+
}
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
private async run(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
|
@@ -239,6 +244,36 @@ function asString(v: unknown): string {
|
|
|
239
244
|
return typeof v === "string" ? v : "";
|
|
240
245
|
}
|
|
241
246
|
|
|
247
|
+
// `opencode export` (and other opencode subcommands that emit JSON) write
|
|
248
|
+
// status lines to stdout BEFORE the JSON payload:
|
|
249
|
+
// • plugin banners via console.log: "[omo-stable] Downloading comment-checker binary..."
|
|
250
|
+
// • plugin registration via console.log: "[omo-stable] ast-grep binary ready."
|
|
251
|
+
// • CLI status: "Exporting session: ses_..."
|
|
252
|
+
// • any other stdout-side logging from plugins
|
|
253
|
+
// These break JSON.parse(). Find the first line that starts a JSON object
|
|
254
|
+
// or array and slice from there. Returns null if no JSON-looking content.
|
|
255
|
+
//
|
|
256
|
+
// We try JSON.parse at each candidate (line starting with '{' or '[') and
|
|
257
|
+
// return the first one that parses — necessary because plugin banners like
|
|
258
|
+
// "[omo-stable] ..." also start with '[' but aren't JSON.
|
|
259
|
+
export function stripNonJsonPreamble(s: string): string | null {
|
|
260
|
+
const lines = s.split(/\r?\n/);
|
|
261
|
+
for (let i = 0; i < lines.length; i++) {
|
|
262
|
+
const line = lines[i];
|
|
263
|
+
if (!line) continue;
|
|
264
|
+
const trimmed = line.trimStart();
|
|
265
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) continue;
|
|
266
|
+
const candidate = lines.slice(i).join("\n");
|
|
267
|
+
try {
|
|
268
|
+
JSON.parse(candidate);
|
|
269
|
+
return candidate;
|
|
270
|
+
} catch {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
242
277
|
function parseSessionExport(v: unknown): SessionExport | null {
|
|
243
278
|
if (!v || typeof v !== "object") return null;
|
|
244
279
|
const o = v as Record<string, unknown>;
|
package/src/schema.sql
CHANGED
|
@@ -42,6 +42,9 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
42
42
|
author TEXT NOT NULL REFERENCES users(login),
|
|
43
43
|
created_at TEXT NOT NULL,
|
|
44
44
|
updated_at TEXT NOT NULL,
|
|
45
|
+
-- NULL when open; stamped on close, cleared on reopen. Backfill-safe because
|
|
46
|
+
-- migrateIssuesTable ADDs the column idempotently for legacy DBs.
|
|
47
|
+
closed_at TEXT,
|
|
45
48
|
UNIQUE (project_id, number)
|
|
46
49
|
);
|
|
47
50
|
CREATE INDEX IF NOT EXISTS issues_project_state_updated
|
package/src/store.ts
CHANGED
|
@@ -49,6 +49,7 @@ export interface IssueRow {
|
|
|
49
49
|
author: string;
|
|
50
50
|
created_at: string;
|
|
51
51
|
updated_at: string;
|
|
52
|
+
closed_at: string | null;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export interface IssueWithMeta extends IssueRow {
|
|
@@ -308,18 +309,36 @@ export function listAllIssues(opts: ListIssuesOpts = {}): IssueWithMeta[] {
|
|
|
308
309
|
return rawDB().query(sql).all(...args) as IssueWithMeta[];
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
export interface CreateIssueOpts {
|
|
313
|
+
createdAt?: string;
|
|
314
|
+
updatedAt?: string;
|
|
315
|
+
state?: "open" | "closed";
|
|
316
|
+
closedAt?: string | null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isoOr(value: string | undefined, fallback: string): string {
|
|
320
|
+
if (value === undefined || value === "") return fallback;
|
|
321
|
+
const parsed = Date.parse(value);
|
|
322
|
+
if (Number.isNaN(parsed)) throw new StoreError(400, "非法 ISO8601 时间戳");
|
|
323
|
+
return new Date(parsed).toISOString();
|
|
324
|
+
}
|
|
325
|
+
|
|
311
326
|
export function createIssue(
|
|
312
327
|
projectId: number,
|
|
313
328
|
title: string,
|
|
314
329
|
body: string,
|
|
315
|
-
author: string
|
|
330
|
+
author: string,
|
|
331
|
+
opts: CreateIssueOpts = {}
|
|
316
332
|
): IssueRow {
|
|
317
333
|
title = title.trim();
|
|
318
334
|
if (!title) throw new StoreError(400, "标题不能为空");
|
|
319
335
|
if (title.length > 255) throw new StoreError(400, "标题过长(≤255)");
|
|
320
336
|
if (body.length > 65536) throw new StoreError(413, "正文过长(≤65536)");
|
|
321
337
|
ensureUser(author);
|
|
322
|
-
const
|
|
338
|
+
const createdAt = isoOr(opts.createdAt, now());
|
|
339
|
+
const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
|
|
340
|
+
const state: "open" | "closed" = opts.state ?? "open";
|
|
341
|
+
const closedAt = state === "closed" ? isoOr(opts.closedAt ?? undefined, updatedAt) : null;
|
|
323
342
|
const db = rawDB();
|
|
324
343
|
return tx(db, () => {
|
|
325
344
|
const next = (
|
|
@@ -329,19 +348,29 @@ export function createIssue(
|
|
|
329
348
|
).n;
|
|
330
349
|
const info = db
|
|
331
350
|
.query(
|
|
332
|
-
"INSERT INTO issues (project_id, number, title, body, state, author, created_at, updated_at) VALUES (?, ?, ?, ?,
|
|
351
|
+
"INSERT INTO issues (project_id, number, title, body, state, author, created_at, updated_at, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
333
352
|
)
|
|
334
|
-
.run(projectId, next, title, body, author,
|
|
335
|
-
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(
|
|
353
|
+
.run(projectId, next, title, body, state, author, createdAt, updatedAt, closedAt);
|
|
354
|
+
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(updatedAt, projectId);
|
|
336
355
|
return getIssueById(Number(info.lastInsertRowid))!;
|
|
337
356
|
});
|
|
338
357
|
}
|
|
339
358
|
|
|
340
|
-
export function setIssueState(
|
|
341
|
-
|
|
359
|
+
export function setIssueState(
|
|
360
|
+
issueId: number,
|
|
361
|
+
state: "open" | "closed",
|
|
362
|
+
opts: { closedAt?: string; updatedAt?: string } = {}
|
|
363
|
+
): void {
|
|
364
|
+
const ts = opts.updatedAt ? isoOr(opts.updatedAt, now()) : now();
|
|
365
|
+
const closedAt = state === "closed" ? isoOr(opts.closedAt ?? undefined, ts) : null;
|
|
342
366
|
const db = rawDB();
|
|
343
367
|
tx(db, () => {
|
|
344
|
-
db.query("UPDATE issues SET state = ?, updated_at = ? WHERE id = ?").run(
|
|
368
|
+
db.query("UPDATE issues SET state = ?, updated_at = ?, closed_at = ? WHERE id = ?").run(
|
|
369
|
+
state,
|
|
370
|
+
ts,
|
|
371
|
+
closedAt,
|
|
372
|
+
issueId
|
|
373
|
+
);
|
|
345
374
|
const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number };
|
|
346
375
|
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(ts, row.project_id);
|
|
347
376
|
});
|
|
@@ -351,11 +380,13 @@ export interface IssuePatch {
|
|
|
351
380
|
title?: string;
|
|
352
381
|
body?: string;
|
|
353
382
|
state?: "open" | "closed";
|
|
383
|
+
closedAt?: string | null;
|
|
384
|
+
updatedAt?: string;
|
|
354
385
|
}
|
|
355
386
|
|
|
356
387
|
export function editIssue(issueId: number, patch: IssuePatch): void {
|
|
357
388
|
const sets: string[] = [];
|
|
358
|
-
const args: (string | number)[] = [];
|
|
389
|
+
const args: (string | number | null)[] = [];
|
|
359
390
|
if (patch.title !== undefined) {
|
|
360
391
|
const t = patch.title.trim();
|
|
361
392
|
if (!t) throw new StoreError(400, "标题不能为空");
|
|
@@ -374,10 +405,20 @@ export function editIssue(issueId: number, patch: IssuePatch): void {
|
|
|
374
405
|
}
|
|
375
406
|
sets.push("state = ?");
|
|
376
407
|
args.push(patch.state);
|
|
408
|
+
const ts = patch.updatedAt ? isoOr(patch.updatedAt, now()) : now();
|
|
409
|
+
sets.push("updated_at = ?");
|
|
410
|
+
args.push(ts);
|
|
411
|
+
sets.push("closed_at = ?");
|
|
412
|
+
args.push(patch.state === "closed" ? isoOr(patch.closedAt ?? undefined, ts) : null);
|
|
413
|
+
} else if (patch.updatedAt !== undefined) {
|
|
414
|
+
sets.push("updated_at = ?");
|
|
415
|
+
args.push(isoOr(patch.updatedAt, now()));
|
|
377
416
|
}
|
|
378
417
|
if (sets.length === 0) return;
|
|
379
|
-
|
|
380
|
-
|
|
418
|
+
if (!patch.state && !patch.updatedAt) {
|
|
419
|
+
sets.push("updated_at = ?");
|
|
420
|
+
args.push(now());
|
|
421
|
+
}
|
|
381
422
|
args.push(issueId);
|
|
382
423
|
const db = rawDB();
|
|
383
424
|
tx(db, () => {
|
|
@@ -434,19 +475,30 @@ export function listCommentsForIssue(issueId: number): CommentRow[] {
|
|
|
434
475
|
.all(issueId) as CommentRow[];
|
|
435
476
|
}
|
|
436
477
|
|
|
437
|
-
export
|
|
478
|
+
export interface CreateCommentOpts {
|
|
479
|
+
createdAt?: string;
|
|
480
|
+
updatedAt?: string;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function postComment(
|
|
484
|
+
issueId: number,
|
|
485
|
+
body: string,
|
|
486
|
+
author: string,
|
|
487
|
+
opts: CreateCommentOpts = {}
|
|
488
|
+
): CommentRow {
|
|
438
489
|
if (!body || !body.trim()) throw new StoreError(400, "评论不能为空");
|
|
439
490
|
if (body.length > 65536) throw new StoreError(413, "评论过长(≤65536)");
|
|
440
491
|
ensureUser(author);
|
|
441
|
-
const
|
|
492
|
+
const createdAt = isoOr(opts.createdAt, now());
|
|
493
|
+
const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
|
|
442
494
|
const db = rawDB();
|
|
443
495
|
return tx(db, () => {
|
|
444
496
|
const info = db
|
|
445
497
|
.query("INSERT INTO comments (issue_id, author, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
|
|
446
|
-
.run(issueId, author, body,
|
|
447
|
-
db.query("UPDATE issues SET updated_at = ? WHERE id = ?").run(
|
|
498
|
+
.run(issueId, author, body, createdAt, updatedAt);
|
|
499
|
+
db.query("UPDATE issues SET updated_at = ? WHERE id = ?").run(updatedAt, issueId);
|
|
448
500
|
const row = db.query("SELECT project_id FROM issues WHERE id = ?").get(issueId) as { project_id: number };
|
|
449
|
-
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(
|
|
501
|
+
db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(updatedAt, row.project_id);
|
|
450
502
|
return db.query(
|
|
451
503
|
`SELECT c.*, u.kind AS author_kind
|
|
452
504
|
FROM comments c LEFT JOIN users u ON u.login = c.author
|
package/src/webhooks.ts
CHANGED
|
@@ -62,6 +62,44 @@ const MAX_RESPONSE_LOG_BYTES = 8192;
|
|
|
62
62
|
const HTTP_TIMEOUT_MS = 10_000;
|
|
63
63
|
const RETRY_DELAYS_MS = [0, 2_000, 8_000];
|
|
64
64
|
|
|
65
|
+
// Global cap on concurrent in-flight webhook HTTP deliveries. Without this, a
|
|
66
|
+
// flood of events against dead targets (e.g. daemon down during migration)
|
|
67
|
+
// kicks off unbounded concurrent fetches — each waits HTTP_TIMEOUT_MS, so the
|
|
68
|
+
// Bun event loop saturates and ework-web stops serving user traffic. The cap
|
|
69
|
+
// queues surplus deliveries; releaseSlot() drains the queue FIFO.
|
|
70
|
+
const MAX_CONCURRENT_DELIVERIES = clampPositiveInt(
|
|
71
|
+
Number(process.env.WORK_WEBHOOK_MAX_CONCURRENT ?? "6"),
|
|
72
|
+
1,
|
|
73
|
+
64,
|
|
74
|
+
6
|
|
75
|
+
);
|
|
76
|
+
let inFlightDeliveries = 0;
|
|
77
|
+
const deliveryQueue: Array<() => void> = [];
|
|
78
|
+
|
|
79
|
+
function clampPositiveInt(v: number, lo: number, hi: number, fallback: number): number {
|
|
80
|
+
if (!Number.isFinite(v) || v < lo || v > hi) return fallback;
|
|
81
|
+
return Math.floor(v);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function acquireDeliverySlot(): Promise<void> {
|
|
85
|
+
if (inFlightDeliveries < MAX_CONCURRENT_DELIVERIES) {
|
|
86
|
+
inFlightDeliveries++;
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
}
|
|
89
|
+
return new Promise<void>((resolve) => {
|
|
90
|
+
deliveryQueue.push(() => {
|
|
91
|
+
inFlightDeliveries++;
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function releaseDeliverySlot(): void {
|
|
98
|
+
inFlightDeliveries--;
|
|
99
|
+
const next = deliveryQueue.shift();
|
|
100
|
+
if (next) next();
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
function now(): string {
|
|
66
104
|
return new Date().toISOString();
|
|
67
105
|
}
|
|
@@ -323,7 +361,7 @@ function buildIssue(
|
|
|
323
361
|
comments: commentCount,
|
|
324
362
|
created_at: issue.created_at,
|
|
325
363
|
updated_at: issue.updated_at,
|
|
326
|
-
closed_at: issue.
|
|
364
|
+
closed_at: issue.closed_at,
|
|
327
365
|
due_date: null,
|
|
328
366
|
pull_request: null,
|
|
329
367
|
repository: buildRepository(project, origin),
|
|
@@ -488,23 +526,28 @@ async function deliver(
|
|
|
488
526
|
event: WebhookEventName,
|
|
489
527
|
rawBody: string
|
|
490
528
|
): Promise<void> {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
529
|
+
await acquireDeliverySlot();
|
|
530
|
+
try {
|
|
531
|
+
for (const attempt of RETRY_DELAYS_MS) {
|
|
532
|
+
if (attempt > 0) await Bun.sleep(attempt);
|
|
533
|
+
const deliveryUuid = randomUUID();
|
|
534
|
+
const headers = buildHeaders(event, deliveryUuid, rawBody, webhook.secret);
|
|
535
|
+
const result = await postWithTimeout(webhook.url, rawBody, headers);
|
|
536
|
+
const success = result.status !== null && result.status >= 200 && result.status < 300;
|
|
537
|
+
recordDelivery(webhook.id, event, deliveryUuid, rawBody, result);
|
|
538
|
+
if (success) return;
|
|
539
|
+
// 410 Gone: receiver explicitly says "stop sending" — honor it, no retry.
|
|
540
|
+
if (result.status === 410) return;
|
|
541
|
+
}
|
|
542
|
+
log.warn("webhook: gave up after retries", {
|
|
543
|
+
attempts: RETRY_DELAYS_MS.length,
|
|
544
|
+
webhookId: webhook.id,
|
|
545
|
+
url: webhook.url,
|
|
546
|
+
event,
|
|
547
|
+
});
|
|
548
|
+
} finally {
|
|
549
|
+
releaseDeliverySlot();
|
|
501
550
|
}
|
|
502
|
-
log.warn("webhook: gave up after retries", {
|
|
503
|
-
attempts: RETRY_DELAYS_MS.length,
|
|
504
|
-
webhookId: webhook.id,
|
|
505
|
-
url: webhook.url,
|
|
506
|
-
event,
|
|
507
|
-
});
|
|
508
551
|
}
|
|
509
552
|
|
|
510
553
|
function fanOut(
|