@h-ai/feedback 0.1.0-alpha.73 → 0.1.0-alpha.75
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 +72 -5
- package/README.ja.md +72 -5
- package/README.md +72 -5
- package/dist/bundle/index.d.ts +2 -0
- package/dist/bundle/index.js +1 -1
- package/dist/{chunk-PYKGS35A.js → chunk-7W47WZXY.js} +13 -9
- package/dist/chunk-7W47WZXY.js.map +1 -0
- package/dist/{chunk-5QWKURWU.js → chunk-KGMLTOMM.js} +5 -3
- package/dist/chunk-KGMLTOMM.js.map +1 -0
- package/dist/client/index.d.ts +12 -2
- package/dist/client/index.js +117 -19
- package/dist/client/index.js.map +1 -1
- package/dist/index.d.ts +82 -3
- package/dist/index.js +480 -75
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/dist/chunk-5QWKURWU.js.map +0 -1
- package/dist/chunk-PYKGS35A.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-
|
|
2
|
-
export { HaiFeedbackError } from './chunk-
|
|
3
|
-
import { createFeedbackBundle } from './chunk-
|
|
1
|
+
import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-7W47WZXY.js';
|
|
2
|
+
export { HaiFeedbackError } from './chunk-7W47WZXY.js';
|
|
3
|
+
import { createFeedbackBundle } from './chunk-KGMLTOMM.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { resolveFeedbackBudget, FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema } from '@h-ai/api-contract/feedback';
|
|
5
|
+
import { resolveFeedbackBudget, FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackRetryInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema, FeedbackAttachmentInputSchema } from '@h-ai/api-contract/feedback';
|
|
6
6
|
import { core, ok, err } from '@h-ai/core';
|
|
7
7
|
import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
8
8
|
import { storage } from '@h-ai/storage';
|
|
9
9
|
import { Buffer } from 'buffer';
|
|
10
|
-
import { randomUUID, createHash } from 'crypto';
|
|
10
|
+
import { randomUUID, createHash, createHmac } from 'crypto';
|
|
11
11
|
import { totalmem, arch, release, platform } from 'os';
|
|
12
12
|
import process from 'process';
|
|
13
13
|
|
|
@@ -20,8 +20,10 @@ var FeedbackConfigSchema = z.strictObject({
|
|
|
20
20
|
buildId: z.string().max(128).optional(),
|
|
21
21
|
/** 源码提交标识,帮助定位对应代码。 */
|
|
22
22
|
commit: z.string().max(128).optional(),
|
|
23
|
-
/** disabled 不通知,console
|
|
23
|
+
/** disabled 不通知,console 默认仅输出元数据,reach 使用已有通知通道。 */
|
|
24
24
|
notificationMode: z.enum(["disabled", "console", "reach"]).default("reach"),
|
|
25
|
+
/** console 通知内容;metadata 只输出定位和采集状态,summary 显式加入通用脱敏摘要。 */
|
|
26
|
+
consoleContent: z.enum(["metadata", "summary"]).default("metadata"),
|
|
25
27
|
/** Reach 接收方,由实际 Provider 校验邮箱、手机号或业务目的地。 */
|
|
26
28
|
recipients: z.array(z.string().min(1).max(320)).max(20).default([]),
|
|
27
29
|
/** 已初始化 Reach Provider 的唯一名称,不限制为邮件。 */
|
|
@@ -30,14 +32,39 @@ var FeedbackConfigSchema = z.strictObject({
|
|
|
30
32
|
adminUrl: z.url().refine((value) => ["http:", "https:"].includes(new URL(value).protocol)).optional(),
|
|
31
33
|
/** 补充材料收集超时,单位毫秒;超时后冻结为完整或部分材料。 */
|
|
32
34
|
collectionTimeoutMs: z.number().int().min(0).max(3e5).default(6e4),
|
|
35
|
+
/** 业务 collectEvidence 异步回调的等待上限,单位毫秒;超时后重试采集任务。 */
|
|
36
|
+
businessCollectionTimeoutMs: z.number().int().min(1).max(3e5).default(3e4),
|
|
37
|
+
/** 反馈采集结束后的 Webhook 地址;空字符串禁用,只允许可信 HTTP(S) 地址且不跟随重定向。 */
|
|
38
|
+
webhookUrl: z.string().max(2048).refine((value) => {
|
|
39
|
+
if (!value)
|
|
40
|
+
return true;
|
|
41
|
+
if (!URL.canParse(value))
|
|
42
|
+
return false;
|
|
43
|
+
const url = new URL(value);
|
|
44
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash;
|
|
45
|
+
}).default(""),
|
|
46
|
+
/** Webhook HMAC-SHA256 签名密钥;空字符串不签名,仅服务端私有环境配置。 */
|
|
47
|
+
webhookSecret: z.string().max(4096).default(""),
|
|
48
|
+
/** 单次 Webhook 请求超时,单位毫秒;非 2xx 或超时均进入持久重试。 */
|
|
49
|
+
webhookTimeoutMs: z.number().int().min(1).max(6e4).default(1e4),
|
|
33
50
|
/** 持久任务扫描间隔,单位毫秒;零值由宿主手动运行任务。 */
|
|
34
51
|
workerIntervalMs: z.number().int().min(0).max(6e4).default(1e3),
|
|
35
52
|
/** 持久任务执行租约时长,单位毫秒;过期后允许其他实例接管。 */
|
|
36
53
|
leaseMs: z.number().int().min(1e3).max(3e5).default(3e4),
|
|
37
|
-
/**
|
|
54
|
+
/** 汇集、通知、Webhook 和导出任务最大尝试次数;到期清理持续重试。 */
|
|
38
55
|
maxAttempts: z.number().int().min(1).max(20).default(5),
|
|
39
56
|
/** 已受理反馈及对象附件的保留天数。 */
|
|
40
57
|
retentionDays: z.number().int().min(1).max(3650).default(30),
|
|
58
|
+
/** 单个用户按数据库 UTC 日累计受理上限;幂等重试不重复计数。 */
|
|
59
|
+
maxReportsPerUserDay: z.number().int().min(1).max(1e4).default(20),
|
|
60
|
+
/** 单个用户每日预留存储字节(报告与最多历史导出);默认 2 GiB。 */
|
|
61
|
+
maxBytesPerUserDay: z.number().int().min(4 * 1024 * 1024).max(1024 ** 4).default(2 * 1024 ** 3),
|
|
62
|
+
/** 应用内单个 scope 的持久材料预留容量;到期清理成功后释放,默认 10 GiB。 */
|
|
63
|
+
maxStoredBytesPerScope: z.number().int().min(4 * 1024 * 1024).max(1024 ** 4).default(10 * 1024 ** 3),
|
|
64
|
+
/** 单份报告最多历史导出数;已有有效快照复用,不重复计数。 */
|
|
65
|
+
maxExportsPerReport: z.number().int().min(1).max(20).default(5),
|
|
66
|
+
/** 已完成导出的有效小时数;过期禁止下载,后台删除对象,默认 24 小时。 */
|
|
67
|
+
exportRetentionHours: z.number().int().min(1).max(720).default(24),
|
|
41
68
|
/** 允许采集的服务端环境变量名;凭据类名称仍按敏感信息策略排除。 */
|
|
42
69
|
envAllowlist: z.array(z.string().min(1).max(128)).max(100).default([]),
|
|
43
70
|
/** 未压缩导出总预算;客户端、附件总量和传输预算由此派生。 */
|
|
@@ -57,6 +84,7 @@ var EVIDENCE = "hai_feedback_evidence";
|
|
|
57
84
|
var ATTACHMENTS = "hai_feedback_attachments";
|
|
58
85
|
var HISTORY = "hai_feedback_history";
|
|
59
86
|
var JOBS = "hai_feedback_jobs";
|
|
87
|
+
var QUOTAS = "hai_feedback_quotas";
|
|
60
88
|
var identityColumns = ["id", "report_id", "scope_id", "app_id", "user_id", "source", "request_id", "error_id", "client_id"];
|
|
61
89
|
function feedbackTextEquals(column, value, params) {
|
|
62
90
|
if (!identityColumns.includes(column))
|
|
@@ -95,7 +123,7 @@ function readFeedbackJson(value) {
|
|
|
95
123
|
return typeof value === "string" ? JSON.parse(value) : value;
|
|
96
124
|
}
|
|
97
125
|
var FeedbackReportRepository = class extends BaseReldbCrudRepository {
|
|
98
|
-
tables = { reports: REPORTS, evidence: EVIDENCE, attachments: ATTACHMENTS, history: HISTORY, jobs: JOBS };
|
|
126
|
+
tables = { reports: REPORTS, evidence: EVIDENCE, attachments: ATTACHMENTS, history: HISTORY, jobs: JOBS, quotas: QUOTAS };
|
|
99
127
|
constructor() {
|
|
100
128
|
super(reldb, {
|
|
101
129
|
table: REPORTS,
|
|
@@ -107,6 +135,18 @@ var FeedbackReportRepository = class extends BaseReldbCrudRepository {
|
|
|
107
135
|
if (!report.success)
|
|
108
136
|
return report;
|
|
109
137
|
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
|
+
}],
|
|
110
150
|
[EVIDENCE, {
|
|
111
151
|
id: { type: "TEXT", primaryKey: true },
|
|
112
152
|
report_id: { type: "TEXT" },
|
|
@@ -163,9 +203,11 @@ var FeedbackReportRepository = class extends BaseReldbCrudRepository {
|
|
|
163
203
|
return created;
|
|
164
204
|
}
|
|
165
205
|
const indexes = [
|
|
206
|
+
{ table: QUOTAS, name: "hai_feedback_quotas_expiry", columns: ["app_id", "period"] },
|
|
166
207
|
{ table: REPORTS, name: "hai_feedback_reports_scope_time", columns: ["scope_id", "created_at"] },
|
|
167
208
|
{ table: REPORTS, name: "hai_feedback_reports_scope_status", columns: ["scope_id", "status", "created_at"] },
|
|
168
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"] },
|
|
169
211
|
{ table: EVIDENCE, name: "hai_feedback_evidence_temp_scope", columns: ["scope_id", "user_id", "timestamp"] },
|
|
170
212
|
{ table: ATTACHMENTS, name: "hai_feedback_attachments_report", columns: ["report_id"] },
|
|
171
213
|
{ table: HISTORY, name: "hai_feedback_history_report", columns: ["report_id"] },
|
|
@@ -270,28 +312,68 @@ function createFeedbackCapture(appId, envAllowlist, service) {
|
|
|
270
312
|
let capacityLoss;
|
|
271
313
|
let sequence = 0;
|
|
272
314
|
let totalBytes = 0;
|
|
315
|
+
let pendingBytes = 0;
|
|
316
|
+
let failures = 0;
|
|
317
|
+
let retryAfter = 0;
|
|
273
318
|
let stopped = false;
|
|
274
319
|
let flushing = null;
|
|
275
|
-
|
|
320
|
+
function removePending(key) {
|
|
321
|
+
const previous = pending.get(key);
|
|
322
|
+
if (previous)
|
|
323
|
+
pendingBytes -= previous.bytes;
|
|
324
|
+
pending.delete(key);
|
|
325
|
+
}
|
|
326
|
+
async function flush(force = false) {
|
|
276
327
|
if (flushing)
|
|
277
328
|
return flushing;
|
|
329
|
+
if (!force && retryAfter > Date.now())
|
|
330
|
+
return;
|
|
278
331
|
flushing = core.context.run({ suppressLogObservers: true }, async () => {
|
|
279
|
-
|
|
280
|
-
|
|
332
|
+
const snapshot = [...pending];
|
|
333
|
+
let offset = 0;
|
|
334
|
+
while (offset < snapshot.length) {
|
|
335
|
+
const batch = [];
|
|
336
|
+
let bytes = 0;
|
|
337
|
+
while (offset < snapshot.length && batch.length < 32) {
|
|
338
|
+
const [key, item] = snapshot[offset];
|
|
339
|
+
if (batch.length && bytes + item.bytes > 256 * 1024)
|
|
340
|
+
break;
|
|
341
|
+
offset++;
|
|
342
|
+
if (item.event.timestamp < Date.now() - 5 * 6e4) {
|
|
343
|
+
removePending(key);
|
|
344
|
+
rememberLoss(item, "server_pending_evidence_expired");
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
batch.push([key, item]);
|
|
348
|
+
bytes += item.bytes;
|
|
349
|
+
}
|
|
350
|
+
if (!batch.length)
|
|
351
|
+
continue;
|
|
352
|
+
const current2 = await reldb.sql.query(`SELECT id FROM hai_feedback_evidence WHERE id IN (${batch.map(() => "?").join(",")})`, batch.map(([key]) => key));
|
|
281
353
|
if (!current2.success) {
|
|
282
|
-
rememberLoss(item, "server_temporary_evidence_write_failed");
|
|
354
|
+
for (const [, item] of batch) rememberLoss(item, "server_temporary_evidence_write_failed");
|
|
355
|
+
retryAfter = Date.now() + Math.min(3e4, 250 * 2 ** Math.min(++failures, 7));
|
|
283
356
|
return;
|
|
284
357
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
358
|
+
const existing = new Set(current2.data.map((row) => row.id));
|
|
359
|
+
const additions = batch.filter(([key]) => !existing.has(key));
|
|
360
|
+
if (additions.length) {
|
|
361
|
+
const params = additions.flatMap(([key, item]) => {
|
|
362
|
+
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)];
|
|
364
|
+
});
|
|
365
|
+
const duplicate = reldb.config?.type === "mysql" ? "ON DUPLICATE KEY UPDATE id = id" : "ON CONFLICT (id) DO NOTHING";
|
|
366
|
+
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);
|
|
288
367
|
if (!result.success) {
|
|
289
|
-
rememberLoss(item, "server_temporary_evidence_write_failed");
|
|
368
|
+
for (const [, item] of batch) rememberLoss(item, "server_temporary_evidence_write_failed");
|
|
369
|
+
retryAfter = Date.now() + Math.min(3e4, 250 * 2 ** Math.min(++failures, 7));
|
|
290
370
|
return;
|
|
291
371
|
}
|
|
292
372
|
}
|
|
293
|
-
|
|
373
|
+
for (const [key] of batch) removePending(key);
|
|
294
374
|
}
|
|
375
|
+
failures = 0;
|
|
376
|
+
retryAfter = 0;
|
|
295
377
|
}).finally(() => {
|
|
296
378
|
flushing = null;
|
|
297
379
|
});
|
|
@@ -301,7 +383,7 @@ function createFeedbackCapture(appId, envAllowlist, service) {
|
|
|
301
383
|
for (const [key2, previous] of losses) {
|
|
302
384
|
if (previous.event.timestamp < Date.now() - 5 * 6e4) {
|
|
303
385
|
losses.delete(key2);
|
|
304
|
-
|
|
386
|
+
removePending(createHash("sha256").update(`${source}:${previous.event.sequence}`).digest("hex"));
|
|
305
387
|
}
|
|
306
388
|
}
|
|
307
389
|
if (capacityLoss && capacityLoss.timestamp < Date.now() - 5 * 6e4)
|
|
@@ -317,12 +399,21 @@ function createFeedbackCapture(appId, envAllowlist, service) {
|
|
|
317
399
|
}
|
|
318
400
|
const entry = { event, scopeId: item.scopeId, userId: item.userId, bytes: Buffer.byteLength(JSON.stringify(event)) };
|
|
319
401
|
losses.set(key, entry);
|
|
320
|
-
|
|
402
|
+
if (pendingBytes + entry.bytes <= 8 * 1024 * 1024) {
|
|
403
|
+
pending.set(createHash("sha256").update(`${source}:${event.sequence}`).digest("hex"), entry);
|
|
404
|
+
pendingBytes += entry.bytes;
|
|
405
|
+
} else {
|
|
406
|
+
capacityLoss ??= { ...event, requestId: void 0, missingReason: "server_capture_capacity_exceeded" };
|
|
407
|
+
}
|
|
321
408
|
}
|
|
322
409
|
function pin(item, errorId) {
|
|
323
410
|
if (item.event.kind === "error")
|
|
324
411
|
item.event.errorId = item.event.id;
|
|
325
412
|
else item.event.errorId ??= errorId;
|
|
413
|
+
const bytes = Buffer.byteLength(JSON.stringify(item.event));
|
|
414
|
+
if (item.buffered)
|
|
415
|
+
totalBytes += bytes - item.bytes;
|
|
416
|
+
item.bytes = bytes;
|
|
326
417
|
const key = createHash("sha256").update(`${source}:${item.event.sequence}`).digest("hex");
|
|
327
418
|
if (pending.has(key))
|
|
328
419
|
return;
|
|
@@ -330,8 +421,13 @@ function createFeedbackCapture(appId, envAllowlist, service) {
|
|
|
330
421
|
rememberLoss(item, "server_pending_evidence_overflow");
|
|
331
422
|
return;
|
|
332
423
|
}
|
|
424
|
+
if (pendingBytes + item.bytes > 8 * 1024 * 1024 - 128 * 1024) {
|
|
425
|
+
rememberLoss(item, "server_pending_evidence_bytes_exceeded");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
333
428
|
const event = { ...item.event };
|
|
334
429
|
pending.set(key, { ...item, event });
|
|
430
|
+
pendingBytes += item.bytes;
|
|
335
431
|
}
|
|
336
432
|
function observe(record) {
|
|
337
433
|
if (stopped || record.context.module === "feedback")
|
|
@@ -369,9 +465,12 @@ function createFeedbackCapture(appId, envAllowlist, service) {
|
|
|
369
465
|
else rememberLoss(item, "server_error_window_overflow");
|
|
370
466
|
}
|
|
371
467
|
buffer.push(item);
|
|
372
|
-
|
|
468
|
+
item.buffered = true;
|
|
469
|
+
totalBytes += item.bytes;
|
|
373
470
|
while (buffer.length > 1e4 || totalBytes > 16 * 1024 * 1024 || (buffer[0]?.event.timestamp ?? Date.now()) < Date.now() - 5 * 6e4) {
|
|
374
471
|
const removed = buffer.shift();
|
|
472
|
+
if (removed)
|
|
473
|
+
removed.buffered = false;
|
|
375
474
|
totalBytes -= removed?.bytes ?? 0;
|
|
376
475
|
if (removed && removed.event.timestamp >= Date.now() - 5 * 6e4)
|
|
377
476
|
rememberLoss(removed, "server_log_buffer_overflow");
|
|
@@ -384,7 +483,7 @@ function createFeedbackCapture(appId, envAllowlist, service) {
|
|
|
384
483
|
timer.unref();
|
|
385
484
|
return {
|
|
386
485
|
async collect(scopeId, userId, anchor) {
|
|
387
|
-
await flush();
|
|
486
|
+
await flush(true);
|
|
388
487
|
const params = [];
|
|
389
488
|
const identity = `${feedbackTextEquals("app_id", appId, params)} AND ${feedbackTextEquals("scope_id", scopeId, params)} AND ${feedbackTextEquals("user_id", userId, params)}`;
|
|
390
489
|
params.push(anchor - 5 * 6e4, anchor + 3e4);
|
|
@@ -446,7 +545,7 @@ function createFeedbackCapture(appId, envAllowlist, service) {
|
|
|
446
545
|
stopped = true;
|
|
447
546
|
stop();
|
|
448
547
|
clearInterval(timer);
|
|
449
|
-
await flush();
|
|
548
|
+
await flush(true);
|
|
450
549
|
}
|
|
451
550
|
};
|
|
452
551
|
}
|
|
@@ -489,13 +588,77 @@ function validActor(actor) {
|
|
|
489
588
|
function escapeHtml(value) {
|
|
490
589
|
return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character] ?? character);
|
|
491
590
|
}
|
|
492
|
-
function createFeedbackOperations(repo, config, capture) {
|
|
591
|
+
function createFeedbackOperations(repo, config, capture, options = {}) {
|
|
493
592
|
const budget = resolveFeedbackBudget(config);
|
|
494
593
|
const table = repo.tables;
|
|
495
594
|
const clock = feedbackClockSql();
|
|
496
595
|
let stopped = false;
|
|
497
596
|
let timer;
|
|
498
597
|
let running = null;
|
|
598
|
+
let idleRounds = 0;
|
|
599
|
+
let cleanupBacklog = false;
|
|
600
|
+
let cleanupAfter = 0;
|
|
601
|
+
let wakePending = false;
|
|
602
|
+
const shutdown = new AbortController();
|
|
603
|
+
function schedule(delay) {
|
|
604
|
+
if (stopped || !config.workerIntervalMs)
|
|
605
|
+
return;
|
|
606
|
+
if (timer)
|
|
607
|
+
clearTimeout(timer);
|
|
608
|
+
timer = setTimeout(() => {
|
|
609
|
+
timer = void 0;
|
|
610
|
+
void tick();
|
|
611
|
+
}, delay);
|
|
612
|
+
timer.unref();
|
|
613
|
+
}
|
|
614
|
+
function wakeWorker() {
|
|
615
|
+
idleRounds = 0;
|
|
616
|
+
if (running)
|
|
617
|
+
wakePending = true;
|
|
618
|
+
else
|
|
619
|
+
schedule(config.workerIntervalMs);
|
|
620
|
+
}
|
|
621
|
+
async function tick() {
|
|
622
|
+
if (stopped)
|
|
623
|
+
return;
|
|
624
|
+
const result = await runJobs();
|
|
625
|
+
idleRounds = result.success && (result.data.processed > 0 || cleanupBacklog || wakePending) ? 0 : Math.min(idleRounds + 1, 10);
|
|
626
|
+
wakePending = false;
|
|
627
|
+
schedule(Math.min(3e4, config.workerIntervalMs * 2 ** idleRounds));
|
|
628
|
+
}
|
|
629
|
+
const collectEvidence = options.collectEvidence;
|
|
630
|
+
function quotaExceeded() {
|
|
631
|
+
return err(HaiFeedbackError.QUOTA_EXCEEDED, feedbackM("feedback_quotaExceeded"));
|
|
632
|
+
}
|
|
633
|
+
function scopeQuotaId(scopeId) {
|
|
634
|
+
return hash(JSON.stringify([config.appId, scopeId, "storage"]));
|
|
635
|
+
}
|
|
636
|
+
async function reserveQuota(tx, actor, reportId) {
|
|
637
|
+
const time = await tx.get(`SELECT ${clock} AS now`);
|
|
638
|
+
if (!time.success)
|
|
639
|
+
return time;
|
|
640
|
+
const now = Number(time.data?.now);
|
|
641
|
+
if (!Number.isFinite(now))
|
|
642
|
+
return failed();
|
|
643
|
+
const day = Math.floor(now / 864e5) * 864e5;
|
|
644
|
+
const bytes = budget.maxTotalBytes * (config.maxExportsPerReport + 1);
|
|
645
|
+
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 }
|
|
648
|
+
];
|
|
649
|
+
for (const bucket of buckets) {
|
|
650
|
+
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]);
|
|
652
|
+
if (!inserted.success)
|
|
653
|
+
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]);
|
|
655
|
+
if (!reserved.success)
|
|
656
|
+
return reserved;
|
|
657
|
+
if (!reserved.data.changes)
|
|
658
|
+
return quotaExceeded();
|
|
659
|
+
}
|
|
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]);
|
|
661
|
+
}
|
|
499
662
|
async function access(id, actor, permission, sql = reldb.sql) {
|
|
500
663
|
if (!validActor(actor))
|
|
501
664
|
return forbidden();
|
|
@@ -619,28 +782,25 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
619
782
|
const result = await sql.query(`SELECT * FROM ${table.evidence} WHERE report_id = ? AND revision <= ? ORDER BY timestamp, source, sequence`, [id, revision]);
|
|
620
783
|
return result.success ? ok(result.data.map((row) => FeedbackEvidenceSchema.parse({ ...FeedbackEvidenceInputSchema.parse(readFeedbackJson(row.payload)), feedbackId: id, revision: Number(row.revision) }))) : result;
|
|
621
784
|
}
|
|
785
|
+
async function runJobs() {
|
|
786
|
+
if (stopped)
|
|
787
|
+
return err(HaiFeedbackError.NOT_INITIALIZED, feedbackM("feedback_notInitialized"));
|
|
788
|
+
if (running)
|
|
789
|
+
return running;
|
|
790
|
+
running = core.context.run({ suppressLogObservers: true }, processJobs).catch(failed).finally(() => {
|
|
791
|
+
running = null;
|
|
792
|
+
});
|
|
793
|
+
return running;
|
|
794
|
+
}
|
|
622
795
|
const operations = {
|
|
623
796
|
start() {
|
|
624
797
|
if (!config.workerIntervalMs || stopped)
|
|
625
798
|
return;
|
|
626
|
-
|
|
627
|
-
if (stopped)
|
|
628
|
-
return;
|
|
629
|
-
await operations.runJobs();
|
|
630
|
-
if (!stopped) {
|
|
631
|
-
timer = setTimeout(() => {
|
|
632
|
-
void tick();
|
|
633
|
-
}, config.workerIntervalMs);
|
|
634
|
-
timer.unref();
|
|
635
|
-
}
|
|
636
|
-
};
|
|
637
|
-
timer = setTimeout(() => {
|
|
638
|
-
void tick();
|
|
639
|
-
}, config.workerIntervalMs);
|
|
640
|
-
timer.unref();
|
|
799
|
+
schedule(config.workerIntervalMs);
|
|
641
800
|
},
|
|
642
801
|
async close() {
|
|
643
802
|
stopped = true;
|
|
803
|
+
shutdown.abort();
|
|
644
804
|
if (timer)
|
|
645
805
|
clearTimeout(timer);
|
|
646
806
|
await running;
|
|
@@ -654,6 +814,8 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
654
814
|
if (!parsed.success || parsed.data.appId !== config.appId)
|
|
655
815
|
return invalid();
|
|
656
816
|
const sanitized = FeedbackSubmitInputSchema.parse(sanitizeFeedbackValue(parsed.data));
|
|
817
|
+
if (sanitized.evidence?.some((event) => event.source.startsWith("server:")))
|
|
818
|
+
return invalid();
|
|
657
819
|
const submissionKey = hash(JSON.stringify([actor.scopeId, actor.userId, config.appId, sanitized.submissionId]));
|
|
658
820
|
const digest = hash(canonicalFeedbackJson(sanitized));
|
|
659
821
|
const existing = await reldb.sql.get(`SELECT * FROM ${table.reports} WHERE submission_key = ?`, [submissionKey]);
|
|
@@ -694,13 +856,16 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
694
856
|
const inserted = await repo.create({ ...row }, tx);
|
|
695
857
|
if (!inserted.success)
|
|
696
858
|
return inserted;
|
|
697
|
-
const
|
|
859
|
+
const reservation = await reserveQuota(tx, actor, id);
|
|
860
|
+
if (!reservation.success)
|
|
861
|
+
return reservation;
|
|
862
|
+
const initial = await appendInside(tx, row, [...sanitized.evidence ?? [], capture.environment()], true);
|
|
698
863
|
if (!initial.success)
|
|
699
864
|
return initial;
|
|
700
865
|
if ((sanitized.attachments ?? []).reduce((total, file) => total + file.size, 0) > budget.maxAttachmentsBytes)
|
|
701
866
|
return limited();
|
|
702
867
|
for (const attachment of sanitized.attachments ?? []) {
|
|
703
|
-
if (attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
|
|
868
|
+
if (attachment.id.startsWith("server:") || attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
|
|
704
869
|
return invalid();
|
|
705
870
|
const attachmentId = hash(`${id}:${attachment.id}`);
|
|
706
871
|
const data = { ...attachment, feedbackId: id, status: "pending" };
|
|
@@ -713,7 +878,12 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
713
878
|
if (!saved.success)
|
|
714
879
|
return saved;
|
|
715
880
|
}
|
|
716
|
-
|
|
881
|
+
if (config.webhookUrl) {
|
|
882
|
+
const webhook = await insertJob(tx, report, "webhook", "created", { url: config.webhookUrl }, row.collect_deadline);
|
|
883
|
+
if (!webhook.success)
|
|
884
|
+
return webhook;
|
|
885
|
+
}
|
|
886
|
+
const collected = await insertJob(tx, report, "collect", "initial", { anchor: sanitized.anchor?.timestamp ?? now, businessEvidence: !!collectEvidence }, row.collect_deadline);
|
|
717
887
|
if (!collected.success)
|
|
718
888
|
return collected;
|
|
719
889
|
const cleanup = await insertJob(tx, report, "cleanup", "expiry", {}, row.expires_at);
|
|
@@ -731,9 +901,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
731
901
|
return missing();
|
|
732
902
|
return duplicate.data.submission_hash === digest ? ok(repo.report(duplicate.data)) : conflict();
|
|
733
903
|
}
|
|
734
|
-
|
|
735
|
-
if (!environment.success)
|
|
736
|
-
core.logger.warn("Feedback environment collection deferred", { module: "feedback", feedbackId: id });
|
|
904
|
+
wakeWorker();
|
|
737
905
|
const current2 = await repo.find(id, actor.scopeId);
|
|
738
906
|
return current2.success && current2.data ? ok(repo.report(current2.data)) : result;
|
|
739
907
|
},
|
|
@@ -900,7 +1068,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
900
1068
|
if (!saved.success)
|
|
901
1069
|
return saved;
|
|
902
1070
|
}
|
|
903
|
-
const next = await reldb.sql.execute(`UPDATE ${table.jobs} SET next_run_at = ${clock}, updated_at = ${clock} WHERE report_id = ? AND kind IN ('collect', 'notify') AND status = 'pending'`, [input.id]);
|
|
1071
|
+
const next = await reldb.sql.execute(`UPDATE ${table.jobs} SET next_run_at = ${clock}, updated_at = ${clock} WHERE report_id = ? AND kind IN ('collect', 'notify', 'webhook') AND status = 'pending'`, [input.id]);
|
|
904
1072
|
return next.success ? ok(repo.report(row.data)) : next;
|
|
905
1073
|
},
|
|
906
1074
|
async uploadAttachment(input, actor) {
|
|
@@ -926,6 +1094,9 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
926
1094
|
return ok({ name: metadata.name, contentType: metadata.contentType, dataBase64: bytes.data.toString("base64") });
|
|
927
1095
|
},
|
|
928
1096
|
async retry(input, actor) {
|
|
1097
|
+
const parsed = FeedbackRetryInputSchema.safeParse(input);
|
|
1098
|
+
if (!parsed.success)
|
|
1099
|
+
return invalid();
|
|
929
1100
|
const permission = input.target === "export" ? "feedback:export" : "feedback:process";
|
|
930
1101
|
const row = await access(input.id, actor, permission);
|
|
931
1102
|
if (!row.success)
|
|
@@ -942,13 +1113,15 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
942
1113
|
if (!resumed.success)
|
|
943
1114
|
return resumed;
|
|
944
1115
|
}
|
|
1116
|
+
if (updated.success)
|
|
1117
|
+
wakeWorker();
|
|
945
1118
|
return updated.success ? ok(void 0) : updated;
|
|
946
1119
|
},
|
|
947
1120
|
async export(input, actor) {
|
|
948
1121
|
const parsed = FeedbackExportInputSchema.safeParse(input);
|
|
949
1122
|
if (!parsed.success)
|
|
950
1123
|
return invalid();
|
|
951
|
-
|
|
1124
|
+
const result = await repo.transaction(async (tx) => {
|
|
952
1125
|
const row = await access(input.id, actor, "feedback:export", tx);
|
|
953
1126
|
if (!row.success)
|
|
954
1127
|
return row;
|
|
@@ -962,12 +1135,20 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
962
1135
|
if (previous.data) {
|
|
963
1136
|
const snapshot = readFeedbackJson(previous.data.payload);
|
|
964
1137
|
const live = snapshot && typeof snapshot === "object" && "snapshotExpiresAt" in snapshot && typeof snapshot.snapshotExpiresAt === "number" && snapshot.snapshotExpiresAt > Date.now();
|
|
965
|
-
if (previous.data.status === "succeeded" || live)
|
|
1138
|
+
if (previous.data.status === "succeeded" && Number(previous.data.created_at) + config.exportRetentionHours * 36e5 > Date.now() || previous.data.status !== "succeeded" && live)
|
|
966
1139
|
return ok(jobData(previous.data));
|
|
967
1140
|
}
|
|
968
1141
|
if (revision < Number(row.data.revision)) {
|
|
969
1142
|
return missing();
|
|
970
1143
|
}
|
|
1144
|
+
const exports = await tx.get(`SELECT COUNT(*) AS count FROM ${table.jobs} WHERE report_id = ? AND kind = 'export'`, [input.id]);
|
|
1145
|
+
if (!exports.success)
|
|
1146
|
+
return exports;
|
|
1147
|
+
const reservation = await tx.get(`SELECT report_bytes, export_limit FROM ${table.quotas} WHERE id = ?`, [input.id]);
|
|
1148
|
+
if (!reservation.success)
|
|
1149
|
+
return reservation;
|
|
1150
|
+
if (!reservation.data || Number(exports.data?.count ?? 0) >= Math.min(config.maxExportsPerReport, Number(reservation.data.export_limit)))
|
|
1151
|
+
return quotaExceeded();
|
|
971
1152
|
const events = await evidence(input.id, revision, tx);
|
|
972
1153
|
if (!events.success)
|
|
973
1154
|
return events;
|
|
@@ -978,12 +1159,15 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
978
1159
|
if (!changes.success)
|
|
979
1160
|
return changes;
|
|
980
1161
|
const suffix = `${revision}:${row.data.version}${previous.data ? `:${previous.data.id}` : ""}`;
|
|
981
|
-
const added = await insertJob(tx, repo.report(row.data), "export", suffix, { report: repo.report(row.data), evidence: events.data, attachments: files.data, history: changes.data, snapshotExpiresAt: Date.now() + 36e5 }, Date.now());
|
|
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());
|
|
982
1163
|
if (!added.success)
|
|
983
1164
|
return added;
|
|
984
1165
|
const job = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [added.data]);
|
|
985
1166
|
return job.success && job.data ? ok(jobData(job.data)) : missing();
|
|
986
1167
|
});
|
|
1168
|
+
if (result.success)
|
|
1169
|
+
wakeWorker();
|
|
1170
|
+
return result;
|
|
987
1171
|
},
|
|
988
1172
|
async getExport(input, actor) {
|
|
989
1173
|
const row = await access(input.id, actor, "feedback:export");
|
|
@@ -1004,6 +1188,8 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1004
1188
|
return missing();
|
|
1005
1189
|
return err(HaiFeedbackError.CONFLICT, feedbackM("feedback_exportPending"));
|
|
1006
1190
|
}
|
|
1191
|
+
if (Number(job.data.created_at) + config.exportRetentionHours * 36e5 <= Date.now())
|
|
1192
|
+
return missing();
|
|
1007
1193
|
if (job.data.status === "failed")
|
|
1008
1194
|
return failed();
|
|
1009
1195
|
const data = readFeedbackJson(job.data.result);
|
|
@@ -1016,16 +1202,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1016
1202
|
return err(HaiFeedbackError.CHECKSUM_MISMATCH, feedbackM("feedback_checksumMismatch"));
|
|
1017
1203
|
return ok({ name: `${repo.report(row.data).number}.zip`, contentType: "application/zip", dataBase64: bytes.data.toString("base64") });
|
|
1018
1204
|
},
|
|
1019
|
-
|
|
1020
|
-
if (stopped)
|
|
1021
|
-
return err(HaiFeedbackError.NOT_INITIALIZED, feedbackM("feedback_notInitialized"));
|
|
1022
|
-
if (running)
|
|
1023
|
-
return running;
|
|
1024
|
-
running = core.context.run({ suppressLogObservers: true }, processJobs).catch(failed).finally(() => {
|
|
1025
|
-
running = null;
|
|
1026
|
-
});
|
|
1027
|
-
return running;
|
|
1028
|
-
}
|
|
1205
|
+
runJobs
|
|
1029
1206
|
};
|
|
1030
1207
|
async function upload(input, actor) {
|
|
1031
1208
|
const parsed = FeedbackUploadInputSchema.safeParse(input);
|
|
@@ -1084,6 +1261,124 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1084
1261
|
}
|
|
1085
1262
|
return ok({ ...metadata, status: "available" });
|
|
1086
1263
|
}
|
|
1264
|
+
async function collectBusiness(job, report, actor) {
|
|
1265
|
+
const marker = hash(`${report.id}:server:business:0`);
|
|
1266
|
+
const previous = await reldb.sql.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [marker]);
|
|
1267
|
+
if (!previous.success)
|
|
1268
|
+
return previous;
|
|
1269
|
+
if (previous.data)
|
|
1270
|
+
return ok(void 0);
|
|
1271
|
+
if (!collectEvidence)
|
|
1272
|
+
return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
|
|
1273
|
+
const controller = new AbortController();
|
|
1274
|
+
const signal = AbortSignal.any([controller.signal, shutdown.signal]);
|
|
1275
|
+
const timeout = setTimeout(() => controller.abort(), config.businessCollectionTimeoutMs);
|
|
1276
|
+
timeout.unref();
|
|
1277
|
+
let collected;
|
|
1278
|
+
let onAbort;
|
|
1279
|
+
try {
|
|
1280
|
+
collected = await Promise.race([
|
|
1281
|
+
Promise.resolve().then(() => collectEvidence({ report: Object.freeze({ ...report }), signal })),
|
|
1282
|
+
new Promise((_resolve, reject) => {
|
|
1283
|
+
onAbort = () => reject(new Error("Feedback business collection aborted"));
|
|
1284
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1285
|
+
if (signal.aborted)
|
|
1286
|
+
onAbort();
|
|
1287
|
+
})
|
|
1288
|
+
]);
|
|
1289
|
+
} catch {
|
|
1290
|
+
return failed();
|
|
1291
|
+
} finally {
|
|
1292
|
+
clearTimeout(timeout);
|
|
1293
|
+
if (onAbort)
|
|
1294
|
+
signal.removeEventListener("abort", onAbort);
|
|
1295
|
+
}
|
|
1296
|
+
if (!collected || typeof collected !== "object" || collected.text !== void 0 && typeof collected.text !== "string" || collected.attachments !== void 0 && !Array.isArray(collected.attachments))
|
|
1297
|
+
return invalid();
|
|
1298
|
+
if ((collected.attachments?.length ?? 0) > budget.maxAttachmentCount)
|
|
1299
|
+
return limited();
|
|
1300
|
+
const files = [];
|
|
1301
|
+
for (const input of collected.attachments ?? []) {
|
|
1302
|
+
if (!input || !(input.bytes instanceof Uint8Array) || typeof input.id !== "string" || input.id.length > 100)
|
|
1303
|
+
return invalid();
|
|
1304
|
+
if (input.bytes.byteLength > budget.maxAttachmentBytes)
|
|
1305
|
+
return limited();
|
|
1306
|
+
let bytes = Buffer.from(input.bytes);
|
|
1307
|
+
if (!matchesContentType(bytes, input.contentType))
|
|
1308
|
+
return invalid();
|
|
1309
|
+
if (input.contentType === "text/plain")
|
|
1310
|
+
bytes = Buffer.from(redactFeedbackText(bytes.toString("utf8")));
|
|
1311
|
+
if (input.contentType === "application/json")
|
|
1312
|
+
bytes = Buffer.from(JSON.stringify(sanitizeFeedbackValue(JSON.parse(bytes.toString("utf8")))));
|
|
1313
|
+
if (bytes.length > budget.maxAttachmentBytes)
|
|
1314
|
+
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) });
|
|
1316
|
+
if (!parsed.success || !input.id || files.some((file) => file.metadata.id === parsed.data.id))
|
|
1317
|
+
return invalid();
|
|
1318
|
+
const rowId = hash(`${report.id}:${parsed.data.id}`);
|
|
1319
|
+
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 });
|
|
1321
|
+
}
|
|
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) } }));
|
|
1323
|
+
if (!event.success)
|
|
1324
|
+
return invalid();
|
|
1325
|
+
async function checkCapacity(sql) {
|
|
1326
|
+
const existing = await attachments(report.id, sql);
|
|
1327
|
+
if (!existing.success)
|
|
1328
|
+
return existing;
|
|
1329
|
+
if (existing.data.length + files.length > budget.maxAttachmentCount || existing.data.reduce((total, item) => total + Number(item.size), 0) + files.reduce((total, file) => total + file.bytes.length, 0) > budget.maxAttachmentsBytes)
|
|
1330
|
+
return limited();
|
|
1331
|
+
return ok(void 0);
|
|
1332
|
+
}
|
|
1333
|
+
const frozenFiles = files.map((file) => ({ id: file.rowId, size: file.bytes.length, checksum: file.metadata.checksum })).sort((left, right) => left.id.localeCompare(right.id));
|
|
1334
|
+
const frozen = await withLease(job, async (tx) => {
|
|
1335
|
+
const current2 = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [job.id]);
|
|
1336
|
+
if (!current2.success || !current2.data)
|
|
1337
|
+
return missing();
|
|
1338
|
+
const payload = readFeedbackJson(current2.data.payload);
|
|
1339
|
+
if (!payload || typeof payload !== "object")
|
|
1340
|
+
return invalid();
|
|
1341
|
+
if ("businessFiles" in payload)
|
|
1342
|
+
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]);
|
|
1344
|
+
return updated.success ? ok(void 0) : updated;
|
|
1345
|
+
});
|
|
1346
|
+
if (!frozen.success)
|
|
1347
|
+
return frozen;
|
|
1348
|
+
const capacity = await checkCapacity(reldb.sql);
|
|
1349
|
+
if (!capacity.success)
|
|
1350
|
+
return capacity;
|
|
1351
|
+
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)]) });
|
|
1353
|
+
if (!stored.success) {
|
|
1354
|
+
const existing = await storage.file.get(file.key);
|
|
1355
|
+
if (!existing.success || hash(existing.data) !== file.metadata.checksum)
|
|
1356
|
+
return stored;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return withLease(job, async (tx) => {
|
|
1360
|
+
const fresh = await access(report.id, actor, void 0, tx);
|
|
1361
|
+
if (!fresh.success)
|
|
1362
|
+
return fresh;
|
|
1363
|
+
const completed = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [marker]);
|
|
1364
|
+
if (!completed.success)
|
|
1365
|
+
return completed;
|
|
1366
|
+
if (completed.data)
|
|
1367
|
+
return ok(void 0);
|
|
1368
|
+
const capacity2 = await checkCapacity(tx);
|
|
1369
|
+
if (!capacity2.success)
|
|
1370
|
+
return capacity2;
|
|
1371
|
+
const appended = await appendInside(tx, fresh.data, [event.data], true);
|
|
1372
|
+
if (!appended.success)
|
|
1373
|
+
return appended;
|
|
1374
|
+
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)]);
|
|
1376
|
+
if (!saved.success)
|
|
1377
|
+
return saved;
|
|
1378
|
+
}
|
|
1379
|
+
return ok(void 0);
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1087
1382
|
async function processJobs() {
|
|
1088
1383
|
let processed = 0;
|
|
1089
1384
|
const candidateParams = [];
|
|
@@ -1123,8 +1418,6 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1123
1418
|
if (!terminal.success)
|
|
1124
1419
|
return terminal;
|
|
1125
1420
|
}
|
|
1126
|
-
if (!saved.data.changes && job.kind === "export" && result.success && result.data && typeof result.data === "object" && "key" in result.data && typeof result.data.key === "string")
|
|
1127
|
-
await storage.file.delete(result.data.key);
|
|
1128
1421
|
if (job.kind === "notify")
|
|
1129
1422
|
await notificationSummary(job.report_id);
|
|
1130
1423
|
processed++;
|
|
@@ -1136,8 +1429,47 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1136
1429
|
clearInterval(renewal);
|
|
1137
1430
|
}
|
|
1138
1431
|
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1432
|
+
if (Date.now() >= cleanupAfter) {
|
|
1433
|
+
const expiredExports = await reldb.sql.query(`SELECT * FROM ${table.jobs} WHERE app_id = ? AND kind = 'export' AND status = 'succeeded' AND created_at <= ${clock} - ? ORDER BY created_at, id LIMIT 20`, [config.appId, config.exportRetentionHours * 36e5]);
|
|
1434
|
+
if (!expiredExports.success)
|
|
1435
|
+
return expiredExports;
|
|
1436
|
+
for (const job of expiredExports.data) {
|
|
1437
|
+
if (job.app_id !== config.appId)
|
|
1438
|
+
continue;
|
|
1439
|
+
const result = readFeedbackJson(job.result);
|
|
1440
|
+
if (result && typeof result === "object" && "key" in result && typeof result.key === "string") {
|
|
1441
|
+
const deleted = await storage.file.delete(result.key);
|
|
1442
|
+
if (!deleted.success)
|
|
1443
|
+
return deleted;
|
|
1444
|
+
}
|
|
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]);
|
|
1446
|
+
if (!marked.success)
|
|
1447
|
+
return marked;
|
|
1448
|
+
}
|
|
1449
|
+
const quotaRows = await reldb.sql.query(`SELECT id FROM ${table.quotas} WHERE app_id = ? AND period > 0 AND period < ${clock} - 172800000 ORDER BY period, id LIMIT 100`, [config.appId]);
|
|
1450
|
+
if (!quotaRows.success)
|
|
1451
|
+
return quotaRows;
|
|
1452
|
+
if (quotaRows.data.length) {
|
|
1453
|
+
const removed = await reldb.sql.execute(`DELETE FROM ${table.quotas} WHERE id IN (${quotaRows.data.map(() => "?").join(",")})`, quotaRows.data.map((row) => row.id));
|
|
1454
|
+
if (!removed.success)
|
|
1455
|
+
return removed;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
if (Date.now() >= cleanupAfter) {
|
|
1459
|
+
const params = [];
|
|
1460
|
+
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()]);
|
|
1462
|
+
if (!expired.success)
|
|
1463
|
+
return expired;
|
|
1464
|
+
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)]);
|
|
1466
|
+
if (!removed.success)
|
|
1467
|
+
return removed;
|
|
1468
|
+
}
|
|
1469
|
+
cleanupBacklog = expired.data.length === 100;
|
|
1470
|
+
cleanupAfter = cleanupBacklog ? 0 : Date.now() + 3e4;
|
|
1471
|
+
}
|
|
1472
|
+
return ok({ processed });
|
|
1141
1473
|
}
|
|
1142
1474
|
async function executeJob(job) {
|
|
1143
1475
|
if (job.app_id !== config.appId)
|
|
@@ -1174,6 +1506,11 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1174
1506
|
});
|
|
1175
1507
|
if (!saved.success)
|
|
1176
1508
|
return saved;
|
|
1509
|
+
if ("businessEvidence" in payload && payload.businessEvidence === true) {
|
|
1510
|
+
const business = await collectBusiness(job, report, actor);
|
|
1511
|
+
if (!business.success)
|
|
1512
|
+
return business;
|
|
1513
|
+
}
|
|
1177
1514
|
const files = await attachments(report.id);
|
|
1178
1515
|
if (!files.success)
|
|
1179
1516
|
return files;
|
|
@@ -1184,6 +1521,50 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1184
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]));
|
|
1185
1522
|
return updated.success ? ok({ collected: captured.length }) : updated;
|
|
1186
1523
|
}
|
|
1524
|
+
if (job.kind === "webhook") {
|
|
1525
|
+
if (!("url" in payload) || payload.url !== config.webhookUrl || !config.webhookUrl)
|
|
1526
|
+
return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
|
|
1527
|
+
if (report.collectionStatus === "collecting")
|
|
1528
|
+
return ok({ pending: true });
|
|
1529
|
+
const frozen = await withLease(job, async (tx) => {
|
|
1530
|
+
const live = await access(report.id, actor, void 0, tx);
|
|
1531
|
+
if (!live.success)
|
|
1532
|
+
return live;
|
|
1533
|
+
if (live.data.collection_status === "collecting")
|
|
1534
|
+
return conflict();
|
|
1535
|
+
const stored = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [job.id]);
|
|
1536
|
+
if (!stored.success || !stored.data)
|
|
1537
|
+
return missing();
|
|
1538
|
+
const snapshot = readFeedbackJson(stored.data.payload);
|
|
1539
|
+
if (snapshot && typeof snapshot === "object" && "body" in snapshot && typeof snapshot.body === "string")
|
|
1540
|
+
return ok(snapshot.body);
|
|
1541
|
+
const files = await attachments(report.id, tx);
|
|
1542
|
+
if (!files.success)
|
|
1543
|
+
return files;
|
|
1544
|
+
const business = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [hash(`${report.id}:server:business:0`)]);
|
|
1545
|
+
if (!business.success)
|
|
1546
|
+
return business;
|
|
1547
|
+
const detailUrl = config.adminUrl ? new URL(config.adminUrl) : void 0;
|
|
1548
|
+
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 } : {} };
|
|
1550
|
+
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]);
|
|
1552
|
+
return saved.success ? ok(body) : saved;
|
|
1553
|
+
});
|
|
1554
|
+
if (!frozen.success)
|
|
1555
|
+
return frozen;
|
|
1556
|
+
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 };
|
|
1558
|
+
if (config.webhookSecret)
|
|
1559
|
+
headers["x-hai-feedback-signature"] = `sha256=${createHmac("sha256", config.webhookSecret).update(`${timestamp}.${frozen.data}`).digest("hex")}`;
|
|
1560
|
+
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)]) });
|
|
1562
|
+
await response.body?.cancel();
|
|
1563
|
+
return response.ok ? ok({ status: response.status }) : failed();
|
|
1564
|
+
} catch {
|
|
1565
|
+
return failed();
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1187
1568
|
if (job.kind === "notify") {
|
|
1188
1569
|
if (config.notificationMode === "disabled")
|
|
1189
1570
|
return ok({ disabled: true });
|
|
@@ -1195,7 +1576,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1195
1576
|
return live;
|
|
1196
1577
|
const current2 = repo.report(live.data);
|
|
1197
1578
|
if (config.notificationMode === "console") {
|
|
1198
|
-
core.logger.info("Feedback notification", { module: "feedback", number: current2.number, appId: current2.appId,
|
|
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 } : {} });
|
|
1199
1580
|
return ok({ channel: "console" });
|
|
1200
1581
|
}
|
|
1201
1582
|
const { HaiReachError, reach } = await import('@h-ai/reach');
|
|
@@ -1226,6 +1607,8 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1226
1607
|
if (!("report" in payload) || !("evidence" in payload) || !("attachments" in payload))
|
|
1227
1608
|
return invalid();
|
|
1228
1609
|
const fixed = payload;
|
|
1610
|
+
if (!Number.isSafeInteger(fixed.maxBytes) || fixed.maxBytes < 4 * 1024 * 1024 || fixed.maxBytes > 40 * 1024 * 1024)
|
|
1611
|
+
return invalid();
|
|
1229
1612
|
if (fixed.snapshotExpiresAt < Date.now())
|
|
1230
1613
|
return missing();
|
|
1231
1614
|
const files = [];
|
|
@@ -1245,12 +1628,17 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1245
1628
|
const info = attachmentData(file);
|
|
1246
1629
|
files.push({ id: info.id, name: info.name, contentType: info.contentType, checksum: info.checksum, bytes: stored2.data });
|
|
1247
1630
|
}
|
|
1248
|
-
const bundle = await createFeedbackBundle({ maxBytes:
|
|
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 });
|
|
1249
1632
|
if (!bundle.success)
|
|
1250
1633
|
return bundle;
|
|
1251
|
-
const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/exports/${job.id}
|
|
1634
|
+
const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/exports/${job.id}.zip`;
|
|
1252
1635
|
const stored = await storage.file.put(key, bundle.data, { contentType: "application/zip", ifNoneMatch: "*" });
|
|
1253
|
-
|
|
1636
|
+
if (!stored.success) {
|
|
1637
|
+
const existing = await storage.file.get(key);
|
|
1638
|
+
if (!existing.success || hash(existing.data) !== hash(bundle.data))
|
|
1639
|
+
return stored;
|
|
1640
|
+
}
|
|
1641
|
+
return ok({ key, checksum: hash(bundle.data), size: bundle.data.length });
|
|
1254
1642
|
}
|
|
1255
1643
|
const marked = await withLease(job, async (tx) => {
|
|
1256
1644
|
const locked = await repo.find(report.id, report.scopeId, tx);
|
|
@@ -1279,6 +1667,17 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1279
1667
|
if (!removed2.success)
|
|
1280
1668
|
return removed2;
|
|
1281
1669
|
}
|
|
1670
|
+
const reservation = await tx.get(`SELECT reserved_bytes FROM ${table.quotas} WHERE id = ?`, [report.id]);
|
|
1671
|
+
if (!reservation.success)
|
|
1672
|
+
return reservation;
|
|
1673
|
+
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)
|
|
1676
|
+
return released.success ? conflict() : released;
|
|
1677
|
+
const removed2 = await tx.execute(`DELETE FROM ${table.quotas} WHERE id = ?`, [report.id]);
|
|
1678
|
+
if (!removed2.success)
|
|
1679
|
+
return removed2;
|
|
1680
|
+
}
|
|
1282
1681
|
const removedJobs = await tx.execute(`DELETE FROM ${table.jobs} WHERE report_id = ? AND id <> ?`, [report.id, job.id]);
|
|
1283
1682
|
if (!removedJobs.success)
|
|
1284
1683
|
return removedJobs;
|
|
@@ -1295,9 +1694,13 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1295
1694
|
return operations;
|
|
1296
1695
|
}
|
|
1297
1696
|
function allowedContentType(type) {
|
|
1298
|
-
return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf"].includes(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);
|
|
1299
1698
|
}
|
|
1300
1699
|
function matchesContentType(bytes, type) {
|
|
1700
|
+
if (type === "application/zip")
|
|
1701
|
+
return bytes.length >= 4 && bytes[0] === 80 && bytes[1] === 75 && (bytes[2] === 3 && bytes[3] === 4 || bytes[2] === 5 && bytes[3] === 6);
|
|
1702
|
+
if (type === "application/gzip")
|
|
1703
|
+
return bytes.length >= 10 && bytes[0] === 31 && bytes[1] === 139 && bytes[2] === 8;
|
|
1301
1704
|
if (type === "image/png")
|
|
1302
1705
|
return bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && bytes.readUInt32BE(16) * bytes.readUInt32BE(20) <= 32 * 1024 * 1024;
|
|
1303
1706
|
if (type === "image/jpeg")
|
|
@@ -1329,7 +1732,7 @@ var generation = 0;
|
|
|
1329
1732
|
var initializing = false;
|
|
1330
1733
|
var inFlight = /* @__PURE__ */ new Set();
|
|
1331
1734
|
var feedback = {
|
|
1332
|
-
async init(config) {
|
|
1735
|
+
async init(config, options = {}) {
|
|
1333
1736
|
if (initializing)
|
|
1334
1737
|
return err(HaiFeedbackError.INIT_IN_PROGRESS, feedbackM("feedback_initInProgress"));
|
|
1335
1738
|
initializing = true;
|
|
@@ -1339,6 +1742,8 @@ var feedback = {
|
|
|
1339
1742
|
const parsed = FeedbackConfigSchema.safeParse(config);
|
|
1340
1743
|
if (!parsed.success)
|
|
1341
1744
|
return configFailure(parsed.error.issues.map((issue) => issue.path.join(".")));
|
|
1745
|
+
if (options.collectEvidence !== void 0 && typeof options.collectEvidence !== "function")
|
|
1746
|
+
return configFailure(["collectEvidence"]);
|
|
1342
1747
|
if (!reldb.isInitialized || !storage.isInitialized)
|
|
1343
1748
|
return configFailure([!reldb.isInitialized ? "reldb" : "storage"]);
|
|
1344
1749
|
if (parsed.data.notificationMode === "reach") {
|
|
@@ -1354,8 +1759,9 @@ var feedback = {
|
|
|
1354
1759
|
if (current) {
|
|
1355
1760
|
const previous = current;
|
|
1356
1761
|
current = null;
|
|
1762
|
+
const closing = previous.operations.close();
|
|
1357
1763
|
await Promise.allSettled([...inFlight]);
|
|
1358
|
-
await
|
|
1764
|
+
await closing;
|
|
1359
1765
|
await previous.capture.close();
|
|
1360
1766
|
}
|
|
1361
1767
|
const repository = new FeedbackReportRepository();
|
|
@@ -1365,7 +1771,7 @@ var feedback = {
|
|
|
1365
1771
|
if (epoch !== generation)
|
|
1366
1772
|
return notInitialized.result();
|
|
1367
1773
|
candidateCapture = createFeedbackCapture(parsed.data.appId, parsed.data.envAllowlist, { appVersion: parsed.data.appVersion, buildId: parsed.data.buildId, commit: parsed.data.commit });
|
|
1368
|
-
const operations = createFeedbackOperations(repository, parsed.data, candidateCapture);
|
|
1774
|
+
const operations = createFeedbackOperations(repository, parsed.data, candidateCapture, options);
|
|
1369
1775
|
if (epoch !== generation) {
|
|
1370
1776
|
await operations.close();
|
|
1371
1777
|
await candidateCapture.close();
|
|
@@ -1387,12 +1793,11 @@ var feedback = {
|
|
|
1387
1793
|
generation++;
|
|
1388
1794
|
const previous = current;
|
|
1389
1795
|
current = null;
|
|
1390
|
-
await Promise.allSettled([...inFlight]);
|
|
1391
|
-
if (!previous)
|
|
1392
|
-
return ok(void 0);
|
|
1393
1796
|
try {
|
|
1394
|
-
|
|
1395
|
-
await
|
|
1797
|
+
const closing = previous?.operations.close();
|
|
1798
|
+
await Promise.allSettled([...inFlight]);
|
|
1799
|
+
await closing;
|
|
1800
|
+
await previous?.capture.close();
|
|
1396
1801
|
return ok(void 0);
|
|
1397
1802
|
} catch {
|
|
1398
1803
|
return err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));
|