dsh-data-cleaning-agent 0.5.2 → 0.6.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 +67 -0
- package/README.en.md +20 -10
- package/README.md +17 -8
- package/docs/COMPATIBILITY.md +33 -2
- package/docs/RELEASE-0.5.2.md +6 -2
- package/docs/RELEASE-0.5.3.md +66 -0
- package/docs/RELEASE-0.6.0.md +46 -0
- package/docs/UI-WORKFLOW-V2-ACCEPTANCE.md +84 -0
- package/docs/UI-WORKFLOW-V2-MIGRATION.md +62 -0
- package/docs/UI-WORKFLOW-V2.md +174 -0
- package/docs/USER-GUIDE.md +25 -10
- package/lib/artifacts.js +239 -0
- package/lib/client.js +1620 -192
- package/lib/index.js +1 -1
- package/lib/web.js +214 -0
- package/lib/workflow-contract.js +263 -0
- package/lib/workflow.js +452 -0
- package/package.json +7 -2
package/lib/workflow.js
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host 持久化工作流:只保存任务元数据、统计摘要与导出制品引用。
|
|
3
|
+
* 原始名单、企业名称、匹配候选及 QCC 返回明细不得写入 storageDomain。
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import {
|
|
7
|
+
SOURCE_TYPES,
|
|
8
|
+
TERMINAL_WORKFLOW_STATES,
|
|
9
|
+
WORKFLOW_SCHEMA_VERSION,
|
|
10
|
+
WorkflowContractError,
|
|
11
|
+
assertWorkflowRecordShape,
|
|
12
|
+
assertWorkflowTransition,
|
|
13
|
+
normalizeFieldSelection,
|
|
14
|
+
normalizeWorkflowDraft,
|
|
15
|
+
validateMappings,
|
|
16
|
+
} from './workflow-contract.js';
|
|
17
|
+
|
|
18
|
+
const DOMAIN_NAME = 'dc_workflows_v2';
|
|
19
|
+
const DOMAIN_VERSION = 1;
|
|
20
|
+
const TABLE_NAME = 'tasks';
|
|
21
|
+
const permissiveSchema = {
|
|
22
|
+
parse: (value) => value,
|
|
23
|
+
safeParse: (value) => ({ success: true, data: value }),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const domainSpec = () => ({
|
|
27
|
+
name: DOMAIN_NAME,
|
|
28
|
+
version: DOMAIN_VERSION,
|
|
29
|
+
tables: { [TABLE_NAME]: { valueSchema: permissiveSchema } },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export class WorkflowError extends Error {
|
|
33
|
+
constructor(code, message, status = 400) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'WorkflowError';
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.status = status;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function contractCall(callback) {
|
|
42
|
+
try {
|
|
43
|
+
return callback();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof WorkflowContractError) {
|
|
46
|
+
throw new WorkflowError(error.code, error.message, 400);
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function integer(value, min = 0, max = Number.MAX_SAFE_INTEGER) {
|
|
53
|
+
const parsed = Number(value);
|
|
54
|
+
if (!Number.isFinite(parsed)) return min;
|
|
55
|
+
return Math.min(max, Math.max(min, Math.trunc(parsed)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeText(value, max = 160) {
|
|
59
|
+
return String(value ?? '').trim().slice(0, max);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeStringList(value, maxItems = 128, maxLength = 160) {
|
|
63
|
+
if (!Array.isArray(value)) return [];
|
|
64
|
+
return [...new Set(value.map((item) => safeText(item, maxLength)).filter(Boolean))].slice(0, maxItems);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeSummary(value, keys) {
|
|
68
|
+
const output = {};
|
|
69
|
+
for (const key of keys) output[key] = integer(value?.[key]);
|
|
70
|
+
return output;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertSummaryTotal(summary, componentKeys, code) {
|
|
74
|
+
const accounted = componentKeys.reduce((total, key) => total + integer(summary[key]), 0);
|
|
75
|
+
if (accounted > summary.total) {
|
|
76
|
+
throw new WorkflowError(code, 'Summary component counts cannot exceed total.', 400);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sanitizeSource(value = {}) {
|
|
81
|
+
const type = SOURCE_TYPES.includes(value.type) ? value.type : 'text';
|
|
82
|
+
return {
|
|
83
|
+
type,
|
|
84
|
+
fileName: safeText(value.fileName, 240),
|
|
85
|
+
rowCount: integer(value.rowCount, 0, 1_000_000),
|
|
86
|
+
columnCount: integer(value.columnCount, 0, 1_000),
|
|
87
|
+
headers: safeStringList(value.headers, 256, 120),
|
|
88
|
+
sizeBytes: integer(value.sizeBytes, 0, 64 * 1024 * 1024),
|
|
89
|
+
checksum: safeText(value.checksum, 160),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sanitizeArtifact(value = {}, timestamp) {
|
|
94
|
+
const format = ['csv', 'xlsx', 'json'].includes(value.format) ? value.format : 'csv';
|
|
95
|
+
return {
|
|
96
|
+
id: safeText(value.id, 160),
|
|
97
|
+
kind: ['clean', 'complete', 'review', 'report'].includes(value.kind) ? value.kind : 'complete',
|
|
98
|
+
format,
|
|
99
|
+
fileName: safeText(value.fileName, 240),
|
|
100
|
+
rowCount: integer(value.rowCount, 0, 1_000_000),
|
|
101
|
+
sizeBytes: integer(value.sizeBytes, 0, 64 * 1024 * 1024),
|
|
102
|
+
checksum: safeText(value.checksum, 160),
|
|
103
|
+
mediaType: safeText(value.mediaType, 160),
|
|
104
|
+
createdAt: safeText(value.createdAt, 80) || timestamp,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function nowIso() {
|
|
109
|
+
return new Date().toISOString();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function defaultId() {
|
|
113
|
+
return `dcw-${randomUUID()}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class DataCleaningWorkflowStore {
|
|
117
|
+
constructor({ storageDomain, logger, nowFn = nowIso, idFactory = defaultId }) {
|
|
118
|
+
this.storageDomain = storageDomain;
|
|
119
|
+
this.logger = logger ?? console;
|
|
120
|
+
this.nowFn = nowFn;
|
|
121
|
+
this.idFactory = idFactory;
|
|
122
|
+
this.access = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async init() {
|
|
126
|
+
if (!this.storageDomain) throw new WorkflowError('DC_WORKFLOW_UNAVAILABLE', 'storageDomain service unavailable', 503);
|
|
127
|
+
this.access = await this.storageDomain.open(domainSpec());
|
|
128
|
+
this.logger.info('[dc-agent] workflow v2 storage ready');
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
table() {
|
|
133
|
+
if (!this.access) throw new WorkflowError('DC_WORKFLOW_UNAVAILABLE', 'workflow store not initialized', 503);
|
|
134
|
+
return this.access.table(TABLE_NAME);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async create(input = {}) {
|
|
138
|
+
const timestamp = this.nowFn();
|
|
139
|
+
const draft = normalizeWorkflowDraft(input);
|
|
140
|
+
const id = this.idFactory();
|
|
141
|
+
const record = {
|
|
142
|
+
id,
|
|
143
|
+
schemaVersion: WORKFLOW_SCHEMA_VERSION,
|
|
144
|
+
revision: 1,
|
|
145
|
+
title: draft.title,
|
|
146
|
+
state: 'draft',
|
|
147
|
+
stage: 'upload',
|
|
148
|
+
objectives: draft.objectives,
|
|
149
|
+
fieldSelection: draft.fieldSelection,
|
|
150
|
+
mappings: draft.mappings,
|
|
151
|
+
matchRules: draft.matchRules,
|
|
152
|
+
source: null,
|
|
153
|
+
qualitySummary: null,
|
|
154
|
+
matchSummary: null,
|
|
155
|
+
enrichmentSummary: null,
|
|
156
|
+
qccRunId: null,
|
|
157
|
+
artifacts: [],
|
|
158
|
+
error: null,
|
|
159
|
+
createdAt: timestamp,
|
|
160
|
+
updatedAt: timestamp,
|
|
161
|
+
completedAt: null,
|
|
162
|
+
};
|
|
163
|
+
await this.table().put(id, record);
|
|
164
|
+
return record;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async list() {
|
|
168
|
+
const records = [...this.table().entries()].map(([, record]) => assertWorkflowRecordShape(record));
|
|
169
|
+
return records.sort((left, right) => String(right.updatedAt).localeCompare(String(left.updatedAt)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async get(id) {
|
|
173
|
+
const record = await this.table().get(String(id));
|
|
174
|
+
return record ? assertWorkflowRecordShape(record) : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async require(id) {
|
|
178
|
+
const record = await this.get(id);
|
|
179
|
+
if (!record) throw new WorkflowError('DC_WORKFLOW_NOT_FOUND', `Workflow task not found: ${id}`, 404);
|
|
180
|
+
return record;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async mutate(id, expectedRevision, updater) {
|
|
184
|
+
const key = String(id);
|
|
185
|
+
const current = await this.require(key);
|
|
186
|
+
if (expectedRevision !== undefined && integer(expectedRevision) !== current.revision) {
|
|
187
|
+
throw new WorkflowError('DC_WORKFLOW_REVISION_CONFLICT', 'Workflow task was updated by another session.', 409);
|
|
188
|
+
}
|
|
189
|
+
let updated;
|
|
190
|
+
await this.table().update(key, (latest) => {
|
|
191
|
+
if (!latest) throw new WorkflowError('DC_WORKFLOW_NOT_FOUND', `Workflow task not found: ${key}`, 404);
|
|
192
|
+
if (latest.revision !== current.revision) {
|
|
193
|
+
throw new WorkflowError('DC_WORKFLOW_REVISION_CONFLICT', 'Workflow task was updated by another session.', 409);
|
|
194
|
+
}
|
|
195
|
+
const patch = updater(assertWorkflowRecordShape(latest));
|
|
196
|
+
updated = {
|
|
197
|
+
...latest,
|
|
198
|
+
...patch,
|
|
199
|
+
id: latest.id,
|
|
200
|
+
schemaVersion: WORKFLOW_SCHEMA_VERSION,
|
|
201
|
+
revision: latest.revision + 1,
|
|
202
|
+
updatedAt: this.nowFn(),
|
|
203
|
+
};
|
|
204
|
+
return updated;
|
|
205
|
+
});
|
|
206
|
+
return updated ?? this.require(key);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async updateDraft(id, input = {}) {
|
|
210
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
211
|
+
if (!['draft', 'uploaded'].includes(record.state)) {
|
|
212
|
+
throw new WorkflowError('DC_WORKFLOW_LOCKED', 'Draft settings cannot be changed after rules are confirmed.', 409);
|
|
213
|
+
}
|
|
214
|
+
const draft = normalizeWorkflowDraft({
|
|
215
|
+
title: input.title ?? record.title,
|
|
216
|
+
objectives: input.objectives ?? record.objectives,
|
|
217
|
+
fieldSelection: input.fieldSelection ?? record.fieldSelection,
|
|
218
|
+
mappings: input.mappings ?? record.mappings,
|
|
219
|
+
matchRules: input.matchRules ?? record.matchRules,
|
|
220
|
+
});
|
|
221
|
+
return draft;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async recordUpload(id, input = {}) {
|
|
226
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
227
|
+
if (!['draft', 'uploaded', 'parse_failed'].includes(record.state)) {
|
|
228
|
+
throw new WorkflowError('DC_WORKFLOW_UPLOAD_STATE', 'Upload metadata cannot be replaced in the current state.', 409);
|
|
229
|
+
}
|
|
230
|
+
if (record.state !== 'uploaded') contractCall(() => assertWorkflowTransition(record.state, 'uploaded'));
|
|
231
|
+
return {
|
|
232
|
+
state: 'uploaded',
|
|
233
|
+
stage: 'rules',
|
|
234
|
+
source: sanitizeSource(input.source),
|
|
235
|
+
qualitySummary: null,
|
|
236
|
+
matchSummary: null,
|
|
237
|
+
enrichmentSummary: null,
|
|
238
|
+
qccRunId: null,
|
|
239
|
+
artifacts: [],
|
|
240
|
+
error: null,
|
|
241
|
+
completedAt: null,
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async confirmRules(id, input = {}) {
|
|
247
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
248
|
+
if (!['uploaded', 'rules_confirmed'].includes(record.state)) {
|
|
249
|
+
throw new WorkflowError('DC_WORKFLOW_RULE_STATE', 'Rules can only be confirmed after upload.', 409);
|
|
250
|
+
}
|
|
251
|
+
if (record.state !== 'rules_confirmed') contractCall(() => assertWorkflowTransition(record.state, 'rules_confirmed'));
|
|
252
|
+
const mappings = contractCall(() => validateMappings(input.mappings ?? record.mappings));
|
|
253
|
+
const draft = normalizeWorkflowDraft({
|
|
254
|
+
title: record.title,
|
|
255
|
+
objectives: input.objectives ?? record.objectives,
|
|
256
|
+
fieldSelection: input.fieldSelection ?? record.fieldSelection,
|
|
257
|
+
mappings,
|
|
258
|
+
matchRules: input.matchRules ?? record.matchRules,
|
|
259
|
+
});
|
|
260
|
+
return { ...draft, state: 'rules_confirmed', stage: 'match', error: null };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async recordQuality(id, input = {}) {
|
|
265
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
266
|
+
if (!['rules_confirmed', 'diagnosed'].includes(record.state)) {
|
|
267
|
+
throw new WorkflowError('DC_WORKFLOW_QUALITY_STATE', 'Quality summary requires confirmed rules.', 409);
|
|
268
|
+
}
|
|
269
|
+
if (record.state !== 'diagnosed') contractCall(() => assertWorkflowTransition(record.state, 'diagnosed'));
|
|
270
|
+
return {
|
|
271
|
+
state: 'diagnosed',
|
|
272
|
+
stage: 'match',
|
|
273
|
+
error: null,
|
|
274
|
+
qualitySummary: safeSummary(input.summary, [
|
|
275
|
+
'total', 'valid', 'missingAnchor', 'duplicates', 'invalidCreditNo', 'invalidPhone', 'emptyFields',
|
|
276
|
+
]),
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async startMatch(id, input = {}) {
|
|
282
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
283
|
+
contractCall(() => assertWorkflowTransition(record.state, 'matching'));
|
|
284
|
+
return { state: 'matching', stage: 'match', error: null };
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async recordMatch(id, input = {}) {
|
|
289
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
290
|
+
if (!['rules_confirmed', 'diagnosed', 'matching', 'review_required', 'partial'].includes(record.state)) {
|
|
291
|
+
throw new WorkflowError('DC_WORKFLOW_MATCH_STATE', 'Match summary cannot be recorded in the current state.', 409);
|
|
292
|
+
}
|
|
293
|
+
const summary = safeSummary(input.summary, ['total', 'exact', 'candidate', 'confirmed', 'unresolved', 'failed', 'reviewRequired']);
|
|
294
|
+
assertSummaryTotal(summary, ['exact', 'candidate', 'confirmed', 'unresolved', 'failed'], 'DC_WORKFLOW_MATCH_SUMMARY');
|
|
295
|
+
if (summary.reviewRequired > summary.candidate) {
|
|
296
|
+
throw new WorkflowError('DC_WORKFLOW_MATCH_SUMMARY', 'Review-required count cannot exceed candidate count.', 400);
|
|
297
|
+
}
|
|
298
|
+
const nextState = summary.reviewRequired > 0 ? 'review_required' : 'matched';
|
|
299
|
+
if (record.state !== nextState) contractCall(() => assertWorkflowTransition(record.state, nextState));
|
|
300
|
+
return {
|
|
301
|
+
state: nextState,
|
|
302
|
+
stage: nextState === 'review_required' ? 'match' : 'enrich',
|
|
303
|
+
matchSummary: summary,
|
|
304
|
+
qccRunId: safeText(input.qccRunId, 160) || record.qccRunId,
|
|
305
|
+
error: null,
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async startEnrichment(id, input = {}) {
|
|
311
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
312
|
+
if (record.state === 'review_required' && integer(record.matchSummary?.reviewRequired) > 0) {
|
|
313
|
+
throw new WorkflowError('DC_WORKFLOW_REVIEW_REQUIRED', 'Resolve ambiguous matches before enrichment.', 409);
|
|
314
|
+
}
|
|
315
|
+
contractCall(() => assertWorkflowTransition(record.state, 'enriching'));
|
|
316
|
+
return {
|
|
317
|
+
state: 'enriching',
|
|
318
|
+
stage: 'enrich',
|
|
319
|
+
fieldSelection: input.fieldSelection === undefined
|
|
320
|
+
? record.fieldSelection
|
|
321
|
+
: normalizeFieldSelection(input.fieldSelection),
|
|
322
|
+
error: null,
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async recordEnrichment(id, input = {}) {
|
|
328
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
329
|
+
if (!['matched', 'enriching', 'partial', 'authorization_required'].includes(record.state)) {
|
|
330
|
+
throw new WorkflowError('DC_WORKFLOW_ENRICH_STATE', 'Enrichment summary cannot be recorded in the current state.', 409);
|
|
331
|
+
}
|
|
332
|
+
const summary = safeSummary(input.summary, ['total', 'completed', 'unchanged', 'failed', 'reviewRequired', 'callsUsed']);
|
|
333
|
+
assertSummaryTotal(summary, ['completed', 'unchanged', 'failed', 'reviewRequired'], 'DC_WORKFLOW_ENRICH_SUMMARY');
|
|
334
|
+
const nextState = summary.failed > 0 || summary.reviewRequired > 0 ? 'partial' : 'export_ready';
|
|
335
|
+
if (record.state !== nextState) contractCall(() => assertWorkflowTransition(record.state, nextState));
|
|
336
|
+
return {
|
|
337
|
+
state: nextState,
|
|
338
|
+
stage: 'download',
|
|
339
|
+
enrichmentSummary: summary,
|
|
340
|
+
qccRunId: safeText(input.qccRunId, 160) || record.qccRunId,
|
|
341
|
+
error: null,
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async prepareLocalExport(id, input = {}) {
|
|
347
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
348
|
+
if (!['rules_confirmed', 'diagnosed', 'export_ready'].includes(record.state)) {
|
|
349
|
+
throw new WorkflowError('DC_WORKFLOW_LOCAL_EXPORT_STATE', 'Local output requires confirmed rules or a completed quality check.', 409);
|
|
350
|
+
}
|
|
351
|
+
if (record.state !== 'export_ready') contractCall(() => assertWorkflowTransition(record.state, 'export_ready'));
|
|
352
|
+
const total = integer(input.summary?.total ?? record.source?.rowCount);
|
|
353
|
+
const completed = integer(input.summary?.completed ?? total);
|
|
354
|
+
const unchanged = integer(input.summary?.unchanged);
|
|
355
|
+
const failed = integer(input.summary?.failed);
|
|
356
|
+
const summary = {
|
|
357
|
+
total,
|
|
358
|
+
completed,
|
|
359
|
+
unchanged,
|
|
360
|
+
failed,
|
|
361
|
+
reviewRequired: 0,
|
|
362
|
+
callsUsed: 0,
|
|
363
|
+
};
|
|
364
|
+
assertSummaryTotal(summary, ['completed', 'unchanged', 'failed'], 'DC_WORKFLOW_ENRICH_SUMMARY');
|
|
365
|
+
return {
|
|
366
|
+
state: 'export_ready',
|
|
367
|
+
stage: 'download',
|
|
368
|
+
enrichmentSummary: summary,
|
|
369
|
+
error: null,
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async recordExport(id, input = {}) {
|
|
375
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
376
|
+
if (!['export_ready', 'partial'].includes(record.state)) {
|
|
377
|
+
throw new WorkflowError('DC_WORKFLOW_EXPORT_STATE', 'Export can only be recorded when output is ready.', 409);
|
|
378
|
+
}
|
|
379
|
+
contractCall(() => assertWorkflowTransition(record.state, 'completed'));
|
|
380
|
+
const timestamp = this.nowFn();
|
|
381
|
+
const incoming = Array.isArray(input.artifacts) ? input.artifacts : [input.artifact];
|
|
382
|
+
const artifacts = incoming.filter(Boolean).map((artifact) => sanitizeArtifact(artifact, timestamp));
|
|
383
|
+
if (!artifacts.length || artifacts.some((artifact) => !artifact.id)) {
|
|
384
|
+
throw new WorkflowError('DC_WORKFLOW_ARTIFACT_REQUIRED', 'At least one Host artifact reference is required.', 400);
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
state: 'completed',
|
|
388
|
+
stage: 'download',
|
|
389
|
+
artifacts: [...record.artifacts, ...artifacts].slice(-20),
|
|
390
|
+
error: null,
|
|
391
|
+
completedAt: timestamp,
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async recordParseFailure(id, input = {}) {
|
|
397
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
398
|
+
if (!['draft', 'uploaded'].includes(record.state)) {
|
|
399
|
+
throw new WorkflowError('DC_WORKFLOW_PARSE_STATE', 'Parse failure cannot be recorded in the current state.', 409);
|
|
400
|
+
}
|
|
401
|
+
contractCall(() => assertWorkflowTransition(record.state, 'parse_failed'));
|
|
402
|
+
return {
|
|
403
|
+
state: 'parse_failed',
|
|
404
|
+
stage: 'upload',
|
|
405
|
+
error: { code: safeText(input.code, 80) || 'DC_PARSE' },
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async requireAuthorization(id, input = {}) {
|
|
411
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
412
|
+
contractCall(() => assertWorkflowTransition(record.state, 'authorization_required'));
|
|
413
|
+
return {
|
|
414
|
+
state: 'authorization_required',
|
|
415
|
+
stage: ['matched', 'enriching'].includes(record.state) ? 'enrich' : 'match',
|
|
416
|
+
error: { code: 'QCC_AUTH_REQUIRED' },
|
|
417
|
+
};
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async recordFailure(id, input = {}) {
|
|
422
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
423
|
+
contractCall(() => assertWorkflowTransition(record.state, 'failed'));
|
|
424
|
+
return {
|
|
425
|
+
state: 'failed',
|
|
426
|
+
error: { code: safeText(input.code, 80) || 'DC_WORKFLOW_FAILED' },
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async cancel(id, input = {}) {
|
|
432
|
+
return this.mutate(id, input.expectedRevision, (record) => {
|
|
433
|
+
if (TERMINAL_WORKFLOW_STATES.includes(record.state)) {
|
|
434
|
+
throw new WorkflowError('DC_WORKFLOW_TERMINAL', 'Completed or cancelled tasks cannot be cancelled.', 409);
|
|
435
|
+
}
|
|
436
|
+
contractCall(() => assertWorkflowTransition(record.state, 'cancelled'));
|
|
437
|
+
return { state: 'cancelled', error: null, completedAt: this.nowFn() };
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async dispose() {
|
|
442
|
+
if (!this.access) return;
|
|
443
|
+
try { await this.access.close(); } catch {}
|
|
444
|
+
this.access = null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export const WORKFLOW_STORAGE = Object.freeze({
|
|
449
|
+
domain: DOMAIN_NAME,
|
|
450
|
+
domainVersion: DOMAIN_VERSION,
|
|
451
|
+
table: TABLE_NAME,
|
|
452
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-data-cleaning-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Clean, complete, and profile enterprise name lists in DeepSeek Harness — a data cleaning & completion agent plugin with local CSV/XLSX/JSON engine and optional Qichacha (QCC) MCP enrichment. Maintained by Qichacha/QCC.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -30,12 +30,17 @@
|
|
|
30
30
|
"docs/RELEASE-0.5.0.md",
|
|
31
31
|
"docs/RELEASE-0.5.1.md",
|
|
32
32
|
"docs/RELEASE-0.5.2.md",
|
|
33
|
+
"docs/RELEASE-0.5.3.md",
|
|
34
|
+
"docs/RELEASE-0.6.0.md",
|
|
35
|
+
"docs/UI-WORKFLOW-V2.md",
|
|
36
|
+
"docs/UI-WORKFLOW-V2-MIGRATION.md",
|
|
37
|
+
"docs/UI-WORKFLOW-V2-ACCEPTANCE.md",
|
|
33
38
|
"docs/G5-HOST-BRIDGE.md",
|
|
34
39
|
"docs/G5-E2E-RUNBOOK.md"
|
|
35
40
|
],
|
|
36
41
|
"scripts": {
|
|
37
42
|
"test": "node --test",
|
|
38
|
-
"lint": "node --check lib/index.js && node --check lib/engine.js && node --check lib/tools.js && node --check lib/skill.js && node --check lib/qcc-phase2.js && node --check lib/qcc-phase3.js && node --check lib/qcc-phase3-batch.js && node --check lib/qcc-phase2-acceptance.js && node --check lib/skill-enrich.js && node --check lib/jobs.js && node --check lib/qcc-safety.js && node --check lib/qcc.js && node --check lib/qcc-runs.js && node --check lib/web.js && node --check lib/client.js && node --check scripts/check-readme-version.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/g5-e2e.mjs && node --check scripts/phase3-e2e.mjs && node --check scripts/phase2-acceptance.mjs",
|
|
43
|
+
"lint": "node --check lib/index.js && node --check lib/engine.js && node --check lib/artifacts.js && node --check lib/tools.js && node --check lib/skill.js && node --check lib/qcc-phase2.js && node --check lib/qcc-phase3.js && node --check lib/qcc-phase3-batch.js && node --check lib/qcc-phase2-acceptance.js && node --check lib/skill-enrich.js && node --check lib/jobs.js && node --check lib/workflow-contract.js && node --check lib/workflow.js && node --check lib/qcc-safety.js && node --check lib/qcc.js && node --check lib/qcc-runs.js && node --check lib/web.js && node --check lib/client.js && node --check scripts/check-readme-version.mjs && node --check scripts/check-market-registration.mjs && node --check scripts/g5-e2e.mjs && node --check scripts/phase3-e2e.mjs && node --check scripts/phase2-acceptance.mjs",
|
|
39
44
|
"docs:check": "node scripts/check-readme-version.mjs",
|
|
40
45
|
"marketing:check": "node scripts/check-marketing.mjs",
|
|
41
46
|
"verify-pack": "node scripts/verify-pack.mjs",
|