@h-ai/feedback 0.1.0-alpha.73 → 0.1.0-alpha.75

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/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
- import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-PYKGS35A.js';
2
- export { HaiFeedbackError } from './chunk-PYKGS35A.js';
3
- import { createFeedbackBundle } from './chunk-5QWKURWU.js';
1
+ import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-7W47WZXY.js';
2
+ export { HaiFeedbackError } from './chunk-7W47WZXY.js';
3
+ import { createFeedbackBundle } from './chunk-KGMLTOMM.js';
4
4
  import { z } from 'zod';
5
- import { resolveFeedbackBudget, FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema } from '@h-ai/api-contract/feedback';
5
+ import { resolveFeedbackBudget, FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackRetryInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema, FeedbackAttachmentInputSchema } from '@h-ai/api-contract/feedback';
6
6
  import { core, ok, err } from '@h-ai/core';
7
7
  import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
8
8
  import { storage } from '@h-ai/storage';
9
9
  import { Buffer } from 'buffer';
10
- import { randomUUID, createHash } from 'crypto';
10
+ import { randomUUID, createHash, createHmac } from 'crypto';
11
11
  import { totalmem, arch, release, platform } from 'os';
12
12
  import process from 'process';
13
13
 
@@ -20,8 +20,10 @@ var FeedbackConfigSchema = z.strictObject({
20
20
  buildId: z.string().max(128).optional(),
21
21
  /** 源码提交标识,帮助定位对应代码。 */
22
22
  commit: z.string().max(128).optional(),
23
- /** disabled 不通知,console 输出安全摘要,reach 使用已有通知通道。 */
23
+ /** disabled 不通知,console 默认仅输出元数据,reach 使用已有通知通道。 */
24
24
  notificationMode: z.enum(["disabled", "console", "reach"]).default("reach"),
25
+ /** console 通知内容;metadata 只输出定位和采集状态,summary 显式加入通用脱敏摘要。 */
26
+ consoleContent: z.enum(["metadata", "summary"]).default("metadata"),
25
27
  /** Reach 接收方,由实际 Provider 校验邮箱、手机号或业务目的地。 */
26
28
  recipients: z.array(z.string().min(1).max(320)).max(20).default([]),
27
29
  /** 已初始化 Reach Provider 的唯一名称,不限制为邮件。 */
@@ -30,14 +32,39 @@ var FeedbackConfigSchema = z.strictObject({
30
32
  adminUrl: z.url().refine((value) => ["http:", "https:"].includes(new URL(value).protocol)).optional(),
31
33
  /** 补充材料收集超时,单位毫秒;超时后冻结为完整或部分材料。 */
32
34
  collectionTimeoutMs: z.number().int().min(0).max(3e5).default(6e4),
35
+ /** 业务 collectEvidence 异步回调的等待上限,单位毫秒;超时后重试采集任务。 */
36
+ businessCollectionTimeoutMs: z.number().int().min(1).max(3e5).default(3e4),
37
+ /** 反馈采集结束后的 Webhook 地址;空字符串禁用,只允许可信 HTTP(S) 地址且不跟随重定向。 */
38
+ webhookUrl: z.string().max(2048).refine((value) => {
39
+ if (!value)
40
+ return true;
41
+ if (!URL.canParse(value))
42
+ return false;
43
+ const url = new URL(value);
44
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash;
45
+ }).default(""),
46
+ /** Webhook HMAC-SHA256 签名密钥;空字符串不签名,仅服务端私有环境配置。 */
47
+ webhookSecret: z.string().max(4096).default(""),
48
+ /** 单次 Webhook 请求超时,单位毫秒;非 2xx 或超时均进入持久重试。 */
49
+ webhookTimeoutMs: z.number().int().min(1).max(6e4).default(1e4),
33
50
  /** 持久任务扫描间隔,单位毫秒;零值由宿主手动运行任务。 */
34
51
  workerIntervalMs: z.number().int().min(0).max(6e4).default(1e3),
35
52
  /** 持久任务执行租约时长,单位毫秒;过期后允许其他实例接管。 */
36
53
  leaseMs: z.number().int().min(1e3).max(3e5).default(3e4),
37
- /** 汇集、通知和导出任务最大尝试次数;到期清理持续重试。 */
54
+ /** 汇集、通知、Webhook 和导出任务最大尝试次数;到期清理持续重试。 */
38
55
  maxAttempts: z.number().int().min(1).max(20).default(5),
39
56
  /** 已受理反馈及对象附件的保留天数。 */
40
57
  retentionDays: z.number().int().min(1).max(3650).default(30),
58
+ /** 单个用户按数据库 UTC 日累计受理上限;幂等重试不重复计数。 */
59
+ maxReportsPerUserDay: z.number().int().min(1).max(1e4).default(20),
60
+ /** 单个用户每日预留存储字节(报告与最多历史导出);默认 2 GiB。 */
61
+ maxBytesPerUserDay: z.number().int().min(4 * 1024 * 1024).max(1024 ** 4).default(2 * 1024 ** 3),
62
+ /** 应用内单个 scope 的持久材料预留容量;到期清理成功后释放,默认 10 GiB。 */
63
+ maxStoredBytesPerScope: z.number().int().min(4 * 1024 * 1024).max(1024 ** 4).default(10 * 1024 ** 3),
64
+ /** 单份报告最多历史导出数;已有有效快照复用,不重复计数。 */
65
+ maxExportsPerReport: z.number().int().min(1).max(20).default(5),
66
+ /** 已完成导出的有效小时数;过期禁止下载,后台删除对象,默认 24 小时。 */
67
+ exportRetentionHours: z.number().int().min(1).max(720).default(24),
41
68
  /** 允许采集的服务端环境变量名;凭据类名称仍按敏感信息策略排除。 */
42
69
  envAllowlist: z.array(z.string().min(1).max(128)).max(100).default([]),
43
70
  /** 未压缩导出总预算;客户端、附件总量和传输预算由此派生。 */
@@ -57,6 +84,7 @@ var EVIDENCE = "hai_feedback_evidence";
57
84
  var ATTACHMENTS = "hai_feedback_attachments";
58
85
  var HISTORY = "hai_feedback_history";
59
86
  var JOBS = "hai_feedback_jobs";
87
+ var QUOTAS = "hai_feedback_quotas";
60
88
  var identityColumns = ["id", "report_id", "scope_id", "app_id", "user_id", "source", "request_id", "error_id", "client_id"];
61
89
  function feedbackTextEquals(column, value, params) {
62
90
  if (!identityColumns.includes(column))
@@ -95,7 +123,7 @@ function readFeedbackJson(value) {
95
123
  return typeof value === "string" ? JSON.parse(value) : value;
96
124
  }
97
125
  var FeedbackReportRepository = class extends BaseReldbCrudRepository {
98
- tables = { reports: REPORTS, evidence: EVIDENCE, attachments: ATTACHMENTS, history: HISTORY, jobs: JOBS };
126
+ tables = { reports: REPORTS, evidence: EVIDENCE, attachments: ATTACHMENTS, history: HISTORY, jobs: JOBS, quotas: QUOTAS };
99
127
  constructor() {
100
128
  super(reldb, {
101
129
  table: REPORTS,
@@ -107,6 +135,18 @@ var FeedbackReportRepository = class extends BaseReldbCrudRepository {
107
135
  if (!report.success)
108
136
  return report;
109
137
  const definitions = [
138
+ [QUOTAS, {
139
+ id: { type: "TEXT", primaryKey: true },
140
+ app_id: { type: "TEXT", notNull: true },
141
+ scope_id: { type: "TEXT", notNull: true },
142
+ user_id: { type: "TEXT", notNull: true },
143
+ period: { type: "BIGINT", notNull: true },
144
+ report_count: { type: "BIGINT", notNull: true },
145
+ reserved_bytes: { type: "BIGINT", notNull: true },
146
+ report_bytes: { type: "BIGINT", notNull: true, defaultValue: 0 },
147
+ export_limit: { type: "INTEGER", notNull: true, defaultValue: 0 },
148
+ updated_at: { type: "BIGINT", notNull: true }
149
+ }],
110
150
  [EVIDENCE, {
111
151
  id: { type: "TEXT", primaryKey: true },
112
152
  report_id: { type: "TEXT" },
@@ -163,9 +203,11 @@ var FeedbackReportRepository = class extends BaseReldbCrudRepository {
163
203
  return created;
164
204
  }
165
205
  const indexes = [
206
+ { table: QUOTAS, name: "hai_feedback_quotas_expiry", columns: ["app_id", "period"] },
166
207
  { table: REPORTS, name: "hai_feedback_reports_scope_time", columns: ["scope_id", "created_at"] },
167
208
  { table: REPORTS, name: "hai_feedback_reports_scope_status", columns: ["scope_id", "status", "created_at"] },
168
209
  { table: EVIDENCE, name: "hai_feedback_evidence_report_seq", columns: ["report_id", "source", "sequence"] },
210
+ { table: EVIDENCE, name: "hai_feedback_evidence_temp_expiry", columns: ["app_id", "report_id", "expires_at"] },
169
211
  { table: EVIDENCE, name: "hai_feedback_evidence_temp_scope", columns: ["scope_id", "user_id", "timestamp"] },
170
212
  { table: ATTACHMENTS, name: "hai_feedback_attachments_report", columns: ["report_id"] },
171
213
  { table: HISTORY, name: "hai_feedback_history_report", columns: ["report_id"] },
@@ -270,28 +312,68 @@ function createFeedbackCapture(appId, envAllowlist, service) {
270
312
  let capacityLoss;
271
313
  let sequence = 0;
272
314
  let totalBytes = 0;
315
+ let pendingBytes = 0;
316
+ let failures = 0;
317
+ let retryAfter = 0;
273
318
  let stopped = false;
274
319
  let flushing = null;
275
- async function flush() {
320
+ function removePending(key) {
321
+ const previous = pending.get(key);
322
+ if (previous)
323
+ pendingBytes -= previous.bytes;
324
+ pending.delete(key);
325
+ }
326
+ async function flush(force = false) {
276
327
  if (flushing)
277
328
  return flushing;
329
+ if (!force && retryAfter > Date.now())
330
+ return;
278
331
  flushing = core.context.run({ suppressLogObservers: true }, async () => {
279
- for (const [key, item] of [...pending]) {
280
- const current2 = await reldb.sql.get("SELECT id FROM hai_feedback_evidence WHERE id = ?", [key]);
332
+ const snapshot = [...pending];
333
+ let offset = 0;
334
+ while (offset < snapshot.length) {
335
+ const batch = [];
336
+ let bytes = 0;
337
+ while (offset < snapshot.length && batch.length < 32) {
338
+ const [key, item] = snapshot[offset];
339
+ if (batch.length && bytes + item.bytes > 256 * 1024)
340
+ break;
341
+ offset++;
342
+ if (item.event.timestamp < Date.now() - 5 * 6e4) {
343
+ removePending(key);
344
+ rememberLoss(item, "server_pending_evidence_expired");
345
+ continue;
346
+ }
347
+ batch.push([key, item]);
348
+ bytes += item.bytes;
349
+ }
350
+ if (!batch.length)
351
+ continue;
352
+ const current2 = await reldb.sql.query(`SELECT id FROM hai_feedback_evidence WHERE id IN (${batch.map(() => "?").join(",")})`, batch.map(([key]) => key));
281
353
  if (!current2.success) {
282
- rememberLoss(item, "server_temporary_evidence_write_failed");
354
+ for (const [, item] of batch) rememberLoss(item, "server_temporary_evidence_write_failed");
355
+ retryAfter = Date.now() + Math.min(3e4, 250 * 2 ** Math.min(++failures, 7));
283
356
  return;
284
357
  }
285
- if (!current2.data) {
286
- const event = item.event;
287
- const result = await reldb.sql.execute("INSERT INTO hai_feedback_evidence (id, report_id, scope_id, user_id, app_id, source, sequence, kind, timestamp, request_id, error_id, revision, bytes, expires_at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [key, null, item.scopeId, item.userId, appId, event.source, event.sequence, event.kind, event.timestamp, event.requestId ?? null, event.errorId ?? null, 1, item.bytes, Date.now() + 5 * 6e4, JSON.stringify(event)]);
358
+ const existing = new Set(current2.data.map((row) => row.id));
359
+ const additions = batch.filter(([key]) => !existing.has(key));
360
+ if (additions.length) {
361
+ const params = additions.flatMap(([key, item]) => {
362
+ const event = item.event;
363
+ return [key, null, item.scopeId, item.userId, appId, event.source, event.sequence, event.kind, event.timestamp, event.requestId ?? null, event.errorId ?? null, 1, item.bytes, event.timestamp + 5 * 6e4, JSON.stringify(event)];
364
+ });
365
+ const duplicate = reldb.config?.type === "mysql" ? "ON DUPLICATE KEY UPDATE id = id" : "ON CONFLICT (id) DO NOTHING";
366
+ const result = await reldb.sql.execute(`INSERT INTO hai_feedback_evidence (id, report_id, scope_id, user_id, app_id, source, sequence, kind, timestamp, request_id, error_id, revision, bytes, expires_at, payload) VALUES ${additions.map(() => "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").join(",")} ${duplicate}`, params);
288
367
  if (!result.success) {
289
- rememberLoss(item, "server_temporary_evidence_write_failed");
368
+ for (const [, item] of batch) rememberLoss(item, "server_temporary_evidence_write_failed");
369
+ retryAfter = Date.now() + Math.min(3e4, 250 * 2 ** Math.min(++failures, 7));
290
370
  return;
291
371
  }
292
372
  }
293
- pending.delete(key);
373
+ for (const [key] of batch) removePending(key);
294
374
  }
375
+ failures = 0;
376
+ retryAfter = 0;
295
377
  }).finally(() => {
296
378
  flushing = null;
297
379
  });
@@ -301,7 +383,7 @@ function createFeedbackCapture(appId, envAllowlist, service) {
301
383
  for (const [key2, previous] of losses) {
302
384
  if (previous.event.timestamp < Date.now() - 5 * 6e4) {
303
385
  losses.delete(key2);
304
- pending.delete(createHash("sha256").update(`${source}:${previous.event.sequence}`).digest("hex"));
386
+ removePending(createHash("sha256").update(`${source}:${previous.event.sequence}`).digest("hex"));
305
387
  }
306
388
  }
307
389
  if (capacityLoss && capacityLoss.timestamp < Date.now() - 5 * 6e4)
@@ -317,12 +399,21 @@ function createFeedbackCapture(appId, envAllowlist, service) {
317
399
  }
318
400
  const entry = { event, scopeId: item.scopeId, userId: item.userId, bytes: Buffer.byteLength(JSON.stringify(event)) };
319
401
  losses.set(key, entry);
320
- pending.set(createHash("sha256").update(`${source}:${event.sequence}`).digest("hex"), entry);
402
+ if (pendingBytes + entry.bytes <= 8 * 1024 * 1024) {
403
+ pending.set(createHash("sha256").update(`${source}:${event.sequence}`).digest("hex"), entry);
404
+ pendingBytes += entry.bytes;
405
+ } else {
406
+ capacityLoss ??= { ...event, requestId: void 0, missingReason: "server_capture_capacity_exceeded" };
407
+ }
321
408
  }
322
409
  function pin(item, errorId) {
323
410
  if (item.event.kind === "error")
324
411
  item.event.errorId = item.event.id;
325
412
  else item.event.errorId ??= errorId;
413
+ const bytes = Buffer.byteLength(JSON.stringify(item.event));
414
+ if (item.buffered)
415
+ totalBytes += bytes - item.bytes;
416
+ item.bytes = bytes;
326
417
  const key = createHash("sha256").update(`${source}:${item.event.sequence}`).digest("hex");
327
418
  if (pending.has(key))
328
419
  return;
@@ -330,8 +421,13 @@ function createFeedbackCapture(appId, envAllowlist, service) {
330
421
  rememberLoss(item, "server_pending_evidence_overflow");
331
422
  return;
332
423
  }
424
+ if (pendingBytes + item.bytes > 8 * 1024 * 1024 - 128 * 1024) {
425
+ rememberLoss(item, "server_pending_evidence_bytes_exceeded");
426
+ return;
427
+ }
333
428
  const event = { ...item.event };
334
429
  pending.set(key, { ...item, event });
430
+ pendingBytes += item.bytes;
335
431
  }
336
432
  function observe(record) {
337
433
  if (stopped || record.context.module === "feedback")
@@ -369,9 +465,12 @@ function createFeedbackCapture(appId, envAllowlist, service) {
369
465
  else rememberLoss(item, "server_error_window_overflow");
370
466
  }
371
467
  buffer.push(item);
372
- totalBytes += bytes;
468
+ item.buffered = true;
469
+ totalBytes += item.bytes;
373
470
  while (buffer.length > 1e4 || totalBytes > 16 * 1024 * 1024 || (buffer[0]?.event.timestamp ?? Date.now()) < Date.now() - 5 * 6e4) {
374
471
  const removed = buffer.shift();
472
+ if (removed)
473
+ removed.buffered = false;
375
474
  totalBytes -= removed?.bytes ?? 0;
376
475
  if (removed && removed.event.timestamp >= Date.now() - 5 * 6e4)
377
476
  rememberLoss(removed, "server_log_buffer_overflow");
@@ -384,7 +483,7 @@ function createFeedbackCapture(appId, envAllowlist, service) {
384
483
  timer.unref();
385
484
  return {
386
485
  async collect(scopeId, userId, anchor) {
387
- await flush();
486
+ await flush(true);
388
487
  const params = [];
389
488
  const identity = `${feedbackTextEquals("app_id", appId, params)} AND ${feedbackTextEquals("scope_id", scopeId, params)} AND ${feedbackTextEquals("user_id", userId, params)}`;
390
489
  params.push(anchor - 5 * 6e4, anchor + 3e4);
@@ -446,7 +545,7 @@ function createFeedbackCapture(appId, envAllowlist, service) {
446
545
  stopped = true;
447
546
  stop();
448
547
  clearInterval(timer);
449
- await flush();
548
+ await flush(true);
450
549
  }
451
550
  };
452
551
  }
@@ -489,13 +588,77 @@ function validActor(actor) {
489
588
  function escapeHtml(value) {
490
589
  return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character] ?? character);
491
590
  }
492
- function createFeedbackOperations(repo, config, capture) {
591
+ function createFeedbackOperations(repo, config, capture, options = {}) {
493
592
  const budget = resolveFeedbackBudget(config);
494
593
  const table = repo.tables;
495
594
  const clock = feedbackClockSql();
496
595
  let stopped = false;
497
596
  let timer;
498
597
  let running = null;
598
+ let idleRounds = 0;
599
+ let cleanupBacklog = false;
600
+ let cleanupAfter = 0;
601
+ let wakePending = false;
602
+ const shutdown = new AbortController();
603
+ function schedule(delay) {
604
+ if (stopped || !config.workerIntervalMs)
605
+ return;
606
+ if (timer)
607
+ clearTimeout(timer);
608
+ timer = setTimeout(() => {
609
+ timer = void 0;
610
+ void tick();
611
+ }, delay);
612
+ timer.unref();
613
+ }
614
+ function wakeWorker() {
615
+ idleRounds = 0;
616
+ if (running)
617
+ wakePending = true;
618
+ else
619
+ schedule(config.workerIntervalMs);
620
+ }
621
+ async function tick() {
622
+ if (stopped)
623
+ return;
624
+ const result = await runJobs();
625
+ idleRounds = result.success && (result.data.processed > 0 || cleanupBacklog || wakePending) ? 0 : Math.min(idleRounds + 1, 10);
626
+ wakePending = false;
627
+ schedule(Math.min(3e4, config.workerIntervalMs * 2 ** idleRounds));
628
+ }
629
+ const collectEvidence = options.collectEvidence;
630
+ function quotaExceeded() {
631
+ return err(HaiFeedbackError.QUOTA_EXCEEDED, feedbackM("feedback_quotaExceeded"));
632
+ }
633
+ function scopeQuotaId(scopeId) {
634
+ return hash(JSON.stringify([config.appId, scopeId, "storage"]));
635
+ }
636
+ async function reserveQuota(tx, actor, reportId) {
637
+ const time = await tx.get(`SELECT ${clock} AS now`);
638
+ if (!time.success)
639
+ return time;
640
+ const now = Number(time.data?.now);
641
+ if (!Number.isFinite(now))
642
+ return failed();
643
+ const day = Math.floor(now / 864e5) * 864e5;
644
+ const bytes = budget.maxTotalBytes * (config.maxExportsPerReport + 1);
645
+ const buckets = [
646
+ { id: scopeQuotaId(actor.scopeId), period: 0, maxReports: Number.MAX_SAFE_INTEGER, maxBytes: config.maxStoredBytesPerScope },
647
+ { id: hash(JSON.stringify([config.appId, actor.scopeId, actor.userId, day])), period: day, maxReports: config.maxReportsPerUserDay, maxBytes: config.maxBytesPerUserDay }
648
+ ];
649
+ for (const bucket of buckets) {
650
+ const suffix = reldb.config?.type === "mysql" ? "ON DUPLICATE KEY UPDATE id = id" : "ON CONFLICT (id) DO NOTHING";
651
+ const inserted = await tx.execute(`INSERT INTO ${table.quotas} (id, app_id, scope_id, user_id, period, report_count, reserved_bytes, updated_at) VALUES (?, ?, ?, ?, ?, 0, 0, ?) ${suffix}`, [bucket.id, config.appId, actor.scopeId, bucket.period === 0 ? "" : actor.userId, bucket.period, now]);
652
+ if (!inserted.success)
653
+ return inserted;
654
+ const reserved = await tx.execute(`UPDATE ${table.quotas} SET report_count = report_count + 1, reserved_bytes = reserved_bytes + ?, updated_at = ? WHERE id = ? AND report_count < ? AND reserved_bytes <= ?`, [bytes, now, bucket.id, bucket.maxReports, bucket.maxBytes - bytes]);
655
+ if (!reserved.success)
656
+ return reserved;
657
+ if (!reserved.data.changes)
658
+ return quotaExceeded();
659
+ }
660
+ return tx.execute(`INSERT INTO ${table.quotas} (id, app_id, scope_id, user_id, period, report_count, reserved_bytes, report_bytes, export_limit, updated_at) VALUES (?, ?, ?, ?, -1, 1, ?, ?, ?, ?)`, [reportId, config.appId, actor.scopeId, actor.userId, bytes, budget.maxTotalBytes, config.maxExportsPerReport, now]);
661
+ }
499
662
  async function access(id, actor, permission, sql = reldb.sql) {
500
663
  if (!validActor(actor))
501
664
  return forbidden();
@@ -619,28 +782,25 @@ function createFeedbackOperations(repo, config, capture) {
619
782
  const result = await sql.query(`SELECT * FROM ${table.evidence} WHERE report_id = ? AND revision <= ? ORDER BY timestamp, source, sequence`, [id, revision]);
620
783
  return result.success ? ok(result.data.map((row) => FeedbackEvidenceSchema.parse({ ...FeedbackEvidenceInputSchema.parse(readFeedbackJson(row.payload)), feedbackId: id, revision: Number(row.revision) }))) : result;
621
784
  }
785
+ async function runJobs() {
786
+ if (stopped)
787
+ return err(HaiFeedbackError.NOT_INITIALIZED, feedbackM("feedback_notInitialized"));
788
+ if (running)
789
+ return running;
790
+ running = core.context.run({ suppressLogObservers: true }, processJobs).catch(failed).finally(() => {
791
+ running = null;
792
+ });
793
+ return running;
794
+ }
622
795
  const operations = {
623
796
  start() {
624
797
  if (!config.workerIntervalMs || stopped)
625
798
  return;
626
- const tick = async () => {
627
- if (stopped)
628
- return;
629
- await operations.runJobs();
630
- if (!stopped) {
631
- timer = setTimeout(() => {
632
- void tick();
633
- }, config.workerIntervalMs);
634
- timer.unref();
635
- }
636
- };
637
- timer = setTimeout(() => {
638
- void tick();
639
- }, config.workerIntervalMs);
640
- timer.unref();
799
+ schedule(config.workerIntervalMs);
641
800
  },
642
801
  async close() {
643
802
  stopped = true;
803
+ shutdown.abort();
644
804
  if (timer)
645
805
  clearTimeout(timer);
646
806
  await running;
@@ -654,6 +814,8 @@ function createFeedbackOperations(repo, config, capture) {
654
814
  if (!parsed.success || parsed.data.appId !== config.appId)
655
815
  return invalid();
656
816
  const sanitized = FeedbackSubmitInputSchema.parse(sanitizeFeedbackValue(parsed.data));
817
+ if (sanitized.evidence?.some((event) => event.source.startsWith("server:")))
818
+ return invalid();
657
819
  const submissionKey = hash(JSON.stringify([actor.scopeId, actor.userId, config.appId, sanitized.submissionId]));
658
820
  const digest = hash(canonicalFeedbackJson(sanitized));
659
821
  const existing = await reldb.sql.get(`SELECT * FROM ${table.reports} WHERE submission_key = ?`, [submissionKey]);
@@ -694,13 +856,16 @@ function createFeedbackOperations(repo, config, capture) {
694
856
  const inserted = await repo.create({ ...row }, tx);
695
857
  if (!inserted.success)
696
858
  return inserted;
697
- const initial = await appendInside(tx, row, sanitized.evidence ?? []);
859
+ const reservation = await reserveQuota(tx, actor, id);
860
+ if (!reservation.success)
861
+ return reservation;
862
+ const initial = await appendInside(tx, row, [...sanitized.evidence ?? [], capture.environment()], true);
698
863
  if (!initial.success)
699
864
  return initial;
700
865
  if ((sanitized.attachments ?? []).reduce((total, file) => total + file.size, 0) > budget.maxAttachmentsBytes)
701
866
  return limited();
702
867
  for (const attachment of sanitized.attachments ?? []) {
703
- if (attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
868
+ if (attachment.id.startsWith("server:") || attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
704
869
  return invalid();
705
870
  const attachmentId = hash(`${id}:${attachment.id}`);
706
871
  const data = { ...attachment, feedbackId: id, status: "pending" };
@@ -713,7 +878,12 @@ function createFeedbackOperations(repo, config, capture) {
713
878
  if (!saved.success)
714
879
  return saved;
715
880
  }
716
- const collected = await insertJob(tx, report, "collect", "initial", { anchor: sanitized.anchor?.timestamp ?? now }, row.collect_deadline);
881
+ if (config.webhookUrl) {
882
+ const webhook = await insertJob(tx, report, "webhook", "created", { url: config.webhookUrl }, row.collect_deadline);
883
+ if (!webhook.success)
884
+ return webhook;
885
+ }
886
+ const collected = await insertJob(tx, report, "collect", "initial", { anchor: sanitized.anchor?.timestamp ?? now, businessEvidence: !!collectEvidence }, row.collect_deadline);
717
887
  if (!collected.success)
718
888
  return collected;
719
889
  const cleanup = await insertJob(tx, report, "cleanup", "expiry", {}, row.expires_at);
@@ -731,9 +901,7 @@ function createFeedbackOperations(repo, config, capture) {
731
901
  return missing();
732
902
  return duplicate.data.submission_hash === digest ? ok(repo.report(duplicate.data)) : conflict();
733
903
  }
734
- const environment = await appendInternal(id, actor, [capture.environment()]);
735
- if (!environment.success)
736
- core.logger.warn("Feedback environment collection deferred", { module: "feedback", feedbackId: id });
904
+ wakeWorker();
737
905
  const current2 = await repo.find(id, actor.scopeId);
738
906
  return current2.success && current2.data ? ok(repo.report(current2.data)) : result;
739
907
  },
@@ -900,7 +1068,7 @@ function createFeedbackOperations(repo, config, capture) {
900
1068
  if (!saved.success)
901
1069
  return saved;
902
1070
  }
903
- const next = await reldb.sql.execute(`UPDATE ${table.jobs} SET next_run_at = ${clock}, updated_at = ${clock} WHERE report_id = ? AND kind IN ('collect', 'notify') AND status = 'pending'`, [input.id]);
1071
+ const next = await reldb.sql.execute(`UPDATE ${table.jobs} SET next_run_at = ${clock}, updated_at = ${clock} WHERE report_id = ? AND kind IN ('collect', 'notify', 'webhook') AND status = 'pending'`, [input.id]);
904
1072
  return next.success ? ok(repo.report(row.data)) : next;
905
1073
  },
906
1074
  async uploadAttachment(input, actor) {
@@ -926,6 +1094,9 @@ function createFeedbackOperations(repo, config, capture) {
926
1094
  return ok({ name: metadata.name, contentType: metadata.contentType, dataBase64: bytes.data.toString("base64") });
927
1095
  },
928
1096
  async retry(input, actor) {
1097
+ const parsed = FeedbackRetryInputSchema.safeParse(input);
1098
+ if (!parsed.success)
1099
+ return invalid();
929
1100
  const permission = input.target === "export" ? "feedback:export" : "feedback:process";
930
1101
  const row = await access(input.id, actor, permission);
931
1102
  if (!row.success)
@@ -942,13 +1113,15 @@ function createFeedbackOperations(repo, config, capture) {
942
1113
  if (!resumed.success)
943
1114
  return resumed;
944
1115
  }
1116
+ if (updated.success)
1117
+ wakeWorker();
945
1118
  return updated.success ? ok(void 0) : updated;
946
1119
  },
947
1120
  async export(input, actor) {
948
1121
  const parsed = FeedbackExportInputSchema.safeParse(input);
949
1122
  if (!parsed.success)
950
1123
  return invalid();
951
- return repo.transaction(async (tx) => {
1124
+ const result = await repo.transaction(async (tx) => {
952
1125
  const row = await access(input.id, actor, "feedback:export", tx);
953
1126
  if (!row.success)
954
1127
  return row;
@@ -962,12 +1135,20 @@ function createFeedbackOperations(repo, config, capture) {
962
1135
  if (previous.data) {
963
1136
  const snapshot = readFeedbackJson(previous.data.payload);
964
1137
  const live = snapshot && typeof snapshot === "object" && "snapshotExpiresAt" in snapshot && typeof snapshot.snapshotExpiresAt === "number" && snapshot.snapshotExpiresAt > Date.now();
965
- if (previous.data.status === "succeeded" || live)
1138
+ if (previous.data.status === "succeeded" && Number(previous.data.created_at) + config.exportRetentionHours * 36e5 > Date.now() || previous.data.status !== "succeeded" && live)
966
1139
  return ok(jobData(previous.data));
967
1140
  }
968
1141
  if (revision < Number(row.data.revision)) {
969
1142
  return missing();
970
1143
  }
1144
+ const exports = await tx.get(`SELECT COUNT(*) AS count FROM ${table.jobs} WHERE report_id = ? AND kind = 'export'`, [input.id]);
1145
+ if (!exports.success)
1146
+ return exports;
1147
+ const reservation = await tx.get(`SELECT report_bytes, export_limit FROM ${table.quotas} WHERE id = ?`, [input.id]);
1148
+ if (!reservation.success)
1149
+ return reservation;
1150
+ if (!reservation.data || Number(exports.data?.count ?? 0) >= Math.min(config.maxExportsPerReport, Number(reservation.data.export_limit)))
1151
+ return quotaExceeded();
971
1152
  const events = await evidence(input.id, revision, tx);
972
1153
  if (!events.success)
973
1154
  return events;
@@ -978,12 +1159,15 @@ function createFeedbackOperations(repo, config, capture) {
978
1159
  if (!changes.success)
979
1160
  return changes;
980
1161
  const suffix = `${revision}:${row.data.version}${previous.data ? `:${previous.data.id}` : ""}`;
981
- const added = await insertJob(tx, repo.report(row.data), "export", suffix, { report: repo.report(row.data), evidence: events.data, attachments: files.data, history: changes.data, snapshotExpiresAt: Date.now() + 36e5 }, Date.now());
1162
+ const added = await insertJob(tx, repo.report(row.data), "export", suffix, { report: repo.report(row.data), evidence: events.data, attachments: files.data, history: changes.data, maxBytes: Number(reservation.data.report_bytes), snapshotExpiresAt: Date.now() + 36e5 }, Date.now());
982
1163
  if (!added.success)
983
1164
  return added;
984
1165
  const job = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [added.data]);
985
1166
  return job.success && job.data ? ok(jobData(job.data)) : missing();
986
1167
  });
1168
+ if (result.success)
1169
+ wakeWorker();
1170
+ return result;
987
1171
  },
988
1172
  async getExport(input, actor) {
989
1173
  const row = await access(input.id, actor, "feedback:export");
@@ -1004,6 +1188,8 @@ function createFeedbackOperations(repo, config, capture) {
1004
1188
  return missing();
1005
1189
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_exportPending"));
1006
1190
  }
1191
+ if (Number(job.data.created_at) + config.exportRetentionHours * 36e5 <= Date.now())
1192
+ return missing();
1007
1193
  if (job.data.status === "failed")
1008
1194
  return failed();
1009
1195
  const data = readFeedbackJson(job.data.result);
@@ -1016,16 +1202,7 @@ function createFeedbackOperations(repo, config, capture) {
1016
1202
  return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
1017
1203
  return ok({ name: `${repo.report(row.data).number}.zip`, contentType: "application/zip", dataBase64: bytes.data.toString("base64") });
1018
1204
  },
1019
- async runJobs() {
1020
- if (stopped)
1021
- return err(HaiFeedbackError.NOT_INITIALIZED, feedbackM("feedback_notInitialized"));
1022
- if (running)
1023
- return running;
1024
- running = core.context.run({ suppressLogObservers: true }, processJobs).catch(failed).finally(() => {
1025
- running = null;
1026
- });
1027
- return running;
1028
- }
1205
+ runJobs
1029
1206
  };
1030
1207
  async function upload(input, actor) {
1031
1208
  const parsed = FeedbackUploadInputSchema.safeParse(input);
@@ -1084,6 +1261,124 @@ function createFeedbackOperations(repo, config, capture) {
1084
1261
  }
1085
1262
  return ok({ ...metadata, status: "available" });
1086
1263
  }
1264
+ async function collectBusiness(job, report, actor) {
1265
+ const marker = hash(`${report.id}:server:business:0`);
1266
+ const previous = await reldb.sql.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [marker]);
1267
+ if (!previous.success)
1268
+ return previous;
1269
+ if (previous.data)
1270
+ return ok(void 0);
1271
+ if (!collectEvidence)
1272
+ return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1273
+ const controller = new AbortController();
1274
+ const signal = AbortSignal.any([controller.signal, shutdown.signal]);
1275
+ const timeout = setTimeout(() => controller.abort(), config.businessCollectionTimeoutMs);
1276
+ timeout.unref();
1277
+ let collected;
1278
+ let onAbort;
1279
+ try {
1280
+ collected = await Promise.race([
1281
+ Promise.resolve().then(() => collectEvidence({ report: Object.freeze({ ...report }), signal })),
1282
+ new Promise((_resolve, reject) => {
1283
+ onAbort = () => reject(new Error("Feedback business collection aborted"));
1284
+ signal.addEventListener("abort", onAbort, { once: true });
1285
+ if (signal.aborted)
1286
+ onAbort();
1287
+ })
1288
+ ]);
1289
+ } catch {
1290
+ return failed();
1291
+ } finally {
1292
+ clearTimeout(timeout);
1293
+ if (onAbort)
1294
+ signal.removeEventListener("abort", onAbort);
1295
+ }
1296
+ if (!collected || typeof collected !== "object" || collected.text !== void 0 && typeof collected.text !== "string" || collected.attachments !== void 0 && !Array.isArray(collected.attachments))
1297
+ return invalid();
1298
+ if ((collected.attachments?.length ?? 0) > budget.maxAttachmentCount)
1299
+ return limited();
1300
+ const files = [];
1301
+ for (const input of collected.attachments ?? []) {
1302
+ if (!input || !(input.bytes instanceof Uint8Array) || typeof input.id !== "string" || input.id.length > 100)
1303
+ return invalid();
1304
+ if (input.bytes.byteLength > budget.maxAttachmentBytes)
1305
+ return limited();
1306
+ let bytes = Buffer.from(input.bytes);
1307
+ if (!matchesContentType(bytes, input.contentType))
1308
+ return invalid();
1309
+ if (input.contentType === "text/plain")
1310
+ bytes = Buffer.from(redactFeedbackText(bytes.toString("utf8")));
1311
+ if (input.contentType === "application/json")
1312
+ bytes = Buffer.from(JSON.stringify(sanitizeFeedbackValue(JSON.parse(bytes.toString("utf8")))));
1313
+ if (bytes.length > budget.maxAttachmentBytes)
1314
+ return limited();
1315
+ const parsed = FeedbackAttachmentInputSchema.safeParse({ id: `server:business:${input.id}`, name: typeof input.name === "string" ? redactFeedbackText(input.name) : input.name, contentType: input.contentType, size: bytes.length, checksum: hash(bytes) });
1316
+ if (!parsed.success || !input.id || files.some((file) => file.metadata.id === parsed.data.id))
1317
+ return invalid();
1318
+ const rowId = hash(`${report.id}:${parsed.data.id}`);
1319
+ const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/business/${rowId}-${parsed.data.checksum}`;
1320
+ files.push({ metadata: { ...parsed.data, feedbackId: report.id, status: "available" }, bytes, key, rowId });
1321
+ }
1322
+ const event = FeedbackEvidenceInputSchema.safeParse(sanitizeFeedbackValue({ id: `business-${report.id}`, source: "server:business", sequence: 0, kind: "business", timestamp: report.createdAt, data: { ...collected.data, ...collected.text !== void 0 ? { text: collected.text } : {}, attachmentIds: files.map((file) => file.metadata.id) } }));
1323
+ if (!event.success)
1324
+ return invalid();
1325
+ async function checkCapacity(sql) {
1326
+ const existing = await attachments(report.id, sql);
1327
+ if (!existing.success)
1328
+ return existing;
1329
+ if (existing.data.length + files.length > budget.maxAttachmentCount || existing.data.reduce((total, item) => total + Number(item.size), 0) + files.reduce((total, file) => total + file.bytes.length, 0) > budget.maxAttachmentsBytes)
1330
+ return limited();
1331
+ return ok(void 0);
1332
+ }
1333
+ const frozenFiles = files.map((file) => ({ id: file.rowId, size: file.bytes.length, checksum: file.metadata.checksum })).sort((left, right) => left.id.localeCompare(right.id));
1334
+ const frozen = await withLease(job, async (tx) => {
1335
+ const current2 = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [job.id]);
1336
+ if (!current2.success || !current2.data)
1337
+ return missing();
1338
+ const payload = readFeedbackJson(current2.data.payload);
1339
+ if (!payload || typeof payload !== "object")
1340
+ return invalid();
1341
+ if ("businessFiles" in payload)
1342
+ return canonicalFeedbackJson(payload.businessFiles) === canonicalFeedbackJson(frozenFiles) ? ok(void 0) : conflict();
1343
+ const updated = await tx.execute(`UPDATE ${table.jobs} SET payload = ? WHERE id = ? AND token = ?`, [JSON.stringify({ ...payload, businessFiles: frozenFiles }), job.id, job.token]);
1344
+ return updated.success ? ok(void 0) : updated;
1345
+ });
1346
+ if (!frozen.success)
1347
+ return frozen;
1348
+ const capacity = await checkCapacity(reldb.sql);
1349
+ if (!capacity.success)
1350
+ return capacity;
1351
+ for (const file of files) {
1352
+ const stored = await storage.file.put(file.key, file.bytes, { contentType: file.metadata.contentType, ifNoneMatch: "*", signal: AbortSignal.any([shutdown.signal, AbortSignal.timeout(3e4)]) });
1353
+ if (!stored.success) {
1354
+ const existing = await storage.file.get(file.key);
1355
+ if (!existing.success || hash(existing.data) !== file.metadata.checksum)
1356
+ return stored;
1357
+ }
1358
+ }
1359
+ return withLease(job, async (tx) => {
1360
+ const fresh = await access(report.id, actor, void 0, tx);
1361
+ if (!fresh.success)
1362
+ return fresh;
1363
+ const completed = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [marker]);
1364
+ if (!completed.success)
1365
+ return completed;
1366
+ if (completed.data)
1367
+ return ok(void 0);
1368
+ const capacity2 = await checkCapacity(tx);
1369
+ if (!capacity2.success)
1370
+ return capacity2;
1371
+ const appended = await appendInside(tx, fresh.data, [event.data], true);
1372
+ if (!appended.success)
1373
+ return appended;
1374
+ for (const file of files) {
1375
+ const saved = await tx.execute(`INSERT INTO ${table.attachments} (id, client_id, report_id, scope_id, object_key, status, size, checksum, updated_at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [file.rowId, file.metadata.id, report.id, actor.scopeId, file.key, "available", file.bytes.length, file.metadata.checksum, Date.now(), JSON.stringify(file.metadata)]);
1376
+ if (!saved.success)
1377
+ return saved;
1378
+ }
1379
+ return ok(void 0);
1380
+ });
1381
+ }
1087
1382
  async function processJobs() {
1088
1383
  let processed = 0;
1089
1384
  const candidateParams = [];
@@ -1123,8 +1418,6 @@ function createFeedbackOperations(repo, config, capture) {
1123
1418
  if (!terminal.success)
1124
1419
  return terminal;
1125
1420
  }
1126
- if (!saved.data.changes && job.kind === "export" && result.success && result.data && typeof result.data === "object" && "key" in result.data && typeof result.data.key === "string")
1127
- await storage.file.delete(result.data.key);
1128
1421
  if (job.kind === "notify")
1129
1422
  await notificationSummary(job.report_id);
1130
1423
  processed++;
@@ -1136,8 +1429,47 @@ function createFeedbackOperations(repo, config, capture) {
1136
1429
  clearInterval(renewal);
1137
1430
  }
1138
1431
  }
1139
- const expired = await reldb.sql.execute(`DELETE FROM ${table.evidence} WHERE report_id IS NULL AND expires_at < ?`, [Date.now()]);
1140
- return expired.success ? ok({ processed }) : expired;
1432
+ if (Date.now() >= cleanupAfter) {
1433
+ const expiredExports = await reldb.sql.query(`SELECT * FROM ${table.jobs} WHERE app_id = ? AND kind = 'export' AND status = 'succeeded' AND created_at <= ${clock} - ? ORDER BY created_at, id LIMIT 20`, [config.appId, config.exportRetentionHours * 36e5]);
1434
+ if (!expiredExports.success)
1435
+ return expiredExports;
1436
+ for (const job of expiredExports.data) {
1437
+ if (job.app_id !== config.appId)
1438
+ continue;
1439
+ const result = readFeedbackJson(job.result);
1440
+ if (result && typeof result === "object" && "key" in result && typeof result.key === "string") {
1441
+ const deleted = await storage.file.delete(result.key);
1442
+ if (!deleted.success)
1443
+ return deleted;
1444
+ }
1445
+ const marked = await reldb.sql.execute(`UPDATE ${table.jobs} SET status = 'failed', result = NULL, payload = ?, last_error = ? WHERE id = ? AND status = 'succeeded'`, [JSON.stringify({ expired: true }), HaiFeedbackError.NOT_FOUND.code, job.id]);
1446
+ if (!marked.success)
1447
+ return marked;
1448
+ }
1449
+ const quotaRows = await reldb.sql.query(`SELECT id FROM ${table.quotas} WHERE app_id = ? AND period > 0 AND period < ${clock} - 172800000 ORDER BY period, id LIMIT 100`, [config.appId]);
1450
+ if (!quotaRows.success)
1451
+ return quotaRows;
1452
+ if (quotaRows.data.length) {
1453
+ const removed = await reldb.sql.execute(`DELETE FROM ${table.quotas} WHERE id IN (${quotaRows.data.map(() => "?").join(",")})`, quotaRows.data.map((row) => row.id));
1454
+ if (!removed.success)
1455
+ return removed;
1456
+ }
1457
+ }
1458
+ if (Date.now() >= cleanupAfter) {
1459
+ const params = [];
1460
+ const identity = feedbackTextEquals("app_id", config.appId, params);
1461
+ const expired = await reldb.sql.query(`SELECT id FROM ${table.evidence} WHERE ${identity} AND report_id IS NULL AND expires_at < ? ORDER BY expires_at, id LIMIT 100`, [...params, Date.now()]);
1462
+ if (!expired.success)
1463
+ return expired;
1464
+ if (expired.data.length) {
1465
+ const removed = await reldb.sql.execute(`DELETE FROM ${table.evidence} WHERE ${identity} AND report_id IS NULL AND id IN (${expired.data.map(() => "?").join(",")})`, [...params, ...expired.data.map((row) => row.id)]);
1466
+ if (!removed.success)
1467
+ return removed;
1468
+ }
1469
+ cleanupBacklog = expired.data.length === 100;
1470
+ cleanupAfter = cleanupBacklog ? 0 : Date.now() + 3e4;
1471
+ }
1472
+ return ok({ processed });
1141
1473
  }
1142
1474
  async function executeJob(job) {
1143
1475
  if (job.app_id !== config.appId)
@@ -1174,6 +1506,11 @@ function createFeedbackOperations(repo, config, capture) {
1174
1506
  });
1175
1507
  if (!saved.success)
1176
1508
  return saved;
1509
+ if ("businessEvidence" in payload && payload.businessEvidence === true) {
1510
+ const business = await collectBusiness(job, report, actor);
1511
+ if (!business.success)
1512
+ return business;
1513
+ }
1177
1514
  const files = await attachments(report.id);
1178
1515
  if (!files.success)
1179
1516
  return files;
@@ -1184,6 +1521,50 @@ function createFeedbackOperations(repo, config, capture) {
1184
1521
  const updated = await withLease(job, (tx) => tx.execute(`UPDATE ${table.reports} SET collection_status = ?, updated_at = ${clock} WHERE id = ?`, [partial ? "partial" : "complete", report.id]));
1185
1522
  return updated.success ? ok({ collected: captured.length }) : updated;
1186
1523
  }
1524
+ if (job.kind === "webhook") {
1525
+ if (!("url" in payload) || payload.url !== config.webhookUrl || !config.webhookUrl)
1526
+ return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1527
+ if (report.collectionStatus === "collecting")
1528
+ return ok({ pending: true });
1529
+ const frozen = await withLease(job, async (tx) => {
1530
+ const live = await access(report.id, actor, void 0, tx);
1531
+ if (!live.success)
1532
+ return live;
1533
+ if (live.data.collection_status === "collecting")
1534
+ return conflict();
1535
+ const stored = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [job.id]);
1536
+ if (!stored.success || !stored.data)
1537
+ return missing();
1538
+ const snapshot = readFeedbackJson(stored.data.payload);
1539
+ if (snapshot && typeof snapshot === "object" && "body" in snapshot && typeof snapshot.body === "string")
1540
+ return ok(snapshot.body);
1541
+ const files = await attachments(report.id, tx);
1542
+ if (!files.success)
1543
+ return files;
1544
+ const business = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [hash(`${report.id}:server:business:0`)]);
1545
+ if (!business.success)
1546
+ return business;
1547
+ const detailUrl = config.adminUrl ? new URL(config.adminUrl) : void 0;
1548
+ detailUrl?.searchParams.set("feedback", report.id);
1549
+ const event = { event: "feedback.created", eventId: job.id, createdAt: report.createdAt, report: repo.report(live.data), evidence: { revision: Number(live.data.revision), business: !!business.data, attachments: files.data.map(attachmentData) }, ...detailUrl ? { detailUrl: detailUrl.href } : {} };
1550
+ const body = JSON.stringify(event);
1551
+ const saved = await tx.execute(`UPDATE ${table.jobs} SET payload = ? WHERE id = ? AND token = ?`, [JSON.stringify({ url: config.webhookUrl, body }), job.id, job.token]);
1552
+ return saved.success ? ok(body) : saved;
1553
+ });
1554
+ if (!frozen.success)
1555
+ return frozen;
1556
+ const timestamp = String(Math.floor(Date.now() / 1e3));
1557
+ const headers = { "content-type": "application/json", "idempotency-key": job.id, "x-hai-feedback-event": "feedback.created", "x-hai-feedback-timestamp": timestamp };
1558
+ if (config.webhookSecret)
1559
+ headers["x-hai-feedback-signature"] = `sha256=${createHmac("sha256", config.webhookSecret).update(`${timestamp}.${frozen.data}`).digest("hex")}`;
1560
+ try {
1561
+ const response = await fetch(config.webhookUrl, { method: "POST", headers, body: frozen.data, redirect: "manual", signal: AbortSignal.any([shutdown.signal, AbortSignal.timeout(config.webhookTimeoutMs)]) });
1562
+ await response.body?.cancel();
1563
+ return response.ok ? ok({ status: response.status }) : failed();
1564
+ } catch {
1565
+ return failed();
1566
+ }
1567
+ }
1187
1568
  if (job.kind === "notify") {
1188
1569
  if (config.notificationMode === "disabled")
1189
1570
  return ok({ disabled: true });
@@ -1195,7 +1576,7 @@ function createFeedbackOperations(repo, config, capture) {
1195
1576
  return live;
1196
1577
  const current2 = repo.report(live.data);
1197
1578
  if (config.notificationMode === "console") {
1198
- core.logger.info("Feedback notification", { module: "feedback", number: current2.number, appId: current2.appId, summary: current2.summary, collectionStatus: current2.collectionStatus });
1579
+ core.logger.info("Feedback notification", { module: "feedback", number: current2.number, appId: current2.appId, appVersion: current2.appVersion, buildId: current2.buildId, commit: current2.commit, collectionStatus: current2.collectionStatus, ...config.consoleContent === "summary" ? { summary: current2.summary } : {} });
1199
1580
  return ok({ channel: "console" });
1200
1581
  }
1201
1582
  const { HaiReachError, reach } = await import('@h-ai/reach');
@@ -1226,6 +1607,8 @@ function createFeedbackOperations(repo, config, capture) {
1226
1607
  if (!("report" in payload) || !("evidence" in payload) || !("attachments" in payload))
1227
1608
  return invalid();
1228
1609
  const fixed = payload;
1610
+ if (!Number.isSafeInteger(fixed.maxBytes) || fixed.maxBytes < 4 * 1024 * 1024 || fixed.maxBytes > 40 * 1024 * 1024)
1611
+ return invalid();
1229
1612
  if (fixed.snapshotExpiresAt < Date.now())
1230
1613
  return missing();
1231
1614
  const files = [];
@@ -1245,12 +1628,17 @@ function createFeedbackOperations(repo, config, capture) {
1245
1628
  const info = attachmentData(file);
1246
1629
  files.push({ id: info.id, name: info.name, contentType: info.contentType, checksum: info.checksum, bytes: stored2.data });
1247
1630
  }
1248
- const bundle = await createFeedbackBundle({ maxBytes: budget.maxTotalBytes, report: fixed.report, evidence: fixed.evidence, history: fixed.history, attachments: files, missing: unavailable2 });
1631
+ const bundle = await createFeedbackBundle({ maxBytes: fixed.maxBytes, createdAt: Number(job.created_at), report: fixed.report, evidence: fixed.evidence, history: fixed.history, attachments: files, missing: unavailable2 });
1249
1632
  if (!bundle.success)
1250
1633
  return bundle;
1251
- const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/exports/${job.id}-${job.token}.zip`;
1634
+ const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/exports/${job.id}.zip`;
1252
1635
  const stored = await storage.file.put(key, bundle.data, { contentType: "application/zip", ifNoneMatch: "*" });
1253
- return stored.success ? ok({ key, checksum: hash(bundle.data), size: bundle.data.length }) : stored;
1636
+ if (!stored.success) {
1637
+ const existing = await storage.file.get(key);
1638
+ if (!existing.success || hash(existing.data) !== hash(bundle.data))
1639
+ return stored;
1640
+ }
1641
+ return ok({ key, checksum: hash(bundle.data), size: bundle.data.length });
1254
1642
  }
1255
1643
  const marked = await withLease(job, async (tx) => {
1256
1644
  const locked = await repo.find(report.id, report.scopeId, tx);
@@ -1279,6 +1667,17 @@ function createFeedbackOperations(repo, config, capture) {
1279
1667
  if (!removed2.success)
1280
1668
  return removed2;
1281
1669
  }
1670
+ const reservation = await tx.get(`SELECT reserved_bytes FROM ${table.quotas} WHERE id = ?`, [report.id]);
1671
+ if (!reservation.success)
1672
+ return reservation;
1673
+ if (reservation.data) {
1674
+ const released = await tx.execute(`UPDATE ${table.quotas} SET report_count = report_count - 1, reserved_bytes = reserved_bytes - ?, updated_at = ${clock} WHERE id = ? AND reserved_bytes >= ? AND report_count > 0`, [Number(reservation.data.reserved_bytes), scopeQuotaId(report.scopeId), Number(reservation.data.reserved_bytes)]);
1675
+ if (!released.success || !released.data.changes)
1676
+ return released.success ? conflict() : released;
1677
+ const removed2 = await tx.execute(`DELETE FROM ${table.quotas} WHERE id = ?`, [report.id]);
1678
+ if (!removed2.success)
1679
+ return removed2;
1680
+ }
1282
1681
  const removedJobs = await tx.execute(`DELETE FROM ${table.jobs} WHERE report_id = ? AND id <> ?`, [report.id, job.id]);
1283
1682
  if (!removedJobs.success)
1284
1683
  return removedJobs;
@@ -1295,9 +1694,13 @@ function createFeedbackOperations(repo, config, capture) {
1295
1694
  return operations;
1296
1695
  }
1297
1696
  function allowedContentType(type) {
1298
- return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf"].includes(type);
1697
+ return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf", "application/zip", "application/gzip"].includes(type);
1299
1698
  }
1300
1699
  function matchesContentType(bytes, type) {
1700
+ if (type === "application/zip")
1701
+ return bytes.length >= 4 && bytes[0] === 80 && bytes[1] === 75 && (bytes[2] === 3 && bytes[3] === 4 || bytes[2] === 5 && bytes[3] === 6);
1702
+ if (type === "application/gzip")
1703
+ return bytes.length >= 10 && bytes[0] === 31 && bytes[1] === 139 && bytes[2] === 8;
1301
1704
  if (type === "image/png")
1302
1705
  return bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && bytes.readUInt32BE(16) * bytes.readUInt32BE(20) <= 32 * 1024 * 1024;
1303
1706
  if (type === "image/jpeg")
@@ -1329,7 +1732,7 @@ var generation = 0;
1329
1732
  var initializing = false;
1330
1733
  var inFlight = /* @__PURE__ */ new Set();
1331
1734
  var feedback = {
1332
- async init(config) {
1735
+ async init(config, options = {}) {
1333
1736
  if (initializing)
1334
1737
  return err(HaiFeedbackError.INIT_IN_PROGRESS, feedbackM("feedback_initInProgress"));
1335
1738
  initializing = true;
@@ -1339,6 +1742,8 @@ var feedback = {
1339
1742
  const parsed = FeedbackConfigSchema.safeParse(config);
1340
1743
  if (!parsed.success)
1341
1744
  return configFailure(parsed.error.issues.map((issue) => issue.path.join(".")));
1745
+ if (options.collectEvidence !== void 0 && typeof options.collectEvidence !== "function")
1746
+ return configFailure(["collectEvidence"]);
1342
1747
  if (!reldb.isInitialized || !storage.isInitialized)
1343
1748
  return configFailure([!reldb.isInitialized ? "reldb" : "storage"]);
1344
1749
  if (parsed.data.notificationMode === "reach") {
@@ -1354,8 +1759,9 @@ var feedback = {
1354
1759
  if (current) {
1355
1760
  const previous = current;
1356
1761
  current = null;
1762
+ const closing = previous.operations.close();
1357
1763
  await Promise.allSettled([...inFlight]);
1358
- await previous.operations.close();
1764
+ await closing;
1359
1765
  await previous.capture.close();
1360
1766
  }
1361
1767
  const repository = new FeedbackReportRepository();
@@ -1365,7 +1771,7 @@ var feedback = {
1365
1771
  if (epoch !== generation)
1366
1772
  return notInitialized.result();
1367
1773
  candidateCapture = createFeedbackCapture(parsed.data.appId, parsed.data.envAllowlist, { appVersion: parsed.data.appVersion, buildId: parsed.data.buildId, commit: parsed.data.commit });
1368
- const operations = createFeedbackOperations(repository, parsed.data, candidateCapture);
1774
+ const operations = createFeedbackOperations(repository, parsed.data, candidateCapture, options);
1369
1775
  if (epoch !== generation) {
1370
1776
  await operations.close();
1371
1777
  await candidateCapture.close();
@@ -1387,12 +1793,11 @@ var feedback = {
1387
1793
  generation++;
1388
1794
  const previous = current;
1389
1795
  current = null;
1390
- await Promise.allSettled([...inFlight]);
1391
- if (!previous)
1392
- return ok(void 0);
1393
1796
  try {
1394
- await previous.operations.close();
1395
- await previous.capture.close();
1797
+ const closing = previous?.operations.close();
1798
+ await Promise.allSettled([...inFlight]);
1799
+ await closing;
1800
+ await previous?.capture.close();
1396
1801
  return ok(void 0);
1397
1802
  } catch {
1398
1803
  return err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));