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

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 { FeedbackBudget, FeedbackSubmitInput, FeedbackReport, FeedbackListInput, FeedbackDetail, FeedbackEvidenceQuery, FeedbackEvidence, FeedbackSetStatusInput, FeedbackAppendEvidenceInput, FeedbackCompleteInput, FeedbackUploadInput, FeedbackAttachment, FeedbackAttachmentData, FeedbackRetryInput, FeedbackExportInput, FeedbackJob } from '@h-ai/api-contract/feedback';
4
+ import { FeedbackEvidenceInput, FeedbackReport, FeedbackBudget, FeedbackSubmitInput, 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
  /**
@@ -23,10 +23,18 @@ declare const FeedbackConfigSchema: z.ZodObject<{
23
23
  console: "console";
24
24
  reach: "reach";
25
25
  }>>;
26
+ consoleContent: z.ZodDefault<z.ZodEnum<{
27
+ metadata: "metadata";
28
+ summary: "summary";
29
+ }>>;
26
30
  recipients: z.ZodDefault<z.ZodArray<z.ZodString>>;
27
31
  notificationProvider: z.ZodDefault<z.ZodString>;
28
32
  adminUrl: z.ZodOptional<z.ZodURL>;
29
33
  collectionTimeoutMs: z.ZodDefault<z.ZodNumber>;
34
+ businessCollectionTimeoutMs: z.ZodDefault<z.ZodNumber>;
35
+ webhookUrl: z.ZodDefault<z.ZodString>;
36
+ webhookSecret: z.ZodDefault<z.ZodString>;
37
+ webhookTimeoutMs: z.ZodDefault<z.ZodNumber>;
30
38
  workerIntervalMs: z.ZodDefault<z.ZodNumber>;
31
39
  leaseMs: z.ZodDefault<z.ZodNumber>;
32
40
  maxAttempts: z.ZodDefault<z.ZodNumber>;
@@ -93,6 +101,71 @@ interface FeedbackContext {
93
101
  userId: string;
94
102
  permissions?: readonly string[];
95
103
  }
104
+ /**
105
+ * 业务采集附件;ID 在同一反馈内稳定,正文由框架计算校验和并写入私有存储。
106
+ * @example
107
+ * ```ts
108
+ * import type { FeedbackCollectedAttachment } from '@h-ai/feedback'
109
+ * const file: FeedbackCollectedAttachment = { id: 'logs', name: 'logs.txt', contentType: 'text/plain', bytes: new TextEncoder().encode('diagnostics') }
110
+ * void file
111
+ * ```
112
+ */
113
+ interface FeedbackCollectedAttachment {
114
+ id: string;
115
+ name: string;
116
+ contentType: string;
117
+ bytes: Uint8Array;
118
+ }
119
+ /**
120
+ * 业务补充材料;短文本/结构化信息出现在 business 证据,完整日志使用附件。
121
+ * @example
122
+ * ```ts
123
+ * import type { FeedbackCollectedEvidence } from '@h-ai/feedback'
124
+ * const evidence: FeedbackCollectedEvidence = { text: 'Order diagnostics', data: { orderId: 'order-1' } }
125
+ * void evidence
126
+ * ```
127
+ */
128
+ interface FeedbackCollectedEvidence {
129
+ text?: string;
130
+ data?: FeedbackEvidenceInput['data'];
131
+ attachments?: FeedbackCollectedAttachment[];
132
+ }
133
+ /**
134
+ * 采集扩展在持久任务中执行并等待异步结果;可能重试,宿主应按 report.id 幂等并响应 signal。
135
+ * @example
136
+ * ```ts
137
+ * import type { FeedbackInitOptions } from '@h-ai/feedback'
138
+ * const options: FeedbackInitOptions = { collectEvidence: async ({ report, signal }) => { signal.throwIfAborted(); return { text: report.number } } }
139
+ * void options
140
+ * ```
141
+ */
142
+ interface FeedbackInitOptions {
143
+ collectEvidence?: (context: {
144
+ report: Readonly<FeedbackReport>;
145
+ signal: AbortSignal;
146
+ }) => FeedbackCollectedEvidence | Promise<FeedbackCollectedEvidence>;
147
+ }
148
+ /**
149
+ * 采集结束后发送的 feedback.created 事件;eventId 跨重试稳定,附件正文通过受权 API 读取。
150
+ * @example
151
+ * ```ts
152
+ * import type { FeedbackWebhookEvent } from '@h-ai/feedback'
153
+ * function eventKey(event: FeedbackWebhookEvent) { return event.eventId }
154
+ * void eventKey
155
+ * ```
156
+ */
157
+ interface FeedbackWebhookEvent {
158
+ event: 'feedback.created';
159
+ eventId: string;
160
+ createdAt: number;
161
+ report: FeedbackReport;
162
+ evidence: {
163
+ revision: number;
164
+ business: boolean;
165
+ attachments: FeedbackAttachment[];
166
+ };
167
+ detailUrl?: string;
168
+ }
96
169
  /**
97
170
  * 反馈服务对象;领域操作返回 HaiResult,close 不释放宿主依赖连接。
98
171
  * @example
@@ -106,7 +179,7 @@ interface FeedbackContext {
106
179
  * ```
107
180
  */
108
181
  interface FeedbackFunctions {
109
- init: (config: FeedbackConfigInput) => Promise<HaiResult<void>>;
182
+ init: (config: FeedbackConfigInput, options?: FeedbackInitOptions) => Promise<HaiResult<void>>;
110
183
  close: () => Promise<HaiResult<void>>;
111
184
  readonly isInitialized: boolean;
112
185
  readonly config: FeedbackConfig | null;
@@ -158,4 +231,4 @@ interface FeedbackFunctions {
158
231
  */
159
232
  declare const feedback: FeedbackFunctions;
160
233
 
161
- export { type FeedbackConfig, type FeedbackConfigInput, FeedbackConfigSchema, type FeedbackContext, type FeedbackFunctions, HaiFeedbackError, feedback };
234
+ export { type FeedbackCollectedAttachment, type FeedbackCollectedEvidence, type FeedbackConfig, type FeedbackConfigInput, FeedbackConfigSchema, type FeedbackContext, type FeedbackFunctions, type FeedbackInitOptions, type FeedbackWebhookEvent, HaiFeedbackError, feedback };
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';
1
+ import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-UIVT267C.js';
2
+ export { HaiFeedbackError } from './chunk-UIVT267C.js';
3
3
  import { createFeedbackBundle } from './chunk-5QWKURWU.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,11 +32,26 @@ 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),
@@ -489,13 +506,15 @@ function validActor(actor) {
489
506
  function escapeHtml(value) {
490
507
  return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character] ?? character);
491
508
  }
492
- function createFeedbackOperations(repo, config, capture) {
509
+ function createFeedbackOperations(repo, config, capture, options = {}) {
493
510
  const budget = resolveFeedbackBudget(config);
494
511
  const table = repo.tables;
495
512
  const clock = feedbackClockSql();
496
513
  let stopped = false;
497
514
  let timer;
498
515
  let running = null;
516
+ const shutdown = new AbortController();
517
+ const collectEvidence = options.collectEvidence;
499
518
  async function access(id, actor, permission, sql = reldb.sql) {
500
519
  if (!validActor(actor))
501
520
  return forbidden();
@@ -641,6 +660,7 @@ function createFeedbackOperations(repo, config, capture) {
641
660
  },
642
661
  async close() {
643
662
  stopped = true;
663
+ shutdown.abort();
644
664
  if (timer)
645
665
  clearTimeout(timer);
646
666
  await running;
@@ -700,7 +720,7 @@ function createFeedbackOperations(repo, config, capture) {
700
720
  if ((sanitized.attachments ?? []).reduce((total, file) => total + file.size, 0) > budget.maxAttachmentsBytes)
701
721
  return limited();
702
722
  for (const attachment of sanitized.attachments ?? []) {
703
- if (attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
723
+ if (attachment.id.startsWith("server:") || attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
704
724
  return invalid();
705
725
  const attachmentId = hash(`${id}:${attachment.id}`);
706
726
  const data = { ...attachment, feedbackId: id, status: "pending" };
@@ -713,7 +733,12 @@ function createFeedbackOperations(repo, config, capture) {
713
733
  if (!saved.success)
714
734
  return saved;
715
735
  }
716
- const collected = await insertJob(tx, report, "collect", "initial", { anchor: sanitized.anchor?.timestamp ?? now }, row.collect_deadline);
736
+ if (config.webhookUrl) {
737
+ const webhook = await insertJob(tx, report, "webhook", "created", { url: config.webhookUrl }, row.collect_deadline);
738
+ if (!webhook.success)
739
+ return webhook;
740
+ }
741
+ const collected = await insertJob(tx, report, "collect", "initial", { anchor: sanitized.anchor?.timestamp ?? now, businessEvidence: !!collectEvidence }, row.collect_deadline);
717
742
  if (!collected.success)
718
743
  return collected;
719
744
  const cleanup = await insertJob(tx, report, "cleanup", "expiry", {}, row.expires_at);
@@ -900,7 +925,7 @@ function createFeedbackOperations(repo, config, capture) {
900
925
  if (!saved.success)
901
926
  return saved;
902
927
  }
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]);
928
+ 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
929
  return next.success ? ok(repo.report(row.data)) : next;
905
930
  },
906
931
  async uploadAttachment(input, actor) {
@@ -926,6 +951,9 @@ function createFeedbackOperations(repo, config, capture) {
926
951
  return ok({ name: metadata.name, contentType: metadata.contentType, dataBase64: bytes.data.toString("base64") });
927
952
  },
928
953
  async retry(input, actor) {
954
+ const parsed = FeedbackRetryInputSchema.safeParse(input);
955
+ if (!parsed.success)
956
+ return invalid();
929
957
  const permission = input.target === "export" ? "feedback:export" : "feedback:process";
930
958
  const row = await access(input.id, actor, permission);
931
959
  if (!row.success)
@@ -1084,6 +1112,109 @@ function createFeedbackOperations(repo, config, capture) {
1084
1112
  }
1085
1113
  return ok({ ...metadata, status: "available" });
1086
1114
  }
1115
+ async function collectBusiness(job, report, actor) {
1116
+ const marker = hash(`${report.id}:server:business:0`);
1117
+ const previous = await reldb.sql.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [marker]);
1118
+ if (!previous.success)
1119
+ return previous;
1120
+ if (previous.data)
1121
+ return ok(void 0);
1122
+ if (!collectEvidence)
1123
+ return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1124
+ const controller = new AbortController();
1125
+ const signal = AbortSignal.any([controller.signal, shutdown.signal]);
1126
+ const timeout = setTimeout(() => controller.abort(), config.businessCollectionTimeoutMs);
1127
+ timeout.unref();
1128
+ let collected;
1129
+ let onAbort;
1130
+ try {
1131
+ collected = await Promise.race([
1132
+ Promise.resolve().then(() => collectEvidence({ report: Object.freeze({ ...report }), signal })),
1133
+ new Promise((_resolve, reject) => {
1134
+ onAbort = () => reject(new Error("Feedback business collection aborted"));
1135
+ signal.addEventListener("abort", onAbort, { once: true });
1136
+ if (signal.aborted)
1137
+ onAbort();
1138
+ })
1139
+ ]);
1140
+ } catch {
1141
+ return failed();
1142
+ } finally {
1143
+ clearTimeout(timeout);
1144
+ if (onAbort)
1145
+ signal.removeEventListener("abort", onAbort);
1146
+ }
1147
+ if (!collected || typeof collected !== "object" || collected.text !== void 0 && typeof collected.text !== "string" || collected.attachments !== void 0 && !Array.isArray(collected.attachments))
1148
+ return invalid();
1149
+ if ((collected.attachments?.length ?? 0) > budget.maxAttachmentCount)
1150
+ return limited();
1151
+ const files = [];
1152
+ for (const input of collected.attachments ?? []) {
1153
+ if (!input || !(input.bytes instanceof Uint8Array) || typeof input.id !== "string" || input.id.length > 100)
1154
+ return invalid();
1155
+ if (input.bytes.byteLength > budget.maxAttachmentBytes)
1156
+ return limited();
1157
+ let bytes = Buffer.from(input.bytes);
1158
+ if (!matchesContentType(bytes, input.contentType))
1159
+ return invalid();
1160
+ if (input.contentType === "text/plain")
1161
+ bytes = Buffer.from(redactFeedbackText(bytes.toString("utf8")));
1162
+ if (input.contentType === "application/json")
1163
+ bytes = Buffer.from(JSON.stringify(sanitizeFeedbackValue(JSON.parse(bytes.toString("utf8")))));
1164
+ if (bytes.length > budget.maxAttachmentBytes)
1165
+ return limited();
1166
+ 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) });
1167
+ if (!parsed.success || !input.id || files.some((file) => file.metadata.id === parsed.data.id))
1168
+ return invalid();
1169
+ const rowId = hash(`${report.id}:${parsed.data.id}`);
1170
+ const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/business/${rowId}-${parsed.data.checksum}`;
1171
+ files.push({ metadata: { ...parsed.data, feedbackId: report.id, status: "available" }, bytes, key, rowId });
1172
+ }
1173
+ 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) } }));
1174
+ if (!event.success)
1175
+ return invalid();
1176
+ async function checkCapacity(sql) {
1177
+ const existing = await attachments(report.id, sql);
1178
+ if (!existing.success)
1179
+ return existing;
1180
+ 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)
1181
+ return limited();
1182
+ return ok(void 0);
1183
+ }
1184
+ const capacity = await checkCapacity(reldb.sql);
1185
+ if (!capacity.success)
1186
+ return capacity;
1187
+ for (const file of files) {
1188
+ const stored = await storage.file.put(file.key, file.bytes, { contentType: file.metadata.contentType, ifNoneMatch: "*", signal: AbortSignal.any([shutdown.signal, AbortSignal.timeout(3e4)]) });
1189
+ if (!stored.success) {
1190
+ const existing = await storage.file.get(file.key);
1191
+ if (!existing.success || hash(existing.data) !== file.metadata.checksum)
1192
+ return stored;
1193
+ }
1194
+ }
1195
+ return withLease(job, async (tx) => {
1196
+ const fresh = await access(report.id, actor, void 0, tx);
1197
+ if (!fresh.success)
1198
+ return fresh;
1199
+ const completed = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [marker]);
1200
+ if (!completed.success)
1201
+ return completed;
1202
+ if (completed.data)
1203
+ return ok(void 0);
1204
+ const capacity2 = await checkCapacity(tx);
1205
+ if (!capacity2.success)
1206
+ return capacity2;
1207
+ const appended = await appendInside(tx, fresh.data, [event.data], true);
1208
+ if (!appended.success)
1209
+ return appended;
1210
+ for (const file of files) {
1211
+ 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)]);
1212
+ if (!saved.success)
1213
+ return saved;
1214
+ }
1215
+ return ok(void 0);
1216
+ });
1217
+ }
1087
1218
  async function processJobs() {
1088
1219
  let processed = 0;
1089
1220
  const candidateParams = [];
@@ -1174,6 +1305,11 @@ function createFeedbackOperations(repo, config, capture) {
1174
1305
  });
1175
1306
  if (!saved.success)
1176
1307
  return saved;
1308
+ if ("businessEvidence" in payload && payload.businessEvidence === true) {
1309
+ const business = await collectBusiness(job, report, actor);
1310
+ if (!business.success)
1311
+ return business;
1312
+ }
1177
1313
  const files = await attachments(report.id);
1178
1314
  if (!files.success)
1179
1315
  return files;
@@ -1184,6 +1320,50 @@ function createFeedbackOperations(repo, config, capture) {
1184
1320
  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
1321
  return updated.success ? ok({ collected: captured.length }) : updated;
1186
1322
  }
1323
+ if (job.kind === "webhook") {
1324
+ if (!("url" in payload) || payload.url !== config.webhookUrl || !config.webhookUrl)
1325
+ return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1326
+ if (report.collectionStatus === "collecting")
1327
+ return ok({ pending: true });
1328
+ const frozen = await withLease(job, async (tx) => {
1329
+ const live = await access(report.id, actor, void 0, tx);
1330
+ if (!live.success)
1331
+ return live;
1332
+ if (live.data.collection_status === "collecting")
1333
+ return conflict();
1334
+ const stored = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [job.id]);
1335
+ if (!stored.success || !stored.data)
1336
+ return missing();
1337
+ const snapshot = readFeedbackJson(stored.data.payload);
1338
+ if (snapshot && typeof snapshot === "object" && "body" in snapshot && typeof snapshot.body === "string")
1339
+ return ok(snapshot.body);
1340
+ const files = await attachments(report.id, tx);
1341
+ if (!files.success)
1342
+ return files;
1343
+ const business = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [hash(`${report.id}:server:business:0`)]);
1344
+ if (!business.success)
1345
+ return business;
1346
+ const detailUrl = config.adminUrl ? new URL(config.adminUrl) : void 0;
1347
+ detailUrl?.searchParams.set("feedback", report.id);
1348
+ 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 } : {} };
1349
+ const body = JSON.stringify(event);
1350
+ const saved = await tx.execute(`UPDATE ${table.jobs} SET payload = ? WHERE id = ? AND token = ?`, [JSON.stringify({ url: config.webhookUrl, body }), job.id, job.token]);
1351
+ return saved.success ? ok(body) : saved;
1352
+ });
1353
+ if (!frozen.success)
1354
+ return frozen;
1355
+ const timestamp = String(Math.floor(Date.now() / 1e3));
1356
+ const headers = { "content-type": "application/json", "idempotency-key": job.id, "x-hai-feedback-event": "feedback.created", "x-hai-feedback-timestamp": timestamp };
1357
+ if (config.webhookSecret)
1358
+ headers["x-hai-feedback-signature"] = `sha256=${createHmac("sha256", config.webhookSecret).update(`${timestamp}.${frozen.data}`).digest("hex")}`;
1359
+ try {
1360
+ const response = await fetch(config.webhookUrl, { method: "POST", headers, body: frozen.data, redirect: "manual", signal: AbortSignal.any([shutdown.signal, AbortSignal.timeout(config.webhookTimeoutMs)]) });
1361
+ await response.body?.cancel();
1362
+ return response.ok ? ok({ status: response.status }) : failed();
1363
+ } catch {
1364
+ return failed();
1365
+ }
1366
+ }
1187
1367
  if (job.kind === "notify") {
1188
1368
  if (config.notificationMode === "disabled")
1189
1369
  return ok({ disabled: true });
@@ -1195,7 +1375,7 @@ function createFeedbackOperations(repo, config, capture) {
1195
1375
  return live;
1196
1376
  const current2 = repo.report(live.data);
1197
1377
  if (config.notificationMode === "console") {
1198
- core.logger.info("Feedback notification", { module: "feedback", number: current2.number, appId: current2.appId, summary: current2.summary, collectionStatus: current2.collectionStatus });
1378
+ 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
1379
  return ok({ channel: "console" });
1200
1380
  }
1201
1381
  const { HaiReachError, reach } = await import('@h-ai/reach');
@@ -1295,9 +1475,13 @@ function createFeedbackOperations(repo, config, capture) {
1295
1475
  return operations;
1296
1476
  }
1297
1477
  function allowedContentType(type) {
1298
- return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf"].includes(type);
1478
+ return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf", "application/zip", "application/gzip"].includes(type);
1299
1479
  }
1300
1480
  function matchesContentType(bytes, type) {
1481
+ if (type === "application/zip")
1482
+ return bytes.length >= 4 && bytes[0] === 80 && bytes[1] === 75 && (bytes[2] === 3 && bytes[3] === 4 || bytes[2] === 5 && bytes[3] === 6);
1483
+ if (type === "application/gzip")
1484
+ return bytes.length >= 10 && bytes[0] === 31 && bytes[1] === 139 && bytes[2] === 8;
1301
1485
  if (type === "image/png")
1302
1486
  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
1487
  if (type === "image/jpeg")
@@ -1329,7 +1513,7 @@ var generation = 0;
1329
1513
  var initializing = false;
1330
1514
  var inFlight = /* @__PURE__ */ new Set();
1331
1515
  var feedback = {
1332
- async init(config) {
1516
+ async init(config, options = {}) {
1333
1517
  if (initializing)
1334
1518
  return err(HaiFeedbackError.INIT_IN_PROGRESS, feedbackM("feedback_initInProgress"));
1335
1519
  initializing = true;
@@ -1339,6 +1523,8 @@ var feedback = {
1339
1523
  const parsed = FeedbackConfigSchema.safeParse(config);
1340
1524
  if (!parsed.success)
1341
1525
  return configFailure(parsed.error.issues.map((issue) => issue.path.join(".")));
1526
+ if (options.collectEvidence !== void 0 && typeof options.collectEvidence !== "function")
1527
+ return configFailure(["collectEvidence"]);
1342
1528
  if (!reldb.isInitialized || !storage.isInitialized)
1343
1529
  return configFailure([!reldb.isInitialized ? "reldb" : "storage"]);
1344
1530
  if (parsed.data.notificationMode === "reach") {
@@ -1354,8 +1540,9 @@ var feedback = {
1354
1540
  if (current) {
1355
1541
  const previous = current;
1356
1542
  current = null;
1543
+ const closing = previous.operations.close();
1357
1544
  await Promise.allSettled([...inFlight]);
1358
- await previous.operations.close();
1545
+ await closing;
1359
1546
  await previous.capture.close();
1360
1547
  }
1361
1548
  const repository = new FeedbackReportRepository();
@@ -1365,7 +1552,7 @@ var feedback = {
1365
1552
  if (epoch !== generation)
1366
1553
  return notInitialized.result();
1367
1554
  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);
1555
+ const operations = createFeedbackOperations(repository, parsed.data, candidateCapture, options);
1369
1556
  if (epoch !== generation) {
1370
1557
  await operations.close();
1371
1558
  await candidateCapture.close();
@@ -1387,12 +1574,11 @@ var feedback = {
1387
1574
  generation++;
1388
1575
  const previous = current;
1389
1576
  current = null;
1390
- await Promise.allSettled([...inFlight]);
1391
- if (!previous)
1392
- return ok(void 0);
1393
1577
  try {
1394
- await previous.operations.close();
1395
- await previous.capture.close();
1578
+ const closing = previous?.operations.close();
1579
+ await Promise.allSettled([...inFlight]);
1580
+ await closing;
1581
+ await previous?.capture.close();
1396
1582
  return ok(void 0);
1397
1583
  } catch {
1398
1584
  return err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));