@h-ai/feedback 1.0.0-beta.2 → 1.0.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-7W47WZXY.js';
2
2
  export { HaiFeedbackError } from './chunk-7W47WZXY.js';
3
- import { createFeedbackBundle } from './chunk-KGMLTOMM.js';
3
+ import { createFeedbackBundle } from './chunk-IFJHL5VK.js';
4
4
  import { z } from 'zod';
5
5
  import { resolveFeedbackBudget, FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackRetryInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema, FeedbackAttachmentInputSchema } from '@h-ai/api-contract/feedback';
6
6
  import { core, ok, err } from '@h-ai/core';
@@ -76,8 +76,13 @@ var FeedbackConfigSchema = z.strictObject({
76
76
  /** 单份反馈累计证据允许的最大条数。 */
77
77
  maxEvidenceCount: z.number().int().min(1).max(1e4).default(2e3)
78
78
  }).superRefine((config, context) => {
79
- if (config.notificationMode === "reach" && !config.recipients.length)
80
- context.addIssue({ code: "custom", path: ["recipients"], message: "Reach recipients are required" });
79
+ if (config.notificationMode === "reach" && !config.recipients.length) {
80
+ context.addIssue({
81
+ code: "custom",
82
+ path: ["recipients"],
83
+ message: "Reach recipients are required"
84
+ });
85
+ }
81
86
  });
82
87
  var REPORTS = "hai_feedback_reports";
83
88
  var EVIDENCE = "hai_feedback_evidence";
@@ -85,7 +90,17 @@ var ATTACHMENTS = "hai_feedback_attachments";
85
90
  var HISTORY = "hai_feedback_history";
86
91
  var JOBS = "hai_feedback_jobs";
87
92
  var QUOTAS = "hai_feedback_quotas";
88
- var identityColumns = ["id", "report_id", "scope_id", "app_id", "user_id", "source", "request_id", "error_id", "client_id"];
93
+ var identityColumns = [
94
+ "id",
95
+ "report_id",
96
+ "scope_id",
97
+ "app_id",
98
+ "user_id",
99
+ "source",
100
+ "request_id",
101
+ "error_id",
102
+ "client_id"
103
+ ];
89
104
  function feedbackTextEquals(column, value, params) {
90
105
  if (!identityColumns.includes(column))
91
106
  throw new Error("Invalid feedback identity column");
@@ -123,11 +138,25 @@ function readFeedbackJson(value) {
123
138
  return typeof value === "string" ? JSON.parse(value) : value;
124
139
  }
125
140
  var FeedbackReportRepository = class extends BaseReldbCrudRepository {
126
- tables = { reports: REPORTS, evidence: EVIDENCE, attachments: ATTACHMENTS, history: HISTORY, jobs: JOBS, quotas: QUOTAS };
141
+ tables = {
142
+ reports: REPORTS,
143
+ evidence: EVIDENCE,
144
+ attachments: ATTACHMENTS,
145
+ history: HISTORY,
146
+ jobs: JOBS,
147
+ quotas: QUOTAS
148
+ };
127
149
  constructor() {
128
150
  super(reldb, {
129
151
  table: REPORTS,
130
- fields: Object.entries(reportDef).map(([name, def]) => ({ fieldName: name, columnName: name, def, select: true, create: true, update: name !== "id" }))
152
+ fields: Object.entries(reportDef).map(([name, def]) => ({
153
+ fieldName: name,
154
+ columnName: name,
155
+ def,
156
+ select: true,
157
+ create: true,
158
+ update: name !== "id"
159
+ }))
131
160
  });
132
161
  }
133
162
  async ready() {
@@ -135,67 +164,86 @@ var FeedbackReportRepository = class extends BaseReldbCrudRepository {
135
164
  if (!report.success)
136
165
  return report;
137
166
  const definitions = [
138
- [QUOTAS, {
139
- id: { type: "TEXT", primaryKey: true },
140
- app_id: { type: "TEXT", notNull: true },
141
- scope_id: { type: "TEXT", notNull: true },
142
- user_id: { type: "TEXT", notNull: true },
143
- period: { type: "BIGINT", notNull: true },
144
- report_count: { type: "BIGINT", notNull: true },
145
- reserved_bytes: { type: "BIGINT", notNull: true },
146
- report_bytes: { type: "BIGINT", notNull: true, defaultValue: 0 },
147
- export_limit: { type: "INTEGER", notNull: true, defaultValue: 0 },
148
- updated_at: { type: "BIGINT", notNull: true }
149
- }],
150
- [EVIDENCE, {
151
- id: { type: "TEXT", primaryKey: true },
152
- report_id: { type: "TEXT" },
153
- scope_id: { type: "TEXT", notNull: true },
154
- user_id: { type: "TEXT", notNull: true },
155
- app_id: { type: "TEXT", notNull: true },
156
- source: { type: "TEXT", notNull: true },
157
- sequence: { type: "BIGINT", notNull: true },
158
- kind: { type: "TEXT", notNull: true },
159
- timestamp: { type: "BIGINT", notNull: true },
160
- request_id: { type: "TEXT" },
161
- error_id: { type: "TEXT" },
162
- revision: { type: "INTEGER", notNull: true },
163
- bytes: { type: "INTEGER", notNull: true },
164
- expires_at: { type: "BIGINT", notNull: true },
165
- payload: { type: "JSON", notNull: true }
166
- }],
167
- [ATTACHMENTS, {
168
- id: { type: "TEXT", primaryKey: true },
169
- client_id: { type: "TEXT", notNull: true },
170
- report_id: { type: "TEXT", notNull: true },
171
- scope_id: { type: "TEXT", notNull: true },
172
- object_key: { type: "TEXT", notNull: true },
173
- status: { type: "TEXT", notNull: true },
174
- size: { type: "BIGINT", notNull: true },
175
- checksum: { type: "TEXT", notNull: true },
176
- updated_at: { type: "BIGINT", notNull: true },
177
- payload: { type: "JSON", notNull: true }
178
- }],
179
- [HISTORY, { id: { type: "TEXT", primaryKey: true }, report_id: { type: "TEXT", notNull: true }, payload: { type: "JSON", notNull: true } }],
180
- [JOBS, {
181
- id: { type: "TEXT", primaryKey: true },
182
- app_id: { type: "TEXT", notNull: true },
183
- snapshot_revision: { type: "INTEGER" },
184
- snapshot_version: { type: "INTEGER" },
185
- report_id: { type: "TEXT", notNull: true },
186
- scope_id: { type: "TEXT", notNull: true },
187
- kind: { type: "TEXT", notNull: true },
188
- status: { type: "TEXT", notNull: true },
189
- token: { type: "TEXT" },
190
- lease_until: { type: "BIGINT", notNull: true },
191
- attempts: { type: "INTEGER", notNull: true },
192
- next_run_at: { type: "BIGINT", notNull: true },
193
- created_at: { type: "BIGINT", notNull: true },
194
- updated_at: { type: "BIGINT", notNull: true },
195
- last_error: { type: "TEXT" },
196
- payload: { type: "JSON", notNull: true },
197
- result: { type: "JSON" }
198
- }]
167
+ [
168
+ QUOTAS,
169
+ {
170
+ id: { type: "TEXT", primaryKey: true },
171
+ app_id: { type: "TEXT", notNull: true },
172
+ scope_id: { type: "TEXT", notNull: true },
173
+ user_id: { type: "TEXT", notNull: true },
174
+ period: { type: "BIGINT", notNull: true },
175
+ report_count: { type: "BIGINT", notNull: true },
176
+ reserved_bytes: { type: "BIGINT", notNull: true },
177
+ report_bytes: { type: "BIGINT", notNull: true, defaultValue: 0 },
178
+ export_limit: { type: "INTEGER", notNull: true, defaultValue: 0 },
179
+ updated_at: { type: "BIGINT", notNull: true }
180
+ }
181
+ ],
182
+ [
183
+ EVIDENCE,
184
+ {
185
+ id: { type: "TEXT", primaryKey: true },
186
+ report_id: { type: "TEXT" },
187
+ scope_id: { type: "TEXT", notNull: true },
188
+ user_id: { type: "TEXT", notNull: true },
189
+ app_id: { type: "TEXT", notNull: true },
190
+ source: { type: "TEXT", notNull: true },
191
+ sequence: { type: "BIGINT", notNull: true },
192
+ kind: { type: "TEXT", notNull: true },
193
+ timestamp: { type: "BIGINT", notNull: true },
194
+ request_id: { type: "TEXT" },
195
+ error_id: { type: "TEXT" },
196
+ revision: { type: "INTEGER", notNull: true },
197
+ bytes: { type: "INTEGER", notNull: true },
198
+ expires_at: { type: "BIGINT", notNull: true },
199
+ payload: { type: "JSON", notNull: true }
200
+ }
201
+ ],
202
+ [
203
+ ATTACHMENTS,
204
+ {
205
+ id: { type: "TEXT", primaryKey: true },
206
+ client_id: { type: "TEXT", notNull: true },
207
+ report_id: { type: "TEXT", notNull: true },
208
+ scope_id: { type: "TEXT", notNull: true },
209
+ object_key: { type: "TEXT", notNull: true },
210
+ status: { type: "TEXT", notNull: true },
211
+ size: { type: "BIGINT", notNull: true },
212
+ checksum: { type: "TEXT", notNull: true },
213
+ updated_at: { type: "BIGINT", notNull: true },
214
+ payload: { type: "JSON", notNull: true }
215
+ }
216
+ ],
217
+ [
218
+ HISTORY,
219
+ {
220
+ id: { type: "TEXT", primaryKey: true },
221
+ report_id: { type: "TEXT", notNull: true },
222
+ payload: { type: "JSON", notNull: true }
223
+ }
224
+ ],
225
+ [
226
+ JOBS,
227
+ {
228
+ id: { type: "TEXT", primaryKey: true },
229
+ app_id: { type: "TEXT", notNull: true },
230
+ snapshot_revision: { type: "INTEGER" },
231
+ snapshot_version: { type: "INTEGER" },
232
+ report_id: { type: "TEXT", notNull: true },
233
+ scope_id: { type: "TEXT", notNull: true },
234
+ kind: { type: "TEXT", notNull: true },
235
+ status: { type: "TEXT", notNull: true },
236
+ token: { type: "TEXT" },
237
+ lease_until: { type: "BIGINT", notNull: true },
238
+ attempts: { type: "INTEGER", notNull: true },
239
+ next_run_at: { type: "BIGINT", notNull: true },
240
+ created_at: { type: "BIGINT", notNull: true },
241
+ updated_at: { type: "BIGINT", notNull: true },
242
+ last_error: { type: "TEXT" },
243
+ payload: { type: "JSON", notNull: true },
244
+ result: { type: "JSON" }
245
+ }
246
+ ]
199
247
  ];
200
248
  for (const [table, definition] of definitions) {
201
249
  const created = await reldb.ddl.createTable(table, definition);
@@ -203,16 +251,56 @@ var FeedbackReportRepository = class extends BaseReldbCrudRepository {
203
251
  return created;
204
252
  }
205
253
  const indexes = [
206
- { table: QUOTAS, name: "hai_feedback_quotas_expiry", columns: ["app_id", "period"] },
207
- { table: REPORTS, name: "hai_feedback_reports_scope_time", columns: ["scope_id", "created_at"] },
208
- { table: REPORTS, name: "hai_feedback_reports_scope_status", columns: ["scope_id", "status", "created_at"] },
209
- { table: EVIDENCE, name: "hai_feedback_evidence_report_seq", columns: ["report_id", "source", "sequence"] },
210
- { table: EVIDENCE, name: "hai_feedback_evidence_temp_expiry", columns: ["app_id", "report_id", "expires_at"] },
211
- { table: EVIDENCE, name: "hai_feedback_evidence_temp_scope", columns: ["scope_id", "user_id", "timestamp"] },
212
- { table: ATTACHMENTS, name: "hai_feedback_attachments_report", columns: ["report_id"] },
213
- { table: HISTORY, name: "hai_feedback_history_report", columns: ["report_id"] },
214
- { table: JOBS, name: "hai_feedback_jobs_pending", columns: ["app_id", "status", "next_run_at"] },
215
- { table: JOBS, name: "hai_feedback_jobs_snapshot", columns: ["report_id", "snapshot_revision", "snapshot_version"] }
254
+ {
255
+ table: QUOTAS,
256
+ name: "hai_feedback_quotas_expiry",
257
+ columns: ["app_id", "period"]
258
+ },
259
+ {
260
+ table: REPORTS,
261
+ name: "hai_feedback_reports_scope_time",
262
+ columns: ["scope_id", "created_at"]
263
+ },
264
+ {
265
+ table: REPORTS,
266
+ name: "hai_feedback_reports_scope_status",
267
+ columns: ["scope_id", "status", "created_at"]
268
+ },
269
+ {
270
+ table: EVIDENCE,
271
+ name: "hai_feedback_evidence_report_seq",
272
+ columns: ["report_id", "source", "sequence"]
273
+ },
274
+ {
275
+ table: EVIDENCE,
276
+ name: "hai_feedback_evidence_temp_expiry",
277
+ columns: ["app_id", "report_id", "expires_at"]
278
+ },
279
+ {
280
+ table: EVIDENCE,
281
+ name: "hai_feedback_evidence_temp_scope",
282
+ columns: ["scope_id", "user_id", "timestamp"]
283
+ },
284
+ {
285
+ table: ATTACHMENTS,
286
+ name: "hai_feedback_attachments_report",
287
+ columns: ["report_id"]
288
+ },
289
+ {
290
+ table: HISTORY,
291
+ name: "hai_feedback_history_report",
292
+ columns: ["report_id"]
293
+ },
294
+ {
295
+ table: JOBS,
296
+ name: "hai_feedback_jobs_pending",
297
+ columns: ["app_id", "status", "next_run_at"]
298
+ },
299
+ {
300
+ table: JOBS,
301
+ name: "hai_feedback_jobs_snapshot",
302
+ columns: ["report_id", "snapshot_revision", "snapshot_version"]
303
+ }
216
304
  ];
217
305
  for (const index of indexes) {
218
306
  const inspect = async () => {
@@ -360,7 +448,23 @@ function createFeedbackCapture(appId, envAllowlist, service) {
360
448
  if (additions.length) {
361
449
  const params = additions.flatMap(([key, item]) => {
362
450
  const event = item.event;
363
- return [key, null, item.scopeId, item.userId, appId, event.source, event.sequence, event.kind, event.timestamp, event.requestId ?? null, event.errorId ?? null, 1, item.bytes, event.timestamp + 5 * 6e4, JSON.stringify(event)];
451
+ return [
452
+ key,
453
+ null,
454
+ item.scopeId,
455
+ item.userId,
456
+ appId,
457
+ event.source,
458
+ event.sequence,
459
+ event.kind,
460
+ event.timestamp,
461
+ event.requestId ?? null,
462
+ event.errorId ?? null,
463
+ 1,
464
+ item.bytes,
465
+ event.timestamp + 5 * 6e4,
466
+ JSON.stringify(event)
467
+ ];
364
468
  });
365
469
  const duplicate = reldb.config?.type === "mysql" ? "ON DUPLICATE KEY UPDATE id = id" : "ON CONFLICT (id) DO NOTHING";
366
470
  const result = await reldb.sql.execute(`INSERT INTO hai_feedback_evidence (id, report_id, scope_id, user_id, app_id, source, sequence, kind, timestamp, request_id, error_id, revision, bytes, expires_at, payload) VALUES ${additions.map(() => "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").join(",")} ${duplicate}`, params);
@@ -389,15 +493,34 @@ function createFeedbackCapture(appId, envAllowlist, service) {
389
493
  if (capacityLoss && capacityLoss.timestamp < Date.now() - 5 * 6e4)
390
494
  capacityLoss = void 0;
391
495
  const requestId = item.requestId ?? item.event?.requestId;
392
- const key = JSON.stringify([item.scopeId, item.userId, requestId, reason]);
496
+ const key = JSON.stringify([
497
+ item.scopeId,
498
+ item.userId,
499
+ requestId,
500
+ reason
501
+ ]);
393
502
  if (losses.has(key))
394
503
  return;
395
- const event = { id: randomUUID(), source, sequence: sequence++, kind: "missing", timestamp: Date.now(), requestId, data: { source: "server_logs" }, missingReason: reason };
504
+ const event = {
505
+ id: randomUUID(),
506
+ source,
507
+ sequence: sequence++,
508
+ kind: "missing",
509
+ timestamp: Date.now(),
510
+ requestId,
511
+ data: { source: "server_logs" },
512
+ missingReason: reason
513
+ };
396
514
  if (losses.size >= 200) {
397
515
  capacityLoss ??= { ...event, requestId: void 0, missingReason: "server_capture_capacity_exceeded" };
398
516
  return;
399
517
  }
400
- const entry = { event, scopeId: item.scopeId, userId: item.userId, bytes: Buffer.byteLength(JSON.stringify(event)) };
518
+ const entry = {
519
+ event,
520
+ scopeId: item.scopeId,
521
+ userId: item.userId,
522
+ bytes: Buffer.byteLength(JSON.stringify(event))
523
+ };
401
524
  losses.set(key, entry);
402
525
  if (pendingBytes + entry.bytes <= 8 * 1024 * 1024) {
403
526
  pending.set(createHash("sha256").update(`${source}:${event.sequence}`).digest("hex"), entry);
@@ -436,7 +559,15 @@ function createFeedbackCapture(appId, envAllowlist, service) {
436
559
  if (typeof scopeId !== "string" || typeof userId !== "string" || typeof requestId !== "string")
437
560
  return;
438
561
  const value = sanitizeFeedbackValue({ message: record.message, level: record.level, context: record.context });
439
- const event = FeedbackEvidenceInputSchema.parse({ id: randomUUID(), source, sequence: sequence++, kind: record.level === "error" || record.level === "fatal" ? "error" : "log", timestamp: record.timestamp, requestId, data: value });
562
+ const event = FeedbackEvidenceInputSchema.parse({
563
+ id: randomUUID(),
564
+ source,
565
+ sequence: sequence++,
566
+ kind: record.level === "error" || record.level === "fatal" ? "error" : "log",
567
+ timestamp: record.timestamp,
568
+ requestId,
569
+ data: value
570
+ });
440
571
  const bytes = Buffer.byteLength(JSON.stringify(event));
441
572
  if (bytes > 65536) {
442
573
  rememberLoss({ scopeId, userId, requestId }, "server_log_record_too_large");
@@ -505,14 +636,20 @@ function createFeedbackCapture(appId, envAllowlist, service) {
505
636
  }) : [];
506
637
  for (const event of saved) {
507
638
  const key = `${event.source}:${event.sequence}`;
508
- if (!events.has(key))
639
+ if (!events.has(key)) {
509
640
  events.set(key, event.kind === "error" ? { ...event, errorId: event.id } : event);
641
+ }
510
642
  }
511
- const pinnedRequests = new Set([...events.values()].filter((item) => item.errorId).map((item) => item.requestId));
643
+ const pinnedRequests = new Set([
644
+ ...events.values()
645
+ ].filter((item) => item.errorId).map((item) => item.requestId));
512
646
  const local = buffer.filter((item) => item.scopeId === scopeId && item.userId === userId && item.event.timestamp >= anchor - 5 * 6e4 && item.event.timestamp <= anchor + 3e4);
513
647
  const before = local.filter((item) => item.event.timestamp <= anchor).slice(-100);
514
648
  const after = local.filter((item) => item.event.timestamp > anchor).slice(0, 100);
515
- for (const item of [...before, ...after]) {
649
+ for (const item of [
650
+ ...before,
651
+ ...after
652
+ ]) {
516
653
  if (!pinnedRequests.has(item.event.requestId))
517
654
  events.set(`${item.event.source}:${item.event.sequence}`, item.event);
518
655
  }
@@ -528,18 +665,53 @@ function createFeedbackCapture(appId, envAllowlist, service) {
528
665
  events.set(`${capacityLoss.source}:${capacityLoss.sequence}`, capacityLoss);
529
666
  if (events.size > 1e3) {
530
667
  rememberLoss({ scopeId, userId }, "server_evidence_truncated");
531
- const marker = losses.get(JSON.stringify([scopeId, userId, void 0, "server_evidence_truncated"]))?.event;
668
+ const marker = losses.get(JSON.stringify([
669
+ scopeId,
670
+ userId,
671
+ void 0,
672
+ "server_evidence_truncated"
673
+ ]))?.event;
532
674
  if (marker)
533
675
  events.set(`${marker.source}:${marker.sequence}`, marker);
534
676
  }
535
- const missing2 = [...events.values()].filter((item) => item.kind === "missing").slice(-1e3);
536
- const logs = [...events.values()].filter((item) => item.kind !== "missing").sort((a, b) => a.timestamp - b.timestamp);
677
+ const missing2 = [
678
+ ...events.values()
679
+ ].filter((item) => item.kind === "missing").slice(-1e3);
680
+ const logs = [
681
+ ...events.values()
682
+ ].filter((item) => item.kind !== "missing").sort((a, b) => a.timestamp - b.timestamp);
537
683
  const allowance = 1e3 - missing2.length;
538
- return [...allowance > 0 ? logs.slice(-allowance) : [], ...missing2].sort((a, b) => a.timestamp - b.timestamp);
684
+ return [
685
+ ...allowance > 0 ? logs.slice(-allowance) : [],
686
+ ...missing2
687
+ ].sort((a, b) => a.timestamp - b.timestamp);
539
688
  },
540
689
  environment() {
541
690
  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]));
542
- 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 }) });
691
+ return FeedbackEvidenceInputSchema.parse({
692
+ id: randomUUID(),
693
+ source,
694
+ sequence: sequence++,
695
+ kind: "environment",
696
+ timestamp: Date.now(),
697
+ data: sanitizeFeedbackValue({
698
+ appId,
699
+ service,
700
+ os: {
701
+ platform: platform(),
702
+ release: release(),
703
+ arch: arch()
704
+ },
705
+ runtime: {
706
+ name: "node",
707
+ version: process.version,
708
+ uptimeSeconds: Math.floor(process.uptime()),
709
+ memoryBytes: process.memoryUsage().rss,
710
+ totalMemoryBytes: totalmem()
711
+ },
712
+ environment: env
713
+ })
714
+ });
543
715
  },
544
716
  async close() {
545
717
  stopped = true;
@@ -586,7 +758,13 @@ function validActor(actor) {
586
758
  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;
587
759
  }
588
760
  function escapeHtml(value) {
589
- return value.replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character] ?? character);
761
+ return value.replace(/[&<>"']/g, (character) => ({
762
+ "&": "&amp;",
763
+ "<": "&lt;",
764
+ ">": "&gt;",
765
+ '"': "&quot;",
766
+ "'": "&#39;"
767
+ })[character] ?? character);
590
768
  }
591
769
  function createFeedbackOperations(repo, config, capture, options = {}) {
592
770
  const budget = resolveFeedbackBudget(config);
@@ -643,21 +821,58 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
643
821
  const day = Math.floor(now / 864e5) * 864e5;
644
822
  const bytes = budget.maxTotalBytes * (config.maxExportsPerReport + 1);
645
823
  const buckets = [
646
- { id: scopeQuotaId(actor.scopeId), period: 0, maxReports: Number.MAX_SAFE_INTEGER, maxBytes: config.maxStoredBytesPerScope },
647
- { id: hash(JSON.stringify([config.appId, actor.scopeId, actor.userId, day])), period: day, maxReports: config.maxReportsPerUserDay, maxBytes: config.maxBytesPerUserDay }
824
+ {
825
+ id: scopeQuotaId(actor.scopeId),
826
+ period: 0,
827
+ maxReports: Number.MAX_SAFE_INTEGER,
828
+ maxBytes: config.maxStoredBytesPerScope
829
+ },
830
+ {
831
+ id: hash(JSON.stringify([
832
+ config.appId,
833
+ actor.scopeId,
834
+ actor.userId,
835
+ day
836
+ ])),
837
+ period: day,
838
+ maxReports: config.maxReportsPerUserDay,
839
+ maxBytes: config.maxBytesPerUserDay
840
+ }
648
841
  ];
649
842
  for (const bucket of buckets) {
650
843
  const suffix = reldb.config?.type === "mysql" ? "ON DUPLICATE KEY UPDATE id = id" : "ON CONFLICT (id) DO NOTHING";
651
- const inserted = await tx.execute(`INSERT INTO ${table.quotas} (id, app_id, scope_id, user_id, period, report_count, reserved_bytes, updated_at) VALUES (?, ?, ?, ?, ?, 0, 0, ?) ${suffix}`, [bucket.id, config.appId, actor.scopeId, bucket.period === 0 ? "" : actor.userId, bucket.period, now]);
844
+ const inserted = await tx.execute(`INSERT INTO ${table.quotas} (id, app_id, scope_id, user_id, period, report_count, reserved_bytes, updated_at) VALUES (?, ?, ?, ?, ?, 0, 0, ?) ${suffix}`, [
845
+ bucket.id,
846
+ config.appId,
847
+ actor.scopeId,
848
+ bucket.period === 0 ? "" : actor.userId,
849
+ bucket.period,
850
+ now
851
+ ]);
652
852
  if (!inserted.success)
653
853
  return inserted;
654
- const reserved = await tx.execute(`UPDATE ${table.quotas} SET report_count = report_count + 1, reserved_bytes = reserved_bytes + ?, updated_at = ? WHERE id = ? AND report_count < ? AND reserved_bytes <= ?`, [bytes, now, bucket.id, bucket.maxReports, bucket.maxBytes - bytes]);
854
+ const reserved = await tx.execute(`UPDATE ${table.quotas} SET report_count = report_count + 1, reserved_bytes = reserved_bytes + ?, updated_at = ? WHERE id = ? AND report_count < ? AND reserved_bytes <= ?`, [
855
+ bytes,
856
+ now,
857
+ bucket.id,
858
+ bucket.maxReports,
859
+ bucket.maxBytes - bytes
860
+ ]);
655
861
  if (!reserved.success)
656
862
  return reserved;
657
863
  if (!reserved.data.changes)
658
864
  return quotaExceeded();
659
865
  }
660
- return tx.execute(`INSERT INTO ${table.quotas} (id, app_id, scope_id, user_id, period, report_count, reserved_bytes, report_bytes, export_limit, updated_at) VALUES (?, ?, ?, ?, -1, 1, ?, ?, ?, ?)`, [reportId, config.appId, actor.scopeId, actor.userId, bytes, budget.maxTotalBytes, config.maxExportsPerReport, now]);
866
+ return tx.execute(`INSERT INTO ${table.quotas} (id, app_id, scope_id, user_id, period, report_count, reserved_bytes, report_bytes, export_limit, updated_at) VALUES (?, ?, ?, ?, -1, 1, ?, ?, ?, ?)`, [
867
+ reportId,
868
+ config.appId,
869
+ actor.scopeId,
870
+ actor.userId,
871
+ bytes,
872
+ budget.maxTotalBytes,
873
+ config.maxExportsPerReport,
874
+ now
875
+ ]);
661
876
  }
662
877
  async function access(id, actor, permission, sql = reldb.sql) {
663
878
  if (!validActor(actor))
@@ -667,8 +882,9 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
667
882
  return result;
668
883
  if (!result.data || result.data.id !== id || result.data.scope_id !== actor.scopeId || result.data.app_id !== config.appId || Number(result.data.deleting))
669
884
  return missing();
670
- if (permission ? !permitted(actor, permission) : result.data.user_id !== actor.userId && !permitted(actor, "feedback:read"))
885
+ if (permission ? !permitted(actor, permission) : result.data.user_id !== actor.userId && !permitted(actor, "feedback:read")) {
671
886
  return forbidden();
887
+ }
672
888
  return ok(result.data);
673
889
  }
674
890
  async function accessWrite(id, actor, sql = reldb.sql) {
@@ -685,15 +901,47 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
685
901
  if (previous.data)
686
902
  return ok(id);
687
903
  const now = Date.now();
688
- 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]);
904
+ 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} + ?, ?, ?, ?, ?, ?)`, [
905
+ id,
906
+ config.appId,
907
+ kind === "export" ? report.evidenceRevision : null,
908
+ kind === "export" ? report.version : null,
909
+ report.id,
910
+ report.scopeId,
911
+ kind,
912
+ "pending",
913
+ null,
914
+ 0,
915
+ 0,
916
+ Math.max(0, nextRun - now),
917
+ now,
918
+ now,
919
+ null,
920
+ JSON.stringify(payload),
921
+ null
922
+ ]);
689
923
  return inserted.success ? ok(id) : inserted;
690
924
  }
691
925
  function jobData(row) {
692
926
  const result = readFeedbackJson(row.result);
693
- 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 });
927
+ return FeedbackJobSchema.parse({
928
+ id: row.id,
929
+ feedbackId: row.report_id,
930
+ type: row.kind,
931
+ status: row.status,
932
+ attempts: Number(row.attempts),
933
+ createdAt: Number(row.created_at),
934
+ updatedAt: Number(row.updated_at),
935
+ nextRunAt: Number(row.next_run_at),
936
+ lastError: row.last_error ?? void 0,
937
+ result: result && typeof result === "object" ? { available: true } : void 0
938
+ });
694
939
  }
695
940
  function attachmentData(row) {
696
- return FeedbackAttachmentSchema.parse({ ...FeedbackAttachmentSchema.parse(readFeedbackJson(row.payload)), status: row.status });
941
+ return FeedbackAttachmentSchema.parse({
942
+ ...FeedbackAttachmentSchema.parse(readFeedbackJson(row.payload)),
943
+ status: row.status
944
+ });
697
945
  }
698
946
  async function appendInside(tx, row, events, internal = false) {
699
947
  const acknowledged = [];
@@ -721,20 +969,46 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
721
969
  }
722
970
  acknowledged.push(event.id);
723
971
  }
724
- if (!additions.length)
725
- return ok({ acknowledged, evidenceRevision: Number(row.revision) });
972
+ if (!additions.length) {
973
+ return ok({
974
+ acknowledged,
975
+ evidenceRevision: Number(row.revision)
976
+ });
977
+ }
726
978
  const bytes = additions.reduce((total, item) => total + item.bytes, 0);
727
979
  const revision = Number(row.revision) + 1;
728
- const params = [additions.length, bytes, revision, Date.now()];
980
+ const params = [
981
+ additions.length,
982
+ bytes,
983
+ revision,
984
+ Date.now()
985
+ ];
729
986
  const identity = `${feedbackTextEquals("id", row.id, params)} AND ${feedbackTextEquals("scope_id", row.scope_id, params)}`;
730
987
  params.push(row.revision, additions.length, budget.maxEvidenceCount, bytes, budget.maxEvidenceBytes);
731
988
  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);
732
989
  if (!changed.success)
733
990
  return changed;
734
- if (!changed.data.changes)
991
+ if (!changed.data.changes) {
735
992
  return Number(row.evidence_count) + additions.length > budget.maxEvidenceCount || Number(row.evidence_bytes) + bytes > budget.maxEvidenceBytes ? limited() : conflict();
993
+ }
736
994
  for (const { key, event, bytes: itemBytes } of additions) {
737
- 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)]);
995
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
996
+ key,
997
+ row.id,
998
+ row.scope_id,
999
+ row.user_id,
1000
+ row.app_id,
1001
+ event.source,
1002
+ event.sequence,
1003
+ event.kind,
1004
+ event.timestamp,
1005
+ event.requestId ?? null,
1006
+ event.errorId ?? null,
1007
+ revision,
1008
+ itemBytes,
1009
+ row.expires_at,
1010
+ JSON.stringify(event)
1011
+ ]);
738
1012
  if (!inserted.success)
739
1013
  return inserted;
740
1014
  }
@@ -780,7 +1054,11 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
780
1054
  }
781
1055
  async function evidence(id, revision, sql = reldb.sql) {
782
1056
  const result = await sql.query(`SELECT * FROM ${table.evidence} WHERE report_id = ? AND revision <= ? ORDER BY timestamp, source, sequence`, [id, revision]);
783
- return result.success ? ok(result.data.map((row) => FeedbackEvidenceSchema.parse({ ...FeedbackEvidenceInputSchema.parse(readFeedbackJson(row.payload)), feedbackId: id, revision: Number(row.revision) }))) : result;
1057
+ return result.success ? ok(result.data.map((row) => FeedbackEvidenceSchema.parse({
1058
+ ...FeedbackEvidenceInputSchema.parse(readFeedbackJson(row.payload)),
1059
+ feedbackId: id,
1060
+ revision: Number(row.revision)
1061
+ }))) : result;
784
1062
  }
785
1063
  async function runJobs() {
786
1064
  if (stopped)
@@ -816,7 +1094,12 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
816
1094
  const sanitized = FeedbackSubmitInputSchema.parse(sanitizeFeedbackValue(parsed.data));
817
1095
  if (sanitized.evidence?.some((event) => event.source.startsWith("server:")))
818
1096
  return invalid();
819
- const submissionKey = hash(JSON.stringify([actor.scopeId, actor.userId, config.appId, sanitized.submissionId]));
1097
+ const submissionKey = hash(JSON.stringify([
1098
+ actor.scopeId,
1099
+ actor.userId,
1100
+ config.appId,
1101
+ sanitized.submissionId
1102
+ ]));
820
1103
  const digest = hash(canonicalFeedbackJson(sanitized));
821
1104
  const existing = await reldb.sql.get(`SELECT * FROM ${table.reports} WHERE submission_key = ?`, [submissionKey]);
822
1105
  if (!existing.success)
@@ -825,8 +1108,9 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
825
1108
  return conflict();
826
1109
  if (existing.data && Number(existing.data.deleting))
827
1110
  return missing();
828
- if (existing.data)
1111
+ if (existing.data) {
829
1112
  return existing.data.submission_hash === digest ? ok(repo.report(existing.data)) : conflict();
1113
+ }
830
1114
  const now = Date.now();
831
1115
  const id = `fb_${randomUUID()}`;
832
1116
  const report = {
@@ -851,7 +1135,30 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
851
1135
  version: 1,
852
1136
  evidenceRevision: 1
853
1137
  };
854
- const row = { id, scope_id: actor.scopeId, user_id: actor.userId, app_id: config.appId, number: report.number, app_version: report.appVersion ?? null, summary: report.summary ?? "", submission_key: submissionKey, submission_hash: digest, status: "pending", collection_status: "collecting", notification_status: report.notificationStatus, created_at: now, updated_at: now, version: 1, revision: 1, evidence_count: 0, evidence_bytes: 0, collect_deadline: now + config.collectionTimeoutMs, expires_at: now + config.retentionDays * 864e5, payload: report, deleting: 0 };
1138
+ const row = {
1139
+ id,
1140
+ scope_id: actor.scopeId,
1141
+ user_id: actor.userId,
1142
+ app_id: config.appId,
1143
+ number: report.number,
1144
+ app_version: report.appVersion ?? null,
1145
+ summary: report.summary ?? "",
1146
+ submission_key: submissionKey,
1147
+ submission_hash: digest,
1148
+ status: "pending",
1149
+ collection_status: "collecting",
1150
+ notification_status: report.notificationStatus,
1151
+ created_at: now,
1152
+ updated_at: now,
1153
+ version: 1,
1154
+ revision: 1,
1155
+ evidence_count: 0,
1156
+ evidence_bytes: 0,
1157
+ collect_deadline: now + config.collectionTimeoutMs,
1158
+ expires_at: now + config.retentionDays * 864e5,
1159
+ payload: report,
1160
+ deleting: 0
1161
+ };
855
1162
  const result = await repo.transaction(async (tx) => {
856
1163
  const inserted = await repo.create({ ...row }, tx);
857
1164
  if (!inserted.success)
@@ -859,7 +1166,10 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
859
1166
  const reservation = await reserveQuota(tx, actor, id);
860
1167
  if (!reservation.success)
861
1168
  return reservation;
862
- const initial = await appendInside(tx, row, [...sanitized.evidence ?? [], capture.environment()], true);
1169
+ const initial = await appendInside(tx, row, [
1170
+ ...sanitized.evidence ?? [],
1171
+ capture.environment()
1172
+ ], true);
863
1173
  if (!initial.success)
864
1174
  return initial;
865
1175
  if ((sanitized.attachments ?? []).reduce((total, file) => total + file.size, 0) > budget.maxAttachmentsBytes)
@@ -869,7 +1179,18 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
869
1179
  return invalid();
870
1180
  const attachmentId = hash(`${id}:${attachment.id}`);
871
1181
  const data = { ...attachment, feedbackId: id, status: "pending" };
872
- 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)]);
1182
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1183
+ attachmentId,
1184
+ attachment.id,
1185
+ id,
1186
+ actor.scopeId,
1187
+ `feedback/${hash(actor.scopeId).slice(0, 16)}/${id}/${attachmentId}`,
1188
+ "pending",
1189
+ attachment.size,
1190
+ attachment.checksum,
1191
+ now,
1192
+ JSON.stringify(data)
1193
+ ]);
873
1194
  if (!saved.success)
874
1195
  return saved;
875
1196
  }
@@ -915,8 +1236,17 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
915
1236
  if (query.from !== void 0 && query.to !== void 0 && query.from > query.to)
916
1237
  return invalid();
917
1238
  const params = [];
918
- const conditions = [feedbackTextEquals("scope_id", actor.scopeId, params), feedbackTextEquals("app_id", config.appId, params), "deleting = 0"];
919
- for (const [column, value] of [["status", query.status], ["collection_status", query.collectionStatus], ["notification_status", query.notificationStatus], ["app_id", query.appId]]) {
1239
+ const conditions = [
1240
+ feedbackTextEquals("scope_id", actor.scopeId, params),
1241
+ feedbackTextEquals("app_id", config.appId, params),
1242
+ "deleting = 0"
1243
+ ];
1244
+ for (const [column, value] of [
1245
+ ["status", query.status],
1246
+ ["collection_status", query.collectionStatus],
1247
+ ["notification_status", query.notificationStatus],
1248
+ ["app_id", query.appId]
1249
+ ]) {
920
1250
  if (value) {
921
1251
  if (column === "app_id") {
922
1252
  conditions.push(feedbackTextEquals(column, value, params));
@@ -944,8 +1274,15 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
944
1274
  params.push(query.appVersion);
945
1275
  }
946
1276
  const direction = query.sortDirection === "asc" ? "ASC" : "DESC";
947
- 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 } });
948
- return page.success ? ok({ ...page.data, items: page.data.items.map((row) => repo.report(row)) }) : page;
1277
+ const page = await reldb.sql.queryPage({
1278
+ sql: `SELECT * FROM ${table.reports} WHERE ${conditions.join(" AND ")} ORDER BY created_at ${direction}, id ${direction}`,
1279
+ params,
1280
+ pagination: { page: query.page, pageSize: query.pageSize }
1281
+ });
1282
+ return page.success ? ok({
1283
+ ...page.data,
1284
+ items: page.data.items.map((row) => repo.report(row))
1285
+ }) : page;
949
1286
  },
950
1287
  async get(id, actor) {
951
1288
  const report = await access(id, actor);
@@ -958,7 +1295,12 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
958
1295
  if (!changes.success)
959
1296
  return changes;
960
1297
  const jobs = await reldb.sql.query(`SELECT * FROM ${table.jobs} WHERE report_id = ? ORDER BY created_at, id`, [id]);
961
- return jobs.success ? ok({ report: repo.report(report.data), attachments: files.data.map(attachmentData), history: changes.data, jobs: jobs.data.map(jobData) }) : jobs;
1298
+ return jobs.success ? ok({
1299
+ report: repo.report(report.data),
1300
+ attachments: files.data.map(attachmentData),
1301
+ history: changes.data,
1302
+ jobs: jobs.data.map(jobData)
1303
+ }) : jobs;
962
1304
  },
963
1305
  async getEvidence(input, actor) {
964
1306
  const parsed = FeedbackEvidenceQuerySchema.safeParse(input);
@@ -969,8 +1311,15 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
969
1311
  return accessResult;
970
1312
  const query = parsed.data;
971
1313
  const params = [];
972
- const conditions = [feedbackTextEquals("report_id", query.id, params), feedbackTextEquals("scope_id", actor.scopeId, params)];
973
- for (const [column, value] of [["source", query.source], ["kind", query.kind], ["request_id", query.requestId]]) {
1314
+ const conditions = [
1315
+ feedbackTextEquals("report_id", query.id, params),
1316
+ feedbackTextEquals("scope_id", actor.scopeId, params)
1317
+ ];
1318
+ for (const [column, value] of [
1319
+ ["source", query.source],
1320
+ ["kind", query.kind],
1321
+ ["request_id", query.requestId]
1322
+ ]) {
974
1323
  if (value) {
975
1324
  if (column === "kind") {
976
1325
  conditions.push(`${column} = ?`);
@@ -986,8 +1335,14 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
986
1335
  const anchor = await reldb.sql.get(`SELECT * FROM ${table.evidence} WHERE ${anchorIdentity} AND kind = 'error'`, anchorParams);
987
1336
  if (!anchor.success)
988
1337
  return anchor;
989
- if (!anchor.data)
990
- return ok({ items: [], total: 0, page: query.page ?? 1, pageSize: query.pageSize ?? 20 });
1338
+ if (!anchor.data) {
1339
+ return ok({
1340
+ items: [],
1341
+ total: 0,
1342
+ page: query.page ?? 1,
1343
+ pageSize: query.pageSize ?? 20
1344
+ });
1345
+ }
991
1346
  const contextParams = [];
992
1347
  const contextIdentity = `${feedbackTextEquals("report_id", query.id, contextParams)} AND ${feedbackTextEquals("source", anchor.data.source, contextParams)}`;
993
1348
  const request = anchor.data.request_id ? feedbackTextEquals("request_id", anchor.data.request_id, contextParams) : "request_id IS NULL";
@@ -997,7 +1352,10 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
997
1352
  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]);
998
1353
  if (!after.success)
999
1354
  return after;
1000
- const ids = [...before.data, ...after.data].map((row) => row.id);
1355
+ const ids = [
1356
+ ...before.data,
1357
+ ...after.data
1358
+ ].map((row) => row.id);
1001
1359
  conditions.push(`id IN (${ids.map(() => "?").join(",")})`);
1002
1360
  params.push(...ids);
1003
1361
  }
@@ -1009,8 +1367,19 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1009
1367
  conditions.push("timestamp <= ?");
1010
1368
  params.push(query.to);
1011
1369
  }
1012
- 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 } });
1013
- 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;
1370
+ const page = await reldb.sql.queryPage({
1371
+ sql: `SELECT * FROM ${table.evidence} WHERE ${conditions.join(" AND ")} ORDER BY timestamp, source, sequence`,
1372
+ params,
1373
+ pagination: { page: query.page, pageSize: query.pageSize }
1374
+ });
1375
+ return page.success ? ok({
1376
+ ...page.data,
1377
+ items: page.data.items.map((row) => FeedbackEvidenceSchema.parse({
1378
+ ...FeedbackEvidenceInputSchema.parse(readFeedbackJson(row.payload)),
1379
+ feedbackId: query.id,
1380
+ revision: Number(row.revision)
1381
+ }))
1382
+ }) : page;
1014
1383
  },
1015
1384
  async setStatus(input, actor) {
1016
1385
  const parsed = FeedbackSetStatusInputSchema.safeParse(input);
@@ -1033,14 +1402,31 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1033
1402
  return updated;
1034
1403
  if (!updated.data.changes)
1035
1404
  return conflict();
1036
- 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 };
1405
+ const change = {
1406
+ id: randomUUID(),
1407
+ feedbackId: input.id,
1408
+ userId: actor.userId,
1409
+ from: repo.report(row.data).status,
1410
+ to: parsed.data.status,
1411
+ timestamp: now,
1412
+ note: parsed.data.note ? redactFeedbackText(parsed.data.note) : void 0
1413
+ };
1037
1414
  const previousHistory = await history(input.id, tx);
1038
1415
  if (!previousHistory.success)
1039
1416
  return previousHistory;
1040
1417
  if (Buffer.byteLength(JSON.stringify([...previousHistory.data, change])) > 256 * 1024)
1041
1418
  return limited();
1042
- const saved = await tx.execute(`INSERT INTO ${table.history} (id, report_id, payload) VALUES (?, ?, ?)`, [change.id, input.id, JSON.stringify(change)]);
1043
- return saved.success ? ok({ ...repo.report(row.data), status: parsed.data.status, version: parsed.data.expectedVersion + 1, updatedAt: now }) : saved;
1419
+ const saved = await tx.execute(`INSERT INTO ${table.history} (id, report_id, payload) VALUES (?, ?, ?)`, [
1420
+ change.id,
1421
+ input.id,
1422
+ JSON.stringify(change)
1423
+ ]);
1424
+ return saved.success ? ok({
1425
+ ...repo.report(row.data),
1426
+ status: parsed.data.status,
1427
+ version: parsed.data.expectedVersion + 1,
1428
+ updatedAt: now
1429
+ }) : saved;
1044
1430
  });
1045
1431
  },
1046
1432
  async appendEvidence(input, actor) {
@@ -1064,7 +1450,17 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1064
1450
  if (!row.success)
1065
1451
  return row;
1066
1452
  if (parsed.data.missing?.length) {
1067
- 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" }]);
1453
+ const saved = await appendInternal(input.id, actor, [
1454
+ {
1455
+ id: randomUUID(),
1456
+ source: "collection",
1457
+ sequence: Date.now(),
1458
+ kind: "missing",
1459
+ timestamp: Date.now(),
1460
+ data: { missing: parsed.data.missing },
1461
+ missingReason: "collection_incomplete"
1462
+ }
1463
+ ]);
1068
1464
  if (!saved.success)
1069
1465
  return saved;
1070
1466
  }
@@ -1091,7 +1487,11 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1091
1487
  if (hash(bytes.data) !== found.data.checksum)
1092
1488
  return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
1093
1489
  const metadata = attachmentData(found.data);
1094
- return ok({ name: metadata.name, contentType: metadata.contentType, dataBase64: bytes.data.toString("base64") });
1490
+ return ok({
1491
+ name: metadata.name,
1492
+ contentType: metadata.contentType,
1493
+ dataBase64: bytes.data.toString("base64")
1494
+ });
1095
1495
  },
1096
1496
  async retry(input, actor) {
1097
1497
  const parsed = FeedbackRetryInputSchema.safeParse(input);
@@ -1102,7 +1502,9 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1102
1502
  if (!row.success)
1103
1503
  return row;
1104
1504
  if (input.target === "attachment") {
1105
- const params = [Date.now()];
1505
+ const params = [
1506
+ Date.now()
1507
+ ];
1106
1508
  const identity = `${feedbackTextEquals("report_id", input.id, params)} AND ${feedbackTextEquals("client_id", input.targetId ?? "", params)} AND ${feedbackTextEquals("scope_id", actor.scopeId, params)}`;
1107
1509
  const updated2 = await reldb.sql.execute(`UPDATE ${table.attachments} SET status = 'pending', updated_at = ? WHERE ${identity} AND status = 'failed'`, params);
1108
1510
  return updated2.success ? ok(void 0) : updated2;
@@ -1129,7 +1531,11 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1129
1531
  if (revision > Number(row.data.revision))
1130
1532
  return conflict();
1131
1533
  const snapshotVersion = revision === Number(row.data.revision) ? " AND snapshot_version = ?" : "";
1132
- 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] : []]);
1534
+ 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`, [
1535
+ input.id,
1536
+ revision,
1537
+ ...snapshotVersion ? [row.data.version] : []
1538
+ ]);
1133
1539
  if (!previous.success)
1134
1540
  return previous;
1135
1541
  if (previous.data) {
@@ -1159,7 +1565,14 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1159
1565
  if (!changes.success)
1160
1566
  return changes;
1161
1567
  const suffix = `${revision}:${row.data.version}${previous.data ? `:${previous.data.id}` : ""}`;
1162
- const added = await insertJob(tx, repo.report(row.data), "export", suffix, { report: repo.report(row.data), evidence: events.data, attachments: files.data, history: changes.data, maxBytes: Number(reservation.data.report_bytes), snapshotExpiresAt: Date.now() + 36e5 }, Date.now());
1568
+ const added = await insertJob(tx, repo.report(row.data), "export", suffix, {
1569
+ report: repo.report(row.data),
1570
+ evidence: events.data,
1571
+ attachments: files.data,
1572
+ history: changes.data,
1573
+ maxBytes: Number(reservation.data.report_bytes),
1574
+ snapshotExpiresAt: Date.now() + 36e5
1575
+ }, Date.now());
1163
1576
  if (!added.success)
1164
1577
  return added;
1165
1578
  const job = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [added.data]);
@@ -1200,7 +1613,11 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1200
1613
  return bytes;
1201
1614
  if (!("checksum" in data) || typeof data.checksum !== "string" || hash(bytes.data) !== data.checksum)
1202
1615
  return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
1203
- return ok({ name: `${repo.report(row.data).number}.zip`, contentType: "application/zip", dataBase64: bytes.data.toString("base64") });
1616
+ return ok({
1617
+ name: `${repo.report(row.data).number}.zip`,
1618
+ contentType: "application/zip",
1619
+ dataBase64: bytes.data.toString("base64")
1620
+ });
1204
1621
  },
1205
1622
  runJobs
1206
1623
  };
@@ -1225,11 +1642,18 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1225
1642
  return invalid();
1226
1643
  if (hash(bytes) !== attachment.checksum)
1227
1644
  return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
1228
- const stored = await storage.file.put(attachment.object_key, bytes, { contentType: metadata.contentType, ifNoneMatch: "*", signal: AbortSignal.timeout(3e4) });
1645
+ const stored = await storage.file.put(attachment.object_key, bytes, {
1646
+ contentType: metadata.contentType,
1647
+ ifNoneMatch: "*",
1648
+ signal: AbortSignal.timeout(3e4)
1649
+ });
1229
1650
  if (!stored.success) {
1230
1651
  const existing = await storage.file.get(attachment.object_key);
1231
1652
  if (!existing.success || hash(existing.data) !== attachment.checksum) {
1232
- await reldb.sql.execute(`UPDATE ${table.attachments} SET status = 'failed', updated_at = ? WHERE id = ? AND status <> 'available'`, [Date.now(), attachment.id]);
1653
+ await reldb.sql.execute(`UPDATE ${table.attachments} SET status = 'failed', updated_at = ? WHERE id = ? AND status <> 'available'`, [
1654
+ Date.now(),
1655
+ attachment.id
1656
+ ]);
1233
1657
  return stored;
1234
1658
  }
1235
1659
  }
@@ -1278,7 +1702,10 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1278
1702
  let onAbort;
1279
1703
  try {
1280
1704
  collected = await Promise.race([
1281
- Promise.resolve().then(() => collectEvidence({ report: Object.freeze({ ...report }), signal })),
1705
+ Promise.resolve().then(() => collectEvidence({
1706
+ report: Object.freeze({ ...report }),
1707
+ signal
1708
+ })),
1282
1709
  new Promise((_resolve, reject) => {
1283
1710
  onAbort = () => reject(new Error("Feedback business collection aborted"));
1284
1711
  signal.addEventListener("abort", onAbort, { once: true });
@@ -1312,14 +1739,36 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1312
1739
  bytes = Buffer.from(JSON.stringify(sanitizeFeedbackValue(JSON.parse(bytes.toString("utf8")))));
1313
1740
  if (bytes.length > budget.maxAttachmentBytes)
1314
1741
  return limited();
1315
- const parsed = FeedbackAttachmentInputSchema.safeParse({ id: `server:business:${input.id}`, name: typeof input.name === "string" ? redactFeedbackText(input.name) : input.name, contentType: input.contentType, size: bytes.length, checksum: hash(bytes) });
1742
+ const parsed = FeedbackAttachmentInputSchema.safeParse({
1743
+ id: `server:business:${input.id}`,
1744
+ name: typeof input.name === "string" ? redactFeedbackText(input.name) : input.name,
1745
+ contentType: input.contentType,
1746
+ size: bytes.length,
1747
+ checksum: hash(bytes)
1748
+ });
1316
1749
  if (!parsed.success || !input.id || files.some((file) => file.metadata.id === parsed.data.id))
1317
1750
  return invalid();
1318
1751
  const rowId = hash(`${report.id}:${parsed.data.id}`);
1319
1752
  const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/business/${rowId}-${parsed.data.checksum}`;
1320
- files.push({ metadata: { ...parsed.data, feedbackId: report.id, status: "available" }, bytes, key, rowId });
1753
+ files.push({
1754
+ metadata: { ...parsed.data, feedbackId: report.id, status: "available" },
1755
+ bytes,
1756
+ key,
1757
+ rowId
1758
+ });
1321
1759
  }
1322
- const event = FeedbackEvidenceInputSchema.safeParse(sanitizeFeedbackValue({ id: `business-${report.id}`, source: "server:business", sequence: 0, kind: "business", timestamp: report.createdAt, data: { ...collected.data, ...collected.text !== void 0 ? { text: collected.text } : {}, attachmentIds: files.map((file) => file.metadata.id) } }));
1760
+ const event = FeedbackEvidenceInputSchema.safeParse(sanitizeFeedbackValue({
1761
+ id: `business-${report.id}`,
1762
+ source: "server:business",
1763
+ sequence: 0,
1764
+ kind: "business",
1765
+ timestamp: report.createdAt,
1766
+ data: {
1767
+ ...collected.data,
1768
+ ...collected.text !== void 0 ? { text: collected.text } : {},
1769
+ attachmentIds: files.map((file) => file.metadata.id)
1770
+ }
1771
+ }));
1323
1772
  if (!event.success)
1324
1773
  return invalid();
1325
1774
  async function checkCapacity(sql) {
@@ -1338,9 +1787,14 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1338
1787
  const payload = readFeedbackJson(current2.data.payload);
1339
1788
  if (!payload || typeof payload !== "object")
1340
1789
  return invalid();
1341
- if ("businessFiles" in payload)
1790
+ if ("businessFiles" in payload) {
1342
1791
  return canonicalFeedbackJson(payload.businessFiles) === canonicalFeedbackJson(frozenFiles) ? ok(void 0) : conflict();
1343
- const updated = await tx.execute(`UPDATE ${table.jobs} SET payload = ? WHERE id = ? AND token = ?`, [JSON.stringify({ ...payload, businessFiles: frozenFiles }), job.id, job.token]);
1792
+ }
1793
+ const updated = await tx.execute(`UPDATE ${table.jobs} SET payload = ? WHERE id = ? AND token = ?`, [
1794
+ JSON.stringify({ ...payload, businessFiles: frozenFiles }),
1795
+ job.id,
1796
+ job.token
1797
+ ]);
1344
1798
  return updated.success ? ok(void 0) : updated;
1345
1799
  });
1346
1800
  if (!frozen.success)
@@ -1349,7 +1803,14 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1349
1803
  if (!capacity.success)
1350
1804
  return capacity;
1351
1805
  for (const file of files) {
1352
- const stored = await storage.file.put(file.key, file.bytes, { contentType: file.metadata.contentType, ifNoneMatch: "*", signal: AbortSignal.any([shutdown.signal, AbortSignal.timeout(3e4)]) });
1806
+ const stored = await storage.file.put(file.key, file.bytes, {
1807
+ contentType: file.metadata.contentType,
1808
+ ifNoneMatch: "*",
1809
+ signal: AbortSignal.any([
1810
+ shutdown.signal,
1811
+ AbortSignal.timeout(3e4)
1812
+ ])
1813
+ });
1353
1814
  if (!stored.success) {
1354
1815
  const existing = await storage.file.get(file.key);
1355
1816
  if (!existing.success || hash(existing.data) !== file.metadata.checksum)
@@ -1372,7 +1833,18 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1372
1833
  if (!appended.success)
1373
1834
  return appended;
1374
1835
  for (const file of files) {
1375
- const saved = await tx.execute(`INSERT INTO ${table.attachments} (id, client_id, report_id, scope_id, object_key, status, size, checksum, updated_at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [file.rowId, file.metadata.id, report.id, actor.scopeId, file.key, "available", file.bytes.length, file.metadata.checksum, Date.now(), JSON.stringify(file.metadata)]);
1836
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1837
+ file.rowId,
1838
+ file.metadata.id,
1839
+ report.id,
1840
+ actor.scopeId,
1841
+ file.key,
1842
+ "available",
1843
+ file.bytes.length,
1844
+ file.metadata.checksum,
1845
+ Date.now(),
1846
+ JSON.stringify(file.metadata)
1847
+ ]);
1376
1848
  if (!saved.success)
1377
1849
  return saved;
1378
1850
  }
@@ -1410,7 +1882,16 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1410
1882
  const pendingReceipt = result.success && !!result.data && typeof result.data === "object" && "pending" in result.data && result.data.pending === true;
1411
1883
  const purged = result.success && !!result.data && typeof result.data === "object" && "purged" in result.data && result.data.purged === true;
1412
1884
  const status = result.success ? pendingReceipt ? "pending" : "succeeded" : job.kind !== "cleanup" && Number(job.attempts) + 1 >= config.maxAttempts ? "failed" : "pending";
1413
- const saved = purged ? await reldb.sql.execute(`DELETE FROM ${table.jobs} WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [job.id, token]) : await reldb.sql.execute(`UPDATE ${table.jobs} SET status = ?, attempts = attempts - ?, token = NULL, lease_until = 0, next_run_at = ${clock} + ?, updated_at = ${clock}, last_error = ?, result = CASE WHEN ? = 1 THEN ? ELSE result END WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [status, pendingReceipt ? 1 : 0, Math.min(6e4, 1e3 * 2 ** Math.min(Number(job.attempts), 6)), result.success ? null : result.error.code, result.success ? 1 : 0, result.success ? JSON.stringify(result.data) : null, job.id, token]);
1885
+ const saved = purged ? await reldb.sql.execute(`DELETE FROM ${table.jobs} WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [job.id, token]) : await reldb.sql.execute(`UPDATE ${table.jobs} SET status = ?, attempts = attempts - ?, token = NULL, lease_until = 0, next_run_at = ${clock} + ?, updated_at = ${clock}, last_error = ?, result = CASE WHEN ? = 1 THEN ? ELSE result END WHERE id = ? AND token = ? AND status = 'running' AND lease_until > ${clock}`, [
1886
+ status,
1887
+ pendingReceipt ? 1 : 0,
1888
+ Math.min(6e4, 1e3 * 2 ** Math.min(Number(job.attempts), 6)),
1889
+ result.success ? null : result.error.code,
1890
+ result.success ? 1 : 0,
1891
+ result.success ? JSON.stringify(result.data) : null,
1892
+ job.id,
1893
+ token
1894
+ ]);
1414
1895
  if (!saved.success)
1415
1896
  return saved;
1416
1897
  if (saved.data.changes && status === "failed" && job.kind === "collect") {
@@ -1422,7 +1903,12 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1422
1903
  await notificationSummary(job.report_id);
1423
1904
  processed++;
1424
1905
  } catch {
1425
- 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]);
1906
+ 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}`, [
1907
+ job.kind === "cleanup" ? "pending" : "failed",
1908
+ HaiFeedbackError.OPERATION_FAILED.code,
1909
+ job.id,
1910
+ token
1911
+ ]);
1426
1912
  if (job.kind === "collect")
1427
1913
  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]);
1428
1914
  } finally {
@@ -1442,7 +1928,11 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1442
1928
  if (!deleted.success)
1443
1929
  return deleted;
1444
1930
  }
1445
- const marked = await reldb.sql.execute(`UPDATE ${table.jobs} SET status = 'failed', result = NULL, payload = ?, last_error = ? WHERE id = ? AND status = 'succeeded'`, [JSON.stringify({ expired: true }), HaiFeedbackError.NOT_FOUND.code, job.id]);
1931
+ const marked = await reldb.sql.execute(`UPDATE ${table.jobs} SET status = 'failed', result = NULL, payload = ?, last_error = ? WHERE id = ? AND status = 'succeeded'`, [
1932
+ JSON.stringify({ expired: true }),
1933
+ HaiFeedbackError.NOT_FOUND.code,
1934
+ job.id
1935
+ ]);
1446
1936
  if (!marked.success)
1447
1937
  return marked;
1448
1938
  }
@@ -1458,11 +1948,17 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1458
1948
  if (Date.now() >= cleanupAfter) {
1459
1949
  const params = [];
1460
1950
  const identity = feedbackTextEquals("app_id", config.appId, params);
1461
- const expired = await reldb.sql.query(`SELECT id FROM ${table.evidence} WHERE ${identity} AND report_id IS NULL AND expires_at < ? ORDER BY expires_at, id LIMIT 100`, [...params, Date.now()]);
1951
+ const expired = await reldb.sql.query(`SELECT id FROM ${table.evidence} WHERE ${identity} AND report_id IS NULL AND expires_at < ? ORDER BY expires_at, id LIMIT 100`, [
1952
+ ...params,
1953
+ Date.now()
1954
+ ]);
1462
1955
  if (!expired.success)
1463
1956
  return expired;
1464
1957
  if (expired.data.length) {
1465
- const removed = await reldb.sql.execute(`DELETE FROM ${table.evidence} WHERE ${identity} AND report_id IS NULL AND id IN (${expired.data.map(() => "?").join(",")})`, [...params, ...expired.data.map((row) => row.id)]);
1958
+ const removed = await reldb.sql.execute(`DELETE FROM ${table.evidence} WHERE ${identity} AND report_id IS NULL AND id IN (${expired.data.map(() => "?").join(",")})`, [
1959
+ ...params,
1960
+ ...expired.data.map((row) => row.id)
1961
+ ]);
1466
1962
  if (!removed.success)
1467
1963
  return removed;
1468
1964
  }
@@ -1496,13 +1992,27 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1496
1992
  if (Number(row.data.deleting) && job.kind !== "cleanup")
1497
1993
  return missing();
1498
1994
  const report = repo.report(row.data);
1499
- const actor = { scopeId: report.scopeId, userId: report.userId, permissions: ["feedback:*"] };
1995
+ const actor = {
1996
+ scopeId: report.scopeId,
1997
+ userId: report.userId,
1998
+ permissions: ["feedback:*"]
1999
+ };
1500
2000
  if (job.kind === "collect") {
1501
2001
  const anchor = "anchor" in payload && typeof payload.anchor === "number" ? payload.anchor : report.createdAt;
1502
2002
  const captured = await capture.collect(report.scopeId, report.userId, anchor);
1503
2003
  const saved = await withLease(job, async (tx) => {
1504
2004
  const fresh = await access(report.id, actor, void 0, tx);
1505
- 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;
2005
+ return fresh.success ? appendInside(tx, fresh.data, captured.length ? captured : [
2006
+ {
2007
+ id: `missing-server-${report.id}`,
2008
+ source: "collection",
2009
+ sequence: 0,
2010
+ kind: "missing",
2011
+ timestamp: report.createdAt,
2012
+ data: { source: "server_logs" },
2013
+ missingReason: "no_correlated_server_logs"
2014
+ }
2015
+ ], true) : fresh;
1506
2016
  });
1507
2017
  if (!saved.success)
1508
2018
  return saved;
@@ -1518,7 +2028,10 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1518
2028
  if (!reasons.success)
1519
2029
  return reasons;
1520
2030
  const partial = Number(reasons.data?.count ?? 0) > 0 || files.data.some((item) => item.status !== "available");
1521
- const updated = await withLease(job, (tx) => tx.execute(`UPDATE ${table.reports} SET collection_status = ?, updated_at = ${clock} WHERE id = ?`, [partial ? "partial" : "complete", report.id]));
2031
+ const updated = await withLease(job, (tx) => tx.execute(`UPDATE ${table.reports} SET collection_status = ?, updated_at = ${clock} WHERE id = ?`, [
2032
+ partial ? "partial" : "complete",
2033
+ report.id
2034
+ ]));
1522
2035
  return updated.success ? ok({ collected: captured.length }) : updated;
1523
2036
  }
1524
2037
  if (job.kind === "webhook") {
@@ -1541,24 +2054,55 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1541
2054
  const files = await attachments(report.id, tx);
1542
2055
  if (!files.success)
1543
2056
  return files;
1544
- const business = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [hash(`${report.id}:server:business:0`)]);
2057
+ const business = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [
2058
+ hash(`${report.id}:server:business:0`)
2059
+ ]);
1545
2060
  if (!business.success)
1546
2061
  return business;
1547
2062
  const detailUrl = config.adminUrl ? new URL(config.adminUrl) : void 0;
1548
2063
  detailUrl?.searchParams.set("feedback", report.id);
1549
- const event = { event: "feedback.created", eventId: job.id, createdAt: report.createdAt, report: repo.report(live.data), evidence: { revision: Number(live.data.revision), business: !!business.data, attachments: files.data.map(attachmentData) }, ...detailUrl ? { detailUrl: detailUrl.href } : {} };
2064
+ const event = {
2065
+ event: "feedback.created",
2066
+ eventId: job.id,
2067
+ createdAt: report.createdAt,
2068
+ report: repo.report(live.data),
2069
+ evidence: {
2070
+ revision: Number(live.data.revision),
2071
+ business: !!business.data,
2072
+ attachments: files.data.map(attachmentData)
2073
+ },
2074
+ ...detailUrl ? { detailUrl: detailUrl.href } : {}
2075
+ };
1550
2076
  const body = JSON.stringify(event);
1551
- const saved = await tx.execute(`UPDATE ${table.jobs} SET payload = ? WHERE id = ? AND token = ?`, [JSON.stringify({ url: config.webhookUrl, body }), job.id, job.token]);
2077
+ const saved = await tx.execute(`UPDATE ${table.jobs} SET payload = ? WHERE id = ? AND token = ?`, [
2078
+ JSON.stringify({ url: config.webhookUrl, body }),
2079
+ job.id,
2080
+ job.token
2081
+ ]);
1552
2082
  return saved.success ? ok(body) : saved;
1553
2083
  });
1554
2084
  if (!frozen.success)
1555
2085
  return frozen;
1556
2086
  const timestamp = String(Math.floor(Date.now() / 1e3));
1557
- const headers = { "content-type": "application/json", "idempotency-key": job.id, "x-hai-feedback-event": "feedback.created", "x-hai-feedback-timestamp": timestamp };
2087
+ const headers = {
2088
+ "content-type": "application/json",
2089
+ "idempotency-key": job.id,
2090
+ "x-hai-feedback-event": "feedback.created",
2091
+ "x-hai-feedback-timestamp": timestamp
2092
+ };
1558
2093
  if (config.webhookSecret)
1559
2094
  headers["x-hai-feedback-signature"] = `sha256=${createHmac("sha256", config.webhookSecret).update(`${timestamp}.${frozen.data}`).digest("hex")}`;
1560
2095
  try {
1561
- const response = await fetch(config.webhookUrl, { method: "POST", headers, body: frozen.data, redirect: "manual", signal: AbortSignal.any([shutdown.signal, AbortSignal.timeout(config.webhookTimeoutMs)]) });
2096
+ const response = await fetch(config.webhookUrl, {
2097
+ method: "POST",
2098
+ headers,
2099
+ body: frozen.data,
2100
+ redirect: "manual",
2101
+ signal: AbortSignal.any([
2102
+ shutdown.signal,
2103
+ AbortSignal.timeout(config.webhookTimeoutMs)
2104
+ ])
2105
+ });
1562
2106
  await response.body?.cancel();
1563
2107
  return response.ok ? ok({ status: response.status }) : failed();
1564
2108
  } catch {
@@ -1576,7 +2120,16 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1576
2120
  return live;
1577
2121
  const current2 = repo.report(live.data);
1578
2122
  if (config.notificationMode === "console") {
1579
- core.logger.info("Feedback notification", { module: "feedback", number: current2.number, appId: current2.appId, appVersion: current2.appVersion, buildId: current2.buildId, commit: current2.commit, collectionStatus: current2.collectionStatus, ...config.consoleContent === "summary" ? { summary: current2.summary } : {} });
2123
+ core.logger.info("Feedback notification", {
2124
+ module: "feedback",
2125
+ number: current2.number,
2126
+ appId: current2.appId,
2127
+ appVersion: current2.appVersion,
2128
+ buildId: current2.buildId,
2129
+ commit: current2.commit,
2130
+ collectionStatus: current2.collectionStatus,
2131
+ ...config.consoleContent === "summary" ? { summary: current2.summary } : {}
2132
+ });
1580
2133
  return ok({ channel: "console" });
1581
2134
  }
1582
2135
  const { HaiReachError, reach } = await import('@h-ai/reach');
@@ -1588,10 +2141,24 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1588
2141
  }[current2.collectionStatus];
1589
2142
  const detailUrl = config.adminUrl ? new URL(config.adminUrl) : void 0;
1590
2143
  detailUrl?.searchParams.set("feedback", report.id);
1591
- const facts = [current2.number, `${current2.appId} ${current2.appVersion ?? ""}`, `${feedbackM("feedback_submittedAt")}: ${new Date(current2.createdAt).toISOString()}`, `${feedbackM("feedback_materialStatus")}: ${materialStatus}`, current2.summary ?? "", ...detailUrl ? [detailUrl.href] : []];
2144
+ const facts = [
2145
+ current2.number,
2146
+ `${current2.appId} ${current2.appVersion ?? ""}`,
2147
+ `${feedbackM("feedback_submittedAt")}: ${new Date(current2.createdAt).toISOString()}`,
2148
+ `${feedbackM("feedback_materialStatus")}: ${materialStatus}`,
2149
+ current2.summary ?? "",
2150
+ ...detailUrl ? [detailUrl.href] : []
2151
+ ];
1592
2152
  const isEmail = reach.config?.providers.find((provider) => provider.name === config.notificationProvider)?.type === "smtp";
1593
2153
  const body = isEmail ? facts.map((value) => `<p>${escapeHtml(value)}</p>`).join("") : facts.join("\n");
1594
- const sent = await reach.send({ provider: config.notificationProvider, to: recipient, subject: `${feedbackM("feedback_received")} ${current2.number}`, body, idempotencyKey: job.id, deferPolicy: "reject" });
2154
+ const sent = await reach.send({
2155
+ provider: config.notificationProvider,
2156
+ to: recipient,
2157
+ subject: `${feedbackM("feedback_received")} ${current2.number}`,
2158
+ body,
2159
+ idempotencyKey: job.id,
2160
+ deferPolicy: "reject"
2161
+ });
1595
2162
  if (!sent.success && sent.error.code === HaiReachError.DND_BLOCKED.code)
1596
2163
  return ok({ pending: true, deferred: true });
1597
2164
  if (!sent.success)
@@ -1626,9 +2193,23 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1626
2193
  if (total > 64 * 1024 * 1024)
1627
2194
  return limited();
1628
2195
  const info = attachmentData(file);
1629
- files.push({ id: info.id, name: info.name, contentType: info.contentType, checksum: info.checksum, bytes: stored2.data });
2196
+ files.push({
2197
+ id: info.id,
2198
+ name: info.name,
2199
+ contentType: info.contentType,
2200
+ checksum: info.checksum,
2201
+ bytes: stored2.data
2202
+ });
1630
2203
  }
1631
- const bundle = await createFeedbackBundle({ maxBytes: fixed.maxBytes, createdAt: Number(job.created_at), report: fixed.report, evidence: fixed.evidence, history: fixed.history, attachments: files, missing: unavailable2 });
2204
+ const bundle = await createFeedbackBundle({
2205
+ maxBytes: fixed.maxBytes,
2206
+ createdAt: Number(job.created_at),
2207
+ report: fixed.report,
2208
+ evidence: fixed.evidence,
2209
+ history: fixed.history,
2210
+ attachments: files,
2211
+ missing: unavailable2
2212
+ });
1632
2213
  if (!bundle.success)
1633
2214
  return bundle;
1634
2215
  const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/exports/${job.id}.zip`;
@@ -1638,7 +2219,11 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1638
2219
  if (!existing.success || hash(existing.data) !== hash(bundle.data))
1639
2220
  return stored;
1640
2221
  }
1641
- return ok({ key, checksum: hash(bundle.data), size: bundle.data.length });
2222
+ return ok({
2223
+ key,
2224
+ checksum: hash(bundle.data),
2225
+ size: bundle.data.length
2226
+ });
1642
2227
  }
1643
2228
  const marked = await withLease(job, async (tx) => {
1644
2229
  const locked = await repo.find(report.id, report.scopeId, tx);
@@ -1671,9 +2256,14 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1671
2256
  if (!reservation.success)
1672
2257
  return reservation;
1673
2258
  if (reservation.data) {
1674
- const released = await tx.execute(`UPDATE ${table.quotas} SET report_count = report_count - 1, reserved_bytes = reserved_bytes - ?, updated_at = ${clock} WHERE id = ? AND reserved_bytes >= ? AND report_count > 0`, [Number(reservation.data.reserved_bytes), scopeQuotaId(report.scopeId), Number(reservation.data.reserved_bytes)]);
1675
- if (!released.success || !released.data.changes)
2259
+ const released = await tx.execute(`UPDATE ${table.quotas} SET report_count = report_count - 1, reserved_bytes = reserved_bytes - ?, updated_at = ${clock} WHERE id = ? AND reserved_bytes >= ? AND report_count > 0`, [
2260
+ Number(reservation.data.reserved_bytes),
2261
+ scopeQuotaId(report.scopeId),
2262
+ Number(reservation.data.reserved_bytes)
2263
+ ]);
2264
+ if (!released.success || !released.data.changes) {
1676
2265
  return released.success ? conflict() : released;
2266
+ }
1677
2267
  const removed2 = await tx.execute(`DELETE FROM ${table.quotas} WHERE id = ?`, [report.id]);
1678
2268
  if (!removed2.success)
1679
2269
  return removed2;
@@ -1681,7 +2271,11 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1681
2271
  const removedJobs = await tx.execute(`DELETE FROM ${table.jobs} WHERE report_id = ? AND id <> ?`, [report.id, job.id]);
1682
2272
  if (!removedJobs.success)
1683
2273
  return removedJobs;
1684
- 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]);
2274
+ const tombstone = await tx.execute(`UPDATE ${table.jobs} SET payload = ?, result = NULL WHERE id = ? AND token = ?`, [
2275
+ JSON.stringify({ tombstone: true, prefix, until: Date.now() + 864e5 }),
2276
+ job.id,
2277
+ job.token
2278
+ ]);
1685
2279
  if (!tombstone.success)
1686
2280
  return tombstone;
1687
2281
  const params = [];
@@ -1694,7 +2288,18 @@ function createFeedbackOperations(repo, config, capture, options = {}) {
1694
2288
  return operations;
1695
2289
  }
1696
2290
  function allowedContentType(type) {
1697
- return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf", "application/zip", "application/gzip"].includes(type);
2291
+ return [
2292
+ "image/png",
2293
+ "image/jpeg",
2294
+ "image/webp",
2295
+ "video/webm",
2296
+ "video/mp4",
2297
+ "text/plain",
2298
+ "application/json",
2299
+ "application/pdf",
2300
+ "application/zip",
2301
+ "application/gzip"
2302
+ ].includes(type);
1698
2303
  }
1699
2304
  function matchesContentType(bytes, type) {
1700
2305
  if (type === "application/zip")
@@ -1744,8 +2349,11 @@ var feedback = {
1744
2349
  return configFailure(parsed.error.issues.map((issue) => issue.path.join(".")));
1745
2350
  if (options.collectEvidence !== void 0 && typeof options.collectEvidence !== "function")
1746
2351
  return configFailure(["collectEvidence"]);
1747
- if (!reldb.isInitialized || !storage.isInitialized)
1748
- return configFailure([!reldb.isInitialized ? "reldb" : "storage"]);
2352
+ if (!reldb.isInitialized || !storage.isInitialized) {
2353
+ return configFailure([
2354
+ !reldb.isInitialized ? "reldb" : "storage"
2355
+ ]);
2356
+ }
1749
2357
  if (parsed.data.notificationMode === "reach") {
1750
2358
  const provider = await import('@h-ai/reach').catch(() => void 0);
1751
2359
  if (!provider)
@@ -1807,7 +2415,11 @@ var feedback = {
1807
2415
  return current !== null;
1808
2416
  },
1809
2417
  get config() {
1810
- return current ? { ...current.config, recipients: [...current.config.recipients], envAllowlist: [...current.config.envAllowlist] } : null;
2418
+ return current ? {
2419
+ ...current.config,
2420
+ recipients: [...current.config.recipients],
2421
+ envAllowlist: [...current.config.envAllowlist]
2422
+ } : null;
1811
2423
  },
1812
2424
  get budget() {
1813
2425
  return resolveFeedbackBudget(current?.config);
@@ -1827,7 +2439,14 @@ var feedback = {
1827
2439
  runJobs: () => guarded(() => (current?.operations ?? unavailable).runJobs())
1828
2440
  };
1829
2441
  function configFailure(fields) {
1830
- return { success: false, error: { ...HaiFeedbackError.CONFIG_ERROR, message: feedbackM("feedback_configError"), ext: { module: "feedback", fields } } };
2442
+ return {
2443
+ success: false,
2444
+ error: {
2445
+ ...HaiFeedbackError.CONFIG_ERROR,
2446
+ message: feedbackM("feedback_configError"),
2447
+ ext: { module: "feedback", fields }
2448
+ }
2449
+ };
1831
2450
  }
1832
2451
  async function guarded(work) {
1833
2452
  const promise = core.context.run({ suppressLogObservers: true }, work);