@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,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)
|