@h-ai/feedback 0.1.0-alpha.72 → 0.1.0-alpha.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +60 -3
- package/README.ja.md +60 -3
- package/README.md +60 -3
- package/dist/{chunk-PYKGS35A.js → chunk-UIVT267C.js} +5 -5
- package/dist/chunk-UIVT267C.js.map +1 -0
- package/dist/client/index.js +8 -3
- package/dist/client/index.js.map +1 -1
- package/dist/index.d.ts +76 -3
- package/dist/index.js +206 -20
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/dist/chunk-PYKGS35A.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import * as _h_ai_core from '@h-ai/core';
|
|
3
3
|
import { HaiResult, PaginatedResult } from '@h-ai/core';
|
|
4
|
-
import { FeedbackBudget, FeedbackSubmitInput,
|
|
4
|
+
import { FeedbackEvidenceInput, FeedbackReport, FeedbackBudget, FeedbackSubmitInput, FeedbackListInput, FeedbackDetail, FeedbackEvidenceQuery, FeedbackEvidence, FeedbackSetStatusInput, FeedbackAppendEvidenceInput, FeedbackCompleteInput, FeedbackUploadInput, FeedbackAttachment, FeedbackAttachmentData, FeedbackRetryInput, FeedbackExportInput, FeedbackJob } from '@h-ai/api-contract/feedback';
|
|
5
5
|
export { FeedbackAppendEvidenceInput, FeedbackAttachment, FeedbackAttachmentData, FeedbackCompleteInput, FeedbackDetail, FeedbackEvidence, FeedbackEvidenceQuery, FeedbackExportInput, FeedbackJob, FeedbackListInput, FeedbackReport, FeedbackRetryInput, FeedbackSetStatusInput, FeedbackSubmitInput, FeedbackUploadInput } from '@h-ai/api-contract/feedback';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -23,10 +23,18 @@ declare const FeedbackConfigSchema: z.ZodObject<{
|
|
|
23
23
|
console: "console";
|
|
24
24
|
reach: "reach";
|
|
25
25
|
}>>;
|
|
26
|
+
consoleContent: z.ZodDefault<z.ZodEnum<{
|
|
27
|
+
metadata: "metadata";
|
|
28
|
+
summary: "summary";
|
|
29
|
+
}>>;
|
|
26
30
|
recipients: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
27
31
|
notificationProvider: z.ZodDefault<z.ZodString>;
|
|
28
32
|
adminUrl: z.ZodOptional<z.ZodURL>;
|
|
29
33
|
collectionTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
34
|
+
businessCollectionTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
35
|
+
webhookUrl: z.ZodDefault<z.ZodString>;
|
|
36
|
+
webhookSecret: z.ZodDefault<z.ZodString>;
|
|
37
|
+
webhookTimeoutMs: z.ZodDefault<z.ZodNumber>;
|
|
30
38
|
workerIntervalMs: z.ZodDefault<z.ZodNumber>;
|
|
31
39
|
leaseMs: z.ZodDefault<z.ZodNumber>;
|
|
32
40
|
maxAttempts: z.ZodDefault<z.ZodNumber>;
|
|
@@ -93,6 +101,71 @@ interface FeedbackContext {
|
|
|
93
101
|
userId: string;
|
|
94
102
|
permissions?: readonly string[];
|
|
95
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* 业务采集附件;ID 在同一反馈内稳定,正文由框架计算校验和并写入私有存储。
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* import type { FeedbackCollectedAttachment } from '@h-ai/feedback'
|
|
109
|
+
* const file: FeedbackCollectedAttachment = { id: 'logs', name: 'logs.txt', contentType: 'text/plain', bytes: new TextEncoder().encode('diagnostics') }
|
|
110
|
+
* void file
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
interface FeedbackCollectedAttachment {
|
|
114
|
+
id: string;
|
|
115
|
+
name: string;
|
|
116
|
+
contentType: string;
|
|
117
|
+
bytes: Uint8Array;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 业务补充材料;短文本/结构化信息出现在 business 证据,完整日志使用附件。
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* import type { FeedbackCollectedEvidence } from '@h-ai/feedback'
|
|
124
|
+
* const evidence: FeedbackCollectedEvidence = { text: 'Order diagnostics', data: { orderId: 'order-1' } }
|
|
125
|
+
* void evidence
|
|
126
|
+
* ```
|
|
127
|
+
*/
|
|
128
|
+
interface FeedbackCollectedEvidence {
|
|
129
|
+
text?: string;
|
|
130
|
+
data?: FeedbackEvidenceInput['data'];
|
|
131
|
+
attachments?: FeedbackCollectedAttachment[];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* 采集扩展在持久任务中执行并等待异步结果;可能重试,宿主应按 report.id 幂等并响应 signal。
|
|
135
|
+
* @example
|
|
136
|
+
* ```ts
|
|
137
|
+
* import type { FeedbackInitOptions } from '@h-ai/feedback'
|
|
138
|
+
* const options: FeedbackInitOptions = { collectEvidence: async ({ report, signal }) => { signal.throwIfAborted(); return { text: report.number } } }
|
|
139
|
+
* void options
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
interface FeedbackInitOptions {
|
|
143
|
+
collectEvidence?: (context: {
|
|
144
|
+
report: Readonly<FeedbackReport>;
|
|
145
|
+
signal: AbortSignal;
|
|
146
|
+
}) => FeedbackCollectedEvidence | Promise<FeedbackCollectedEvidence>;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 采集结束后发送的 feedback.created 事件;eventId 跨重试稳定,附件正文通过受权 API 读取。
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* import type { FeedbackWebhookEvent } from '@h-ai/feedback'
|
|
153
|
+
* function eventKey(event: FeedbackWebhookEvent) { return event.eventId }
|
|
154
|
+
* void eventKey
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
interface FeedbackWebhookEvent {
|
|
158
|
+
event: 'feedback.created';
|
|
159
|
+
eventId: string;
|
|
160
|
+
createdAt: number;
|
|
161
|
+
report: FeedbackReport;
|
|
162
|
+
evidence: {
|
|
163
|
+
revision: number;
|
|
164
|
+
business: boolean;
|
|
165
|
+
attachments: FeedbackAttachment[];
|
|
166
|
+
};
|
|
167
|
+
detailUrl?: string;
|
|
168
|
+
}
|
|
96
169
|
/**
|
|
97
170
|
* 反馈服务对象;领域操作返回 HaiResult,close 不释放宿主依赖连接。
|
|
98
171
|
* @example
|
|
@@ -106,7 +179,7 @@ interface FeedbackContext {
|
|
|
106
179
|
* ```
|
|
107
180
|
*/
|
|
108
181
|
interface FeedbackFunctions {
|
|
109
|
-
init: (config: FeedbackConfigInput) => Promise<HaiResult<void>>;
|
|
182
|
+
init: (config: FeedbackConfigInput, options?: FeedbackInitOptions) => Promise<HaiResult<void>>;
|
|
110
183
|
close: () => Promise<HaiResult<void>>;
|
|
111
184
|
readonly isInitialized: boolean;
|
|
112
185
|
readonly config: FeedbackConfig | null;
|
|
@@ -158,4 +231,4 @@ interface FeedbackFunctions {
|
|
|
158
231
|
*/
|
|
159
232
|
declare const feedback: FeedbackFunctions;
|
|
160
233
|
|
|
161
|
-
export { type FeedbackConfig, type FeedbackConfigInput, FeedbackConfigSchema, type FeedbackContext, type FeedbackFunctions, HaiFeedbackError, feedback };
|
|
234
|
+
export { type FeedbackCollectedAttachment, type FeedbackCollectedEvidence, type FeedbackConfig, type FeedbackConfigInput, FeedbackConfigSchema, type FeedbackContext, type FeedbackFunctions, type FeedbackInitOptions, type FeedbackWebhookEvent, HaiFeedbackError, feedback };
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-
|
|
2
|
-
export { HaiFeedbackError } from './chunk-
|
|
1
|
+
import { HaiFeedbackError, feedbackM, redactFeedbackText } from './chunk-UIVT267C.js';
|
|
2
|
+
export { HaiFeedbackError } from './chunk-UIVT267C.js';
|
|
3
3
|
import { createFeedbackBundle } from './chunk-5QWKURWU.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { resolveFeedbackBudget, FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema } from '@h-ai/api-contract/feedback';
|
|
5
|
+
import { resolveFeedbackBudget, FeedbackReportSchema, FeedbackEvidenceInputSchema, FeedbackExportInputSchema, FeedbackRetryInputSchema, FeedbackCompleteInputSchema, FeedbackAppendEvidenceInputSchema, FeedbackSetStatusInputSchema, FeedbackEvidenceQuerySchema, FeedbackEvidenceSchema, FeedbackListInputSchema, FeedbackSubmitInputSchema, FeedbackJobSchema, FeedbackHistorySchema, FeedbackAttachmentSchema, FeedbackUploadInputSchema, FeedbackAttachmentInputSchema } from '@h-ai/api-contract/feedback';
|
|
6
6
|
import { core, ok, err } from '@h-ai/core';
|
|
7
7
|
import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
8
8
|
import { storage } from '@h-ai/storage';
|
|
9
9
|
import { Buffer } from 'buffer';
|
|
10
|
-
import { randomUUID, createHash } from 'crypto';
|
|
10
|
+
import { randomUUID, createHash, createHmac } from 'crypto';
|
|
11
11
|
import { totalmem, arch, release, platform } from 'os';
|
|
12
12
|
import process from 'process';
|
|
13
13
|
|
|
@@ -20,8 +20,10 @@ var FeedbackConfigSchema = z.strictObject({
|
|
|
20
20
|
buildId: z.string().max(128).optional(),
|
|
21
21
|
/** 源码提交标识,帮助定位对应代码。 */
|
|
22
22
|
commit: z.string().max(128).optional(),
|
|
23
|
-
/** disabled 不通知,console
|
|
23
|
+
/** disabled 不通知,console 默认仅输出元数据,reach 使用已有通知通道。 */
|
|
24
24
|
notificationMode: z.enum(["disabled", "console", "reach"]).default("reach"),
|
|
25
|
+
/** console 通知内容;metadata 只输出定位和采集状态,summary 显式加入通用脱敏摘要。 */
|
|
26
|
+
consoleContent: z.enum(["metadata", "summary"]).default("metadata"),
|
|
25
27
|
/** Reach 接收方,由实际 Provider 校验邮箱、手机号或业务目的地。 */
|
|
26
28
|
recipients: z.array(z.string().min(1).max(320)).max(20).default([]),
|
|
27
29
|
/** 已初始化 Reach Provider 的唯一名称,不限制为邮件。 */
|
|
@@ -30,11 +32,26 @@ var FeedbackConfigSchema = z.strictObject({
|
|
|
30
32
|
adminUrl: z.url().refine((value) => ["http:", "https:"].includes(new URL(value).protocol)).optional(),
|
|
31
33
|
/** 补充材料收集超时,单位毫秒;超时后冻结为完整或部分材料。 */
|
|
32
34
|
collectionTimeoutMs: z.number().int().min(0).max(3e5).default(6e4),
|
|
35
|
+
/** 业务 collectEvidence 异步回调的等待上限,单位毫秒;超时后重试采集任务。 */
|
|
36
|
+
businessCollectionTimeoutMs: z.number().int().min(1).max(3e5).default(3e4),
|
|
37
|
+
/** 反馈采集结束后的 Webhook 地址;空字符串禁用,只允许可信 HTTP(S) 地址且不跟随重定向。 */
|
|
38
|
+
webhookUrl: z.string().max(2048).refine((value) => {
|
|
39
|
+
if (!value)
|
|
40
|
+
return true;
|
|
41
|
+
if (!URL.canParse(value))
|
|
42
|
+
return false;
|
|
43
|
+
const url = new URL(value);
|
|
44
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash;
|
|
45
|
+
}).default(""),
|
|
46
|
+
/** Webhook HMAC-SHA256 签名密钥;空字符串不签名,仅服务端私有环境配置。 */
|
|
47
|
+
webhookSecret: z.string().max(4096).default(""),
|
|
48
|
+
/** 单次 Webhook 请求超时,单位毫秒;非 2xx 或超时均进入持久重试。 */
|
|
49
|
+
webhookTimeoutMs: z.number().int().min(1).max(6e4).default(1e4),
|
|
33
50
|
/** 持久任务扫描间隔,单位毫秒;零值由宿主手动运行任务。 */
|
|
34
51
|
workerIntervalMs: z.number().int().min(0).max(6e4).default(1e3),
|
|
35
52
|
/** 持久任务执行租约时长,单位毫秒;过期后允许其他实例接管。 */
|
|
36
53
|
leaseMs: z.number().int().min(1e3).max(3e5).default(3e4),
|
|
37
|
-
/**
|
|
54
|
+
/** 汇集、通知、Webhook 和导出任务最大尝试次数;到期清理持续重试。 */
|
|
38
55
|
maxAttempts: z.number().int().min(1).max(20).default(5),
|
|
39
56
|
/** 已受理反馈及对象附件的保留天数。 */
|
|
40
57
|
retentionDays: z.number().int().min(1).max(3650).default(30),
|
|
@@ -489,13 +506,15 @@ function validActor(actor) {
|
|
|
489
506
|
function escapeHtml(value) {
|
|
490
507
|
return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character] ?? character);
|
|
491
508
|
}
|
|
492
|
-
function createFeedbackOperations(repo, config, capture) {
|
|
509
|
+
function createFeedbackOperations(repo, config, capture, options = {}) {
|
|
493
510
|
const budget = resolveFeedbackBudget(config);
|
|
494
511
|
const table = repo.tables;
|
|
495
512
|
const clock = feedbackClockSql();
|
|
496
513
|
let stopped = false;
|
|
497
514
|
let timer;
|
|
498
515
|
let running = null;
|
|
516
|
+
const shutdown = new AbortController();
|
|
517
|
+
const collectEvidence = options.collectEvidence;
|
|
499
518
|
async function access(id, actor, permission, sql = reldb.sql) {
|
|
500
519
|
if (!validActor(actor))
|
|
501
520
|
return forbidden();
|
|
@@ -641,6 +660,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
641
660
|
},
|
|
642
661
|
async close() {
|
|
643
662
|
stopped = true;
|
|
663
|
+
shutdown.abort();
|
|
644
664
|
if (timer)
|
|
645
665
|
clearTimeout(timer);
|
|
646
666
|
await running;
|
|
@@ -700,7 +720,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
700
720
|
if ((sanitized.attachments ?? []).reduce((total, file) => total + file.size, 0) > budget.maxAttachmentsBytes)
|
|
701
721
|
return limited();
|
|
702
722
|
for (const attachment of sanitized.attachments ?? []) {
|
|
703
|
-
if (attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
|
|
723
|
+
if (attachment.id.startsWith("server:") || attachment.size > budget.maxAttachmentBytes || !allowedContentType(attachment.contentType))
|
|
704
724
|
return invalid();
|
|
705
725
|
const attachmentId = hash(`${id}:${attachment.id}`);
|
|
706
726
|
const data = { ...attachment, feedbackId: id, status: "pending" };
|
|
@@ -713,7 +733,12 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
713
733
|
if (!saved.success)
|
|
714
734
|
return saved;
|
|
715
735
|
}
|
|
716
|
-
|
|
736
|
+
if (config.webhookUrl) {
|
|
737
|
+
const webhook = await insertJob(tx, report, "webhook", "created", { url: config.webhookUrl }, row.collect_deadline);
|
|
738
|
+
if (!webhook.success)
|
|
739
|
+
return webhook;
|
|
740
|
+
}
|
|
741
|
+
const collected = await insertJob(tx, report, "collect", "initial", { anchor: sanitized.anchor?.timestamp ?? now, businessEvidence: !!collectEvidence }, row.collect_deadline);
|
|
717
742
|
if (!collected.success)
|
|
718
743
|
return collected;
|
|
719
744
|
const cleanup = await insertJob(tx, report, "cleanup", "expiry", {}, row.expires_at);
|
|
@@ -900,7 +925,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
900
925
|
if (!saved.success)
|
|
901
926
|
return saved;
|
|
902
927
|
}
|
|
903
|
-
const next = await reldb.sql.execute(`UPDATE ${table.jobs} SET next_run_at = ${clock}, updated_at = ${clock} WHERE report_id = ? AND kind IN ('collect', 'notify') AND status = 'pending'`, [input.id]);
|
|
928
|
+
const next = await reldb.sql.execute(`UPDATE ${table.jobs} SET next_run_at = ${clock}, updated_at = ${clock} WHERE report_id = ? AND kind IN ('collect', 'notify', 'webhook') AND status = 'pending'`, [input.id]);
|
|
904
929
|
return next.success ? ok(repo.report(row.data)) : next;
|
|
905
930
|
},
|
|
906
931
|
async uploadAttachment(input, actor) {
|
|
@@ -926,6 +951,9 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
926
951
|
return ok({ name: metadata.name, contentType: metadata.contentType, dataBase64: bytes.data.toString("base64") });
|
|
927
952
|
},
|
|
928
953
|
async retry(input, actor) {
|
|
954
|
+
const parsed = FeedbackRetryInputSchema.safeParse(input);
|
|
955
|
+
if (!parsed.success)
|
|
956
|
+
return invalid();
|
|
929
957
|
const permission = input.target === "export" ? "feedback:export" : "feedback:process";
|
|
930
958
|
const row = await access(input.id, actor, permission);
|
|
931
959
|
if (!row.success)
|
|
@@ -1084,6 +1112,109 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1084
1112
|
}
|
|
1085
1113
|
return ok({ ...metadata, status: "available" });
|
|
1086
1114
|
}
|
|
1115
|
+
async function collectBusiness(job, report, actor) {
|
|
1116
|
+
const marker = hash(`${report.id}:server:business:0`);
|
|
1117
|
+
const previous = await reldb.sql.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [marker]);
|
|
1118
|
+
if (!previous.success)
|
|
1119
|
+
return previous;
|
|
1120
|
+
if (previous.data)
|
|
1121
|
+
return ok(void 0);
|
|
1122
|
+
if (!collectEvidence)
|
|
1123
|
+
return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
|
|
1124
|
+
const controller = new AbortController();
|
|
1125
|
+
const signal = AbortSignal.any([controller.signal, shutdown.signal]);
|
|
1126
|
+
const timeout = setTimeout(() => controller.abort(), config.businessCollectionTimeoutMs);
|
|
1127
|
+
timeout.unref();
|
|
1128
|
+
let collected;
|
|
1129
|
+
let onAbort;
|
|
1130
|
+
try {
|
|
1131
|
+
collected = await Promise.race([
|
|
1132
|
+
Promise.resolve().then(() => collectEvidence({ report: Object.freeze({ ...report }), signal })),
|
|
1133
|
+
new Promise((_resolve, reject) => {
|
|
1134
|
+
onAbort = () => reject(new Error("Feedback business collection aborted"));
|
|
1135
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1136
|
+
if (signal.aborted)
|
|
1137
|
+
onAbort();
|
|
1138
|
+
})
|
|
1139
|
+
]);
|
|
1140
|
+
} catch {
|
|
1141
|
+
return failed();
|
|
1142
|
+
} finally {
|
|
1143
|
+
clearTimeout(timeout);
|
|
1144
|
+
if (onAbort)
|
|
1145
|
+
signal.removeEventListener("abort", onAbort);
|
|
1146
|
+
}
|
|
1147
|
+
if (!collected || typeof collected !== "object" || collected.text !== void 0 && typeof collected.text !== "string" || collected.attachments !== void 0 && !Array.isArray(collected.attachments))
|
|
1148
|
+
return invalid();
|
|
1149
|
+
if ((collected.attachments?.length ?? 0) > budget.maxAttachmentCount)
|
|
1150
|
+
return limited();
|
|
1151
|
+
const files = [];
|
|
1152
|
+
for (const input of collected.attachments ?? []) {
|
|
1153
|
+
if (!input || !(input.bytes instanceof Uint8Array) || typeof input.id !== "string" || input.id.length > 100)
|
|
1154
|
+
return invalid();
|
|
1155
|
+
if (input.bytes.byteLength > budget.maxAttachmentBytes)
|
|
1156
|
+
return limited();
|
|
1157
|
+
let bytes = Buffer.from(input.bytes);
|
|
1158
|
+
if (!matchesContentType(bytes, input.contentType))
|
|
1159
|
+
return invalid();
|
|
1160
|
+
if (input.contentType === "text/plain")
|
|
1161
|
+
bytes = Buffer.from(redactFeedbackText(bytes.toString("utf8")));
|
|
1162
|
+
if (input.contentType === "application/json")
|
|
1163
|
+
bytes = Buffer.from(JSON.stringify(sanitizeFeedbackValue(JSON.parse(bytes.toString("utf8")))));
|
|
1164
|
+
if (bytes.length > budget.maxAttachmentBytes)
|
|
1165
|
+
return limited();
|
|
1166
|
+
const parsed = FeedbackAttachmentInputSchema.safeParse({ id: `server:business:${input.id}`, name: typeof input.name === "string" ? redactFeedbackText(input.name) : input.name, contentType: input.contentType, size: bytes.length, checksum: hash(bytes) });
|
|
1167
|
+
if (!parsed.success || !input.id || files.some((file) => file.metadata.id === parsed.data.id))
|
|
1168
|
+
return invalid();
|
|
1169
|
+
const rowId = hash(`${report.id}:${parsed.data.id}`);
|
|
1170
|
+
const key = `feedback/${hash(report.scopeId).slice(0, 16)}/${report.id}/business/${rowId}-${parsed.data.checksum}`;
|
|
1171
|
+
files.push({ metadata: { ...parsed.data, feedbackId: report.id, status: "available" }, bytes, key, rowId });
|
|
1172
|
+
}
|
|
1173
|
+
const event = FeedbackEvidenceInputSchema.safeParse(sanitizeFeedbackValue({ id: `business-${report.id}`, source: "server:business", sequence: 0, kind: "business", timestamp: report.createdAt, data: { ...collected.data, ...collected.text !== void 0 ? { text: collected.text } : {}, attachmentIds: files.map((file) => file.metadata.id) } }));
|
|
1174
|
+
if (!event.success)
|
|
1175
|
+
return invalid();
|
|
1176
|
+
async function checkCapacity(sql) {
|
|
1177
|
+
const existing = await attachments(report.id, sql);
|
|
1178
|
+
if (!existing.success)
|
|
1179
|
+
return existing;
|
|
1180
|
+
if (existing.data.length + files.length > budget.maxAttachmentCount || existing.data.reduce((total, item) => total + Number(item.size), 0) + files.reduce((total, file) => total + file.bytes.length, 0) > budget.maxAttachmentsBytes)
|
|
1181
|
+
return limited();
|
|
1182
|
+
return ok(void 0);
|
|
1183
|
+
}
|
|
1184
|
+
const capacity = await checkCapacity(reldb.sql);
|
|
1185
|
+
if (!capacity.success)
|
|
1186
|
+
return capacity;
|
|
1187
|
+
for (const file of files) {
|
|
1188
|
+
const stored = await storage.file.put(file.key, file.bytes, { contentType: file.metadata.contentType, ifNoneMatch: "*", signal: AbortSignal.any([shutdown.signal, AbortSignal.timeout(3e4)]) });
|
|
1189
|
+
if (!stored.success) {
|
|
1190
|
+
const existing = await storage.file.get(file.key);
|
|
1191
|
+
if (!existing.success || hash(existing.data) !== file.metadata.checksum)
|
|
1192
|
+
return stored;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return withLease(job, async (tx) => {
|
|
1196
|
+
const fresh = await access(report.id, actor, void 0, tx);
|
|
1197
|
+
if (!fresh.success)
|
|
1198
|
+
return fresh;
|
|
1199
|
+
const completed = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [marker]);
|
|
1200
|
+
if (!completed.success)
|
|
1201
|
+
return completed;
|
|
1202
|
+
if (completed.data)
|
|
1203
|
+
return ok(void 0);
|
|
1204
|
+
const capacity2 = await checkCapacity(tx);
|
|
1205
|
+
if (!capacity2.success)
|
|
1206
|
+
return capacity2;
|
|
1207
|
+
const appended = await appendInside(tx, fresh.data, [event.data], true);
|
|
1208
|
+
if (!appended.success)
|
|
1209
|
+
return appended;
|
|
1210
|
+
for (const file of files) {
|
|
1211
|
+
const saved = await tx.execute(`INSERT INTO ${table.attachments} (id, client_id, report_id, scope_id, object_key, status, size, checksum, updated_at, payload) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [file.rowId, file.metadata.id, report.id, actor.scopeId, file.key, "available", file.bytes.length, file.metadata.checksum, Date.now(), JSON.stringify(file.metadata)]);
|
|
1212
|
+
if (!saved.success)
|
|
1213
|
+
return saved;
|
|
1214
|
+
}
|
|
1215
|
+
return ok(void 0);
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1087
1218
|
async function processJobs() {
|
|
1088
1219
|
let processed = 0;
|
|
1089
1220
|
const candidateParams = [];
|
|
@@ -1174,6 +1305,11 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1174
1305
|
});
|
|
1175
1306
|
if (!saved.success)
|
|
1176
1307
|
return saved;
|
|
1308
|
+
if ("businessEvidence" in payload && payload.businessEvidence === true) {
|
|
1309
|
+
const business = await collectBusiness(job, report, actor);
|
|
1310
|
+
if (!business.success)
|
|
1311
|
+
return business;
|
|
1312
|
+
}
|
|
1177
1313
|
const files = await attachments(report.id);
|
|
1178
1314
|
if (!files.success)
|
|
1179
1315
|
return files;
|
|
@@ -1184,6 +1320,50 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1184
1320
|
const updated = await withLease(job, (tx) => tx.execute(`UPDATE ${table.reports} SET collection_status = ?, updated_at = ${clock} WHERE id = ?`, [partial ? "partial" : "complete", report.id]));
|
|
1185
1321
|
return updated.success ? ok({ collected: captured.length }) : updated;
|
|
1186
1322
|
}
|
|
1323
|
+
if (job.kind === "webhook") {
|
|
1324
|
+
if (!("url" in payload) || payload.url !== config.webhookUrl || !config.webhookUrl)
|
|
1325
|
+
return err(HaiFeedbackError.CONFIG_ERROR, feedbackM("feedback_configError"));
|
|
1326
|
+
if (report.collectionStatus === "collecting")
|
|
1327
|
+
return ok({ pending: true });
|
|
1328
|
+
const frozen = await withLease(job, async (tx) => {
|
|
1329
|
+
const live = await access(report.id, actor, void 0, tx);
|
|
1330
|
+
if (!live.success)
|
|
1331
|
+
return live;
|
|
1332
|
+
if (live.data.collection_status === "collecting")
|
|
1333
|
+
return conflict();
|
|
1334
|
+
const stored = await tx.get(`SELECT * FROM ${table.jobs} WHERE id = ?`, [job.id]);
|
|
1335
|
+
if (!stored.success || !stored.data)
|
|
1336
|
+
return missing();
|
|
1337
|
+
const snapshot = readFeedbackJson(stored.data.payload);
|
|
1338
|
+
if (snapshot && typeof snapshot === "object" && "body" in snapshot && typeof snapshot.body === "string")
|
|
1339
|
+
return ok(snapshot.body);
|
|
1340
|
+
const files = await attachments(report.id, tx);
|
|
1341
|
+
if (!files.success)
|
|
1342
|
+
return files;
|
|
1343
|
+
const business = await tx.get(`SELECT id FROM ${table.evidence} WHERE id = ?`, [hash(`${report.id}:server:business:0`)]);
|
|
1344
|
+
if (!business.success)
|
|
1345
|
+
return business;
|
|
1346
|
+
const detailUrl = config.adminUrl ? new URL(config.adminUrl) : void 0;
|
|
1347
|
+
detailUrl?.searchParams.set("feedback", report.id);
|
|
1348
|
+
const event = { event: "feedback.created", eventId: job.id, createdAt: report.createdAt, report: repo.report(live.data), evidence: { revision: Number(live.data.revision), business: !!business.data, attachments: files.data.map(attachmentData) }, ...detailUrl ? { detailUrl: detailUrl.href } : {} };
|
|
1349
|
+
const body = JSON.stringify(event);
|
|
1350
|
+
const saved = await tx.execute(`UPDATE ${table.jobs} SET payload = ? WHERE id = ? AND token = ?`, [JSON.stringify({ url: config.webhookUrl, body }), job.id, job.token]);
|
|
1351
|
+
return saved.success ? ok(body) : saved;
|
|
1352
|
+
});
|
|
1353
|
+
if (!frozen.success)
|
|
1354
|
+
return frozen;
|
|
1355
|
+
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
1356
|
+
const headers = { "content-type": "application/json", "idempotency-key": job.id, "x-hai-feedback-event": "feedback.created", "x-hai-feedback-timestamp": timestamp };
|
|
1357
|
+
if (config.webhookSecret)
|
|
1358
|
+
headers["x-hai-feedback-signature"] = `sha256=${createHmac("sha256", config.webhookSecret).update(`${timestamp}.${frozen.data}`).digest("hex")}`;
|
|
1359
|
+
try {
|
|
1360
|
+
const response = await fetch(config.webhookUrl, { method: "POST", headers, body: frozen.data, redirect: "manual", signal: AbortSignal.any([shutdown.signal, AbortSignal.timeout(config.webhookTimeoutMs)]) });
|
|
1361
|
+
await response.body?.cancel();
|
|
1362
|
+
return response.ok ? ok({ status: response.status }) : failed();
|
|
1363
|
+
} catch {
|
|
1364
|
+
return failed();
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1187
1367
|
if (job.kind === "notify") {
|
|
1188
1368
|
if (config.notificationMode === "disabled")
|
|
1189
1369
|
return ok({ disabled: true });
|
|
@@ -1195,7 +1375,7 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1195
1375
|
return live;
|
|
1196
1376
|
const current2 = repo.report(live.data);
|
|
1197
1377
|
if (config.notificationMode === "console") {
|
|
1198
|
-
core.logger.info("Feedback notification", { module: "feedback", number: current2.number, appId: current2.appId,
|
|
1378
|
+
core.logger.info("Feedback notification", { module: "feedback", number: current2.number, appId: current2.appId, appVersion: current2.appVersion, buildId: current2.buildId, commit: current2.commit, collectionStatus: current2.collectionStatus, ...config.consoleContent === "summary" ? { summary: current2.summary } : {} });
|
|
1199
1379
|
return ok({ channel: "console" });
|
|
1200
1380
|
}
|
|
1201
1381
|
const { HaiReachError, reach } = await import('@h-ai/reach');
|
|
@@ -1295,9 +1475,13 @@ function createFeedbackOperations(repo, config, capture) {
|
|
|
1295
1475
|
return operations;
|
|
1296
1476
|
}
|
|
1297
1477
|
function allowedContentType(type) {
|
|
1298
|
-
return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf"].includes(type);
|
|
1478
|
+
return ["image/png", "image/jpeg", "image/webp", "video/webm", "video/mp4", "text/plain", "application/json", "application/pdf", "application/zip", "application/gzip"].includes(type);
|
|
1299
1479
|
}
|
|
1300
1480
|
function matchesContentType(bytes, type) {
|
|
1481
|
+
if (type === "application/zip")
|
|
1482
|
+
return bytes.length >= 4 && bytes[0] === 80 && bytes[1] === 75 && (bytes[2] === 3 && bytes[3] === 4 || bytes[2] === 5 && bytes[3] === 6);
|
|
1483
|
+
if (type === "application/gzip")
|
|
1484
|
+
return bytes.length >= 10 && bytes[0] === 31 && bytes[1] === 139 && bytes[2] === 8;
|
|
1301
1485
|
if (type === "image/png")
|
|
1302
1486
|
return bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && bytes.readUInt32BE(16) * bytes.readUInt32BE(20) <= 32 * 1024 * 1024;
|
|
1303
1487
|
if (type === "image/jpeg")
|
|
@@ -1329,7 +1513,7 @@ var generation = 0;
|
|
|
1329
1513
|
var initializing = false;
|
|
1330
1514
|
var inFlight = /* @__PURE__ */ new Set();
|
|
1331
1515
|
var feedback = {
|
|
1332
|
-
async init(config) {
|
|
1516
|
+
async init(config, options = {}) {
|
|
1333
1517
|
if (initializing)
|
|
1334
1518
|
return err(HaiFeedbackError.INIT_IN_PROGRESS, feedbackM("feedback_initInProgress"));
|
|
1335
1519
|
initializing = true;
|
|
@@ -1339,6 +1523,8 @@ var feedback = {
|
|
|
1339
1523
|
const parsed = FeedbackConfigSchema.safeParse(config);
|
|
1340
1524
|
if (!parsed.success)
|
|
1341
1525
|
return configFailure(parsed.error.issues.map((issue) => issue.path.join(".")));
|
|
1526
|
+
if (options.collectEvidence !== void 0 && typeof options.collectEvidence !== "function")
|
|
1527
|
+
return configFailure(["collectEvidence"]);
|
|
1342
1528
|
if (!reldb.isInitialized || !storage.isInitialized)
|
|
1343
1529
|
return configFailure([!reldb.isInitialized ? "reldb" : "storage"]);
|
|
1344
1530
|
if (parsed.data.notificationMode === "reach") {
|
|
@@ -1354,8 +1540,9 @@ var feedback = {
|
|
|
1354
1540
|
if (current) {
|
|
1355
1541
|
const previous = current;
|
|
1356
1542
|
current = null;
|
|
1543
|
+
const closing = previous.operations.close();
|
|
1357
1544
|
await Promise.allSettled([...inFlight]);
|
|
1358
|
-
await
|
|
1545
|
+
await closing;
|
|
1359
1546
|
await previous.capture.close();
|
|
1360
1547
|
}
|
|
1361
1548
|
const repository = new FeedbackReportRepository();
|
|
@@ -1365,7 +1552,7 @@ var feedback = {
|
|
|
1365
1552
|
if (epoch !== generation)
|
|
1366
1553
|
return notInitialized.result();
|
|
1367
1554
|
candidateCapture = createFeedbackCapture(parsed.data.appId, parsed.data.envAllowlist, { appVersion: parsed.data.appVersion, buildId: parsed.data.buildId, commit: parsed.data.commit });
|
|
1368
|
-
const operations = createFeedbackOperations(repository, parsed.data, candidateCapture);
|
|
1555
|
+
const operations = createFeedbackOperations(repository, parsed.data, candidateCapture, options);
|
|
1369
1556
|
if (epoch !== generation) {
|
|
1370
1557
|
await operations.close();
|
|
1371
1558
|
await candidateCapture.close();
|
|
@@ -1387,12 +1574,11 @@ var feedback = {
|
|
|
1387
1574
|
generation++;
|
|
1388
1575
|
const previous = current;
|
|
1389
1576
|
current = null;
|
|
1390
|
-
await Promise.allSettled([...inFlight]);
|
|
1391
|
-
if (!previous)
|
|
1392
|
-
return ok(void 0);
|
|
1393
1577
|
try {
|
|
1394
|
-
|
|
1395
|
-
await
|
|
1578
|
+
const closing = previous?.operations.close();
|
|
1579
|
+
await Promise.allSettled([...inFlight]);
|
|
1580
|
+
await closing;
|
|
1581
|
+
await previous?.capture.close();
|
|
1396
1582
|
return ok(void 0);
|
|
1397
1583
|
} catch {
|
|
1398
1584
|
return err(HaiFeedbackError.OPERATION_FAILED, feedbackM("feedback_operationFailed"));
|