ework-web 0.1.1 → 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.1",
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,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/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
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(