@h-ai/feedback 0.1.0-alpha.69 → 0.1.0-alpha.71

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
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import * as _h_ai_core from '@h-ai/core';
3
3
  import { HaiResult, PaginatedResult } from '@h-ai/core';
4
- import { FeedbackSubmitInput, FeedbackReport, FeedbackListInput, FeedbackDetail, FeedbackEvidenceQuery, FeedbackEvidence, FeedbackSetStatusInput, FeedbackAppendEvidenceInput, FeedbackCompleteInput, FeedbackUploadInput, FeedbackAttachment, FeedbackAttachmentData, FeedbackRetryInput, FeedbackExportInput, FeedbackJob } from '@h-ai/api-contract/feedback';
4
+ import { FeedbackBudget, FeedbackSubmitInput, FeedbackReport, FeedbackListInput, FeedbackDetail, FeedbackEvidenceQuery, FeedbackEvidence, FeedbackSetStatusInput, FeedbackAppendEvidenceInput, FeedbackCompleteInput, FeedbackUploadInput, FeedbackAttachment, FeedbackAttachmentData, FeedbackRetryInput, FeedbackExportInput, FeedbackJob } from '@h-ai/api-contract/feedback';
5
5
  export { FeedbackAppendEvidenceInput, FeedbackAttachment, FeedbackAttachmentData, FeedbackCompleteInput, FeedbackDetail, FeedbackEvidence, FeedbackEvidenceQuery, FeedbackExportInput, FeedbackJob, FeedbackListInput, FeedbackReport, FeedbackRetryInput, FeedbackSetStatusInput, FeedbackSubmitInput, FeedbackUploadInput } from '@h-ai/api-contract/feedback';
6
6
 
7
7
  /**
@@ -18,15 +18,21 @@ declare const FeedbackConfigSchema: z.ZodObject<{
18
18
  appVersion: z.ZodOptional<z.ZodString>;
19
19
  buildId: z.ZodOptional<z.ZodString>;
20
20
  commit: z.ZodOptional<z.ZodString>;
21
- recipients: z.ZodArray<z.ZodEmail>;
21
+ notificationMode: z.ZodDefault<z.ZodEnum<{
22
+ disabled: "disabled";
23
+ console: "console";
24
+ reach: "reach";
25
+ }>>;
26
+ recipients: z.ZodDefault<z.ZodArray<z.ZodString>>;
22
27
  notificationProvider: z.ZodDefault<z.ZodString>;
23
- adminUrl: z.ZodURL;
28
+ adminUrl: z.ZodOptional<z.ZodURL>;
24
29
  collectionTimeoutMs: z.ZodDefault<z.ZodNumber>;
25
30
  workerIntervalMs: z.ZodDefault<z.ZodNumber>;
26
31
  leaseMs: z.ZodDefault<z.ZodNumber>;
27
32
  maxAttempts: z.ZodDefault<z.ZodNumber>;
28
33
  retentionDays: z.ZodDefault<z.ZodNumber>;
29
34
  envAllowlist: z.ZodDefault<z.ZodArray<z.ZodString>>;
35
+ maxTotalBytes: z.ZodDefault<z.ZodNumber>;
30
36
  maxAttachmentBytes: z.ZodDefault<z.ZodNumber>;
31
37
  maxEvidenceBytes: z.ZodDefault<z.ZodNumber>;
32
38
  maxEvidenceCount: z.ZodDefault<z.ZodNumber>;
@@ -104,6 +110,7 @@ interface FeedbackFunctions {
104
110
  close: () => Promise<HaiResult<void>>;
105
111
  readonly isInitialized: boolean;
106
112
  readonly config: FeedbackConfig | null;
113
+ readonly budget: FeedbackBudget;
107
114
  submit: (input: FeedbackSubmitInput, context: FeedbackContext) => Promise<HaiResult<FeedbackReport>>;
108
115
  list: (input: FeedbackListInput, context: FeedbackContext) => Promise<HaiResult<PaginatedResult<FeedbackReport>>>;
109
116
  get: (id: string, context: FeedbackContext) => Promise<HaiResult<FeedbackDetail>>;
package/dist/index.js CHANGED
@@ -1,16 +1,15 @@
1
- import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-DYEK2FNM.js';
2
- export { HaiFeedbackError } from './chunk-DYEK2FNM.js';
3
- import { createFeedbackBundle } from './chunk-43AOXE4W.js';
1
+ import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-KGHZL7K6.js';
2
+ export { HaiFeedbackError } from './chunk-KGHZL7K6.js';
3
+ import { createFeedbackBundle } from './chunk-XQXX5WMV.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
6
  import { core, ok, err } from '@h-ai/core';
6
- import { reach } from '@h-ai/reach';
7
7
  import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
8
8
  import { storage } from '@h-ai/storage';
9
9
  import { Buffer } from 'buffer';
10
10
  import { randomUUID, createHash } from 'crypto';
11
11
  import { totalmem, arch, release, platform } from 'os';
12
12
  import process from 'process';
13
- import { FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema } from '@h-ai/api-contract/feedback';
14
13
 
15
14
  var FeedbackConfigSchema = z.strictObject({
16
15
  /** 反馈所属应用标识;应与浏览器及 API 入口配置一致。 */
@@ -21,12 +20,14 @@ var FeedbackConfigSchema = z.strictObject({
21
20
  buildId: z.string().max(128).optional(),
22
21
  /** 源码提交标识,帮助定位对应代码。 */
23
22
  commit: z.string().max(128).optional(),
24
- /** 接收待处理反馈通知的管理员邮件地址。 */
25
- recipients: z.array(z.email()).min(1).max(20),
26
- /** 已初始化 Reach 邮件 Provider 的唯一名称。 */
23
+ /** disabled 不通知,console 输出安全摘要,reach 使用已有通知通道。 */
24
+ notificationMode: z.enum(["disabled", "console", "reach"]).default("reach"),
25
+ /** Reach 接收方,由实际 Provider 校验邮箱、手机号或业务目的地。 */
26
+ recipients: z.array(z.string().min(1).max(320)).max(20).default([]),
27
+ /** 已初始化 Reach Provider 的唯一名称,不限制为邮件。 */
27
28
  notificationProvider: z.string().min(1).default("email"),
28
29
  /** 管理员通知中的反馈管理页面地址。 */
29
- adminUrl: z.url().refine((value) => ["http:", "https:"].includes(new URL(value).protocol)),
30
+ adminUrl: z.url().refine((value) => ["http:", "https:"].includes(new URL(value).protocol)).optional(),
30
31
  /** 补充材料收集超时,单位毫秒;超时后冻结为完整或部分材料。 */
31
32
  collectionTimeoutMs: z.number().int().min(0).max(3e5).default(6e4),
32
33
  /** 持久任务扫描间隔,单位毫秒;零值由宿主手动运行任务。 */
@@ -39,12 +40,17 @@ var FeedbackConfigSchema = z.strictObject({
39
40
  retentionDays: z.number().int().min(1).max(3650).default(30),
40
41
  /** 允许采集的服务端环境变量名;凭据类名称仍按敏感信息策略排除。 */
41
42
  envAllowlist: z.array(z.string().min(1).max(128)).max(100).default([]),
42
- /** 单个附件允许的最大字节数。 */
43
+ /** 未压缩导出总预算;客户端、附件总量和传输预算由此派生。 */
44
+ maxTotalBytes: z.number().int().min(4 * 1024 * 1024).max(40 * 1024 * 1024).default(32 * 1024 * 1024),
45
+ /** 单个附件允许的最大字节数;同时受总预算约束。 */
43
46
  maxAttachmentBytes: z.number().int().min(1).max(5 * 1024 * 1024).default(5 * 1024 * 1024),
44
47
  /** 单份反馈累计证据允许的最大字节数。 */
45
- maxEvidenceBytes: z.number().int().min(1024).max(32 * 1024 * 1024).default(4 * 1024 * 1024),
48
+ maxEvidenceBytes: z.number().int().min(1024).max(4 * 1024 * 1024).default(4 * 1024 * 1024),
46
49
  /** 单份反馈累计证据允许的最大条数。 */
47
50
  maxEvidenceCount: z.number().int().min(1).max(1e4).default(2e3)
51
+ }).superRefine((config, context) => {
52
+ if (config.notificationMode === "reach" && !config.recipients.length)
53
+ context.addIssue({ code: "custom", path: ["recipients"], message: "Reach recipients are required" });
48
54
  });
49
55
  var REPORTS = "hai_feedback_reports";
50
56
  var EVIDENCE = "hai_feedback_evidence";
@@ -484,6 +490,7 @@ function escapeHtml(value) {
484
490
  return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character] ?? character);
485
491
  }
486
492
  function createFeedbackOperations(repo, config, capture) {
493
+ const budget = resolveFeedbackBudget(config);
487
494
  const table = repo.tables;
488
495
  const clock = feedbackClockSql();
489
496
  let stopped = false;
@@ -557,12 +564,12 @@ function createFeedbackOperations(repo, config, capture) {
557
564
  const revision = Number(row.revision) + 1;
558
565
  const params = [additions.length, bytes, revision, Date.now()];
559
566
  const identity = `${feedbackTextEquals("id", row.id, params)} AND ${feedbackTextEquals("scope_id", row.scope_id, params)}`;
560
- params.push(row.revision, additions.length, config.maxEvidenceCount, bytes, config.maxEvidenceBytes);
567
+ params.push(row.revision, additions.length, budget.maxEvidenceCount, bytes, budget.maxEvidenceBytes);
561
568
  const changed = await tx.execute(`UPDATE ${table.reports} SET evidence_count = evidence_count + ?, evidence_bytes = evidence_bytes + ?, revision = ?, updated_at = ? WHERE ${identity} AND revision = ? AND deleting = 0 AND evidence_count + ? <= ? AND evidence_bytes + ? <= ?`, params);
562
569
  if (!changed.success)
563
570
  return changed;
564
571
  if (!changed.data.changes)
565
- return Number(row.evidence_count) + additions.length > config.maxEvidenceCount || Number(row.evidence_bytes) + bytes > config.maxEvidenceBytes ? limited() : conflict();
572
+ return Number(row.evidence_count) + additions.length > budget.maxEvidenceCount || Number(row.evidence_bytes) + bytes > budget.maxEvidenceBytes ? limited() : conflict();
566
573
  for (const { key, event, bytes: itemBytes } of additions) {
567
574
  const inserted = await tx.execute(`INSERT INTO ${table.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, row.id, row.scope_id, row.user_id, row.app_id, event.source, event.sequence, event.kind, event.timestamp, event.requestId ?? null, event.errorId ?? null, revision, itemBytes, row.expires_at, JSON.stringify(event)]);
568
575
  if (!inserted.success)
@@ -595,11 +602,14 @@ function createFeedbackOperations(repo, config, capture) {
595
602
  });
596
603
  }
597
604
  async function notificationSummary(id) {
605
+ if (config.notificationMode === "disabled")
606
+ return reldb.sql.execute(`UPDATE ${table.reports} SET notification_status = 'disabled' WHERE id = ?`, [id]);
598
607
  return reldb.sql.execute(`UPDATE ${table.reports} SET notification_status = CASE
599
608
  WHEN EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'notify' AND (status = 'failed' OR last_error IS NOT NULL)) THEN 'failed'
600
609
  WHEN NOT EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'notify' AND status <> 'succeeded') THEN 'sent'
601
610
  WHEN EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'notify' AND status = 'running') THEN 'sending'
602
- ELSE 'pending' END WHERE id = ?`, [id, id, id, id]);
611
+ WHEN EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'notify' AND status = 'pending' AND result IS NOT NULL) THEN 'deferred'
612
+ ELSE 'pending' END WHERE id = ?`, [id, id, id, id, id]);
603
613
  }
604
614
  async function history(id, sql = reldb.sql) {
605
615
  const result = await sql.query(`SELECT payload FROM ${table.history} WHERE report_id = ? ORDER BY id`, [id]);
@@ -673,13 +683,13 @@ function createFeedbackOperations(repo, config, capture) {
673
683
  commit: sanitized.commit ?? config.commit,
674
684
  status: "pending",
675
685
  collectionStatus: "collecting",
676
- notificationStatus: "pending",
686
+ notificationStatus: config.notificationMode === "disabled" ? "disabled" : "pending",
677
687
  createdAt: now,
678
688
  updatedAt: now,
679
689
  version: 1,
680
690
  evidenceRevision: 1
681
691
  };
682
- const row = { id, scope_id: actor.scopeId, user_id: actor.userId, app_id: config.appId, number: report.number, app_version: report.appVersion ?? null, summary: report.summary ?? "", submission_key: submissionKey, submission_hash: digest, status: "pending", collection_status: "collecting", notification_status: "pending", created_at: now, updated_at: now, version: 1, revision: 1, evidence_count: 0, evidence_bytes: 0, collect_deadline: now + config.collectionTimeoutMs, expires_at: now + config.retentionDays * 864e5, payload: report, deleting: 0 };
692
+ const row = { id, scope_id: actor.scopeId, user_id: actor.userId, app_id: config.appId, number: report.number, app_version: report.appVersion ?? null, summary: report.summary ?? "", submission_key: submissionKey, submission_hash: digest, status: "pending", collection_status: "collecting", notification_status: report.notificationStatus, created_at: now, updated_at: now, version: 1, revision: 1, evidence_count: 0, evidence_bytes: 0, collect_deadline: now + config.collectionTimeoutMs, expires_at: now + config.retentionDays * 864e5, payload: report, deleting: 0 };
683
693
  const result = await repo.transaction(async (tx) => {
684
694
  const inserted = await repo.create({ ...row }, tx);
685
695
  if (!inserted.success)
@@ -687,8 +697,10 @@ function createFeedbackOperations(repo, config, capture) {
687
697
  const initial = await appendInside(tx, row, sanitized.evidence ?? []);
688
698
  if (!initial.success)
689
699
  return initial;
700
+ if ((sanitized.attachments ?? []).reduce((total, file) => total + file.size, 0) > budget.maxAttachmentsBytes)
701
+ return limited();
690
702
  for (const attachment of sanitized.attachments ?? []) {
691
- if (attachment.size > config.maxAttachmentBytes || !allowedContentType(attachment.contentType))
703
+ if (attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
692
704
  return invalid();
693
705
  const attachmentId = hash(`${id}:${attachment.id}`);
694
706
  const data = { ...attachment, feedbackId: id, status: "pending" };
@@ -696,7 +708,7 @@ function createFeedbackOperations(repo, config, capture) {
696
708
  if (!saved.success)
697
709
  return saved;
698
710
  }
699
- for (const recipient of config.recipients) {
711
+ for (const recipient of config.notificationMode === "disabled" ? [] : config.notificationMode === "console" ? ["console"] : config.recipients) {
700
712
  const saved = await insertJob(tx, report, "notify", recipient, { recipient }, row.collect_deadline);
701
713
  if (!saved.success)
702
714
  return saved;
@@ -854,6 +866,11 @@ function createFeedbackOperations(repo, config, capture) {
854
866
  if (!updated.data.changes)
855
867
  return conflict();
856
868
  const change = { id: randomUUID(), feedbackId: input.id, userId: actor.userId, from: repo.report(row.data).status, to: parsed.data.status, timestamp: now, note: parsed.data.note ? redactFeedbackText(parsed.data.note) : void 0 };
869
+ const previousHistory = await history(input.id, tx);
870
+ if (!previousHistory.success)
871
+ return previousHistory;
872
+ if (Buffer.byteLength(JSON.stringify([...previousHistory.data, change])) > 256 * 1024)
873
+ return limited();
857
874
  const saved = await tx.execute(`INSERT INTO ${table.history} (id, report_id, payload) VALUES (?, ?, ?)`, [change.id, input.id, JSON.stringify(change)]);
858
875
  return saved.success ? ok({ ...repo.report(row.data), status: parsed.data.status, version: parsed.data.expectedVersion + 1, updatedAt: now }) : saved;
859
876
  });
@@ -1027,7 +1044,7 @@ function createFeedbackOperations(repo, config, capture) {
1027
1044
  const attachment = found.data;
1028
1045
  const metadata = attachmentData(attachment);
1029
1046
  const bytes = Buffer.from(parsed.data.dataBase64, "base64");
1030
- if (bytes.length !== Number(attachment.size) || bytes.length > config.maxAttachmentBytes || !matchesContentType(bytes, metadata.contentType))
1047
+ if (bytes.length !== Number(attachment.size) || bytes.length > budget.maxAttachmentBytes || !matchesContentType(bytes, metadata.contentType))
1031
1048
  return invalid();
1032
1049
  if (hash(bytes) !== attachment.checksum)
1033
1050
  return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
@@ -1098,7 +1115,7 @@ function createFeedbackOperations(repo, config, capture) {
1098
1115
  const pendingReceipt = result.success && !!result.data && typeof result.data === "object" && "pending" in result.data && result.data.pending === true;
1099
1116
  const purged = result.success && !!result.data && typeof result.data === "object" && "purged" in result.data && result.data.purged === true;
1100
1117
  const status = result.success ? pendingReceipt ? "pending" : "succeeded" : job.kind !== "cleanup" && Number(job.attempts) + 1 >= config.maxAttempts ? "failed" : "pending";
1101
- const saved = purged ? await reldb.sql.execute(`DELETE FROM ${table.jobs} WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [job.id, token]) : await reldb.sql.execute(`UPDATE ${table.jobs} SET status = ?, token = NULL, lease_until = 0, next_run_at = ${clock} + ?, updated_at = ${clock}, last_error = ?, result = CASE WHEN ? = 1 THEN ? ELSE result END WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [status, Math.min(6e4, 1e3 * 2 ** Math.min(Number(job.attempts), 6)), result.success ? null : result.error.code, result.success ? 1 : 0, result.success ? JSON.stringify(result.data) : null, job.id, token]);
1118
+ const saved = purged ? await reldb.sql.execute(`DELETE FROM ${table.jobs} WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [job.id, token]) : await reldb.sql.execute(`UPDATE ${table.jobs} SET status = ?, attempts = attempts - ?, token = NULL, lease_until = 0, next_run_at = ${clock} + ?, updated_at = ${clock}, last_error = ?, result = CASE WHEN ? = 1 THEN ? ELSE result END WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [status, pendingReceipt ? 1 : 0, Math.min(6e4, 1e3 * 2 ** Math.min(Number(job.attempts), 6)), result.success ? null : result.error.code, result.success ? 1 : 0, result.success ? JSON.stringify(result.data) : null, job.id, token]);
1102
1119
  if (!saved.success)
1103
1120
  return saved;
1104
1121
  if (saved.data.changes && status === "failed" && job.kind === "collect") {
@@ -1168,24 +1185,34 @@ function createFeedbackOperations(repo, config, capture) {
1168
1185
  return updated.success ? ok({ collected: captured.length }) : updated;
1169
1186
  }
1170
1187
  if (job.kind === "notify") {
1188
+ if (config.notificationMode === "disabled")
1189
+ return ok({ disabled: true });
1171
1190
  const recipient = "recipient" in payload && typeof payload.recipient === "string" ? payload.recipient : "";
1172
- if (!config.recipients.includes(recipient))
1191
+ if (config.notificationMode === "reach" && !config.recipients.includes(recipient))
1173
1192
  return invalid();
1174
- if (reach.config?.dnd?.enabled)
1175
- return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1176
1193
  const live = await withLease(job, (tx) => access(report.id, actor, void 0, tx));
1177
1194
  if (!live.success)
1178
1195
  return live;
1179
1196
  const current2 = repo.report(live.data);
1197
+ if (config.notificationMode === "console") {
1198
+ core.logger.info("Feedback notification", { module: "feedback", number: current2.number, appId: current2.appId, summary: current2.summary, collectionStatus: current2.collectionStatus });
1199
+ return ok({ channel: "console" });
1200
+ }
1201
+ const { HaiReachError, reach } = await import('@h-ai/reach');
1180
1202
  const materialStatus = {
1181
1203
  collecting: feedbackM("feedback_materialCollecting"),
1182
1204
  complete: feedbackM("feedback_materialComplete"),
1183
1205
  partial: feedbackM("feedback_materialPartial"),
1184
1206
  failed: feedbackM("feedback_materialFailed")
1185
1207
  }[current2.collectionStatus];
1186
- const detailUrl = new URL(config.adminUrl);
1187
- detailUrl.searchParams.set("feedback", report.id);
1188
- const sent = await reach.send({ provider: config.notificationProvider, to: recipient, subject: `${feedbackM("feedback_received")} ${current2.number}`, body: `<p>${escapeHtml(current2.number)}</p><p>${escapeHtml(current2.appId)} ${escapeHtml(current2.appVersion ?? "")}</p><p>${escapeHtml(feedbackM("feedback_submittedAt"))}: ${escapeHtml(new Date(current2.createdAt).toISOString())}</p><p>${escapeHtml(feedbackM("feedback_materialStatus"))}: ${escapeHtml(materialStatus)}</p><p>${escapeHtml(current2.summary ?? "")}</p><p><a href="${escapeHtml(detailUrl.href)}">${escapeHtml(current2.number)}</a></p>`, idempotencyKey: job.id });
1208
+ const detailUrl = config.adminUrl ? new URL(config.adminUrl) : void 0;
1209
+ detailUrl?.searchParams.set("feedback", report.id);
1210
+ const facts = [current2.number, `${current2.appId} ${current2.appVersion ?? ""}`, `${feedbackM("feedback_submittedAt")}: ${new Date(current2.createdAt).toISOString()}`, `${feedbackM("feedback_materialStatus")}: ${materialStatus}`, current2.summary ?? "", ...detailUrl ? [detailUrl.href] : []];
1211
+ const isEmail = reach.config?.providers.find((provider) => provider.name === config.notificationProvider)?.type === "smtp";
1212
+ const body = isEmail ? facts.map((value) => `<p>${escapeHtml(value)}</p>`).join("") : facts.join("\n");
1213
+ const sent = await reach.send({ provider: config.notificationProvider, to: recipient, subject: `${feedbackM("feedback_received")} ${current2.number}`, body, idempotencyKey: job.id, deferPolicy: "reject" });
1214
+ if (!sent.success && sent.error.code === HaiReachError.DND_BLOCKED.code)
1215
+ return ok({ pending: true, deferred: true });
1189
1216
  if (!sent.success)
1190
1217
  return sent;
1191
1218
  if (!sent.data.success)
@@ -1218,7 +1245,7 @@ function createFeedbackOperations(repo, config, capture) {
1218
1245
  const info = attachmentData(file);
1219
1246
  files.push({ id: info.id, name: info.name, contentType: info.contentType, checksum: info.checksum, bytes: stored2.data });
1220
1247
  }
1221
- const bundle = await createFeedbackBundle({ report: fixed.report, evidence: fixed.evidence, history: fixed.history, attachments: files, missing: unavailable2 });
1248
+ const bundle = await createFeedbackBundle({ maxBytes: budget.maxTotalBytes, report: fixed.report, evidence: fixed.evidence, history: fixed.history, attachments: files, missing: unavailable2 });
1222
1249
  if (!bundle.success)
1223
1250
  return bundle;
1224
1251
  const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/exports/${job.id}-${job.token}.zip`;
@@ -1268,7 +1295,7 @@ function createFeedbackOperations(repo, config, capture) {
1268
1295
  return operations;
1269
1296
  }
1270
1297
  function allowedContentType(type) {
1271
- return ["image/png", "image/jpeg", "image/webp", "text/plain", "application/json", "application/pdf"].includes(type);
1298
+ return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf"].includes(type);
1272
1299
  }
1273
1300
  function matchesContentType(bytes, type) {
1274
1301
  if (type === "image/png")
@@ -1279,6 +1306,10 @@ function matchesContentType(bytes, type) {
1279
1306
  return bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WEBP";
1280
1307
  if (type === "application/pdf")
1281
1308
  return bytes.subarray(0, 5).toString() === "%PDF-";
1309
+ if (type === "video/webm")
1310
+ return bytes.length >= 12 && bytes.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]));
1311
+ if (type === "video/mp4")
1312
+ return bytes.length >= 12 && bytes.subarray(4, 8).toString() === "ftyp";
1282
1313
  if (type === "application/json") {
1283
1314
  try {
1284
1315
  JSON.parse(bytes.toString("utf8"));
@@ -1306,12 +1337,20 @@ var feedback = {
1306
1337
  let candidateCapture;
1307
1338
  try {
1308
1339
  const parsed = FeedbackConfigSchema.safeParse(config);
1309
- if (!parsed.success || !reldb.isInitialized || !storage.isInitialized || !reach.isInitialized)
1310
- return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1311
- if (reach.config?.dnd?.enabled)
1312
- return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1313
- if (!reach.config?.providers.some((provider) => provider.name === parsed.data.notificationProvider))
1314
- return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1340
+ if (!parsed.success)
1341
+ return configFailure(parsed.error.issues.map((issue) => issue.path.join(".")));
1342
+ if (!reldb.isInitialized || !storage.isInitialized)
1343
+ return configFailure([!reldb.isInitialized ? "reldb" : "storage"]);
1344
+ if (parsed.data.notificationMode === "reach") {
1345
+ const provider = await import('@h-ai/reach').catch(() => void 0);
1346
+ if (!provider)
1347
+ return configFailure(["reach"]);
1348
+ const { reach } = provider;
1349
+ if (!reach.isInitialized)
1350
+ return configFailure(["reach"]);
1351
+ if (!reach.config?.providers.some((provider2) => provider2.name === parsed.data.notificationProvider))
1352
+ return configFailure(["notificationProvider"]);
1353
+ }
1315
1354
  if (current) {
1316
1355
  const previous = current;
1317
1356
  current = null;
@@ -1365,6 +1404,9 @@ var feedback = {
1365
1404
  get config() {
1366
1405
  return current ? { ...current.config, recipients: [...current.config.recipients], envAllowlist: [...current.config.envAllowlist] } : null;
1367
1406
  },
1407
+ get budget() {
1408
+ return resolveFeedbackBudget(current?.config);
1409
+ },
1368
1410
  submit: (input, actor) => guarded(() => (current?.operations ?? unavailable).submit(input, actor)),
1369
1411
  list: (input, actor) => guarded(() => (current?.operations ?? unavailable).list(input, actor)),
1370
1412
  get: (id, actor) => guarded(() => (current?.operations ?? unavailable).get(id, actor)),
@@ -1379,6 +1421,9 @@ var feedback = {
1379
1421
  getExport: (input, actor) => guarded(() => (current?.operations ?? unavailable).getExport(input, actor)),
1380
1422
  runJobs: () => guarded(() => (current?.operations ?? unavailable).runJobs())
1381
1423
  };
1424
+ function configFailure(fields) {
1425
+ return { success: false, error: { ...HaiFeedbackError.CONFIG_ERROR, message: feedbackM("feedback_configError"), ext: { module: "feedback", fields } } };
1426
+ }
1382
1427
  async function guarded(work) {
1383
1428
  const promise = core.context.run({ suppressLogObservers: true }, work);
1384
1429
  inFlight.add(promise);