ework-web 0.1.0 → 0.1.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
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,11 +166,13 @@ 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
 
166
174
  export function resolveTtsBackend(cfg: Config, id?: string): TtsBackend | null {
167
- const list = cfg.ttsBackends;
175
+ const list = cfg.ttsBackends.filter((b) => b.url.trim() !== "");
168
176
  if (list.length === 0) return null;
169
177
  return list.find((b) => b.id === id) ?? list.find((b) => b.id === cfg.ttsDefaultBackend) ?? list[0] ?? null;
170
178
  }
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) {
@@ -534,7 +538,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
534
538
  }
535
539
 
536
540
  if (req.method === "GET" && url.pathname === "/api/tts/backends") {
537
- return json(cfg.ttsBackends.map((b) => ({ id: b.id, label: b.label, voice: b.voice })));
541
+ return json(cfg.ttsBackends.filter((b) => b.url.trim() !== "").map((b) => ({ id: b.id, label: b.label, voice: b.voice })));
538
542
  }
539
543
 
540
544
  if (req.method === "POST" && url.pathname === "/api/tts") {
@@ -44,7 +44,7 @@ export function relTime(iso: string): string {
44
44
  }
45
45
 
46
46
  // Must match app.js cardHTML so client hydration/polling produce identical markup.
47
- export function renderCommentCard(c: CommentView): string {
47
+ export function renderCommentCard(c: CommentView, cfg?: { translateUrl?: string; ttsBackends?: { url: string }[] }): string {
48
48
  const tag = c.tag || "human";
49
49
  const label = TAG_LABEL[tag] || "👤";
50
50
  const rx =
@@ -53,6 +53,8 @@ export function renderCommentCard(c: CommentView): string {
53
53
  .map((r) => `<span class="rxc">${r.e}<span class="rxn">${r.n}</span></span>`)
54
54
  .join("")}</span>`
55
55
  : "";
56
+ const translateEnabled = cfg ? !!cfg.translateUrl && cfg.translateUrl.trim() !== "" : true;
57
+ const ttsEnabled = cfg ? !!(cfg.ttsBackends && cfg.ttsBackends.some((b) => b.url && b.url.trim() !== "")) : true;
56
58
  return (
57
59
  `<div class="item item-${esc(tag)}" id="comment-${c.id}" data-id="${c.id}">` +
58
60
  `<div class="card"><div class="card-h">` +
@@ -60,7 +62,7 @@ export function renderCommentCard(c: CommentView): string {
60
62
  `<span class="who">${esc(c.login)}</span>` +
61
63
  `<span class="when" data-ts="${esc(c.created_at)}" title="${esc(c.created_at)}">${relTime(c.created_at)}</span>` +
62
64
  rx +
63
- `<span class="card-actions">` + actionBarHTML({ cid: String(c.id), copy: true, link: true, translate: true, tts: true }) + `</span>` +
65
+ `<span class="card-actions">` + actionBarHTML({ cid: String(c.id), copy: true, link: true, translate: true, tts: true, translateEnabled, ttsEnabled }) + `</span>` +
64
66
  `</div><div class="card-b">${c.body_html}</div></div></div>`
65
67
  );
66
68
  }
@@ -12,6 +12,8 @@ export interface LayoutProps {
12
12
  writesEnabled?: boolean;
13
13
  operatorLogin?: string;
14
14
  upstreamWebUrl?: string | null;
15
+ translateEnabled?: boolean;
16
+ ttsEnabled?: boolean;
15
17
  }
16
18
 
17
19
  export const THEME_CSS = `
@@ -143,7 +145,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
143
145
  </div>
144
146
  </div>
145
147
  ${props.descriptionHtml.trim() ? `<div class="desc-wrap">
146
- <div class="desc-actions">${actionBarHTML({ copy: true, link: true, translate: true, tts: true })}</div>
148
+ <div class="desc-actions">${actionBarHTML({ copy: true, link: true, translate: true, tts: true, translateEnabled: props.translateEnabled !== false, ttsEnabled: props.ttsEnabled !== false })}</div>
147
149
  <div class="desc${props.descriptionCollapsed ? " collapsed" : ""}" id="issueDesc">${props.descriptionHtml}</div>
148
150
  ${props.descriptionCollapsed ? `<button type="button" class="desc-toggle" id="descToggle">显示详情 ▾</button>` : ""}
149
151
  </div>` : ""}
@@ -226,14 +228,18 @@ export interface ActionBarOpts {
226
228
  link?: boolean;
227
229
  translate?: boolean;
228
230
  tts?: boolean;
231
+ translateEnabled?: boolean;
232
+ ttsEnabled?: boolean;
229
233
  }
230
234
 
231
235
  export function actionBarHTML(o: ActionBarOpts): string {
232
236
  const d = o.cid != null ? ` data-cid="${escapeAttr(o.cid)}"` : "";
233
237
  const cpy = o.copy ? `<button type="button" class="cbtn"${d} title="复制">📋</button>` : "";
234
238
  const lnk = o.link ? `<button type="button" class="clink"${d} title="复制楼层链接">🔗</button>` : "";
235
- const tr = o.translate ? `<button type="button" class="tbtn"${d} title="翻译">翻译</button>` : "";
236
- const tts = o.tts
239
+ const tr = (o.translate && o.translateEnabled !== false)
240
+ ? `<button type="button" class="tbtn"${d} title="翻译">翻译</button>`
241
+ : "";
242
+ const tts = (o.tts && o.ttsEnabled !== false)
237
243
  ? `<button type="button" class="ttsstop"${d} title="停止朗读">⏹</button><button type="button" class="ttsbtn"${d} title="朗读">🔊</button>`
238
244
  : "";
239
245
  return cpy + lnk + tr + tts;
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 ts = now();
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 (?, ?, ?, ?, 'open', ?, ?, ?)"
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, ts, ts);
335
- db.query("UPDATE projects SET updated_at = ? WHERE id = ?").run(ts, projectId);
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(issueId: number, state: "open" | "closed"): void {
341
- const ts = now();
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(state, ts, issueId);
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
- sets.push("updated_at = ?");
380
- args.push(now());
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 function postComment(issueId: number, body: string, author: string): CommentRow {
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 ts = now();
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, ts, ts);
447
- db.query("UPDATE issues SET updated_at = ? WHERE id = ?").run(ts, issueId);
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(ts, row.project_id);
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
@@ -124,9 +124,11 @@ export function buildIssueThread(
124
124
  writesEnabled: cfg.writesEnabled,
125
125
  operatorLogin: viewerLogin ?? cfg.operatorLogin,
126
126
  upstreamWebUrl,
127
+ translateEnabled: !!cfg.translateUrl && cfg.translateUrl.trim() !== "",
128
+ ttsEnabled: cfg.ttsBackends.some((b) => b.url && b.url.trim() !== ""),
127
129
  },
128
130
  safeJsonEmbed(payload),
129
- displayViews.map(renderCommentCard).join("")
131
+ displayViews.map((v) => renderCommentCard(v, cfg)).join("")
130
132
  );
131
133
  return { html };
132
134
  }
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.state === "closed" ? issue.updated_at : null,
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
- for (const attempt of RETRY_DELAYS_MS) {
492
- if (attempt > 0) await Bun.sleep(attempt);
493
- const deliveryUuid = randomUUID();
494
- const headers = buildHeaders(event, deliveryUuid, rawBody, webhook.secret);
495
- const result = await postWithTimeout(webhook.url, rawBody, headers);
496
- const success = result.status !== null && result.status >= 200 && result.status < 300;
497
- recordDelivery(webhook.id, event, deliveryUuid, rawBody, result);
498
- if (success) return;
499
- // 410 Gone: receiver explicitly says "stop sending" — honor it, no retry.
500
- if (result.status === 410) return;
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(