dsh-data-cleaning-agent 0.9.7 → 0.9.8

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 CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.8] - 2026-09-13
8
+
9
+ - DC-SCOPE-P0:持久保存任务来源 Session/Workspace 与安全展示名称;v2 旧记录采用附加字段兼容读取,不猜测来源、不批量改写历史。
10
+ - Profile 历史改为独立只读详情;打开来源会话按宿主能力降级,不覆盖当前任务、规则、名单或命令。保留客户 XLSX 与次要任务报告分层。
11
+ - 当前绑定恢复、迟到刷新及 HTTP 写入/命令准备校验来源;跨会话请求拒绝,版本冲突仍由 Host revision 控制。
12
+ - 审计增加 Provider 六类状态区分;完成态迟到更新、A/B、旧记录、重启和来源导航补充回归。真实 DSH Web 未认证,真实宿主与 Provider 未验证。
13
+
7
14
  ## [0.9.7] - 2026-09-11
8
15
 
9
16
  - 优化数据清洗补全工作台的区域间距、信息层级与明暗主题响应式布局,重构导入清单、字段映射、质量体检和主体匹配页面。
package/README.en.md CHANGED
@@ -10,7 +10,9 @@ Related agents: [数据清洗补全](https://github.com/duhu2000/dsh-data-cleani
10
10
 
11
11
  > A data cleaning & completion agent plugin for DeepSeek Harness: local CSV/XLSX/JSON engine plus optional Qichacha (QCC) MCP enterprise-data enrichment. Initiated and maintained by the Qichacha (QCC) team.
12
12
  >
13
- > Current source version / 当前源码版本: **0.9.7** (stable release)
13
+ > Current source version / 当前源码版本: **0.9.8** (stable release)
14
+
15
+ 0.9.8 makes Profile history read-only and persists origin Workspace/Session metadata. Legacy records remain explicitly unattributed and cannot become the current Session task. Open-origin navigation is capability-gated; existing XLSX artifacts remain downloadable without MCP calls. Rollback: `dsh plugin --profile web add dsh-data-cleaning-agent@0.9.7` (reintroduces the old history-binding risk). Live DSH A/B verification is unverified because the local Web session requires authentication; see the release checklist.
14
16
 
15
17
  This release refines workbench hierarchy, spacing, and responsive behavior across imported lists, field mapping, quality profiling, and entity matching. File, image, and pasted-data inputs collapse after a successful import and return through the explicit reimport action. Field mapping uses grouped choices, and searching a dimension exposes all fields in that group. Preparing a draft does not query QCC; sending starts execution. Fully restart DSH after upgrading. Existing download artifacts are not rewritten.
16
18
 
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  数据清洗补全智能体:面向 Excel/CSV/JSON 企业名单,提供数据清洗、表格清洗、清洗补全、去重、企业数据补全与字段补全,支持企查查 MCP 和结果导出。
10
10
 
11
11
  ```sh
12
- dsh plugin --profile web add dsh-data-cleaning-agent@0.9.7
12
+ dsh plugin --profile web add dsh-data-cleaning-agent@0.9.8
13
13
  ```
14
14
 
15
15
  请先满足下文的 DSH、连接器及侧边栏依赖要求;安装后完整停止并重启对应 Profile。
@@ -27,7 +27,9 @@ dsh plugin --profile web add dsh-data-cleaning-agent@0.9.7
27
27
 
28
28
  > 在 DeepSeek Harness 中清洗、补全、画像企业名单数据的智能体插件:本地 CSV/XLSX/JSON 引擎 + 可选企查查(Qichacha/QCC)MCP 企业数据补全,由企查查(Qichacha/QCC)团队发起并维护。
29
29
  >
30
- > 当前源码版本 / Current source version: **0.9.7**(正式版本)
30
+ > 当前源码版本 / Current source version: **0.9.8**(正式版本)
31
+
32
+ 0.9.8 修复历史任务跨 Session 误绑定:历史默认只读,显示来源工作区/会话;旧记录显示“历史版本未保存来源”,不自动接管。来源可用时可“打开来源会话”,不会修改当前任务。已有 XLSX 与任务报告可继续下载,导航不调用 MCP。升级后旧版本当前绑定将进入只读历史;新任务按 Session 恢复。回滚:`dsh plugin --profile web add dsh-data-cleaning-agent@0.9.7`(回滚会重新暴露旧版误绑定风险)。真实 DSH A/B 验收因本机 Web 未认证而未验证,详见发布清单。
31
33
 
32
34
  本版优化工作台的信息层级、区域间距与响应式布局,重构导入清单、字段映射、质量体检及主体匹配页面。导入成功后文件、图片与粘贴入口自动收起,通过“重新导入”再次展开;映射字段按维度分组选择,搜索维度名称时展示该组全部字段。生成说明不查询,发送才执行;升级后需完整重启 DSH,历史下载文件不自动改写。
33
35
 
@@ -0,0 +1,68 @@
1
+ # 0.9.8 · DC-SCOPE-P0
2
+
3
+ Status: release candidate. Date: 2026-09-13.
4
+
5
+ ## Scope and authorization
6
+
7
+ Explicit ledger delegation authorizes code, tests, commit/push, PR merge, immutable tag,
8
+ GitHub Release, OIDC npm publishing and registry read-back. Base main: 2424c97.
9
+ Package: dsh-data-cleaning-agent 0.9.8; qcc-field-contracts stays 0.2.0.
10
+ Adopts DSH-UX-001 v1.5.3 at the shared AI-设计/DSH智能体开发交互规范方案.md;
11
+ visual reference: DSH智能体_企查查蓝_UI_Mockup_v1.5.0.html.
12
+
13
+ ## Migration and boundaries
14
+
15
+ - New UI tasks persist immutable originSessionId/originWorkspaceId and bounded display labels.
16
+ Missing Workspace capability is explicitly unknown; never inferred from active/recent Workspace.
17
+ - Schema v2 additive read migration returns null origin for legacy records. No bulk rewrite.
18
+ - LocalStorage bindings are hints, not ownership proof. Wrong/legacy bindings cannot hydrate
19
+ the current Session; explicit new work creates a new task. Profile history remains readable.
20
+ - History selection has separate state and uses GET only. It does not load run rows, rules,
21
+ field selection, paid confirmations or command credentials into the current Session.
22
+ - Open-source-Session uses an existing, capability-verified Session only. Missing/deleted
23
+ origins remain read-only; no clone or silent rebind feature is introduced.
24
+ - HTTP task mutations and workflow command preparation require matching persisted origin
25
+ for scoped tasks. This is an application isolation guard, not an authentication boundary:
26
+ existing Host same-origin/Profile access controls remain authoritative. Legacy/headless
27
+ origin-less API records remain backwards-compatible; UI never adopts them as current.
28
+ - Revision conflict rejects concurrent updates; stale UI snapshots do not overwrite newer ones.
29
+ - Customer XLSX and secondary report links remain separated. Downloads reuse saved artifacts.
30
+ - Provider audit distinguishes success-data, success-empty, not-required, no-permission,
31
+ failed, unknown without changing field completeness or charging rules. No raw response saved.
32
+ - Completed/cancelled workflow states cannot regress. Existing explicit partial-result retry
33
+ is retained; it is not classified as an immutable successful completion.
34
+
35
+ ## Verification
36
+
37
+ Local evidence (2026-09-13): `npm run check` passed, 275/275 tests, zero failures.
38
+ Real React/Chromium layout matrix passed 10/10 (light/dark; 320–1440px).
39
+ Artifact preview passed 390/800/1440px with compact rows, two-axis scrolling and fixed headers.
40
+ `git diff --check` passed. Remote CI and registry evidence are pending publication.
41
+ No separate compiled build: runtime consists of shipped JS;
42
+ `npm run check` includes syntax, documentation, package allowlist and full node tests.
43
+ Real React/Chromium layout and preview regressions are separate from live DSH.
44
+ Tests cover Session A/B, origin persistence across store restart, legacy unknown records,
45
+ concurrent revision updates, stale history/current responses, open-origin capability checks,
46
+ Tab X/fold/provider-remount and synthetic four-plugin coexistence baseline from PR #13.
47
+
48
+ **真实 DSH Session A/B 浏览器验收:因本机 DSH Web 未认证而未验证。**
49
+ Observed browser text: “dsh web authentication required; reopen the URL printed by dsh web.”
50
+ No authentication bypass or token collection attempted. Ledger confirmed this external
51
+ condition does not block release if all automated gates pass. Live QCC/OCR/Windows-host
52
+ and real four-plugin installation verification are not claimed. Market publishing out of scope.
53
+
54
+ Minimal user recheck after authentication/restart:
55
+ 1. In Session A, create a synthetic task without sending paid commands; confirm origin labels.
56
+ 2. In Session B, enter a different draft/field selection, view A from history, then return to
57
+ current task. B's draft, fields, taskId and stage must remain unchanged.
58
+ 3. Download A's saved artifact without new MCP calls; open source Session A only via explicit action.
59
+ 4. Restart/reload, close Tab X, fold/unfold Sidebar, switch among four plugins and repeat.
60
+ Old tasks must say “历史版本未保存来源” and must never replace B's current task.
61
+
62
+ ## Publish and rollback
63
+
64
+ Follow PR → exact merge main CI → annotated v0.9.8 → Release workflow OIDC/provenance.
65
+ Read back npm latest/version/gitHead/attestations, tag SHA and GitHub Release flags.
66
+ Rollback package: `dsh plugin --profile web add dsh-data-cleaning-agent@0.9.7`.
67
+ Rollback reintroduces history adoption risk; preserve persisted records and do not move tags.
68
+ Release evidence will be appended after publication; no market or user acceptance claim.
package/lib/client.js CHANGED
@@ -1941,8 +1941,20 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1941
1941
  const time = date && Number.isFinite(date.getTime()) ? date.toLocaleString() : '';
1942
1942
  return `${base}|${task.source?.type === 'image' && task.state === 'draft' ? '待识别' : `${task.source?.rowCount ?? 0} 条`}${time ? '|' + time : ''}`;
1943
1943
  }
1944
+ function taskBelongsToSession(task, sessionId) {
1945
+ if (!sessionId || task?.originSessionId !== sessionId) return false;
1946
+ const scope = sessionWorkbenchController?.originFor?.(sessionId);
1947
+ return !scope?.originWorkspaceId || task.originWorkspaceId === scope.originWorkspaceId;
1948
+ }
1949
+ function workflowOriginLabel(task) {
1950
+ if (!task?.originSessionId) return '历史版本未保存来源';
1951
+ return `工作区:${task.originWorkspaceName || task.originWorkspaceId || '未取得来源工作区'} · 会话:${task.originSessionName || task.originSessionId}`;
1952
+ }
1944
1953
  function cacheWorkflowTask(actions, sessionId, task) {
1945
1954
  if (!task) return null;
1955
+ if (!taskBelongsToSession(task, sessionId)) throw new Error('此任务不属于当前会话;请在任务历史中只读查看或打开来源会话。');
1956
+ const previous = workflowTaskBySession.get(String(sessionId));
1957
+ if (previous?.id === task.id && previous.revision > task.revision) return previous;
1946
1958
  if (sessionId) workflowTaskBySession.set(String(sessionId), task);
1947
1959
  bindTask(sessionId, task.id);
1948
1960
  actions.setWorkflowTask(task);
@@ -1955,10 +1967,13 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1955
1967
  }
1956
1968
 
1957
1969
  async function createWorkflowTask(actions, sessionId, draft = {}) {
1970
+ if (!sessionId) throw new Error('请先打开数据清洗会话。');
1958
1971
  const key = String(sessionId || 'unassigned');
1959
1972
  let pending = workflowTaskCreationBySession.get(key);
1960
1973
  if (!pending) {
1961
1974
  pending = requestJson('/data-cleaning/api/workflow/tasks', 'POST', {
1975
+ ...sessionWorkbenchController?.originFor?.(sessionId),
1976
+ originSessionId: sessionId,
1962
1977
  title: draft.title || '企业数据清洗补全任务',
1963
1978
  objectives: draft.objectives || DEFAULT_CLEANING_KEYS,
1964
1979
  fieldSelection: draft.fieldSelection || DEFAULT_ENRICHMENT_KEYS,
@@ -1984,9 +1999,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
1984
1999
  const saved = readTaskBindings()[key];
1985
2000
  if (ownedSessionId(key) && /^dcw-[a-zA-Z0-9-]+$/.test(saved || '')) {
1986
2001
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(saved)}`);
1987
- const task = cacheWorkflowTask(actions, sessionId, response.task);
1988
- if (restoreStage) actions.setStep(task.stage || 'upload');
1989
- return task;
2002
+ if (taskBelongsToSession(response.task, sessionId)) {
2003
+ const task = cacheWorkflowTask(actions, sessionId, response.task);
2004
+ if (restoreStage) actions.setStep(task.stage || 'upload');
2005
+ return task;
2006
+ }
2007
+ bindTask(sessionId, null);
1990
2008
  }
1991
2009
  return createWorkflowTask(actions, sessionId, draft);
1992
2010
  }
@@ -2000,6 +2018,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2000
2018
  async function ensureEditableWorkflowTask(actions, sessionId, draft = {}, preferredTask) {
2001
2019
  const key = String(sessionId || 'unassigned');
2002
2020
  const current = workflowTaskBySession.get(key) ?? preferredTask ?? null;
2021
+ if (current && !taskBelongsToSession(current, sessionId)) throw new Error('不能将历史任务绑定到当前会话。请新建清洗任务。');
2003
2022
  if (!current) return createWorkflowTask(actions, sessionId, draft);
2004
2023
  if (EDITABLE_WORKFLOW_STATES.has(current.state)) return cacheWorkflowTask(actions, sessionId, current);
2005
2024
  workflowTaskBySession.delete(key);
@@ -2023,8 +2042,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2023
2042
  async function updateWorkflowTask(actions, sessionId, task, input) {
2024
2043
  const latest = workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? task;
2025
2044
  if (!latest) return ensureWorkflowTask(actions, sessionId, input);
2045
+ if (!taskBelongsToSession(latest, sessionId)) throw new Error('此任务不属于当前会话。');
2026
2046
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}`, 'PATCH', {
2027
2047
  ...input,
2048
+ originSessionId: sessionId, originWorkspaceId: latest.originWorkspaceId,
2028
2049
  expectedRevision: latest.revision,
2029
2050
  });
2030
2051
  return cacheWorkflowTask(actions, sessionId, response.task);
@@ -2033,8 +2054,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2033
2054
  async function workflowAction(actions, sessionId, task, action, input = {}) {
2034
2055
  const latest = workflowTaskBySession.get(String(sessionId || 'unassigned')) ?? task;
2035
2056
  if (!latest) throw new Error('请先创建数据清洗补全任务。');
2057
+ if (!taskBelongsToSession(latest, sessionId)) throw new Error('此任务不属于当前会话。');
2036
2058
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/actions/${action}`, 'POST', {
2037
2059
  ...input,
2060
+ originSessionId: sessionId, originWorkspaceId: latest.originWorkspaceId,
2038
2061
  expectedRevision: latest.revision,
2039
2062
  });
2040
2063
  return cacheWorkflowTask(actions, sessionId, response.task);
@@ -2048,6 +2071,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2048
2071
  if (JSON.stringify([...(task.fieldSelection || [])].sort()) !== JSON.stringify([...(expected.enrichmentKeys || [])].sort())) throw new Error('图片任务字段配置尚未保存,请重试。');
2049
2072
  const bound = await requestJson(`/data-cleaning/api/images/commands/${encodeURIComponent(expected.imageCommandId)}`, 'PATCH', {
2050
2073
  taskId: task.id, expectedRevision: task.revision, executeOnSend: true,
2074
+ originSessionId: sessionId, originWorkspaceId: task.originWorkspaceId,
2051
2075
  });
2052
2076
  return { ...bound.command, prompt: buildTaskPrompt(expected) };
2053
2077
  }
@@ -2071,6 +2095,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2071
2095
  });
2072
2096
  const response = await requestJson('/data-cleaning/api/g5/commands', 'POST', {
2073
2097
  kind: 'enrich', workflowOwned: true, expectedRevision: task.revision, taskId: task.id,
2098
+ originSessionId: sessionId, originWorkspaceId: task.originWorkspaceId,
2074
2099
  rows: runtime.rows, headers: runtime.headers, fieldSelection: task.fieldSelection,
2075
2100
  });
2076
2101
  if (!response.command?.commandId || !response.command?.prompt) throw new Error('Host 未返回执行凭证;未发送任务。');
@@ -2079,6 +2104,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2079
2104
 
2080
2105
  async function refreshHostedTask(actions, sessionId, taskId, run, navigate = true) {
2081
2106
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(taskId)}`);
2107
+ if (!taskBelongsToSession(response.task, sessionId)) {
2108
+ if (readTaskBindings()[sessionId] === taskId) bindTask(sessionId, null);
2109
+ return null;
2110
+ }
2082
2111
  const selectedTask = workflowTaskBySession.get(String(sessionId));
2083
2112
  if (selectedTask && selectedTask.id !== taskId) return response.task;
2084
2113
  if (selectedTask && selectedTask.revision > response.task.revision) return selectedTask;
@@ -2136,6 +2165,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2136
2165
  workflowContract: null,
2137
2166
  workflowTask: null,
2138
2167
  workflowTasks: [],
2168
+ historyTask: null,
2139
2169
  taskTitle: '企业数据清洗补全任务',
2140
2170
  mappings: [],
2141
2171
  objectives: [...DEFAULT_CLEANING_KEYS],
@@ -2178,6 +2208,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2178
2208
  draft.workflowTasks = (Array.isArray(value) ? value : []).map(task =>
2179
2209
  (cached.get(task.id)?.revision ?? -1) > (task.revision ?? -1) ? cached.get(task.id) : task);
2180
2210
  },
2211
+ setHistoryTask: (draft, value) => { draft.historyTask = value; },
2212
+ refreshHistoryTask: (draft, value) => {
2213
+ if (value?.id === draft.historyTask?.id && value.revision >= draft.historyTask.revision) draft.historyTask = value;
2214
+ },
2181
2215
  setTaskTitle: (draft, value) => { draft.taskTitle = String(value ?? ''); },
2182
2216
  setMappings: (draft, value) => {
2183
2217
  const next = Array.isArray(value) ? value : [];
@@ -3751,6 +3785,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3751
3785
  const workflowContract = useStore((state) => state.workflowContract);
3752
3786
  const workflowTask = useStore((state) => state.workflowTask);
3753
3787
  const workflowTasks = useStore((state) => state.workflowTasks);
3788
+ const historyTask = useStore((state) => state.historyTask);
3754
3789
  const taskTitle = useStore((state) => state.taskTitle);
3755
3790
  const mappings = useStore((state) => state.mappings);
3756
3791
  const objectives = useStore((state) => state.objectives);
@@ -3811,6 +3846,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3811
3846
  try {
3812
3847
  const task = await refreshHostedTask(actions, activeSessionId, workflowTask.id, null, false);
3813
3848
  if (disposed) return;
3849
+ if (!task) return;
3814
3850
  if (['matching', 'enriching'].includes(task.state)) timer = setTimeout(poll, 2000);
3815
3851
  else if (step === 'match' || step === 'enrich') actions.setStep(task.stage || step);
3816
3852
  } catch (error) { if (!disposed) actions.setError(error.message); }
@@ -4046,8 +4082,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4046
4082
  throw new Error('当前会话无法提交数据清洗补全企查查任务');
4047
4083
  }
4048
4084
  const latest = workflowTaskBySession.get(String(activeSessionId)) ?? cachedTask;
4085
+ if (!taskBelongsToSession(latest, activeSessionId)) throw new Error('此任务不属于当前会话。');
4049
4086
  const prepared = await api('/data-cleaning/api/g5/commands', {
4050
4087
  ...payload, workflowOwned: true, expectedRevision: latest.revision,
4088
+ originSessionId: activeSessionId, originWorkspaceId: latest.originWorkspaceId,
4051
4089
  ...(payload.kind === 'enrich' ? {} : { confirmPaidCalls: true }),
4052
4090
  });
4053
4091
  const command = prepared?.command;
@@ -4200,28 +4238,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4200
4238
  };
4201
4239
 
4202
4240
  const resumeWorkflowTask = async (task) => {
4241
+ // History selection has its own state; never hydrate the current task runtime.
4242
+ actions.setHistoryTask(task);
4203
4243
  try {
4204
- workflowTaskBySession.set(String(activeSessionId), task);
4205
- task = await refreshHostedTask(actions, activeSessionId, task.id);
4206
- if (task.qccRunId) {
4207
- try {
4208
- const response = await api(`/data-cleaning/api/g5/run/${encodeURIComponent(task.qccRunId)}`);
4209
- actions.setQccRun(response);
4210
- } catch { /* Expired run data does not prevent persisted artifact downloads. */ }
4211
- }
4244
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(task.id)}`);
4245
+ actions.refreshHistoryTask(response.task);
4212
4246
  } catch (error) { actions.setError(error.message); return; }
4213
- workflowTaskBySession.set(String(activeSessionId || 'unassigned'), task);
4214
- actions.setWorkflowTask(task);
4215
- actions.setTaskTitle(task.title);
4216
- actions.setMappings(task.mappings || []);
4217
- actions.setObjectives(task.objectives || []);
4218
- actions.setFieldSelection(task.fieldSelection || []);
4219
- for (const [key, value] of Object.entries(task.matchRules || {})) actions.setMatchRule(key, value);
4220
- actions.setStep(task.stage || 'upload');
4221
- if (!runtimeFor(task.id, false)?.rows?.length && task.source?.rowCount && !(task.artifacts || []).length) {
4222
- actions.setDataset(null);
4223
- actions.setError('已恢复任务元数据。出于隐私保护,原始企业名单未持久化;继续处理前请重新上传源文件。');
4224
- }
4225
4247
  };
4226
4248
 
4227
4249
  const startNewWorkflow = () => {
@@ -4251,6 +4273,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4251
4273
  const rows = exportRows();
4252
4274
  if (!rows?.length) throw new Error('当前页面没有可导出的明细。若任务来自历史记录,请重新上传源文件并继续处理。');
4253
4275
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts`, 'POST', {
4276
+ originSessionId: activeSessionId, originWorkspaceId: latest.originWorkspaceId,
4254
4277
  expectedRevision: latest.revision,
4255
4278
  headers: runtime.headers,
4256
4279
  rows,
@@ -4350,7 +4373,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4350
4373
  let pane;
4351
4374
  if (step === 'history') {
4352
4375
  pane = h('div', { className: 'dcAgentPane' },
4353
- h('p', { className: 'dcAgentHint' }, '查看当前 Host 的数据清洗补全任务。Host 只保存任务元数据和摘要,不保存原始企业名单。'),
4376
+ h('p', { className: 'dcAgentHint' }, '当前 Profile 的任务历史,仅只读查看;不会改变当前会话的任务、名单、规则或执行范围。'),
4354
4377
  h('div', { className: 'dcAgentRow' },
4355
4378
  h('button', {
4356
4379
  type: 'button',
@@ -4365,9 +4388,29 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4365
4388
  onClick: startNewWorkflow,
4366
4389
  }, '新建清洗任务'),
4367
4390
  ),
4391
+ historyTask ? h('section', { className: 'dcAgentSection', 'aria-label': '历史任务只读详情' },
4392
+ h('h3', null, workflowDisplayName(historyTask)),
4393
+ h('p', { className: 'dcAgentHint' }, workflowOriginLabel(historyTask)),
4394
+ h('p', null, `只读 · ${WORKFLOW_STATE_LABELS[historyTask.state] || '未知'} · ${historyTask.source?.rowCount ?? 0} 行`),
4395
+ h('p', { className: 'dcAgentHint' }, `任务编号:${historyTask.id}`),
4396
+ sessionWorkbenchController?.canOpenOrigin?.(historyTask)
4397
+ ? h('button', { type: 'button', className: 'dcAgentButton', onClick: () => {
4398
+ try { sessionWorkbenchController.openOrigin(historyTask); }
4399
+ catch (error) { actions.setError(error.message); }
4400
+ } }, '打开来源会话')
4401
+ : h('p', { className: 'dcAgentHint' }, '来源会话未知、已不可用或宿主不支持打开;保留只读查看和已有文件下载。'),
4402
+ (historyTask.artifacts || []).filter(a => a.format === 'xlsx').map(artifact =>
4403
+ h('div', { key: artifact.id, className: 'dcAgentRow' },
4404
+ artifact.kind !== 'report' ? h('a', { className: 'dcAgentButton', href: `/data-cleaning/api/workflow/tasks/${encodeURIComponent(historyTask.id)}/artifacts/${encodeURIComponent(artifact.id)}?preview=1`, target: '_blank', rel: 'noopener' }, '预览 / 打开') : null,
4405
+ h('a', { className: artifact.kind === 'report' ? 'dcAgentHint' : 'dcAgentButton',
4406
+ href: `/data-cleaning/api/workflow/tasks/${encodeURIComponent(historyTask.id)}/artifacts/${encodeURIComponent(artifact.id)}`,
4407
+ download: artifact.fileName }, artifact.kind === 'report' ? '下载任务结果报告' : `下载 ${artifact.fileName}`))),
4408
+ !historyTask.artifacts?.length ? h('p', { className: 'dcAgentHint' }, '此任务暂无已保存制品;历史查看不会重新调用企查查。') : null,
4409
+ ) : null,
4368
4410
  workflowTasks.length ? h('div', { className: 'dcAgentPane', 'aria-label': '任务历史列表' },
4369
4411
  workflowTasks.map((task) => h('button', { key: task.id, type: 'button', className: 'dcAgentHistoryTask', onClick: () => resumeWorkflowTask(task) },
4370
4412
  h('h3', null, workflowDisplayName(task)),
4413
+ h('p', { className: 'dcAgentHint' }, workflowOriginLabel(task)),
4371
4414
  h('div', { className: 'dcAgentRow' },
4372
4415
  h('span', { className: 'dcAgentJobsPill', 'data-state': task.state ?? 'idle' }, WORKFLOW_STATE_LABELS[task.state] ?? task.state ?? '未知'),
4373
4416
  h('span', { className: 'dcAgentHint' }, `${task.source?.rowCount ?? 0} 行 · ${task.updatedAt ? new Date(task.updatedAt).toLocaleString() : ''}`),
@@ -4966,6 +5009,27 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4966
5009
  : null;
4967
5010
  const disconnectFixture = typeof ctx.inject !== 'function' ? connectProvider(ctx.betterSidebar) : () => {};
4968
5011
  const controller = {
5012
+ originFor(sessionId) {
5013
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : ctx.sessions;
5014
+ const workspaces = typeof ctx.get === 'function' ? ctx.get('workspaces') : ctx.workspaces;
5015
+ const summary = sessions?.list?.getSnapshot?.()?.byId?.[sessionId];
5016
+ const workspace = workspaces?.list?.getSnapshot?.()?.items?.find(item => item.sessionIds?.includes(sessionId));
5017
+ return { originSessionId: sessionId, originWorkspaceId: workspace?.workspaceId || summary?.workspaceId || null,
5018
+ originSessionName: summary?.title || null, originWorkspaceName: workspace?.name || workspace?.title || null };
5019
+ },
5020
+ canOpenOrigin(task) {
5021
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : ctx.sessions;
5022
+ if (!task?.originSessionId || !ownedSessionId(task.originSessionId) || typeof sessions?.open !== 'function') return false;
5023
+ const summary = sessions.list?.getSnapshot?.()?.byId?.[task.originSessionId];
5024
+ const scope = controller.originFor(task.originSessionId);
5025
+ return Boolean(summary && (!task.originWorkspaceId || scope.originWorkspaceId === task.originWorkspaceId));
5026
+ },
5027
+ openOrigin(task) {
5028
+ if (!controller.canOpenOrigin(task)) throw new Error('来源会话不可用;请继续只读查看。');
5029
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : ctx.sessions;
5030
+ sessions.open(task.originSessionId);
5031
+ // Navigation only: no task binding, command preparation, or draft writes.
5032
+ },
4969
5033
  storeFor: instanceFor,
4970
5034
  actionsFor: sessionId => instanceFor(sessionId).actions,
4971
5035
  open(sessionId, step) {
@@ -5168,6 +5232,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5168
5232
  ensureWorkflowTask,
5169
5233
  ensureEditableWorkflowTask,
5170
5234
  queueWorkflowOperation,
5235
+ taskBelongsToSession,
5236
+ workflowOriginLabel,
5237
+ cacheWorkflowTask,
5238
+ refreshHostedTask,
5171
5239
  };
5172
5240
  return module.exports;
5173
5241
  },
@@ -23,6 +23,7 @@ export function createImageWorkflow({ getWorkflow, execution, commands }) {
23
23
  summary: { total: rows.length, valid: rows.length, missingAnchor: 0,
24
24
  duplicates: rows.length - new Set(result.entries).size } });
25
25
  const payload = await execution.prepare({ taskId: task.id, expectedRevision: task.revision,
26
+ originSessionId: task.originSessionId, originWorkspaceId: task.originWorkspaceId,
26
27
  kind: 'enrich', rows, headers: ['主体标识'], nameField: '主体标识' });
27
28
  const command = commands.prepare(payload, { artifactOrigin: record.workflow.artifactOrigin });
28
29
  record.qccCommand = { commandId: command.commandId, taskId: task.id };
package/lib/qcc-safety.js CHANGED
@@ -78,6 +78,7 @@ export function safeAuditEvent(event) {
78
78
  upstreamCode: source.upstreamCode ? String(source.upstreamCode) : null,
79
79
  durationMs: Math.max(0, Number(source.durationMs ?? 0)),
80
80
  };
81
+ if (['success-data', 'success-empty', 'not-required', 'no-permission', 'failed', 'unknown'].includes(source.providerState)) safe.providerState = source.providerState;
81
82
  if (source.catalogVersion || Array.isArray(source.missing) || Array.isArray(source.unknown)) {
82
83
  safe.catalogVersion = source.catalogVersion ? String(source.catalogVersion).slice(0, 64) : null;
83
84
  safe.missing = safeCatalogLabels(source.missing);
package/lib/qcc.js CHANGED
@@ -281,12 +281,26 @@ function normalizedFailure(result, toolName, state) {
281
281
 
282
282
  function emitAudit(options, event) {
283
283
  try {
284
- options?.onAudit?.(safeAuditEvent(event));
284
+ options?.onAudit?.(safeAuditEvent({ ...event, providerState: event.providerState ?? providerResultState({
285
+ error: event.outcome === 'failed' || event.outcome === 'not-dispatched' ? { code: event.code, upstreamCode: event.upstreamCode } : null,
286
+ }) }));
285
287
  } catch {
286
288
  // 审计 sink 失败不能改变计费工具调用的业务结果。
287
289
  }
288
290
  }
289
291
 
292
+ /** Provider outcome is separate from execution completion and business review. */
293
+ export function providerResultState({ data, error, required = true } = {}) {
294
+ if (!required) return 'not-required';
295
+ if (error) return ['401', '403', 'QCC_AUTH_REQUIRED'].includes(String(error.upstreamCode || error.code)) ? 'no-permission' : 'failed';
296
+ if (Array.isArray(data)) return data.length ? 'success-data' : 'success-empty';
297
+ if (!data || typeof data !== 'object' || !Object.keys(data).length) return 'unknown';
298
+ const lists = ['实际控制人信息', '受益所有人信息', 'items', 'records', '企业列表'].map(k => data[k]).filter(Array.isArray);
299
+ if (lists.length && lists.every(v => !v.length)) return data.has_more === true ? 'unknown' : 'success-empty';
300
+ // No fabricated zero count for a missing/null/unrecognised transport payload.
301
+ return 'success-data';
302
+ }
303
+
290
304
  function candidateView(candidate) {
291
305
  const item = isRecord(candidate) ? candidate : {};
292
306
  return {
@@ -835,15 +849,17 @@ export class QccHostBridge {
835
849
  } : {}),
836
850
  });
837
851
  if (result?.isError !== true) {
852
+ const data = decodeQccToolValue(result?.value);
838
853
  emitAudit(options, {
839
854
  toolName: activeToolName,
840
855
  callId,
841
856
  attempt,
842
857
  outcome: 'success',
858
+ providerState: providerResultState({ data }),
843
859
  durationMs: Date.now() - attemptStarted,
844
860
  });
845
861
  attemptAudited = true;
846
- return { callId, toolName: activeToolName, data: decodeQccToolValue(result?.value) };
862
+ return { callId, toolName: activeToolName, data };
847
863
  }
848
864
  const upstreamCode = upstreamFailureCode(result);
849
865
  if (upstreamCode === 'UNKNOWN_TOOL' && attempt === 1 && !state.signal.aborted) {
package/lib/web.js CHANGED
@@ -424,6 +424,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
424
424
  const payload = JSON.parse((await readBody(req)).toString('utf8'));
425
425
  const record = imageIntake.require(decodeURIComponent(rest[0]));
426
426
  const task = await (await getWorkflow()).require(payload.taskId);
427
+ await (await getWorkflow()).assertRequestOrigin(task.id, payload);
427
428
  if (payload.executeOnSend !== true || record.state !== 'prepared' || task.state !== 'draft'
428
429
  || task.revision !== payload.expectedRevision || task.source?.type !== 'image'
429
430
  || task.source.fileName !== record.fileName || !task.fieldSelection.length) {
@@ -516,6 +517,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
516
517
  if (req.method === 'PATCH') {
517
518
  const body = await readBody(req);
518
519
  const payload = body.length ? JSON.parse(body.toString('utf8')) : {};
520
+ await workflow.assertRequestOrigin(taskId, payload);
519
521
  return writeJson(res, 200, {
520
522
  ok: true,
521
523
  marker: 'data-cleaning-workflow-v2',
@@ -545,6 +547,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
545
547
  if (rest.length === 2 && req.method === 'POST') {
546
548
  const body = await readBody(req);
547
549
  const payload = body.length ? JSON.parse(body.toString('utf8')) : {};
550
+ await workflow.assertRequestOrigin(taskId, payload);
548
551
  if (Number(payload.expectedRevision) !== current.revision) {
549
552
  throw new WorkflowError('DC_WORKFLOW_REVISION_CONFLICT', 'Workflow task was updated by another session.', 409);
550
553
  }
@@ -612,6 +615,7 @@ export function mountWebRoutes(wctx, { logger, report, TOOL_NAME, SKILL_NAME })
612
615
  const body = await readBody(req);
613
616
  const payload = body.length ? JSON.parse(body.toString('utf8')) : {};
614
617
  const action = rest[2];
618
+ await workflow.assertRequestOrigin(taskId, payload);
615
619
  const actions = {
616
620
  upload: () => workflow.recordUpload(taskId, payload),
617
621
  rules: () => workflow.confirmRules(taskId, payload),
@@ -215,5 +215,17 @@ export function assertWorkflowRecordShape(record) {
215
215
  if (!STAGE_IDS.has(record.stage) || !STATE_IDS.has(record.state)) {
216
216
  throw new WorkflowContractError('DC_WORKFLOW_SCHEMA', 'Workflow record contains an invalid stage or state.');
217
217
  }
218
- return record;
218
+ // Additive v2 migration: never infer ownership from whichever Session reads history.
219
+ return { ...record, ...normalizeWorkflowOrigin(record) };
220
+ }
221
+
222
+ export function normalizeWorkflowOrigin(value = {}) {
223
+ const id = value => typeof value === 'string' && /^[\w:.-]{1,200}$/.test(value) ? value : null;
224
+ const label = value => typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 120) || null : null;
225
+ return {
226
+ originSessionId: id(value.originSessionId),
227
+ originWorkspaceId: id(value.originWorkspaceId),
228
+ originSessionName: label(value.originSessionName),
229
+ originWorkspaceName: label(value.originWorkspaceName),
230
+ };
219
231
  }
@@ -10,7 +10,7 @@ export function createWorkflowExecution({ getWorkflow, artifacts }) {
10
10
  return {
11
11
  async prepare(payload) {
12
12
  const store = await requireStore();
13
- const task = await store.require(payload.taskId);
13
+ const task = await store.assertRequestOrigin(payload.taskId, payload);
14
14
  if (!['rules_confirmed', 'diagnosed', 'review_required', 'partial'].includes(task.state)) {
15
15
  throw new WorkflowError('DC_EXECUTION_STATE', '请先在工作台确认规则;进行中或已完成任务不能重复启动。', 409);
16
16
  }
package/lib/workflow.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  assertWorkflowTransition,
13
13
  normalizeFieldSelection,
14
14
  normalizeWorkflowDraft,
15
+ normalizeWorkflowOrigin,
15
16
  validateMappings,
16
17
  } from './workflow-contract.js';
17
18
 
@@ -140,6 +141,7 @@ export class DataCleaningWorkflowStore {
140
141
  const id = this.idFactory();
141
142
  const record = {
142
143
  id,
144
+ ...normalizeWorkflowOrigin(input),
143
145
  schemaVersion: WORKFLOW_SCHEMA_VERSION,
144
146
  revision: 1,
145
147
  title: draft.title,
@@ -180,6 +182,16 @@ export class DataCleaningWorkflowStore {
180
182
  return record;
181
183
  }
182
184
 
185
+ async assertRequestOrigin(id, input = {}) {
186
+ const task = await this.require(id);
187
+ // Origin-less legacy/headless records remain readable; UI never adopts them.
188
+ if (task.originSessionId && (input.originSessionId !== task.originSessionId
189
+ || (task.originWorkspaceId && input.originWorkspaceId !== task.originWorkspaceId))) {
190
+ throw new WorkflowError('DC_WORKFLOW_SCOPE', '任务不属于此来源会话;历史任务仅可只读查看。', 409);
191
+ }
192
+ return task;
193
+ }
194
+
183
195
  async mutate(id, expectedRevision, updater) {
184
196
  const key = String(id);
185
197
  const current = await this.require(key);
@@ -197,6 +209,7 @@ export class DataCleaningWorkflowStore {
197
209
  ...latest,
198
210
  ...patch,
199
211
  id: latest.id,
212
+ ...normalizeWorkflowOrigin(latest),
200
213
  schemaVersion: WORKFLOW_SCHEMA_VERSION,
201
214
  revision: latest.revision + 1,
202
215
  updatedAt: this.nowFn(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-data-cleaning-agent",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "Data cleaning and data enrichment for CSV/XLSX/JSON enterprise lists in DeepSeek Harness, including spreadsheet cleaning, deduplication, profiling, optional Qichacha MCP and exports.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -20,6 +20,7 @@
20
20
  "install.sh",
21
21
  "marketing",
22
22
  "docs/USER-GUIDE.md",
23
+ "docs/RELEASE-0.9.8.md",
23
24
  "docs/FIRST-CONTRIBUTION.md",
24
25
  "docs/COMPATIBILITY.md",
25
26
  "docs/UI-V1.5.0-ADOPTION.md",