@ljwei-stak/dsh-model-router 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh-plugin/client.js +3092 -0
- package/.dsh-plugin/index.mjs +1651 -0
- package/.dsh-plugin/official-tools-remote-service.mjs +104 -0
- package/.dsh-plugin/shared/harness-plan.mjs +179 -0
- package/.dsh-plugin/shared/livebench.mjs +264 -0
- package/.dsh-plugin/shared/model-profiles.mjs +142 -0
- package/.dsh-plugin/shared/official-team-runtime.mjs +411 -0
- package/.dsh-plugin/shared/official-tool-executor.mjs +801 -0
- package/.dsh-plugin/shared/official-tool-registry.mjs +138 -0
- package/.dsh-plugin/shared/official-tools-remote.mjs +173 -0
- package/.dsh-plugin/shared/official-tools-runtime.mjs +642 -0
- package/.dsh-plugin/shared/router-state.mjs +207 -0
- package/.dsh-plugin/shared/router.mjs +1134 -0
- package/.dsh-plugin/shared/routing-presets.mjs +49 -0
- package/.dsh-plugin/shared/run-ledger.mjs +348 -0
- package/.dsh-plugin/shared/security-boundaries.mjs +54 -0
- package/.dsh-plugin/shared/subscription-billing.mjs +340 -0
- package/.dsh-plugin/shared/task-executors.mjs +1154 -0
- package/.dsh-plugin/shared/tool-health.mjs +311 -0
- package/.dsh-plugin/shared/vendor-mimo-grok-adapter.mjs +308 -0
- package/.dsh-plugin/shared/vendor-minimax-adapter.mjs +247 -0
- package/.dsh-plugin/shared/zcode-bundle.mjs +208 -0
- package/.dsh-plugin/shared/zcode-installer.mjs +247 -0
- package/CHANGELOG.md +36 -0
- package/INSTALLATION_GUIDE.zh.md +134 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +53 -0
- package/README.i18n.yaml +3 -0
- package/README.md +424 -0
- package/README.zh.md +413 -0
- package/cordis.patch.yml +12 -0
- package/docs/assets/candidate-pruning.svg +80 -0
- package/docs/assets/desktop-official-tools-0.9.0.png +0 -0
- package/docs/assets/router-only-0.12.0.png +0 -0
- package/docs/assets/routing-workflow.svg +96 -0
- package/docs/assets/workbench-usage.svg +119 -0
- package/package.json +161 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional, user supplied information for exact routes in the Harness model
|
|
3
|
+
* directory. Harness does not publish prices or comparative quality scores.
|
|
4
|
+
* This file never accepts credentials, endpoints, packages or shell commands.
|
|
5
|
+
* `billing`/`subscription`/`apiRoute` only name catalog routes; plan keys and
|
|
6
|
+
* base URLs stay in the Harness provider settings.
|
|
7
|
+
*/
|
|
8
|
+
import { toolForProvider } from './official-tool-registry.mjs'
|
|
9
|
+
const MAX_TEXT = 32_000
|
|
10
|
+
const MAX_PROFILES = 200
|
|
11
|
+
const id = value => typeof value === 'string' ? value.trim() : ''
|
|
12
|
+
const BILLING_VALUES = new Set(['subscription-first', 'api-only', 'subscription-only'])
|
|
13
|
+
const SUBSCRIPTION_VALUES = new Set(['plan-key', 'cli-login', 'none'])
|
|
14
|
+
|
|
15
|
+
function nonnegative(value, label) {
|
|
16
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1_000_000) {
|
|
17
|
+
throw new Error(`${label} 必须是 0 到 1000000 之间的有限数字`)
|
|
18
|
+
}
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeProfile(entry, index) {
|
|
23
|
+
const label = `第 ${index + 1} 个模型`
|
|
24
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new Error(`${label} 必须是对象`)
|
|
25
|
+
const allowed = new Set(['provider', 'model', 'quality', 'pricing', 'specialties', 'cliModel', 'execution', 'billing', 'subscription', 'apiRoute'])
|
|
26
|
+
const unknown = Object.keys(entry).find(key => !allowed.has(key))
|
|
27
|
+
if (unknown) throw new Error(`${label} 含不支持的字段 ${unknown};不要在这里填写密钥或命令`)
|
|
28
|
+
const provider = id(entry.provider)
|
|
29
|
+
const model = id(entry.model)
|
|
30
|
+
if (!provider || !model || provider.length > 160 || model.length > 240) {
|
|
31
|
+
throw new Error(`${label} 需要模型目录中的准确 provider 和 model`)
|
|
32
|
+
}
|
|
33
|
+
const profile = { provider, model }
|
|
34
|
+
if (entry.quality !== undefined) {
|
|
35
|
+
const value = nonnegative(entry.quality, `${label} 的 quality`)
|
|
36
|
+
if (value > 100) throw new Error(`${label} 的 quality 应在 0 到 100 之间`)
|
|
37
|
+
profile.quality = value / 100
|
|
38
|
+
}
|
|
39
|
+
if (entry.pricing !== undefined) {
|
|
40
|
+
const prices = entry.pricing
|
|
41
|
+
if (!prices || typeof prices !== 'object' || Array.isArray(prices)) throw new Error(`${label} 的 pricing 必须是对象`)
|
|
42
|
+
const priceFields = new Set(['input', 'output', 'cacheRead', 'cacheWrite', 'currency'])
|
|
43
|
+
const unknownPrice = Object.keys(prices).find(key => !priceFields.has(key))
|
|
44
|
+
if (unknownPrice) throw new Error(`${label} 的 pricing 含不支持的字段 ${unknownPrice}`)
|
|
45
|
+
if (prices.currency !== undefined && prices.currency !== 'USD') throw new Error(`${label} 的价格币种目前仅支持 USD`)
|
|
46
|
+
profile.pricing = {
|
|
47
|
+
input: nonnegative(prices.input, `${label} 的输入单价`),
|
|
48
|
+
output: nonnegative(prices.output, `${label} 的输出单价`),
|
|
49
|
+
currency: 'USD',
|
|
50
|
+
}
|
|
51
|
+
if (prices.cacheRead !== undefined) profile.pricing.cacheRead = nonnegative(prices.cacheRead, `${label} 的缓存读取单价`)
|
|
52
|
+
if (prices.cacheWrite !== undefined) profile.pricing.cacheWrite = nonnegative(prices.cacheWrite, `${label} 的缓存写入单价`)
|
|
53
|
+
}
|
|
54
|
+
if (entry.specialties !== undefined) {
|
|
55
|
+
if (!Array.isArray(entry.specialties) || entry.specialties.length > 16
|
|
56
|
+
|| entry.specialties.some(value => !/^[a-z][a-z0-9-]{0,39}$/.test(value))) {
|
|
57
|
+
throw new Error(`${label} 的 specialties 必须是至多 16 个英文任务标签`)
|
|
58
|
+
}
|
|
59
|
+
profile.specialties = [...new Set(entry.specialties)]
|
|
60
|
+
}
|
|
61
|
+
if (entry.cliModel !== undefined) {
|
|
62
|
+
const cliModel = id(entry.cliModel)
|
|
63
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/.test(cliModel)) throw new Error(`${label} 的 cliModel 无效`)
|
|
64
|
+
if (toolForProvider(provider)?.id === 'zcode') throw new Error(`${label} 对应的 ZCode CLI 暂不支持逐次切换模型`)
|
|
65
|
+
profile.cliModel = cliModel
|
|
66
|
+
}
|
|
67
|
+
if (entry.execution !== undefined) {
|
|
68
|
+
if (entry.execution !== 'auto' && entry.execution !== 'official' && entry.execution !== 'api') {
|
|
69
|
+
throw new Error(`${label} 的 execution 只能是 auto、official 或 api`)
|
|
70
|
+
}
|
|
71
|
+
if (entry.execution !== 'auto') profile.execution = entry.execution
|
|
72
|
+
}
|
|
73
|
+
if (entry.billing !== undefined) {
|
|
74
|
+
if (!BILLING_VALUES.has(entry.billing)) throw new Error(`${label} 的 billing 只能是 subscription-first、api-only 或 subscription-only`)
|
|
75
|
+
profile.billing = entry.billing
|
|
76
|
+
}
|
|
77
|
+
if (entry.subscription !== undefined) {
|
|
78
|
+
if (!SUBSCRIPTION_VALUES.has(entry.subscription)) throw new Error(`${label} 的 subscription 只能是 plan-key、cli-login 或 none`)
|
|
79
|
+
profile.subscription = entry.subscription
|
|
80
|
+
}
|
|
81
|
+
if (entry.apiRoute !== undefined) {
|
|
82
|
+
const target = entry.apiRoute
|
|
83
|
+
if (!target || typeof target !== 'object' || Array.isArray(target) || Object.keys(target).some(key => key !== 'provider' && key !== 'model')) {
|
|
84
|
+
throw new Error(`${label} 的 apiRoute 必须是 { provider, model }`)
|
|
85
|
+
}
|
|
86
|
+
const apiProvider = id(target.provider)
|
|
87
|
+
const apiModel = id(target.model)
|
|
88
|
+
if (!apiProvider || !apiModel || apiProvider.length > 160 || apiModel.length > 240) throw new Error(`${label} 的 apiRoute 需要模型目录中的准确 provider 和 model`)
|
|
89
|
+
if (profile.subscription !== 'plan-key') throw new Error(`${label} 只有 subscription 为 plan-key 时才能设置 apiRoute`)
|
|
90
|
+
if (apiProvider === provider && apiModel === model) throw new Error(`${label} 的 apiRoute 不能指向自己`)
|
|
91
|
+
profile.apiRoute = { provider: apiProvider, model: apiModel }
|
|
92
|
+
}
|
|
93
|
+
if (Object.keys(profile).length === 2) throw new Error(`${label} 至少提供 quality、pricing、specialties、cliModel、execution、billing 或 subscription 之一`)
|
|
94
|
+
return profile
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `auto` is the default and is not stored. `official` and `api` are explicit. */
|
|
98
|
+
export function normalizeExecutionPreference(value) {
|
|
99
|
+
if (value === undefined || value === null || value === '' || value === 'auto') return 'auto'
|
|
100
|
+
if (value === 'official' || value === 'api') return value
|
|
101
|
+
throw new TypeError('execution must be auto, official, or api')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Parse a settings field; an empty value intentionally means no overrides. */
|
|
105
|
+
export function parseModelProfilesJson(value) {
|
|
106
|
+
const source = value == null || value === '' ? '[]' : value
|
|
107
|
+
if (typeof source !== 'string' || source.length > MAX_TEXT) throw new Error('模型价格与能力配置超过 32000 字符上限')
|
|
108
|
+
let input
|
|
109
|
+
try { input = JSON.parse(source) } catch { throw new Error('模型价格与能力配置不是有效的 JSON') }
|
|
110
|
+
if (!Array.isArray(input) || input.length > MAX_PROFILES) throw new Error('模型价格与能力配置必须是最多 200 项的 JSON 数组')
|
|
111
|
+
const profiles = input.map(normalizeProfile)
|
|
112
|
+
const seen = new Set()
|
|
113
|
+
for (const profile of profiles) {
|
|
114
|
+
const key = `${profile.provider}\0${profile.model}`
|
|
115
|
+
if (seen.has(key)) throw new Error(`模型 ${profile.provider}/${profile.model} 重复配置`)
|
|
116
|
+
seen.add(key)
|
|
117
|
+
}
|
|
118
|
+
return profiles
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Apply metadata only to exact routes actually returned by Harness. */
|
|
122
|
+
export function applyModelProfiles(routes, profiles) {
|
|
123
|
+
const input = Array.isArray(routes) ? routes : []
|
|
124
|
+
const values = typeof profiles === 'string' ? parseModelProfilesJson(profiles) : profiles
|
|
125
|
+
if (!Array.isArray(values)) throw new Error('模型配置必须是数组')
|
|
126
|
+
const byKey = new Map(values.map(profile => [`${profile.provider}\0${profile.model}`, profile]))
|
|
127
|
+
return input.map(route => {
|
|
128
|
+
const profile = byKey.get(`${route.provider}\0${route.model}`)
|
|
129
|
+
if (!profile) return route
|
|
130
|
+
return {
|
|
131
|
+
...route,
|
|
132
|
+
...(profile.quality === undefined ? {} : { quality: profile.quality, qualitySource: 'user' }),
|
|
133
|
+
...(profile.pricing === undefined ? {} : { pricing: { ...profile.pricing }, pricingSource: 'user' }),
|
|
134
|
+
...(profile.specialties === undefined ? {} : { specialties: [...profile.specialties] }),
|
|
135
|
+
...(profile.cliModel === undefined ? {} : { cliModel: profile.cliModel }),
|
|
136
|
+
...(profile.execution === undefined ? {} : { execution: profile.execution }),
|
|
137
|
+
...(profile.billing === undefined ? {} : { billing: profile.billing }),
|
|
138
|
+
...(profile.subscription === undefined ? {} : { subscription: profile.subscription }),
|
|
139
|
+
...(profile.apiRoute === undefined ? {} : { apiRoute: { ...profile.apiRoute } }),
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute a routed series of official CLI work packages in one isolated Git
|
|
3
|
+
* worktree. A successful editable run is copied back with git apply only after
|
|
4
|
+
* the original checkout is rechecked clean; incomplete work stays in the
|
|
5
|
+
* worktree for review. No package supplies an executable or shell fragment.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process'
|
|
8
|
+
import { appendFile, mkdir, realpath, stat } from 'node:fs/promises'
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
11
|
+
import { runOfficialTool, officialToolExecutionCapabilities, officialToolReadiness } from './official-tool-executor.mjs'
|
|
12
|
+
import { toolForProvider } from './official-tool-registry.mjs'
|
|
13
|
+
|
|
14
|
+
const MAX_GIT_OUTPUT = 64_000_000
|
|
15
|
+
const GIT_TIMEOUT_MS = 120_000
|
|
16
|
+
|
|
17
|
+
function within(parent, child) {
|
|
18
|
+
const part = relative(parent, child)
|
|
19
|
+
return part === '' || (part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function canonicalDirectory(path) {
|
|
23
|
+
if (typeof path !== 'string' || !isAbsolute(path) || path.includes('\0')) throw new TypeError('workspace must be an absolute directory')
|
|
24
|
+
const canonical = await realpath(path)
|
|
25
|
+
if (!(await stat(canonical)).isDirectory()) throw new TypeError('workspace must be a directory')
|
|
26
|
+
return canonical
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The session owner, never model arguments, selects the filesystem scope. */
|
|
30
|
+
export async function sessionWorkspace(ctx, exec) {
|
|
31
|
+
const session = exec?.agent?.session
|
|
32
|
+
if (!session) throw new Error('official CLI execution requires a live Harness session')
|
|
33
|
+
const policy = ctx.sandboxPolicy.resolve({ session })
|
|
34
|
+
const root = await canonicalDirectory(policy.workspaceRoot)
|
|
35
|
+
const chosen = await canonicalDirectory(session.header?.cwd ?? policy.workspaceRoot)
|
|
36
|
+
if (!within(root, chosen)) throw new Error('session working directory is outside the Harness workspace policy')
|
|
37
|
+
return { cwd: chosen, root, sandboxMode: policy.mode }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function git(args, { cwd, input, signal, timeoutMs = GIT_TIMEOUT_MS } = {}) {
|
|
41
|
+
return new Promise(resolveResult => {
|
|
42
|
+
let child
|
|
43
|
+
try {
|
|
44
|
+
child = spawn('git', args, { cwd, windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
45
|
+
} catch (error) {
|
|
46
|
+
resolveResult({ ok: false, code: null, output: Buffer.from(String(error?.message ?? error)) })
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const stdoutChunks = []
|
|
50
|
+
const stderrChunks = []
|
|
51
|
+
let bytes = 0
|
|
52
|
+
let oversized = false
|
|
53
|
+
let timedOut = false
|
|
54
|
+
let cancelled = false
|
|
55
|
+
const collect = (data, chunks) => {
|
|
56
|
+
bytes += data.length
|
|
57
|
+
if (bytes > MAX_GIT_OUTPUT) { oversized = true; child.kill('SIGTERM'); return }
|
|
58
|
+
chunks.push(data)
|
|
59
|
+
}
|
|
60
|
+
child.stdout.on('data', data => collect(data, stdoutChunks))
|
|
61
|
+
child.stderr.on('data', data => collect(data, stderrChunks))
|
|
62
|
+
child.stdin.on('error', () => { /* Git may exit before consuming a patch. */ })
|
|
63
|
+
const onAbort = () => { cancelled = true; child.kill('SIGTERM') }
|
|
64
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
65
|
+
const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM') }, timeoutMs)
|
|
66
|
+
child.on('error', error => finish(null, error))
|
|
67
|
+
child.on('close', code => finish(code))
|
|
68
|
+
if (input === undefined) child.stdin.end()
|
|
69
|
+
else child.stdin.end(input)
|
|
70
|
+
let finished = false
|
|
71
|
+
function finish(code, error) {
|
|
72
|
+
if (finished) return
|
|
73
|
+
finished = true
|
|
74
|
+
clearTimeout(timer)
|
|
75
|
+
signal?.removeEventListener('abort', onAbort)
|
|
76
|
+
const output = Buffer.concat(stdoutChunks)
|
|
77
|
+
const stderr = Buffer.concat(stderrChunks)
|
|
78
|
+
resolveResult({ ok: code === 0 && !oversized && !timedOut && !cancelled, code,
|
|
79
|
+
output, stderr, oversized, timedOut, cancelled, error: error?.message })
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function gitError(action, result) {
|
|
85
|
+
const detail = String(result.stderr?.length ? result.stderr : result.output ?? '').trim().slice(-900)
|
|
86
|
+
return new Error(`${action}失败${result.cancelled ? '(已取消)' : result.timedOut ? '(超时)' : result.oversized ? '(输出过大)' : ''}${detail ? `:${detail}` : ''}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function cleanRepoRoot(workspace, signal) {
|
|
90
|
+
const top = await git(['rev-parse', '--show-toplevel'], { cwd: workspace, signal })
|
|
91
|
+
if (!top.ok) throw gitError('读取 Git 仓库', top)
|
|
92
|
+
const repo = await canonicalDirectory(top.output.toString('utf8').trim())
|
|
93
|
+
if (repo !== workspace) throw new Error('可编辑团队任务要求当前 Harness 工作目录为 Git 仓库根目录')
|
|
94
|
+
const status = await git(['status', '--porcelain=v1', '--untracked-files=all'], { cwd: workspace, signal })
|
|
95
|
+
if (!status.ok) throw gitError('检查原工作区', status)
|
|
96
|
+
if (status.output.length > 0) throw new Error('原工作区有未提交改动;请先提交或选择干净工作区,再启动可编辑团队任务')
|
|
97
|
+
return repo
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Keep nested worktrees out of ordinary git status without changing tracked files. */
|
|
101
|
+
async function excludeNestedWorktrees(workspace, signal) {
|
|
102
|
+
const ignored = await git(['check-ignore', '-q', '--', '.model-router-workspaces/'], { cwd: workspace, signal })
|
|
103
|
+
if (ignored.ok) return
|
|
104
|
+
if (ignored.code !== 1 || ignored.cancelled || ignored.timedOut) throw gitError('检查独立工作区忽略规则', ignored)
|
|
105
|
+
const pathResult = await git(['rev-parse', '--git-path', 'info/exclude'], { cwd: workspace, signal })
|
|
106
|
+
if (!pathResult.ok) throw gitError('定位 Git 本地忽略文件', pathResult)
|
|
107
|
+
const pathText = pathResult.output.toString('utf8').trim()
|
|
108
|
+
if (!pathText || pathText.includes('\0')) throw new Error('Git 本地忽略文件路径无效')
|
|
109
|
+
const excludePath = isAbsolute(pathText) ? pathText : resolve(workspace, pathText)
|
|
110
|
+
const parent = dirname(excludePath)
|
|
111
|
+
await mkdir(parent, { recursive: true })
|
|
112
|
+
await appendFile(excludePath, '\n# model-router-galgame isolated worktrees\n/.model-router-workspaces/\n', 'utf8')
|
|
113
|
+
const confirmed = await git(['check-ignore', '-q', '--', '.model-router-workspaces/'], { cwd: workspace, signal })
|
|
114
|
+
if (!confirmed.ok) throw gitError('确认独立工作区忽略规则', confirmed)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Keep generated worktrees beside the project so they use the same drive. */
|
|
118
|
+
async function isolatedWorktree(workspace, signal, allowedRoot) {
|
|
119
|
+
await cleanRepoRoot(workspace, signal)
|
|
120
|
+
const base = await git(['rev-parse', 'HEAD'], { cwd: workspace, signal })
|
|
121
|
+
if (!base.ok || !/^[0-9a-f]{40,64}$/i.test(base.output.toString('utf8').trim())) {
|
|
122
|
+
throw gitError('记录原仓库基线', base)
|
|
123
|
+
}
|
|
124
|
+
const baseCommit = base.output.toString('utf8').trim()
|
|
125
|
+
const policyRoot = await canonicalDirectory(allowedRoot ?? workspace)
|
|
126
|
+
const sibling = join(dirname(workspace), '.model-router-workspaces', basename(workspace))
|
|
127
|
+
const parent = within(policyRoot, sibling) ? sibling : join(workspace, '.model-router-workspaces')
|
|
128
|
+
if (parent === join(workspace, '.model-router-workspaces')) await excludeNestedWorktrees(workspace, signal)
|
|
129
|
+
await mkdir(parent, { recursive: true })
|
|
130
|
+
const isolatedRoot = await canonicalDirectory(parent)
|
|
131
|
+
const target = join(isolatedRoot, `run-${randomUUID()}`)
|
|
132
|
+
const added = await git(['worktree', 'add', '--detach', target, 'HEAD'], { cwd: workspace, signal })
|
|
133
|
+
if (!added.ok) throw gitError('创建独立 Git 工作区', added)
|
|
134
|
+
const runPath = await canonicalDirectory(target)
|
|
135
|
+
if (!within(isolatedRoot, runPath) || runPath === isolatedRoot) throw new Error('独立工作区路径校验失败')
|
|
136
|
+
return { workspace: runPath, isolatedRoot, baseCommit, source: workspace }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function integrateWorktree(source, isolated, baseCommit, signal) {
|
|
140
|
+
await cleanRepoRoot(source, signal)
|
|
141
|
+
const current = await git(['rev-parse', 'HEAD'], { cwd: source, signal })
|
|
142
|
+
if (!current.ok) throw gitError('复核原仓库基线', current)
|
|
143
|
+
if (current.output.toString('utf8').trim() !== baseCommit) {
|
|
144
|
+
throw new Error('原仓库 HEAD 在官方 CLI 执行期间发生变化;保留独立工作区,拒绝自动整合')
|
|
145
|
+
}
|
|
146
|
+
const ignored = await git(['ls-files', '--others', '--ignored', '--exclude-standard', '-z'], { cwd: isolated, signal })
|
|
147
|
+
if (!ignored.ok) throw gitError('检查独立工作区忽略文件', ignored)
|
|
148
|
+
const ignoredPaths = ignored.output.toString('utf8').split('\0').filter(Boolean)
|
|
149
|
+
const staged = await git(['add', '-A'], { cwd: isolated, signal })
|
|
150
|
+
if (!staged.ok) throw gitError('收集独立工作区变更', staged)
|
|
151
|
+
// Compare the final index with the original checkout commit. Vendor agents
|
|
152
|
+
// may create commits in their worktree; diffing only its current HEAD would
|
|
153
|
+
// silently drop all committed work.
|
|
154
|
+
const patch = await git(['diff', '--cached', '--binary', baseCommit], { cwd: isolated, signal })
|
|
155
|
+
if (!patch.ok) throw gitError('生成独立工作区补丁', patch)
|
|
156
|
+
const names = await git(['diff', '--cached', '--name-only', baseCommit], { cwd: isolated, signal })
|
|
157
|
+
if (!names.ok) throw gitError('列出修改文件', names)
|
|
158
|
+
const changedFiles = names.output.toString('utf8').trim().split(/\r?\n/).filter(Boolean)
|
|
159
|
+
if (patch.output.length === 0) return { integrated: false, changedFiles: [],
|
|
160
|
+
ignoredArtifacts: ignoredPaths.length, ignoredExamples: ignoredPaths.slice(0, 12),
|
|
161
|
+
notice: ignoredPaths.length > 0
|
|
162
|
+
? '官方 CLI 只生成了 Git 忽略的产物;它们仍在独立工作区,须人工核对。'
|
|
163
|
+
: '官方 CLI 没有修改受 Git 管理的文件。' }
|
|
164
|
+
const checked = await git(['apply', '--check', '--binary', '-'], { cwd: source, input: patch.output, signal })
|
|
165
|
+
if (!checked.ok) throw gitError('核对原工作区补丁', checked)
|
|
166
|
+
const applied = await git(['apply', '--binary', '-'], { cwd: source, input: patch.output, signal })
|
|
167
|
+
if (!applied.ok) throw gitError('整合原工作区补丁', applied)
|
|
168
|
+
return { integrated: true, changedFiles,
|
|
169
|
+
ignoredArtifacts: ignoredPaths.length, ignoredExamples: ignoredPaths.slice(0, 12),
|
|
170
|
+
notice: ignoredPaths.length > 0
|
|
171
|
+
? `源文件补丁已应用;${ignoredPaths.length} 个 Git 忽略产物未整合,仍在独立工作区供核对。`
|
|
172
|
+
: '补丁已应用至原工作区,保留独立工作区供核对。' }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function checkedTeamCliModels(cliModels, assigned) {
|
|
176
|
+
if (cliModels == null) return Object.create(null)
|
|
177
|
+
if (typeof cliModels !== 'object' || Array.isArray(cliModels)) {
|
|
178
|
+
throw new TypeError('cliModels must map work package ids or official tool ids to configured CLI model names')
|
|
179
|
+
}
|
|
180
|
+
const packages = new Map(assigned.map(item => [item.id, item]))
|
|
181
|
+
const tools = new Set(assigned.map(item => item.toolId))
|
|
182
|
+
const checked = Object.create(null)
|
|
183
|
+
for (const [key, model] of Object.entries(cliModels)) {
|
|
184
|
+
const toolId = packages.get(key)?.toolId ?? (tools.has(key) ? key : null)
|
|
185
|
+
if (!toolId || toolId === 'zcode') {
|
|
186
|
+
throw new TypeError(`cliModels contains an unsupported package or tool id: ${key}`)
|
|
187
|
+
}
|
|
188
|
+
if (typeof model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,119}$/.test(model)) {
|
|
189
|
+
throw new TypeError(`cliModels contains an invalid model name for ${toolId}`)
|
|
190
|
+
}
|
|
191
|
+
if ((toolId === 'minimax-code' || toolId === 'mimo-code') && !model.includes('/')) {
|
|
192
|
+
throw new TypeError(`${toolId} requires a provider/model CLI name`)
|
|
193
|
+
}
|
|
194
|
+
checked[key] = model
|
|
195
|
+
}
|
|
196
|
+
return checked
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function teamCliModel(item, cliModels) {
|
|
200
|
+
// Harness directory IDs and vendor CLI aliases are independent namespaces.
|
|
201
|
+
// Only the two CLIs with a verified direct model argument contract receive
|
|
202
|
+
// a planned ID. All other vendors use their configured default until the
|
|
203
|
+
// user supplies a verified CLI mapping through that vendor's own settings.
|
|
204
|
+
if (cliModels[item.id]) return cliModels[item.id]
|
|
205
|
+
if (cliModels[item.toolId]) return cliModels[item.toolId]
|
|
206
|
+
return item.toolId === 'claude-code' || item.toolId === 'codex'
|
|
207
|
+
? item.recommendedModel : null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function reportedCliModel(toolId, value) {
|
|
211
|
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
212
|
+
// MiniMax's verified exec.result.model is a structured selection, while
|
|
213
|
+
// its --model argument is one provider/model string.
|
|
214
|
+
if (toolId === 'minimax-code' && value && typeof value === 'object'
|
|
215
|
+
&& typeof value.providerId === 'string' && value.providerId
|
|
216
|
+
&& typeof value.modelId === 'string' && value.modelId) {
|
|
217
|
+
return `${value.providerId}/${value.modelId}`
|
|
218
|
+
}
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function packagePrompt(task, item, completed) {
|
|
223
|
+
const dependencies = item.dependsOn.map(id => completed.find(result => result.id === id)).filter(Boolean)
|
|
224
|
+
const directions = {
|
|
225
|
+
analysis: '提取本工作包对应的需求、约束、依赖和验收依据。此阶段只做分析,不修改文件。',
|
|
226
|
+
execution: '依据总任务与前置分析完成当前领域的实现或交付;不要接管其他执行工作包。',
|
|
227
|
+
verification: '逐项核对前置工作包的交付与原任务要求,列出证据、失败点和未验证内容。',
|
|
228
|
+
synthesis: '整合前置工作包成果,核对冲突与遗漏,给出最终交付清单和未完成事项。',
|
|
229
|
+
}
|
|
230
|
+
return [
|
|
231
|
+
`总任务:\n${String(task).slice(0, 40_000)}`,
|
|
232
|
+
`当前工作包:${item.name}(${item.type})\n工作要求:${directions[item.purpose] ?? item.purpose}`,
|
|
233
|
+
item.objective ? `当前包的具体目标:\n${item.objective}` : '',
|
|
234
|
+
Array.isArray(item.verificationChecklist) && item.verificationChecklist.length
|
|
235
|
+
? `验收要点:\n${item.verificationChecklist.map(point => `- ${point}`).join('\n')}` : '',
|
|
236
|
+
dependencies.length
|
|
237
|
+
? `依赖工作包结果:\n${dependencies.map(dep => `${dep.name}: ${dep.finalText.slice(0, 2_500)}`).join('\n\n')}`
|
|
238
|
+
: '当前工作包无前置依赖。',
|
|
239
|
+
'仅处理当前工作包。说明实际完成内容、改动文件和未完成事项;不要把无法完成说成完成。',
|
|
240
|
+
].join('\n\n')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Preflight every assignment before any model request or workspace write. */
|
|
244
|
+
export function executableTeamPackages(plan, installedIds = [], mode = 'read-only') {
|
|
245
|
+
const capabilities = new Map(officialToolExecutionCapabilities().map(item => [item.id, item]))
|
|
246
|
+
const packages = Array.isArray(plan?.team?.workPackages) ? plan.team.workPackages : []
|
|
247
|
+
const blocking = []
|
|
248
|
+
const assigned = packages.map(item => {
|
|
249
|
+
const tool = toolForProvider(item.recommendedProvider)
|
|
250
|
+
const capability = tool ? capabilities.get(tool.id) : null
|
|
251
|
+
if (!tool || !capability?.supported || !capability.modes?.includes(mode) || !installedIds.includes(tool.id)) {
|
|
252
|
+
blocking.push({ id: item.id, name: item.name, provider: item.recommendedProvider,
|
|
253
|
+
toolId: tool?.id ?? null,
|
|
254
|
+
reason: !tool ? '该供应商没有注册表中的官方 CLI。'
|
|
255
|
+
: !capability?.supported ? capability?.reason ?? '此 CLI 暂不支持托管执行。'
|
|
256
|
+
: !capability.modes?.includes(mode) ? `此 CLI 不支持 ${mode} 模式;请选择已审批的可编辑隔离工作区模式。`
|
|
257
|
+
: '此 CLI 尚未安装。' })
|
|
258
|
+
}
|
|
259
|
+
return { ...item, toolId: tool?.id ?? null }
|
|
260
|
+
})
|
|
261
|
+
return { assigned, blocking }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Remove a worktree this run created but could not use; failures are ignored. */
|
|
265
|
+
async function removeWorktree(source, target) {
|
|
266
|
+
try { await git(['worktree', 'remove', '--force', target], { cwd: source }) } catch { /* left for manual cleanup */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Copy the changes of an earlier, never integrated worktree into a fresh one.
|
|
271
|
+
* Refuses when the original checkout moved since that run (the patch would be
|
|
272
|
+
* based on another commit) or when the earlier worktree is not one of ours.
|
|
273
|
+
*/
|
|
274
|
+
async function seedWorktree(isolated, seedFrom, signal) {
|
|
275
|
+
if (!/^[0-9a-f]{40,64}$/i.test(seedFrom.baseCommit)) throw new Error('原运行没有记录 Git 基线,无法在新工作区续跑。')
|
|
276
|
+
if (seedFrom.baseCommit !== isolated.baseCommit) {
|
|
277
|
+
throw new Error('原仓库 HEAD 已不是原运行的基线提交;无法安全套用之前的改动。请核对原独立工作区后重新执行整个团队任务。')
|
|
278
|
+
}
|
|
279
|
+
let prior
|
|
280
|
+
try { prior = await canonicalDirectory(seedFrom.workspace) }
|
|
281
|
+
catch { throw new Error('原运行的独立工作区已不存在,无法套用之前的改动;请重新执行整个团队任务。') }
|
|
282
|
+
// Only a worktree of this repository that the plugin created (run-<uuid>) is accepted.
|
|
283
|
+
const listed = await git(['worktree', 'list', '--porcelain'], { cwd: isolated.source, signal })
|
|
284
|
+
const worktrees = listed.ok ? listed.output.toString('utf8').split(/\r?\n/).filter(line => line.startsWith('worktree ')).map(line => line.slice(9)) : []
|
|
285
|
+
const known = await Promise.all(worktrees.map(path => canonicalDirectory(path).catch(() => null)))
|
|
286
|
+
if (!known.includes(prior) || !/^run-[0-9a-f-]{36}$/i.test(basename(prior)) || basename(dirname(dirname(prior))) !== '.model-router-workspaces' && basename(dirname(prior)) !== '.model-router-workspaces') {
|
|
287
|
+
throw new Error('原独立工作区不是本仓库由插件创建的 Git 工作树,拒绝读取。')
|
|
288
|
+
}
|
|
289
|
+
const staged = await git(['add', '-A'], { cwd: prior, signal })
|
|
290
|
+
if (!staged.ok) throw gitError('收集原独立工作区变更', staged)
|
|
291
|
+
const patch = await git(['diff', '--cached', '--binary', seedFrom.baseCommit], { cwd: prior, signal })
|
|
292
|
+
if (!patch.ok) throw gitError('生成原独立工作区补丁', patch)
|
|
293
|
+
const names = await git(['diff', '--cached', '--name-only', seedFrom.baseCommit], { cwd: prior, signal })
|
|
294
|
+
const files = names.ok ? names.output.toString('utf8').trim().split(/\r?\n/).filter(Boolean) : []
|
|
295
|
+
if (patch.output.length === 0) return { from: prior, files: [] }
|
|
296
|
+
const checked = await git(['apply', '--check', '--binary', '-'], { cwd: isolated.workspace, input: patch.output, signal })
|
|
297
|
+
if (!checked.ok) throw gitError('核对之前改动的补丁', checked)
|
|
298
|
+
const applied = await git(['apply', '--binary', '-'], { cwd: isolated.workspace, input: patch.output, signal })
|
|
299
|
+
if (!applied.ok) throw gitError('在新工作区套用之前的改动', applied)
|
|
300
|
+
return { from: prior, files }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Sequential execution preserves DAG dependencies and avoids edit conflicts. */
|
|
304
|
+
export async function runOfficialTeam({ plan, task, workspace, allowedRoot, mode = 'read-only', installedIds = [], cliModels, signal, sandbox, runtime, previous = [], onlyIds = null, seedFrom = null }) {
|
|
305
|
+
// A retry runs only `onlyIds`; earlier results feed dependency context. An
|
|
306
|
+
// editable retry needs `seedFrom` (the earlier, never integrated worktree and
|
|
307
|
+
// its base commit): a fresh worktree gets that worktree's changes first, so
|
|
308
|
+
// the retried steps continue from them and one patch is integrated at the end.
|
|
309
|
+
const retry = Array.isArray(onlyIds)
|
|
310
|
+
if (retry && mode !== 'read-only' && !(seedFrom && typeof seedFrom.workspace === 'string' && typeof seedFrom.baseCommit === 'string')) {
|
|
311
|
+
throw new TypeError('an editable team retry needs the earlier isolated worktree (seedFrom)')
|
|
312
|
+
}
|
|
313
|
+
const selected = executableTeamPackages(plan, installedIds, mode)
|
|
314
|
+
const assigned = retry ? selected.assigned.filter(item => onlyIds.includes(item.id)) : selected.assigned
|
|
315
|
+
const blocking = retry ? selected.blocking.filter(item => onlyIds.includes(item.id)) : selected.blocking
|
|
316
|
+
if (assigned.length === 0) return { status: 'blocked', blocking: [{ reason: '计划没有工作包。' }], results: [] }
|
|
317
|
+
if (blocking.length > 0) return { status: 'blocked', blocking, results: [] }
|
|
318
|
+
if (mode !== 'read-only' && mode !== 'workspace-write') throw new TypeError('team mode must be read-only or workspace-write')
|
|
319
|
+
const configuredCliModels = checkedTeamCliModels(cliModels, assigned)
|
|
320
|
+
const source = await canonicalDirectory(workspace)
|
|
321
|
+
if (typeof sandbox?.confine !== 'function') return { status: 'blocked', blocking: [{ reason: '官方 Harness 进程沙箱不可用。' }], results: [] }
|
|
322
|
+
// The Host passes no runtime override. Tests can inject inert CLI adapters to
|
|
323
|
+
// exercise assignment, stopping, and Git integration without provider accounts.
|
|
324
|
+
const readinessCheck = runtime?.readiness ?? officialToolReadiness
|
|
325
|
+
const runTool = runtime?.runTool ?? runOfficialTool
|
|
326
|
+
const readiness = await Promise.all([...new Set(assigned.map(item => item.toolId))].map(id => readinessCheck(id, source)))
|
|
327
|
+
const unready = readiness.filter(item => !item.ready)
|
|
328
|
+
if (unready.length > 0) return { status: 'blocked', blocking: unready.map(item => ({ toolId: item.id, reason: item.reason })), results: [] }
|
|
329
|
+
const isolated = mode === 'workspace-write' ? await isolatedWorktree(source, signal, allowedRoot) : null
|
|
330
|
+
const runWorkspace = isolated?.workspace ?? source
|
|
331
|
+
let seeded = null
|
|
332
|
+
if (isolated && retry) {
|
|
333
|
+
try { seeded = await seedWorktree(isolated, seedFrom, signal) }
|
|
334
|
+
catch (error) {
|
|
335
|
+
await removeWorktree(source, isolated.workspace)
|
|
336
|
+
return { status: 'blocked', blocking: [{ reason: String(error?.message ?? error) }], results: [] }
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const completed = []
|
|
340
|
+
const context = retry ? (Array.isArray(previous) ? previous : []).filter(item => item?.id && typeof item.finalText === 'string') : []
|
|
341
|
+
for (const item of assigned) {
|
|
342
|
+
if (signal?.aborted) return { status: 'cancelled', workspace: runWorkspace, results: completed }
|
|
343
|
+
const cliModel = teamCliModel(item, configuredCliModels)
|
|
344
|
+
const result = await runTool({
|
|
345
|
+
toolId: item.toolId,
|
|
346
|
+
modelId: cliModel,
|
|
347
|
+
task: packagePrompt(task, item, [...context, ...completed]),
|
|
348
|
+
workspace: runWorkspace,
|
|
349
|
+
mode,
|
|
350
|
+
isolatedRoot: isolated?.isolatedRoot,
|
|
351
|
+
sandbox,
|
|
352
|
+
signal,
|
|
353
|
+
})
|
|
354
|
+
const reportedModel = reportedCliModel(item.toolId, result.reportedModel)
|
|
355
|
+
const modelMismatch = result.status === 'succeeded' && cliModel && reportedModel
|
|
356
|
+
&& cliModel !== reportedModel
|
|
357
|
+
const packageStatus = modelMismatch ? 'model-mismatch' : result.status
|
|
358
|
+
completed.push({ id: item.id, name: item.name,
|
|
359
|
+
...(item.objective ? { objective: item.objective } : {}), provider: item.recommendedProvider,
|
|
360
|
+
recommendedModel: item.recommendedModel, toolId: item.toolId,
|
|
361
|
+
requestedCliModel: result.requestedModel ?? null,
|
|
362
|
+
actualModel: reportedModel,
|
|
363
|
+
modelNotice: modelMismatch
|
|
364
|
+
? `官方 CLI 回报模型 ${reportedModel},与本包指定的 ${cliModel} 不一致;后续工作包已停止。`
|
|
365
|
+
: result.modelNotice ?? (!cliModel
|
|
366
|
+
? 'Harness 推荐模型 ID 未经此厂商 CLI 验证;本包使用厂商 CLI 已配置的默认模型。'
|
|
367
|
+
: reportedModel ? null : '该 CLI 未返回可核验的实际模型 ID;请以厂商运行记录核对。'),
|
|
368
|
+
status: packageStatus, finalText: result.finalText ?? '',
|
|
369
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
370
|
+
...(Number.isFinite(result.reportedCostUsd) ? { reportedCostUsd: result.reportedCostUsd } : {}),
|
|
371
|
+
error: modelMismatch ? '官方 CLI 实际模型与指定模型不一致。' : result.error ?? result.reason ?? null,
|
|
372
|
+
outputTail: result.status === 'succeeded' ? null : result.stderrTail ?? result.stdoutTail ?? null })
|
|
373
|
+
if (packageStatus !== 'succeeded') return { status: 'incomplete', workspace: runWorkspace, results: completed,
|
|
374
|
+
...(isolated ? { baseCommit: isolated.baseCommit } : {}), ...(seeded ? { seeded } : {}) }
|
|
375
|
+
}
|
|
376
|
+
if (!isolated) return { status: 'cli-completed', workspace: runWorkspace, results: completed,
|
|
377
|
+
notice: '只读模式已收集各工作包结果;模型输出仍需人工验收。' }
|
|
378
|
+
try {
|
|
379
|
+
const integration = await integrateWorktree(source, runWorkspace, isolated.baseCommit, signal)
|
|
380
|
+
return { status: integration.ignoredArtifacts > 0 ? 'integration-pending' : 'cli-completed',
|
|
381
|
+
workspace: runWorkspace, results: completed, integration, baseCommit: isolated.baseCommit, ...(seeded ? { seeded } : {}),
|
|
382
|
+
notice: integration.ignoredArtifacts > 0
|
|
383
|
+
? '受 Git 管理的源文件已整合;忽略产物保留在独立工作区,仍需核对和补交。'
|
|
384
|
+
: '各 CLI 进程成功结束且可用补丁已整合;文件内容仍需按任务要求验收。' }
|
|
385
|
+
} catch (error) {
|
|
386
|
+
return { status: 'integration-pending', workspace: runWorkspace, results: completed, baseCommit: isolated.baseCommit,
|
|
387
|
+
...(seeded ? { seeded } : {}),
|
|
388
|
+
error: String(error?.message ?? error), notice: '独立工作区已保留;原工作区未自动整合。' }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** One editable CLI task also uses the same isolated workspace and merge gate. */
|
|
393
|
+
export async function runOfficialTask({ toolId, task, workspace, allowedRoot, modelId, mode = 'read-only', signal, sandbox }) {
|
|
394
|
+
const source = await canonicalDirectory(workspace)
|
|
395
|
+
if (typeof sandbox?.confine !== 'function') return { toolId, status: 'unsupported', reason: '官方 Harness 进程沙箱不可用。' }
|
|
396
|
+
const readiness = await officialToolReadiness(toolId, source)
|
|
397
|
+
if (!readiness.ready) return { toolId, status: 'unsupported', reason: readiness.reason }
|
|
398
|
+
const isolated = mode === 'workspace-write' ? await isolatedWorktree(source, signal, allowedRoot) : null
|
|
399
|
+
const result = await runOfficialTool({ toolId, task, modelId, workspace: isolated?.workspace ?? source,
|
|
400
|
+
mode, isolatedRoot: isolated?.isolatedRoot, signal, sandbox })
|
|
401
|
+
if (!isolated || result.status !== 'succeeded') return { ...result, ...(isolated ? { isolatedWorkspace: isolated.workspace, baseCommit: isolated.baseCommit } : {}) }
|
|
402
|
+
try {
|
|
403
|
+
const integration = await integrateWorktree(source, isolated.workspace, isolated.baseCommit, signal)
|
|
404
|
+
return { ...result,
|
|
405
|
+
...(integration.ignoredArtifacts > 0 ? { status: 'integration-pending' } : {}),
|
|
406
|
+
isolatedWorkspace: isolated.workspace, integration }
|
|
407
|
+
} catch (error) {
|
|
408
|
+
return { ...result, status: 'integration-pending', isolatedWorkspace: isolated.workspace,
|
|
409
|
+
integrationError: String(error?.message ?? error) }
|
|
410
|
+
}
|
|
411
|
+
}
|