@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,131 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import type { Digest, PairProfile, PhysicalRoute, RoleContextPolicy } from '../contracts.js'
|
|
3
|
+
import { digestOf, sha256Hex } from '../digest.js'
|
|
4
|
+
import { FusionError, PROFILE_UNAVAILABLE } from '../errors.js'
|
|
5
|
+
import { keepaliveChoice } from './keepalive.js'
|
|
6
|
+
import { compactorChoice, profileRoutes } from './compactor.js'
|
|
7
|
+
|
|
8
|
+
const PLACEHOLDERS = new Set(['__AUTHORIZED_PROVIDER__', '__EXACT_MODEL_ID__', '__EXACT_WORKER_ID__', '__EXISTING_PROJECT_POLICY__'])
|
|
9
|
+
|
|
10
|
+
export interface PromptBundle {
|
|
11
|
+
lead: string
|
|
12
|
+
worker: string
|
|
13
|
+
compact: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ResolvedProfile {
|
|
17
|
+
readonly profile: PairProfile
|
|
18
|
+
readonly prompts: PromptBundle
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProfileCatalog {
|
|
22
|
+
readonly authorizedRoutes: readonly PhysicalRoute[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isPlaceholder(route: PhysicalRoute): boolean {
|
|
26
|
+
return PLACEHOLDERS.has(route.provider) || PLACEHOLDERS.has(route.model)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function asPolicy(raw: RoleContextPolicy | undefined, fallback: RoleContextPolicy): RoleContextPolicy {
|
|
30
|
+
const policy = {
|
|
31
|
+
targetInputTokens: raw?.targetInputTokens ?? fallback.targetInputTokens,
|
|
32
|
+
reserveOutputTokens: raw?.reserveOutputTokens ?? fallback.reserveOutputTokens,
|
|
33
|
+
minSafetyTokens: raw?.minSafetyTokens ?? fallback.minSafetyTokens,
|
|
34
|
+
safetyFraction: raw?.safetyFraction ?? fallback.safetyFraction,
|
|
35
|
+
compactToFraction: raw?.compactToFraction ?? fallback.compactToFraction,
|
|
36
|
+
evidenceReadTokens: raw?.evidenceReadTokens ?? fallback.evidenceReadTokens,
|
|
37
|
+
maxToolVisibleTokens: raw?.maxToolVisibleTokens ?? fallback.maxToolVisibleTokens,
|
|
38
|
+
}
|
|
39
|
+
for (const key of ['targetInputTokens', 'reserveOutputTokens', 'evidenceReadTokens', 'maxToolVisibleTokens'] as const) {
|
|
40
|
+
if (!Number.isSafeInteger(policy[key]) || policy[key] <= 0) throw new FusionError(PROFILE_UNAVAILABLE, `Invalid context policy: ${key}`)
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isSafeInteger(policy.minSafetyTokens) || policy.minSafetyTokens < 0
|
|
43
|
+
|| !Number.isFinite(policy.safetyFraction) || policy.safetyFraction < 0 || policy.safetyFraction >= 1
|
|
44
|
+
|| !Number.isFinite(policy.compactToFraction) || policy.compactToFraction <= 0 || policy.compactToFraction >= 1) {
|
|
45
|
+
throw new FusionError(PROFILE_UNAVAILABLE, 'Invalid context safety or compaction policy')
|
|
46
|
+
}
|
|
47
|
+
return policy
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const LEAD_DEFAULT: RoleContextPolicy = {
|
|
51
|
+
// The final guard always intersects this soft target with the registered
|
|
52
|
+
// physical model window, output reservation and safety margin. A fixed 32k
|
|
53
|
+
// default can reject the real Host's system prompt/tools before its first
|
|
54
|
+
// request; do not impose an undocumented smaller window by default.
|
|
55
|
+
targetInputTokens: Number.MAX_SAFE_INTEGER,
|
|
56
|
+
reserveOutputTokens: 8_000,
|
|
57
|
+
minSafetyTokens: 2_048,
|
|
58
|
+
safetyFraction: 0.05,
|
|
59
|
+
compactToFraction: 0.5,
|
|
60
|
+
evidenceReadTokens: 4_000,
|
|
61
|
+
maxToolVisibleTokens: 6_000,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const WORKER_DEFAULT: RoleContextPolicy = {
|
|
65
|
+
...LEAD_DEFAULT,
|
|
66
|
+
compactToFraction: 0.55,
|
|
67
|
+
evidenceReadTokens: 8_000,
|
|
68
|
+
maxToolVisibleTokens: 8_000,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function promptDigests(prompts: PromptBundle): PairProfile['promptDigests'] {
|
|
72
|
+
return {
|
|
73
|
+
lead: sha256Hex(prompts.lead),
|
|
74
|
+
worker: sha256Hex(prompts.worker),
|
|
75
|
+
compact: sha256Hex(prompts.compact),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function completeProfile(raw: Record<string, unknown>, prompts: PromptBundle): PairProfile {
|
|
80
|
+
const lead = raw.lead as PhysicalRoute
|
|
81
|
+
const worker = raw.worker as PhysicalRoute
|
|
82
|
+
const context = (raw.context ?? {}) as Partial<PairProfile['context']>
|
|
83
|
+
const promptHash = promptDigests(prompts)
|
|
84
|
+
const cacheKeepalive = keepaliveChoice(raw.cacheKeepalive)
|
|
85
|
+
const compactor = compactorChoice(raw.compactor)
|
|
86
|
+
if (raw.interactionMode !== undefined && raw.interactionMode !== 'model-like') throw new Error('Unknown Fusion interaction mode')
|
|
87
|
+
if (raw.workflowPolicy !== undefined && (!['enforced-v1', 'enforced-v2', 'enforced-v3'].includes(String(raw.workflowPolicy)) || raw.interactionMode !== 'model-like')) throw new Error('Unknown Fusion workflow policy')
|
|
88
|
+
const profile: PairProfile = {
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
id: String(raw.id),
|
|
91
|
+
version: String(raw.version),
|
|
92
|
+
digest: '' as Digest,
|
|
93
|
+
enabled: raw.enabled === true,
|
|
94
|
+
quality: (raw.quality as PairProfile['quality']) ?? 'experimental',
|
|
95
|
+
...(raw.interactionMode === 'model-like' ? { interactionMode: 'model-like' as const } : {}),
|
|
96
|
+
...(raw.workflowPolicy === 'enforced-v1' || raw.workflowPolicy === 'enforced-v2' || raw.workflowPolicy === 'enforced-v3' ? { workflowPolicy: raw.workflowPolicy } : {}),
|
|
97
|
+
lead,
|
|
98
|
+
worker,
|
|
99
|
+
workerUpgradePath: (raw.workerUpgradePath as PhysicalRoute[]) ?? [],
|
|
100
|
+
promptDigests: promptHash,
|
|
101
|
+
context: {
|
|
102
|
+
lead: asPolicy(context.lead, LEAD_DEFAULT),
|
|
103
|
+
worker: asPolicy(context.worker, WORKER_DEFAULT),
|
|
104
|
+
},
|
|
105
|
+
dataPolicyId: String(raw.dataPolicyId ?? ''),
|
|
106
|
+
evidenceCampaignIds: (raw.evidenceCampaignIds as string[]) ?? [],
|
|
107
|
+
...(cacheKeepalive === undefined ? {} : { cacheKeepalive }),
|
|
108
|
+
...(compactor === undefined ? {} : { compactor }),
|
|
109
|
+
}
|
|
110
|
+
return { ...profile, digest: digestOf(profile) }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resolveProfile(raw: Record<string, unknown>, prompts: PromptBundle, catalog: ProfileCatalog): ResolvedProfile {
|
|
114
|
+
const profile = completeProfile(raw, prompts)
|
|
115
|
+
if (!profile.enabled) {
|
|
116
|
+
throw new FusionError(PROFILE_UNAVAILABLE, `profile ${profile.id} is disabled`)
|
|
117
|
+
}
|
|
118
|
+
if (profileRoutes(profile).some(isPlaceholder) || PLACEHOLDERS.has(profile.dataPolicyId)) {
|
|
119
|
+
throw new FusionError(PROFILE_UNAVAILABLE, `profile ${profile.id} still has placeholder routes`)
|
|
120
|
+
}
|
|
121
|
+
const authorized = (route: PhysicalRoute) =>
|
|
122
|
+
catalog.authorizedRoutes.some(item => item.provider === route.provider && item.model === route.model)
|
|
123
|
+
if (!profileRoutes(profile).every(authorized)) {
|
|
124
|
+
throw new FusionError(PROFILE_UNAVAILABLE, `profile ${profile.id} routes are not authorized on this Host`)
|
|
125
|
+
}
|
|
126
|
+
return { profile, prompts }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function loadJsonObject(path: string): Record<string, unknown> {
|
|
130
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
|
|
131
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import type { PromptBundle } from './profile/resolve.js'
|
|
5
|
+
import { promptDigests } from './profile/resolve.js'
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
|
|
9
|
+
export function defaultPromptDir(): string {
|
|
10
|
+
return join(here, '..', 'prompts')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function loadPromptBundle(dir = defaultPromptDir()): PromptBundle {
|
|
14
|
+
return {
|
|
15
|
+
lead: readFileSync(join(dir, 'lead.md'), 'utf8'),
|
|
16
|
+
worker: readFileSync(join(dir, 'worker.md'), 'utf8'),
|
|
17
|
+
compact: readFileSync(join(dir, 'compact.md'), 'utf8'),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadModelPromptBundle(dir = defaultPromptDir()): PromptBundle {
|
|
22
|
+
return {
|
|
23
|
+
lead: readFileSync(join(dir, 'model-lead.md'), 'utf8'),
|
|
24
|
+
worker: readFileSync(join(dir, 'model-worker.md'), 'utf8'),
|
|
25
|
+
compact: readFileSync(join(dir, 'compact.md'), 'utf8'),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function promptManifest(dir = defaultPromptDir()) {
|
|
30
|
+
const prompts = loadPromptBundle(dir)
|
|
31
|
+
return { prompts, digests: promptDigests(prompts) }
|
|
32
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { OutboxRow, SessionId } from '../contracts.js'
|
|
2
|
+
import { NEEDS_REPAIR, OUTCOME_UNKNOWN } from '../errors.js'
|
|
3
|
+
|
|
4
|
+
export type ChildPersistence = 'present' | 'missing' | 'corrupt'
|
|
5
|
+
|
|
6
|
+
export interface RecoveryScan {
|
|
7
|
+
readonly outbox: OutboxRow
|
|
8
|
+
readonly childPersistence: ChildPersistence
|
|
9
|
+
readonly effectKnown: boolean | null
|
|
10
|
+
readonly reportAlreadyRecorded: boolean
|
|
11
|
+
readonly parentDelivered: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RecoveryDecision =
|
|
15
|
+
| { action: 'retry-prepare'; child: SessionId }
|
|
16
|
+
| { action: 'resume-child'; child: SessionId }
|
|
17
|
+
| { action: 'return-stored-result' }
|
|
18
|
+
| { action: 'needs-repair'; code: typeof NEEDS_REPAIR }
|
|
19
|
+
| { action: 'outcome-unknown'; code: typeof OUTCOME_UNKNOWN }
|
|
20
|
+
| { action: 'idle' }
|
|
21
|
+
|
|
22
|
+
export function reconcile(scan: RecoveryScan): RecoveryDecision {
|
|
23
|
+
if (scan.reportAlreadyRecorded && !scan.parentDelivered) return { action: 'return-stored-result' }
|
|
24
|
+
if (scan.reportAlreadyRecorded && scan.parentDelivered) return { action: 'idle' }
|
|
25
|
+
if (scan.effectKnown === null && (scan.outbox.state === 'dispatched' || scan.outbox.state === 'claimed')) {
|
|
26
|
+
return { action: 'outcome-unknown', code: OUTCOME_UNKNOWN }
|
|
27
|
+
}
|
|
28
|
+
if (scan.outbox.state === 'prepared' && scan.childPersistence === 'missing' && scan.outbox.reservedChild) {
|
|
29
|
+
return { action: 'retry-prepare', child: scan.outbox.reservedChild }
|
|
30
|
+
}
|
|
31
|
+
if (scan.outbox.state === 'accepted' && scan.childPersistence !== 'present') {
|
|
32
|
+
return { action: 'needs-repair', code: NEEDS_REPAIR }
|
|
33
|
+
}
|
|
34
|
+
if ((scan.outbox.state === 'accepted' || scan.outbox.state === 'claimed') && scan.outbox.reservedChild) {
|
|
35
|
+
return { action: 'resume-child', child: scan.outbox.reservedChild }
|
|
36
|
+
}
|
|
37
|
+
return { action: 'idle' }
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { FusionEvent, ReviewTicket, TaskId } from '../contracts.js'
|
|
2
|
+
import { EVENT_CONFLICT, FusionError } from '../errors.js'
|
|
3
|
+
import type { ReviewClassification } from './protocol.js'
|
|
4
|
+
import { captureTicket, createReviewTicket, resultFromCapturedTicket, reviewOutboxRow, reviewRequestedEvent } from './ticket.js'
|
|
5
|
+
import type { FusionStore } from '../task/store.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Offline producer: persist the captured ticket and outbox row before any adapter send.
|
|
9
|
+
* The model callback may read the latest TaskState only for seq/CAS.
|
|
10
|
+
*/
|
|
11
|
+
export function persistReviewRequest(
|
|
12
|
+
store: FusionStore,
|
|
13
|
+
taskId: TaskId,
|
|
14
|
+
ids: { ticketId: string; requestId: string },
|
|
15
|
+
payloadRef: string,
|
|
16
|
+
): Readonly<ReviewTicket> {
|
|
17
|
+
const current = store.load(taskId)
|
|
18
|
+
if (!current) throw new FusionError(EVENT_CONFLICT, `unknown task ${taskId}`)
|
|
19
|
+
const ticket = captureTicket(createReviewTicket(current, ids))
|
|
20
|
+
const requested = reviewRequestedEvent(current, current.seq + 1, ids)
|
|
21
|
+
store.transact(taskId, current.seq, persisted => ({
|
|
22
|
+
events: [requested],
|
|
23
|
+
outbox: [...persisted.outbox, reviewOutboxRow(ticket, payloadRef)],
|
|
24
|
+
}))
|
|
25
|
+
return ticket
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function loadReviewOutboxByRequestId(store: FusionStore, taskId: TaskId, requestId: string) {
|
|
29
|
+
const row = store.outbox(taskId).find(item => item.requestId === requestId || item.ticket?.requestId === requestId)
|
|
30
|
+
if (!row?.ticket) throw new FusionError(EVENT_CONFLICT, `review outbox missing for ${requestId}`)
|
|
31
|
+
return { row, ticket: captureTicket(row.ticket) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function appendCapturedReviewResult(
|
|
35
|
+
store: FusionStore,
|
|
36
|
+
taskId: TaskId,
|
|
37
|
+
ticket: Readonly<ReviewTicket>,
|
|
38
|
+
outcome: { requestId: string; terminalEvidenceRef: string; classification: ReviewClassification },
|
|
39
|
+
): ReturnType<FusionStore['append']> {
|
|
40
|
+
const current = store.load(taskId)
|
|
41
|
+
if (!current) throw new FusionError(EVENT_CONFLICT, `unknown task ${taskId}`)
|
|
42
|
+
const event: FusionEvent = resultFromCapturedTicket(ticket, outcome, {
|
|
43
|
+
eventId: `review-completed-${ticket.requestId}-${current.seq + 1}`,
|
|
44
|
+
seq: current.seq + 1,
|
|
45
|
+
createdAt: new Date().toISOString(),
|
|
46
|
+
})
|
|
47
|
+
return store.append(taskId, current.revision, [event])
|
|
48
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
export type ReviewStatus = 'complete' | 'incomplete' | 'not_required' | 'failed'
|
|
2
|
+
|
|
3
|
+
export type ReviewDecision = 'approve' | 'rework' | 'needs-decision' | 'REVIEW_INCOMPLETE'
|
|
4
|
+
|
|
5
|
+
export type ExecutionPath = 'direct' | 'forced_fusion' | 'fusion_auto'
|
|
6
|
+
|
|
7
|
+
export type ReviewTermination = 'success' | 'truncated' | 'aborted' | 'error' | 'filtered' | 'unknown'
|
|
8
|
+
|
|
9
|
+
export interface ReviewClassification {
|
|
10
|
+
reviewStatus: ReviewStatus
|
|
11
|
+
decision: ReviewDecision | 'not_required'
|
|
12
|
+
protocolComplete: boolean
|
|
13
|
+
/** True only after a substitute artifact was actually delivered. */
|
|
14
|
+
fallbackUsed: boolean
|
|
15
|
+
/** True when a fallback or retry is still required. */
|
|
16
|
+
fallbackRequired: boolean
|
|
17
|
+
termination: ReviewTermination
|
|
18
|
+
reasons: readonly string[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReviewAttemptFields {
|
|
22
|
+
artifactPass: boolean
|
|
23
|
+
executionPath: ExecutionPath
|
|
24
|
+
leadReviewStatus: ReviewStatus
|
|
25
|
+
finishReason: string | null
|
|
26
|
+
protocolComplete: boolean
|
|
27
|
+
fallbackUsed: boolean
|
|
28
|
+
deliveredArtifactSource: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const VALID_DECISIONS = new Set(['approve', 'accept', 'rework', 'needs-decision'])
|
|
32
|
+
|
|
33
|
+
const SUCCESS_REASONS = new Set(['stop', 'end_turn', 'end-turn', 'tool_calls', 'tool_call', 'completed', 'success'])
|
|
34
|
+
const TRUNCATED_REASONS = new Set(['length', 'max_tokens', 'max_token'])
|
|
35
|
+
const ABORTED_REASONS = new Set(['aborted', 'abort', 'cancelled', 'canceled', 'cancel'])
|
|
36
|
+
const ERROR_REASONS = new Set(['error', 'internal_error', 'server_error'])
|
|
37
|
+
const FILTERED_REASONS = new Set(['content_filter', 'content-filter', 'filtered', 'contentfilter'])
|
|
38
|
+
|
|
39
|
+
function normalizeFinishReason(finishReason: string | null | undefined): string | null {
|
|
40
|
+
if (finishReason === null || finishReason === undefined) return null
|
|
41
|
+
const trimmed = finishReason.trim().toLowerCase()
|
|
42
|
+
return trimmed.length ? trimmed : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function terminationFromFinishReason(finishReason: string | null): ReviewTermination | null {
|
|
46
|
+
if (finishReason === null) return null
|
|
47
|
+
if (SUCCESS_REASONS.has(finishReason)) return 'success'
|
|
48
|
+
if (TRUNCATED_REASONS.has(finishReason)) return 'truncated'
|
|
49
|
+
if (ABORTED_REASONS.has(finishReason)) return 'aborted'
|
|
50
|
+
if (ERROR_REASONS.has(finishReason)) return 'error'
|
|
51
|
+
if (FILTERED_REASONS.has(finishReason)) return 'filtered'
|
|
52
|
+
return 'unknown'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeTermination(input: {
|
|
56
|
+
finishReason?: string | null
|
|
57
|
+
termination?: ReviewTermination
|
|
58
|
+
completeEvidence?: boolean
|
|
59
|
+
}): ReviewTermination {
|
|
60
|
+
const finishReason = normalizeFinishReason(input.finishReason)
|
|
61
|
+
const fromFinish = terminationFromFinishReason(finishReason)
|
|
62
|
+
if (input.termination && fromFinish && input.termination !== fromFinish) {
|
|
63
|
+
return 'unknown'
|
|
64
|
+
}
|
|
65
|
+
if (input.termination) return input.termination
|
|
66
|
+
if (fromFinish) return fromFinish
|
|
67
|
+
return input.completeEvidence === true ? 'success' : 'unknown'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parsedDecision(parsed: unknown): string | undefined {
|
|
71
|
+
if (!parsed || typeof parsed !== 'object') return undefined
|
|
72
|
+
const decision = (parsed as { decision?: unknown }).decision
|
|
73
|
+
return typeof decision === 'string' ? decision.trim().toLowerCase() : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function incomplete(
|
|
77
|
+
reasons: readonly string[],
|
|
78
|
+
termination: ReviewTermination,
|
|
79
|
+
reviewStatus: ReviewStatus = 'incomplete',
|
|
80
|
+
): ReviewClassification {
|
|
81
|
+
return {
|
|
82
|
+
reviewStatus,
|
|
83
|
+
decision: 'REVIEW_INCOMPLETE',
|
|
84
|
+
protocolComplete: false,
|
|
85
|
+
fallbackUsed: false,
|
|
86
|
+
fallbackRequired: true,
|
|
87
|
+
termination,
|
|
88
|
+
reasons,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lead review is fail-closed. Only an explicit success termination plus a
|
|
94
|
+
* versioned accept/rework/needs-decision structure can complete the protocol.
|
|
95
|
+
* DIRECT skips review.
|
|
96
|
+
*/
|
|
97
|
+
export function classifyReview(input: {
|
|
98
|
+
executionPath: ExecutionPath
|
|
99
|
+
finishReason?: string | null
|
|
100
|
+
termination?: ReviewTermination
|
|
101
|
+
completeEvidence?: boolean
|
|
102
|
+
rawText?: string | null
|
|
103
|
+
parsed?: unknown
|
|
104
|
+
}): ReviewClassification {
|
|
105
|
+
if (input.executionPath === 'direct') {
|
|
106
|
+
return {
|
|
107
|
+
reviewStatus: 'not_required',
|
|
108
|
+
decision: 'not_required',
|
|
109
|
+
protocolComplete: true,
|
|
110
|
+
fallbackUsed: false,
|
|
111
|
+
fallbackRequired: false,
|
|
112
|
+
termination: 'success',
|
|
113
|
+
reasons: ['direct path does not require Lead review'],
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const reasons: string[] = []
|
|
118
|
+
const termination = normalizeTermination(input)
|
|
119
|
+
if (termination === 'truncated') reasons.push(`finish_reason=${normalizeFinishReason(input.finishReason) ?? 'truncated'}`)
|
|
120
|
+
if (termination === 'aborted') reasons.push('termination=aborted')
|
|
121
|
+
if (termination === 'error') reasons.push('termination=error')
|
|
122
|
+
if (termination === 'filtered') reasons.push('termination=filtered')
|
|
123
|
+
if (termination === 'unknown') reasons.push('termination=unknown; parsed text is not success evidence')
|
|
124
|
+
|
|
125
|
+
const raw = input.rawText ?? ''
|
|
126
|
+
if (!raw.trim()) reasons.push('empty review output')
|
|
127
|
+
|
|
128
|
+
const decision = parsedDecision(input.parsed)
|
|
129
|
+
if (input.parsed !== undefined && input.parsed !== null && !decision) {
|
|
130
|
+
reasons.push('illegal review structure')
|
|
131
|
+
} else if (decision && !VALID_DECISIONS.has(decision)) {
|
|
132
|
+
reasons.push(`illegal review decision ${decision}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (termination === 'truncated' || termination === 'unknown') {
|
|
136
|
+
return incomplete(reasons.length ? reasons : [`termination=${termination}`], termination)
|
|
137
|
+
}
|
|
138
|
+
if (termination === 'aborted' || termination === 'error' || termination === 'filtered') {
|
|
139
|
+
return incomplete(reasons, termination, 'failed')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (reasons.length) {
|
|
143
|
+
return incomplete(reasons, termination)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (decision === 'rework') {
|
|
147
|
+
return {
|
|
148
|
+
reviewStatus: 'complete',
|
|
149
|
+
decision: 'rework',
|
|
150
|
+
protocolComplete: true,
|
|
151
|
+
fallbackUsed: false,
|
|
152
|
+
fallbackRequired: false,
|
|
153
|
+
termination,
|
|
154
|
+
reasons: [],
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (decision === 'needs-decision') {
|
|
158
|
+
return {
|
|
159
|
+
reviewStatus: 'complete',
|
|
160
|
+
decision: 'needs-decision',
|
|
161
|
+
protocolComplete: true,
|
|
162
|
+
fallbackUsed: false,
|
|
163
|
+
fallbackRequired: false,
|
|
164
|
+
termination,
|
|
165
|
+
reasons: [],
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (decision === 'approve' || decision === 'accept') {
|
|
169
|
+
return {
|
|
170
|
+
reviewStatus: 'complete',
|
|
171
|
+
decision: 'approve',
|
|
172
|
+
protocolComplete: true,
|
|
173
|
+
fallbackUsed: false,
|
|
174
|
+
fallbackRequired: false,
|
|
175
|
+
termination,
|
|
176
|
+
reasons: [],
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return incomplete(['review decision missing'], termination)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Map classifier output onto the reducer event decision. */
|
|
184
|
+
export function toReducerReviewDecision(
|
|
185
|
+
classification: ReviewClassification,
|
|
186
|
+
): 'accept' | 'rework' | 'needs-decision' | 'REVIEW_INCOMPLETE' {
|
|
187
|
+
if (classification.decision === 'approve') return 'accept'
|
|
188
|
+
if (classification.decision === 'rework') return 'rework'
|
|
189
|
+
if (classification.decision === 'needs-decision') return 'needs-decision'
|
|
190
|
+
return 'REVIEW_INCOMPLETE'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function reviewAttemptFields(input: {
|
|
194
|
+
artifactPass: boolean
|
|
195
|
+
executionPath: ExecutionPath
|
|
196
|
+
classification: ReviewClassification
|
|
197
|
+
finishReason?: string | null
|
|
198
|
+
deliveredArtifactSource?: string | null
|
|
199
|
+
}): ReviewAttemptFields {
|
|
200
|
+
const deliveredArtifactSource = input.deliveredArtifactSource ?? null
|
|
201
|
+
return {
|
|
202
|
+
artifactPass: input.artifactPass,
|
|
203
|
+
executionPath: input.executionPath,
|
|
204
|
+
leadReviewStatus: input.classification.reviewStatus,
|
|
205
|
+
finishReason: input.finishReason ?? null,
|
|
206
|
+
protocolComplete: input.classification.protocolComplete,
|
|
207
|
+
fallbackUsed: Boolean(deliveredArtifactSource) && (
|
|
208
|
+
input.classification.fallbackRequired || input.classification.fallbackUsed
|
|
209
|
+
),
|
|
210
|
+
deliveredArtifactSource,
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FusionEvent,
|
|
3
|
+
OutboxRow,
|
|
4
|
+
ReportSubject,
|
|
5
|
+
ReviewReadinessReceipt,
|
|
6
|
+
ReviewResultV2,
|
|
7
|
+
ReviewTicket,
|
|
8
|
+
TaskId,
|
|
9
|
+
WorkerReport,
|
|
10
|
+
} from '../contracts.js'
|
|
11
|
+
import { OperationId } from '../contracts.js'
|
|
12
|
+
import { digestOf } from '../digest.js'
|
|
13
|
+
import { EVENT_CONFLICT, FusionError } from '../errors.js'
|
|
14
|
+
import type { ReviewClassification } from './protocol.js'
|
|
15
|
+
import { toReducerReviewDecision } from './protocol.js'
|
|
16
|
+
import type { TaskState } from '../task/state.js'
|
|
17
|
+
|
|
18
|
+
function requireThat(value: unknown, code: string): asserts value {
|
|
19
|
+
if (!value) throw new FusionError(EVENT_CONFLICT, code)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function freeze<T>(value: T): Readonly<T> {
|
|
23
|
+
if (value && typeof value === 'object') {
|
|
24
|
+
for (const child of Object.values(value as Record<string, unknown>)) freeze(child)
|
|
25
|
+
Object.freeze(value)
|
|
26
|
+
}
|
|
27
|
+
return value as Readonly<T>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function reportSubject(taskId: TaskId, report: WorkerReport): ReportSubject {
|
|
31
|
+
const reportDigest = digestOf(report)
|
|
32
|
+
return {
|
|
33
|
+
taskId,
|
|
34
|
+
workOrderId: report.workOrderId,
|
|
35
|
+
revision: report.revision,
|
|
36
|
+
reportId: `report:${reportDigest}`,
|
|
37
|
+
reportDigest,
|
|
38
|
+
snapshot: report.snapshot,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sameSubject(left: ReportSubject, right: ReportSubject): boolean {
|
|
43
|
+
return digestOf(left) === digestOf(right)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function reviewReadinessReceipt(subject: ReportSubject): ReviewReadinessReceipt {
|
|
47
|
+
const receipt = {
|
|
48
|
+
subject,
|
|
49
|
+
receiptId: `ready:${subject.reportDigest}`,
|
|
50
|
+
verdict: 'review-ready' as const,
|
|
51
|
+
receiptDigest: '' as ReviewReadinessReceipt['receiptDigest'],
|
|
52
|
+
}
|
|
53
|
+
return { ...receipt, receiptDigest: digestOf({ subject, receiptId: receipt.receiptId, verdict: receipt.verdict }) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function captureTicket(ticket: ReviewTicket): Readonly<ReviewTicket> {
|
|
57
|
+
requireThat(ticket.id && ticket.requestId && Number.isSafeInteger(ticket.generation), 'INVALID_TICKET')
|
|
58
|
+
requireThat(ticket.workOrderDigest, 'WORK_ORDER_DIGEST_REQUIRED')
|
|
59
|
+
return freeze(structuredClone(ticket))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createReviewTicket(
|
|
63
|
+
state: TaskState,
|
|
64
|
+
ids: { ticketId: string; requestId: string },
|
|
65
|
+
): ReviewTicket {
|
|
66
|
+
const receipt = state.validatedReceipt
|
|
67
|
+
const workOrder = state.currentWorkOrder
|
|
68
|
+
if (!receipt) throw new FusionError(EVENT_CONFLICT, 'review ticket requires a validated report')
|
|
69
|
+
if (!workOrder) throw new FusionError(EVENT_CONFLICT, 'review ticket requires a current work order')
|
|
70
|
+
return captureTicket({
|
|
71
|
+
id: ids.ticketId,
|
|
72
|
+
requestId: ids.requestId,
|
|
73
|
+
generation: state.reviewGeneration + 1,
|
|
74
|
+
subject: receipt.subject,
|
|
75
|
+
validationDigest: digestOf(receipt),
|
|
76
|
+
workOrderDigest: digestOf(workOrder),
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function reviewRequestedEvent(
|
|
81
|
+
state: TaskState,
|
|
82
|
+
seq: number,
|
|
83
|
+
ids: { ticketId: string; requestId: string },
|
|
84
|
+
extras: Partial<Pick<FusionEvent, 'id' | 'revision' | 'createdAt' | 'causeId'>> = {},
|
|
85
|
+
): Extract<FusionEvent, { type: 'review/requested' }> {
|
|
86
|
+
return {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
id: extras.id ?? `review-requested-${ids.requestId}`,
|
|
89
|
+
taskId: state.taskId,
|
|
90
|
+
seq,
|
|
91
|
+
revision: extras.revision ?? state.revision,
|
|
92
|
+
type: 'review/requested',
|
|
93
|
+
createdAt: extras.createdAt ?? new Date().toISOString(),
|
|
94
|
+
causeId: extras.causeId ?? ids.requestId,
|
|
95
|
+
payload: { ticket: createReviewTicket(state, ids) },
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function reviewOutboxRow(ticket: Readonly<ReviewTicket>, payloadRef: string): OutboxRow {
|
|
100
|
+
requireThat(payloadRef.length > 0, 'REVIEW_PAYLOAD_REF_REQUIRED')
|
|
101
|
+
return {
|
|
102
|
+
operationId: OperationId(`review:${ticket.requestId}`),
|
|
103
|
+
taskId: ticket.subject.taskId,
|
|
104
|
+
kind: 'review-request',
|
|
105
|
+
state: 'prepared',
|
|
106
|
+
payloadRef,
|
|
107
|
+
requestId: ticket.requestId,
|
|
108
|
+
ticket: structuredClone(ticket),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Callback constructor. No TaskState argument: the producer cannot rebind to the latest task. */
|
|
113
|
+
export function resultFromCapturedTicket(
|
|
114
|
+
ticket: Readonly<ReviewTicket>,
|
|
115
|
+
outcome: {
|
|
116
|
+
requestId: string
|
|
117
|
+
terminalEvidenceRef: string
|
|
118
|
+
classification: ReviewClassification
|
|
119
|
+
},
|
|
120
|
+
envelope: { eventId: string; seq: number; createdAt: string },
|
|
121
|
+
): Extract<FusionEvent, { type: 'review/completed' }> {
|
|
122
|
+
requireThat(outcome.requestId === ticket.requestId, 'REVIEW_REQUEST_MISMATCH')
|
|
123
|
+
requireThat(outcome.terminalEvidenceRef.length > 0, 'TERMINAL_EVIDENCE_REQUIRED')
|
|
124
|
+
requireThat(envelope.eventId && Number.isSafeInteger(envelope.seq) && envelope.seq > 0, 'INVALID_EVENT_ENVELOPE')
|
|
125
|
+
const classification = outcome.classification
|
|
126
|
+
requireThat(classification.decision !== 'not_required', 'DIRECT_IS_NOT_A_REVIEW_RESULT')
|
|
127
|
+
const protocolValid = classification.reviewStatus === 'complete'
|
|
128
|
+
&& classification.protocolComplete
|
|
129
|
+
&& classification.termination === 'success'
|
|
130
|
+
const decision = protocolValid ? toReducerReviewDecision(classification) : 'REVIEW_INCOMPLETE'
|
|
131
|
+
const binding: ReviewResultV2 = {
|
|
132
|
+
ticketId: ticket.id,
|
|
133
|
+
requestId: ticket.requestId,
|
|
134
|
+
generation: ticket.generation,
|
|
135
|
+
subject: structuredClone(ticket.subject),
|
|
136
|
+
terminalEvidenceRef: outcome.terminalEvidenceRef,
|
|
137
|
+
decision,
|
|
138
|
+
protocolValid,
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
schemaVersion: 1,
|
|
142
|
+
id: envelope.eventId,
|
|
143
|
+
taskId: ticket.subject.taskId,
|
|
144
|
+
seq: envelope.seq,
|
|
145
|
+
revision: ticket.subject.revision,
|
|
146
|
+
type: 'review/completed',
|
|
147
|
+
createdAt: envelope.createdAt,
|
|
148
|
+
causeId: ticket.requestId,
|
|
149
|
+
payload: { decision, binding },
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Call at review dispatch AND immediately before committing verified completion. */
|
|
154
|
+
export function assertCurrentSubject(state: TaskState, ticket: ReviewTicket, actualSnapshot: ReportSubject['snapshot']): void {
|
|
155
|
+
const workOrder = state.currentWorkOrder
|
|
156
|
+
requireThat(workOrder, 'CURRENT_WORK_ORDER_REQUIRED')
|
|
157
|
+
requireThat(workOrder.taskId === state.taskId && workOrder.id === ticket.subject.workOrderId, 'WORK_ORDER_CHANGED')
|
|
158
|
+
requireThat(state.revision === workOrder.revision && workOrder.revision === ticket.subject.revision, 'REVISION_CHANGED')
|
|
159
|
+
requireThat(digestOf(workOrder) === ticket.workOrderDigest, 'WORK_ORDER_CHANGED')
|
|
160
|
+
requireThat(state.candidateReport && state.validatedReport && state.validatedReceipt, 'VALIDATED_CANDIDATE_REQUIRED')
|
|
161
|
+
requireThat(digestOf(reportSubject(state.taskId, state.candidateReport)) === digestOf(ticket.subject), 'CANDIDATE_CHANGED')
|
|
162
|
+
requireThat(digestOf(reportSubject(state.taskId, state.validatedReport)) === digestOf(ticket.subject), 'VALIDATED_REPORT_CHANGED')
|
|
163
|
+
requireThat(digestOf(state.validatedReceipt.subject) === digestOf(ticket.subject), 'READINESS_SUBJECT_CHANGED')
|
|
164
|
+
requireThat(digestOf(state.validatedReceipt) === ticket.validationDigest, 'READINESS_CHANGED')
|
|
165
|
+
requireThat(actualSnapshot === ticket.subject.snapshot, 'WORKSPACE_CHANGED')
|
|
166
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ControlView } from './host/native-model-control.js'
|
|
2
|
+
import type { PhysicalRoute, Role, TokenMeasurement, VerificationLevel } from './contracts.js'
|
|
3
|
+
|
|
4
|
+
/** Bounded, read-only UI projection. No prompts, tool arguments or credentials. */
|
|
5
|
+
export interface FusionStatus {
|
|
6
|
+
schemaVersion: 1
|
|
7
|
+
sessionId: string
|
|
8
|
+
selected: boolean
|
|
9
|
+
observedAt: string
|
|
10
|
+
task?: {
|
|
11
|
+
id: string
|
|
12
|
+
revision: number
|
|
13
|
+
stage: string
|
|
14
|
+
detail: string | null
|
|
15
|
+
/** English copies for an English DSH; optional so older records still read. */
|
|
16
|
+
stageEn?: string
|
|
17
|
+
detailEn?: string | null
|
|
18
|
+
attention: boolean
|
|
19
|
+
verification: VerificationLevel
|
|
20
|
+
workerId: string | null
|
|
21
|
+
modelControl?: ControlView
|
|
22
|
+
models: { lead: PhysicalRoute; worker: PhysicalRoute; compactor: PhysicalRoute | null }
|
|
23
|
+
usage: {
|
|
24
|
+
calls: number
|
|
25
|
+
finalCalls: number
|
|
26
|
+
provisionalCalls: number
|
|
27
|
+
unreportedCalls: number
|
|
28
|
+
compactionCalls: number
|
|
29
|
+
keepaliveCalls: number
|
|
30
|
+
/** Known subtotals only; missing request fields never become zero totals. */
|
|
31
|
+
input: UsageSubtotal
|
|
32
|
+
cacheRead: UsageSubtotal
|
|
33
|
+
output: UsageSubtotal
|
|
34
|
+
actualBilledUsd: null
|
|
35
|
+
upstreamHttpCalls: null
|
|
36
|
+
/** Where the tokens went: the point of Fusion is that most land on the cheaper Worker. */
|
|
37
|
+
byRole: Record<Role, RoleUsage>
|
|
38
|
+
}
|
|
39
|
+
/** Checks frozen on the current work order; 0 means the Lead review was the only verification. */
|
|
40
|
+
automatedChecks: number | null
|
|
41
|
+
requests: { id: string; role: Role; purpose: string; provider: string; model: string;
|
|
42
|
+
outcome: string; authority: 'none' | 'provisional' | 'final'; startedAt: string }[]
|
|
43
|
+
tools: { id: string; role: Role; name: string; state: string; failed: boolean; startedAt: string }[]
|
|
44
|
+
contexts: { role: Role; purpose: string; inputTokens: number; budget: number;
|
|
45
|
+
quality: TokenMeasurement['quality']; admitted: boolean; checkedAt: string }[]
|
|
46
|
+
pendingApprovals: number
|
|
47
|
+
unsettledTools: number
|
|
48
|
+
/** True while a human inspection is required before continue, model switch, or leaving Fusion. */
|
|
49
|
+
recovering?: boolean
|
|
50
|
+
outcomeUnknown?: boolean
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RoleUsage { calls: number; input: UsageSubtotal; cacheRead: UsageSubtotal; output: UsageSubtotal }
|
|
55
|
+
export interface UsageSubtotal { knownTokens: number; reportedRequests: number; totalRequests: number }
|