@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.
Files changed (213) hide show
  1. package/INSTALL.md +89 -0
  2. package/INSTALL.zh.md +86 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +193 -0
  5. package/README.md +182 -2
  6. package/RELEASING.md +123 -0
  7. package/RELEASING.zh.md +115 -0
  8. package/contracts/fusion.ts +279 -0
  9. package/contracts/storage.sql +86 -0
  10. package/cordis.patch.yml +3 -0
  11. package/docs/COMPATIBILITY.md +162 -0
  12. package/docs/EVIDENCE.md +141 -0
  13. package/dshx.yml +39 -0
  14. package/lib/client.js +1920 -0
  15. package/lib/client.js.map +1 -0
  16. package/lib/dsh-fusion.js +12664 -0
  17. package/lib/types/activity.d.ts +20 -0
  18. package/lib/types/approval/logical.d.ts +10 -0
  19. package/lib/types/benchmark/naive.d.ts +37 -0
  20. package/lib/types/benchmark/native-run.d.ts +68 -0
  21. package/lib/types/benchmark/output-limits.d.ts +19 -0
  22. package/lib/types/bilingual.d.ts +2 -0
  23. package/lib/types/client/ActivityRows.d.ts +8 -0
  24. package/lib/types/client/FusionCache.d.ts +4 -0
  25. package/lib/types/client/FusionHint.d.ts +9 -0
  26. package/lib/types/client/FusionHistory.d.ts +5 -0
  27. package/lib/types/client/FusionPanelIcon.d.ts +13 -0
  28. package/lib/types/client/FusionRecovery.d.ts +5 -0
  29. package/lib/types/client/FusionSettings.d.ts +9 -0
  30. package/lib/types/client/FusionStatus.d.ts +5 -0
  31. package/lib/types/client/FusionTool.d.ts +4 -0
  32. package/lib/types/client/activity-model.d.ts +14 -0
  33. package/lib/types/client/i18n.d.ts +15 -0
  34. package/lib/types/client/index.d.ts +22 -0
  35. package/lib/types/client/settings-events.d.ts +2 -0
  36. package/lib/types/client/status-polling.d.ts +9 -0
  37. package/lib/types/context/guard.d.ts +21 -0
  38. package/lib/types/context/handoff.d.ts +17 -0
  39. package/lib/types/contracts.d.ts +449 -0
  40. package/lib/types/digest.d.ts +5 -0
  41. package/lib/types/dsh-fusion.d.ts +13 -0
  42. package/lib/types/errors.d.ts +28 -0
  43. package/lib/types/evidence/gate.d.ts +23 -0
  44. package/lib/types/evidence/receipts.d.ts +65 -0
  45. package/lib/types/execution/write-lease.d.ts +42 -0
  46. package/lib/types/host/bindings.d.ts +30 -0
  47. package/lib/types/host/cache-defaults.d.ts +48 -0
  48. package/lib/types/host/cache-policy.d.ts +155 -0
  49. package/lib/types/host/change-evidence.d.ts +38 -0
  50. package/lib/types/host/compaction-routing.d.ts +15 -0
  51. package/lib/types/host/coordinator.d.ts +169 -0
  52. package/lib/types/host/evidence-page.d.ts +13 -0
  53. package/lib/types/host/history.d.ts +88 -0
  54. package/lib/types/host/http.d.ts +4 -0
  55. package/lib/types/host/model-output.d.ts +13 -0
  56. package/lib/types/host/native-activity.d.ts +26 -0
  57. package/lib/types/host/native-approvals.d.ts +29 -0
  58. package/lib/types/host/native-auxiliary.d.ts +17 -0
  59. package/lib/types/host/native-budget.d.ts +33 -0
  60. package/lib/types/host/native-checks.d.ts +104 -0
  61. package/lib/types/host/native-compaction.d.ts +8 -0
  62. package/lib/types/host/native-context.d.ts +36 -0
  63. package/lib/types/host/native-effects.d.ts +75 -0
  64. package/lib/types/host/native-evidence.d.ts +4 -0
  65. package/lib/types/host/native-exploration.d.ts +15 -0
  66. package/lib/types/host/native-keepalive.d.ts +82 -0
  67. package/lib/types/host/native-model-control.d.ts +99 -0
  68. package/lib/types/host/native-on-demand-context.d.ts +39 -0
  69. package/lib/types/host/native-request.d.ts +5 -0
  70. package/lib/types/host/native-review.d.ts +11 -0
  71. package/lib/types/host/native-role-sandbox.d.ts +49 -0
  72. package/lib/types/host/native-scopes.d.ts +49 -0
  73. package/lib/types/host/native-selection.d.ts +29 -0
  74. package/lib/types/host/native-task-context.d.ts +39 -0
  75. package/lib/types/host/native-usage.d.ts +46 -0
  76. package/lib/types/host/native-worker-brief.d.ts +74 -0
  77. package/lib/types/host/native-worker-notices.d.ts +15 -0
  78. package/lib/types/host/native-worker.d.ts +44 -0
  79. package/lib/types/host/native-workflow.d.ts +41 -0
  80. package/lib/types/host/route-transport.d.ts +22 -0
  81. package/lib/types/host/settings.d.ts +39 -0
  82. package/lib/types/host/shell.d.ts +11 -0
  83. package/lib/types/host/status.d.ts +7 -0
  84. package/lib/types/host/subagent-seam.d.ts +10 -0
  85. package/lib/types/host/tool-table.d.ts +35 -0
  86. package/lib/types/host/worker-terminal.d.ts +68 -0
  87. package/lib/types/host/workspace-guard.d.ts +13 -0
  88. package/lib/types/host/workspace.d.ts +52 -0
  89. package/lib/types/identity.d.ts +64 -0
  90. package/lib/types/index.d.ts +34 -0
  91. package/lib/types/message-source.d.ts +29 -0
  92. package/lib/types/profile/compactor.d.ts +5 -0
  93. package/lib/types/profile/keepalive.d.ts +3 -0
  94. package/lib/types/profile/resolve.d.ts +17 -0
  95. package/lib/types/prompts.d.ts +8 -0
  96. package/lib/types/recovery/reconcile.d.ts +28 -0
  97. package/lib/types/review/dispatch.d.ts +20 -0
  98. package/lib/types/review/protocol.d.ts +51 -0
  99. package/lib/types/review/ticket.d.ts +32 -0
  100. package/lib/types/status.d.ts +88 -0
  101. package/lib/types/store-migration.d.ts +29 -0
  102. package/lib/types/task/deletion.d.ts +64 -0
  103. package/lib/types/task/reducer.d.ts +6 -0
  104. package/lib/types/task/sqlite-store.d.ts +71 -0
  105. package/lib/types/task/state.d.ts +51 -0
  106. package/lib/types/task/status-index.d.ts +28 -0
  107. package/lib/types/task/store.d.ts +48 -0
  108. package/lib/types/usage/budget.d.ts +27 -0
  109. package/lib/types/usage/ledger.d.ts +66 -0
  110. package/lib/types/usage/normalize.d.ts +50 -0
  111. package/lib/types/usage/pricing.d.ts +18 -0
  112. package/package.json +178 -4
  113. package/profiles/fusion-auto.example.json +45 -0
  114. package/prompts/compact.md +7 -0
  115. package/prompts/lead.md +42 -0
  116. package/prompts/model-lead.md +13 -0
  117. package/prompts/model-worker.md +5 -0
  118. package/prompts/worker.md +21 -0
  119. package/src/activity.ts +23 -0
  120. package/src/approval/logical.ts +44 -0
  121. package/src/benchmark/naive.ts +216 -0
  122. package/src/benchmark/native-run.ts +215 -0
  123. package/src/benchmark/output-limits.ts +33 -0
  124. package/src/bilingual.ts +2 -0
  125. package/src/client/ActivityRows.tsx +59 -0
  126. package/src/client/FusionCache.tsx +130 -0
  127. package/src/client/FusionHint.tsx +39 -0
  128. package/src/client/FusionHistory.tsx +247 -0
  129. package/src/client/FusionPanelIcon.tsx +39 -0
  130. package/src/client/FusionRecovery.tsx +69 -0
  131. package/src/client/FusionSettings.tsx +205 -0
  132. package/src/client/FusionStatus.tsx +103 -0
  133. package/src/client/FusionTool.tsx +66 -0
  134. package/src/client/activity-model.ts +31 -0
  135. package/src/client/i18n.ts +30 -0
  136. package/src/client/index.ts +50 -0
  137. package/src/client/settings-events.ts +3 -0
  138. package/src/client/status-polling.ts +88 -0
  139. package/src/context/guard.ts +81 -0
  140. package/src/context/handoff.ts +28 -0
  141. package/src/contracts.ts +449 -0
  142. package/src/digest.ts +28 -0
  143. package/src/dsh-fusion.ts +321 -0
  144. package/src/errors.ts +34 -0
  145. package/src/evidence/gate.ts +290 -0
  146. package/src/evidence/receipts.ts +175 -0
  147. package/src/execution/write-lease.ts +187 -0
  148. package/src/host/bindings.ts +91 -0
  149. package/src/host/cache-defaults.ts +103 -0
  150. package/src/host/cache-policy.ts +168 -0
  151. package/src/host/change-evidence.ts +156 -0
  152. package/src/host/compaction-routing.ts +46 -0
  153. package/src/host/coordinator.ts +2160 -0
  154. package/src/host/evidence-page.ts +25 -0
  155. package/src/host/history.ts +114 -0
  156. package/src/host/http.ts +30 -0
  157. package/src/host/model-output.ts +22 -0
  158. package/src/host/native-activity.ts +199 -0
  159. package/src/host/native-approvals.ts +196 -0
  160. package/src/host/native-auxiliary.ts +19 -0
  161. package/src/host/native-budget.ts +112 -0
  162. package/src/host/native-checks.ts +474 -0
  163. package/src/host/native-compaction.ts +34 -0
  164. package/src/host/native-context.ts +204 -0
  165. package/src/host/native-effects.ts +260 -0
  166. package/src/host/native-evidence.ts +31 -0
  167. package/src/host/native-exploration.ts +114 -0
  168. package/src/host/native-keepalive.ts +292 -0
  169. package/src/host/native-model-control.ts +298 -0
  170. package/src/host/native-on-demand-context.ts +74 -0
  171. package/src/host/native-request.ts +12 -0
  172. package/src/host/native-review.ts +51 -0
  173. package/src/host/native-role-sandbox.ts +171 -0
  174. package/src/host/native-scopes.ts +203 -0
  175. package/src/host/native-selection.ts +110 -0
  176. package/src/host/native-task-context.ts +264 -0
  177. package/src/host/native-usage.ts +121 -0
  178. package/src/host/native-worker-brief.ts +122 -0
  179. package/src/host/native-worker-notices.ts +72 -0
  180. package/src/host/native-worker.ts +215 -0
  181. package/src/host/native-workflow.ts +103 -0
  182. package/src/host/route-transport.ts +54 -0
  183. package/src/host/settings.ts +93 -0
  184. package/src/host/shell.ts +13 -0
  185. package/src/host/status.ts +112 -0
  186. package/src/host/subagent-seam.ts +20 -0
  187. package/src/host/tool-table.ts +56 -0
  188. package/src/host/worker-terminal.ts +91 -0
  189. package/src/host/workspace-guard.ts +17 -0
  190. package/src/host/workspace.ts +184 -0
  191. package/src/identity.ts +76 -0
  192. package/src/index.ts +50 -0
  193. package/src/message-source.ts +31 -0
  194. package/src/profile/compactor.ts +26 -0
  195. package/src/profile/keepalive.ts +13 -0
  196. package/src/profile/resolve.ts +131 -0
  197. package/src/prompts.ts +32 -0
  198. package/src/recovery/reconcile.ts +38 -0
  199. package/src/review/dispatch.ts +48 -0
  200. package/src/review/protocol.ts +212 -0
  201. package/src/review/ticket.ts +166 -0
  202. package/src/status.ts +55 -0
  203. package/src/store-migration.ts +65 -0
  204. package/src/task/deletion.ts +105 -0
  205. package/src/task/reducer.ts +679 -0
  206. package/src/task/sqlite-store.ts +386 -0
  207. package/src/task/state.ts +72 -0
  208. package/src/task/status-index.ts +198 -0
  209. package/src/task/store.ts +287 -0
  210. package/src/usage/budget.ts +88 -0
  211. package/src/usage/ledger.ts +218 -0
  212. package/src/usage/normalize.ts +308 -0
  213. package/src/usage/pricing.ts +95 -0
@@ -0,0 +1,287 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import type { ArtifactRef, FusionEvent, OutboxRow, OutboxState, TaskId } from '../contracts.js'
4
+ import { ArtifactId } from '../contracts.js'
5
+ import { digestOf, sha256Hex } from '../digest.js'
6
+ import { CHECKSUM_MISMATCH, EVENT_CONFLICT, FLUSH_FAILED, FusionError, REVISION_CONFLICT, STORE_MIGRATION_REQUIRED } from '../errors.js'
7
+ import { reduce, reduceAll } from './reducer.js'
8
+ import type { PersistedTask, TaskState } from './state.js'
9
+
10
+ export interface StoreIo {
11
+ existsSync(path: string): boolean
12
+ mkdirSync(path: string): void
13
+ readFileSync(path: string): string
14
+ writeFileSync(path: string, data: string | Uint8Array): void
15
+ renameSync(from: string, to: string): void
16
+ rmSync(path: string): void
17
+ }
18
+
19
+ export const nodeIo: StoreIo = {
20
+ existsSync,
21
+ mkdirSync: path => mkdirSync(path, { recursive: true }),
22
+ readFileSync: path => readFileSync(path, 'utf8'),
23
+ writeFileSync: (path, data) => writeFileSync(path, data),
24
+ renameSync,
25
+ rmSync: path => rmSync(path, { force: true }),
26
+ }
27
+
28
+ export interface ArtifactRecord extends ArtifactRef {
29
+ readonly storageRef: string
30
+ }
31
+
32
+ export const STORE_PROJECTION_VERSION = 2
33
+
34
+ export interface DiskSnapshot {
35
+ readonly projectionVersion: typeof STORE_PROJECTION_VERSION
36
+ readonly checksum: string
37
+ readonly persisted: PersistedTask
38
+ readonly artifacts: readonly ArtifactRecord[]
39
+ }
40
+
41
+ /** The reducer and review producer share this port across file and transactional stores. */
42
+ export type FusionStore = Pick<FileFusionStore,
43
+ 'load' | 'replay' | 'create' | 'transact' | 'append' | 'putArtifact' |
44
+ 'prepareOutbox' | 'advanceOutbox' | 'outbox' | 'artifacts'>
45
+
46
+ export class FileFusionStore {
47
+ readonly #memory = new Map<TaskId, DiskSnapshot>()
48
+
49
+ constructor(
50
+ readonly root: string,
51
+ readonly io: StoreIo = nodeIo,
52
+ ) {}
53
+
54
+ load(taskId: TaskId): TaskState | undefined {
55
+ return this.#read(taskId)?.persisted.state
56
+ }
57
+
58
+ replay(taskId: TaskId): TaskState {
59
+ const snap = this.#require(taskId)
60
+ return reduceAll(snap.persisted.events)
61
+ }
62
+
63
+ create(event: Extract<FusionEvent, { type: 'task/created' }>): TaskState {
64
+ if (this.load(event.taskId)) throw new FusionError(EVENT_CONFLICT, 'task exists')
65
+ const state = reduce(undefined, event)
66
+ this.#persist(event.taskId, {
67
+ projectionVersion: STORE_PROJECTION_VERSION,
68
+ checksum: '',
69
+ persisted: { state, events: [event], outbox: [] },
70
+ artifacts: [],
71
+ })
72
+ return this.#require(event.taskId).persisted.state
73
+ }
74
+
75
+ transact(
76
+ taskId: TaskId,
77
+ expectedSeq: number,
78
+ mutate: (current: PersistedTask) => { events?: readonly FusionEvent[]; outbox?: readonly OutboxRow[] },
79
+ ): TaskState {
80
+ const current = this.#require(taskId)
81
+ if (current.persisted.state.seq !== expectedSeq) {
82
+ throw new FusionError(EVENT_CONFLICT, `expected seq ${expectedSeq}, have ${current.persisted.state.seq}`)
83
+ }
84
+ const patch = mutate(current.persisted)
85
+ let state = current.persisted.state
86
+ const nextEvents = [...current.persisted.events]
87
+ for (const event of patch.events ?? []) {
88
+ if (state.appliedEventIds.includes(event.id)) continue
89
+ state = reduce(state, event)
90
+ nextEvents.push(event)
91
+ }
92
+ this.#persist(taskId, {
93
+ projectionVersion: STORE_PROJECTION_VERSION,
94
+ checksum: '',
95
+ persisted: {
96
+ state,
97
+ events: nextEvents,
98
+ outbox: patch.outbox ?? current.persisted.outbox,
99
+ },
100
+ artifacts: current.artifacts,
101
+ })
102
+ return this.#require(taskId).persisted.state
103
+ }
104
+
105
+ append(taskId: TaskId, expectedRevision: number, events: readonly FusionEvent[]): TaskState {
106
+ const current = this.#require(taskId)
107
+ if (current.persisted.state.revision !== expectedRevision) {
108
+ throw new FusionError(REVISION_CONFLICT, `expected revision ${expectedRevision}, have ${current.persisted.state.revision}`)
109
+ }
110
+ let state = current.persisted.state
111
+ const nextEvents = [...current.persisted.events]
112
+ for (const event of events) {
113
+ if (state.appliedEventIds.includes(event.id)) continue
114
+ state = reduce(state, event)
115
+ nextEvents.push(event)
116
+ }
117
+ this.#persist(taskId, {
118
+ projectionVersion: STORE_PROJECTION_VERSION,
119
+ checksum: '',
120
+ persisted: { state, events: nextEvents, outbox: current.persisted.outbox },
121
+ artifacts: current.artifacts,
122
+ })
123
+ return this.#require(taskId).persisted.state
124
+ }
125
+
126
+ putArtifact(taskId: TaskId, bytes: Uint8Array, mediaType: string): ArtifactRef {
127
+ const current = this.#require(taskId)
128
+ const digest = sha256Hex(bytes)
129
+ const dir = this.#taskDir(taskId)
130
+ this.io.mkdirSync(join(dir, 'artifacts'))
131
+ const finalPath = join(dir, 'artifacts', digest)
132
+ const partPath = `${finalPath}.part`
133
+ this.io.writeFileSync(partPath, bytes)
134
+ this.io.renameSync(partPath, finalPath)
135
+ const record: ArtifactRecord = {
136
+ id: ArtifactId(digest),
137
+ digest,
138
+ ownerTaskId: taskId,
139
+ mediaType,
140
+ bytes: bytes.byteLength,
141
+ storageRef: finalPath,
142
+ }
143
+ this.#persist(taskId, {
144
+ projectionVersion: STORE_PROJECTION_VERSION,
145
+ checksum: '',
146
+ persisted: current.persisted,
147
+ artifacts: [...current.artifacts.filter(item => item.digest !== digest), record],
148
+ })
149
+ return record
150
+ }
151
+
152
+ prepareOutbox(row: OutboxRow): OutboxRow {
153
+ const current = this.#require(row.taskId)
154
+ const existing = current.persisted.outbox.find(item => item.operationId === row.operationId)
155
+ if (existing) return existing
156
+ this.#persist(row.taskId, {
157
+ projectionVersion: STORE_PROJECTION_VERSION,
158
+ checksum: '',
159
+ persisted: { ...current.persisted, outbox: [...current.persisted.outbox, { ...row, state: 'prepared' }] },
160
+ artifacts: current.artifacts,
161
+ })
162
+ return this.outbox(row.taskId).find(item => item.operationId === row.operationId)!
163
+ }
164
+
165
+ advanceOutbox(taskId: TaskId, operationId: string, from: OutboxState, to: OutboxState, patch: Partial<OutboxRow> = {}): OutboxRow {
166
+ const current = this.#require(taskId)
167
+ const next = current.persisted.outbox.map(row => {
168
+ if (row.operationId !== operationId) return row
169
+ if (row.state !== from) throw new FusionError(EVENT_CONFLICT, `outbox ${operationId} is ${row.state}, not ${from}`)
170
+ return { ...row, ...patch, state: to }
171
+ })
172
+ if (!next.some(row => row.operationId === operationId)) {
173
+ throw new FusionError(EVENT_CONFLICT, `outbox ${operationId} missing`)
174
+ }
175
+ this.#persist(taskId, {
176
+ projectionVersion: STORE_PROJECTION_VERSION,
177
+ checksum: '',
178
+ persisted: { ...current.persisted, outbox: next },
179
+ artifacts: current.artifacts,
180
+ })
181
+ return this.outbox(taskId).find(row => row.operationId === operationId)!
182
+ }
183
+
184
+ outbox(taskId: TaskId): readonly OutboxRow[] {
185
+ return this.#require(taskId).persisted.outbox
186
+ }
187
+
188
+ artifacts(taskId: TaskId): readonly ArtifactRecord[] {
189
+ return this.#require(taskId).artifacts
190
+ }
191
+
192
+ #taskDir(taskId: TaskId): string {
193
+ return join(this.root, 'tasks', taskId)
194
+ }
195
+
196
+ #read(taskId: TaskId): DiskSnapshot | undefined {
197
+ const cached = this.#memory.get(taskId)
198
+ if (cached) return cached
199
+ const path = join(this.#taskDir(taskId), 'snapshot.json')
200
+ if (!this.io.existsSync(path)) return undefined
201
+ const raw = this.io.readFileSync(path)
202
+ const parsed = decodeStoreSnapshot(raw, taskId)
203
+ this.#memory.set(taskId, parsed)
204
+ return parsed
205
+ }
206
+
207
+ #require(taskId: TaskId): DiskSnapshot {
208
+ const snap = this.#read(taskId)
209
+ if (!snap) throw new FusionError(EVENT_CONFLICT, `unknown task ${taskId}`)
210
+ return snap
211
+ }
212
+
213
+ #persist(taskId: TaskId, next: DiskSnapshot): void {
214
+ const checksum = digestOf({ persisted: next.persisted, artifacts: next.artifacts })
215
+ const snapshot: DiskSnapshot = { ...next, projectionVersion: STORE_PROJECTION_VERSION, checksum }
216
+ const dir = this.#taskDir(taskId)
217
+ this.io.mkdirSync(dir)
218
+ const finalPath = join(dir, 'snapshot.json')
219
+ const tmpPath = `${finalPath}.tmp`
220
+ try {
221
+ this.io.writeFileSync(tmpPath, `${JSON.stringify(snapshot, null, 2)}\n`)
222
+ this.io.renameSync(tmpPath, finalPath)
223
+ } catch (error) {
224
+ try {
225
+ this.io.rmSync(tmpPath)
226
+ } catch {
227
+ /* ignore */
228
+ }
229
+ throw new FusionError(FLUSH_FAILED, error instanceof Error ? error.message : 'flush failed')
230
+ }
231
+ this.#memory.set(taskId, snapshot)
232
+ }
233
+ }
234
+
235
+ /** Validate before either backend admits a persisted execution projection. */
236
+ export function decodeStoreSnapshot(raw: string, taskId: TaskId): DiskSnapshot {
237
+ let parsed: DiskSnapshot
238
+ try { parsed = JSON.parse(raw) as DiskSnapshot } catch {
239
+ throw new FusionError(STORE_MIGRATION_REQUIRED, `snapshot for ${taskId} is not a JSON store document`)
240
+ }
241
+ if (!parsed || typeof parsed !== 'object' || !parsed.persisted) {
242
+ throw new FusionError(STORE_MIGRATION_REQUIRED, `snapshot for ${taskId} is not a store document`)
243
+ }
244
+ if (parsed.checksum !== digestOf({ persisted: parsed.persisted, artifacts: parsed.artifacts })) {
245
+ throw new FusionError(CHECKSUM_MISMATCH, `corrupt snapshot for ${taskId}`)
246
+ }
247
+ requireStorageProjectionVersion(parsed)
248
+ const state = parsed.persisted.state
249
+ const control = state?.control
250
+ if (!control || !['running', 'paused', 'stop-requested', 'cancelled', 'completed'].includes(control.mode)
251
+ || !Array.isArray(control.pendingApprovalIds) || !control.pendingApprovalIds.every(id => typeof id === 'string')
252
+ || typeof control.budgetBlocked !== 'boolean' || typeof control.recovering !== 'boolean'
253
+ || typeof control.outcomeUnknown !== 'boolean'
254
+ || !Array.isArray(state.pendingApprovalIds) || !state.pendingApprovals
255
+ || !Array.isArray(state.appliedEventIds) || !Array.isArray(state.ignoredReviewResults)
256
+ || !Array.isArray(state.staleValidations) || !Number.isSafeInteger(state.reviewGeneration)
257
+ || !Array.isArray(parsed.persisted.events) || !Array.isArray(parsed.persisted.outbox)
258
+ || !Array.isArray(parsed.artifacts) || state.taskId !== taskId) {
259
+ throw new FusionError(STORE_MIGRATION_REQUIRED, `snapshot for ${taskId} has an incomplete execution projection`)
260
+ }
261
+ return parsed
262
+ }
263
+
264
+ export function requireStorageProjectionVersion(raw: unknown): asserts raw is DiskSnapshot {
265
+ if (!raw || typeof raw !== 'object') throw new FusionError(STORE_MIGRATION_REQUIRED, 'INVALID_STORE_DOCUMENT')
266
+ if ((raw as { projectionVersion?: number }).projectionVersion !== STORE_PROJECTION_VERSION) {
267
+ throw new FusionError(STORE_MIGRATION_REQUIRED, 'STORE_MIGRATION_REQUIRED')
268
+ }
269
+ }
270
+
271
+ export function failingRenameIo(base: StoreIo, failOnce = true): StoreIo {
272
+ let failed = false
273
+ return {
274
+ ...base,
275
+ renameSync(from, to) {
276
+ if (dirname(to).endsWith('artifacts')) {
277
+ base.renameSync(from, to)
278
+ return
279
+ }
280
+ if (!failed || !failOnce) {
281
+ failed = true
282
+ throw new Error('injected flush failure')
283
+ }
284
+ base.renameSync(from, to)
285
+ },
286
+ }
287
+ }
@@ -0,0 +1,88 @@
1
+ import { FusionError, SPEND_UNAUTHORIZED } from '../errors.js'
2
+ import type { UsdDecimal } from '../contracts.js'
3
+
4
+ export interface SpendingAuthorization {
5
+ schemaVersion: 1
6
+ approved: boolean
7
+ authorizationId: string | null
8
+ currency: string
9
+ maximumTotal: string | null
10
+ allowedModels: readonly string[]
11
+ expiresAt: string | null
12
+ approvedBy: string | null
13
+ }
14
+
15
+ export interface Reservation {
16
+ readonly authorizationId: string
17
+ readonly reserved: UsdDecimal
18
+ readonly remaining: UsdDecimal | null
19
+ }
20
+
21
+ export class BudgetLedger {
22
+ #spent = 0
23
+ #reserved = 0
24
+
25
+ constructor(readonly authorization: SpendingAuthorization) {}
26
+
27
+ assertAuthorized(model?: string): void {
28
+ if (!this.authorization.approved || !this.authorization.authorizationId) {
29
+ throw new FusionError(SPEND_UNAUTHORIZED, 'no approved spending authorization')
30
+ }
31
+ if (model && this.authorization.allowedModels.length && !this.authorization.allowedModels.includes(model)) {
32
+ throw new FusionError(SPEND_UNAUTHORIZED, `model ${model} is not on the authorization allowlist`)
33
+ }
34
+ }
35
+
36
+ reserve(estimatedUsd: number, model?: string): Reservation {
37
+ this.assertAuthorized(model)
38
+ if (!Number.isFinite(estimatedUsd) || estimatedUsd < 0) throw new TypeError('estimatedUsd must be finite and nonnegative')
39
+ const cap = this.authorization.maximumTotal === null ? null : Number(this.authorization.maximumTotal)
40
+ if (cap !== null && this.#spent + this.#reserved + estimatedUsd > cap) {
41
+ throw new FusionError(SPEND_UNAUTHORIZED, 'budget reservation would exceed maximumTotal')
42
+ }
43
+ this.#reserved += estimatedUsd
44
+ const remaining = cap === null ? null : (cap - this.#spent - this.#reserved).toString()
45
+ return {
46
+ authorizationId: this.authorization.authorizationId!,
47
+ reserved: estimatedUsd.toString() as UsdDecimal,
48
+ remaining: remaining as UsdDecimal | null,
49
+ }
50
+ }
51
+
52
+ settle(reserved: Reservation, actualUsd: number | null): void {
53
+ const reservedN = Number(reserved.reserved)
54
+ if (actualUsd === null) {
55
+ return
56
+ }
57
+ this.#reserved = Math.max(0, this.#reserved - reservedN)
58
+ if (!Number.isFinite(actualUsd) || actualUsd < 0) throw new TypeError('actualUsd must be finite and nonnegative')
59
+ const cap = this.authorization.maximumTotal === null ? null : Number(this.authorization.maximumTotal)
60
+ if (cap !== null && this.#spent + actualUsd > cap) {
61
+ throw new FusionError(SPEND_UNAUTHORIZED, 'actual spend would exceed maximumTotal')
62
+ }
63
+ this.#spent += actualUsd
64
+ }
65
+
66
+ get reserved(): number {
67
+ return this.#reserved
68
+ }
69
+
70
+ get spent(): number {
71
+ return this.#spent
72
+ }
73
+ }
74
+
75
+ export function loadSpendingAuthorization(raw: unknown): SpendingAuthorization {
76
+ const value = raw as SpendingAuthorization
77
+ if (!value || value.schemaVersion !== 1) throw new TypeError('unsupported spending authorization schema')
78
+ return {
79
+ schemaVersion: 1,
80
+ approved: value.approved === true,
81
+ authorizationId: value.authorizationId ?? null,
82
+ currency: value.currency ?? 'USD',
83
+ maximumTotal: value.maximumTotal ?? null,
84
+ allowedModels: Array.isArray(value.allowedModels) ? value.allowedModels : [],
85
+ expiresAt: value.expiresAt ?? null,
86
+ approvedBy: value.approvedBy ?? null,
87
+ }
88
+ }
@@ -0,0 +1,218 @@
1
+ import { digestOf } from '../digest.js'
2
+ import { FusionError, USAGE_MERGE_CONFLICT } from '../errors.js'
3
+
4
+ export type Count =
5
+ | { state: 'known'; tokens: number }
6
+ | { state: 'unknown' }
7
+ | { state: 'not_applicable' }
8
+
9
+ export const known = (tokens: number): Count => ({ state: 'known', tokens: safeTokens(tokens) })
10
+ export const unknown = (): Count => ({ state: 'unknown' })
11
+ export const na = (): Count => ({ state: 'not_applicable' })
12
+
13
+ export type CacheWrites =
14
+ | { kind: 'not_applicable' }
15
+ | { kind: 'unknown' }
16
+ | { kind: 'aggregate'; tokens: Count; rateKey: string }
17
+ | { kind: 'details'; buckets: Readonly<Record<string, { tokens: Count; rateKey: string }>> }
18
+
19
+ export interface CanonicalBill {
20
+ uncachedInput: Count
21
+ cacheRead: Count
22
+ output: Count
23
+ cacheWrite: CacheWrites
24
+ reasoning: { kind: 'included' | 'separate' | 'unknown' | 'not_applicable'; tokens: Count }
25
+ }
26
+
27
+ export interface UsageRequestKey {
28
+ provider: string
29
+ model: string
30
+ requestId: string
31
+ attemptId: string
32
+ contractDigest: string
33
+ }
34
+
35
+ export interface UsageObservation {
36
+ id: string
37
+ key: UsageRequestKey
38
+ sequence: number
39
+ mode: 'snapshot' | 'delta' | 'final'
40
+ bill: CanonicalBill
41
+ }
42
+
43
+ export interface UsageLedgerV2 {
44
+ schemaVersion: 2
45
+ key: UsageRequestKey
46
+ observations: readonly UsageObservation[]
47
+ }
48
+
49
+ export interface UsageProjection {
50
+ bill?: CanonicalBill
51
+ authority: 'none' | 'provisional' | 'final'
52
+ }
53
+
54
+ function requireThat(condition: unknown, code: string, message = code): asserts condition {
55
+ if (!condition) throw new FusionError(USAGE_MERGE_CONFLICT, message)
56
+ }
57
+
58
+ function safeTokens(n: number): number {
59
+ requireThat(Number.isSafeInteger(n) && n >= 0, 'INVALID_TOKEN_COUNT')
60
+ return n
61
+ }
62
+
63
+ function same(a: unknown, b: unknown): boolean {
64
+ return digestOf(a) === digestOf(b)
65
+ }
66
+
67
+ function freezeDeep<T>(value: T): Readonly<T> {
68
+ if (value && typeof value === 'object') {
69
+ for (const child of Object.values(value as Record<string, unknown>)) freezeDeep(child)
70
+ Object.freeze(value)
71
+ }
72
+ return value as Readonly<T>
73
+ }
74
+
75
+ export function newLedger(key: UsageRequestKey): UsageLedgerV2 {
76
+ requireThat(Object.values(key).every(value => typeof value === 'string' && value.length > 0), 'INVALID_REQUEST_KEY')
77
+ return freezeDeep({ schemaVersion: 2, key: structuredClone(key), observations: [] })
78
+ }
79
+
80
+ function validCount(value: Count): void {
81
+ requireThat(value && ['known', 'unknown', 'not_applicable'].includes(value.state), 'INVALID_COUNT')
82
+ if (value.state === 'known') safeTokens(value.tokens)
83
+ }
84
+
85
+ function validateBill(bill: CanonicalBill): void {
86
+ validCount(bill.uncachedInput)
87
+ validCount(bill.cacheRead)
88
+ validCount(bill.output)
89
+ validCount(bill.reasoning.tokens)
90
+ requireThat(['included', 'separate', 'unknown', 'not_applicable'].includes(bill.reasoning.kind), 'INVALID_REASONING_CONTRACT')
91
+ if (bill.reasoning.kind === 'included' && bill.reasoning.tokens.state === 'known' && bill.output.state === 'known') {
92
+ requireThat(bill.reasoning.tokens.tokens <= bill.output.tokens, 'REASONING_EXCEEDS_OUTPUT')
93
+ }
94
+ if (bill.reasoning.kind === 'not_applicable') {
95
+ requireThat(bill.reasoning.tokens.state === 'not_applicable', 'REASONING_NOT_APPLICABLE_CONFLICT')
96
+ }
97
+ const write = bill.cacheWrite
98
+ requireThat(['aggregate', 'details', 'unknown', 'not_applicable'].includes(write.kind), 'INVALID_CACHE_LAYOUT')
99
+ if (write.kind === 'aggregate') {
100
+ validCount(write.tokens)
101
+ requireThat(write.rateKey.length > 0, 'RATE_KEY_REQUIRED')
102
+ }
103
+ if (write.kind === 'details') {
104
+ requireThat(Object.keys(write.buckets).length > 0, 'EMPTY_CACHE_DETAIL')
105
+ for (const bucket of Object.values(write.buckets)) {
106
+ validCount(bucket.tokens)
107
+ requireThat(bucket.rateKey.length > 0, 'RATE_KEY_REQUIRED')
108
+ }
109
+ }
110
+ }
111
+
112
+ function addCount(first: Count, second: Count): Count {
113
+ if (first.state === 'unknown' || second.state === 'unknown') return unknown()
114
+ if (first.state === 'not_applicable') return second
115
+ if (second.state === 'not_applicable') return first
116
+ return known(first.tokens + second.tokens)
117
+ }
118
+
119
+ function addWrites(first: CacheWrites, second: CacheWrites): CacheWrites {
120
+ if (first.kind === 'unknown' || second.kind === 'unknown') return { kind: 'unknown' }
121
+ if (first.kind === 'not_applicable') return second
122
+ if (second.kind === 'not_applicable') return first
123
+ requireThat(first.kind === second.kind, 'CACHE_LAYOUT_MIX_REQUIRES_EXPLICIT_CONVERSION')
124
+ if (first.kind === 'aggregate' && second.kind === 'aggregate') {
125
+ requireThat(first.rateKey === second.rateKey, 'CACHE_RATE_CONFLICT')
126
+ return { kind: 'aggregate', rateKey: first.rateKey, tokens: addCount(first.tokens, second.tokens) }
127
+ }
128
+ requireThat(first.kind === 'details' && second.kind === 'details', 'CACHE_LAYOUT_CONFLICT')
129
+ const keys = [...new Set([...Object.keys(first.buckets), ...Object.keys(second.buckets)])].sort()
130
+ const buckets = Object.fromEntries(keys.map(key => {
131
+ const left = first.buckets[key]
132
+ const right = second.buckets[key]
133
+ if (!left) return [key, right] as const
134
+ if (!right) return [key, left] as const
135
+ requireThat(left.rateKey === right.rateKey, 'CACHE_RATE_CONFLICT')
136
+ return [key, { rateKey: left.rateKey, tokens: addCount(left.tokens, right.tokens) }] as const
137
+ }))
138
+ return { kind: 'details', buckets }
139
+ }
140
+
141
+ function addBill(first: CanonicalBill, second: CanonicalBill): CanonicalBill {
142
+ requireThat(first.reasoning.kind === second.reasoning.kind, 'DELTA_REASONING_CONTRACT_CONFLICT')
143
+ return {
144
+ uncachedInput: addCount(first.uncachedInput, second.uncachedInput),
145
+ cacheRead: addCount(first.cacheRead, second.cacheRead),
146
+ output: addCount(first.output, second.output),
147
+ cacheWrite: addWrites(first.cacheWrite, second.cacheWrite),
148
+ reasoning: { kind: first.reasoning.kind, tokens: addCount(first.reasoning.tokens, second.reasoning.tokens) },
149
+ }
150
+ }
151
+
152
+ export function projectLedger(ledger: UsageLedgerV2): UsageProjection {
153
+ const finals = ledger.observations.filter(item => item.mode === 'final')
154
+ if (finals.length) {
155
+ for (const item of finals) requireThat(same(item.bill, finals[0]!.bill), 'CONFLICTING_FINAL_BILLS')
156
+ return { authority: 'final', bill: finals[0]!.bill }
157
+ }
158
+ const snapshots = ledger.observations.filter(item => item.mode === 'snapshot').sort((a, b) => a.sequence - b.sequence)
159
+ const base = snapshots.at(-1)
160
+ const deltas = ledger.observations
161
+ .filter(item => item.mode === 'delta' && (!base || item.sequence > base.sequence))
162
+ .sort((a, b) => a.sequence - b.sequence)
163
+ let bill = base?.bill
164
+ for (const delta of deltas) bill = bill ? addBill(bill, delta.bill) : delta.bill
165
+ return { authority: bill ? 'provisional' : 'none', bill }
166
+ }
167
+
168
+ export function ingestObservation(ledger: UsageLedgerV2, observation: UsageObservation): UsageLedgerV2 {
169
+ requireThat(ledger.schemaVersion === 2 && same(ledger.key, observation.key), 'REQUEST_SCOPE_MISMATCH')
170
+ requireThat(observation.id.length > 0 && Number.isSafeInteger(observation.sequence) && observation.sequence >= 0, 'INVALID_OBSERVATION_ID_OR_SEQUENCE')
171
+ requireThat(['snapshot', 'delta', 'final'].includes(observation.mode), 'INVALID_OBSERVATION_MODE')
172
+ validateBill(observation.bill)
173
+ const existing = ledger.observations.find(item => item.id === observation.id)
174
+ if (existing) {
175
+ requireThat(digestOf(existing) === digestOf(observation), 'OBSERVATION_ID_CONFLICT')
176
+ return ledger
177
+ }
178
+ const sameSlot = ledger.observations.find(item => item.sequence === observation.sequence)
179
+ if (sameSlot) {
180
+ requireThat(sameSlot.mode === observation.mode && same(sameSlot.bill, observation.bill), 'OBSERVATION_SEQUENCE_CONFLICT')
181
+ requireThat(observation.mode !== 'delta', 'DELTA_REQUIRES_STABLE_EVENT_ID')
182
+ }
183
+ const next = freezeDeep({
184
+ ...ledger,
185
+ observations: [...ledger.observations, structuredClone(observation)],
186
+ })
187
+ projectLedger(next)
188
+ return next
189
+ }
190
+
191
+ export function ingestDurable(ledger: UsageLedgerV2, observation: UsageObservation): UsageLedgerV2 {
192
+ return ingestObservation(structuredClone(ledger), structuredClone(observation))
193
+ }
194
+
195
+ export function restoreLedger(raw: unknown): UsageLedgerV2 {
196
+ requireThat(raw && typeof raw === 'object', 'INVALID_LEDGER')
197
+ const value = raw as Partial<UsageLedgerV2>
198
+ requireThat(value.schemaVersion === 2 && value.key && Array.isArray(value.observations), 'UNSUPPORTED_LEDGER_FORMAT')
199
+ for (const field of ['provider', 'model', 'requestId', 'attemptId', 'contractDigest'] as const) {
200
+ requireThat(typeof value.key[field] === 'string' && value.key[field].length, 'INVALID_REQUEST_KEY')
201
+ }
202
+ let state = newLedger(structuredClone(value.key as UsageRequestKey))
203
+ for (const item of value.observations ?? []) state = ingestDurable(state, item)
204
+ return state
205
+ }
206
+
207
+ export function splitInput(total: Count, cached: Count, includesCache: boolean | null): Count {
208
+ if (includesCache === false) return total
209
+ if (includesCache === null) {
210
+ if (cached.state === 'not_applicable' || (cached.state === 'known' && cached.tokens === 0)) return total
211
+ return unknown()
212
+ }
213
+ if (total.state !== 'known' || cached.state !== 'known') return unknown()
214
+ requireThat(cached.tokens <= total.tokens, 'CACHE_EXCEEDS_INPUT')
215
+ return known(total.tokens - cached.tokens)
216
+ }
217
+
218
+ export const observationDigest = (observation: UsageObservation): string => digestOf(observation)