@h-ai/feedback 0.0.0-stage → 0.1.0-alpha.70

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 ADDED
@@ -0,0 +1,1396 @@
1
+ import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-DYEK2FNM.js';
2
+ export { HaiFeedbackError } from './chunk-DYEK2FNM.js';
3
+ import { createFeedbackBundle } from './chunk-43AOXE4W.js';
4
+ import { z } from 'zod';
5
+ import { core, ok, err } from '@h-ai/core';
6
+ import { reach } from '@h-ai/reach';
7
+ import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
8
+ import { storage } from '@h-ai/storage';
9
+ import { Buffer } from 'buffer';
10
+ import { randomUUID, createHash } from 'crypto';
11
+ import { totalmem, arch, release, platform } from 'os';
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
+
15
+ var FeedbackConfigSchema = z.strictObject({
16
+ /** 反馈所属应用标识;应与浏览器及 API 入口配置一致。 */
17
+ appId: z.string().min(1).max(128),
18
+ /** 当前应用版本,作为默认诊断信息保存。 */
19
+ appVersion: z.string().max(128).optional(),
20
+ /** 构建标识,帮助定位发布产物。 */
21
+ buildId: z.string().max(128).optional(),
22
+ /** 源码提交标识,帮助定位对应代码。 */
23
+ commit: z.string().max(128).optional(),
24
+ /** 接收待处理反馈通知的管理员邮件地址。 */
25
+ recipients: z.array(z.email()).min(1).max(20),
26
+ /** 已初始化 Reach 邮件 Provider 的唯一名称。 */
27
+ notificationProvider: z.string().min(1).default("email"),
28
+ /** 管理员通知中的反馈管理页面地址。 */
29
+ adminUrl: z.url().refine((value) => ["http:", "https:"].includes(new URL(value).protocol)),
30
+ /** 补充材料收集超时,单位毫秒;超时后冻结为完整或部分材料。 */
31
+ collectionTimeoutMs: z.number().int().min(0).max(3e5).default(6e4),
32
+ /** 持久任务扫描间隔,单位毫秒;零值由宿主手动运行任务。 */
33
+ workerIntervalMs: z.number().int().min(0).max(6e4).default(1e3),
34
+ /** 持久任务执行租约时长,单位毫秒;过期后允许其他实例接管。 */
35
+ leaseMs: z.number().int().min(1e3).max(3e5).default(3e4),
36
+ /** 汇集、通知和导出任务最大尝试次数;到期清理持续重试。 */
37
+ maxAttempts: z.number().int().min(1).max(20).default(5),
38
+ /** 已受理反馈及对象附件的保留天数。 */
39
+ retentionDays: z.number().int().min(1).max(3650).default(30),
40
+ /** 允许采集的服务端环境变量名;凭据类名称仍按敏感信息策略排除。 */
41
+ envAllowlist: z.array(z.string().min(1).max(128)).max(100).default([]),
42
+ /** 单个附件允许的最大字节数。 */
43
+ maxAttachmentBytes: z.number().int().min(1).max(5 * 1024 * 1024).default(5 * 1024 * 1024),
44
+ /** 单份反馈累计证据允许的最大字节数。 */
45
+ maxEvidenceBytes: z.number().int().min(1024).max(32 * 1024 * 1024).default(4 * 1024 * 1024),
46
+ /** 单份反馈累计证据允许的最大条数。 */
47
+ maxEvidenceCount: z.number().int().min(1).max(1e4).default(2e3)
48
+ });
49
+ var REPORTS = "hai_feedback_reports";
50
+ var EVIDENCE = "hai_feedback_evidence";
51
+ var ATTACHMENTS = "hai_feedback_attachments";
52
+ var HISTORY = "hai_feedback_history";
53
+ var JOBS = "hai_feedback_jobs";
54
+ var identityColumns = ["id", "report_id", "scope_id", "app_id", "user_id", "source", "request_id", "error_id", "client_id"];
55
+ function feedbackTextEquals(column, value, params) {
56
+ if (!identityColumns.includes(column))
57
+ throw new Error("Invalid feedback identity column");
58
+ params.push(value);
59
+ if (reldb.config?.type !== "mysql")
60
+ return `${column} = ?`;
61
+ params.push(value);
62
+ return `(${column} = ? AND CAST(${column} AS BINARY) = CAST(? AS BINARY))`;
63
+ }
64
+ var reportDef = {
65
+ id: { type: "TEXT", primaryKey: true },
66
+ scope_id: { type: "TEXT", notNull: true },
67
+ user_id: { type: "TEXT", notNull: true },
68
+ app_id: { type: "TEXT", notNull: true },
69
+ number: { type: "TEXT", notNull: true },
70
+ app_version: { type: "TEXT" },
71
+ summary: { type: "TEXT", notNull: true },
72
+ submission_key: { type: "TEXT", notNull: true, unique: true },
73
+ submission_hash: { type: "TEXT", notNull: true },
74
+ status: { type: "TEXT", notNull: true },
75
+ collection_status: { type: "TEXT", notNull: true },
76
+ notification_status: { type: "TEXT", notNull: true },
77
+ created_at: { type: "BIGINT", notNull: true },
78
+ updated_at: { type: "BIGINT", notNull: true },
79
+ version: { type: "INTEGER", notNull: true },
80
+ revision: { type: "INTEGER", notNull: true },
81
+ evidence_count: { type: "INTEGER", notNull: true },
82
+ evidence_bytes: { type: "BIGINT", notNull: true },
83
+ collect_deadline: { type: "BIGINT", notNull: true },
84
+ expires_at: { type: "BIGINT", notNull: true },
85
+ payload: { type: "JSON", notNull: true },
86
+ deleting: { type: "INTEGER", notNull: true }
87
+ };
88
+ function readFeedbackJson(value) {
89
+ return typeof value === "string" ? JSON.parse(value) : value;
90
+ }
91
+ var FeedbackReportRepository = class extends BaseReldbCrudRepository {
92
+ tables = { reports: REPORTS, evidence: EVIDENCE, attachments: ATTACHMENTS, history: HISTORY, jobs: JOBS };
93
+ constructor() {
94
+ super(reldb, {
95
+ table: REPORTS,
96
+ fields: Object.entries(reportDef).map(([name, def]) => ({ fieldName: name, columnName: name, def, select: true, create: true, update: name !== "id" }))
97
+ });
98
+ }
99
+ async ready() {
100
+ const report = await this.ensureReady();
101
+ if (!report.success)
102
+ return report;
103
+ const definitions = [
104
+ [EVIDENCE, {
105
+ id: { type: "TEXT", primaryKey: true },
106
+ report_id: { type: "TEXT" },
107
+ scope_id: { type: "TEXT", notNull: true },
108
+ user_id: { type: "TEXT", notNull: true },
109
+ app_id: { type: "TEXT", notNull: true },
110
+ source: { type: "TEXT", notNull: true },
111
+ sequence: { type: "BIGINT", notNull: true },
112
+ kind: { type: "TEXT", notNull: true },
113
+ timestamp: { type: "BIGINT", notNull: true },
114
+ request_id: { type: "TEXT" },
115
+ error_id: { type: "TEXT" },
116
+ revision: { type: "INTEGER", notNull: true },
117
+ bytes: { type: "INTEGER", notNull: true },
118
+ expires_at: { type: "BIGINT", notNull: true },
119
+ payload: { type: "JSON", notNull: true }
120
+ }],
121
+ [ATTACHMENTS, {
122
+ id: { type: "TEXT", primaryKey: true },
123
+ client_id: { type: "TEXT", notNull: true },
124
+ report_id: { type: "TEXT", notNull: true },
125
+ scope_id: { type: "TEXT", notNull: true },
126
+ object_key: { type: "TEXT", notNull: true },
127
+ status: { type: "TEXT", notNull: true },
128
+ size: { type: "BIGINT", notNull: true },
129
+ checksum: { type: "TEXT", notNull: true },
130
+ updated_at: { type: "BIGINT", notNull: true },
131
+ payload: { type: "JSON", notNull: true }
132
+ }],
133
+ [HISTORY, { id: { type: "TEXT", primaryKey: true }, report_id: { type: "TEXT", notNull: true }, payload: { type: "JSON", notNull: true } }],
134
+ [JOBS, {
135
+ id: { type: "TEXT", primaryKey: true },
136
+ app_id: { type: "TEXT", notNull: true },
137
+ snapshot_revision: { type: "INTEGER" },
138
+ snapshot_version: { type: "INTEGER" },
139
+ report_id: { type: "TEXT", notNull: true },
140
+ scope_id: { type: "TEXT", notNull: true },
141
+ kind: { type: "TEXT", notNull: true },
142
+ status: { type: "TEXT", notNull: true },
143
+ token: { type: "TEXT" },
144
+ lease_until: { type: "BIGINT", notNull: true },
145
+ attempts: { type: "INTEGER", notNull: true },
146
+ next_run_at: { type: "BIGINT", notNull: true },
147
+ created_at: { type: "BIGINT", notNull: true },
148
+ updated_at: { type: "BIGINT", notNull: true },
149
+ last_error: { type: "TEXT" },
150
+ payload: { type: "JSON", notNull: true },
151
+ result: { type: "JSON" }
152
+ }]
153
+ ];
154
+ for (const [table, definition] of definitions) {
155
+ const created = await reldb.ddl.createTable(table, definition);
156
+ if (!created.success)
157
+ return created;
158
+ }
159
+ const indexes = [
160
+ { table: REPORTS, name: "hai_feedback_reports_scope_time", columns: ["scope_id", "created_at"] },
161
+ { table: REPORTS, name: "hai_feedback_reports_scope_status", columns: ["scope_id", "status", "created_at"] },
162
+ { table: EVIDENCE, name: "hai_feedback_evidence_report_seq", columns: ["report_id", "source", "sequence"] },
163
+ { table: EVIDENCE, name: "hai_feedback_evidence_temp_scope", columns: ["scope_id", "user_id", "timestamp"] },
164
+ { table: ATTACHMENTS, name: "hai_feedback_attachments_report", columns: ["report_id"] },
165
+ { table: HISTORY, name: "hai_feedback_history_report", columns: ["report_id"] },
166
+ { table: JOBS, name: "hai_feedback_jobs_pending", columns: ["app_id", "status", "next_run_at"] },
167
+ { table: JOBS, name: "hai_feedback_jobs_snapshot", columns: ["report_id", "snapshot_revision", "snapshot_version"] }
168
+ ];
169
+ for (const index of indexes) {
170
+ const inspect = async () => {
171
+ const found = await reldb.sql.query("SELECT COLUMN_NAME AS column_name, NON_UNIQUE AS non_unique FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? ORDER BY SEQ_IN_INDEX", [index.table, index.name]);
172
+ if (!found.success)
173
+ return found;
174
+ if (!found.data.length)
175
+ return ok(false);
176
+ const matches = found.data.length === index.columns.length && found.data.every((column, position) => column.column_name === index.columns[position] && Number(column.non_unique) === 1);
177
+ return matches ? ok(true) : err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
178
+ };
179
+ if (reldb.config?.type === "mysql") {
180
+ const existing = await inspect();
181
+ if (!existing.success)
182
+ return existing;
183
+ if (existing.data)
184
+ continue;
185
+ }
186
+ const result = await reldb.ddl.createIndex(index.table, index.name, { columns: index.columns });
187
+ if (!result.success) {
188
+ if (reldb.config?.type === "mysql") {
189
+ const concurrent = await inspect();
190
+ if (concurrent.success && concurrent.data)
191
+ continue;
192
+ }
193
+ return result;
194
+ }
195
+ }
196
+ return ok(void 0);
197
+ }
198
+ async find(id, scope, sql = reldb.sql) {
199
+ const lock = sql !== reldb.sql && reldb.config?.type !== "sqlite" ? " FOR UPDATE" : "";
200
+ const params = [];
201
+ const condition = `${feedbackTextEquals("id", id, params)} AND ${feedbackTextEquals("scope_id", scope, params)}`;
202
+ const result = await sql.get(`SELECT * FROM ${REPORTS} WHERE ${condition}${lock}`, params);
203
+ return result.success && result.data && (result.data.id !== id || result.data.scope_id !== scope) ? ok(null) : result;
204
+ }
205
+ report(row) {
206
+ return FeedbackReportSchema.parse({
207
+ ...FeedbackReportSchema.parse(readFeedbackJson(row.payload)),
208
+ status: row.status,
209
+ collectionStatus: row.collection_status,
210
+ notificationStatus: row.notification_status,
211
+ updatedAt: Number(row.updated_at),
212
+ version: Number(row.version),
213
+ evidenceRevision: Number(row.revision)
214
+ });
215
+ }
216
+ /** 显式事务逐步检查 HaiResult;业务失败也执行 rollback。 */
217
+ async transaction(work) {
218
+ const begun = await reldb.tx.begin();
219
+ if (!begun.success)
220
+ return begun;
221
+ try {
222
+ const result = await work(begun.data);
223
+ if (!result.success) {
224
+ const rollback = await begun.data.rollback();
225
+ return rollback.success ? result : rollback;
226
+ }
227
+ const committed = await begun.data.commit();
228
+ return committed.success ? result : committed;
229
+ } catch {
230
+ await begun.data.rollback();
231
+ return err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));
232
+ }
233
+ }
234
+ };
235
+ function sanitizeFeedbackValue(value) {
236
+ const sanitized = core.sanitize.sanitizeSensitiveFields(value);
237
+ return JSON.parse(JSON.stringify(sanitized, (_key, item) => {
238
+ if (typeof item === "bigint")
239
+ return item.toString();
240
+ if (typeof item === "string") {
241
+ if (/^https?:\/\//i.test(item))
242
+ return core.sanitize.sanitizeUrl(item);
243
+ return redactFeedbackText(item);
244
+ }
245
+ return item;
246
+ }));
247
+ }
248
+ function canonicalFeedbackJson(value) {
249
+ if (Array.isArray(value))
250
+ return `[${value.map(canonicalFeedbackJson).join(",")}]`;
251
+ if (value && typeof value === "object") {
252
+ return `{${Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonicalFeedbackJson(item)}`).join(",")}}`;
253
+ }
254
+ return JSON.stringify(value) ?? "null";
255
+ }
256
+
257
+ // src/feedback-capture.ts
258
+ function createFeedbackCapture(appId, envAllowlist, service) {
259
+ const source = `server:${randomUUID()}`;
260
+ const buffer = [];
261
+ const pending = /* @__PURE__ */ new Map();
262
+ const windows = /* @__PURE__ */ new Map();
263
+ const losses = /* @__PURE__ */ new Map();
264
+ let capacityLoss;
265
+ let sequence = 0;
266
+ let totalBytes = 0;
267
+ let stopped = false;
268
+ let flushing = null;
269
+ async function flush() {
270
+ if (flushing)
271
+ return flushing;
272
+ flushing = core.context.run({ suppressLogObservers: true }, async () => {
273
+ for (const [key, item] of [...pending]) {
274
+ const current2 = await reldb.sql.get("SELECT id FROM hai_feedback_evidence WHERE id = ?", [key]);
275
+ if (!current2.success) {
276
+ rememberLoss(item, "server_temporary_evidence_write_failed");
277
+ return;
278
+ }
279
+ if (!current2.data) {
280
+ const event = item.event;
281
+ 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)]);
282
+ if (!result.success) {
283
+ rememberLoss(item, "server_temporary_evidence_write_failed");
284
+ return;
285
+ }
286
+ }
287
+ pending.delete(key);
288
+ }
289
+ }).finally(() => {
290
+ flushing = null;
291
+ });
292
+ return flushing;
293
+ }
294
+ function rememberLoss(item, reason) {
295
+ for (const [key2, previous] of losses) {
296
+ if (previous.event.timestamp < Date.now() - 5 * 6e4) {
297
+ losses.delete(key2);
298
+ pending.delete(createHash("sha256").update(`${source}:${previous.event.sequence}`).digest("hex"));
299
+ }
300
+ }
301
+ if (capacityLoss && capacityLoss.timestamp < Date.now() - 5 * 6e4)
302
+ capacityLoss = void 0;
303
+ const requestId = item.requestId ?? item.event?.requestId;
304
+ const key = JSON.stringify([item.scopeId, item.userId, requestId, reason]);
305
+ if (losses.has(key))
306
+ return;
307
+ const event = { id: randomUUID(), source, sequence: sequence++, kind: "missing", timestamp: Date.now(), requestId, data: { source: "server_logs" }, missingReason: reason };
308
+ if (losses.size >= 200) {
309
+ capacityLoss ??= { ...event, requestId: void 0, missingReason: "server_capture_capacity_exceeded" };
310
+ return;
311
+ }
312
+ const entry = { event, scopeId: item.scopeId, userId: item.userId, bytes: Buffer.byteLength(JSON.stringify(event)) };
313
+ losses.set(key, entry);
314
+ pending.set(createHash("sha256").update(`${source}:${event.sequence}`).digest("hex"), entry);
315
+ }
316
+ function pin(item, errorId) {
317
+ if (item.event.kind === "error")
318
+ item.event.errorId = item.event.id;
319
+ else item.event.errorId ??= errorId;
320
+ const key = createHash("sha256").update(`${source}:${item.event.sequence}`).digest("hex");
321
+ if (pending.has(key))
322
+ return;
323
+ if (pending.size >= 3e3) {
324
+ rememberLoss(item, "server_pending_evidence_overflow");
325
+ return;
326
+ }
327
+ const event = { ...item.event };
328
+ pending.set(key, { ...item, event });
329
+ }
330
+ function observe(record) {
331
+ if (stopped || record.context.module === "feedback")
332
+ return;
333
+ const { scopeId, userId, requestId } = record.context;
334
+ if (typeof scopeId !== "string" || typeof userId !== "string" || typeof requestId !== "string")
335
+ return;
336
+ const value = sanitizeFeedbackValue({ message: record.message, level: record.level, context: record.context });
337
+ const event = FeedbackEvidenceInputSchema.parse({ id: randomUUID(), source, sequence: sequence++, kind: record.level === "error" || record.level === "fatal" ? "error" : "log", timestamp: record.timestamp, requestId, data: value });
338
+ const bytes = Buffer.byteLength(JSON.stringify(event));
339
+ if (bytes > 65536) {
340
+ rememberLoss({ scopeId, userId, requestId }, "server_log_record_too_large");
341
+ return;
342
+ }
343
+ const item = { event, scopeId, userId, bytes };
344
+ const key = JSON.stringify([scopeId, userId, requestId]);
345
+ const previous = windows.get(key);
346
+ if (previous) {
347
+ if (previous.deadline >= Date.now() && previous.after < 100) {
348
+ pin(item, previous.errorId);
349
+ previous.after++;
350
+ } else {
351
+ windows.delete(key);
352
+ }
353
+ }
354
+ if (event.kind === "error") {
355
+ for (const [windowKey, window] of windows) {
356
+ if (window.deadline < Date.now())
357
+ windows.delete(windowKey);
358
+ }
359
+ for (const before of buffer.filter((entry) => entry.scopeId === scopeId && entry.userId === userId && entry.event.requestId === requestId).slice(-100)) pin(before, event.id);
360
+ pin(item, event.id);
361
+ if (windows.has(key) || windows.size < 100)
362
+ windows.set(key, { errorId: event.id, after: 0, deadline: Date.now() + 3e4 });
363
+ else rememberLoss(item, "server_error_window_overflow");
364
+ }
365
+ buffer.push(item);
366
+ totalBytes += bytes;
367
+ while (buffer.length > 1e4 || totalBytes > 16 * 1024 * 1024 || (buffer[0]?.event.timestamp ?? Date.now()) < Date.now() - 5 * 6e4) {
368
+ const removed = buffer.shift();
369
+ totalBytes -= removed?.bytes ?? 0;
370
+ if (removed && removed.event.timestamp >= Date.now() - 5 * 6e4)
371
+ rememberLoss(removed, "server_log_buffer_overflow");
372
+ }
373
+ }
374
+ const stop = core.logger.observe(observe);
375
+ const timer = setInterval(() => {
376
+ void flush().catch(() => void 0);
377
+ }, 250);
378
+ timer.unref();
379
+ return {
380
+ async collect(scopeId, userId, anchor) {
381
+ await flush();
382
+ const params = [];
383
+ const identity = `${feedbackTextEquals("app_id", appId, params)} AND ${feedbackTextEquals("scope_id", scopeId, params)} AND ${feedbackTextEquals("user_id", userId, params)}`;
384
+ params.push(anchor - 5 * 6e4, anchor + 3e4);
385
+ const persisted = await reldb.sql.query(`SELECT payload FROM hai_feedback_evidence WHERE report_id IS NULL AND ${identity} AND timestamp >= ? AND timestamp <= ? ORDER BY timestamp DESC, source DESC, sequence DESC LIMIT 1001`, params);
386
+ if (!persisted.success)
387
+ rememberLoss({ scopeId, userId }, "server_temporary_evidence_read_failed");
388
+ else if (persisted.data.length > 1e3)
389
+ rememberLoss({ scopeId, userId }, "server_evidence_truncated");
390
+ const events = /* @__PURE__ */ new Map();
391
+ const saved = persisted.success ? persisted.data.slice(0, 1e3).reverse().flatMap((row) => {
392
+ try {
393
+ const parsed = FeedbackEvidenceInputSchema.safeParse(readFeedbackJson(row.payload));
394
+ if (parsed.success)
395
+ return [parsed.data];
396
+ } catch {
397
+ }
398
+ rememberLoss({ scopeId, userId }, "server_temporary_evidence_invalid");
399
+ return [];
400
+ }) : [];
401
+ for (const event of saved) {
402
+ const key = `${event.source}:${event.sequence}`;
403
+ if (!events.has(key))
404
+ events.set(key, event.kind === "error" ? { ...event, errorId: event.id } : event);
405
+ }
406
+ const pinnedRequests = new Set([...events.values()].filter((item) => item.errorId).map((item) => item.requestId));
407
+ const local = buffer.filter((item) => item.scopeId === scopeId && item.userId === userId && item.event.timestamp >= anchor - 5 * 6e4 && item.event.timestamp <= anchor + 3e4);
408
+ const before = local.filter((item) => item.event.timestamp <= anchor).slice(-100);
409
+ const after = local.filter((item) => item.event.timestamp > anchor).slice(0, 100);
410
+ for (const item of [...before, ...after]) {
411
+ if (!pinnedRequests.has(item.event.requestId))
412
+ events.set(`${item.event.source}:${item.event.sequence}`, item.event);
413
+ }
414
+ for (const [key, item] of losses) {
415
+ if (item.event.timestamp < Date.now() - 5 * 6e4) {
416
+ losses.delete(key);
417
+ continue;
418
+ }
419
+ if (item.scopeId === scopeId && item.userId === userId && item.event.timestamp >= anchor - 5 * 6e4)
420
+ events.set(`${item.event.source}:${item.event.sequence}`, item.event);
421
+ }
422
+ if (capacityLoss && capacityLoss.timestamp >= anchor - 5 * 6e4)
423
+ events.set(`${capacityLoss.source}:${capacityLoss.sequence}`, capacityLoss);
424
+ if (events.size > 1e3) {
425
+ rememberLoss({ scopeId, userId }, "server_evidence_truncated");
426
+ const marker = losses.get(JSON.stringify([scopeId, userId, void 0, "server_evidence_truncated"]))?.event;
427
+ if (marker)
428
+ events.set(`${marker.source}:${marker.sequence}`, marker);
429
+ }
430
+ const missing2 = [...events.values()].filter((item) => item.kind === "missing").slice(-1e3);
431
+ const logs = [...events.values()].filter((item) => item.kind !== "missing").sort((a, b) => a.timestamp - b.timestamp);
432
+ const allowance = 1e3 - missing2.length;
433
+ return [...allowance > 0 ? logs.slice(-allowance) : [], ...missing2].sort((a, b) => a.timestamp - b.timestamp);
434
+ },
435
+ environment() {
436
+ const env = Object.fromEntries(envAllowlist.filter((key) => !core.sanitize.defaultSensitiveKeyRegex.test(key.replace(/([a-z])([A-Z])/g, "$1_$2"))).map((key) => [key, process.env[key] ?? null]));
437
+ return FeedbackEvidenceInputSchema.parse({ id: randomUUID(), source, sequence: sequence++, kind: "environment", timestamp: Date.now(), data: sanitizeFeedbackValue({ appId, service, os: { platform: platform(), release: release(), arch: arch() }, runtime: { name: "node", version: process.version, uptimeSeconds: Math.floor(process.uptime()), memoryBytes: process.memoryUsage().rss, totalMemoryBytes: totalmem() }, environment: env }) });
438
+ },
439
+ async close() {
440
+ stopped = true;
441
+ stop();
442
+ clearInterval(timer);
443
+ await flush();
444
+ }
445
+ };
446
+ }
447
+ function feedbackClockSql() {
448
+ if (reldb.config?.type === "postgresql")
449
+ return "(FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint)";
450
+ if (reldb.config?.type === "mysql")
451
+ return "(CAST(UNIX_TIMESTAMP(SYSDATE(3)) * 1000 AS UNSIGNED))";
452
+ return "(CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))";
453
+ }
454
+
455
+ // src/feedback-functions.ts
456
+ function failed() {
457
+ return err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));
458
+ }
459
+ function invalid() {
460
+ return err(HaiFeedbackError.INVALID_INPUT, feedbackM("feedback_invalidInput"));
461
+ }
462
+ function conflict() {
463
+ return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_conflict"));
464
+ }
465
+ function forbidden() {
466
+ return err(HaiFeedbackError.FORBIDDEN, feedbackM("feedback_forbidden"));
467
+ }
468
+ function missing() {
469
+ return err(HaiFeedbackError.NOT_FOUND, feedbackM("feedback_notFound"));
470
+ }
471
+ function limited() {
472
+ return err(HaiFeedbackError.LIMIT_EXCEEDED, feedbackM("feedback_limitExceeded"));
473
+ }
474
+ function hash(value) {
475
+ return createHash("sha256").update(value).digest("hex");
476
+ }
477
+ function permitted(actor, permission) {
478
+ return actor.permissions?.includes("feedback:*") || actor.permissions?.includes(permission);
479
+ }
480
+ function validActor(actor) {
481
+ return !!actor && typeof actor.scopeId === "string" && actor.scopeId.length > 0 && actor.scopeId.length <= 128 && typeof actor.userId === "string" && actor.userId.length > 0 && actor.userId.length <= 128;
482
+ }
483
+ function escapeHtml(value) {
484
+ return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character] ?? character);
485
+ }
486
+ function createFeedbackOperations(repo, config, capture) {
487
+ const table = repo.tables;
488
+ const clock = feedbackClockSql();
489
+ let stopped = false;
490
+ let timer;
491
+ let running = null;
492
+ async function access(id, actor, permission, sql = reldb.sql) {
493
+ if (!validActor(actor))
494
+ return forbidden();
495
+ const result = await repo.find(id, actor.scopeId, sql);
496
+ if (!result.success)
497
+ return result;
498
+ if (!result.data || result.data.id !== id || result.data.scope_id !== actor.scopeId || result.data.app_id !== config.appId || Number(result.data.deleting))
499
+ return missing();
500
+ if (permission ? !permitted(actor, permission) : result.data.user_id !== actor.userId && !permitted(actor, "feedback:read"))
501
+ return forbidden();
502
+ return ok(result.data);
503
+ }
504
+ async function accessWrite(id, actor, sql = reldb.sql) {
505
+ const result = await access(id, actor, permitted(actor, "feedback:process") ? "feedback:process" : void 0, sql);
506
+ if (!result.success)
507
+ return result;
508
+ return result.data.user_id === actor.userId || permitted(actor, "feedback:process") ? result : forbidden();
509
+ }
510
+ async function insertJob(tx, report, kind, suffix, payload, nextRun) {
511
+ const id = hash(`${report.id}:${kind}:${suffix}`);
512
+ const previous = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [id]);
513
+ if (!previous.success)
514
+ return previous;
515
+ if (previous.data)
516
+ return ok(id);
517
+ const now = Date.now();
518
+ const inserted = await tx.execute(`INSERT INTO ${table.jobs} (id, app_id, snapshot_revision, snapshot_version, report_id, scope_id, kind, status, token, lease_until, attempts, next_run_at, created_at, updated_at, last_error, payload, result) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${clock} + ?, ?, ?, ?, ?, ?)`, [id, config.appId, kind === "export" ? report.evidenceRevision : null, kind === "export" ? report.version : null, report.id, report.scopeId, kind, "pending", null, 0, 0, Math.max(0, nextRun - now), now, now, null, JSON.stringify(payload), null]);
519
+ return inserted.success ? ok(id) : inserted;
520
+ }
521
+ function jobData(row) {
522
+ const result = readFeedbackJson(row.result);
523
+ return FeedbackJobSchema.parse({ id: row.id, feedbackId: row.report_id, type: row.kind, status: row.status, attempts: Number(row.attempts), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), nextRunAt: Number(row.next_run_at), lastError: row.last_error ?? void 0, result: result && typeof result === "object" ? { available: true } : void 0 });
524
+ }
525
+ function attachmentData(row) {
526
+ return FeedbackAttachmentSchema.parse({ ...FeedbackAttachmentSchema.parse(readFeedbackJson(row.payload)), status: row.status });
527
+ }
528
+ async function appendInside(tx, row, events, internal = false) {
529
+ const acknowledged = [];
530
+ const additions = [];
531
+ for (const input of events) {
532
+ if (!internal && input.source.startsWith("server:"))
533
+ return invalid();
534
+ const event = FeedbackEvidenceInputSchema.parse(sanitizeFeedbackValue(input));
535
+ const key = hash(`${row.id}:${event.source}:${event.sequence}`);
536
+ const previous = await tx.get(`SELECT * FROM ${table.evidence} WHERE id = ?`, [key]);
537
+ if (!previous.success)
538
+ return previous;
539
+ if (previous.data) {
540
+ if (canonicalFeedbackJson(readFeedbackJson(previous.data.payload)) !== canonicalFeedbackJson(event))
541
+ return conflict();
542
+ } else {
543
+ const bytes2 = Buffer.byteLength(JSON.stringify(event));
544
+ if (bytes2 > 1024 * 1024)
545
+ return limited();
546
+ const staged = additions.find((item) => item.key === key);
547
+ if (staged && canonicalFeedbackJson(staged.event) !== canonicalFeedbackJson(event))
548
+ return conflict();
549
+ if (!staged)
550
+ additions.push({ key, event, bytes: bytes2 });
551
+ }
552
+ acknowledged.push(event.id);
553
+ }
554
+ if (!additions.length)
555
+ return ok({ acknowledged, evidenceRevision: Number(row.revision) });
556
+ const bytes = additions.reduce((total, item) => total + item.bytes, 0);
557
+ const revision = Number(row.revision) + 1;
558
+ const params = [additions.length, bytes, revision, Date.now()];
559
+ 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);
561
+ 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
+ if (!changed.success)
563
+ return changed;
564
+ if (!changed.data.changes)
565
+ return Number(row.evidence_count) + additions.length > config.maxEvidenceCount || Number(row.evidence_bytes) + bytes > config.maxEvidenceBytes ? limited() : conflict();
566
+ for (const { key, event, bytes: itemBytes } of additions) {
567
+ 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
+ if (!inserted.success)
569
+ return inserted;
570
+ }
571
+ return ok({ acknowledged, evidenceRevision: revision });
572
+ }
573
+ async function appendInternal(id, actor, events) {
574
+ return repo.transaction(async (tx) => {
575
+ const row = await access(id, actor, void 0, tx);
576
+ return row.success ? appendInside(tx, row.data, events, true) : row;
577
+ });
578
+ }
579
+ async function attachments(id, sql = reldb.sql) {
580
+ return sql.query(`SELECT * FROM ${table.attachments} WHERE report_id = ? ORDER BY id`, [id]);
581
+ }
582
+ async function withLease(job, work) {
583
+ return repo.transaction(async (tx) => {
584
+ const lock = reldb.config?.type === "sqlite" ? "" : " FOR UPDATE";
585
+ const owned = await tx.get(`SELECT id FROM ${table.jobs} WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}${lock}`, [job.id, job.token]);
586
+ if (!owned.success)
587
+ return owned;
588
+ if (!owned.data)
589
+ return conflict();
590
+ const result = await work(tx);
591
+ if (!result.success)
592
+ return result;
593
+ const stillOwned = await tx.get(`SELECT id FROM ${table.jobs} WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [job.id, job.token]);
594
+ return stillOwned.success && stillOwned.data ? result : conflict();
595
+ });
596
+ }
597
+ async function notificationSummary(id) {
598
+ return reldb.sql.execute(`UPDATE ${table.reports} SET notification_status = CASE
599
+ 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
+ WHEN NOT EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'notify' AND status <> 'succeeded') THEN 'sent'
601
+ 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]);
603
+ }
604
+ async function history(id, sql = reldb.sql) {
605
+ const result = await sql.query(`SELECT payload FROM ${table.history} WHERE report_id = ? ORDER BY id`, [id]);
606
+ return result.success ? ok(result.data.map((row) => FeedbackHistorySchema.parse(readFeedbackJson(row.payload)))) : result;
607
+ }
608
+ async function evidence(id, revision, sql = reldb.sql) {
609
+ const result = await sql.query(`SELECT * FROM ${table.evidence} WHERE report_id = ? AND revision <= ? ORDER BY timestamp, source, sequence`, [id, revision]);
610
+ return result.success ? ok(result.data.map((row) => FeedbackEvidenceSchema.parse({ ...FeedbackEvidenceInputSchema.parse(readFeedbackJson(row.payload)), feedbackId: id, revision: Number(row.revision) }))) : result;
611
+ }
612
+ const operations = {
613
+ start() {
614
+ if (!config.workerIntervalMs || stopped)
615
+ return;
616
+ const tick = async () => {
617
+ if (stopped)
618
+ return;
619
+ await operations.runJobs();
620
+ if (!stopped) {
621
+ timer = setTimeout(() => {
622
+ void tick();
623
+ }, config.workerIntervalMs);
624
+ timer.unref();
625
+ }
626
+ };
627
+ timer = setTimeout(() => {
628
+ void tick();
629
+ }, config.workerIntervalMs);
630
+ timer.unref();
631
+ },
632
+ async close() {
633
+ stopped = true;
634
+ if (timer)
635
+ clearTimeout(timer);
636
+ await running;
637
+ },
638
+ async submit(input, actor) {
639
+ if (!validActor(actor))
640
+ return forbidden();
641
+ if (actor.permissions !== void 0 && !permitted(actor, "feedback:create"))
642
+ return forbidden();
643
+ const parsed = FeedbackSubmitInputSchema.safeParse(input);
644
+ if (!parsed.success || parsed.data.appId !== config.appId)
645
+ return invalid();
646
+ const sanitized = FeedbackSubmitInputSchema.parse(sanitizeFeedbackValue(parsed.data));
647
+ const submissionKey = hash(JSON.stringify([actor.scopeId, actor.userId, config.appId, sanitized.submissionId]));
648
+ const digest = hash(canonicalFeedbackJson(sanitized));
649
+ const existing = await reldb.sql.get(`SELECT * FROM ${table.reports} WHERE submission_key = ?`, [submissionKey]);
650
+ if (!existing.success)
651
+ return existing;
652
+ if (existing.data && (existing.data.scope_id !== actor.scopeId || existing.data.user_id !== actor.userId || existing.data.app_id !== config.appId))
653
+ return conflict();
654
+ if (existing.data && Number(existing.data.deleting))
655
+ return missing();
656
+ if (existing.data)
657
+ return existing.data.submission_hash === digest ? ok(repo.report(existing.data)) : conflict();
658
+ const now = Date.now();
659
+ const id = `fb_${randomUUID()}`;
660
+ const report = {
661
+ id,
662
+ number: `FB-${new Date(now).toISOString().slice(0, 10).replaceAll("-", "")}-${id.slice(-8).toUpperCase()}`,
663
+ appId: config.appId,
664
+ scopeId: actor.scopeId,
665
+ userId: actor.userId,
666
+ description: sanitized.description,
667
+ category: sanitized.category,
668
+ contact: sanitized.contact,
669
+ url: sanitized.url,
670
+ summary: (sanitized.description?.trim() || sanitized.url || feedbackM("feedback_defaultSummary")).slice(0, 200),
671
+ appVersion: sanitized.appVersion ?? config.appVersion,
672
+ buildId: sanitized.buildId ?? config.buildId,
673
+ commit: sanitized.commit ?? config.commit,
674
+ status: "pending",
675
+ collectionStatus: "collecting",
676
+ notificationStatus: "pending",
677
+ createdAt: now,
678
+ updatedAt: now,
679
+ version: 1,
680
+ evidenceRevision: 1
681
+ };
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 };
683
+ const result = await repo.transaction(async (tx) => {
684
+ const inserted = await repo.create({ ...row }, tx);
685
+ if (!inserted.success)
686
+ return inserted;
687
+ const initial = await appendInside(tx, row, sanitized.evidence ?? []);
688
+ if (!initial.success)
689
+ return initial;
690
+ for (const attachment of sanitized.attachments ?? []) {
691
+ if (attachment.size > config.maxAttachmentBytes || !allowedContentType(attachment.contentType))
692
+ return invalid();
693
+ const attachmentId = hash(`${id}:${attachment.id}`);
694
+ const data = { ...attachment, feedbackId: id, status: "pending" };
695
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [attachmentId, attachment.id, id, actor.scopeId, `feedback/${hash(actor.scopeId).slice(0, 16)}/${id}/${attachmentId}`, "pending", attachment.size, attachment.checksum, now, JSON.stringify(data)]);
696
+ if (!saved.success)
697
+ return saved;
698
+ }
699
+ for (const recipient of config.recipients) {
700
+ const saved = await insertJob(tx, report, "notify", recipient, { recipient }, row.collect_deadline);
701
+ if (!saved.success)
702
+ return saved;
703
+ }
704
+ const collected = await insertJob(tx, report, "collect", "initial", { anchor: sanitized.anchor?.timestamp ?? now }, row.collect_deadline);
705
+ if (!collected.success)
706
+ return collected;
707
+ const cleanup = await insertJob(tx, report, "cleanup", "expiry", {}, row.expires_at);
708
+ if (!cleanup.success)
709
+ return cleanup;
710
+ return ok({ ...report, evidenceRevision: initial.data.evidenceRevision });
711
+ });
712
+ if (!result.success) {
713
+ const duplicate = await reldb.sql.get(`SELECT * FROM ${table.reports} WHERE submission_key = ?`, [submissionKey]);
714
+ if (!duplicate.success || !duplicate.data)
715
+ return result;
716
+ if (duplicate.data.scope_id !== actor.scopeId || duplicate.data.user_id !== actor.userId || duplicate.data.app_id !== config.appId)
717
+ return conflict();
718
+ if (Number(duplicate.data.deleting))
719
+ return missing();
720
+ return duplicate.data.submission_hash === digest ? ok(repo.report(duplicate.data)) : conflict();
721
+ }
722
+ const environment = await appendInternal(id, actor, [capture.environment()]);
723
+ if (!environment.success)
724
+ core.logger.warn("Feedback environment collection deferred", { module: "feedback", feedbackId: id });
725
+ const current2 = await repo.find(id, actor.scopeId);
726
+ return current2.success && current2.data ? ok(repo.report(current2.data)) : result;
727
+ },
728
+ async list(input, actor) {
729
+ if (!validActor(actor) || !permitted(actor, "feedback:read"))
730
+ return forbidden();
731
+ const parsed = FeedbackListInputSchema.safeParse(input);
732
+ if (!parsed.success)
733
+ return invalid();
734
+ const query = parsed.data;
735
+ if (query.from !== void 0 && query.to !== void 0 && query.from > query.to)
736
+ return invalid();
737
+ const params = [];
738
+ const conditions = [feedbackTextEquals("scope_id", actor.scopeId, params), feedbackTextEquals("app_id", config.appId, params), "deleting = 0"];
739
+ for (const [column, value] of [["status", query.status], ["collection_status", query.collectionStatus], ["notification_status", query.notificationStatus], ["app_id", query.appId]]) {
740
+ if (value) {
741
+ if (column === "app_id") {
742
+ conditions.push(feedbackTextEquals(column, value, params));
743
+ } else {
744
+ conditions.push(`${column} = ?`);
745
+ params.push(value);
746
+ }
747
+ }
748
+ }
749
+ if (query.from !== void 0) {
750
+ conditions.push("created_at >= ?");
751
+ params.push(query.from);
752
+ }
753
+ if (query.to !== void 0) {
754
+ conditions.push("created_at <= ?");
755
+ params.push(query.to);
756
+ }
757
+ if (query.search) {
758
+ conditions.push("(number LIKE ? ESCAPE '!' OR summary LIKE ? ESCAPE '!')");
759
+ const search = `%${query.search.replaceAll("!", "!!").replaceAll("%", "!%").replaceAll("_", "!_")}%`;
760
+ params.push(search, search);
761
+ }
762
+ if (query.appVersion) {
763
+ conditions.push("app_version = ?");
764
+ params.push(query.appVersion);
765
+ }
766
+ const direction = query.sortDirection === "asc" ? "ASC" : "DESC";
767
+ const page = await reldb.sql.queryPage({ sql: `SELECT * FROM ${table.reports} WHERE ${conditions.join(" AND ")} ORDER BY created_at ${direction}, id ${direction}`, params, pagination: { page: query.page, pageSize: query.pageSize } });
768
+ return page.success ? ok({ ...page.data, items: page.data.items.map((row) => repo.report(row)) }) : page;
769
+ },
770
+ async get(id, actor) {
771
+ const report = await access(id, actor);
772
+ if (!report.success)
773
+ return report;
774
+ const files = await attachments(id);
775
+ if (!files.success)
776
+ return files;
777
+ const changes = await history(id);
778
+ if (!changes.success)
779
+ return changes;
780
+ const jobs = await reldb.sql.query(`SELECT * FROM ${table.jobs} WHERE report_id = ? ORDER BY created_at, id`, [id]);
781
+ return jobs.success ? ok({ report: repo.report(report.data), attachments: files.data.map(attachmentData), history: changes.data, jobs: jobs.data.map(jobData) }) : jobs;
782
+ },
783
+ async getEvidence(input, actor) {
784
+ const parsed = FeedbackEvidenceQuerySchema.safeParse(input);
785
+ if (!parsed.success)
786
+ return invalid();
787
+ const accessResult = await access(parsed.data.id, actor);
788
+ if (!accessResult.success)
789
+ return accessResult;
790
+ const query = parsed.data;
791
+ const params = [];
792
+ const conditions = [feedbackTextEquals("report_id", query.id, params), feedbackTextEquals("scope_id", actor.scopeId, params)];
793
+ for (const [column, value] of [["source", query.source], ["kind", query.kind], ["request_id", query.requestId]]) {
794
+ if (value) {
795
+ if (column === "kind") {
796
+ conditions.push(`${column} = ?`);
797
+ params.push(value);
798
+ } else {
799
+ conditions.push(feedbackTextEquals(column, value, params));
800
+ }
801
+ }
802
+ }
803
+ if (query.errorId) {
804
+ const anchorParams = [];
805
+ const anchorIdentity = `${feedbackTextEquals("report_id", query.id, anchorParams)} AND ${feedbackTextEquals("error_id", query.errorId, anchorParams)}`;
806
+ const anchor = await reldb.sql.get(`SELECT * FROM ${table.evidence} WHERE ${anchorIdentity} AND kind = 'error'`, anchorParams);
807
+ if (!anchor.success)
808
+ return anchor;
809
+ if (!anchor.data)
810
+ return ok({ items: [], total: 0, page: query.page ?? 1, pageSize: query.pageSize ?? 20 });
811
+ const contextParams = [];
812
+ const contextIdentity = `${feedbackTextEquals("report_id", query.id, contextParams)} AND ${feedbackTextEquals("source", anchor.data.source, contextParams)}`;
813
+ const request = anchor.data.request_id ? feedbackTextEquals("request_id", anchor.data.request_id, contextParams) : "request_id IS NULL";
814
+ const before = await reldb.sql.query(`SELECT id FROM ${table.evidence} WHERE ${contextIdentity} AND ${request} AND kind IN ('log', 'error') AND sequence <= ? ORDER BY sequence DESC LIMIT 101`, [...contextParams, anchor.data.sequence]);
815
+ if (!before.success)
816
+ return before;
817
+ const after = await reldb.sql.query(`SELECT id FROM ${table.evidence} WHERE ${contextIdentity} AND ${request} AND kind IN ('log', 'error') AND sequence > ? ORDER BY sequence ASC LIMIT 100`, [...contextParams, anchor.data.sequence]);
818
+ if (!after.success)
819
+ return after;
820
+ const ids = [...before.data, ...after.data].map((row) => row.id);
821
+ conditions.push(`id IN (${ids.map(() => "?").join(",")})`);
822
+ params.push(...ids);
823
+ }
824
+ if (query.from !== void 0) {
825
+ conditions.push("timestamp >= ?");
826
+ params.push(query.from);
827
+ }
828
+ if (query.to !== void 0) {
829
+ conditions.push("timestamp <= ?");
830
+ params.push(query.to);
831
+ }
832
+ const page = await reldb.sql.queryPage({ sql: `SELECT * FROM ${table.evidence} WHERE ${conditions.join(" AND ")} ORDER BY timestamp, source, sequence`, params, pagination: { page: query.page, pageSize: query.pageSize } });
833
+ return page.success ? ok({ ...page.data, items: page.data.items.map((row) => FeedbackEvidenceSchema.parse({ ...FeedbackEvidenceInputSchema.parse(readFeedbackJson(row.payload)), feedbackId: query.id, revision: Number(row.revision) })) }) : page;
834
+ },
835
+ async setStatus(input, actor) {
836
+ const parsed = FeedbackSetStatusInputSchema.safeParse(input);
837
+ if (!parsed.success)
838
+ return invalid();
839
+ return repo.transaction(async (tx) => {
840
+ const row = await access(input.id, actor, input.status === "handled" ? "feedback:process" : "feedback:reopen", tx);
841
+ if (!row.success)
842
+ return row;
843
+ if (Number(row.data.version) !== parsed.data.expectedVersion)
844
+ return conflict();
845
+ if (row.data.status === parsed.data.status)
846
+ return ok(repo.report(row.data));
847
+ const now = Date.now();
848
+ const params = [parsed.data.status, now];
849
+ const identity = `${feedbackTextEquals("id", input.id, params)} AND ${feedbackTextEquals("scope_id", actor.scopeId, params)}`;
850
+ params.push(parsed.data.expectedVersion);
851
+ const updated = await tx.execute(`UPDATE ${table.reports} SET status = ?, version = version + 1, updated_at = ? WHERE ${identity} AND version = ?`, params);
852
+ if (!updated.success)
853
+ return updated;
854
+ if (!updated.data.changes)
855
+ return conflict();
856
+ 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 };
857
+ const saved = await tx.execute(`INSERT INTO ${table.history} (id, report_id, payload) VALUES (?, ?, ?)`, [change.id, input.id, JSON.stringify(change)]);
858
+ return saved.success ? ok({ ...repo.report(row.data), status: parsed.data.status, version: parsed.data.expectedVersion + 1, updatedAt: now }) : saved;
859
+ });
860
+ },
861
+ async appendEvidence(input, actor) {
862
+ const parsed = FeedbackAppendEvidenceInputSchema.safeParse(input);
863
+ if (!parsed.success)
864
+ return invalid();
865
+ return repo.transaction(async (tx) => {
866
+ const row = await accessWrite(input.id, actor, tx);
867
+ if (!row.success)
868
+ return row;
869
+ if (Number(row.data.collect_deadline) + 5 * 6e4 < Date.now())
870
+ return conflict();
871
+ return appendInside(tx, row.data, parsed.data.evidence);
872
+ });
873
+ },
874
+ async completeCollection(input, actor) {
875
+ const parsed = FeedbackCompleteInputSchema.safeParse(input);
876
+ if (!parsed.success)
877
+ return invalid();
878
+ const row = await accessWrite(input.id, actor);
879
+ if (!row.success)
880
+ return row;
881
+ if (parsed.data.missing?.length) {
882
+ const saved = await appendInternal(input.id, actor, [{ id: randomUUID(), source: "collection", sequence: Date.now(), kind: "missing", timestamp: Date.now(), data: { missing: parsed.data.missing }, missingReason: "collection_incomplete" }]);
883
+ if (!saved.success)
884
+ return saved;
885
+ }
886
+ 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]);
887
+ return next.success ? ok(repo.report(row.data)) : next;
888
+ },
889
+ async uploadAttachment(input, actor) {
890
+ return upload(input, actor);
891
+ },
892
+ async getAttachment(input, actor) {
893
+ const row = await access(input.id, actor);
894
+ if (!row.success)
895
+ return row;
896
+ const params = [];
897
+ const identity = `${feedbackTextEquals("report_id", input.id, params)} AND ${feedbackTextEquals("client_id", input.attachmentId, params)} AND ${feedbackTextEquals("scope_id", actor.scopeId, params)}`;
898
+ const found = await reldb.sql.get(`SELECT * FROM ${table.attachments} WHERE ${identity}`, params);
899
+ if (!found.success)
900
+ return found;
901
+ if (!found.data || found.data.report_id !== input.id || found.data.scope_id !== actor.scopeId || found.data.client_id !== input.attachmentId || found.data.status !== "available")
902
+ return missing();
903
+ const bytes = await storage.file.get(found.data.object_key);
904
+ if (!bytes.success)
905
+ return bytes;
906
+ if (hash(bytes.data) !== found.data.checksum)
907
+ return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
908
+ const metadata = attachmentData(found.data);
909
+ return ok({ name: metadata.name, contentType: metadata.contentType, dataBase64: bytes.data.toString("base64") });
910
+ },
911
+ async retry(input, actor) {
912
+ const permission = input.target === "export" ? "feedback:export" : "feedback:process";
913
+ const row = await access(input.id, actor, permission);
914
+ if (!row.success)
915
+ return row;
916
+ if (input.target === "attachment") {
917
+ const params = [Date.now()];
918
+ const identity = `${feedbackTextEquals("report_id", input.id, params)} AND ${feedbackTextEquals("client_id", input.targetId ?? "", params)} AND ${feedbackTextEquals("scope_id", actor.scopeId, params)}`;
919
+ const updated2 = await reldb.sql.execute(`UPDATE ${table.attachments} SET status = 'pending', updated_at = ? WHERE ${identity} AND status = 'failed'`, params);
920
+ return updated2.success ? ok(void 0) : updated2;
921
+ }
922
+ const updated = await reldb.sql.execute(`UPDATE ${table.jobs} SET status = 'pending', attempts = 0, next_run_at = ${clock}, last_error = NULL WHERE report_id = ? AND kind = ? AND status IN ('failed', 'pending')`, [input.id, input.target]);
923
+ if (updated.success && input.target === "collect") {
924
+ const resumed = await reldb.sql.execute(`UPDATE ${table.reports} SET collection_status = 'collecting' WHERE id = ? AND deleting = 0 AND EXISTS (SELECT 1 FROM ${table.jobs} WHERE report_id = ? AND kind = 'collect' AND status = 'pending')`, [input.id, input.id]);
925
+ if (!resumed.success)
926
+ return resumed;
927
+ }
928
+ return updated.success ? ok(void 0) : updated;
929
+ },
930
+ async export(input, actor) {
931
+ const parsed = FeedbackExportInputSchema.safeParse(input);
932
+ if (!parsed.success)
933
+ return invalid();
934
+ return repo.transaction(async (tx) => {
935
+ const row = await access(input.id, actor, "feedback:export", tx);
936
+ if (!row.success)
937
+ return row;
938
+ const revision = parsed.data.evidenceRevision ?? Number(row.data.revision);
939
+ if (revision > Number(row.data.revision))
940
+ return conflict();
941
+ const snapshotVersion = revision === Number(row.data.revision) ? " AND snapshot_version = ?" : "";
942
+ const previous = await tx.get(`SELECT * FROM ${table.jobs} WHERE report_id = ? AND kind = 'export' AND snapshot_revision = ?${snapshotVersion} ORDER BY created_at DESC, id DESC LIMIT 1`, [input.id, revision, ...snapshotVersion ? [row.data.version] : []]);
943
+ if (!previous.success)
944
+ return previous;
945
+ if (previous.data) {
946
+ const snapshot = readFeedbackJson(previous.data.payload);
947
+ const live = snapshot && typeof snapshot === "object" && "snapshotExpiresAt" in snapshot && typeof snapshot.snapshotExpiresAt === "number" && snapshot.snapshotExpiresAt > Date.now();
948
+ if (previous.data.status === "succeeded" || live)
949
+ return ok(jobData(previous.data));
950
+ }
951
+ if (revision < Number(row.data.revision)) {
952
+ return missing();
953
+ }
954
+ const events = await evidence(input.id, revision, tx);
955
+ if (!events.success)
956
+ return events;
957
+ const files = await attachments(input.id, tx);
958
+ if (!files.success)
959
+ return files;
960
+ const changes = await history(input.id, tx);
961
+ if (!changes.success)
962
+ return changes;
963
+ const suffix = `${revision}:${row.data.version}${previous.data ? `:${previous.data.id}` : ""}`;
964
+ 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());
965
+ if (!added.success)
966
+ return added;
967
+ const job = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [added.data]);
968
+ return job.success && job.data ? ok(jobData(job.data)) : missing();
969
+ });
970
+ },
971
+ async getExport(input, actor) {
972
+ const row = await access(input.id, actor, "feedback:export");
973
+ if (!row.success)
974
+ return row;
975
+ const params = [];
976
+ const identity = `${feedbackTextEquals("id", input.jobId, params)} AND ${feedbackTextEquals("report_id", input.id, params)} AND ${feedbackTextEquals("scope_id", actor.scopeId, params)} AND ${feedbackTextEquals("app_id", config.appId, params)}`;
977
+ const job = await reldb.sql.get(`SELECT * FROM ${table.jobs} WHERE ${identity} AND kind = 'export'`, params);
978
+ if (!job.success)
979
+ return job;
980
+ if (!job.data)
981
+ return missing();
982
+ if (job.data.status === "pending" || job.data.status === "running") {
983
+ const snapshot = readFeedbackJson(job.data.payload);
984
+ if (!snapshot || typeof snapshot !== "object" || !("snapshotExpiresAt" in snapshot) || typeof snapshot.snapshotExpiresAt !== "number")
985
+ return failed();
986
+ if (snapshot.snapshotExpiresAt < Date.now())
987
+ return missing();
988
+ return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_exportPending"));
989
+ }
990
+ if (job.data.status === "failed")
991
+ return failed();
992
+ const data = readFeedbackJson(job.data.result);
993
+ if (!data || typeof data !== "object" || !("key" in data) || typeof data.key !== "string")
994
+ return missing();
995
+ const bytes = await storage.file.get(data.key);
996
+ if (!bytes.success)
997
+ return bytes;
998
+ if (!("checksum" in data) || typeof data.checksum !== "string" || hash(bytes.data) !== data.checksum)
999
+ return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
1000
+ return ok({ name: `${repo.report(row.data).number}.zip`, contentType: "application/zip", dataBase64: bytes.data.toString("base64") });
1001
+ },
1002
+ async runJobs() {
1003
+ if (stopped)
1004
+ return err(HaiFeedbackError.NOT_INITIALIZED, feedbackM("feedback_notInitialized"));
1005
+ if (running)
1006
+ return running;
1007
+ running = core.context.run({ suppressLogObservers: true }, processJobs).catch(failed).finally(() => {
1008
+ running = null;
1009
+ });
1010
+ return running;
1011
+ }
1012
+ };
1013
+ async function upload(input, actor) {
1014
+ const parsed = FeedbackUploadInputSchema.safeParse(input);
1015
+ if (!parsed.success)
1016
+ return invalid();
1017
+ const row = await accessWrite(input.id, actor);
1018
+ if (!row.success)
1019
+ return row;
1020
+ const params = [];
1021
+ const identity = `${feedbackTextEquals("report_id", input.id, params)} AND ${feedbackTextEquals("client_id", input.attachmentId, params)} AND ${feedbackTextEquals("scope_id", actor.scopeId, params)}`;
1022
+ const found = await reldb.sql.get(`SELECT * FROM ${table.attachments} WHERE ${identity}`, params);
1023
+ if (!found.success)
1024
+ return found;
1025
+ if (!found.data || found.data.report_id !== input.id || found.data.scope_id !== actor.scopeId || found.data.client_id !== input.attachmentId)
1026
+ return missing();
1027
+ const attachment = found.data;
1028
+ const metadata = attachmentData(attachment);
1029
+ const bytes = Buffer.from(parsed.data.dataBase64, "base64");
1030
+ if (bytes.length !== Number(attachment.size) || bytes.length > config.maxAttachmentBytes || !matchesContentType(bytes, metadata.contentType))
1031
+ return invalid();
1032
+ if (hash(bytes) !== attachment.checksum)
1033
+ return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
1034
+ const stored = await storage.file.put(attachment.object_key, bytes, { contentType: metadata.contentType, ifNoneMatch: "*", signal: AbortSignal.timeout(3e4) });
1035
+ if (!stored.success) {
1036
+ const existing = await storage.file.get(attachment.object_key);
1037
+ if (!existing.success || hash(existing.data) !== attachment.checksum) {
1038
+ await reldb.sql.execute(`UPDATE ${table.attachments} SET status = 'failed', updated_at = ? WHERE id = ? AND status <> 'available'`, [Date.now(), attachment.id]);
1039
+ return stored;
1040
+ }
1041
+ }
1042
+ const updated = await repo.transaction(async (tx) => {
1043
+ const live = await accessWrite(input.id, actor, tx);
1044
+ if (!live.success)
1045
+ return live;
1046
+ const currentParams = [];
1047
+ const currentIdentity = `${feedbackTextEquals("id", attachment.id, currentParams)} AND ${feedbackTextEquals("scope_id", actor.scopeId, currentParams)}`;
1048
+ const currentFile = await tx.get(`SELECT * FROM ${table.attachments} WHERE ${currentIdentity}`, currentParams);
1049
+ if (!currentFile.success)
1050
+ return currentFile;
1051
+ if (!currentFile.data || currentFile.data.id !== attachment.id || currentFile.data.report_id !== input.id || currentFile.data.scope_id !== actor.scopeId || currentFile.data.client_id !== input.attachmentId)
1052
+ return missing();
1053
+ if (currentFile.data.status === "available")
1054
+ return ok(void 0);
1055
+ const confirmed = await tx.execute(`UPDATE ${table.attachments} SET status = 'available', updated_at = ${clock} WHERE ${currentIdentity}`, currentParams);
1056
+ if (!confirmed.success)
1057
+ return confirmed;
1058
+ if (!confirmed.data.changes)
1059
+ return conflict();
1060
+ const revised = await tx.execute(`UPDATE ${table.reports} SET revision = revision + 1, updated_at = ${clock} WHERE id = ? AND deleting = 0`, [input.id]);
1061
+ return revised.success && revised.data.changes ? ok(void 0) : conflict();
1062
+ });
1063
+ if (!updated.success) {
1064
+ if (updated.error.code === HaiFeedbackError.NOT_FOUND.code)
1065
+ await storage.file.delete(attachment.object_key);
1066
+ return updated;
1067
+ }
1068
+ return ok({ ...metadata, status: "available" });
1069
+ }
1070
+ async function processJobs() {
1071
+ let processed = 0;
1072
+ const candidateParams = [];
1073
+ const appIdentity = feedbackTextEquals("app_id", config.appId, candidateParams);
1074
+ const candidates = await reldb.sql.query(`SELECT * FROM ${table.jobs} WHERE ${appIdentity} AND ((status = 'pending' AND next_run_at <= ${clock}) OR (status = 'running' AND lease_until < ${clock})) ORDER BY next_run_at, id LIMIT 20`, candidateParams);
1075
+ if (!candidates.success)
1076
+ return candidates;
1077
+ for (const job of candidates.data) {
1078
+ if (stopped)
1079
+ break;
1080
+ if (job.app_id !== config.appId)
1081
+ continue;
1082
+ const token = randomUUID();
1083
+ const claimParams = [token, config.leaseMs, job.id];
1084
+ const claimIdentity = feedbackTextEquals("app_id", config.appId, claimParams);
1085
+ const claimed = await reldb.sql.execute(`UPDATE ${table.jobs} SET status = 'running', token = ?, lease_until = ${clock} + ?, attempts = attempts + 1, updated_at = ${clock} WHERE id = ? AND ${claimIdentity} AND ((status = 'pending' AND next_run_at <= ${clock}) OR (status = 'running' AND lease_until < ${clock}))`, claimParams);
1086
+ if (!claimed.success)
1087
+ return claimed;
1088
+ if (!claimed.data.changes)
1089
+ continue;
1090
+ if (job.kind === "notify")
1091
+ await notificationSummary(job.report_id);
1092
+ const renewal = setInterval(() => {
1093
+ void reldb.sql.execute(`UPDATE ${table.jobs} SET lease_until = ${clock} + ? WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [config.leaseMs, job.id, token]);
1094
+ }, Math.max(250, Math.floor(config.leaseMs / 3)));
1095
+ renewal.unref();
1096
+ try {
1097
+ const result = await executeJob({ ...job, token, attempts: Number(job.attempts) + 1 });
1098
+ const pendingReceipt = result.success && !!result.data && typeof result.data === "object" && "pending" in result.data && result.data.pending === true;
1099
+ const purged = result.success && !!result.data && typeof result.data === "object" && "purged" in result.data && result.data.purged === true;
1100
+ 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]);
1102
+ if (!saved.success)
1103
+ return saved;
1104
+ if (saved.data.changes && status === "failed" && job.kind === "collect") {
1105
+ const terminal = await reldb.sql.execute(`UPDATE ${table.reports} SET collection_status = 'failed', updated_at = ${clock} WHERE id = ? AND deleting = 0 AND EXISTS (SELECT 1 FROM ${table.jobs} WHERE id = ? AND status = 'failed')`, [job.report_id, job.id]);
1106
+ if (!terminal.success)
1107
+ return terminal;
1108
+ }
1109
+ if (!saved.data.changes && job.kind === "export" && result.success && result.data && typeof result.data === "object" && "key" in result.data && typeof result.data.key === "string")
1110
+ await storage.file.delete(result.data.key);
1111
+ if (job.kind === "notify")
1112
+ await notificationSummary(job.report_id);
1113
+ processed++;
1114
+ } catch {
1115
+ await reldb.sql.execute(`UPDATE ${table.jobs} SET status = ?, next_run_at = ${clock} + 60000, last_error = ?, token = NULL, lease_until = 0 WHERE id = ? AND token = ? AND lease_until > ${clock}`, [job.kind === "cleanup" ? "pending" : "failed", HaiFeedbackError.OPERATION_FAILED.code, job.id, token]);
1116
+ if (job.kind === "collect")
1117
+ await reldb.sql.execute(`UPDATE ${table.reports} SET collection_status = 'failed', updated_at = ${clock} WHERE id = ? AND deleting = 0 AND EXISTS (SELECT 1 FROM ${table.jobs} WHERE id = ? AND status = 'failed')`, [job.report_id, job.id]);
1118
+ } finally {
1119
+ clearInterval(renewal);
1120
+ }
1121
+ }
1122
+ const expired = await reldb.sql.execute(`DELETE FROM ${table.evidence} WHERE report_id IS NULL AND expires_at < ?`, [Date.now()]);
1123
+ return expired.success ? ok({ processed }) : expired;
1124
+ }
1125
+ async function executeJob(job) {
1126
+ if (job.app_id !== config.appId)
1127
+ return missing();
1128
+ const owned = await reldb.sql.get(`SELECT id FROM ${table.jobs} WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [job.id, job.token]);
1129
+ if (!owned.success)
1130
+ return owned;
1131
+ if (!owned.data)
1132
+ return conflict();
1133
+ const raw = readFeedbackJson(job.payload);
1134
+ const payload = raw && typeof raw === "object" ? raw : {};
1135
+ const prefix = `feedback/${hash(job.scope_id).slice(0, 16)}/${job.report_id}/`;
1136
+ if (job.kind === "cleanup" && "tombstone" in payload && payload.tombstone === true) {
1137
+ const cleared2 = await storage.dir.delete(prefix);
1138
+ if (!cleared2.success)
1139
+ return cleared2;
1140
+ return "until" in payload && typeof payload.until === "number" && payload.until <= Date.now() ? ok({ purged: true }) : ok({ pending: true });
1141
+ }
1142
+ const row = await repo.find(job.report_id, job.scope_id);
1143
+ if (!row.success)
1144
+ return row;
1145
+ if (!row.data || row.data.id !== job.report_id || row.data.scope_id !== job.scope_id || row.data.app_id !== config.appId)
1146
+ return missing();
1147
+ if (Number(row.data.deleting) && job.kind !== "cleanup")
1148
+ return missing();
1149
+ const report = repo.report(row.data);
1150
+ const actor = { scopeId: report.scopeId, userId: report.userId, permissions: ["feedback:*"] };
1151
+ if (job.kind === "collect") {
1152
+ const anchor = "anchor" in payload && typeof payload.anchor === "number" ? payload.anchor : report.createdAt;
1153
+ const captured = await capture.collect(report.scopeId, report.userId, anchor);
1154
+ const saved = await withLease(job, async (tx) => {
1155
+ const fresh = await access(report.id, actor, void 0, tx);
1156
+ return fresh.success ? appendInside(tx, fresh.data, captured.length ? captured : [{ id: `missing-server-${report.id}`, source: "collection", sequence: 0, kind: "missing", timestamp: report.createdAt, data: { source: "server_logs" }, missingReason: "no_correlated_server_logs" }], true) : fresh;
1157
+ });
1158
+ if (!saved.success)
1159
+ return saved;
1160
+ const files = await attachments(report.id);
1161
+ if (!files.success)
1162
+ return files;
1163
+ const reasons = await reldb.sql.get(`SELECT COUNT(*) AS count FROM ${table.evidence} WHERE report_id = ? AND kind = 'missing'`, [report.id]);
1164
+ if (!reasons.success)
1165
+ return reasons;
1166
+ const partial = Number(reasons.data?.count ?? 0) > 0 || files.data.some((item) => item.status !== "available");
1167
+ const updated = await withLease(job, (tx) => tx.execute(`UPDATE ${table.reports} SET collection_status = ?, updated_at = ${clock} WHERE id = ?`, [partial ? "partial" : "complete", report.id]));
1168
+ return updated.success ? ok({ collected: captured.length }) : updated;
1169
+ }
1170
+ if (job.kind === "notify") {
1171
+ const recipient = "recipient" in payload && typeof payload.recipient === "string" ? payload.recipient : "";
1172
+ if (!config.recipients.includes(recipient))
1173
+ return invalid();
1174
+ if (reach.config?.dnd?.enabled)
1175
+ return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1176
+ const live = await withLease(job, (tx) => access(report.id, actor, void 0, tx));
1177
+ if (!live.success)
1178
+ return live;
1179
+ const current2 = repo.report(live.data);
1180
+ const materialStatus = {
1181
+ collecting: feedbackM("feedback_materialCollecting"),
1182
+ complete: feedbackM("feedback_materialComplete"),
1183
+ partial: feedbackM("feedback_materialPartial"),
1184
+ failed: feedbackM("feedback_materialFailed")
1185
+ }[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 });
1189
+ if (!sent.success)
1190
+ return sent;
1191
+ if (!sent.data.success)
1192
+ return failed();
1193
+ if (sent.data.deferred) {
1194
+ return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1195
+ }
1196
+ return ok({ messageId: sent.data.messageId ?? "" });
1197
+ }
1198
+ if (job.kind === "export") {
1199
+ if (!("report" in payload) || !("evidence" in payload) || !("attachments" in payload))
1200
+ return invalid();
1201
+ const fixed = payload;
1202
+ if (fixed.snapshotExpiresAt < Date.now())
1203
+ return missing();
1204
+ const files = [];
1205
+ const unavailable2 = fixed.attachments.filter((file) => file.status !== "available").map((file) => `attachment:${file.client_id}:${file.status}`).sort();
1206
+ let total = Buffer.byteLength(JSON.stringify(fixed.evidence));
1207
+ for (const file of fixed.attachments) {
1208
+ if (file.status !== "available")
1209
+ continue;
1210
+ const stored2 = await storage.file.get(file.object_key);
1211
+ if (!stored2.success)
1212
+ return stored2;
1213
+ if (hash(stored2.data) !== file.checksum)
1214
+ return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
1215
+ total += stored2.data.length;
1216
+ if (total > 64 * 1024 * 1024)
1217
+ return limited();
1218
+ const info = attachmentData(file);
1219
+ files.push({ id: info.id, name: info.name, contentType: info.contentType, checksum: info.checksum, bytes: stored2.data });
1220
+ }
1221
+ const bundle = await createFeedbackBundle({ report: fixed.report, evidence: fixed.evidence, history: fixed.history, attachments: files, missing: unavailable2 });
1222
+ if (!bundle.success)
1223
+ return bundle;
1224
+ const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/exports/${job.id}-${job.token}.zip`;
1225
+ const stored = await storage.file.put(key, bundle.data, { contentType: "application/zip", ifNoneMatch: "*" });
1226
+ return stored.success ? ok({ key, checksum: hash(bundle.data), size: bundle.data.length }) : stored;
1227
+ }
1228
+ const marked = await withLease(job, async (tx) => {
1229
+ const locked = await repo.find(report.id, report.scopeId, tx);
1230
+ if (!locked.success)
1231
+ return locked;
1232
+ if (!locked.data)
1233
+ return missing();
1234
+ const active = await tx.get(`SELECT COUNT(*) AS count FROM ${table.jobs} WHERE report_id = ? AND kind = 'export' AND status IN ('pending', 'running')`, [report.id]);
1235
+ if (!active.success)
1236
+ return active;
1237
+ if (Number(active.data?.count ?? 0) > 0)
1238
+ return conflict();
1239
+ const updated = await tx.execute(`UPDATE ${table.reports} SET deleting = 1 WHERE id = ? AND expires_at <= ${clock}`, [report.id]);
1240
+ if (!updated.success)
1241
+ return updated;
1242
+ return updated.data.changes || Number(locked.data.deleting) ? ok(void 0) : conflict();
1243
+ });
1244
+ if (!marked.success)
1245
+ return marked;
1246
+ const cleared = await storage.dir.delete(prefix);
1247
+ if (!cleared.success)
1248
+ return cleared;
1249
+ const deleted = await withLease(job, async (tx) => {
1250
+ for (const name of [table.evidence, table.attachments, table.history]) {
1251
+ const removed2 = await tx.execute(`DELETE FROM ${name} WHERE report_id = ?`, [report.id]);
1252
+ if (!removed2.success)
1253
+ return removed2;
1254
+ }
1255
+ const removedJobs = await tx.execute(`DELETE FROM ${table.jobs} WHERE report_id = ? AND id <> ?`, [report.id, job.id]);
1256
+ if (!removedJobs.success)
1257
+ return removedJobs;
1258
+ const tombstone = await tx.execute(`UPDATE ${table.jobs} SET payload = ?, result = NULL WHERE id = ? AND token = ?`, [JSON.stringify({ tombstone: true, prefix, until: Date.now() + 864e5 }), job.id, job.token]);
1259
+ if (!tombstone.success)
1260
+ return tombstone;
1261
+ const params = [];
1262
+ const identity = `${feedbackTextEquals("id", report.id, params)} AND ${feedbackTextEquals("scope_id", actor.scopeId, params)}`;
1263
+ const removed = await tx.execute(`DELETE FROM ${table.reports} WHERE ${identity} AND expires_at <= ${clock}`, params);
1264
+ return removed.success ? ok({ pending: true }) : removed;
1265
+ });
1266
+ return deleted;
1267
+ }
1268
+ return operations;
1269
+ }
1270
+ function allowedContentType(type) {
1271
+ return ["image/png", "image/jpeg", "image/webp", "text/plain", "application/json", "application/pdf"].includes(type);
1272
+ }
1273
+ function matchesContentType(bytes, type) {
1274
+ if (type === "image/png")
1275
+ 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;
1276
+ if (type === "image/jpeg")
1277
+ return bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
1278
+ if (type === "image/webp")
1279
+ return bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WEBP";
1280
+ if (type === "application/pdf")
1281
+ return bytes.subarray(0, 5).toString() === "%PDF-";
1282
+ if (type === "application/json") {
1283
+ try {
1284
+ JSON.parse(bytes.toString("utf8"));
1285
+ return true;
1286
+ } catch {
1287
+ return false;
1288
+ }
1289
+ }
1290
+ return type === "text/plain" && !bytes.includes(0);
1291
+ }
1292
+
1293
+ // src/feedback-main.ts
1294
+ var notInitialized = core.module.createNotInitializedKit(HaiFeedbackError.NOT_INITIALIZED, () => feedbackM("feedback_notInitialized"));
1295
+ var unavailable = notInitialized.proxy();
1296
+ var current = null;
1297
+ var generation = 0;
1298
+ var initializing = false;
1299
+ var inFlight = /* @__PURE__ */ new Set();
1300
+ var feedback = {
1301
+ async init(config) {
1302
+ if (initializing)
1303
+ return err(HaiFeedbackError.INIT_IN_PROGRESS, feedbackM("feedback_initInProgress"));
1304
+ initializing = true;
1305
+ const epoch = ++generation;
1306
+ let candidateCapture;
1307
+ try {
1308
+ 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"));
1315
+ if (current) {
1316
+ const previous = current;
1317
+ current = null;
1318
+ await Promise.allSettled([...inFlight]);
1319
+ await previous.operations.close();
1320
+ await previous.capture.close();
1321
+ }
1322
+ const repository = new FeedbackReportRepository();
1323
+ const ready = await repository.ready();
1324
+ if (!ready.success)
1325
+ return ready;
1326
+ if (epoch !== generation)
1327
+ return notInitialized.result();
1328
+ candidateCapture = createFeedbackCapture(parsed.data.appId, parsed.data.envAllowlist, { appVersion: parsed.data.appVersion, buildId: parsed.data.buildId, commit: parsed.data.commit });
1329
+ const operations = createFeedbackOperations(repository, parsed.data, candidateCapture);
1330
+ if (epoch !== generation) {
1331
+ await operations.close();
1332
+ await candidateCapture.close();
1333
+ return notInitialized.result();
1334
+ }
1335
+ current = { operations, capture: candidateCapture, config: parsed.data };
1336
+ operations.start();
1337
+ core.logger.info("Feedback module initialized", { module: "feedback", appId: parsed.data.appId });
1338
+ return ok(void 0);
1339
+ } catch {
1340
+ if (candidateCapture)
1341
+ await candidateCapture.close();
1342
+ return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
1343
+ } finally {
1344
+ initializing = false;
1345
+ }
1346
+ },
1347
+ async close() {
1348
+ generation++;
1349
+ const previous = current;
1350
+ current = null;
1351
+ await Promise.allSettled([...inFlight]);
1352
+ if (!previous)
1353
+ return ok(void 0);
1354
+ try {
1355
+ await previous.operations.close();
1356
+ await previous.capture.close();
1357
+ return ok(void 0);
1358
+ } catch {
1359
+ return err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));
1360
+ }
1361
+ },
1362
+ get isInitialized() {
1363
+ return current !== null;
1364
+ },
1365
+ get config() {
1366
+ return current ? { ...current.config, recipients: [...current.config.recipients], envAllowlist: [...current.config.envAllowlist] } : null;
1367
+ },
1368
+ submit: (input, actor) => guarded(() => (current?.operations ?? unavailable).submit(input, actor)),
1369
+ list: (input, actor) => guarded(() => (current?.operations ?? unavailable).list(input, actor)),
1370
+ get: (id, actor) => guarded(() => (current?.operations ?? unavailable).get(id, actor)),
1371
+ getEvidence: (input, actor) => guarded(() => (current?.operations ?? unavailable).getEvidence(input, actor)),
1372
+ setStatus: (input, actor) => guarded(() => (current?.operations ?? unavailable).setStatus(input, actor)),
1373
+ appendEvidence: (input, actor) => guarded(() => (current?.operations ?? unavailable).appendEvidence(input, actor)),
1374
+ completeCollection: (input, actor) => guarded(() => (current?.operations ?? unavailable).completeCollection(input, actor)),
1375
+ uploadAttachment: (input, actor) => guarded(() => (current?.operations ?? unavailable).uploadAttachment(input, actor)),
1376
+ getAttachment: (input, actor) => guarded(() => (current?.operations ?? unavailable).getAttachment(input, actor)),
1377
+ retry: (input, actor) => guarded(() => (current?.operations ?? unavailable).retry(input, actor)),
1378
+ export: (input, actor) => guarded(() => (current?.operations ?? unavailable).export(input, actor)),
1379
+ getExport: (input, actor) => guarded(() => (current?.operations ?? unavailable).getExport(input, actor)),
1380
+ runJobs: () => guarded(() => (current?.operations ?? unavailable).runJobs())
1381
+ };
1382
+ async function guarded(work) {
1383
+ const promise = core.context.run({ suppressLogObservers: true }, work);
1384
+ inFlight.add(promise);
1385
+ try {
1386
+ return await promise;
1387
+ } catch {
1388
+ return err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));
1389
+ } finally {
1390
+ inFlight.delete(promise);
1391
+ }
1392
+ }
1393
+
1394
+ export { FeedbackConfigSchema, feedback };
1395
+ //# sourceMappingURL=index.js.map
1396
+ //# sourceMappingURL=index.js.map