@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,679 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTROL_BLOCKED,
|
|
3
|
+
EVENT_CONFLICT,
|
|
4
|
+
FusionError,
|
|
5
|
+
REVIEW_RESULT_CONFLICT,
|
|
6
|
+
REVIEW_TICKET_REQUIRED,
|
|
7
|
+
REVISION_STALE,
|
|
8
|
+
WORK_ORDER_CONFLICT,
|
|
9
|
+
} from '../errors.js'
|
|
10
|
+
import type {
|
|
11
|
+
ControlState,
|
|
12
|
+
FusionEvent,
|
|
13
|
+
LogicalApproval,
|
|
14
|
+
Phase,
|
|
15
|
+
ReviewGate,
|
|
16
|
+
ReviewResultV2,
|
|
17
|
+
WorkerReport,
|
|
18
|
+
} from '../contracts.js'
|
|
19
|
+
import { TERMINAL_PHASES } from '../contracts.js'
|
|
20
|
+
import { digestOf } from '../digest.js'
|
|
21
|
+
import { assertCurrentSubject, reportSubject, reviewReadinessReceipt, sameSubject } from '../review/ticket.js'
|
|
22
|
+
import type { TaskState } from './state.js'
|
|
23
|
+
|
|
24
|
+
const WAITING: ReadonlySet<Phase> = new Set([
|
|
25
|
+
'WAITING_APPROVAL',
|
|
26
|
+
'WAITING_USER',
|
|
27
|
+
'WAITING_BUDGET',
|
|
28
|
+
'RECOVERING',
|
|
29
|
+
'PAUSED',
|
|
30
|
+
'STOPPING',
|
|
31
|
+
'NEEDS_DECISION',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
function initialControl(): ControlState {
|
|
35
|
+
return {
|
|
36
|
+
mode: 'running',
|
|
37
|
+
pendingApprovalIds: [],
|
|
38
|
+
budgetBlocked: false,
|
|
39
|
+
recovering: false,
|
|
40
|
+
outcomeUnknown: false,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function waiting(state: TaskState, phase: Phase): TaskState {
|
|
45
|
+
return { ...state, resumePhase: WAITING.has(state.phase) ? state.resumePhase : state.phase, phase }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resume(state: TaskState, fallback: Phase): TaskState {
|
|
49
|
+
return { ...state, phase: state.resumePhase ?? fallback, resumePhase: undefined }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function patchControl(state: TaskState, patch: Partial<ControlState>): ControlState {
|
|
53
|
+
return { ...state.control, ...patch }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Releasing one gate never releases another; late review results remain actionable. */
|
|
57
|
+
function projectReleasedControl(state: TaskState): TaskState {
|
|
58
|
+
const control = state.control
|
|
59
|
+
if (control.mode !== 'running') return state
|
|
60
|
+
if (control.pendingApprovalIds.length) return { ...state, phase: 'WAITING_APPROVAL' }
|
|
61
|
+
if (control.outcomeUnknown || state.outcomeUnknown) return { ...state, phase: 'NEEDS_DECISION' }
|
|
62
|
+
if (control.recovering) return { ...state, phase: 'RECOVERING' }
|
|
63
|
+
if (control.budgetBlocked) return { ...state, phase: 'WAITING_BUDGET' }
|
|
64
|
+
if (state.reviewResult) {
|
|
65
|
+
const decision = state.reviewResult.decision
|
|
66
|
+
return {
|
|
67
|
+
...state, resumePhase: undefined,
|
|
68
|
+
phase: decision === 'rework' ? 'REWORK'
|
|
69
|
+
: decision === 'accept' && state.reviewResult.protocolValid ? 'REVIEWING' : 'NEEDS_DECISION',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return resume(state, state.intent === 'DIRECT' ? 'DIRECT' : 'PLANNING')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function requireReconciliation(state: TaskState, evidenceRef: string): void {
|
|
76
|
+
if (!evidenceRef.trim() || writerBusy(state)) {
|
|
77
|
+
throw new FusionError(CONTROL_BLOCKED, 'reconciliation requires evidence and a quiescent writer')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function applyPayload(state: TaskState | undefined, event: FusionEvent): TaskState {
|
|
82
|
+
switch (event.type) {
|
|
83
|
+
case 'task/created':
|
|
84
|
+
if (state) throw new FusionError(EVENT_CONFLICT, 'task already exists')
|
|
85
|
+
if (event.seq !== 1) throw new FusionError(EVENT_CONFLICT, 'task/created must be seq 1')
|
|
86
|
+
return {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
taskId: event.taskId,
|
|
89
|
+
revision: event.revision,
|
|
90
|
+
seq: event.seq,
|
|
91
|
+
phase: 'READY',
|
|
92
|
+
parent: event.payload.parent,
|
|
93
|
+
selection: event.payload.selection,
|
|
94
|
+
verification: 'unverified',
|
|
95
|
+
pendingApprovalIds: [],
|
|
96
|
+
pendingApprovals: {},
|
|
97
|
+
control: initialControl(),
|
|
98
|
+
outcomeUnknown: false,
|
|
99
|
+
reviewGeneration: 0,
|
|
100
|
+
ignoredReviewResults: [],
|
|
101
|
+
staleValidations: [],
|
|
102
|
+
appliedEventIds: [event.id],
|
|
103
|
+
}
|
|
104
|
+
case 'requirements/revised':
|
|
105
|
+
return resetReviewForNewSubject({
|
|
106
|
+
...need(state, event),
|
|
107
|
+
revision: event.revision,
|
|
108
|
+
phase: state!.phase === 'READY' ? 'PLANNING' : state!.phase,
|
|
109
|
+
})
|
|
110
|
+
case 'profile/frozen':
|
|
111
|
+
return applyProfileFrozen(state, event)
|
|
112
|
+
case 'intent/chosen':
|
|
113
|
+
return {
|
|
114
|
+
...need(state, event),
|
|
115
|
+
intent: event.payload.intent,
|
|
116
|
+
phase: event.payload.intent === 'DIRECT' ? 'DIRECT' : 'PLANNING',
|
|
117
|
+
}
|
|
118
|
+
case 'work-order/prepared':
|
|
119
|
+
return applyWorkOrderPrepared(state, event)
|
|
120
|
+
case 'work-order/acceptance-amended': {
|
|
121
|
+
// Only the Lead amends its own frozen checks. Earlier reports were
|
|
122
|
+
// verified against the old plans, so no review may carry over.
|
|
123
|
+
const next = need(state, event), order = next.currentWorkOrder
|
|
124
|
+
if (!order || order.mode === 'explore' || order.id !== event.payload.workOrderId || next.lease) {
|
|
125
|
+
throw new FusionError(WORK_ORDER_CONFLICT, 'Acceptance amendment requires the current quiescent implementation work order')
|
|
126
|
+
}
|
|
127
|
+
return resetReviewForNewSubject({ ...next, currentWorkOrder: { ...order, acceptance: event.payload.acceptance } })
|
|
128
|
+
}
|
|
129
|
+
case 'work-order/scope-expanded': {
|
|
130
|
+
// Only the Lead widens its own frozen scope, and only by adding paths.
|
|
131
|
+
const next = need(state, event), order = next.currentWorkOrder
|
|
132
|
+
if (!order || order.mode === 'explore' || order.mode === 'text' || order.id !== event.payload.workOrderId || next.lease
|
|
133
|
+
|| order.allowedPaths.some(path => !event.payload.allowedPaths.includes(path))) {
|
|
134
|
+
throw new FusionError(WORK_ORDER_CONFLICT, 'Scope expansion requires the current quiescent implementation work order and keeps every allowed path')
|
|
135
|
+
}
|
|
136
|
+
return resetReviewForNewSubject({ ...next, currentWorkOrder: { ...order, allowedPaths: event.payload.allowedPaths } })
|
|
137
|
+
}
|
|
138
|
+
case 'child/accepted':
|
|
139
|
+
return {
|
|
140
|
+
...need(state, event),
|
|
141
|
+
exploration: state?.currentWorkOrder?.mode === 'explore' ? undefined : state?.exploration,
|
|
142
|
+
acceptedChild: event.payload.child,
|
|
143
|
+
acceptedMessageId: event.payload.messageId,
|
|
144
|
+
phase: 'WORKER_RUNNING',
|
|
145
|
+
}
|
|
146
|
+
case 'child/claimed':
|
|
147
|
+
return { ...need(state, event), phase: 'WORKER_RUNNING' }
|
|
148
|
+
case 'exploration/recorded': {
|
|
149
|
+
const next = need(state, event), report = event.payload.report, order = next.currentWorkOrder
|
|
150
|
+
assertControlAllowsExecution(next)
|
|
151
|
+
if (order?.mode !== 'explore' || order.id !== report.workOrderId || order.revision !== report.revision
|
|
152
|
+
|| next.revision !== report.revision || order.baseSnapshot !== report.snapshot || next.lease) {
|
|
153
|
+
throw new FusionError(EVENT_CONFLICT, 'Exploration requires the current read-only work order and unchanged snapshot')
|
|
154
|
+
}
|
|
155
|
+
return { ...next, exploration: report, phase: 'PLANNING', lastSnapshot: report.snapshot }
|
|
156
|
+
}
|
|
157
|
+
case 'report/submitted':
|
|
158
|
+
return applySubmittedReport(state, event)
|
|
159
|
+
case 'report/validated':
|
|
160
|
+
return applyValidatedReport(state, event)
|
|
161
|
+
case 'review/requested':
|
|
162
|
+
return applyReviewRequested(state, event)
|
|
163
|
+
case 'review/completed':
|
|
164
|
+
return applyReviewCompleted(state, event)
|
|
165
|
+
case 'lease/acquired':
|
|
166
|
+
return { ...need(state, event), lease: event.payload.lease }
|
|
167
|
+
case 'lease/released':
|
|
168
|
+
return { ...need(state, event), lease: undefined }
|
|
169
|
+
case 'approval/pending':
|
|
170
|
+
return applyApprovalPending(state, event)
|
|
171
|
+
case 'approval/answered':
|
|
172
|
+
return applyApprovalAnswered(state, event)
|
|
173
|
+
case 'budget/blocked':
|
|
174
|
+
return {
|
|
175
|
+
...waiting(need(state, event), 'WAITING_BUDGET'),
|
|
176
|
+
control: patchControl(need(state, event), { budgetBlocked: true }),
|
|
177
|
+
}
|
|
178
|
+
case 'budget/unblocked': {
|
|
179
|
+
const next = need(state, event)
|
|
180
|
+
if (!event.payload.authorizationId.trim()) throw new FusionError(CONTROL_BLOCKED, 'budget authorization is required')
|
|
181
|
+
return projectReleasedControl({ ...next, control: patchControl(next, { budgetBlocked: false }) })
|
|
182
|
+
}
|
|
183
|
+
case 'checkpoint/committed':
|
|
184
|
+
return { ...need(state, event), lastSnapshot: event.payload.checkpoint.snapshot }
|
|
185
|
+
case 'recovery/needed':
|
|
186
|
+
return {
|
|
187
|
+
...waiting(need(state, event), 'RECOVERING'),
|
|
188
|
+
control: patchControl(need(state, event), { recovering: true }),
|
|
189
|
+
}
|
|
190
|
+
case 'recovery/reconciled': {
|
|
191
|
+
const next = need(state, event)
|
|
192
|
+
requireReconciliation(next, event.payload.evidenceRef)
|
|
193
|
+
return projectReleasedControl({ ...next, lastSnapshot: event.payload.snapshot, control: patchControl(next, { recovering: false }) })
|
|
194
|
+
}
|
|
195
|
+
case 'effect/outcome-unknown':
|
|
196
|
+
return {
|
|
197
|
+
...waiting(need(state, event), 'NEEDS_DECISION'),
|
|
198
|
+
outcomeUnknown: true,
|
|
199
|
+
control: patchControl(need(state, event), { outcomeUnknown: true }),
|
|
200
|
+
}
|
|
201
|
+
case 'effects/reconciled': {
|
|
202
|
+
const next = need(state, event)
|
|
203
|
+
requireReconciliation(next, event.payload.evidenceRef)
|
|
204
|
+
if (event.payload.quiescent !== true) throw new FusionError(CONTROL_BLOCKED, 'effects are not quiescent')
|
|
205
|
+
return projectReleasedControl({
|
|
206
|
+
...next, outcomeUnknown: false, lastSnapshot: event.payload.snapshot,
|
|
207
|
+
control: patchControl(next, { outcomeUnknown: false }),
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
case 'task/stop-requested':
|
|
211
|
+
return {
|
|
212
|
+
...waiting(need(state, event), 'STOPPING'),
|
|
213
|
+
control: patchControl(need(state, event), { mode: 'stop-requested' }),
|
|
214
|
+
}
|
|
215
|
+
case 'task/paused':
|
|
216
|
+
return {
|
|
217
|
+
...waiting(need(state, event), 'PAUSED'),
|
|
218
|
+
control: patchControl(need(state, event), { mode: 'paused' }),
|
|
219
|
+
}
|
|
220
|
+
case 'task/resumed': {
|
|
221
|
+
const next = need(state, event)
|
|
222
|
+
if (next.control.mode !== 'paused' || writerBusy(next)) {
|
|
223
|
+
throw new FusionError(CONTROL_BLOCKED, 'only a quiescent paused task can resume')
|
|
224
|
+
}
|
|
225
|
+
return projectReleasedControl({ ...next, control: patchControl(next, { mode: 'running' }) })
|
|
226
|
+
}
|
|
227
|
+
case 'task/completed':
|
|
228
|
+
return complete(need(state, event), event)
|
|
229
|
+
case 'task/cancelled':
|
|
230
|
+
return {
|
|
231
|
+
...need(state, event),
|
|
232
|
+
phase: 'CANCELLED',
|
|
233
|
+
control: patchControl(need(state, event), { mode: 'cancelled' }),
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
return assertNever(event)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function need(state: TaskState | undefined, event: FusionEvent): TaskState {
|
|
241
|
+
if (!state) throw new FusionError(EVENT_CONFLICT, `${event.type} requires an existing task`)
|
|
242
|
+
if (state.taskId !== event.taskId) throw new FusionError(EVENT_CONFLICT, 'taskId mismatch')
|
|
243
|
+
if (event.seq !== state.seq + 1) throw new FusionError(EVENT_CONFLICT, `expected seq ${state.seq + 1}, got ${event.seq}`)
|
|
244
|
+
if (event.revision < 1) throw new FusionError(EVENT_CONFLICT, 'revision must be >= 1')
|
|
245
|
+
if (TERMINAL_PHASES.has(state.phase) && event.type !== 'task/cancelled') {
|
|
246
|
+
throw new FusionError(EVENT_CONFLICT, `terminal phase ${state.phase} rejects ${event.type}`)
|
|
247
|
+
}
|
|
248
|
+
return { ...state, seq: event.seq, revision: Math.max(state.revision, event.revision), appliedEventIds: [...state.appliedEventIds, event.id] }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function rejectStaleReport(state: TaskState | undefined, reportRevision: number): void {
|
|
252
|
+
if (state && reportRevision < state.revision) {
|
|
253
|
+
throw new FusionError(REVISION_STALE, `report revision ${reportRevision} cannot advance task revision ${state.revision}`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function reportDigest(report: WorkerReport) {
|
|
258
|
+
return digestOf(report)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function blockingGate(gate: ReviewGate | undefined): boolean {
|
|
262
|
+
return gate !== undefined && gate.terminalStatus !== 'accepting'
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resetReviewForNewSubject(state: TaskState): TaskState {
|
|
266
|
+
return {
|
|
267
|
+
...state,
|
|
268
|
+
candidateReport: undefined,
|
|
269
|
+
validatedReport: undefined,
|
|
270
|
+
validatedReceipt: undefined,
|
|
271
|
+
activeReviewTicket: undefined,
|
|
272
|
+
reviewResult: undefined,
|
|
273
|
+
reviewResultDigest: undefined,
|
|
274
|
+
acceptedReview: undefined,
|
|
275
|
+
lastReport: undefined,
|
|
276
|
+
reviewGate: undefined,
|
|
277
|
+
reviewGeneration: state.reviewGeneration + 1,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function applyProfileFrozen(
|
|
282
|
+
state: TaskState | undefined,
|
|
283
|
+
event: Extract<FusionEvent, { type: 'profile/frozen' }>,
|
|
284
|
+
): TaskState {
|
|
285
|
+
const next = need(state, event)
|
|
286
|
+
const updated: TaskState = {
|
|
287
|
+
...next,
|
|
288
|
+
profileDigest: event.payload.digest,
|
|
289
|
+
phase: next.phase === 'READY' ? 'PLANNING' : next.phase,
|
|
290
|
+
}
|
|
291
|
+
if (next.candidateReport || next.validatedReport || next.activeReviewTicket || next.acceptedReview) {
|
|
292
|
+
return resetReviewForNewSubject(updated)
|
|
293
|
+
}
|
|
294
|
+
return updated
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function applyWorkOrderPrepared(
|
|
298
|
+
state: TaskState | undefined,
|
|
299
|
+
event: Extract<FusionEvent, { type: 'work-order/prepared' }>,
|
|
300
|
+
): TaskState {
|
|
301
|
+
const next = need(state, event)
|
|
302
|
+
const incoming = event.payload.order
|
|
303
|
+
const current = next.currentWorkOrder
|
|
304
|
+
if (current && current.taskId === incoming.taskId && current.id === incoming.id && current.revision === incoming.revision) {
|
|
305
|
+
if (digestOf(current) !== digestOf(incoming)) {
|
|
306
|
+
throw new FusionError(WORK_ORDER_CONFLICT, 'same work-order identity cannot change its frozen digest')
|
|
307
|
+
}
|
|
308
|
+
return { ...next, currentWorkOrder: incoming, reservedChild: event.payload.reservedChild, phase: 'PLANNING' }
|
|
309
|
+
}
|
|
310
|
+
const reset = current || next.candidateReport || next.validatedReport || next.activeReviewTicket || next.acceptedReview
|
|
311
|
+
? resetReviewForNewSubject(next)
|
|
312
|
+
: next
|
|
313
|
+
return {
|
|
314
|
+
...reset,
|
|
315
|
+
currentWorkOrder: incoming,
|
|
316
|
+
reservedChild: event.payload.reservedChild,
|
|
317
|
+
phase: 'PLANNING',
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function applySubmittedReport(
|
|
322
|
+
state: TaskState | undefined,
|
|
323
|
+
event: Extract<FusionEvent, { type: 'report/submitted' }>,
|
|
324
|
+
): TaskState {
|
|
325
|
+
rejectStaleReport(state, event.payload.report.revision)
|
|
326
|
+
const next = need(state, event)
|
|
327
|
+
const incoming = event.payload.report
|
|
328
|
+
if (next.currentWorkOrder?.mode === 'explore') throw new FusionError(EVENT_CONFLICT, 'Exploration cannot submit an implementation report')
|
|
329
|
+
if (next.candidateReport && reportDigest(next.candidateReport) === reportDigest(incoming)) {
|
|
330
|
+
return { ...next, candidateReport: incoming, lastReport: next.validatedReport ?? incoming }
|
|
331
|
+
}
|
|
332
|
+
const reset = resetReviewForNewSubject(next)
|
|
333
|
+
return {
|
|
334
|
+
...reset,
|
|
335
|
+
candidateReport: incoming,
|
|
336
|
+
lastReport: incoming,
|
|
337
|
+
phase: next.phase === 'WORKER_RUNNING' || next.phase === 'REVIEWING' || next.phase === 'REWORK' ? 'WORKER_RUNNING' : next.phase,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function applyValidatedReport(
|
|
342
|
+
state: TaskState | undefined,
|
|
343
|
+
event: Extract<FusionEvent, { type: 'report/validated' }>,
|
|
344
|
+
): TaskState {
|
|
345
|
+
rejectStaleReport(state, event.payload.report.revision)
|
|
346
|
+
const next = need(state, event)
|
|
347
|
+
const incoming = event.payload.report
|
|
348
|
+
const digest = reportDigest(incoming)
|
|
349
|
+
const subject = reportSubject(next.taskId, incoming)
|
|
350
|
+
const receipt = reviewReadinessReceipt(subject)
|
|
351
|
+
const sameValidated = next.validatedReport !== undefined && reportDigest(next.validatedReport) === digest
|
|
352
|
+
const sameCandidate = next.candidateReport !== undefined && reportDigest(next.candidateReport) === digest
|
|
353
|
+
const sameWork = next.currentWorkOrder
|
|
354
|
+
&& incoming.workOrderId === next.currentWorkOrder.id
|
|
355
|
+
&& incoming.revision === next.currentWorkOrder.revision
|
|
356
|
+
|
|
357
|
+
if (!sameCandidate || !sameWork) {
|
|
358
|
+
return {
|
|
359
|
+
...next,
|
|
360
|
+
staleValidations: [...next.staleValidations, { reportDigest: digest, reason: 'stale-validation' }],
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (sameValidated && blockingGate(next.reviewGate)) {
|
|
364
|
+
return { ...next, lastReport: incoming, validatedReport: incoming, validatedReceipt: receipt }
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
...next,
|
|
368
|
+
lastReport: incoming,
|
|
369
|
+
validatedReport: incoming,
|
|
370
|
+
validatedReceipt: receipt,
|
|
371
|
+
phase: businessPhaseForReview(next),
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function businessPhaseForReview(state: TaskState): Phase {
|
|
376
|
+
if (state.control.mode !== 'running') return state.phase
|
|
377
|
+
if (state.control.pendingApprovalIds.length) return 'WAITING_APPROVAL'
|
|
378
|
+
if (state.control.budgetBlocked) return 'WAITING_BUDGET'
|
|
379
|
+
if (state.control.recovering) return 'RECOVERING'
|
|
380
|
+
if (state.control.outcomeUnknown) return 'NEEDS_DECISION'
|
|
381
|
+
return 'REVIEWING'
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function applyReviewRequested(
|
|
385
|
+
state: TaskState | undefined,
|
|
386
|
+
event: Extract<FusionEvent, { type: 'review/requested' }>,
|
|
387
|
+
): TaskState {
|
|
388
|
+
const next = need(state, event)
|
|
389
|
+
assertControlAllowsExecution(next)
|
|
390
|
+
const receipt = next.validatedReceipt
|
|
391
|
+
if (!receipt || !next.validatedReport) {
|
|
392
|
+
throw new FusionError(EVENT_CONFLICT, 'review/requested requires a validated report')
|
|
393
|
+
}
|
|
394
|
+
const ticket = event.payload.ticket
|
|
395
|
+
const actualSnapshot = next.lastSnapshot ?? ticket.subject.snapshot
|
|
396
|
+
assertCurrentSubject(next, ticket, actualSnapshot)
|
|
397
|
+
if (!sameSubject(ticket.subject, receipt.subject)) {
|
|
398
|
+
throw new FusionError(EVENT_CONFLICT, 'review ticket subject does not match the validated report')
|
|
399
|
+
}
|
|
400
|
+
if (ticket.validationDigest !== digestOf(receipt)) {
|
|
401
|
+
throw new FusionError(EVENT_CONFLICT, 'review ticket validation digest does not match readiness receipt')
|
|
402
|
+
}
|
|
403
|
+
if (ticket.generation !== next.reviewGeneration + 1 && next.activeReviewTicket && digestOf(next.activeReviewTicket) === digestOf(ticket)) {
|
|
404
|
+
return next
|
|
405
|
+
}
|
|
406
|
+
if (ticket.generation !== next.reviewGeneration + 1) {
|
|
407
|
+
throw new FusionError(EVENT_CONFLICT, `review ticket generation ${ticket.generation} is not the next generation ${next.reviewGeneration + 1}`)
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
...next,
|
|
411
|
+
activeReviewTicket: ticket,
|
|
412
|
+
reviewGeneration: ticket.generation,
|
|
413
|
+
reviewResult: undefined,
|
|
414
|
+
reviewResultDigest: undefined,
|
|
415
|
+
acceptedReview: undefined,
|
|
416
|
+
reviewGate: undefined,
|
|
417
|
+
phase: 'REVIEWING',
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function applyReviewCompleted(
|
|
422
|
+
state: TaskState | undefined,
|
|
423
|
+
event: Extract<FusionEvent, { type: 'review/completed' }>,
|
|
424
|
+
): TaskState {
|
|
425
|
+
const next = need(state, event)
|
|
426
|
+
const binding = event.payload.binding
|
|
427
|
+
if (!binding) {
|
|
428
|
+
return applyLegacyReviewDecision(next, event)
|
|
429
|
+
}
|
|
430
|
+
return applyBoundReviewResult(next, binding, event)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function applyLegacyReviewDecision(
|
|
434
|
+
state: TaskState,
|
|
435
|
+
event: Extract<FusionEvent, { type: 'review/completed' }>,
|
|
436
|
+
): TaskState {
|
|
437
|
+
const decision = event.payload.decision
|
|
438
|
+
if (decision === 'accept') {
|
|
439
|
+
return {
|
|
440
|
+
...state,
|
|
441
|
+
reviewGate: state.lastReport
|
|
442
|
+
? {
|
|
443
|
+
taskId: state.taskId,
|
|
444
|
+
workOrderId: state.currentWorkOrder?.id ?? state.lastReport.workOrderId,
|
|
445
|
+
revision: state.lastReport.revision,
|
|
446
|
+
reportDigest: reportDigest(state.lastReport),
|
|
447
|
+
snapshot: state.lastReport.snapshot,
|
|
448
|
+
reviewAttemptId: event.id,
|
|
449
|
+
decision,
|
|
450
|
+
terminalStatus: 'legacy-unbound',
|
|
451
|
+
}
|
|
452
|
+
: state.reviewGate,
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!state.lastReport) {
|
|
456
|
+
throw new FusionError(EVENT_CONFLICT, 'review/completed requires a validated report')
|
|
457
|
+
}
|
|
458
|
+
const gate: ReviewGate = {
|
|
459
|
+
taskId: state.taskId,
|
|
460
|
+
workOrderId: state.currentWorkOrder?.id ?? state.lastReport.workOrderId,
|
|
461
|
+
revision: state.lastReport.revision,
|
|
462
|
+
reportDigest: reportDigest(state.lastReport),
|
|
463
|
+
snapshot: state.lastReport.snapshot,
|
|
464
|
+
reviewAttemptId: event.id,
|
|
465
|
+
decision,
|
|
466
|
+
terminalStatus: decision === 'rework'
|
|
467
|
+
? 'rework'
|
|
468
|
+
: decision === 'REVIEW_INCOMPLETE'
|
|
469
|
+
? 'incomplete'
|
|
470
|
+
: 'needs-decision',
|
|
471
|
+
}
|
|
472
|
+
if (decision === 'rework') return { ...state, reviewGate: gate, phase: 'REWORK' }
|
|
473
|
+
return { ...state, reviewGate: gate, phase: 'NEEDS_DECISION' }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function applyBoundReviewResult(state: TaskState, result: ReviewResultV2, event: Extract<FusionEvent, { type: 'review/completed' }>): TaskState {
|
|
477
|
+
const ticket = state.activeReviewTicket
|
|
478
|
+
if (
|
|
479
|
+
!ticket
|
|
480
|
+
|| ticket.id !== result.ticketId
|
|
481
|
+
|| ticket.requestId !== result.requestId
|
|
482
|
+
|| ticket.generation !== result.generation
|
|
483
|
+
|| !sameSubject(ticket.subject, result.subject)
|
|
484
|
+
) {
|
|
485
|
+
return { ...state, ignoredReviewResults: [...state.ignoredReviewResults, result] }
|
|
486
|
+
}
|
|
487
|
+
const resultDigest = digestOf(result)
|
|
488
|
+
if (state.reviewResultDigest) {
|
|
489
|
+
if (state.reviewResultDigest !== resultDigest) {
|
|
490
|
+
throw new FusionError(REVIEW_RESULT_CONFLICT, 'conflicting review result for the same ticket')
|
|
491
|
+
}
|
|
492
|
+
return state
|
|
493
|
+
}
|
|
494
|
+
const accepts = result.protocolValid && result.decision === 'accept'
|
|
495
|
+
const gate: ReviewGate = {
|
|
496
|
+
taskId: state.taskId,
|
|
497
|
+
workOrderId: ticket.subject.workOrderId,
|
|
498
|
+
revision: ticket.subject.revision,
|
|
499
|
+
reportDigest: ticket.subject.reportDigest,
|
|
500
|
+
snapshot: ticket.subject.snapshot,
|
|
501
|
+
reviewAttemptId: event.id,
|
|
502
|
+
decision: result.decision,
|
|
503
|
+
terminalStatus: accepts
|
|
504
|
+
? 'accepting'
|
|
505
|
+
: result.decision === 'rework'
|
|
506
|
+
? 'rework'
|
|
507
|
+
: result.decision === 'REVIEW_INCOMPLETE'
|
|
508
|
+
? 'incomplete'
|
|
509
|
+
: 'needs-decision',
|
|
510
|
+
}
|
|
511
|
+
const recorded: TaskState = {
|
|
512
|
+
...state,
|
|
513
|
+
reviewResult: result,
|
|
514
|
+
reviewResultDigest: resultDigest,
|
|
515
|
+
acceptedReview: accepts ? { ticket, resultDigest } : undefined,
|
|
516
|
+
reviewGate: gate,
|
|
517
|
+
}
|
|
518
|
+
if (state.control.mode !== 'running' || state.control.pendingApprovalIds.length
|
|
519
|
+
|| state.control.budgetBlocked || state.control.recovering || state.control.outcomeUnknown) {
|
|
520
|
+
return recorded
|
|
521
|
+
}
|
|
522
|
+
if (result.decision === 'rework') return { ...recorded, phase: 'REWORK' }
|
|
523
|
+
if (result.decision === 'needs-decision' || result.decision === 'REVIEW_INCOMPLETE') {
|
|
524
|
+
return { ...recorded, phase: 'NEEDS_DECISION' }
|
|
525
|
+
}
|
|
526
|
+
return { ...recorded, phase: 'REVIEWING' }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function applyApprovalPending(
|
|
530
|
+
state: TaskState | undefined,
|
|
531
|
+
event: Extract<FusionEvent, { type: 'approval/pending' }>,
|
|
532
|
+
): TaskState {
|
|
533
|
+
const next = need(state, event)
|
|
534
|
+
const approval = event.payload.approval
|
|
535
|
+
const pendingApprovalIds = [...new Set([...next.pendingApprovalIds, approval.id])]
|
|
536
|
+
return {
|
|
537
|
+
...waiting(next, 'WAITING_APPROVAL'),
|
|
538
|
+
pendingApprovalIds,
|
|
539
|
+
pendingApprovals: { ...next.pendingApprovals, [approval.id]: approval },
|
|
540
|
+
control: patchControl(next, { pendingApprovalIds }),
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function applyApprovalAnswered(
|
|
545
|
+
state: TaskState | undefined,
|
|
546
|
+
event: Extract<FusionEvent, { type: 'approval/answered' }>,
|
|
547
|
+
): TaskState {
|
|
548
|
+
const next = need(state, event)
|
|
549
|
+
const existing = next.pendingApprovals[event.payload.approvalId]
|
|
550
|
+
if (!existing || !next.pendingApprovalIds.includes(event.payload.approvalId)) {
|
|
551
|
+
throw new FusionError(EVENT_CONFLICT, `approval ${event.payload.approvalId} is not pending`)
|
|
552
|
+
}
|
|
553
|
+
const pendingApprovalIds = next.pendingApprovalIds.filter(id => id !== event.payload.approvalId)
|
|
554
|
+
const pendingApprovals = { ...next.pendingApprovals }
|
|
555
|
+
delete pendingApprovals[event.payload.approvalId]
|
|
556
|
+
const control = patchControl(next, { pendingApprovalIds })
|
|
557
|
+
if (event.payload.state !== 'approved') {
|
|
558
|
+
return {
|
|
559
|
+
...next,
|
|
560
|
+
pendingApprovalIds,
|
|
561
|
+
pendingApprovals,
|
|
562
|
+
control: { ...control, mode: 'paused' },
|
|
563
|
+
phase: pendingApprovalIds.length ? 'WAITING_APPROVAL' : 'NEEDS_DECISION',
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return projectReleasedControl({ ...next, pendingApprovalIds, pendingApprovals, control })
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function currentSnapshot(state: TaskState) {
|
|
570
|
+
return state.lastSnapshot ?? state.validatedReport?.snapshot ?? state.candidateReport?.snapshot ?? state.lastReport?.snapshot
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function writerBusy(state: TaskState): boolean {
|
|
574
|
+
return Boolean(state.lease && state.lease.activeOperationIds.length > 0)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function assertControlAllowsExecution(state: TaskState): void {
|
|
578
|
+
if (state.control.mode !== 'running') {
|
|
579
|
+
throw new FusionError(CONTROL_BLOCKED, `control mode ${state.control.mode} blocks execution`)
|
|
580
|
+
}
|
|
581
|
+
if (state.control.pendingApprovalIds.length) {
|
|
582
|
+
throw new FusionError(CONTROL_BLOCKED, 'pending human approval blocks execution')
|
|
583
|
+
}
|
|
584
|
+
if (state.control.outcomeUnknown || state.outcomeUnknown) {
|
|
585
|
+
throw new FusionError(EVENT_CONFLICT, 'cannot complete while an effect is OUTCOME_UNKNOWN')
|
|
586
|
+
}
|
|
587
|
+
if (state.control.budgetBlocked || state.control.recovering) {
|
|
588
|
+
throw new FusionError(CONTROL_BLOCKED, 'budget or recovery gate is still set')
|
|
589
|
+
}
|
|
590
|
+
if (writerBusy(state)) {
|
|
591
|
+
throw new FusionError(CONTROL_BLOCKED, 'an active write lease blocks execution')
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function assertAcceptingReview(state: TaskState, event: Extract<FusionEvent, { type: 'task/completed' }>): void {
|
|
596
|
+
if (state.intent === 'DIRECT') return
|
|
597
|
+
if (event.payload.verification !== 'verified') {
|
|
598
|
+
throw new FusionError(EVENT_CONFLICT, 'delegated downgrade must not use ordinary verified completion')
|
|
599
|
+
}
|
|
600
|
+
const accepted = state.acceptedReview
|
|
601
|
+
const ticket = state.activeReviewTicket
|
|
602
|
+
const validated = state.validatedReport
|
|
603
|
+
const candidate = state.candidateReport
|
|
604
|
+
const receipt = state.validatedReceipt
|
|
605
|
+
if (!accepted || !ticket || !validated || !candidate || !receipt) {
|
|
606
|
+
throw new FusionError(REVIEW_TICKET_REQUIRED, 'delegated task cannot complete before a bound accepting review')
|
|
607
|
+
}
|
|
608
|
+
const actualSnapshot = state.lastSnapshot ?? accepted.ticket.subject.snapshot
|
|
609
|
+
assertCurrentSubject(state, accepted.ticket, actualSnapshot)
|
|
610
|
+
if (digestOf(accepted.ticket) !== digestOf(ticket) || accepted.ticket.generation !== state.reviewGeneration) {
|
|
611
|
+
throw new FusionError(EVENT_CONFLICT, 'accepting review is stale for the current generation')
|
|
612
|
+
}
|
|
613
|
+
if (!sameSubject(accepted.ticket.subject, reportSubject(state.taskId, candidate))) {
|
|
614
|
+
throw new FusionError(EVENT_CONFLICT, 'accepting review does not match the current candidate')
|
|
615
|
+
}
|
|
616
|
+
if (!sameSubject(accepted.ticket.subject, reportSubject(state.taskId, validated))) {
|
|
617
|
+
throw new FusionError(EVENT_CONFLICT, 'accepting review does not match the current validated report')
|
|
618
|
+
}
|
|
619
|
+
if (accepted.ticket.validationDigest !== digestOf(receipt)) {
|
|
620
|
+
throw new FusionError(EVENT_CONFLICT, 'accepting review validation receipt changed')
|
|
621
|
+
}
|
|
622
|
+
if (!state.reviewResult || digestOf(state.reviewResult) !== accepted.resultDigest) {
|
|
623
|
+
throw new FusionError(EVENT_CONFLICT, 'accepting review result changed')
|
|
624
|
+
}
|
|
625
|
+
if (!state.reviewResult.protocolValid || state.reviewResult.decision !== 'accept') {
|
|
626
|
+
throw new FusionError(EVENT_CONFLICT, 'review result is not an accepting decision')
|
|
627
|
+
}
|
|
628
|
+
const snapshot = currentSnapshot(state)
|
|
629
|
+
if (!snapshot || accepted.ticket.subject.snapshot !== snapshot) {
|
|
630
|
+
throw new FusionError(EVENT_CONFLICT, 'accepting review snapshot is not the current workspace snapshot')
|
|
631
|
+
}
|
|
632
|
+
if (event.payload.snapshot !== snapshot) {
|
|
633
|
+
throw new FusionError(EVENT_CONFLICT, 'completion snapshot is not the current workspace snapshot')
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function complete(state: TaskState, event: Extract<FusionEvent, { type: 'task/completed' }>): TaskState {
|
|
638
|
+
if (event.revision < state.revision) {
|
|
639
|
+
throw new FusionError(REVISION_STALE, 'old revision cannot complete a newer task')
|
|
640
|
+
}
|
|
641
|
+
assertControlAllowsExecution(state)
|
|
642
|
+
if (state.outcomeUnknown || state.control.outcomeUnknown) {
|
|
643
|
+
throw new FusionError(EVENT_CONFLICT, 'cannot complete while an effect is OUTCOME_UNKNOWN')
|
|
644
|
+
}
|
|
645
|
+
if (state.phase !== 'REVIEWING' && state.phase !== 'DIRECT') {
|
|
646
|
+
throw new FusionError(EVENT_CONFLICT, `cannot complete from ${state.phase}`)
|
|
647
|
+
}
|
|
648
|
+
if (event.payload.verification === 'verified') {
|
|
649
|
+
assertAcceptingReview(state, event)
|
|
650
|
+
} else if (state.intent !== 'DIRECT') {
|
|
651
|
+
throw new FusionError(EVENT_CONFLICT, 'delegated downgrade must use an explicit fallback event, not ordinary completion')
|
|
652
|
+
}
|
|
653
|
+
return {
|
|
654
|
+
...state,
|
|
655
|
+
phase: 'COMPLETED',
|
|
656
|
+
verification: event.payload.verification,
|
|
657
|
+
lastSnapshot: event.payload.snapshot,
|
|
658
|
+
control: patchControl(state, { mode: 'completed' }),
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function assertNever(event: never): never {
|
|
663
|
+
throw new FusionError(EVENT_CONFLICT, `unknown event ${(event as FusionEvent).type}`)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function reduce(state: TaskState | undefined, event: FusionEvent): TaskState {
|
|
667
|
+
if (event.schemaVersion !== 1) throw new FusionError(EVENT_CONFLICT, `unsupported schemaVersion ${event.schemaVersion}`)
|
|
668
|
+
if (state?.appliedEventIds.includes(event.id)) return state
|
|
669
|
+
return applyPayload(state, event)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function reduceAll(events: readonly FusionEvent[]): TaskState {
|
|
673
|
+
let state: TaskState | undefined
|
|
674
|
+
for (const event of events) state = reduce(state, event)
|
|
675
|
+
if (!state) throw new FusionError(EVENT_CONFLICT, 'no events')
|
|
676
|
+
return state
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export { assertControlAllowsExecution }
|