@h-ai/feedback 0.1.0-alpha.74 → 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.d.ts CHANGED
@@ -39,6 +39,11 @@ declare const FeedbackConfigSchema: z.ZodObject<{
39
39
  leaseMs: z.ZodDefault<z.ZodNumber>;
40
40
  maxAttempts: z.ZodDefault<z.ZodNumber>;
41
41
  retentionDays: z.ZodDefault<z.ZodNumber>;
42
+ maxReportsPerUserDay: z.ZodDefault<z.ZodNumber>;
43
+ maxBytesPerUserDay: z.ZodDefault<z.ZodNumber>;
44
+ maxStoredBytesPerScope: z.ZodDefault<z.ZodNumber>;
45
+ maxExportsPerReport: z.ZodDefault<z.ZodNumber>;
46
+ exportRetentionHours: z.ZodDefault<z.ZodNumber>;
42
47
  envAllowlist: z.ZodDefault<z.ZodArray<z.ZodString>>;
43
48
  maxTotalBytes: z.ZodDefault<z.ZodNumber>;
44
49
  maxAttachmentBytes: z.ZodDefault<z.ZodNumber>;
@@ -86,6 +91,7 @@ declare const HaiFeedbackError: {
86
91
  LIMIT_EXCEEDED: _h_ai_core.HaiErrorDef;
87
92
  INVALID_INPUT: _h_ai_core.HaiErrorDef;
88
93
  CHECKSUM_MISMATCH: _h_ai_core.HaiErrorDef;
94
+ QUOTA_EXCEEDED: _h_ai_core.HaiErrorDef;
89
95
  };
90
96
  /**
91
97
  * 由可信服务端认证与授权策略生成的范围;HTTP 输入不能指定此对象。
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-UIVT267C.js';
2
- export { HaiFeedbackError } from './chunk-UIVT267C.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
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';
@@ -55,6 +55,16 @@ var FeedbackConfigSchema = z.strictObject({
55
55
  maxAttempts: z.number().int().min(1).max(20).default(5),
56
56
  /** 已受理反馈及对象附件的保留天数。 */
57
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),
58
68
  /** 允许采集的服务端环境变量名;凭据类名称仍按敏感信息策略排除。 */
59
69
  envAllowlist: z.array(z.string().min(1).max(128)).max(100).default([]),
60
70
  /** 未压缩导出总预算;客户端、附件总量和传输预算由此派生。 */
@@ -74,6 +84,7 @@ var EVIDENCE = "hai_feedback_evidence";
74
84
  var ATTACHMENTS = "hai_feedback_attachments";
75
85
  var HISTORY = "hai_feedback_history";
76
86
  var JOBS = "hai_feedback_jobs";
87
+ var QUOTAS = "hai_feedback_quotas";
77
88
  var identityColumns = ["id", "report_id", "scope_id", "app_id", "user_id", "source", "request_id", "error_id", "client_id"];
78
89
  function feedbackTextEquals(column, value, params) {
79
90
  if (!identityColumns.includes(column))
@@ -112,7 +123,7 @@ function readFeedbackJson(value) {
112
123
  return typeof value === "string" ? JSON.parse(value) : value;
113
124
  }
114
125
  var FeedbackReportRepository = class extends BaseReldbCrudRepository {
115
- 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 };
116
127
  constructor() {
117
128
  super(reldb, {
118
129
  table: REPORTS,
@@ -124,6 +135,18 @@ var FeedbackReportRepository = class extends BaseReldbCrudRepository {
124
135
  if (!report.success)
125
136
  return report;
126
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
+ }],
127
150
  [EVIDENCE, {
128
151
  id: { type: "TEXT", primaryKey: true },
129
152
  report_id: { type: "TEXT" },
@@ -180,9 +203,11 @@ var FeedbackReportRepository = class extends BaseReldbCrudRepository {
180
203
  return created;
181
204
  }
182
205
  const indexes = [
206
+ { table: QUOTAS, name: "hai_feedback_quotas_expiry", columns: ["app_id", "period"] },
183
207
  { table: REPORTS, name: "hai_feedback_reports_scope_time", columns: ["scope_id", "created_at"] },
184
208
  { table: REPORTS, name: "hai_feedback_reports_scope_status", columns: ["scope_id", "status", "created_at"] },
185
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"] },
186
211
  { table: EVIDENCE, name: "hai_feedback_evidence_temp_scope", columns: ["scope_id", "user_id", "timestamp"] },
187
212
  { table: ATTACHMENTS, name: "hai_feedback_attachments_report", columns: ["report_id"] },
188
213
  { table: HISTORY, name: "hai_feedback_history_report", columns: ["report_id"] },
@@ -287,28 +312,68 @@ function createFeedbackCapture(appId, envAllowlist, service) {
287
312
  let capacityLoss;
288
313
  let sequence = 0;
289
314
  let totalBytes = 0;
315
+ let pendingBytes = 0;
316
+ let failures = 0;
317
+ let retryAfter = 0;
290
318
  let stopped = false;
291
319
  let flushing = null;
292
- 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) {
293
327
  if (flushing)
294
328
  return flushing;
329
+ if (!force && retryAfter > Date.now())
330
+ return;
295
331
  flushing = core.context.run({ suppressLogObservers: true }, async () => {
296
- for (const [key, item] of [...pending]) {
297
- 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));
298
353
  if (!current2.success) {
299
- 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));
300
356
  return;
301
357
  }
302
- if (!current2.data) {
303
- const event = item.event;
304
- 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);
305
367
  if (!result.success) {
306
- 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));
307
370
  return;
308
371
  }
309
372
  }
310
- pending.delete(key);
373
+ for (const [key] of batch) removePending(key);
311
374
  }
375
+ failures = 0;
376
+ retryAfter = 0;
312
377
  }).finally(() => {
313
378
  flushing = null;
314
379
  });
@@ -318,7 +383,7 @@ function createFeedbackCapture(appId, envAllowlist, service) {
318
383
  for (const [key2, previous] of losses) {
319
384
  if (previous.event.timestamp < Date.now() - 5 * 6e4) {
320
385
  losses.delete(key2);
321
- pending.delete(createHash("sha256").update(`${source}:${previous.event.sequence}`).digest("hex"));
386
+ removePending(createHash("sha256").update(`${source}:${previous.event.sequence}`).digest("hex"));
322
387
  }
323
388
  }
324
389
  if (capacityLoss && capacityLoss.timestamp < Date.now() - 5 * 6e4)
@@ -334,12 +399,21 @@ function createFeedbackCapture(appId, envAllowlist, service) {
334
399
  }
335
400
  const entry = { event, scopeId: item.scopeId, userId: item.userId, bytes: Buffer.byteLength(JSON.stringify(event)) };
336
401
  losses.set(key, entry);
337
- 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
+ }
338
408
  }
339
409
  function pin(item, errorId) {
340
410
  if (item.event.kind === "error")
341
411
  item.event.errorId = item.event.id;
342
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;
343
417
  const key = createHash("sha256").update(`${source}:${item.event.sequence}`).digest("hex");
344
418
  if (pending.has(key))
345
419
  return;
@@ -347,8 +421,13 @@ function createFeedbackCapture(appId, envAllowlist, service) {
347
421
  rememberLoss(item, "server_pending_evidence_overflow");
348
422
  return;
349
423
  }
424
+ if (pendingBytes + item.bytes > 8 * 1024 * 1024 - 128 * 1024) {
425
+ rememberLoss(item, "server_pending_evidence_bytes_exceeded");
426
+ return;
427
+ }
350
428
  const event = { ...item.event };
351
429
  pending.set(key, { ...item, event });
430
+ pendingBytes += item.bytes;
352
431
  }
353
432
  function observe(record) {
354
433
  if (stopped || record.context.module === "feedback")
@@ -386,9 +465,12 @@ function createFeedbackCapture(appId, envAllowlist, service) {
386
465
  else rememberLoss(item, "server_error_window_overflow");
387
466
  }
388
467
  buffer.push(item);
389
- totalBytes += bytes;
468
+ item.buffered = true;
469
+ totalBytes += item.bytes;
390
470
  while (buffer.length > 1e4 || totalBytes > 16 * 1024 * 1024 || (buffer[0]?.event.timestamp ?? Date.now()) < Date.now() - 5 * 6e4) {
391
471
  const removed = buffer.shift();
472
+ if (removed)
473
+ removed.buffered = false;
392
474
  totalBytes -= removed?.bytes ?? 0;
393
475
  if (removed && removed.event.timestamp >= Date.now() - 5 * 6e4)
394
476
  rememberLoss(removed, "server_log_buffer_overflow");
@@ -401,7 +483,7 @@ function createFeedbackCapture(appId, envAllowlist, service) {
401
483
  timer.unref();
402
484
  return {
403
485
  async collect(scopeId, userId, anchor) {
404
- await flush();
486
+ await flush(true);
405
487
  const params = [];
406
488
  const identity = `${feedbackTextEquals("app_id", appId, params)} AND ${feedbackTextEquals("scope_id", scopeId, params)} AND ${feedbackTextEquals("user_id", userId, params)}`;
407
489
  params.push(anchor - 5 * 6e4, anchor + 3e4);
@@ -463,7 +545,7 @@ function createFeedbackCapture(appId, envAllowlist, service) {
463
545
  stopped = true;
464
546
  stop();
465
547
  clearInterval(timer);
466
- await flush();
548
+ await flush(true);
467
549
  }
468
550
  };
469
551
  }
@@ -513,8 +595,70 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
513
595
  let stopped = false;
514
596
  let timer;
515
597
  let running = null;
598
+ let idleRounds = 0;
599
+ let cleanupBacklog = false;
600
+ let cleanupAfter = 0;
601
+ let wakePending = false;
516
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
+ }
517
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
+ }
518
662
  async function access(id, actor, permission, sql = reldb.sql) {
519
663
  if (!validActor(actor))
520
664
  return forbidden();
@@ -638,25 +782,21 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
638
782
  const result = await sql.query(`SELECT * FROM ${table.evidence} WHERE report_id = ? AND revision <= ? ORDER BY timestamp, source, sequence`, [id, revision]);
639
783
  return result.success ? ok(result.data.map((row) => FeedbackEvidenceSchema.parse({ ...FeedbackEvidenceInputSchema.parse(readFeedbackJson(row.payload)), feedbackId: id, revision: Number(row.revision) }))) : result;
640
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
+ }
641
795
  const operations = {
642
796
  start() {
643
797
  if (!config.workerIntervalMs || stopped)
644
798
  return;
645
- const tick = async () => {
646
- if (stopped)
647
- return;
648
- await operations.runJobs();
649
- if (!stopped) {
650
- timer = setTimeout(() => {
651
- void tick();
652
- }, config.workerIntervalMs);
653
- timer.unref();
654
- }
655
- };
656
- timer = setTimeout(() => {
657
- void tick();
658
- }, config.workerIntervalMs);
659
- timer.unref();
799
+ schedule(config.workerIntervalMs);
660
800
  },
661
801
  async close() {
662
802
  stopped = true;
@@ -674,6 +814,8 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
674
814
  if (!parsed.success || parsed.data.appId !== config.appId)
675
815
  return invalid();
676
816
  const sanitized = FeedbackSubmitInputSchema.parse(sanitizeFeedbackValue(parsed.data));
817
+ if (sanitized.evidence?.some((event) => event.source.startsWith("server:")))
818
+ return invalid();
677
819
  const submissionKey = hash(JSON.stringify([actor.scopeId, actor.userId, config.appId, sanitized.submissionId]));
678
820
  const digest = hash(canonicalFeedbackJson(sanitized));
679
821
  const existing = await reldb.sql.get(`SELECT * FROM ${table.reports} WHERE submission_key = ?`, [submissionKey]);
@@ -714,7 +856,10 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
714
856
  const inserted = await repo.create({ ...row }, tx);
715
857
  if (!inserted.success)
716
858
  return inserted;
717
- 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);
718
863
  if (!initial.success)
719
864
  return initial;
720
865
  if ((sanitized.attachments ?? []).reduce((total, file) => total + file.size, 0) > budget.maxAttachmentsBytes)
@@ -756,9 +901,7 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
756
901
  return missing();
757
902
  return duplicate.data.submission_hash === digest ? ok(repo.report(duplicate.data)) : conflict();
758
903
  }
759
- const environment = await appendInternal(id, actor, [capture.environment()]);
760
- if (!environment.success)
761
- core.logger.warn("Feedback environment collection deferred", { module: "feedback", feedbackId: id });
904
+ wakeWorker();
762
905
  const current2 = await repo.find(id, actor.scopeId);
763
906
  return current2.success && current2.data ? ok(repo.report(current2.data)) : result;
764
907
  },
@@ -970,13 +1113,15 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
970
1113
  if (!resumed.success)
971
1114
  return resumed;
972
1115
  }
1116
+ if (updated.success)
1117
+ wakeWorker();
973
1118
  return updated.success ? ok(void 0) : updated;
974
1119
  },
975
1120
  async export(input, actor) {
976
1121
  const parsed = FeedbackExportInputSchema.safeParse(input);
977
1122
  if (!parsed.success)
978
1123
  return invalid();
979
- return repo.transaction(async (tx) => {
1124
+ const result = await repo.transaction(async (tx) => {
980
1125
  const row = await access(input.id, actor, "feedback:export", tx);
981
1126
  if (!row.success)
982
1127
  return row;
@@ -990,12 +1135,20 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
990
1135
  if (previous.data) {
991
1136
  const snapshot = readFeedbackJson(previous.data.payload);
992
1137
  const live = snapshot && typeof snapshot === "object" && "snapshotExpiresAt" in snapshot && typeof snapshot.snapshotExpiresAt === "number" && snapshot.snapshotExpiresAt > Date.now();
993
- 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)
994
1139
  return ok(jobData(previous.data));
995
1140
  }
996
1141
  if (revision < Number(row.data.revision)) {
997
1142
  return missing();
998
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();
999
1152
  const events = await evidence(input.id, revision, tx);
1000
1153
  if (!events.success)
1001
1154
  return events;
@@ -1006,12 +1159,15 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1006
1159
  if (!changes.success)
1007
1160
  return changes;
1008
1161
  const suffix = `${revision}:${row.data.version}${previous.data ? `:${previous.data.id}` : ""}`;
1009
- 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());
1010
1163
  if (!added.success)
1011
1164
  return added;
1012
1165
  const job = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [added.data]);
1013
1166
  return job.success && job.data ? ok(jobData(job.data)) : missing();
1014
1167
  });
1168
+ if (result.success)
1169
+ wakeWorker();
1170
+ return result;
1015
1171
  },
1016
1172
  async getExport(input, actor) {
1017
1173
  const row = await access(input.id, actor, "feedback:export");
@@ -1032,6 +1188,8 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1032
1188
  return missing();
1033
1189
  return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_exportPending"));
1034
1190
  }
1191
+ if (Number(job.data.created_at) + config.exportRetentionHours * 36e5 <= Date.now())
1192
+ return missing();
1035
1193
  if (job.data.status === "failed")
1036
1194
  return failed();
1037
1195
  const data = readFeedbackJson(job.data.result);
@@ -1044,16 +1202,7 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1044
1202
  return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
1045
1203
  return ok({ name: `${repo.report(row.data).number}.zip`, contentType: "application/zip", dataBase64: bytes.data.toString("base64") });
1046
1204
  },
1047
- async runJobs() {
1048
- if (stopped)
1049
- return err(HaiFeedbackError.NOT_INITIALIZED, feedbackM("feedback_notInitialized"));
1050
- if (running)
1051
- return running;
1052
- running = core.context.run({ suppressLogObservers: true }, processJobs).catch(failed).finally(() => {
1053
- running = null;
1054
- });
1055
- return running;
1056
- }
1205
+ runJobs
1057
1206
  };
1058
1207
  async function upload(input, actor) {
1059
1208
  const parsed = FeedbackUploadInputSchema.safeParse(input);
@@ -1181,6 +1330,21 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1181
1330
  return limited();
1182
1331
  return ok(void 0);
1183
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;
1184
1348
  const capacity = await checkCapacity(reldb.sql);
1185
1349
  if (!capacity.success)
1186
1350
  return capacity;
@@ -1254,8 +1418,6 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1254
1418
  if (!terminal.success)
1255
1419
  return terminal;
1256
1420
  }
1257
- if (!saved.data.changes && job.kind === "export" && result.success && result.data && typeof result.data === "object" && "key" in result.data && typeof result.data.key === "string")
1258
- await storage.file.delete(result.data.key);
1259
1421
  if (job.kind === "notify")
1260
1422
  await notificationSummary(job.report_id);
1261
1423
  processed++;
@@ -1267,8 +1429,47 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1267
1429
  clearInterval(renewal);
1268
1430
  }
1269
1431
  }
1270
- const expired = await reldb.sql.execute(`DELETE FROM ${table.evidence} WHERE report_id IS NULL AND expires_at < ?`, [Date.now()]);
1271
- 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 });
1272
1473
  }
1273
1474
  async function executeJob(job) {
1274
1475
  if (job.app_id !== config.appId)
@@ -1406,6 +1607,8 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1406
1607
  if (!("report" in payload) || !("evidence" in payload) || !("attachments" in payload))
1407
1608
  return invalid();
1408
1609
  const fixed = payload;
1610
+ if (!Number.isSafeInteger(fixed.maxBytes) || fixed.maxBytes < 4 * 1024 * 1024 || fixed.maxBytes > 40 * 1024 * 1024)
1611
+ return invalid();
1409
1612
  if (fixed.snapshotExpiresAt < Date.now())
1410
1613
  return missing();
1411
1614
  const files = [];
@@ -1425,12 +1628,17 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1425
1628
  const info = attachmentData(file);
1426
1629
  files.push({ id: info.id, name: info.name, contentType: info.contentType, checksum: info.checksum, bytes: stored2.data });
1427
1630
  }
1428
- 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 });
1429
1632
  if (!bundle.success)
1430
1633
  return bundle;
1431
- 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`;
1432
1635
  const stored = await storage.file.put(key, bundle.data, { contentType: "application/zip", ifNoneMatch: "*" });
1433
- 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 });
1434
1642
  }
1435
1643
  const marked = await withLease(job, async (tx) => {
1436
1644
  const locked = await repo.find(report.id, report.scopeId, tx);
@@ -1459,6 +1667,17 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1459
1667
  if (!removed2.success)
1460
1668
  return removed2;
1461
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
+ }
1462
1681
  const removedJobs = await tx.execute(`DELETE FROM ${table.jobs} WHERE report_id = ? AND id <> ?`, [report.id, job.id]);
1463
1682
  if (!removedJobs.success)
1464
1683
  return removedJobs;