@joekytc/dsh-swarm 0.3.1 → 0.3.6

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.
Files changed (53) hide show
  1. package/README.md +14 -7
  2. package/README.zh-CN.md +10 -5
  3. package/client/KanbanBoard.tsx +3 -1
  4. package/client/TaskDrawer.tsx +74 -2
  5. package/client/WorkflowRail.tsx +84 -1
  6. package/client/config-store.ts +3 -1
  7. package/client/kanban.css +28 -0
  8. package/client/timeline-model.ts +4 -0
  9. package/client/workflow-model.ts +20 -11
  10. package/lib/client.js +1100 -22
  11. package/lib/config.d.ts +5 -1
  12. package/lib/config.js +2 -1
  13. package/lib/dispatcher/agent-runner.js +25 -9
  14. package/lib/dispatcher/event-waker.js +4 -1
  15. package/lib/dispatcher/v-orchestrator.d.ts +2 -0
  16. package/lib/dispatcher/v-orchestrator.js +58 -9
  17. package/lib/domain/config-override.d.ts +6 -0
  18. package/lib/domain/config-override.js +16 -1
  19. package/lib/domain/im-message.d.ts +21 -2
  20. package/lib/domain/im-message.js +84 -4
  21. package/lib/domain/kanban-service.d.ts +8 -1
  22. package/lib/domain/kanban-service.js +67 -1
  23. package/lib/domain/ocr-review.d.ts +6 -2
  24. package/lib/domain/ocr-review.js +20 -11
  25. package/lib/domain/permissions.d.ts +1 -1
  26. package/lib/domain/permissions.js +4 -0
  27. package/lib/domain/projection.js +8 -3
  28. package/lib/domain/review-evidence.js +9 -0
  29. package/lib/domain/review-target.d.ts +10 -0
  30. package/lib/domain/review-target.js +24 -0
  31. package/lib/domain/state-machine.js +1 -1
  32. package/lib/domain/types.d.ts +16 -3
  33. package/lib/routes/kanban-http.js +66 -6
  34. package/lib/services/config-provider.js +2 -0
  35. package/lib/services/im-bot-probe.d.ts +36 -0
  36. package/lib/services/im-bot-probe.js +128 -0
  37. package/lib/services/im-delivery.d.ts +69 -7
  38. package/lib/services/im-delivery.js +220 -34
  39. package/lib/tools/kanban-tools.js +38 -1
  40. package/lib/tools/main-session-tools.js +34 -9
  41. package/lib/tools/ocr-review-tools.js +13 -6
  42. package/package.json +6 -1
  43. package/personas/kanban-d/agent.cordis.yml +0 -1
  44. package/personas/kanban-dt/agent.cordis.yml +4 -3
  45. package/personas/kanban-p/agent.cordis.yml +16 -0
  46. package/personas/kanban-pt/agent.cordis.yml +19 -1
  47. package/personas/kanban-w/agent.cordis.yml +15 -0
  48. package/personas/persona-d.md +0 -1
  49. package/personas/persona-dt.md +2 -2
  50. package/personas/persona-p.md +4 -0
  51. package/personas/persona-pt.md +4 -1
  52. package/personas/persona-w.md +1 -0
  53. package/personas/swarm/agent.cordis.yml +11 -0
@@ -1,11 +1,17 @@
1
- /** 构造 OCR CLI 参数:preview/rule 走 delegate,managed 走 review 并恒带 JSON 输出。 */
1
+ /** 构造 OCR CLI 参数:preview/rule 走 delegate,managed 走 review。
2
+ * preview 默认输出 text,必须显式 --format json 才能拿到可解析输出;
3
+ * managed 恒带 --audience agent(agent 场景标准参数,抑制 progress 输出)。 */
2
4
  export function buildOcrArgs(sub, a) {
3
5
  if (sub === 'preview') {
4
- const args = ['delegate', 'preview'];
5
- if (a.from)
6
- args.push('--from', a.from);
7
- if (a.to)
8
- args.push('--to', a.to);
6
+ const args = ['delegate', 'preview', '--format', 'json'];
7
+ if (a.commit)
8
+ args.push('--commit', a.commit);
9
+ else if (a.from || a.to) {
10
+ if (a.from)
11
+ args.push('--from', a.from);
12
+ if (a.to)
13
+ args.push('--to', a.to);
14
+ }
9
15
  if (a.repo)
10
16
  args.push('--repo', a.repo);
11
17
  return args;
@@ -22,7 +28,9 @@ export function buildOcrArgs(sub, a) {
22
28
  if (a.to)
23
29
  args.push('--to', a.to);
24
30
  }
25
- args.push('--format', 'json');
31
+ args.push('--format', 'json', '--audience', 'agent');
32
+ if (a.background)
33
+ args.push('--background', a.background);
26
34
  return args;
27
35
  }
28
36
  /** 解析失败的兜底结果。 */
@@ -37,7 +45,7 @@ function pathOf(el) {
37
45
  function asArray(v) {
38
46
  return Array.isArray(v) ? v.filter(isRecord) : [];
39
47
  }
40
- /** 解析 delegate preview 的 JSON 输出,失败或结构异常时回退 unknown。 */
48
+ /** 解析 delegate preview 的 JSON 输出(官方字段 reviewable_files/excluded_files/exclude_reason),失败或结构异常时回退 unknown。 */
41
49
  export function parsePreviewJson(stdout) {
42
50
  let obj;
43
51
  try {
@@ -51,8 +59,8 @@ export function parsePreviewJson(stdout) {
51
59
  const mergeBase = obj.merge_base ?? obj.mergeBase;
52
60
  return {
53
61
  mode: String(obj.mode ?? 'unknown'),
54
- files: asArray(obj.files).map((el) => ({ path: pathOf(el), status: String(el.status ?? '') })),
55
- excluded: asArray(obj.excluded).map((el) => ({ path: pathOf(el), reason: String(el.reason ?? '') })),
62
+ files: asArray(obj.reviewable_files).map((el) => ({ path: pathOf(el), status: String(el.status ?? '') })),
63
+ excluded: asArray(obj.excluded_files).map((el) => ({ path: pathOf(el), reason: String(el.exclude_reason ?? '') })),
56
64
  mergeBase: mergeBase == null ? null : String(mergeBase),
57
65
  };
58
66
  }
@@ -68,7 +76,8 @@ export function parseManagedJson(stdout) {
68
76
  if (!isRecord(obj))
69
77
  return { status: 'unknown', comments: [] };
70
78
  const comments = asArray(obj.comments).map((el) => {
71
- const rawLine = el.line ?? el.startLine;
79
+ // 官方 schema 行号字段为 snake_case start_line,line/startLine 仅作容错别名
80
+ const rawLine = el.start_line ?? el.line ?? el.startLine;
72
81
  const line = Number(rawLine);
73
82
  const message = String(el.content ?? el.message ?? el.body ?? '');
74
83
  return {
@@ -1,5 +1,5 @@
1
1
  import type { Role, Task } from './types.js';
2
- export type KanbanAction = 'create-task' | 'create-chain' | 'claim' | 'complete' | 'block' | 'unblock' | 'comment' | 'heartbeat' | 'archive' | 'spec-approve' | 'spec-edit' | 'spec-attach' | 'wiki-write' | 'wiki-read' | 'prefetch' | 'audit-confirm' | 'update-title' | 'delete-chain' | 'create-rework-task';
2
+ export type KanbanAction = 'create-task' | 'create-chain' | 'claim' | 'complete' | 'block' | 'unblock' | 'comment' | 'heartbeat' | 'archive' | 'spec-approve' | 'spec-edit' | 'spec-attach' | 'wiki-write' | 'wiki-read' | 'prefetch' | 'audit-confirm' | 'update-title' | 'delete-chain' | 'create-rework-task' | 'reopen-chain' | 'waive-review';
3
3
  export type Actor = Role | 'human' | 'system';
4
4
  export declare function can(action: KanbanAction, actor: Actor, task: Task | null, opts?: {
5
5
  isOwnTask?: boolean;
@@ -50,5 +50,9 @@ export function can(action, actor, task, opts = {}) {
50
50
  case 'create-rework-task':
51
51
  // 评审失败返工卡创建:仅系统(V 建执行卡、system 建返工卡,防角色伪造返工链)
52
52
  return actor === 'system';
53
+ case 'reopen-chain':
54
+ case 'waive-review':
55
+ // 人工恢复/豁免:仅 human(人工为信任锚;角色与 system 一律拒,防角色越权恢复)
56
+ return actor === 'human';
53
57
  }
54
58
  }
@@ -13,7 +13,8 @@ export function applyTo(state, ev) {
13
13
  case 'chain/executing':
14
14
  case 'chain/completed':
15
15
  case 'chain/aborted':
16
- case 'chain/blocked': {
16
+ case 'chain/blocked':
17
+ case 'chain/reopened': {
17
18
  const c = state.chains.get(ev.chainId);
18
19
  if (!c)
19
20
  throw new Error('projection: unknown chain ' + ev.chainId);
@@ -128,12 +129,16 @@ export function applyTo(state, ev) {
128
129
  // 评审事件(交付质量链):非状态转换——按 payload.targetTaskId 更新被评审任务 reviewStatus
129
130
  case 'review/passed':
130
131
  case 'review/failed':
131
- case 'review/gave-up': {
132
+ case 'review/gave-up':
133
+ case 'review/waived': {
132
134
  const targetId = String(ev.payload['targetTaskId'] ?? '');
133
135
  const t = state.tasks.get(targetId);
134
136
  if (!t)
135
137
  throw new Error('projection: unknown review target ' + targetId);
136
- const status = ev.kind === 'review/passed' ? 'passed' : ev.kind === 'review/failed' ? 'failed' : 'gave-up';
138
+ const status = ev.kind === 'review/passed' ? 'passed'
139
+ : ev.kind === 'review/failed' ? 'failed'
140
+ : ev.kind === 'review/waived' ? 'waived'
141
+ : 'gave-up';
137
142
  next.tasks = new Map(state.tasks).set(targetId, { ...t, reviewStatus: status });
138
143
  break;
139
144
  }
@@ -20,6 +20,15 @@ export function validateReviewEvidence(role, handoff) {
20
20
  missing.push('review_evidence.verdict');
21
21
  if (!Array.isArray(ev.issues))
22
22
  missing.push('review_evidence.issues');
23
+ // 2026-09-15 口径锁死(P1-7,仅 PT):fail 必须至少一条未解决的 critical/high——
24
+ // persona 口径「fail = 存在 critical/high」机械落地,防 medium 冒充阻断(ch_1_mu235xfz R3 summary 实录)。
25
+ // 全为非阻断项时应输出 pass(issues 作为非阻塞建议留档)。DT 不适用:其实证失败(test/build/diff)
26
+ // 未必伴随 high issue,用 severity 门槛会误放行真问题。
27
+ if (role === 'pt' && ev.verdict === 'fail' && Array.isArray(ev.issues)) {
28
+ const blocking = ev.issues.some((i) => i.resolved !== true && (i.severity === 'critical' || i.severity === 'high'));
29
+ if (!blocking)
30
+ missing.push('review_evidence.issues (verdict=fail 需至少一条未解决的 critical/high;否则应判 pass)');
31
+ }
23
32
  if (role === 'pt') {
24
33
  // 计划结构字段:评审须给出被评审计划产物引用(如 artifacts_path/page 结构)——缺则视为证据不足
25
34
  const planRef = ev.reviewPage ?? (handoff.metadata ?? {})['artifacts_path'];
@@ -0,0 +1,10 @@
1
+ import type { BoardState, Task } from './types.js';
2
+ /** 评审目标推导(PT/DT 共用,2026-09-15 由 v-orchestrator 内联逻辑下沉):
3
+ * - currentTarget = 该评审卡实际评审的任务(复审卡经 reworkOfTaskId 指向返工卡;首评回退 parents[0] → 阶段源任务);
4
+ * - root = 沿 reworkOfTaskId 链到顶(原任务),reviewStatus 落点。
5
+ * 返回 null = 无法推导(调用方按原语义处理,如 gave-up)。
6
+ * 编排层与恢复 service 共用同一份推导,消除双份逻辑漂移。 */
7
+ export declare function resolveReviewTarget(state: BoardState, reviewTask: Task, chainId: string): {
8
+ currentTarget: Task;
9
+ root: Task;
10
+ } | null;
@@ -0,0 +1,24 @@
1
+ /** 评审目标推导(PT/DT 共用,2026-09-15 由 v-orchestrator 内联逻辑下沉):
2
+ * - currentTarget = 该评审卡实际评审的任务(复审卡经 reworkOfTaskId 指向返工卡;首评回退 parents[0] → 阶段源任务);
3
+ * - root = 沿 reworkOfTaskId 链到顶(原任务),reviewStatus 落点。
4
+ * 返回 null = 无法推导(调用方按原语义处理,如 gave-up)。
5
+ * 编排层与恢复 service 共用同一份推导,消除双份逻辑漂移。 */
6
+ export function resolveReviewTarget(state, reviewTask, chainId) {
7
+ const role = reviewTask.assignee === 'dt' ? 'dt' : 'pt';
8
+ const srcAssignee = role === 'pt' ? 'p' : 'd';
9
+ const srcMode = role === 'pt' ? 'openspec' : 'execute';
10
+ let currentTarget = reviewTask.reworkOfTaskId
11
+ ? state.tasks.get(reviewTask.reworkOfTaskId)
12
+ : reviewTask.parents[0]
13
+ ? state.tasks.get(reviewTask.parents[0])
14
+ : undefined;
15
+ if (!currentTarget) {
16
+ currentTarget = [...state.tasks.values()].find((t) => t.chainId === chainId && t.assignee === srcAssignee && t.mode === srcMode && t.status === 'done');
17
+ }
18
+ if (!currentTarget)
19
+ return null;
20
+ let root = currentTarget;
21
+ while (root.reworkOfTaskId && state.tasks.get(root.reworkOfTaskId))
22
+ root = state.tasks.get(root.reworkOfTaskId);
23
+ return { currentTarget, root };
24
+ }
@@ -11,7 +11,7 @@ const TASK_TRANSITIONS = {
11
11
  const CHAIN_TRANSITIONS = {
12
12
  planning: { 'chain/executing': 'executing' },
13
13
  executing: { 'chain/completed': 'completed', 'chain/aborted': 'aborted', 'chain/blocked': 'blocked' },
14
- blocked: {},
14
+ blocked: { 'chain/reopened': 'executing' }, // 人工恢复唯一出边(其余事件对 blocked 仍非法)
15
15
  completed: {},
16
16
  aborted: {},
17
17
  };
@@ -3,8 +3,8 @@ export type TaskMode = 'file' | 'external' | 'kb' | 'openspec' | 'mattpocock' |
3
3
  export type TaskStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'failed' | 'archived';
4
4
  export type ChainStatus = 'planning' | 'executing' | 'blocked' | 'completed' | 'aborted';
5
5
  export type SpecCardStatus = 'draft' | 'approved';
6
- /** 评审状态(交付质量链):not-required 普通卡 / pending 等待评审 / passed 通过 / failed 失败待返工 / gave-up 超限放弃。 */
7
- export type ReviewStatus = 'not-required' | 'pending' | 'passed' | 'failed' | 'gave-up';
6
+ /** 评审状态(交付质量链):not-required 普通卡 / pending 等待评审 / passed 通过 / failed 失败待返工 / gave-up 超限放弃 / waived 人工豁免。 */
7
+ export type ReviewStatus = 'not-required' | 'pending' | 'passed' | 'failed' | 'gave-up' | 'waived';
8
8
  /** 评审结论:pass=通过 / fail=不通过。 */
9
9
  export type ReviewVerdict = 'pass' | 'fail';
10
10
  /** 评审问题条目(PT/DT 交接 evidence.issues)。 */
@@ -14,6 +14,14 @@ export interface ReviewIssue {
14
14
  detail: string;
15
15
  location?: string;
16
16
  resolved: boolean;
17
+ /** 依据(上游声明引用或计划内部矛盾点)——评审方运行时扩展字段,注入返工时保留。 */
18
+ basis?: string;
19
+ /** 问题说明(违反什么)。 */
20
+ problem?: string;
21
+ /** 可执行修改建议(怎么改)——返工方最需要的字段。 */
22
+ fix?: string;
23
+ /** 对账标记:true=上一轮遗留 issue 的对账条目;false/缺省=本轮新问题。收敛闸据此判定旧账是否清零(漏标=false=视为新问题,方向安全)。 */
24
+ legacy?: boolean;
17
25
  }
18
26
  /** TDD 硬要求证据(评审时收集的测试相关元信息)。 */
19
27
  export interface TddEvidence {
@@ -27,6 +35,11 @@ export interface TddEvidence {
27
35
  export interface ReviewEvidence {
28
36
  verdict: ReviewVerdict;
29
37
  issues: ReviewIssue[];
38
+ /** 收敛闸降级留痕(2026-09-15):verdict 由 fail 降级为 pass 时记录原判定与原因,审计可查。 */
39
+ downgraded?: {
40
+ from: ReviewVerdict;
41
+ reason: string;
42
+ };
30
43
  tdd?: TddEvidence;
31
44
  test?: Record<string, unknown>;
32
45
  build?: Record<string, unknown>;
@@ -40,7 +53,7 @@ export interface ReviewEvidence {
40
53
  kbUrl: string;
41
54
  };
42
55
  }
43
- export type EventKind = 'chain/created' | 'chain/executing' | 'chain/completed' | 'chain/aborted' | 'chain/blocked' | 'chain/root-task-set' | 'chain/audit-warning' | 'chain/audit-confirmed' | 'chain/title-updated' | 'chain/im-delivery-failed' | 'spec-card/created' | 'spec-card/edited' | 'spec-card/approved' | 'task/created' | 'task/claimed' | 'task/heartbeat' | 'task/commented' | 'task/completed' | 'task/blocked' | 'task/unblocked' | 'task/archived' | 'task/gate-passed' | 'task/gate-failed' | 'task/failed' | 'task/renamed' | 'review/passed' | 'review/failed' | 'review/gave-up';
56
+ export type EventKind = 'chain/created' | 'chain/executing' | 'chain/completed' | 'chain/aborted' | 'chain/blocked' | 'chain/root-task-set' | 'chain/audit-warning' | 'chain/audit-confirmed' | 'chain/title-updated' | 'chain/im-delivery-failed' | 'spec-card/created' | 'spec-card/edited' | 'spec-card/approved' | 'task/created' | 'task/claimed' | 'task/heartbeat' | 'task/commented' | 'task/completed' | 'task/blocked' | 'task/unblocked' | 'task/archived' | 'task/gate-passed' | 'task/gate-failed' | 'task/failed' | 'task/renamed' | 'review/passed' | 'review/failed' | 'review/gave-up' | 'review/waived' | 'chain/reopened';
44
57
  export interface SpecCardSections {
45
58
  problem: string;
46
59
  solution: string;
@@ -1,9 +1,11 @@
1
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
1
2
  import { execFile } from 'node:child_process';
2
3
  import { writeFileSync } from 'node:fs';
3
4
  import { join } from 'node:path';
4
5
  import { buildLlmCatalog } from '../services/llm-catalog.js';
5
6
  import { INSTALL_GUIDANCE, OCR_PACKAGE, managedProviderReady, probeOcr, wireManagedProvider } from '../services/ocr-cli.js';
6
7
  import { serveKanbanEvents } from './kanban-sse.js';
8
+ import { resolveReviewTarget } from '../domain/review-target.js';
7
9
  function json(res, status, body) {
8
10
  res.statusCode = status;
9
11
  res.setHeader('content-type', 'application/json; charset=utf-8');
@@ -72,13 +74,17 @@ const OCR_WIRE_DEGRADED = '未能从 dsh 解析该提供方的接入信息(bas
72
74
  * 从宿主设置解析所选提供方的接入信息(baseUrl/协议/apiKey)。
73
75
  * 事实核查结论(实现期探查):llm 服务的公开 API 不暴露连接事实,但同进程可经
74
76
  * settings 服务的 describe() 读到模型适配器的 provider profile——形如
75
- * { providers: { <id>: { baseURL, api, apiKey | apiKeyEnv } } }(apiKeyEnv 指向进程环境变量名)。
77
+ * { providers: { <id>: { baseURL, api, apiKey | apiKeyEnv } } }(apiKeyEnv 指向凭据引用名)。
78
+ * 踩坑:apiKeyEnv 名下的 key 不一定在进程 env——Models 页写入的凭据存在
79
+ * credentials 服务的 managed store(ctx.credentials)里;解析顺序须先进程 env、
80
+ * 再 ctx.credentials.resolve(env → managed store → $DSH_HOME/.env,per-call 不缓存)。
76
81
  * 命中 profile 但字段不全时跳过该 descriptor 继续尝试下一个(多 descriptor 场景勿误降级),
77
82
  * 绝不回传半套配置;全部不合才返回 null 走降级。apiKey 只透传给 ocr config,不落日志。
78
83
  */
79
- function resolveDshProviderAccess(ctx, providerId) {
84
+ async function resolveDshProviderAccess(ctx, providerId) {
80
85
  try {
81
86
  const settings = ctx.get('settings');
87
+ const creds = ctx.get('credentials');
82
88
  const descriptors = settings?.describe?.() ?? [];
83
89
  for (const d of descriptors) {
84
90
  const providers = d.value?.providers;
@@ -89,8 +95,13 @@ function resolveDshProviderAccess(ctx, providerId) {
89
95
  const api = typeof profile.api === 'string' ? profile.api : '';
90
96
  const protocol = api.startsWith('openai') ? 'openai' : api.includes('anthropic') ? 'anthropic' : '';
91
97
  let apiKey = typeof profile.apiKey === 'string' ? profile.apiKey : '';
92
- if (!apiKey && typeof profile.apiKeyEnv === 'string')
98
+ if (!apiKey && typeof profile.apiKeyEnv === 'string') {
93
99
  apiKey = process.env[profile.apiKeyEnv] ?? '';
100
+ if (!apiKey && creds?.resolve) {
101
+ const r = await creds.resolve(credentialRef(profile.apiKeyEnv));
102
+ apiKey = r?.value ?? '';
103
+ }
104
+ }
94
105
  if (baseUrl && protocol && apiKey)
95
106
  return { baseUrl, protocol, apiKey };
96
107
  continue; // 字段不全:换下一个 descriptor,勿在此误降级
@@ -160,6 +171,25 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
160
171
  json(res, 200, { ok: true });
161
172
  return;
162
173
  }
174
+ // 人工恢复被 blocked 的链(2026-09-15 恢复能力,仅 human):链级 action,在 taskId 守卫前分流。
175
+ // 非 blocked 链 → 409(fail-closed);reason 必填(审计留痕)。
176
+ if (body.type === 'reopen-chain') {
177
+ const chainId = String(body.chainId ?? '').trim();
178
+ const reason = String(body.reason ?? '').trim();
179
+ if (!chainId || !reason) {
180
+ json(res, 400, { error: 'chainId and reason required' });
181
+ return;
182
+ }
183
+ try {
184
+ await provider.service.reopenChain(chainId, reason, 'human');
185
+ }
186
+ catch (err) {
187
+ json(res, 409, { error: String(err instanceof Error ? err.message : err) });
188
+ return;
189
+ }
190
+ json(res, 200, { ok: true });
191
+ return;
192
+ }
163
193
  // rename 是链级或任务级 action(chainId 或 taskId 二选一),在 taskId 守卫前分流
164
194
  if (body.type === 'rename') {
165
195
  const title = String(body.title ?? '').trim();
@@ -196,6 +226,34 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
196
226
  case 'unblock':
197
227
  await provider.service.unblockTask(t, 'human');
198
228
  break;
229
+ case 'waive-review': {
230
+ // 人工评审豁免(2026-09-15 恢复能力,仅 human):目标根卡由 resolveReviewTarget 推导
231
+ //(评审卡→返工链→root),前端无需重复实现;非 failed/gave-up 目标 → 409(防误豁免)。
232
+ const reason = String(body.reason ?? '').trim();
233
+ if (!reason) {
234
+ json(res, 400, { error: 'reason required' });
235
+ return;
236
+ }
237
+ const state = await provider.service.snapshot();
238
+ const reviewTask = state.tasks.get(t);
239
+ if (!reviewTask) {
240
+ json(res, 404, { error: 'unknown task: ' + t });
241
+ return;
242
+ }
243
+ const target = resolveReviewTarget(state, reviewTask, reviewTask.chainId);
244
+ if (!target) {
245
+ json(res, 409, { error: 'review target not resolvable for ' + t });
246
+ return;
247
+ }
248
+ try {
249
+ await provider.service.waiveReview(t, target.root.id, reason, 'human');
250
+ }
251
+ catch (err) {
252
+ json(res, 409, { error: String(err instanceof Error ? err.message : err) });
253
+ return;
254
+ }
255
+ break;
256
+ }
199
257
  case 'retry': {
200
258
  // retry 走 runner(failed→claim→spawn/resume),而非只 claim 造成 running 悬挂
201
259
  const state = await provider.service.snapshot();
@@ -250,8 +308,9 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
250
308
  if (req.method === 'PUT' && req.url?.startsWith('/kanban/config')) {
251
309
  const raw = JSON.parse((await readBody(req)) || '{}');
252
310
  // 归一化缺省字段,保证缺字段走 400 校验失败而非 500。
253
- // reviewEngine 缺省时回退当前 effective(再兜底默认):旧客户端 bundle 不带该字段时保存不清空既有评审引擎配置。
254
- const fb = configProvider.getEffective().reviewEngine;
311
+ // reviewEngine/imDelivery 缺省时回退当前 effective(再兜底默认):旧客户端 bundle 不带这些字段时保存不清空既有配置。
312
+ const cur = configProvider.getEffective();
313
+ const fb = cur.reviewEngine;
255
314
  const snapshot = {
256
315
  wikiVault: { baseUrl: raw.wikiVault?.baseUrl ?? '', pagePrefix: raw.wikiVault?.pagePrefix ?? '' },
257
316
  roles: { models: raw.roles?.models ?? {} },
@@ -262,6 +321,7 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
262
321
  model: raw.reviewEngine?.managed?.model ?? fb?.managed?.model ?? '',
263
322
  },
264
323
  },
324
+ imDelivery: { fallbackBotId: raw.imDelivery?.fallbackBotId ?? cur.imDelivery?.fallbackBotId ?? '' },
265
325
  };
266
326
  const r = configProvider.applyOverride(snapshot);
267
327
  if (!r.ok) {
@@ -330,7 +390,7 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
330
390
  json(res, 200, { ok: false, log: INSTALL_GUIDANCE });
331
391
  return;
332
392
  }
333
- const access = resolveDshProviderAccess(ctx, providerId);
393
+ const access = await resolveDshProviderAccess(ctx, providerId);
334
394
  if (!access) {
335
395
  json(res, 200, { ok: false, log: OCR_WIRE_DEGRADED });
336
396
  return;
@@ -87,6 +87,8 @@ export class ConfigProvider extends Service {
87
87
  if (prev.reviewEngine?.managed?.[f] !== next.reviewEngine?.managed?.[f])
88
88
  keys.add('reviewEngine.managed.' + f);
89
89
  }
90
+ if ((prev.imDelivery?.fallbackBotId ?? '') !== (next.imDelivery?.fallbackBotId ?? ''))
91
+ keys.add('imDelivery.fallbackBotId');
90
92
  return [...keys];
91
93
  }
92
94
  }
@@ -0,0 +1,36 @@
1
+ /** 一个企微机器人的探针视图(字段取自 connection.status 的 bots[],缺失值归一为空/离线)。 */
2
+ export interface BotCandidate {
3
+ botId: string;
4
+ /** 该机器人的默认 dsh 模式(dsh-im 设置页的 agentPreset);未设置时为空串。 */
5
+ agentPreset: string;
6
+ connected: boolean;
7
+ /** 展示名(appIdMasked,脱敏 id),仅用于交互提示。 */
8
+ label: string;
9
+ }
10
+ export type ProbeResult = {
11
+ ok: true;
12
+ bots: BotCandidate[];
13
+ } | {
14
+ ok: false;
15
+ error: string;
16
+ };
17
+ /** 探针依赖(全部注入,便于测试与惰性读取)。 */
18
+ export interface ProbeDeps {
19
+ /** 监听端口;未就绪(webServer 未挂载/未 listen)返回 null。 */
20
+ port: () => number | null;
21
+ /** 进程 token 换 cookie 的入口(ctx.connection.authenticatedUrl);服务缺失返回 null。 */
22
+ authenticatedUrl: (base: string) => string | null;
23
+ fetchImpl: typeof fetch;
24
+ /** 时钟(缓存 TTL 测试用)。 */
25
+ now?: () => number;
26
+ }
27
+ /** connection.status 响应信封 → 候选数组。形状不符返回 null(上游改形状即降级,不猜字段)。 */
28
+ export declare function parseBotCandidates(raw: unknown): BotCandidate[] | null;
29
+ /** Set-Cookie 头 → `name=value` 片段(剥属性)。无 dsh-auth 前缀项返回 null。 */
30
+ export declare function pickAuthCookie(lines: readonly string[]): string | null;
31
+ /** 探针工厂:同一实例内结果短期缓存(连发 /sms 不重复握手),失败同样缓存以免每次重试打满超时。 */
32
+ export declare function createBotProbe(deps: ProbeDeps): () => Promise<ProbeResult>;
33
+ /** 从 cordis ctx 装配探针依赖(惰性:每次调用才读端口与 connection,避免 apply 期缓存未就绪的值)。 */
34
+ export declare function probeDepsFromCtx(ctx: {
35
+ get(name: string): unknown;
36
+ }): ProbeDeps;
@@ -0,0 +1,128 @@
1
+ // src/services/im-bot-probe.ts
2
+ // 多机器人消歧探针:读 dsh-im 侧每个企微机器人的 agentPreset(默认 dsh 模式)与连接态。
3
+ //
4
+ // 数据源是 dsh-im 的管理 RPC `/wecom/connection.status`(WECOM_ENDPOINTS.status)。该通道只对浏览器开放
5
+ // (loopback authority + dsh-auth cookie),宿主插件侧没有本地 invoke 面(HostConnectionRpc 只有
6
+ // handle/intercept),故这里走「进程 token 换 cookie 再自调用」的公开路径:
7
+ // ctx.connection.authenticatedUrl(base) → GET `/` 拿 Set-Cookie → 带 cookie POST 该 RPC。
8
+ // launchToken 只存在于进程内存(WeakMap),不落盘;cookie 是自包含 HMAC,重铸成本极低。
9
+ //
10
+ // 红线(本模块唯一允许接触 cookie 的地方):
11
+ // 1. cookie / token / URL / 请求头绝不进任何日志、错误消息或链事件(dispatcher.log 与 events.jsonl 都长期留盘);
12
+ // 2. 探针失败一律降级(返回 {ok:false}),绝不让投递整体失败——调用方退化到默认机器人/交互选择;
13
+ // 3. 上游改 RPC 形状 → parseBotCandidates 返回 null → fail-closed 同样走降级,不静默投错对象。
14
+ const PROBE_TIMEOUT_MS = 1_500;
15
+ const CACHE_TTL_MS = 30_000;
16
+ const STATUS_PATH = '/wecom/connection.status';
17
+ const COOKIE_PREFIX = 'dsh-auth-';
18
+ /** connection.status 响应信封 → 候选数组。形状不符返回 null(上游改形状即降级,不猜字段)。 */
19
+ export function parseBotCandidates(raw) {
20
+ const bots = raw?.result?.value?.bots;
21
+ if (!Array.isArray(bots))
22
+ return null;
23
+ const out = [];
24
+ for (const entry of bots) {
25
+ const botId = entry?.botId;
26
+ if (typeof botId !== 'string' || !botId)
27
+ return null;
28
+ const preset = entry.agentPreset;
29
+ const label = entry?.bot?.appIdMasked;
30
+ out.push({
31
+ botId,
32
+ agentPreset: typeof preset === 'string' ? preset : '',
33
+ connected: entry.connected === true,
34
+ label: typeof label === 'string' ? label : '',
35
+ });
36
+ }
37
+ return out;
38
+ }
39
+ /** Set-Cookie 头 → `name=value` 片段(剥属性)。无 dsh-auth 前缀项返回 null。 */
40
+ export function pickAuthCookie(lines) {
41
+ for (const line of lines) {
42
+ const pair = line.split(';')[0]?.trim() ?? '';
43
+ if (pair.startsWith(COOKIE_PREFIX))
44
+ return pair;
45
+ }
46
+ return null;
47
+ }
48
+ function setCookieLines(headers) {
49
+ const multi = headers.getSetCookie?.();
50
+ if (Array.isArray(multi) && multi.length > 0)
51
+ return multi;
52
+ const single = headers.get('set-cookie');
53
+ return single ? [single] : [];
54
+ }
55
+ /** 错误归一:只取错误码/名,绝不带 URL、请求头或 cookie(日志红线)。 */
56
+ function errorCode(err) {
57
+ const code = err?.code;
58
+ if (typeof code === 'string' && code)
59
+ return code;
60
+ const name = err?.name;
61
+ return typeof name === 'string' && name ? name : 'probe-failed';
62
+ }
63
+ async function runProbe(deps) {
64
+ const port = deps.port();
65
+ if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0) {
66
+ return { ok: false, error: 'probe-unavailable: web-server-port' };
67
+ }
68
+ const base = `http://127.0.0.1:${port}`;
69
+ const exchangeUrl = deps.authenticatedUrl(`${base}/`);
70
+ if (!exchangeUrl)
71
+ return { ok: false, error: 'probe-unavailable: connection' };
72
+ const controller = new AbortController();
73
+ const timer = setTimeout(() => { controller.abort(); }, PROBE_TIMEOUT_MS);
74
+ try {
75
+ // 进程 token → cookie:GET 应用根,白拿 Set-Cookie(不跟随重定向,避免多一次无用请求)。
76
+ const index = await deps.fetchImpl(exchangeUrl, { redirect: 'manual', signal: controller.signal });
77
+ const cookie = pickAuthCookie(setCookieLines(index.headers));
78
+ if (!cookie)
79
+ return { ok: false, error: `probe-unauthorized: ${index.status}` };
80
+ const res = await deps.fetchImpl(base + STATUS_PATH, {
81
+ method: 'POST',
82
+ redirect: 'manual',
83
+ signal: controller.signal,
84
+ headers: { 'content-type': 'application/json', origin: base, cookie },
85
+ body: JSON.stringify({ type: 'client-request', rpcId: 'dsh-swarm-bot-probe', method: 'connection.status', payload: {} }),
86
+ });
87
+ if (!res.ok)
88
+ return { ok: false, error: `probe-http: ${res.status}` };
89
+ const bots = parseBotCandidates(await res.json());
90
+ if (bots === null)
91
+ return { ok: false, error: 'probe-shape-mismatch' };
92
+ return { ok: true, bots };
93
+ }
94
+ catch (err) {
95
+ return { ok: false, error: `probe-failed: ${errorCode(err)}` };
96
+ }
97
+ finally {
98
+ clearTimeout(timer);
99
+ }
100
+ }
101
+ /** 探针工厂:同一实例内结果短期缓存(连发 /sms 不重复握手),失败同样缓存以免每次重试打满超时。 */
102
+ export function createBotProbe(deps) {
103
+ const now = deps.now ?? Date.now;
104
+ let cached = null;
105
+ return async () => {
106
+ if (cached && now() - cached.at < CACHE_TTL_MS)
107
+ return cached.result;
108
+ const result = await runProbe(deps);
109
+ cached = { at: now(), result };
110
+ return result;
111
+ };
112
+ }
113
+ /** 从 cordis ctx 装配探针依赖(惰性:每次调用才读端口与 connection,避免 apply 期缓存未就绪的值)。 */
114
+ export function probeDepsFromCtx(ctx) {
115
+ return {
116
+ port: () => {
117
+ const ws = ctx.get('webServer');
118
+ return typeof ws?.port === 'number' ? ws.port : null;
119
+ },
120
+ authenticatedUrl: (base) => {
121
+ const conn = ctx.get('connection');
122
+ if (typeof conn?.authenticatedUrl !== 'function')
123
+ return null;
124
+ return conn.authenticatedUrl(base);
125
+ },
126
+ fetchImpl: ((...args) => fetch(...args)),
127
+ };
128
+ }