@sqdr2/dsh-fusion 0.0.0-stage → 0.3.2
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/INSTALL.md +89 -0
- package/INSTALL.zh.md +86 -0
- package/LICENSE +201 -0
- package/README.en.md +193 -0
- package/README.md +182 -2
- package/RELEASING.md +123 -0
- package/RELEASING.zh.md +115 -0
- package/contracts/fusion.ts +279 -0
- package/contracts/storage.sql +86 -0
- package/cordis.patch.yml +3 -0
- package/docs/COMPATIBILITY.md +162 -0
- package/docs/EVIDENCE.md +141 -0
- package/dshx.yml +39 -0
- package/lib/client.js +1920 -0
- package/lib/client.js.map +1 -0
- package/lib/dsh-fusion.js +12664 -0
- package/lib/types/activity.d.ts +20 -0
- package/lib/types/approval/logical.d.ts +10 -0
- package/lib/types/benchmark/naive.d.ts +37 -0
- package/lib/types/benchmark/native-run.d.ts +68 -0
- package/lib/types/benchmark/output-limits.d.ts +19 -0
- package/lib/types/bilingual.d.ts +2 -0
- package/lib/types/client/ActivityRows.d.ts +8 -0
- package/lib/types/client/FusionCache.d.ts +4 -0
- package/lib/types/client/FusionHint.d.ts +9 -0
- package/lib/types/client/FusionHistory.d.ts +5 -0
- package/lib/types/client/FusionPanelIcon.d.ts +13 -0
- package/lib/types/client/FusionRecovery.d.ts +5 -0
- package/lib/types/client/FusionSettings.d.ts +9 -0
- package/lib/types/client/FusionStatus.d.ts +5 -0
- package/lib/types/client/FusionTool.d.ts +4 -0
- package/lib/types/client/activity-model.d.ts +14 -0
- package/lib/types/client/i18n.d.ts +15 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/settings-events.d.ts +2 -0
- package/lib/types/client/status-polling.d.ts +9 -0
- package/lib/types/context/guard.d.ts +21 -0
- package/lib/types/context/handoff.d.ts +17 -0
- package/lib/types/contracts.d.ts +449 -0
- package/lib/types/digest.d.ts +5 -0
- package/lib/types/dsh-fusion.d.ts +13 -0
- package/lib/types/errors.d.ts +28 -0
- package/lib/types/evidence/gate.d.ts +23 -0
- package/lib/types/evidence/receipts.d.ts +65 -0
- package/lib/types/execution/write-lease.d.ts +42 -0
- package/lib/types/host/bindings.d.ts +30 -0
- package/lib/types/host/cache-defaults.d.ts +48 -0
- package/lib/types/host/cache-policy.d.ts +155 -0
- package/lib/types/host/change-evidence.d.ts +38 -0
- package/lib/types/host/compaction-routing.d.ts +15 -0
- package/lib/types/host/coordinator.d.ts +169 -0
- package/lib/types/host/evidence-page.d.ts +13 -0
- package/lib/types/host/history.d.ts +88 -0
- package/lib/types/host/http.d.ts +4 -0
- package/lib/types/host/model-output.d.ts +13 -0
- package/lib/types/host/native-activity.d.ts +26 -0
- package/lib/types/host/native-approvals.d.ts +29 -0
- package/lib/types/host/native-auxiliary.d.ts +17 -0
- package/lib/types/host/native-budget.d.ts +33 -0
- package/lib/types/host/native-checks.d.ts +104 -0
- package/lib/types/host/native-compaction.d.ts +8 -0
- package/lib/types/host/native-context.d.ts +36 -0
- package/lib/types/host/native-effects.d.ts +75 -0
- package/lib/types/host/native-evidence.d.ts +4 -0
- package/lib/types/host/native-exploration.d.ts +15 -0
- package/lib/types/host/native-keepalive.d.ts +82 -0
- package/lib/types/host/native-model-control.d.ts +99 -0
- package/lib/types/host/native-on-demand-context.d.ts +39 -0
- package/lib/types/host/native-request.d.ts +5 -0
- package/lib/types/host/native-review.d.ts +11 -0
- package/lib/types/host/native-role-sandbox.d.ts +49 -0
- package/lib/types/host/native-scopes.d.ts +49 -0
- package/lib/types/host/native-selection.d.ts +29 -0
- package/lib/types/host/native-task-context.d.ts +39 -0
- package/lib/types/host/native-usage.d.ts +46 -0
- package/lib/types/host/native-worker-brief.d.ts +74 -0
- package/lib/types/host/native-worker-notices.d.ts +15 -0
- package/lib/types/host/native-worker.d.ts +44 -0
- package/lib/types/host/native-workflow.d.ts +41 -0
- package/lib/types/host/route-transport.d.ts +22 -0
- package/lib/types/host/settings.d.ts +39 -0
- package/lib/types/host/shell.d.ts +11 -0
- package/lib/types/host/status.d.ts +7 -0
- package/lib/types/host/subagent-seam.d.ts +10 -0
- package/lib/types/host/tool-table.d.ts +35 -0
- package/lib/types/host/worker-terminal.d.ts +68 -0
- package/lib/types/host/workspace-guard.d.ts +13 -0
- package/lib/types/host/workspace.d.ts +52 -0
- package/lib/types/identity.d.ts +64 -0
- package/lib/types/index.d.ts +34 -0
- package/lib/types/message-source.d.ts +29 -0
- package/lib/types/profile/compactor.d.ts +5 -0
- package/lib/types/profile/keepalive.d.ts +3 -0
- package/lib/types/profile/resolve.d.ts +17 -0
- package/lib/types/prompts.d.ts +8 -0
- package/lib/types/recovery/reconcile.d.ts +28 -0
- package/lib/types/review/dispatch.d.ts +20 -0
- package/lib/types/review/protocol.d.ts +51 -0
- package/lib/types/review/ticket.d.ts +32 -0
- package/lib/types/status.d.ts +88 -0
- package/lib/types/store-migration.d.ts +29 -0
- package/lib/types/task/deletion.d.ts +64 -0
- package/lib/types/task/reducer.d.ts +6 -0
- package/lib/types/task/sqlite-store.d.ts +71 -0
- package/lib/types/task/state.d.ts +51 -0
- package/lib/types/task/status-index.d.ts +28 -0
- package/lib/types/task/store.d.ts +48 -0
- package/lib/types/usage/budget.d.ts +27 -0
- package/lib/types/usage/ledger.d.ts +66 -0
- package/lib/types/usage/normalize.d.ts +50 -0
- package/lib/types/usage/pricing.d.ts +18 -0
- package/package.json +178 -4
- package/profiles/fusion-auto.example.json +45 -0
- package/prompts/compact.md +7 -0
- package/prompts/lead.md +42 -0
- package/prompts/model-lead.md +13 -0
- package/prompts/model-worker.md +5 -0
- package/prompts/worker.md +21 -0
- package/src/activity.ts +23 -0
- package/src/approval/logical.ts +44 -0
- package/src/benchmark/naive.ts +216 -0
- package/src/benchmark/native-run.ts +215 -0
- package/src/benchmark/output-limits.ts +33 -0
- package/src/bilingual.ts +2 -0
- package/src/client/ActivityRows.tsx +59 -0
- package/src/client/FusionCache.tsx +130 -0
- package/src/client/FusionHint.tsx +39 -0
- package/src/client/FusionHistory.tsx +247 -0
- package/src/client/FusionPanelIcon.tsx +39 -0
- package/src/client/FusionRecovery.tsx +69 -0
- package/src/client/FusionSettings.tsx +205 -0
- package/src/client/FusionStatus.tsx +103 -0
- package/src/client/FusionTool.tsx +66 -0
- package/src/client/activity-model.ts +31 -0
- package/src/client/i18n.ts +30 -0
- package/src/client/index.ts +50 -0
- package/src/client/settings-events.ts +3 -0
- package/src/client/status-polling.ts +88 -0
- package/src/context/guard.ts +81 -0
- package/src/context/handoff.ts +28 -0
- package/src/contracts.ts +449 -0
- package/src/digest.ts +28 -0
- package/src/dsh-fusion.ts +321 -0
- package/src/errors.ts +34 -0
- package/src/evidence/gate.ts +290 -0
- package/src/evidence/receipts.ts +175 -0
- package/src/execution/write-lease.ts +187 -0
- package/src/host/bindings.ts +91 -0
- package/src/host/cache-defaults.ts +103 -0
- package/src/host/cache-policy.ts +168 -0
- package/src/host/change-evidence.ts +156 -0
- package/src/host/compaction-routing.ts +46 -0
- package/src/host/coordinator.ts +2160 -0
- package/src/host/evidence-page.ts +25 -0
- package/src/host/history.ts +114 -0
- package/src/host/http.ts +30 -0
- package/src/host/model-output.ts +22 -0
- package/src/host/native-activity.ts +199 -0
- package/src/host/native-approvals.ts +196 -0
- package/src/host/native-auxiliary.ts +19 -0
- package/src/host/native-budget.ts +112 -0
- package/src/host/native-checks.ts +474 -0
- package/src/host/native-compaction.ts +34 -0
- package/src/host/native-context.ts +204 -0
- package/src/host/native-effects.ts +260 -0
- package/src/host/native-evidence.ts +31 -0
- package/src/host/native-exploration.ts +114 -0
- package/src/host/native-keepalive.ts +292 -0
- package/src/host/native-model-control.ts +298 -0
- package/src/host/native-on-demand-context.ts +74 -0
- package/src/host/native-request.ts +12 -0
- package/src/host/native-review.ts +51 -0
- package/src/host/native-role-sandbox.ts +171 -0
- package/src/host/native-scopes.ts +203 -0
- package/src/host/native-selection.ts +110 -0
- package/src/host/native-task-context.ts +264 -0
- package/src/host/native-usage.ts +121 -0
- package/src/host/native-worker-brief.ts +122 -0
- package/src/host/native-worker-notices.ts +72 -0
- package/src/host/native-worker.ts +215 -0
- package/src/host/native-workflow.ts +103 -0
- package/src/host/route-transport.ts +54 -0
- package/src/host/settings.ts +93 -0
- package/src/host/shell.ts +13 -0
- package/src/host/status.ts +112 -0
- package/src/host/subagent-seam.ts +20 -0
- package/src/host/tool-table.ts +56 -0
- package/src/host/worker-terminal.ts +91 -0
- package/src/host/workspace-guard.ts +17 -0
- package/src/host/workspace.ts +184 -0
- package/src/identity.ts +76 -0
- package/src/index.ts +50 -0
- package/src/message-source.ts +31 -0
- package/src/profile/compactor.ts +26 -0
- package/src/profile/keepalive.ts +13 -0
- package/src/profile/resolve.ts +131 -0
- package/src/prompts.ts +32 -0
- package/src/recovery/reconcile.ts +38 -0
- package/src/review/dispatch.ts +48 -0
- package/src/review/protocol.ts +212 -0
- package/src/review/ticket.ts +166 -0
- package/src/status.ts +55 -0
- package/src/store-migration.ts +65 -0
- package/src/task/deletion.ts +105 -0
- package/src/task/reducer.ts +679 -0
- package/src/task/sqlite-store.ts +386 -0
- package/src/task/state.ts +72 -0
- package/src/task/status-index.ts +198 -0
- package/src/task/store.ts +287 -0
- package/src/usage/budget.ts +88 -0
- package/src/usage/ledger.ts +218 -0
- package/src/usage/normalize.ts +308 -0
- package/src/usage/pricing.ts +95 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { bi } from './bilingual.js'
|
|
2
|
+
import { FusionError, TASK_BUSY, TASK_NOT_FOUND } from './errors.js'
|
|
3
|
+
import { DeletionBlocked } from './task/deletion.js'
|
|
4
|
+
import { backfillHistory, readHistory } from './host/history.js'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { isAbsolute, join } from 'node:path'
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import z from '@deepseek-ai/schemastery'
|
|
9
|
+
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
10
|
+
import type { ModelModality } from '@deepseek-ai/dsh-llm'
|
|
11
|
+
import type {} from '@deepseek-ai/dsh-api-session-controller'
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
13
|
+
import type {} from '@deepseek-ai/dsh-commands'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
|
15
|
+
import { FusionCatalogAdapter, FUSION_PROVIDER, FUSION_MODEL, installNativeFusionSelection } from './host/native-selection.js'
|
|
16
|
+
import type { ResolvedProfile } from './profile/resolve.js'
|
|
17
|
+
import { choiceFromProfile, modelProfileFromChoice, validatePairChoice, savedLeadRoute } from './host/settings.js'
|
|
18
|
+
import { CachePolicy, cacheView } from './host/cache-policy.js'
|
|
19
|
+
import { CACHE_DEFAULTS_CHECKED } from './host/cache-defaults.js'
|
|
20
|
+
import { loadJsonObject, resolveProfile } from './profile/resolve.js'
|
|
21
|
+
import { profileRoutes } from './profile/compactor.js'
|
|
22
|
+
import { loadModelPromptBundle, loadPromptBundle } from './prompts.js'
|
|
23
|
+
import { SqliteFusionStore } from './task/sqlite-store.js'
|
|
24
|
+
import { BindingRepository, type SessionBinding } from './host/bindings.js'
|
|
25
|
+
import { FusionCoordinator } from './host/coordinator.js'
|
|
26
|
+
import { authorizeConfiguredPair, NativeRequestBudget } from './host/native-budget.js'
|
|
27
|
+
import { json, readJson, trustedRequest } from './host/http.js'
|
|
28
|
+
import { readFusionStatusAsync } from './host/status.js'
|
|
29
|
+
import { readHistoricalFusionActivity } from './host/native-activity.js'
|
|
30
|
+
import { nativeShellTool } from './host/shell.js'
|
|
31
|
+
import { adoptLegacyStateDirectory } from './store-migration.js'
|
|
32
|
+
import { FUSION_API_PATH, LEGACY_FUSION_API_PATH, LEGACY_PLUGIN_NAME, isFusionProvider } from './identity.js'
|
|
33
|
+
|
|
34
|
+
export * from './index.js'
|
|
35
|
+
|
|
36
|
+
export const name = 'dsh-fusion'
|
|
37
|
+
export const inject = ['agents', 'sessions', 'llm', 'tools', 'subagents', 'systemPrompt', 'webServer', 'sessionController', 'sessionQuery', 'commands', 'agentDefaultModel', 'tokenMeter']
|
|
38
|
+
export interface Config { profilePath?: string; authorizationPath?: string; databasePath?: string; workerTools?: string[] }
|
|
39
|
+
const defaultWorkerTools = [nativeShellTool, 'read', 'write', 'edit', 'glob', 'grep', 'job_output', 'job_kill']
|
|
40
|
+
// Schemastery otherwise normalizes an omitted array to [], bypassing ?? below.
|
|
41
|
+
// An explicitly empty list must remain empty, so the default belongs in the schema.
|
|
42
|
+
export const Config: z<Config> = z.object({ profilePath: z.string(), authorizationPath: z.string(), databasePath: z.string(),
|
|
43
|
+
workerTools: z.array(z.string()).default(defaultWorkerTools) })
|
|
44
|
+
|
|
45
|
+
export async function apply(ctx: Context, config: Config = {}): Promise<void> {
|
|
46
|
+
const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
47
|
+
const database = config.databasePath || join(dshHome, 'plugins', name, 'state.sqlite')
|
|
48
|
+
if (!isAbsolute(database)) throw new Error('Fusion databasePath must be absolute')
|
|
49
|
+
// The plugin was called `dsh-model-fusion` before it was renamed. Its whole
|
|
50
|
+
// durable ledger — tasks, briefs, artifacts, cache settings — lives in that
|
|
51
|
+
// directory, so an installation upgrade must keep reading it rather than
|
|
52
|
+
// start an empty store beside it.
|
|
53
|
+
if (!config.databasePath) adoptLegacyStateDirectory(join(dshHome, 'plugins', LEGACY_PLUGIN_NAME), join(dshHome, 'plugins', name))
|
|
54
|
+
const store = new SqliteFusionStore(database), bindings = new BindingRepository(store)
|
|
55
|
+
const cachePolicy = new CachePolicy(store)
|
|
56
|
+
backfillHistory(store)
|
|
57
|
+
const budget = new NativeRequestBudget(store, config.authorizationPath)
|
|
58
|
+
let defaultProfile: ResolvedProfile | undefined
|
|
59
|
+
const physicalCatalog = async () => {
|
|
60
|
+
const catalog = await ctx.sessionController.modelCatalog()
|
|
61
|
+
return { ...catalog, groups: catalog.groups.filter(group => !isFusionProvider(group.id)) }
|
|
62
|
+
}
|
|
63
|
+
const configuredProfile = async (): Promise<ResolvedProfile> => {
|
|
64
|
+
defaultProfile = undefined
|
|
65
|
+
const saved = store.readDocument('settings:profile')
|
|
66
|
+
if (!saved && (!config.profilePath || !isAbsolute(config.profilePath))) throw new Error(bi('请先在设置的 Fusion 页面选择 Lead 和 Worker', 'Open Settings → Fusion and choose a Lead and a Sidekick first'))
|
|
67
|
+
const raw = saved?.value as Record<string, unknown> | undefined ?? loadJsonObject(config.profilePath!)
|
|
68
|
+
const catalog = await physicalCatalog()
|
|
69
|
+
const pair = validatePairChoice(raw, catalog)
|
|
70
|
+
const next = saved ? modelProfileFromChoice(pair) : raw
|
|
71
|
+
defaultProfile = resolveProfile(next, next.interactionMode === 'model-like' ? loadModelPromptBundle() : loadPromptBundle(), { authorizedRoutes: catalog.groups.flatMap(group =>
|
|
72
|
+
group.models.map(model => ({ provider: group.id, model: model.id }))) })
|
|
73
|
+
return defaultProfile
|
|
74
|
+
}
|
|
75
|
+
const needsBudget = (profile: ResolvedProfile['profile']) => Boolean(config.authorizationPath) || profile.interactionMode !== 'model-like'
|
|
76
|
+
let runtime: FusionCoordinator | undefined
|
|
77
|
+
const effectiveRoutes = (binding: SessionBinding) => profileRoutes({ ...binding.profile,
|
|
78
|
+
lead: runtime?.modelControl.route(binding, 'lead') ?? binding.profile.lead,
|
|
79
|
+
worker: runtime?.modelControl.route(binding, 'worker') ?? binding.profile.worker })
|
|
80
|
+
let creating: Promise<FusionCoordinator> | undefined
|
|
81
|
+
const getRuntime = (frozen?: ResolvedProfile): Promise<FusionCoordinator> => {
|
|
82
|
+
if (runtime) return Promise.resolve(runtime)
|
|
83
|
+
if (creating) return creating
|
|
84
|
+
creating = (async () => {
|
|
85
|
+
const profile = frozen ?? await configuredProfile()
|
|
86
|
+
runtime = new FusionCoordinator(ctx, store, { profile, workerTools: config.workerTools ?? defaultWorkerTools,
|
|
87
|
+
resumeAuthorization: binding => needsBudget(binding.profile) ? budget.check(effectiveRoutes(binding)).authorizationId : 'native-account',
|
|
88
|
+
authorizeRequest: (_agent, binding) => {
|
|
89
|
+
if (!needsBudget(binding.profile)) return
|
|
90
|
+
try { budget.check(effectiveRoutes(binding)) }
|
|
91
|
+
catch (error) {
|
|
92
|
+
const state = store.load(binding.taskId)
|
|
93
|
+
if (state?.control.mode === 'running' && !state.control.budgetBlocked) runtime?.append(binding.taskId, 'budget/blocked', { reason: String(error) })
|
|
94
|
+
throw error
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
reserveRequest: (request, binding) => {
|
|
98
|
+
if (!needsBudget(binding.profile)) return
|
|
99
|
+
try { budget.reserve({ provider: request.provider, model: request.model }, request.maxTokens ?? 0) }
|
|
100
|
+
catch (error) {
|
|
101
|
+
const state = store.load(binding.taskId)
|
|
102
|
+
if (state?.control.mode === 'running' && !state.control.budgetBlocked) runtime?.append(binding.taskId, 'budget/blocked', { reason: String(error) })
|
|
103
|
+
throw error
|
|
104
|
+
}
|
|
105
|
+
} })
|
|
106
|
+
return runtime
|
|
107
|
+
})().finally(() => { creating = undefined })
|
|
108
|
+
return creating
|
|
109
|
+
}
|
|
110
|
+
// The catalog entry admits what the configured Lead admits: the Lead is the
|
|
111
|
+
// only role that reads user content, while the Worker receives text work orders.
|
|
112
|
+
const leadInputModalities = async (): Promise<readonly ModelModality[] | undefined> => {
|
|
113
|
+
try {
|
|
114
|
+
const saved = store.readDocument('settings:profile')?.value
|
|
115
|
+
const lead = savedLeadRoute(saved)
|
|
116
|
+
?? (config.profilePath && isAbsolute(config.profilePath) ? savedLeadRoute(loadJsonObject(config.profilePath)) : undefined)
|
|
117
|
+
if (!lead) return undefined
|
|
118
|
+
return (await ctx.llm.resolveModelInfo(lead.provider, lead.model)).inputModalities
|
|
119
|
+
} catch {
|
|
120
|
+
// An unresolvable Lead route (unregistered provider, retired model, broken
|
|
121
|
+
// profile file) cannot verify image support; the catalog entry stays text-only.
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
ctx.effect(() => ctx.llm.registerAdapter([FUSION_PROVIDER], new FusionCatalogAdapter(leadInputModalities)))
|
|
126
|
+
ctx.effect(() => installNativeFusionSelection(ctx, {
|
|
127
|
+
coordinator: () => runtime, profile: () => defaultProfile,
|
|
128
|
+
selection: agent => {
|
|
129
|
+
const selection = agent.session.snapshotEvents().findLast(event => event.type === 'model/selection')
|
|
130
|
+
if (selection?.type === 'model/selection') return selection.data
|
|
131
|
+
if (bindings.read(agent.id)?.binding.selected) return { provider: FUSION_PROVIDER, model: FUSION_MODEL }
|
|
132
|
+
const header = agent.session.requestHeader()
|
|
133
|
+
return header ? { provider: header.config.provider, model: header.config.model } : ctx.agentDefaultModel.currentSelection()
|
|
134
|
+
},
|
|
135
|
+
}))
|
|
136
|
+
// Frozen bindings must be restored before another user request is allowed.
|
|
137
|
+
const restoreFailure = new Map<string, string>()
|
|
138
|
+
ctx.effect(() => ctx.on('agent/pre-step', async (payload, next) => {
|
|
139
|
+
const binding = bindings.read(payload.agent.id)?.binding
|
|
140
|
+
if (!binding?.selected) return next()
|
|
141
|
+
try { await getRuntime({ profile: binding.profile, prompts: binding.prompts }); if (restoreFailure.has(payload.agent.id)) restoreFailure.delete(payload.agent.id) }
|
|
142
|
+
catch (error) { restoreFailure.set(payload.agent.id, String(error)); return { kind: 'reject' } }
|
|
143
|
+
return next()
|
|
144
|
+
}, { prepend: true }))
|
|
145
|
+
const apiHandler: Parameters<Context['webServer']['register']>[0]['handler'] = async (req, res) => {
|
|
146
|
+
if (!trustedRequest(req)) return json(res, 403, { error: 'forbidden' })
|
|
147
|
+
try {
|
|
148
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
149
|
+
if (req.method === 'GET') {
|
|
150
|
+
if (url.searchParams.get('view') === 'history') return json(res, 200, readHistory(store, url.searchParams.get('cursor') ?? ''))
|
|
151
|
+
if (url.searchParams.get('view') === 'activity') return json(res, 200, await readHistoricalFusionActivity(ctx, store,
|
|
152
|
+
url.searchParams.get('sessionId') ?? '', url.searchParams.get('callId') ?? '', Number(url.searchParams.get('after') ?? -1)))
|
|
153
|
+
if (url.searchParams.get('view') === 'status') {
|
|
154
|
+
const abort = new AbortController(), disconnected = () => abort.abort()
|
|
155
|
+
res.once('close', disconnected)
|
|
156
|
+
try {
|
|
157
|
+
const status = await readFusionStatusAsync(store, url.searchParams.get('sessionId') ?? '', abort.signal)
|
|
158
|
+
if (!res.destroyed) return json(res, 200, status)
|
|
159
|
+
return
|
|
160
|
+
} catch (error) { if (!abort.signal.aborted) throw error; return }
|
|
161
|
+
finally { res.off('close', disconnected) }
|
|
162
|
+
}
|
|
163
|
+
if (url.searchParams.get('view') === 'catalog') return json(res, 200, await ctx.sessionController.modelCatalog())
|
|
164
|
+
if (url.searchParams.get('view') === 'cache') {
|
|
165
|
+
let profile: ResolvedProfile | undefined
|
|
166
|
+
try { profile = await configuredProfile() } catch { profile = undefined }
|
|
167
|
+
return json(res, 200, { checked: CACHE_DEFAULTS_CHECKED, models: cacheView(cachePolicy,
|
|
168
|
+
profile ? { lead: profile.profile.lead, worker: profile.profile.worker } : undefined, profile?.profile.cacheKeepalive) })
|
|
169
|
+
}
|
|
170
|
+
if (url.searchParams.get('view') === 'settings') {
|
|
171
|
+
const saved = store.readDocument('settings:profile')
|
|
172
|
+
let profile: ResolvedProfile | undefined, reason: string | undefined, authorized = false
|
|
173
|
+
try {
|
|
174
|
+
profile = await configuredProfile()
|
|
175
|
+
if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile)); authorized = true
|
|
176
|
+
} catch (error) { reason = error instanceof Error ? error.message : String(error) }
|
|
177
|
+
return json(res, 200, { revision: saved?.revision ?? 0, catalog: await physicalCatalog(),
|
|
178
|
+
pair: profile ? choiceFromProfile(profile.profile) : null,
|
|
179
|
+
authorized, reason, managedAuthorization: Boolean(config.authorizationPath), policy: profile?.profile.interactionMode ?? 'legacy', actualBilledUsd: null, apiEquivalentUsd: null })
|
|
180
|
+
}
|
|
181
|
+
const sessionId = url.searchParams.get('sessionId') ?? ''
|
|
182
|
+
const binding = sessionId && bindings.read(sessionId)?.binding
|
|
183
|
+
let available = false, reason: string | undefined
|
|
184
|
+
try {
|
|
185
|
+
const profile = binding && binding.selected ? { profile: binding.profile, prompts: binding.prompts } : await configuredProfile()
|
|
186
|
+
const controller = await getRuntime(profile)
|
|
187
|
+
controller.transport.assertAvailable()
|
|
188
|
+
if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile))
|
|
189
|
+
available = true
|
|
190
|
+
} catch (error) { reason = error instanceof Error ? error.message : String(error) }
|
|
191
|
+
const state = binding && store.load(binding.taskId)
|
|
192
|
+
return json(res, 200, { selected: Boolean(binding && binding.selected), available, ...(reason ? { reason } : {}),
|
|
193
|
+
...(state ? { task: { id: state.taskId, phase: state.phase, verification: state.verification, workerId: state.acceptedChild } } : {}) })
|
|
194
|
+
}
|
|
195
|
+
if (req.method === 'DELETE') {
|
|
196
|
+
const taskId = url.searchParams.get('taskId')
|
|
197
|
+
if (!taskId) return json(res, 400, { error: bi('缺少任务标识', 'Missing task id') })
|
|
198
|
+
try {
|
|
199
|
+
// The coordinator is the only authority that can see in-process Agents,
|
|
200
|
+
// tool calls and leases. It exists whenever this Host has served the plugin;
|
|
201
|
+
// without one, nothing in this process owns the task.
|
|
202
|
+
store.deleteTask(taskId, { liveReason: id => runtime?.liveWork(id) })
|
|
203
|
+
return json(res, 200, { ok: true, taskId })
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
206
|
+
// Decide on the stored code, not on message text: a translated message would silently become a 500.
|
|
207
|
+
if (error instanceof FusionError && error.code === TASK_NOT_FOUND) return json(res, 404, { error: message })
|
|
208
|
+
if (error instanceof FusionError && error.code === TASK_BUSY) {
|
|
209
|
+
const blocker = error instanceof DeletionBlocked ? error.blocker : undefined
|
|
210
|
+
const detail = error instanceof DeletionBlocked ? error.detail : undefined
|
|
211
|
+
return json(res, 409, { error: message, ...(blocker ? { blocker } : {}), ...(detail ? { detail } : {}) })
|
|
212
|
+
}
|
|
213
|
+
return json(res, 500, { error: message })
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
217
|
+
const body = await readJson(req)
|
|
218
|
+
if (['continue', 'switch-role', 'schedule', 'cancel-schedule'].includes(String(body.action))) {
|
|
219
|
+
if (typeof body.sessionId !== 'string' || typeof body.taskId !== 'string' || !Number.isSafeInteger(body.revision)) throw new Error(bi('恢复参数无效', 'Invalid recovery parameters'))
|
|
220
|
+
const saved = bindings.read(body.sessionId)
|
|
221
|
+
if (!saved?.binding.selected || saved.binding.taskId !== body.taskId || saved.binding.profile.interactionMode !== 'model-like') throw new Error(bi('当前任务不支持此恢复操作', 'This task does not support that recovery action'))
|
|
222
|
+
const binding = saved.binding
|
|
223
|
+
const controller = await getRuntime({ profile: binding.profile, prompts: binding.prompts })
|
|
224
|
+
if (needsBudget(binding.profile)) budget.check(effectiveRoutes(binding))
|
|
225
|
+
if (body.action === 'cancel-schedule') {
|
|
226
|
+
const row = store.readDocument(`model-control:${body.sessionId}`)
|
|
227
|
+
if (row?.revision !== body.revision) throw new Error(bi('状态已变化,请刷新', 'The state changed; refresh'))
|
|
228
|
+
controller.modelControl.cancel(binding)
|
|
229
|
+
} else if (body.action === 'schedule') {
|
|
230
|
+
if (typeof body.dueAt !== 'string') throw new Error(bi('请选择继续时间', 'Choose when to continue'))
|
|
231
|
+
await controller.modelControl.schedule(body.sessionId, body.taskId, Number(body.revision), body.dueAt)
|
|
232
|
+
} else {
|
|
233
|
+
let change: { role: 'lead' | 'worker'; route: typeof binding.profile.lead } | undefined
|
|
234
|
+
if (body.action === 'switch-role') {
|
|
235
|
+
if (body.role !== 'lead' && body.role !== 'worker') throw new Error(bi('请选择要更换的角色', 'Choose the role to switch'))
|
|
236
|
+
const pair = validatePairChoice({ lead: body.route, worker: body.route }, await physicalCatalog())
|
|
237
|
+
change = { role: body.role, route: pair.lead }
|
|
238
|
+
if (needsBudget(binding.profile)) budget.check(profileRoutes({ ...binding.profile,
|
|
239
|
+
lead: controller.modelControl.route(binding, 'lead'), worker: controller.modelControl.route(binding, 'worker'), [change.role]: change.route }))
|
|
240
|
+
}
|
|
241
|
+
await controller.modelControl.continue(body.sessionId, body.taskId, Number(body.revision), change)
|
|
242
|
+
}
|
|
243
|
+
return json(res, 200, { ok: true })
|
|
244
|
+
}
|
|
245
|
+
if (body.action === 'cache-setting') {
|
|
246
|
+
if (typeof body.provider !== 'string' || typeof body.model !== 'string' || !body.provider || !body.model) throw new Error(bi('请选择模型', 'Choose a model'))
|
|
247
|
+
const setting = body.reset === true ? null : {
|
|
248
|
+
...(body.mode === undefined || body.mode === null ? {} : { mode: body.mode as 'auto' | 'on' | 'off' }),
|
|
249
|
+
...(body.intervalSeconds === undefined || body.intervalSeconds === null ? {} : { intervalSeconds: Number(body.intervalSeconds) }) }
|
|
250
|
+
cachePolicy.save(body.provider, body.model, setting)
|
|
251
|
+
return json(res, 200, { ok: true })
|
|
252
|
+
}
|
|
253
|
+
if (body.action === 'configure') {
|
|
254
|
+
if (!Number.isSafeInteger(body.revision) || Number(body.revision) < 0) throw new Error(bi('配置版本无效,请刷新后重试', 'Invalid settings revision; refresh and retry'))
|
|
255
|
+
const pair = validatePairChoice(body.pair, await physicalCatalog())
|
|
256
|
+
const revision = store.writeDocument('settings:profile', Number(body.revision), modelProfileFromChoice(pair))
|
|
257
|
+
const profile = await configuredProfile()
|
|
258
|
+
await getRuntime(profile)
|
|
259
|
+
return json(res, 200, { ok: true, revision })
|
|
260
|
+
}
|
|
261
|
+
if (body.action === 'authorize') {
|
|
262
|
+
if (config.authorizationPath) throw new Error(bi('运行额度由外部授权文件管理,不能在此覆盖', 'Run limits are managed by an external authorization file and cannot be overridden here'))
|
|
263
|
+
const saved = store.readDocument('settings:profile')
|
|
264
|
+
if (!saved || saved.revision !== body.revision) throw new Error(bi('模型组合已变化,请刷新后再启用额度', 'The pair changed; refresh before enabling limits'))
|
|
265
|
+
const profile = await configuredProfile()
|
|
266
|
+
if (store.readDocument('settings:profile')?.revision !== body.revision) throw new Error(bi('模型组合已变化,请刷新后再启用额度', 'The pair changed; refresh before enabling limits'))
|
|
267
|
+
const auth = authorizeConfiguredPair(store, profileRoutes(profile.profile), body.limits)
|
|
268
|
+
return json(res, 200, { ok: true, expiresAt: auth.expiresAt })
|
|
269
|
+
}
|
|
270
|
+
throw new Error(bi('请使用原模型菜单选择 Fusion 或切换普通模型', 'Use the model menu to select Fusion or another model'))
|
|
271
|
+
} catch (error) { return json(res, 409, { error: error instanceof Error ? error.message : String(error) }) }
|
|
272
|
+
}
|
|
273
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: FUSION_API_PATH, handler: apiHandler }))
|
|
274
|
+
// A tab still running the pre-rename bundle asks the old path until it is
|
|
275
|
+
// reloaded. Keep answering it from the same handler; nothing new uses it.
|
|
276
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: LEGACY_FUSION_API_PATH, handler: apiHandler }))
|
|
277
|
+
ctx.effect(() => ctx.commands.register({ name: 'fusion', description: bi('Fusion 状态、暂停、恢复与退出(不调用模型)', 'Fusion status, pause, resume and exit (no model call)'),
|
|
278
|
+
input: { hint: bi('status | pause | resume | recover [--effects-stopped] <检查记录> | off', 'status | pause | resume | recover [--effects-stopped] <inspection note> | off') },
|
|
279
|
+
handler: async invocation => {
|
|
280
|
+
const [action = 'status', ...note] = invocation.rawInput.trim().split(/\s+/)
|
|
281
|
+
try {
|
|
282
|
+
const binding = bindings.read(invocation.agent.id)?.binding
|
|
283
|
+
if (!binding?.selected) return { kind: 'success', text: bi('当前会话未启用 Fusion。请在模型选择器中选择 Fusion · 自动。', 'Fusion is not selected in this conversation. Choose Fusion · auto in the model menu.') }
|
|
284
|
+
if (action === 'status' || action === '') {
|
|
285
|
+
const controller = await getRuntime({ profile: binding.profile, prompts: binding.prompts })
|
|
286
|
+
const state = store.load(binding.taskId)!
|
|
287
|
+
const unfinishedEffects = controller.effects.pending(binding.taskId).map(({ record }) => ({ role: record.role, tool: record.toolName }))
|
|
288
|
+
const usage = store.listDocumentIds(`usage:${state.taskId}:`).map(id => store.readDocument(id)!.value as { purpose?: string })
|
|
289
|
+
return { kind: 'success', text: JSON.stringify({ taskId: state.taskId, phase: state.phase, verification: state.verification,
|
|
290
|
+
control: state.control, workerId: state.acceptedChild, modelCalls: usage.length,
|
|
291
|
+
cacheKeepaliveCalls: usage.filter(row => row.purpose === 'cache-keepalive').length,
|
|
292
|
+
compactionCalls: usage.filter(row => row.purpose === 'compaction').length,
|
|
293
|
+
unfinishedEffects, ...(unfinishedEffects.length ? { recoveryHint: bi('请核对中断的工具操作,确认对应命令及子进程已停止;Host 退出不代表这些操作已结束。', 'Check the interrupted tool operations and confirm their commands and child processes stopped; a Host exit does not end them.') } : {}),
|
|
294
|
+
actualBilledUsd: null, apiEquivalentUsd: null }, null, 2) }
|
|
295
|
+
}
|
|
296
|
+
const controller = await getRuntime({ profile: binding.profile, prompts: binding.prompts })
|
|
297
|
+
if (action === 'pause') { controller.modelControl.cancel(binding); await controller.pause(invocation.agent) }
|
|
298
|
+
else if (action === 'resume') {
|
|
299
|
+
const authorizationId = needsBudget(binding.profile) ? budget.check(effectiveRoutes(binding)).authorizationId : 'native-account'
|
|
300
|
+
if (binding.profile.interactionMode === 'model-like') await controller.modelControl.continue(binding.sessionId, binding.taskId,
|
|
301
|
+
store.readDocument(`model-control:${binding.sessionId}`)?.revision ?? 0)
|
|
302
|
+
else await controller.resume(invocation.agent, authorizationId)
|
|
303
|
+
} else if (action === 'recover') await controller.reconcile(invocation.agent, { commandId: invocation.commandId,
|
|
304
|
+
effectsStopped: note[0] === '--effects-stopped', note: (note[0] === '--effects-stopped' ? note.slice(1) : note).join(' ') })
|
|
305
|
+
else if (action === 'off') {
|
|
306
|
+
await controller.clear(invocation.agent)
|
|
307
|
+
await ctx.sessionController.selectModel({ sessionId: SessionId(invocation.agent.id), ...binding.profile.lead })
|
|
308
|
+
}
|
|
309
|
+
else throw new Error(bi('使用 /fusion status、pause、resume、recover <检查记录> 或 off', 'Use /fusion status, pause, resume, recover <inspection note> or off'))
|
|
310
|
+
return { kind: 'success', text: action === 'resume' ? binding.profile.interactionMode === 'model-like' ? bi('已通过原生收件箱提交一次继续操作。', 'One continuation was submitted through the native inbox.') : bi('Fusion 已恢复。发送下一条消息继续当前任务。', 'Fusion resumed. Send a message to continue the task.') : bi(`Fusion ${action} 已完成。`, `Fusion ${action} done.`) }
|
|
311
|
+
} catch (error) { return { kind: 'error', text: error instanceof Error ? error.message : String(error) } }
|
|
312
|
+
} }))
|
|
313
|
+
ctx.effect(() => async () => { if (creating) await creating.catch(() => undefined); if (runtime) await runtime.close(); store.close() })
|
|
314
|
+
// Warm only configuration/scopes; initialization performs no inference.
|
|
315
|
+
try { await getRuntime(await configuredProfile()) }
|
|
316
|
+
catch {
|
|
317
|
+
const selected = bindings.selected()[0]
|
|
318
|
+
if (selected) await getRuntime({ profile: selected.profile, prompts: selected.prompts })
|
|
319
|
+
}
|
|
320
|
+
ctx.logger.info('[my-plugins/dsh-fusion] loaded')
|
|
321
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export class FusionError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
readonly code: string,
|
|
4
|
+
message: string,
|
|
5
|
+
) {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = 'FusionError'
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const FUSION_CONTEXT_BUDGET = 'FUSION_CONTEXT_BUDGET'
|
|
12
|
+
export const REVISION_CONFLICT = 'REVISION_CONFLICT'
|
|
13
|
+
export const REVISION_STALE = 'REVISION_STALE'
|
|
14
|
+
export const EVENT_CONFLICT = 'EVENT_CONFLICT'
|
|
15
|
+
export const FLUSH_FAILED = 'FLUSH_FAILED'
|
|
16
|
+
export const CHECKSUM_MISMATCH = 'CHECKSUM_MISMATCH'
|
|
17
|
+
export const LEASE_BUSY = 'LEASE_BUSY'
|
|
18
|
+
export const LEASE_GENERATION = 'LEASE_GENERATION'
|
|
19
|
+
export const PROFILE_UNAVAILABLE = 'PROFILE_UNAVAILABLE'
|
|
20
|
+
export const SPEND_UNAUTHORIZED = 'SPEND_UNAUTHORIZED'
|
|
21
|
+
export const APPROVAL_SCOPE = 'APPROVAL_SCOPE'
|
|
22
|
+
export const OUTCOME_UNKNOWN = 'OUTCOME_UNKNOWN'
|
|
23
|
+
export const NEEDS_REPAIR = 'NEEDS_REPAIR'
|
|
24
|
+
export const USAGE_MERGE_CONFLICT = 'USAGE_MERGE_CONFLICT'
|
|
25
|
+
export const USAGE_LEDGER_REQUIRED = 'USAGE_LEDGER_REQUIRED'
|
|
26
|
+
export const CONTROL_BLOCKED = 'CONTROL_BLOCKED'
|
|
27
|
+
export const REVIEW_RESULT_CONFLICT = 'REVIEW_RESULT_CONFLICT'
|
|
28
|
+
export const REVIEW_TICKET_REQUIRED = 'REVIEW_TICKET_REQUIRED'
|
|
29
|
+
export const WORK_ORDER_CONFLICT = 'WORK_ORDER_CONFLICT'
|
|
30
|
+
export const STORE_MIGRATION_REQUIRED = 'STORE_MIGRATION_REQUIRED'
|
|
31
|
+
/** No task carries this id (or it was already deleted). */
|
|
32
|
+
export const TASK_NOT_FOUND = 'TASK_NOT_FOUND'
|
|
33
|
+
/** Deleting it would strand a live Worker, lease or unfinished handoff. */
|
|
34
|
+
export const TASK_BUSY = 'TASK_BUSY'
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AcceptanceCriterion,
|
|
3
|
+
Digest,
|
|
4
|
+
SnapshotId,
|
|
5
|
+
TaskId,
|
|
6
|
+
ToolEvidence,
|
|
7
|
+
VerificationLevel,
|
|
8
|
+
WorkOrder,
|
|
9
|
+
WorkerReport,
|
|
10
|
+
} from '../contracts.js'
|
|
11
|
+
import { sha256Hex } from '../digest.js'
|
|
12
|
+
import {
|
|
13
|
+
artifactFingerprint,
|
|
14
|
+
assertPlannedCheck,
|
|
15
|
+
checkPlanDigest,
|
|
16
|
+
invocationHeader,
|
|
17
|
+
sameInvocationHeader,
|
|
18
|
+
type VerificationRegistry,
|
|
19
|
+
} from './receipts.js'
|
|
20
|
+
|
|
21
|
+
export interface EvidenceVerdict {
|
|
22
|
+
readonly verification: VerificationLevel
|
|
23
|
+
readonly reasons: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TrustedEvidenceRegistry =
|
|
27
|
+
| readonly ToolEvidence[]
|
|
28
|
+
| Readonly<Record<string, ToolEvidence>>
|
|
29
|
+
| ReadonlyMap<string, ToolEvidence>
|
|
30
|
+
|
|
31
|
+
export interface RegisteredVerifier {
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly kind: AcceptanceCriterion['verificationKind']
|
|
34
|
+
readonly argvDigests?: readonly Digest[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface EvaluateReportOptions {
|
|
38
|
+
readonly verifiers?: readonly RegisteredVerifier[]
|
|
39
|
+
readonly registry?: VerificationRegistry
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_TEST_VERIFIERS: readonly RegisteredVerifier[] = [
|
|
43
|
+
{ id: 'pytest', kind: 'test', argvDigests: [sha256Hex('python -m pytest')] },
|
|
44
|
+
{ id: 'npm-test', kind: 'test', argvDigests: [sha256Hex('npm test')] },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
interface EvidenceIndex {
|
|
48
|
+
readonly byId: ReadonlyMap<string, ToolEvidence>
|
|
49
|
+
readonly ambiguous: ReadonlySet<string>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sameIdentity(left: ToolEvidence, right: ToolEvidence): boolean {
|
|
53
|
+
return left.operationId === right.operationId
|
|
54
|
+
&& left.taskId === right.taskId
|
|
55
|
+
&& left.nativeToolCallId === right.nativeToolCallId
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sameContent(left: ToolEvidence, right: ToolEvidence): boolean {
|
|
59
|
+
return sameIdentity(left, right)
|
|
60
|
+
&& sameInvocationHeader(invocationHeader(left), invocationHeader(right))
|
|
61
|
+
&& left.argvDigest === right.argvDigest
|
|
62
|
+
&& left.cwdDigest === right.cwdDigest
|
|
63
|
+
&& left.actor === right.actor
|
|
64
|
+
&& left.state === right.state
|
|
65
|
+
&& left.exitCode === right.exitCode
|
|
66
|
+
&& left.inputSnapshot === right.inputSnapshot
|
|
67
|
+
&& left.outputSnapshot === right.outputSnapshot
|
|
68
|
+
&& left.endedAt === right.endedAt
|
|
69
|
+
&& left.startedAt === right.startedAt
|
|
70
|
+
&& artifactFingerprint(left.stdout) === artifactFingerprint(right.stdout)
|
|
71
|
+
&& artifactFingerprint(left.stderr) === artifactFingerprint(right.stderr)
|
|
72
|
+
&& (left.kind ?? 'tool') === (right.kind ?? 'tool')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isLifecycleUpgrade(existing: ToolEvidence, incoming: ToolEvidence): boolean {
|
|
76
|
+
return sameIdentity(existing, incoming)
|
|
77
|
+
&& sameInvocationHeader(invocationHeader(existing), invocationHeader(incoming))
|
|
78
|
+
&& existing.state === 'started'
|
|
79
|
+
&& incoming.state === 'completed'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addAlias(byId: Map<string, ToolEvidence>, ambiguous: Set<string>, id: string, item: ToolEvidence): void {
|
|
83
|
+
const existing = byId.get(id)
|
|
84
|
+
if (!existing) {
|
|
85
|
+
byId.set(id, item)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (sameContent(existing, item)) return
|
|
89
|
+
if (isLifecycleUpgrade(existing, item)) {
|
|
90
|
+
byId.set(id, item)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (isLifecycleUpgrade(item, existing)) return
|
|
94
|
+
ambiguous.add(id)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function indexRegistry(registry: TrustedEvidenceRegistry): EvidenceIndex {
|
|
98
|
+
const byId = new Map<string, ToolEvidence>()
|
|
99
|
+
const ambiguous = new Set<string>()
|
|
100
|
+
|
|
101
|
+
const add = (item: ToolEvidence, explicitId?: string) => {
|
|
102
|
+
if (explicitId) addAlias(byId, ambiguous, explicitId, item)
|
|
103
|
+
addAlias(byId, ambiguous, item.operationId, item)
|
|
104
|
+
addAlias(byId, ambiguous, item.stdout.id, item)
|
|
105
|
+
addAlias(byId, ambiguous, item.stderr.id, item)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (registry instanceof Map) {
|
|
109
|
+
for (const [id, item] of registry) add(item, id)
|
|
110
|
+
} else if (Array.isArray(registry)) {
|
|
111
|
+
for (const item of registry) add(item)
|
|
112
|
+
} else {
|
|
113
|
+
for (const [id, item] of Object.entries(registry)) add(item, id)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { byId, ambiguous }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function eligibilityFailure(item: ToolEvidence, taskId: TaskId, currentSnapshot: SnapshotId): string | undefined {
|
|
120
|
+
if (item.taskId !== taskId) return `evidence ${item.operationId} belongs to task ${item.taskId}, not ${taskId}`
|
|
121
|
+
if (item.state !== 'completed') return `evidence ${item.operationId} is ${item.state}, not completed`
|
|
122
|
+
if (item.endedAt === null) return `evidence ${item.operationId} has no endedAt`
|
|
123
|
+
if (item.exitCode !== 0) return `evidence ${item.operationId} exited ${item.exitCode}`
|
|
124
|
+
if (item.inputSnapshot !== currentSnapshot) {
|
|
125
|
+
return `evidence ${item.operationId} input snapshot ${item.inputSnapshot} is not current ${currentSnapshot}`
|
|
126
|
+
}
|
|
127
|
+
if (item.outputSnapshot !== currentSnapshot) {
|
|
128
|
+
return `evidence ${item.operationId} output snapshot ${item.outputSnapshot ?? 'missing'} is not current ${currentSnapshot}`
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function plannedCheckFailure(
|
|
134
|
+
workOrder: WorkOrder,
|
|
135
|
+
criterion: AcceptanceCriterion,
|
|
136
|
+
evidenceId: string,
|
|
137
|
+
registry: VerificationRegistry | undefined,
|
|
138
|
+
actualSnapshot: SnapshotId,
|
|
139
|
+
): string | undefined {
|
|
140
|
+
if (criterion.verificationKind === 'human' || criterion.verificationKind === 'review') return undefined
|
|
141
|
+
if (!registry || !criterion.planId || !criterion.planDigest) {
|
|
142
|
+
return `criterion ${criterion.id} has no frozen CheckPlan; allowedPaths are not a verification cwd`
|
|
143
|
+
}
|
|
144
|
+
const plan = registry.getPlan(criterion.planId)
|
|
145
|
+
if (!plan) return `criterion ${criterion.id} cites unknown plan ${criterion.planId}`
|
|
146
|
+
if (criterion.planDigest !== checkPlanDigest(plan)) {
|
|
147
|
+
return `criterion ${criterion.id} plan digest does not match the frozen work-order plan`
|
|
148
|
+
}
|
|
149
|
+
const receipt = registry.getReceipt(evidenceId)
|
|
150
|
+
if (!receipt) {
|
|
151
|
+
return `criterion ${criterion.id} cites legacy evidence ${evidenceId} without a planned receipt`
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
assertPlannedCheck(workOrder, criterion.id, plan, receipt, actualSnapshot)
|
|
155
|
+
return undefined
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return `criterion ${criterion.id}: ${(error as Error).message}`
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function relevanceFailure(
|
|
162
|
+
criterion: AcceptanceCriterion,
|
|
163
|
+
record: ToolEvidence,
|
|
164
|
+
verifiers: readonly RegisteredVerifier[],
|
|
165
|
+
): string | undefined {
|
|
166
|
+
const kind = record.kind ?? 'tool'
|
|
167
|
+
if (criterion.verificationKind === 'human') {
|
|
168
|
+
if (kind !== 'human-decision') {
|
|
169
|
+
return `human criterion ${criterion.id} cannot be fulfilled by a tool exit`
|
|
170
|
+
}
|
|
171
|
+
return undefined
|
|
172
|
+
}
|
|
173
|
+
if (criterion.verificationKind === 'review') {
|
|
174
|
+
if (kind !== 'review-accept') {
|
|
175
|
+
return `review criterion ${criterion.id} needs an accepting review record`
|
|
176
|
+
}
|
|
177
|
+
return undefined
|
|
178
|
+
}
|
|
179
|
+
const allowed = verifiers.filter(verifier => {
|
|
180
|
+
if (verifier.kind !== criterion.verificationKind) return false
|
|
181
|
+
if (criterion.verifierId && verifier.id !== criterion.verifierId) return false
|
|
182
|
+
return true
|
|
183
|
+
})
|
|
184
|
+
if (!allowed.some(verifier => verifier.argvDigests?.includes(record.argvDigest))) {
|
|
185
|
+
return `criterion ${criterion.id} evidence argv is not a registered ${criterion.verificationKind} verifier`
|
|
186
|
+
}
|
|
187
|
+
return undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Score only the current work order's mandatory criteria and the evidence those
|
|
192
|
+
* claims actually cite. Historical failures stay in the registry for audit.
|
|
193
|
+
* Authenticity and relevance are independent checks.
|
|
194
|
+
*/
|
|
195
|
+
export function evaluateReport(
|
|
196
|
+
workOrder: WorkOrder,
|
|
197
|
+
report: WorkerReport,
|
|
198
|
+
trustedRegistry: TrustedEvidenceRegistry,
|
|
199
|
+
currentSnapshot: SnapshotId,
|
|
200
|
+
options?: EvaluateReportOptions,
|
|
201
|
+
): EvidenceVerdict {
|
|
202
|
+
const trust: string[] = []
|
|
203
|
+
if (report.workOrderId !== workOrder.id) {
|
|
204
|
+
trust.push(`report workOrderId ${report.workOrderId} does not match frozen work order ${workOrder.id}`)
|
|
205
|
+
}
|
|
206
|
+
if (report.revision !== workOrder.revision) {
|
|
207
|
+
trust.push(`report revision ${report.revision} does not match frozen work order revision ${workOrder.revision}`)
|
|
208
|
+
}
|
|
209
|
+
if (report.snapshot !== currentSnapshot) {
|
|
210
|
+
trust.push('snapshot drift: report snapshot is not the current workspace snapshot')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const acceptanceById = new Map(workOrder.acceptance.map(criterion => [criterion.id, criterion]))
|
|
214
|
+
const seenClaims = new Set<string>()
|
|
215
|
+
const index = indexRegistry(trustedRegistry)
|
|
216
|
+
const verifiers = options?.verifiers ?? DEFAULT_TEST_VERIFIERS
|
|
217
|
+
const satisfied = new Set<string>()
|
|
218
|
+
|
|
219
|
+
for (const claim of report.coverage) {
|
|
220
|
+
if (seenClaims.has(claim.criterionId)) {
|
|
221
|
+
trust.push(`duplicate criterion ${claim.criterionId}`)
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
seenClaims.add(claim.criterionId)
|
|
225
|
+
if (!acceptanceById.has(claim.criterionId)) {
|
|
226
|
+
trust.push(`unknown criterion ${claim.criterionId}`)
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
if (claim.state !== 'satisfied') continue
|
|
230
|
+
if (claim.evidenceIds.length === 0) {
|
|
231
|
+
trust.push(`criterion ${claim.criterionId} claimed satisfied without evidence`)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
const criterion = acceptanceById.get(claim.criterionId)!
|
|
235
|
+
const cited = new Set<string>()
|
|
236
|
+
let claimOk = true
|
|
237
|
+
for (const evidenceId of claim.evidenceIds) {
|
|
238
|
+
if (cited.has(evidenceId)) continue
|
|
239
|
+
cited.add(evidenceId)
|
|
240
|
+
if (index.ambiguous.has(evidenceId)) {
|
|
241
|
+
trust.push(`criterion ${claim.criterionId} cites ambiguous evidence id ${evidenceId}`)
|
|
242
|
+
claimOk = false
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if (criterion.verificationKind === 'test' || criterion.verificationKind === 'static-check') {
|
|
246
|
+
const planned = plannedCheckFailure(workOrder, criterion, evidenceId, options?.registry, currentSnapshot)
|
|
247
|
+
if (planned) {
|
|
248
|
+
trust.push(planned.startsWith('criterion ') ? planned : `criterion ${claim.criterionId}: ${planned}`)
|
|
249
|
+
claimOk = false
|
|
250
|
+
}
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
const record = index.byId.get(evidenceId)
|
|
254
|
+
if (!record) {
|
|
255
|
+
trust.push(`criterion ${claim.criterionId} cites unknown evidence id ${evidenceId}`)
|
|
256
|
+
claimOk = false
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
const why = eligibilityFailure(record, workOrder.taskId, currentSnapshot)
|
|
260
|
+
?? relevanceFailure(criterion, record, verifiers)
|
|
261
|
+
if (why) {
|
|
262
|
+
trust.push(`criterion ${claim.criterionId}: ${why}`)
|
|
263
|
+
claimOk = false
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (claimOk) satisfied.add(claim.criterionId)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (trust.length) {
|
|
270
|
+
return { verification: 'unverified', reasons: trust }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const coverage: string[] = []
|
|
274
|
+
if (report.status !== 'completed') {
|
|
275
|
+
coverage.push(`report status is ${report.status}`)
|
|
276
|
+
}
|
|
277
|
+
if (report.unresolved.length) {
|
|
278
|
+
coverage.push(`${report.unresolved.length} unresolved item(s) remain`)
|
|
279
|
+
}
|
|
280
|
+
for (const criterion of workOrder.acceptance) {
|
|
281
|
+
if (!criterion.mandatory) continue
|
|
282
|
+
if (!satisfied.has(criterion.id)) {
|
|
283
|
+
coverage.push(`mandatory criterion ${criterion.id} is not satisfied by current trusted evidence`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (coverage.length) {
|
|
287
|
+
return { verification: 'partial', reasons: coverage }
|
|
288
|
+
}
|
|
289
|
+
return { verification: 'verified', reasons: [] }
|
|
290
|
+
}
|