@h-ai/feedback 1.0.0-beta.18 → 1.0.0-beta.19
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/README.en.md +13 -7
- package/README.ja.md +13 -7
- package/README.md +13 -7
- package/dist/bundle/index.d.ts +2 -1
- package/dist/bundle/index.js +1 -1
- package/dist/capacitor.d.ts +1 -1
- package/dist/capacitor.js +18 -4
- package/dist/capacitor.js.map +1 -1
- package/dist/chunk-IFJHL5VK.js +442 -0
- package/dist/chunk-IFJHL5VK.js.map +1 -0
- package/dist/client/index.d.ts +4 -3
- package/dist/client/index.js +323 -68
- package/dist/client/index.js.map +1 -1
- package/dist/{feedback-client-files-B88nCOo_.d.ts → feedback-client-files-BlNJ94nn.d.ts} +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +788 -169
- package/dist/index.js.map +1 -1
- package/dist/tauri.d.ts +1 -1
- package/package.json +18 -18
- package/dist/chunk-KGMLTOMM.js +0 -340
- package/dist/chunk-KGMLTOMM.js.map +0 -1
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-
|
|
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({
|
|
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 = [
|
|
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 = {
|
|
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]) => ({
|
|
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
|
-
[
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
{
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
{
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 [
|
|
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([
|
|
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 = {
|
|
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 = {
|
|
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({
|
|
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([
|
|
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 [
|
|
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([
|
|
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 = [
|
|
536
|
-
|
|
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 [
|
|
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({
|
|
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) => ({
|
|
761
|
+
return value.replace(/[&<>"']/g, (character) => ({
|
|
762
|
+
"&": "&",
|
|
763
|
+
"<": "<",
|
|
764
|
+
">": ">",
|
|
765
|
+
'"': """,
|
|
766
|
+
"'": "'"
|
|
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
|
-
{
|
|
647
|
-
|
|
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}`, [
|
|
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 <= ?`, [
|
|
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, ?, ?, ?, ?)`, [
|
|
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} + ?, ?, ?, ?, ?, ?)`, [
|
|
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({
|
|
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({
|
|
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({
|
|
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 = [
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
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({
|
|
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([
|
|
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 = {
|
|
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, [
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
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 = [
|
|
919
|
-
|
|
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({
|
|
948
|
-
|
|
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({
|
|
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 = [
|
|
973
|
-
|
|
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({
|
|
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 = [
|
|
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({
|
|
1013
|
-
|
|
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 = {
|
|
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 (?, ?, ?)`, [
|
|
1043
|
-
|
|
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, [
|
|
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({
|
|
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 = [
|
|
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`, [
|
|
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, {
|
|
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({
|
|
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, {
|
|
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'`, [
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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, {
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
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}`, [
|
|
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}`, [
|
|
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'`, [
|
|
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`, [
|
|
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(",")})`, [
|
|
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 = {
|
|
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 : [
|
|
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 = ?`, [
|
|
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 = ?`, [
|
|
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 = {
|
|
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 = ?`, [
|
|
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 = {
|
|
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, {
|
|
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", {
|
|
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 = [
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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`, [
|
|
1675
|
-
|
|
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 = ?`, [
|
|
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 [
|
|
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([
|
|
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 ? {
|
|
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 {
|
|
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);
|