@joekytc/dsh-swarm 0.3.2 → 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.
- package/README.md +14 -7
- package/README.zh-CN.md +10 -5
- package/client/KanbanBoard.tsx +3 -1
- package/client/TaskDrawer.tsx +74 -2
- package/client/WorkflowRail.tsx +84 -1
- package/client/config-store.ts +3 -1
- package/client/kanban.css +28 -0
- package/client/timeline-model.ts +4 -0
- package/client/workflow-model.ts +20 -11
- package/lib/client.js +1100 -22
- package/lib/config.d.ts +5 -1
- package/lib/config.js +2 -1
- package/lib/dispatcher/agent-runner.js +25 -9
- package/lib/dispatcher/event-waker.js +4 -1
- package/lib/dispatcher/v-orchestrator.d.ts +2 -0
- package/lib/dispatcher/v-orchestrator.js +58 -9
- package/lib/domain/config-override.d.ts +6 -0
- package/lib/domain/config-override.js +16 -1
- package/lib/domain/im-message.d.ts +21 -2
- package/lib/domain/im-message.js +84 -4
- package/lib/domain/kanban-service.d.ts +8 -1
- package/lib/domain/kanban-service.js +67 -1
- package/lib/domain/permissions.d.ts +1 -1
- package/lib/domain/permissions.js +4 -0
- package/lib/domain/projection.js +8 -3
- package/lib/domain/review-evidence.js +9 -0
- package/lib/domain/review-target.d.ts +10 -0
- package/lib/domain/review-target.js +24 -0
- package/lib/domain/state-machine.js +1 -1
- package/lib/domain/types.d.ts +16 -3
- package/lib/routes/kanban-http.js +52 -2
- package/lib/services/config-provider.js +2 -0
- package/lib/services/im-bot-probe.d.ts +36 -0
- package/lib/services/im-bot-probe.js +128 -0
- package/lib/services/im-delivery.d.ts +69 -7
- package/lib/services/im-delivery.js +220 -34
- package/lib/tools/kanban-tools.js +38 -1
- package/lib/tools/main-session-tools.js +34 -9
- package/package.json +1 -1
- package/personas/kanban-p/agent.cordis.yml +16 -0
- package/personas/kanban-pt/agent.cordis.yml +19 -1
- package/personas/kanban-w/agent.cordis.yml +15 -0
- package/personas/persona-p.md +4 -0
- package/personas/persona-pt.md +4 -1
- package/personas/persona-w.md +1 -0
- package/personas/swarm/agent.cordis.yml +11 -0
package/lib/domain/projection.js
CHANGED
|
@@ -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'
|
|
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
|
};
|
package/lib/domain/types.d.ts
CHANGED
|
@@ -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;
|
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { buildLlmCatalog } from '../services/llm-catalog.js';
|
|
6
6
|
import { INSTALL_GUIDANCE, OCR_PACKAGE, managedProviderReady, probeOcr, wireManagedProvider } from '../services/ocr-cli.js';
|
|
7
7
|
import { serveKanbanEvents } from './kanban-sse.js';
|
|
8
|
+
import { resolveReviewTarget } from '../domain/review-target.js';
|
|
8
9
|
function json(res, status, body) {
|
|
9
10
|
res.statusCode = status;
|
|
10
11
|
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
@@ -170,6 +171,25 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
|
|
|
170
171
|
json(res, 200, { ok: true });
|
|
171
172
|
return;
|
|
172
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
|
+
}
|
|
173
193
|
// rename 是链级或任务级 action(chainId 或 taskId 二选一),在 taskId 守卫前分流
|
|
174
194
|
if (body.type === 'rename') {
|
|
175
195
|
const title = String(body.title ?? '').trim();
|
|
@@ -206,6 +226,34 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
|
|
|
206
226
|
case 'unblock':
|
|
207
227
|
await provider.service.unblockTask(t, 'human');
|
|
208
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
|
+
}
|
|
209
257
|
case 'retry': {
|
|
210
258
|
// retry 走 runner(failed→claim→spawn/resume),而非只 claim 造成 running 悬挂
|
|
211
259
|
const state = await provider.service.snapshot();
|
|
@@ -260,8 +308,9 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
|
|
|
260
308
|
if (req.method === 'PUT' && req.url?.startsWith('/kanban/config')) {
|
|
261
309
|
const raw = JSON.parse((await readBody(req)) || '{}');
|
|
262
310
|
// 归一化缺省字段,保证缺字段走 400 校验失败而非 500。
|
|
263
|
-
// reviewEngine 缺省时回退当前 effective(再兜底默认):旧客户端 bundle
|
|
264
|
-
const
|
|
311
|
+
// reviewEngine/imDelivery 缺省时回退当前 effective(再兜底默认):旧客户端 bundle 不带这些字段时保存不清空既有配置。
|
|
312
|
+
const cur = configProvider.getEffective();
|
|
313
|
+
const fb = cur.reviewEngine;
|
|
265
314
|
const snapshot = {
|
|
266
315
|
wikiVault: { baseUrl: raw.wikiVault?.baseUrl ?? '', pagePrefix: raw.wikiVault?.pagePrefix ?? '' },
|
|
267
316
|
roles: { models: raw.roles?.models ?? {} },
|
|
@@ -272,6 +321,7 @@ export function registerKanbanHttp(ctx, provider, configProvider, llm, config, o
|
|
|
272
321
|
model: raw.reviewEngine?.managed?.model ?? fb?.managed?.model ?? '',
|
|
273
322
|
},
|
|
274
323
|
},
|
|
324
|
+
imDelivery: { fallbackBotId: raw.imDelivery?.fallbackBotId ?? cur.imDelivery?.fallbackBotId ?? '' },
|
|
275
325
|
};
|
|
276
326
|
const r = configProvider.applyOverride(snapshot);
|
|
277
327
|
if (!r.ok) {
|
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis';
|
|
|
2
2
|
import type { KanbanService } from '../domain/kanban-service.js';
|
|
3
3
|
import type { BoardState } from '../domain/types.js';
|
|
4
4
|
import type { ConfigProvider } from './config-provider.js';
|
|
5
|
+
import { type ProbeResult } from './im-bot-probe.js';
|
|
5
6
|
/** dsh-im 宿主服务结构接口(形状以 @xmanrui/dsh-im PROACTIVE_DELIVERY.md 为准,运行时守卫校验)。
|
|
6
7
|
* listTargets 证据(2026-09-07):PROACTIVE_DELIVERY.md:176 `const targets = await ctx.dshIm.listTargets(botId);
|
|
7
8
|
* // [{ targetId, name?, kind, route }, ...]`;宿主 lib/index.js 实现为
|
|
@@ -33,17 +34,73 @@ export interface ImDeliveryOptions {
|
|
|
33
34
|
manual?: boolean;
|
|
34
35
|
/** 投递目标类型(0.3.x 自由投递):group=群聊;user=私聊(仅且只有一个已保存目标)。缺省 group。 */
|
|
35
36
|
targetKind?: 'group' | 'user';
|
|
37
|
+
/** 手动投递会话的 preset(工具从 session header 注入):多机器人时用于匹配机器人默认模式。
|
|
38
|
+
* 缺省=无 preset(auto 路径无会话上下文),跳过模式匹配直接走默认/交互。 */
|
|
39
|
+
sessionPreset?: string;
|
|
40
|
+
/** 手动投递会话的 live agent(工具从 exec.agent 注入):userQuestions 的 GUI answerer 挂在
|
|
41
|
+
* Agent-scoped waterfall 上,不传 agent 的询问落在根 ctx 无人应答(NO_PROVIDER)。 */
|
|
42
|
+
agent?: unknown;
|
|
43
|
+
/** 测试注入:覆盖机器人决策链的探针/交互/写默认实现(生产由 ctx 与 configProvider 装配)。 */
|
|
44
|
+
botChoice?: Partial<BotChoiceDeps>;
|
|
36
45
|
}
|
|
37
46
|
/** dsh-im 未安装的可识别错误前缀(0.3.1:缺插件属环境问题不重试,直接友好提醒安装)。 */
|
|
38
47
|
export declare const DSH_IM_MISSING_PREFIX = "dsh-im-not-installed";
|
|
39
48
|
export declare const DSH_IM_MISSING_GUIDANCE = "\u672A\u68C0\u6D4B\u5230 dsh-im \u63D2\u4EF6\uFF0C\u65E0\u6CD5\u6295\u9012\u4F01\u5FAE\u6D88\u606F\u3002\u8BF7\u5148\u5B89\u88C5\u5E76\u542F\u7528 @xmanrui/dsh-im \u63D2\u4EF6\uFF08\u5B89\u88C5\u540E\u91CD\u542F dsh \u751F\u6548\uFF09\uFF0C\u518D\u91CD\u8BD5\u6295\u9012\u3002";
|
|
40
49
|
export declare function isDshImLike(svc: unknown): svc is DshImLike;
|
|
41
|
-
/**
|
|
42
|
-
|
|
50
|
+
/** 机器人来源(供调用方在 guidance 里如实转述,勿让模型猜)。 */
|
|
51
|
+
export type BotVia = 'explicit' | 'single' | 'preset' | 'fallback' | 'interactive';
|
|
52
|
+
export interface BotAskOption {
|
|
53
|
+
botId: string;
|
|
54
|
+
label: string;
|
|
55
|
+
}
|
|
56
|
+
export interface BotChoiceDeps {
|
|
57
|
+
/** 当前会话 preset(手动投递由工具从 session header 注入);null/空 = 无法模式匹配(auto 路径)。 */
|
|
58
|
+
sessionPreset: string | null;
|
|
59
|
+
/** 多机器人时读 bot↔preset/连接态;失败=降级(不阻断投递,退化到默认/交互)。 */
|
|
60
|
+
probe: () => Promise<ProbeResult>;
|
|
61
|
+
/** 交互选择;无询问通道返回 null,用户取消/超时也返回 null(fail-closed 不投)。 */
|
|
62
|
+
ask: ((options: BotAskOption[]) => Promise<{
|
|
63
|
+
botId: string;
|
|
64
|
+
setDefault: boolean;
|
|
65
|
+
} | null>) | null;
|
|
66
|
+
/** 写默认机器人;写失败返回 false(本次投递照常,仅默认未落库)。 */
|
|
67
|
+
setFallback: ((botId: string) => Promise<boolean>) | null;
|
|
68
|
+
log: (m: string) => void;
|
|
69
|
+
}
|
|
70
|
+
export interface BotChoice {
|
|
71
|
+
botId: string;
|
|
72
|
+
via: BotVia;
|
|
73
|
+
fallbackSaved?: boolean;
|
|
74
|
+
}
|
|
75
|
+
/** 机器人决策链:显式配置 → 唯一机器人 → 会话 preset 命中 → 默认机器人 → 交互选择。
|
|
76
|
+
* 每环都 fail-closed(宁可不投也不投错对象);探针不可用只失去自动匹配能力,不阻断投递。 */
|
|
77
|
+
export declare function chooseBot(im: DshImLike, cfg: {
|
|
78
|
+
botId: string;
|
|
79
|
+
fallbackBotId?: string;
|
|
80
|
+
}, deps: BotChoiceDeps): Promise<({
|
|
81
|
+
ok: true;
|
|
82
|
+
} & BotChoice) | {
|
|
83
|
+
ok: false;
|
|
84
|
+
error: string;
|
|
85
|
+
}>;
|
|
86
|
+
/** targetId 解析:配置显式指定优先;留空自动发现该机器人下唯一已保存目标(按 kind)。
|
|
87
|
+
* 群/私聊各自仅且只有一个——发现异常返回 error(调用方留痕不投,fail-closed)。
|
|
88
|
+
* 机器人非显式指定(preset/默认/交互选出)而配置又填死了 targetId 时先校验归属:
|
|
89
|
+
* targetId 是 per-bot 作用域,填错机器人只会落成 dsh-im 的 unknown-target,这里提前给可行动报错。 */
|
|
90
|
+
export declare function resolveTargetId(im: DshImLike, botId: string, botIdExplicit: boolean, cfg: {
|
|
91
|
+
targetId: string;
|
|
92
|
+
dmTargetId?: string;
|
|
93
|
+
}, kind: 'group' | 'user'): Promise<{
|
|
94
|
+
targetId: string;
|
|
95
|
+
} | {
|
|
96
|
+
error: string;
|
|
97
|
+
}>;
|
|
98
|
+
/** botId+targetId 一步解析(无交互/无探针的简化入口,保留给单机器人场景与单测)。 */
|
|
43
99
|
export declare function resolveTarget(im: DshImLike, cfg: {
|
|
44
100
|
botId: string;
|
|
45
101
|
targetId: string;
|
|
46
102
|
dmTargetId?: string;
|
|
103
|
+
fallbackBotId?: string;
|
|
47
104
|
}, kind: 'group' | 'user'): Promise<{
|
|
48
105
|
botId: string;
|
|
49
106
|
targetId: string;
|
|
@@ -57,17 +114,20 @@ export declare function sendWithRetry(im: DshImLike, botId: string, targetId: st
|
|
|
57
114
|
ok: false;
|
|
58
115
|
error: string;
|
|
59
116
|
}>;
|
|
60
|
-
|
|
61
|
-
* auto 路径(wireImDelivery)失败额外写 chain/im-delivery-failed 链事件;
|
|
62
|
-
* manual 路径(/sms,opts.manual)仅 dispatcher.log 留痕,错误同步返回给调用方。 */
|
|
63
|
-
export declare function createSender(ctx: Context, kanban: KanbanService, configProvider: ConfigProvider, opts?: ImDeliveryOptions): (chainId: string, text: string) => Promise<{
|
|
117
|
+
export type SendResult = {
|
|
64
118
|
ok: true;
|
|
65
119
|
botId: string;
|
|
66
120
|
targetId: string;
|
|
121
|
+
via: BotVia;
|
|
122
|
+
fallbackSaved?: boolean;
|
|
67
123
|
} | {
|
|
68
124
|
ok: false;
|
|
69
125
|
error: string;
|
|
70
|
-
}
|
|
126
|
+
};
|
|
127
|
+
/** 发送器工厂:resolveDshIm → chooseBot(fail-closed)→ resolveTargetId → sendWithRetry → 留痕。
|
|
128
|
+
* auto 路径(wireImDelivery)失败额外写 chain/im-delivery-failed 链事件;
|
|
129
|
+
* manual 路径(/sms,opts.manual)仅 dispatcher.log 留痕,错误同步返回给调用方。 */
|
|
130
|
+
export declare function createSender(ctx: Context, kanban: KanbanService, configProvider: ConfigProvider, opts?: ImDeliveryOptions): (chainId: string, text: string) => Promise<SendResult>;
|
|
71
131
|
export type ReportVariant = 'completion' | 'blocked';
|
|
72
132
|
/** /sms 链解析(纯函数)。空 query:completion=最近满足 W3 完成判据的链(最后完成事件 at 最大);
|
|
73
133
|
* blocked=最近阻塞的链(chain/blocked 事件 at 最大)。显式 query:全量链精确/后缀解析后再验判据。 */
|
|
@@ -99,6 +159,8 @@ export declare function sendChainReport(ctx: Context, kanban: KanbanService, con
|
|
|
99
159
|
chainId: string;
|
|
100
160
|
botId: string;
|
|
101
161
|
targetId: string;
|
|
162
|
+
via: BotVia;
|
|
163
|
+
fallbackSaved?: boolean;
|
|
102
164
|
} | {
|
|
103
165
|
ok: false;
|
|
104
166
|
error: string;
|