dsh-data-cleaning-agent 0.3.0 → 0.4.0
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/CHANGELOG.md +33 -0
- package/README.en.md +21 -7
- package/README.md +23 -6
- package/docs/COMPATIBILITY.md +22 -2
- package/docs/G5-E2E-RUNBOOK.md +110 -0
- package/docs/G5-HOST-BRIDGE.md +136 -0
- package/docs/PHASE2-ACCEPTANCE.md +133 -0
- package/docs/QCC-ENRICHMENT-DESIGN.md +5 -2
- package/docs/QCC-PHASES-ROADMAP.md +26 -4
- package/docs/RELEASE-0.4.0.md +57 -0
- package/docs/USER-GUIDE.md +37 -5
- package/lib/index.js +2 -0
- package/lib/qcc-phase2-acceptance.js +191 -0
- package/lib/qcc-phase2.js +99 -0
- package/lib/qcc-runs.js +322 -0
- package/lib/qcc-safety.js +77 -0
- package/lib/qcc.js +702 -0
- package/lib/skill-enrich.js +28 -8
- package/lib/web.js +189 -1
- package/package.json +10 -3
package/lib/qcc-runs.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G5 Host 内存态:候选续跑、人工重试和请求幂等。
|
|
3
|
+
*
|
|
4
|
+
* 原始/补全行只保存在当前 Host 进程,不写 storageDomain;重启后 run 明确失效。
|
|
5
|
+
* 幂等缓存保存请求指纹与同源响应,避免客户端超时重发导致重复计费。
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
8
|
+
import { QccBridgeError } from './qcc.js';
|
|
9
|
+
import { safeAuditEvent } from './qcc-safety.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
12
|
+
const DEFAULT_MAX_RUNS = 50;
|
|
13
|
+
const DEFAULT_MAX_AUDIT = 200;
|
|
14
|
+
const DEFAULT_MAX_IDEMPOTENCY = 200;
|
|
15
|
+
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function canonicalize(value) {
|
|
21
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
22
|
+
if (!isRecord(value)) return value;
|
|
23
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function fingerprintRequest(operation, payload) {
|
|
27
|
+
return createHash('sha256')
|
|
28
|
+
.update(JSON.stringify({ operation: String(operation), payload: canonicalize(payload) }))
|
|
29
|
+
.digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateIdempotencyKey(value) {
|
|
33
|
+
const key = String(value ?? '').trim();
|
|
34
|
+
if (!key) {
|
|
35
|
+
throw new QccBridgeError('QCC_IDEMPOTENCY_REQUIRED', 'A unique idempotencyKey is required before paid QCC calls');
|
|
36
|
+
}
|
|
37
|
+
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(key)) {
|
|
38
|
+
throw new QccBridgeError(
|
|
39
|
+
'QCC_IDEMPOTENCY_INVALID',
|
|
40
|
+
'idempotencyKey must be 8-128 characters using letters, numbers, dot, underscore, colon or hyphen',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return key;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function deriveState(record) {
|
|
47
|
+
if (record.reviewQueue.length > 0) return 'awaiting-review';
|
|
48
|
+
if (record.errors.some((item) => item.error?.retryable)) return 'needs-retry';
|
|
49
|
+
if (record.errors.length > 0) return 'completed-with-errors';
|
|
50
|
+
return 'completed';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function recomputeSummary(record) {
|
|
54
|
+
const count = (status) => record.rows.filter((row) => row.qcc_match_status === status).length;
|
|
55
|
+
record.summary = {
|
|
56
|
+
...record.summary,
|
|
57
|
+
totalRows: record.rows.length,
|
|
58
|
+
enriched: count('enriched'),
|
|
59
|
+
ambiguous: count('ambiguous'),
|
|
60
|
+
unresolved: count('unresolved'),
|
|
61
|
+
failed: count('failed'),
|
|
62
|
+
missingName: count('missing-name'),
|
|
63
|
+
includeRisk: Boolean(record.includeRisk),
|
|
64
|
+
};
|
|
65
|
+
record.state = deriveState(record);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clone(value) {
|
|
69
|
+
return structuredClone(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class G5RunStore {
|
|
73
|
+
constructor({
|
|
74
|
+
clock = () => Date.now(),
|
|
75
|
+
runIdFactory = () => `g5-${randomUUID()}`,
|
|
76
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
77
|
+
maxRuns = DEFAULT_MAX_RUNS,
|
|
78
|
+
maxAudit = DEFAULT_MAX_AUDIT,
|
|
79
|
+
maxIdempotency = DEFAULT_MAX_IDEMPOTENCY,
|
|
80
|
+
} = {}) {
|
|
81
|
+
this.clock = clock;
|
|
82
|
+
this.runIdFactory = runIdFactory;
|
|
83
|
+
this.ttlMs = ttlMs;
|
|
84
|
+
this.maxRuns = maxRuns;
|
|
85
|
+
this.maxAudit = maxAudit;
|
|
86
|
+
this.maxIdempotency = maxIdempotency;
|
|
87
|
+
this.runs = new Map();
|
|
88
|
+
this.idempotency = new Map();
|
|
89
|
+
this.locks = new Set();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
nowIso() {
|
|
93
|
+
return new Date(this.clock()).toISOString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
cleanup() {
|
|
97
|
+
const cutoff = this.clock() - this.ttlMs;
|
|
98
|
+
for (const [id, run] of this.runs) {
|
|
99
|
+
if (run.touchedAtMs < cutoff) this.runs.delete(id);
|
|
100
|
+
}
|
|
101
|
+
for (const [key, entry] of this.idempotency) {
|
|
102
|
+
// 进行中的付费请求必须保留幂等屏障:即使执行时间超过 TTL,也不能让
|
|
103
|
+
// 同一 key 的重试绕过首个 Promise,否则可能重复计费。
|
|
104
|
+
if (entry.settled && entry.touchedAtMs < cutoff) this.idempotency.delete(key);
|
|
105
|
+
}
|
|
106
|
+
while (this.runs.size > this.maxRuns) this.runs.delete(this.runs.keys().next().value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async executeOnce({ key: rawKey, fingerprint, operation }) {
|
|
110
|
+
const key = validateIdempotencyKey(rawKey);
|
|
111
|
+
this.cleanup();
|
|
112
|
+
const existing = this.idempotency.get(key);
|
|
113
|
+
if (existing) {
|
|
114
|
+
existing.touchedAtMs = this.clock();
|
|
115
|
+
if (existing.fingerprint !== fingerprint) {
|
|
116
|
+
throw new QccBridgeError(
|
|
117
|
+
'QCC_IDEMPOTENCY_CONFLICT',
|
|
118
|
+
'idempotencyKey was already used with a different request',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return { value: await existing.promise, replayed: true };
|
|
122
|
+
}
|
|
123
|
+
if (this.idempotency.size >= this.maxIdempotency) {
|
|
124
|
+
throw new QccBridgeError(
|
|
125
|
+
'QCC_IDEMPOTENCY_CAPACITY',
|
|
126
|
+
'G5 idempotency cache is full; wait for older requests to expire before starting another paid call',
|
|
127
|
+
{ retryable: true },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const entry = {
|
|
132
|
+
fingerprint,
|
|
133
|
+
promise: null,
|
|
134
|
+
touchedAtMs: this.clock(),
|
|
135
|
+
settled: false,
|
|
136
|
+
};
|
|
137
|
+
entry.promise = Promise.resolve()
|
|
138
|
+
.then(operation)
|
|
139
|
+
.finally(() => {
|
|
140
|
+
entry.settled = true;
|
|
141
|
+
});
|
|
142
|
+
this.idempotency.set(key, entry);
|
|
143
|
+
return { value: await entry.promise, replayed: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
createRun({ headers, nameField, includeRisk, concurrency, result, audit = [] }) {
|
|
147
|
+
this.cleanup();
|
|
148
|
+
const id = this.runIdFactory();
|
|
149
|
+
const at = this.nowIso();
|
|
150
|
+
const record = {
|
|
151
|
+
id,
|
|
152
|
+
state: 'completed',
|
|
153
|
+
version: 1,
|
|
154
|
+
createdAt: at,
|
|
155
|
+
updatedAt: at,
|
|
156
|
+
touchedAtMs: this.clock(),
|
|
157
|
+
headers: Array.isArray(headers) ? headers.map(String) : [],
|
|
158
|
+
nameField: String(nameField ?? 'name'),
|
|
159
|
+
includeRisk: Boolean(includeRisk),
|
|
160
|
+
concurrency: Number(concurrency ?? 2),
|
|
161
|
+
summary: clone(result.summary),
|
|
162
|
+
rows: clone(result.rows),
|
|
163
|
+
reviewQueue: clone(result.reviewQueue),
|
|
164
|
+
errors: clone(result.errors),
|
|
165
|
+
audit: audit.map(safeAuditEvent).slice(-this.maxAudit),
|
|
166
|
+
};
|
|
167
|
+
recomputeSummary(record);
|
|
168
|
+
this.runs.set(id, record);
|
|
169
|
+
this.cleanup();
|
|
170
|
+
return this.snapshot(record);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
requireRun(id) {
|
|
174
|
+
this.cleanup();
|
|
175
|
+
const record = this.runs.get(String(id ?? ''));
|
|
176
|
+
if (!record) {
|
|
177
|
+
throw new QccBridgeError(
|
|
178
|
+
'QCC_RUN_NOT_FOUND',
|
|
179
|
+
'G5 run was not found or expired; start a new enrichment run',
|
|
180
|
+
{ retryable: false },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
record.touchedAtMs = this.clock();
|
|
184
|
+
return record;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get(id) {
|
|
188
|
+
return this.snapshot(this.requireRun(id));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
snapshot(record) {
|
|
192
|
+
return clone({
|
|
193
|
+
runId: record.id,
|
|
194
|
+
state: record.state,
|
|
195
|
+
version: record.version,
|
|
196
|
+
createdAt: record.createdAt,
|
|
197
|
+
updatedAt: record.updatedAt,
|
|
198
|
+
headers: record.headers,
|
|
199
|
+
summary: record.summary,
|
|
200
|
+
rows: record.rows,
|
|
201
|
+
reviewQueue: record.reviewQueue,
|
|
202
|
+
errors: record.errors,
|
|
203
|
+
audit: record.audit,
|
|
204
|
+
expiresInMs: this.ttlMs,
|
|
205
|
+
persistence: 'host-memory',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
appendAudit(record, event) {
|
|
210
|
+
record.audit.push(safeAuditEvent(event));
|
|
211
|
+
if (record.audit.length > this.maxAudit) record.audit.splice(0, record.audit.length - this.maxAudit);
|
|
212
|
+
record.touchedAtMs = this.clock();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
touch(record) {
|
|
216
|
+
record.version += 1;
|
|
217
|
+
record.updatedAt = this.nowIso();
|
|
218
|
+
record.touchedAtMs = this.clock();
|
|
219
|
+
recomputeSummary(record);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
patchCompany(record, companyName, rowIndexes, result) {
|
|
223
|
+
record.reviewQueue = record.reviewQueue.filter((item) => item.companyName !== companyName);
|
|
224
|
+
record.errors = record.errors.filter((item) => item.companyName !== companyName);
|
|
225
|
+
for (const index of rowIndexes) {
|
|
226
|
+
const row = record.rows[index] ?? {};
|
|
227
|
+
if (result.status === 'enriched') {
|
|
228
|
+
record.rows[index] = {
|
|
229
|
+
...row,
|
|
230
|
+
...result.fields,
|
|
231
|
+
qcc_match_status: 'enriched',
|
|
232
|
+
qcc_source: 'qcc-mcp',
|
|
233
|
+
};
|
|
234
|
+
} else {
|
|
235
|
+
record.rows[index] = { ...row, qcc_match_status: result.status };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (result.status === 'ambiguous') {
|
|
239
|
+
record.reviewQueue.push({ companyName, rowIndexes: [...rowIndexes], candidates: clone(result.candidates) });
|
|
240
|
+
}
|
|
241
|
+
if (result.status === 'failed') {
|
|
242
|
+
record.errors.push({ companyName, rowIndexes: [...rowIndexes], error: clone(result.error) });
|
|
243
|
+
}
|
|
244
|
+
this.touch(record);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async resolveCandidate(runId, { companyName, selectedCreditNo }, bridge, options = {}) {
|
|
248
|
+
const record = this.requireRun(runId);
|
|
249
|
+
const name = String(companyName ?? '').trim();
|
|
250
|
+
const creditNo = String(selectedCreditNo ?? '').trim();
|
|
251
|
+
const queued = record.reviewQueue.find((item) => item.companyName === name);
|
|
252
|
+
if (!queued) {
|
|
253
|
+
throw new QccBridgeError('QCC_REVIEW_NOT_PENDING', 'This company is not awaiting candidate review');
|
|
254
|
+
}
|
|
255
|
+
const candidate = queued.candidates.find((item) => item.creditNo === creditNo);
|
|
256
|
+
if (!creditNo || !candidate) {
|
|
257
|
+
throw new QccBridgeError('QCC_CANDIDATE_INVALID', 'Selected credit number is not in the pending candidate list');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const lock = `${record.id}:resolve:${name}`;
|
|
261
|
+
if (this.locks.has(lock)) {
|
|
262
|
+
throw new QccBridgeError('QCC_OPERATION_IN_PROGRESS', 'Candidate resolution is already in progress', { retryable: true });
|
|
263
|
+
}
|
|
264
|
+
this.locks.add(lock);
|
|
265
|
+
try {
|
|
266
|
+
const result = await bridge.enrichLockedCompany({
|
|
267
|
+
companyName: candidate.companyName || name,
|
|
268
|
+
creditNo,
|
|
269
|
+
}, {
|
|
270
|
+
...options,
|
|
271
|
+
includeRisk: record.includeRisk,
|
|
272
|
+
onAudit: (event) => this.appendAudit(record, event),
|
|
273
|
+
});
|
|
274
|
+
this.patchCompany(record, name, queued.rowIndexes, result);
|
|
275
|
+
return this.snapshot(record);
|
|
276
|
+
} finally {
|
|
277
|
+
this.locks.delete(lock);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async retryCompanies(runId, companyNames, bridge, options = {}) {
|
|
282
|
+
const record = this.requireRun(runId);
|
|
283
|
+
const names = [...new Set((Array.isArray(companyNames) ? companyNames : []).map((name) => String(name).trim()).filter(Boolean))];
|
|
284
|
+
if (names.length === 0) {
|
|
285
|
+
throw new QccBridgeError('QCC_RETRY_EMPTY', 'At least one failed company must be selected for manual retry');
|
|
286
|
+
}
|
|
287
|
+
const selected = names.map((name) => {
|
|
288
|
+
const item = record.errors.find((error) => error.companyName === name);
|
|
289
|
+
if (!item) throw new QccBridgeError('QCC_RETRY_NOT_FAILED', 'Selected company is not in the failed queue');
|
|
290
|
+
if (!item.error?.retryable) throw new QccBridgeError('QCC_RETRY_NOT_ALLOWED', 'Selected failure is not retryable');
|
|
291
|
+
return item;
|
|
292
|
+
});
|
|
293
|
+
const locks = selected.map((item) => `${record.id}:retry:${item.companyName}`);
|
|
294
|
+
if (locks.some((lock) => this.locks.has(lock))) {
|
|
295
|
+
throw new QccBridgeError('QCC_OPERATION_IN_PROGRESS', 'A selected retry is already in progress', { retryable: true });
|
|
296
|
+
}
|
|
297
|
+
locks.forEach((lock) => this.locks.add(lock));
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
for (const item of selected) {
|
|
301
|
+
let result;
|
|
302
|
+
try {
|
|
303
|
+
result = await bridge.enrichCompany(item.companyName, {
|
|
304
|
+
...options,
|
|
305
|
+
includeRisk: record.includeRisk,
|
|
306
|
+
onAudit: (event) => this.appendAudit(record, event),
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error?.code === 'QCC_ABORTED') throw error;
|
|
310
|
+
const normalized = error instanceof QccBridgeError
|
|
311
|
+
? error
|
|
312
|
+
: new QccBridgeError('QCC_RUNTIME_ERROR', 'QCC enrichment failed', { retryable: true });
|
|
313
|
+
result = { status: 'failed', error: normalized.toJSON() };
|
|
314
|
+
}
|
|
315
|
+
this.patchCompany(record, item.companyName, item.rowIndexes, result);
|
|
316
|
+
}
|
|
317
|
+
return this.snapshot(record);
|
|
318
|
+
} finally {
|
|
319
|
+
locks.forEach((lock) => this.locks.delete(lock));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G5 安全输出工具。
|
|
3
|
+
*
|
|
4
|
+
* 仅用于日志、审计和 E2E 报告;同源业务响应仍由调用方按契约返回。
|
|
5
|
+
* 这里不尝试“识别所有秘密”,而是采用两层防线:敏感键整值抹除,字符串再做
|
|
6
|
+
* Bearer/JWT/OAuth 参数、信用代码、邮箱、手机号和已知企业名替换。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SECRET_KEY = /(?:^|_)(?:access_token|refresh_token|id_token|token|authorization|cookie|secret|client_secret|code_verifier|api_key|apikey|password)(?:$|_)/i;
|
|
10
|
+
const CREDIT_NO = /\b[0-9A-Z]{18}\b/g;
|
|
11
|
+
const PHONE = /(?<!\d)1[3-9]\d{9}(?!\d)/g;
|
|
12
|
+
const EMAIL = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
13
|
+
const BEARER = /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi;
|
|
14
|
+
const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
|
|
15
|
+
const URL_SECRET = /([?&](?:code|token|access_token|refresh_token|id_token|client_secret)=)[^&\s]+/gi;
|
|
16
|
+
|
|
17
|
+
function escapeRegExp(value) {
|
|
18
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function companyAliases(companyNames) {
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
return (Array.isArray(companyNames) ? companyNames : [])
|
|
24
|
+
.map((name) => String(name ?? '').trim())
|
|
25
|
+
.filter((name) => name && !seen.has(name) && seen.add(name))
|
|
26
|
+
.sort((a, b) => b.length - a.length)
|
|
27
|
+
.map((name, index) => ({
|
|
28
|
+
pattern: new RegExp(escapeRegExp(name), 'g'),
|
|
29
|
+
replacement: `[COMPANY_${String(index + 1).padStart(2, '0')}]`,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function redactSensitiveText(value, options = {}) {
|
|
34
|
+
let text = String(value ?? '');
|
|
35
|
+
text = text
|
|
36
|
+
.replace(BEARER, 'Bearer [REDACTED]')
|
|
37
|
+
.replace(JWT, '[JWT_REDACTED]')
|
|
38
|
+
.replace(URL_SECRET, '$1[REDACTED]')
|
|
39
|
+
.replace(CREDIT_NO, '[CREDIT_NO_REDACTED]')
|
|
40
|
+
.replace(PHONE, '[PHONE_REDACTED]')
|
|
41
|
+
.replace(EMAIL, '[EMAIL_REDACTED]');
|
|
42
|
+
for (const alias of companyAliases(options.companyNames)) {
|
|
43
|
+
text = text.replace(alias.pattern, alias.replacement);
|
|
44
|
+
}
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function redactSensitive(value, options = {}) {
|
|
49
|
+
const seen = new WeakSet();
|
|
50
|
+
|
|
51
|
+
const visit = (input, key = '') => {
|
|
52
|
+
if (SECRET_KEY.test(key)) return '[REDACTED]';
|
|
53
|
+
if (typeof input === 'string') return redactSensitiveText(input, options);
|
|
54
|
+
if (input === null || typeof input !== 'object') return input;
|
|
55
|
+
if (seen.has(input)) return '[CIRCULAR]';
|
|
56
|
+
seen.add(input);
|
|
57
|
+
if (Array.isArray(input)) return input.map((item) => visit(item));
|
|
58
|
+
return Object.fromEntries(Object.entries(input).map(([childKey, child]) => [childKey, visit(child, childKey)]));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return visit(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function safeAuditEvent(event) {
|
|
65
|
+
const source = event && typeof event === 'object' ? event : {};
|
|
66
|
+
return {
|
|
67
|
+
at: String(source.at ?? new Date().toISOString()),
|
|
68
|
+
event: 'qcc-tool-call',
|
|
69
|
+
toolName: String(source.toolName ?? ''),
|
|
70
|
+
callId: String(source.callId ?? ''),
|
|
71
|
+
attempt: Number(source.attempt ?? 0),
|
|
72
|
+
outcome: String(source.outcome ?? 'unknown'),
|
|
73
|
+
code: source.code ? String(source.code) : null,
|
|
74
|
+
upstreamCode: source.upstreamCode ? String(source.upstreamCode) : null,
|
|
75
|
+
durationMs: Math.max(0, Number(source.durationMs ?? 0)),
|
|
76
|
+
};
|
|
77
|
+
}
|