dsh-data-cleaning-agent 0.9.7 → 0.9.9

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,18 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.9] - 2026-09-13
8
+
9
+ - 原生首页标题桥接适配新版 Host 的 composer sibling slot;菜单进入数据清洗补全后稳定显示“数据清洗补全智能体”,不再回退为“探索未至之境”。
10
+ - 保持桥接可逆并补充第三方插件共存回归,避免误改其它业务智能体的标题、提示区或输入框。
11
+
12
+ ## [0.9.8] - 2026-09-13
13
+
14
+ - DC-SCOPE-P0:持久保存任务来源 Session/Workspace 与安全展示名称;v2 旧记录采用附加字段兼容读取,不猜测来源、不批量改写历史。
15
+ - Profile 历史改为独立只读详情;打开来源会话按宿主能力降级,不覆盖当前任务、规则、名单或命令。保留客户 XLSX 与次要任务报告分层。
16
+ - 当前绑定恢复、迟到刷新及 HTTP 写入/命令准备校验来源;跨会话请求拒绝,版本冲突仍由 Host revision 控制。
17
+ - 审计增加 Provider 六类状态区分;完成态迟到更新、A/B、旧记录、重启和来源导航补充回归。真实 DSH Web 未认证,真实宿主与 Provider 未验证。
18
+
7
19
  ## [0.9.7] - 2026-09-11
8
20
 
9
21
  - 优化数据清洗补全工作台的区域间距、信息层级与明暗主题响应式布局,重构导入清单、字段映射、质量体检和主体匹配页面。
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.9** (stable release)
14
+
15
+ 0.9.9 restores the “数据清洗补全智能体” native home title when newer Host builds place the plugin marker and Hero headline in sibling composer branches. The bridge remains reversible and isolated from other business plugins. The 0.9.8 read-only Profile history and origin Workspace/Session guarantees remain unchanged. Rollback: `dsh plugin --profile web add dsh-data-cleaning-agent@0.9.8`. Live DSH A/B verification remains a post-install user check.
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.9
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.9**(正式版本)
31
+
32
+ 0.9.9 修复新版 Host composer sibling slot 下的原生首页标题定位,进入数据清洗补全后稳定显示“数据清洗补全智能体”,并保持对其它业务插件的标题隔离。0.9.8 的 Profile 历史只读、来源工作区/会话校验与旧记录边界全部保留。回滚:`dsh plugin --profile web add dsh-data-cleaning-agent@0.9.8`。真实 DSH A/B 验收仍以安装后的用户验证为准,详见发布说明。
31
33
 
32
34
  本版优化工作台的信息层级、区域间距与响应式布局,重构导入清单、字段映射、质量体检及主体匹配页面。导入成功后文件、图片与粘贴入口自动收起,通过“重新导入”再次展开;映射字段按维度分组选择,搜索维度名称时展示该组全部字段。生成说明不查询,发送才执行;升级后需完整重启 DSH,历史下载文件不自动改写。
33
35
 
@@ -0,0 +1,24 @@
1
+ # 0.9.9 · Native home title compatibility
2
+
3
+ Status: stable release. Date: 2026-09-13.
4
+
5
+ ## Scope
6
+
7
+ - Adapt the reversible native Hero title bridge to Host layouts where the plugin marker and headline live in sibling branches below the composer container.
8
+ - Keep the data-cleaning home title fixed at “数据清洗补全智能体” without modifying another business plugin's headline, prompt UI or input focus.
9
+ - Preserve the 0.9.8 Profile history ownership, origin Workspace/Session checks and artifact-download behavior.
10
+
11
+ ## Verification
12
+
13
+ - The coexistence fixture now exercises a sibling-slot Host structure rather than the obsolete nested Hero structure.
14
+ - `npm run check` covers syntax, documentation version consistency, marketing metadata, package allowlist and the full Node test suite.
15
+ - The React/Chromium layout matrix remains a separate isolated UI gate; real DSH and production QCC Provider behavior require post-install acceptance.
16
+
17
+ ## Install and rollback
18
+
19
+ ```sh
20
+ dsh plugin --profile web add dsh-data-cleaning-agent@0.9.9
21
+ ```
22
+
23
+ Rollback package: `dsh plugin --profile web add dsh-data-cleaning-agent@0.9.8`.
24
+ Do not move or overwrite existing tags or npm versions.
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 : [];
@@ -2684,7 +2718,22 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
2684
2718
  if (!enabled || typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return () => {};
2685
2719
  const marker = [...document.querySelectorAll('.dcAgentExperience')]
2686
2720
  .find((element) => element?.dataset?.sessionId === sessionId);
2687
- const hero = marker?.closest?.('[data-phase="hero"]');
2721
+ const hasHeadline = (scope) => {
2722
+ if (!scope?.querySelectorAll) return false;
2723
+ const spans = [...scope.querySelectorAll('span')];
2724
+ return spans.some((element) => element.dataset?.dcAgentHeroTitle === 'true'
2725
+ || ['探索未至之境', 'Into the Unknown'].includes(element.textContent?.trim()))
2726
+ || Boolean(scope.querySelector?.('[class*="headlineText"]'));
2727
+ };
2728
+ // 新版 Host 会把 list slot 放在 composer seat 的独立分支;marker 不一定再是
2729
+ // data-phase=hero 的后代。沿祖先寻找同时包含原生标题的最小范围,保留旧结构兼容。
2730
+ let hero = marker?.closest?.('[data-phase="hero"]') ?? null;
2731
+ if (!hasHeadline(hero)) {
2732
+ let scope = marker?.closest?.('[data-composer-seat]') ?? marker?.parentElement ?? null;
2733
+ while (scope && !hasHeadline(scope)) scope = scope.parentElement;
2734
+ if (scope) hero = scope;
2735
+ }
2736
+ if (!hero && document.body?.querySelectorAll) hero = document.body;
2688
2737
  if (!hero) return () => {};
2689
2738
  const changedTitles = new Map();
2690
2739
  const changedBadges = new Map();
@@ -3751,6 +3800,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3751
3800
  const workflowContract = useStore((state) => state.workflowContract);
3752
3801
  const workflowTask = useStore((state) => state.workflowTask);
3753
3802
  const workflowTasks = useStore((state) => state.workflowTasks);
3803
+ const historyTask = useStore((state) => state.historyTask);
3754
3804
  const taskTitle = useStore((state) => state.taskTitle);
3755
3805
  const mappings = useStore((state) => state.mappings);
3756
3806
  const objectives = useStore((state) => state.objectives);
@@ -3811,6 +3861,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
3811
3861
  try {
3812
3862
  const task = await refreshHostedTask(actions, activeSessionId, workflowTask.id, null, false);
3813
3863
  if (disposed) return;
3864
+ if (!task) return;
3814
3865
  if (['matching', 'enriching'].includes(task.state)) timer = setTimeout(poll, 2000);
3815
3866
  else if (step === 'match' || step === 'enrich') actions.setStep(task.stage || step);
3816
3867
  } catch (error) { if (!disposed) actions.setError(error.message); }
@@ -4046,8 +4097,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4046
4097
  throw new Error('当前会话无法提交数据清洗补全企查查任务');
4047
4098
  }
4048
4099
  const latest = workflowTaskBySession.get(String(activeSessionId)) ?? cachedTask;
4100
+ if (!taskBelongsToSession(latest, activeSessionId)) throw new Error('此任务不属于当前会话。');
4049
4101
  const prepared = await api('/data-cleaning/api/g5/commands', {
4050
4102
  ...payload, workflowOwned: true, expectedRevision: latest.revision,
4103
+ originSessionId: activeSessionId, originWorkspaceId: latest.originWorkspaceId,
4051
4104
  ...(payload.kind === 'enrich' ? {} : { confirmPaidCalls: true }),
4052
4105
  });
4053
4106
  const command = prepared?.command;
@@ -4200,28 +4253,12 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4200
4253
  };
4201
4254
 
4202
4255
  const resumeWorkflowTask = async (task) => {
4256
+ // History selection has its own state; never hydrate the current task runtime.
4257
+ actions.setHistoryTask(task);
4203
4258
  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
- }
4259
+ const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(task.id)}`);
4260
+ actions.refreshHistoryTask(response.task);
4212
4261
  } 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
4262
  };
4226
4263
 
4227
4264
  const startNewWorkflow = () => {
@@ -4251,6 +4288,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4251
4288
  const rows = exportRows();
4252
4289
  if (!rows?.length) throw new Error('当前页面没有可导出的明细。若任务来自历史记录,请重新上传源文件并继续处理。');
4253
4290
  const response = await requestJson(`/data-cleaning/api/workflow/tasks/${encodeURIComponent(latest.id)}/artifacts`, 'POST', {
4291
+ originSessionId: activeSessionId, originWorkspaceId: latest.originWorkspaceId,
4254
4292
  expectedRevision: latest.revision,
4255
4293
  headers: runtime.headers,
4256
4294
  rows,
@@ -4350,7 +4388,7 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4350
4388
  let pane;
4351
4389
  if (step === 'history') {
4352
4390
  pane = h('div', { className: 'dcAgentPane' },
4353
- h('p', { className: 'dcAgentHint' }, '查看当前 Host 的数据清洗补全任务。Host 只保存任务元数据和摘要,不保存原始企业名单。'),
4391
+ h('p', { className: 'dcAgentHint' }, '当前 Profile 的任务历史,仅只读查看;不会改变当前会话的任务、名单、规则或执行范围。'),
4354
4392
  h('div', { className: 'dcAgentRow' },
4355
4393
  h('button', {
4356
4394
  type: 'button',
@@ -4365,9 +4403,29 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4365
4403
  onClick: startNewWorkflow,
4366
4404
  }, '新建清洗任务'),
4367
4405
  ),
4406
+ historyTask ? h('section', { className: 'dcAgentSection', 'aria-label': '历史任务只读详情' },
4407
+ h('h3', null, workflowDisplayName(historyTask)),
4408
+ h('p', { className: 'dcAgentHint' }, workflowOriginLabel(historyTask)),
4409
+ h('p', null, `只读 · ${WORKFLOW_STATE_LABELS[historyTask.state] || '未知'} · ${historyTask.source?.rowCount ?? 0} 行`),
4410
+ h('p', { className: 'dcAgentHint' }, `任务编号:${historyTask.id}`),
4411
+ sessionWorkbenchController?.canOpenOrigin?.(historyTask)
4412
+ ? h('button', { type: 'button', className: 'dcAgentButton', onClick: () => {
4413
+ try { sessionWorkbenchController.openOrigin(historyTask); }
4414
+ catch (error) { actions.setError(error.message); }
4415
+ } }, '打开来源会话')
4416
+ : h('p', { className: 'dcAgentHint' }, '来源会话未知、已不可用或宿主不支持打开;保留只读查看和已有文件下载。'),
4417
+ (historyTask.artifacts || []).filter(a => a.format === 'xlsx').map(artifact =>
4418
+ h('div', { key: artifact.id, className: 'dcAgentRow' },
4419
+ 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,
4420
+ h('a', { className: artifact.kind === 'report' ? 'dcAgentHint' : 'dcAgentButton',
4421
+ href: `/data-cleaning/api/workflow/tasks/${encodeURIComponent(historyTask.id)}/artifacts/${encodeURIComponent(artifact.id)}`,
4422
+ download: artifact.fileName }, artifact.kind === 'report' ? '下载任务结果报告' : `下载 ${artifact.fileName}`))),
4423
+ !historyTask.artifacts?.length ? h('p', { className: 'dcAgentHint' }, '此任务暂无已保存制品;历史查看不会重新调用企查查。') : null,
4424
+ ) : null,
4368
4425
  workflowTasks.length ? h('div', { className: 'dcAgentPane', 'aria-label': '任务历史列表' },
4369
4426
  workflowTasks.map((task) => h('button', { key: task.id, type: 'button', className: 'dcAgentHistoryTask', onClick: () => resumeWorkflowTask(task) },
4370
4427
  h('h3', null, workflowDisplayName(task)),
4428
+ h('p', { className: 'dcAgentHint' }, workflowOriginLabel(task)),
4371
4429
  h('div', { className: 'dcAgentRow' },
4372
4430
  h('span', { className: 'dcAgentJobsPill', 'data-state': task.state ?? 'idle' }, WORKFLOW_STATE_LABELS[task.state] ?? task.state ?? '未知'),
4373
4431
  h('span', { className: 'dcAgentHint' }, `${task.source?.rowCount ?? 0} 行 · ${task.updatedAt ? new Date(task.updatedAt).toLocaleString() : ''}`),
@@ -4966,6 +5024,27 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
4966
5024
  : null;
4967
5025
  const disconnectFixture = typeof ctx.inject !== 'function' ? connectProvider(ctx.betterSidebar) : () => {};
4968
5026
  const controller = {
5027
+ originFor(sessionId) {
5028
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : ctx.sessions;
5029
+ const workspaces = typeof ctx.get === 'function' ? ctx.get('workspaces') : ctx.workspaces;
5030
+ const summary = sessions?.list?.getSnapshot?.()?.byId?.[sessionId];
5031
+ const workspace = workspaces?.list?.getSnapshot?.()?.items?.find(item => item.sessionIds?.includes(sessionId));
5032
+ return { originSessionId: sessionId, originWorkspaceId: workspace?.workspaceId || summary?.workspaceId || null,
5033
+ originSessionName: summary?.title || null, originWorkspaceName: workspace?.name || workspace?.title || null };
5034
+ },
5035
+ canOpenOrigin(task) {
5036
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : ctx.sessions;
5037
+ if (!task?.originSessionId || !ownedSessionId(task.originSessionId) || typeof sessions?.open !== 'function') return false;
5038
+ const summary = sessions.list?.getSnapshot?.()?.byId?.[task.originSessionId];
5039
+ const scope = controller.originFor(task.originSessionId);
5040
+ return Boolean(summary && (!task.originWorkspaceId || scope.originWorkspaceId === task.originWorkspaceId));
5041
+ },
5042
+ openOrigin(task) {
5043
+ if (!controller.canOpenOrigin(task)) throw new Error('来源会话不可用;请继续只读查看。');
5044
+ const sessions = typeof ctx.get === 'function' ? ctx.get('sessions') : ctx.sessions;
5045
+ sessions.open(task.originSessionId);
5046
+ // Navigation only: no task binding, command preparation, or draft writes.
5047
+ },
4969
5048
  storeFor: instanceFor,
4970
5049
  actionsFor: sessionId => instanceFor(sessionId).actions,
4971
5050
  open(sessionId, step) {
@@ -5168,6 +5247,10 @@ body:has(.dcAgentPromptBackdrop) { overflow: hidden; }
5168
5247
  ensureWorkflowTask,
5169
5248
  ensureEditableWorkflowTask,
5170
5249
  queueWorkflowOperation,
5250
+ taskBelongsToSession,
5251
+ workflowOriginLabel,
5252
+ cacheWorkflowTask,
5253
+ refreshHostedTask,
5171
5254
  };
5172
5255
  return module.exports;
5173
5256
  },
@@ -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.9",
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.9.md",
23
24
  "docs/FIRST-CONTRIBUTION.md",
24
25
  "docs/COMPATIBILITY.md",
25
26
  "docs/UI-V1.5.0-ADOPTION.md",