@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,386 @@
|
|
|
1
|
+
import { setImmediate as yieldToLoop } from 'node:timers/promises'
|
|
2
|
+
import { StatusIndex, StatusDataError, StatusPending } from './status-index.js'
|
|
3
|
+
import { taskSummary } from '../host/history.js'
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
5
|
+
import { mkdirSync } from 'node:fs'
|
|
6
|
+
import { dirname } from 'node:path'
|
|
7
|
+
import type { ArtifactRef, FusionEvent, OutboxRow, OutboxState, TaskId } from '../contracts.js'
|
|
8
|
+
import { ArtifactId } from '../contracts.js'
|
|
9
|
+
import { digestOf, sha256Hex } from '../digest.js'
|
|
10
|
+
import { CHECKSUM_MISMATCH, EVENT_CONFLICT, FusionError, REVISION_CONFLICT, STORE_MIGRATION_REQUIRED, TASK_BUSY, TASK_NOT_FOUND } from '../errors.js'
|
|
11
|
+
import { reduce, reduceAll } from './reducer.js'
|
|
12
|
+
import type { PersistedTask, TaskState } from './state.js'
|
|
13
|
+
import { decodeStoreSnapshot, STORE_PROJECTION_VERSION } from './store.js'
|
|
14
|
+
import type { ArtifactRecord, DiskSnapshot, FusionStore } from './store.js'
|
|
15
|
+
import { deletionVerdict, DeletionBlocked, recordedProcessId, unfinishedDocument } from './deletion.js'
|
|
16
|
+
import type { DeletionBlocker } from './deletion.js'
|
|
17
|
+
|
|
18
|
+
/** Durable families whose open row pins a task to a still-living process. */
|
|
19
|
+
const FOREIGN_WRITER_FAMILIES = ['native-effect', 'native-approval', 'check-invocation'] as const
|
|
20
|
+
|
|
21
|
+
/** `kill(pid, 0)` probes existence without signalling; EPERM still means the process exists. */
|
|
22
|
+
function processAlive(pid: number): boolean {
|
|
23
|
+
try { process.kill(pid, 0); return true }
|
|
24
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM' }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every document family whose id embeds the owning task as its second segment
|
|
29
|
+
* (`family:<taskId>:...`). Deleting a task sweeps exactly these plus the documents
|
|
30
|
+
* that name it in `taskId`, and never touches the session-scoped families
|
|
31
|
+
* (`binding:`, `model-control:`, `sandbox-intent:`), which outlive any one task.
|
|
32
|
+
* Keep this list in step with the `writeDocument` call sites in src/host/.
|
|
33
|
+
*/
|
|
34
|
+
const TASK_DOCUMENT_FAMILIES = [
|
|
35
|
+
'acceptance-amendment', 'activity', 'activity-link', 'brief', 'check', 'check-invocation',
|
|
36
|
+
'context-recovery', 'context-request', 'effects', 'effects-receipt', 'keepalive',
|
|
37
|
+
'lead-direct', 'lead-takeover', 'native-approval', 'native-effect', 'native-usage', 'progress',
|
|
38
|
+
'restore-index', 'runtime', 'scope-expansion', 'task-activity', 'task-checkpoint', 'task-compaction',
|
|
39
|
+
'task-context', 'task-index', 'task-origin', 'text', 'worker-delivery', 'worker-notice', 'worker-request',
|
|
40
|
+
'workflow-check-repair', 'workflow-effect-denied', 'workflow-progress', 'workflow-replan', 'workflow-stop',
|
|
41
|
+
'workflow-worker-resume', 'usage', 'usage-index',
|
|
42
|
+
] as const
|
|
43
|
+
|
|
44
|
+
/** Durable Host backend. Every read is fresh; CAS and outbox writes share one transaction. */
|
|
45
|
+
export interface DeletionAuthority {
|
|
46
|
+
/**
|
|
47
|
+
* Why this task still owns live work in this process, or `undefined` when it
|
|
48
|
+
* owns none. Only the Host can answer this: it holds the Agent, tool call,
|
|
49
|
+
* lease and stop registries. A caller without a Host passes nothing live.
|
|
50
|
+
*/
|
|
51
|
+
liveReason(taskId: TaskId): DeletionBlocker | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class SqliteFusionStore implements FusionStore {
|
|
55
|
+
readonly #db: DatabaseSync
|
|
56
|
+
readonly #status: StatusIndex
|
|
57
|
+
|
|
58
|
+
constructor(readonly filename: string) {
|
|
59
|
+
if (filename !== ':memory:') mkdirSync(dirname(filename), { recursive: true })
|
|
60
|
+
this.#db = new DatabaseSync(filename)
|
|
61
|
+
try {
|
|
62
|
+
this.#db.exec('PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON;')
|
|
63
|
+
const version = this.#db.prepare('PRAGMA user_version').get()?.user_version
|
|
64
|
+
if (version !== 0 && version !== 1) throw new FusionError(STORE_MIGRATION_REQUIRED, 'unknown Fusion database version')
|
|
65
|
+
if (version === 0) {
|
|
66
|
+
const count = this.#db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").get()?.n
|
|
67
|
+
if (count !== 0) throw new FusionError(STORE_MIGRATION_REQUIRED, 'unversioned nonempty Fusion database')
|
|
68
|
+
this.#db.exec(`BEGIN IMMEDIATE;
|
|
69
|
+
CREATE TABLE tasks (id TEXT PRIMARY KEY, snapshot TEXT NOT NULL);
|
|
70
|
+
CREATE TABLE artifacts (task_id TEXT NOT NULL REFERENCES tasks(id), digest TEXT NOT NULL, bytes BLOB NOT NULL, PRIMARY KEY(task_id, digest));
|
|
71
|
+
CREATE TABLE documents (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, json TEXT NOT NULL, checksum TEXT NOT NULL);
|
|
72
|
+
PRAGMA user_version = 1;
|
|
73
|
+
COMMIT;`)
|
|
74
|
+
}
|
|
75
|
+
this.#db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;')
|
|
76
|
+
this.#status = new StatusIndex(this.#db)
|
|
77
|
+
} catch (error) { this.#db.close(); throw error }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
statusSnapshot<T>(read: (index: StatusIndex) => T, backfill = true): T {
|
|
81
|
+
for (;;) {
|
|
82
|
+
try {
|
|
83
|
+
const result = this.#transaction(() => {
|
|
84
|
+
try { return { value: read(this.#status) } } catch (error) {
|
|
85
|
+
// Keep invalid-row markers durable rather than retrying their backfill on every poll.
|
|
86
|
+
if (error instanceof StatusDataError) return { error }
|
|
87
|
+
throw error
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
if ('error' in result) throw result.error
|
|
91
|
+
return result.value
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!(error instanceof StatusPending) || !backfill) throw error
|
|
94
|
+
while (this.#transaction(() => this.#status.flushBatch(error.task))) { /* release the writer lock between batches */ }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** HTTP reads yield between committed batches; cancellation retains resumable progress. */
|
|
100
|
+
async statusSnapshotAsync<T>(read: (index: StatusIndex) => T, signal?: AbortSignal): Promise<T> {
|
|
101
|
+
for (;;) {
|
|
102
|
+
signal?.throwIfAborted()
|
|
103
|
+
try { return this.statusSnapshot(read, false) } catch (error) {
|
|
104
|
+
if (!(error instanceof StatusPending)) throw error
|
|
105
|
+
this.#transaction(() => this.#status.flushBatch(error.task))
|
|
106
|
+
await yieldToLoop(undefined, { signal })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
close(): void { this.#db.close() }
|
|
112
|
+
|
|
113
|
+
listTaskIds(): readonly TaskId[] {
|
|
114
|
+
return this.#db.prepare('SELECT id FROM tasks ORDER BY id').all().map(row => {
|
|
115
|
+
if (typeof row.id !== 'string') throw new FusionError(STORE_MIGRATION_REQUIRED, 'invalid task identity')
|
|
116
|
+
return row.id as TaskId
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
listDocumentIds(prefix: string): readonly string[] {
|
|
121
|
+
return this.#db.prepare('SELECT id FROM documents WHERE substr(id, 1, ?) = ? ORDER BY id').all(prefix.length, prefix).map(row => {
|
|
122
|
+
if (typeof row.id !== 'string') throw new FusionError(STORE_MIGRATION_REQUIRED, 'invalid document identity')
|
|
123
|
+
return row.id
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
load(taskId: TaskId): TaskState | undefined { return this.#read(taskId)?.persisted.state }
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Remove one task record and everything that belongs to it.
|
|
131
|
+
*
|
|
132
|
+
* Liveness comes from `authority`, the in-process Host that owns the Agents,
|
|
133
|
+
* tool calls, leases and stops, plus durable rows a *different living* process
|
|
134
|
+
* left open. The persisted phase is deliberately not consulted: it describes
|
|
135
|
+
* where an interrupted process stopped, not whether anything still runs. See
|
|
136
|
+
* `deletion.ts` for why that distinction decides this bug.
|
|
137
|
+
*/
|
|
138
|
+
deleteTask(taskId: string, authority: DeletionAuthority): void {
|
|
139
|
+
this.#transaction(() => {
|
|
140
|
+
const current = this.load(taskId as TaskId)
|
|
141
|
+
if (!current) throw new FusionError(TASK_NOT_FOUND, 'task not found')
|
|
142
|
+
const verdict = deletionVerdict({
|
|
143
|
+
live: authority.liveReason(current.taskId),
|
|
144
|
+
foreign: this.#foreignWriters(current.taskId),
|
|
145
|
+
})
|
|
146
|
+
if (!verdict.allowed) throw new DeletionBlocked(verdict.blocker, verdict.detail)
|
|
147
|
+
this.#db.prepare('DELETE FROM artifacts WHERE task_id = ?').run(taskId)
|
|
148
|
+
this.#db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId)
|
|
149
|
+
|
|
150
|
+
// Documents that belong to this task either carry it as the second id segment
|
|
151
|
+
// (`family:<taskId>:...`) or hold it in a documented field. Both are swept, so a
|
|
152
|
+
// deleted task cannot leave a report, evidence row or progress window behind to
|
|
153
|
+
// be re-read by a later task that reuses nothing but the same id shape.
|
|
154
|
+
// Session-scoped documents (binding:, model-control:, sandbox-intent:) are never
|
|
155
|
+
// deleted here: they belong to the conversation, not to the task.
|
|
156
|
+
for (const prefix of TASK_DOCUMENT_FAMILIES) {
|
|
157
|
+
// Both the two-segment form (`task-index:<taskId>`) and the rest (`usage:<taskId>:<id>`).
|
|
158
|
+
this.#db.prepare("DELETE FROM documents WHERE id = ? || ':' || ? OR id LIKE ? || ':' || ? || ':%'")
|
|
159
|
+
.run(prefix, taskId, prefix, taskId)
|
|
160
|
+
}
|
|
161
|
+
this.#db.prepare('DELETE FROM documents WHERE id = ?').run(taskId)
|
|
162
|
+
this.#db.prepare(`
|
|
163
|
+
DELETE FROM documents
|
|
164
|
+
WHERE id NOT LIKE 'binding:%' AND id NOT LIKE 'model-control:%' AND id NOT LIKE 'sandbox-intent:%'
|
|
165
|
+
AND (json_extract(json, '$.taskId') = ? OR json_extract(json, '$.ticket.subject.taskId') = ?)
|
|
166
|
+
`).run(taskId, taskId)
|
|
167
|
+
|
|
168
|
+
this.#db.prepare('DELETE FROM fusion_status_facts_v1 WHERE task = ?').run(taskId)
|
|
169
|
+
this.#db.prepare('DELETE FROM fusion_status_totals_v1 WHERE task = ?').run(taskId)
|
|
170
|
+
for (const kind of TASK_DOCUMENT_FAMILIES) {
|
|
171
|
+
this.#db.prepare("DELETE FROM fusion_status_dirty_v1 WHERE id LIKE ? || ':' || ? || ':%'").run(kind, taskId)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const bindingDocs = this.#db.prepare("SELECT id, json, revision FROM documents WHERE id LIKE 'binding:%'").all()
|
|
175
|
+
for (const row of bindingDocs) {
|
|
176
|
+
if (typeof row.json === 'string' && typeof row.revision === 'number') {
|
|
177
|
+
try {
|
|
178
|
+
const data = JSON.parse(row.json) as { taskId?: unknown; selected?: boolean }
|
|
179
|
+
if (data.taskId === taskId && data.selected) {
|
|
180
|
+
const updated = { ...data, selected: false }
|
|
181
|
+
const json = JSON.stringify(updated)
|
|
182
|
+
const revision = row.revision + 1
|
|
183
|
+
this.#db.prepare('UPDATE documents SET revision = ?, json = ?, checksum = ? WHERE id = ?')
|
|
184
|
+
.run(revision, json, sha256Hex(Buffer.from(json)), row.id as string)
|
|
185
|
+
}
|
|
186
|
+
} catch { /* ignore */ }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Durable rows this task left unfinished under a *different* process that is
|
|
194
|
+
* still alive. A row owned by this process is not evidence: the in-process
|
|
195
|
+
* authority has already reported whether anything here is live, so such a row
|
|
196
|
+
* is residue from a call that was interrupted, not a running one.
|
|
197
|
+
*/
|
|
198
|
+
#foreignWriters(taskId: TaskId): readonly string[] {
|
|
199
|
+
const found: string[] = []
|
|
200
|
+
for (const family of FOREIGN_WRITER_FAMILIES) {
|
|
201
|
+
for (const id of this.listDocumentIds(`${family}:${taskId}:`)) {
|
|
202
|
+
const value = this.readDocument(id)?.value
|
|
203
|
+
if (!unfinishedDocument(family, value)) continue
|
|
204
|
+
const pid = recordedProcessId(value)
|
|
205
|
+
// No recorded owner, or this process: the in-process authority decided.
|
|
206
|
+
if (pid === undefined || pid === process.pid) continue
|
|
207
|
+
if (!processAlive(pid)) continue
|
|
208
|
+
found.push(`${family} owned by pid ${pid}`)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return found
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
events(taskId: TaskId): readonly FusionEvent[] { return this.#require(taskId).persisted.events }
|
|
215
|
+
|
|
216
|
+
replay(taskId: TaskId): TaskState { return reduceAll(this.#require(taskId).persisted.events) }
|
|
217
|
+
|
|
218
|
+
create(event: Extract<FusionEvent, { type: 'task/created' }>): TaskState {
|
|
219
|
+
return this.#transaction(() => {
|
|
220
|
+
if (this.#read(event.taskId)) throw new FusionError(EVENT_CONFLICT, 'task exists')
|
|
221
|
+
const state = reduce(undefined, event)
|
|
222
|
+
this.#write(event.taskId, { state, events: [event], outbox: [] }, [])
|
|
223
|
+
return state
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
transact(taskId: TaskId, expectedSeq: number, mutate: (current: PersistedTask) => {
|
|
228
|
+
events?: readonly FusionEvent[]; outbox?: readonly OutboxRow[]
|
|
229
|
+
}): TaskState {
|
|
230
|
+
return this.#transaction(() => {
|
|
231
|
+
const current = this.#require(taskId)
|
|
232
|
+
if (current.persisted.state.seq !== expectedSeq) throw new FusionError(EVENT_CONFLICT, 'task sequence changed')
|
|
233
|
+
const patch = mutate(structuredClone(current.persisted))
|
|
234
|
+
return this.#apply(taskId, current, patch.events ?? [], patch.outbox ?? current.persisted.outbox)
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
append(taskId: TaskId, expectedRevision: number, events: readonly FusionEvent[]): TaskState {
|
|
239
|
+
return this.#transaction(() => {
|
|
240
|
+
const current = this.#require(taskId)
|
|
241
|
+
if (current.persisted.state.revision !== expectedRevision) throw new FusionError(REVISION_CONFLICT, 'task revision changed')
|
|
242
|
+
return this.#apply(taskId, current, events, current.persisted.outbox)
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
putArtifact(taskId: TaskId, bytes: Uint8Array, mediaType: string): ArtifactRef {
|
|
247
|
+
return this.putArtifacts(taskId, [{ bytes, mediaType }])[0]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One atomic metadata update for a bounded workspace evidence batch. */
|
|
251
|
+
putArtifacts(taskId: TaskId, inputs: readonly { bytes: Uint8Array; mediaType: string }[]): ArtifactRef[] {
|
|
252
|
+
return this.#transaction(() => {
|
|
253
|
+
const current = this.#require(taskId)
|
|
254
|
+
const records = new Map(current.artifacts.map(record => [record.digest, record]))
|
|
255
|
+
const insert = this.#db.prepare('INSERT OR IGNORE INTO artifacts(task_id, digest, bytes) VALUES (?, ?, ?)')
|
|
256
|
+
const result = inputs.map(({ bytes, mediaType }) => {
|
|
257
|
+
const digest = sha256Hex(bytes)
|
|
258
|
+
const record: ArtifactRecord = {
|
|
259
|
+
id: ArtifactId(digest), digest, ownerTaskId: taskId, mediaType, bytes: bytes.byteLength,
|
|
260
|
+
storageRef: `sqlite:${taskId}:${digest}`,
|
|
261
|
+
}
|
|
262
|
+
insert.run(taskId, digest, bytes)
|
|
263
|
+
records.set(digest, record)
|
|
264
|
+
return record
|
|
265
|
+
})
|
|
266
|
+
this.#write(taskId, current.persisted, [...records.values()])
|
|
267
|
+
return result
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Load owned evidence bytes and verify content before trusting the reference. */
|
|
272
|
+
readArtifact(taskId: TaskId, digest: string): Uint8Array {
|
|
273
|
+
return this.readArtifacts(taskId, [digest])[0]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
readArtifacts(taskId: TaskId, digests: readonly string[]): Uint8Array[] {
|
|
277
|
+
const records = new Map<string, ArtifactRecord>(this.artifacts(taskId).map(record => [record.digest, record]))
|
|
278
|
+
const select = this.#db.prepare('SELECT bytes FROM artifacts WHERE task_id = ? AND digest = ?')
|
|
279
|
+
return digests.map(digest => {
|
|
280
|
+
const record = records.get(digest), row = select.get(taskId, digest)
|
|
281
|
+
if (!record || !(row?.bytes instanceof Uint8Array)) throw new FusionError(EVENT_CONFLICT, 'artifact not owned by task')
|
|
282
|
+
if (sha256Hex(row.bytes) !== digest || row.bytes.byteLength !== record.bytes) throw new FusionError(CHECKSUM_MISMATCH, 'corrupt artifact')
|
|
283
|
+
return row.bytes
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
prepareOutbox(row: OutboxRow): OutboxRow {
|
|
288
|
+
return this.#transaction(() => {
|
|
289
|
+
const current = this.#require(row.taskId)
|
|
290
|
+
const existing = current.persisted.outbox.find(item => item.operationId === row.operationId)
|
|
291
|
+
if (existing) return existing
|
|
292
|
+
const prepared: OutboxRow = { ...row, state: 'prepared' }
|
|
293
|
+
this.#write(row.taskId, { ...current.persisted, outbox: [...current.persisted.outbox, prepared] }, current.artifacts)
|
|
294
|
+
return prepared
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
advanceOutbox(taskId: TaskId, operationId: string, from: OutboxState, to: OutboxState, patch: Partial<OutboxRow> = {}): OutboxRow {
|
|
299
|
+
return this.#transaction(() => {
|
|
300
|
+
const current = this.#require(taskId)
|
|
301
|
+
const prior = current.persisted.outbox.find(item => item.operationId === operationId)
|
|
302
|
+
if (!prior || prior.state !== from) throw new FusionError(EVENT_CONFLICT, 'outbox state changed or missing')
|
|
303
|
+
if ((patch.operationId !== undefined && patch.operationId !== prior.operationId)
|
|
304
|
+
|| (patch.taskId !== undefined && patch.taskId !== prior.taskId)) throw new FusionError(EVENT_CONFLICT, 'outbox identity is immutable')
|
|
305
|
+
const next: OutboxRow = { ...prior, ...patch, state: to }
|
|
306
|
+
this.#write(taskId, { ...current.persisted, outbox: current.persisted.outbox.map(item => item.operationId === operationId ? next : item) }, current.artifacts)
|
|
307
|
+
return next
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
outbox(taskId: TaskId): readonly OutboxRow[] { return this.#require(taskId).persisted.outbox }
|
|
312
|
+
artifacts(taskId: TaskId): readonly ArtifactRecord[] { return this.#require(taskId).artifacts }
|
|
313
|
+
|
|
314
|
+
/** Host binding / usage documents require caller-side schema validation after loading. */
|
|
315
|
+
readDocument(id: string): { revision: number; value: unknown } | undefined {
|
|
316
|
+
const row = this.#db.prepare('SELECT revision, json, checksum FROM documents WHERE id = ?').get(id)
|
|
317
|
+
if (!row) return undefined
|
|
318
|
+
if (typeof row.json !== 'string' || typeof row.revision !== 'number') throw new FusionError(STORE_MIGRATION_REQUIRED, 'invalid Host document')
|
|
319
|
+
if (sha256Hex(Buffer.from(row.json)) !== row.checksum) throw new FusionError(CHECKSUM_MISMATCH, 'corrupt Host document')
|
|
320
|
+
return { revision: row.revision, value: JSON.parse(row.json) as unknown }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Compare-and-swap prevents an old Host instance overwriting a newer selection or bill. */
|
|
324
|
+
writeDocument(id: string, expectedRevision: number, value: unknown): number {
|
|
325
|
+
return this.#transaction(() => this.#writeDocument(id, expectedRevision, value))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
writeDocuments(rows: readonly { id: string; expectedRevision: number; value: unknown }[]): number[] {
|
|
329
|
+
return this.#transaction(() => rows.map(row => this.#writeDocument(row.id, row.expectedRevision, row.value)))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#writeDocument(id: string, expectedRevision: number, value: unknown): number {
|
|
333
|
+
const current = this.readDocument(id)
|
|
334
|
+
if ((current?.revision ?? 0) !== expectedRevision) throw new FusionError(REVISION_CONFLICT, 'Host document revision changed')
|
|
335
|
+
const json = JSON.stringify(value)
|
|
336
|
+
if (json === undefined) throw new Error('Host document must be JSON serializable')
|
|
337
|
+
const revision = expectedRevision + 1
|
|
338
|
+
this.#db.prepare('INSERT INTO documents VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET revision=excluded.revision, json=excluded.json, checksum=excluded.checksum')
|
|
339
|
+
.run(id, revision, json, sha256Hex(Buffer.from(json)))
|
|
340
|
+
this.#status.sync(id)
|
|
341
|
+
return revision
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#apply(taskId: TaskId, current: DiskSnapshot, events: readonly FusionEvent[], outbox: readonly OutboxRow[]): TaskState {
|
|
345
|
+
let state = current.persisted.state
|
|
346
|
+
const nextEvents = [...current.persisted.events]
|
|
347
|
+
for (const event of events) {
|
|
348
|
+
if (state.appliedEventIds.includes(event.id)) continue
|
|
349
|
+
state = reduce(state, event)
|
|
350
|
+
nextEvents.push(event)
|
|
351
|
+
}
|
|
352
|
+
this.#write(taskId, { state, events: nextEvents, outbox }, current.artifacts)
|
|
353
|
+
return state
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#read(taskId: TaskId): DiskSnapshot | undefined {
|
|
357
|
+
const row = this.#db.prepare('SELECT snapshot FROM tasks WHERE id = ?').get(taskId)
|
|
358
|
+
if (!row) return undefined
|
|
359
|
+
if (typeof row.snapshot !== 'string') throw new FusionError(STORE_MIGRATION_REQUIRED, 'invalid task snapshot')
|
|
360
|
+
return decodeStoreSnapshot(row.snapshot, taskId)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#require(taskId: TaskId): DiskSnapshot {
|
|
364
|
+
const current = this.#read(taskId)
|
|
365
|
+
if (!current) throw new FusionError(EVENT_CONFLICT, `unknown task ${taskId}`)
|
|
366
|
+
return current
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#write(taskId: TaskId, persisted: PersistedTask, artifacts: readonly ArtifactRecord[]): void {
|
|
370
|
+
const snapshot: DiskSnapshot = { projectionVersion: STORE_PROJECTION_VERSION, persisted, artifacts, checksum: digestOf({ persisted, artifacts }) }
|
|
371
|
+
this.#db.prepare('INSERT INTO tasks VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET snapshot=excluded.snapshot').run(taskId, JSON.stringify(snapshot))
|
|
372
|
+
const indexId = `task-index:${taskId}`
|
|
373
|
+
const prior = this.readDocument(indexId), summary = taskSummary(persisted.state, persisted.events[0]!.createdAt, persisted.events.at(-1)!.createdAt)
|
|
374
|
+
const priorTitle = (prior?.value as { title?: unknown } | undefined)?.title
|
|
375
|
+
if (!persisted.state.currentWorkOrder && typeof priorTitle === 'string') summary.title = priorTitle
|
|
376
|
+
this.#writeDocument(indexId, prior?.revision ?? 0, summary)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#transaction<T>(body: () => T): T {
|
|
380
|
+
this.#db.exec('BEGIN IMMEDIATE')
|
|
381
|
+
try { const result = body(); this.#db.exec('COMMIT'); return result } catch (error) {
|
|
382
|
+
this.#db.exec('ROLLBACK')
|
|
383
|
+
throw error
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ControlState,
|
|
3
|
+
Digest,
|
|
4
|
+
ExecutionIntent,
|
|
5
|
+
ExplorationReport,
|
|
6
|
+
FusionEvent,
|
|
7
|
+
LogicalApproval,
|
|
8
|
+
OutboxRow,
|
|
9
|
+
Phase,
|
|
10
|
+
ReviewGate,
|
|
11
|
+
ReviewReadinessReceipt,
|
|
12
|
+
ReviewResultV2,
|
|
13
|
+
ReviewTicket,
|
|
14
|
+
RunSelection,
|
|
15
|
+
SessionId,
|
|
16
|
+
SnapshotId,
|
|
17
|
+
TaskId,
|
|
18
|
+
VerificationLevel,
|
|
19
|
+
WorkOrder,
|
|
20
|
+
WorkerReport,
|
|
21
|
+
WriteLease,
|
|
22
|
+
} from '../contracts.js'
|
|
23
|
+
|
|
24
|
+
export interface AcceptedReview {
|
|
25
|
+
readonly ticket: ReviewTicket
|
|
26
|
+
readonly resultDigest: Digest
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TaskState {
|
|
30
|
+
readonly schemaVersion: 1
|
|
31
|
+
readonly taskId: TaskId
|
|
32
|
+
readonly revision: number
|
|
33
|
+
readonly seq: number
|
|
34
|
+
readonly phase: Phase
|
|
35
|
+
readonly parent: SessionId
|
|
36
|
+
readonly selection: RunSelection
|
|
37
|
+
readonly profileDigest?: Digest
|
|
38
|
+
readonly intent?: ExecutionIntent
|
|
39
|
+
readonly reservedChild?: SessionId
|
|
40
|
+
readonly acceptedChild?: SessionId
|
|
41
|
+
readonly acceptedMessageId?: string
|
|
42
|
+
readonly currentWorkOrder?: WorkOrder
|
|
43
|
+
readonly exploration?: ExplorationReport
|
|
44
|
+
readonly candidateReport?: WorkerReport
|
|
45
|
+
readonly validatedReport?: WorkerReport
|
|
46
|
+
readonly validatedReceipt?: ReviewReadinessReceipt
|
|
47
|
+
readonly activeReviewTicket?: ReviewTicket
|
|
48
|
+
readonly reviewResult?: ReviewResultV2
|
|
49
|
+
readonly reviewResultDigest?: Digest
|
|
50
|
+
readonly acceptedReview?: AcceptedReview
|
|
51
|
+
readonly reviewGeneration: number
|
|
52
|
+
readonly ignoredReviewResults: readonly ReviewResultV2[]
|
|
53
|
+
readonly staleValidations: readonly { readonly reportDigest: Digest; readonly reason: string }[]
|
|
54
|
+
/** Candidate if unvalidated, otherwise the validated report. Compatibility projection. */
|
|
55
|
+
readonly lastReport?: WorkerReport
|
|
56
|
+
readonly reviewGate?: ReviewGate
|
|
57
|
+
readonly verification: VerificationLevel
|
|
58
|
+
readonly pendingApprovalIds: readonly string[]
|
|
59
|
+
readonly pendingApprovals: Readonly<Record<string, LogicalApproval>>
|
|
60
|
+
readonly control: ControlState
|
|
61
|
+
readonly outcomeUnknown: boolean
|
|
62
|
+
readonly resumePhase?: Phase
|
|
63
|
+
readonly lease?: WriteLease
|
|
64
|
+
readonly lastSnapshot?: SnapshotId
|
|
65
|
+
readonly appliedEventIds: readonly string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface PersistedTask {
|
|
69
|
+
readonly state: TaskState
|
|
70
|
+
readonly events: readonly FusionEvent[]
|
|
71
|
+
readonly outbox: readonly OutboxRow[]
|
|
72
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { DatabaseSync, StatementSync } from 'node:sqlite'
|
|
2
|
+
import { sha256Hex } from '../digest.js'
|
|
3
|
+
import { projectLedger, restoreLedger } from '../usage/ledger.js'
|
|
4
|
+
import type { NativeUsageRecord } from '../host/native-usage.js'
|
|
5
|
+
import type { FusionStatus, RoleUsage } from '../status.js'
|
|
6
|
+
|
|
7
|
+
type TaskStatus = NonNullable<FusionStatus['task']>
|
|
8
|
+
const metrics = ['calls', 'finalCalls', 'provisionalCalls', 'unreportedCalls', 'compactionCalls', 'keepaliveCalls',
|
|
9
|
+
'inputTokens', 'inputReported', 'cacheReadTokens', 'cacheReadReported', 'outputTokens', 'outputReported', 'unsettled', 'checking', 'workerConversations'] as const
|
|
10
|
+
type Metric = typeof metrics[number]
|
|
11
|
+
type Totals = Record<Metric, number>
|
|
12
|
+
const empty = (): Totals => Object.fromEntries(metrics.map(key => [key, 0])) as Totals
|
|
13
|
+
const kinds = ['usage', 'native-effect', 'context-request', 'check-invocation'] as const
|
|
14
|
+
const relevant = (id: string) => kinds.some(kind => id.startsWith(`${kind}:`))
|
|
15
|
+
const condition = (ref: string) => kinds.map(kind => `${ref}.id GLOB '${kind}:*'`).join(' OR ')
|
|
16
|
+
|
|
17
|
+
export class StatusDataError extends Error {}
|
|
18
|
+
export class StatusPending extends Error {
|
|
19
|
+
constructor(readonly task: string) { super('Status projection needs backfill') }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Rebuildable read model. Triggers also track writes/deletes by older plugin processes. */
|
|
23
|
+
export class StatusIndex {
|
|
24
|
+
readonly #statements = new Map<string, StatementSync>()
|
|
25
|
+
constructor(readonly db: DatabaseSync) {
|
|
26
|
+
db.exec('BEGIN IMMEDIATE')
|
|
27
|
+
try {
|
|
28
|
+
const tablesExist = db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type='table' AND name IN ('fusion_status_dirty_v1','fusion_status_facts_v1','fusion_status_totals_v1')").get()?.n === 3
|
|
29
|
+
const triggersExist = db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE type='trigger' AND name IN ('fusion_status_insert_v1','fusion_status_update_v1','fusion_status_delete_v1')").get()?.n === 3
|
|
30
|
+
const exists = tablesExist && triggersExist
|
|
31
|
+
if (!exists) db.exec('DROP TABLE IF EXISTS fusion_status_dirty_v1; DROP TABLE IF EXISTS fusion_status_facts_v1; DROP TABLE IF EXISTS fusion_status_totals_v1;')
|
|
32
|
+
db.exec(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS fusion_status_dirty_v1 (id TEXT PRIMARY KEY);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS fusion_status_facts_v1 (
|
|
35
|
+
id TEXT PRIMARY KEY, task TEXT NOT NULL, kind TEXT NOT NULL, role TEXT NOT NULL,
|
|
36
|
+
stamp TEXT NOT NULL, session TEXT NOT NULL, active INTEGER NOT NULL, error TEXT,
|
|
37
|
+
preview TEXT NOT NULL, ${metrics.map(key => `${key} INTEGER NOT NULL`).join(',')});
|
|
38
|
+
CREATE INDEX IF NOT EXISTS fusion_status_recent_v1 ON fusion_status_facts_v1(task,kind,stamp DESC,id);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS fusion_status_context_v1 ON fusion_status_facts_v1(task,role,stamp DESC,id) WHERE kind='context-request';
|
|
40
|
+
CREATE INDEX IF NOT EXISTS fusion_status_active_v1 ON fusion_status_facts_v1(task,session) WHERE active=1;
|
|
41
|
+
CREATE INDEX IF NOT EXISTS fusion_status_errors_v1 ON fusion_status_facts_v1(id) WHERE error IS NOT NULL;
|
|
42
|
+
CREATE TABLE IF NOT EXISTS fusion_status_totals_v1 (
|
|
43
|
+
task TEXT NOT NULL, role TEXT NOT NULL, ${metrics.map(key => `${key} INTEGER NOT NULL`).join(',')}, PRIMARY KEY(task,role));
|
|
44
|
+
CREATE TRIGGER IF NOT EXISTS fusion_status_insert_v1 AFTER INSERT ON documents WHEN ${condition('NEW')}
|
|
45
|
+
BEGIN INSERT INTO fusion_status_dirty_v1 VALUES (NEW.id) ON CONFLICT(id) DO NOTHING; END;
|
|
46
|
+
CREATE TRIGGER IF NOT EXISTS fusion_status_update_v1 AFTER UPDATE ON documents WHEN ${condition('NEW')} OR ${condition('OLD')}
|
|
47
|
+
BEGIN INSERT INTO fusion_status_dirty_v1 VALUES (OLD.id) ON CONFLICT(id) DO NOTHING; INSERT INTO fusion_status_dirty_v1 VALUES (NEW.id) ON CONFLICT(id) DO NOTHING; END;
|
|
48
|
+
CREATE TRIGGER IF NOT EXISTS fusion_status_delete_v1 AFTER DELETE ON documents WHEN ${condition('OLD')}
|
|
49
|
+
BEGIN INSERT INTO fusion_status_dirty_v1 VALUES (OLD.id) ON CONFLICT(id) DO NOTHING; END;
|
|
50
|
+
`)
|
|
51
|
+
if (!exists) db.exec(`INSERT OR IGNORE INTO fusion_status_dirty_v1 SELECT id FROM documents WHERE ${condition('documents')}`)
|
|
52
|
+
db.exec('COMMIT')
|
|
53
|
+
} catch (error) { db.exec('ROLLBACK'); throw error }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#sql(sql: string): StatementSync {
|
|
57
|
+
let statement = this.#statements.get(sql)
|
|
58
|
+
if (!statement) { statement = this.db.prepare(sql); this.#statements.set(sql, statement) }
|
|
59
|
+
return statement
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Call inside the document transaction, after the source row is written. */
|
|
63
|
+
sync(id: string): void {
|
|
64
|
+
if (!relevant(id)) { this.#sql('DELETE FROM fusion_status_dirty_v1 WHERE id=?').run(id); return }
|
|
65
|
+
const prior = this.#sql('SELECT * FROM fusion_status_facts_v1 WHERE id=?').get(id)
|
|
66
|
+
if (prior) this.#add(String(prior.task), String(prior.role), prior as Totals, -1)
|
|
67
|
+
this.#sql('DELETE FROM fusion_status_facts_v1 WHERE id=?').run(id)
|
|
68
|
+
const source = this.#sql('SELECT json,checksum FROM documents WHERE id=?').get(id)
|
|
69
|
+
if (source) {
|
|
70
|
+
let task = '', role = 'other', stamp = '', session = '', active = 0, error: string | null = null, preview: unknown = {}
|
|
71
|
+
const kind = id.slice(0, id.indexOf(':')), counts = empty()
|
|
72
|
+
try {
|
|
73
|
+
if (typeof source.json !== 'string' || sha256Hex(Buffer.from(source.json)) !== source.checksum) throw new Error('corrupt Host document')
|
|
74
|
+
const row = JSON.parse(source.json)
|
|
75
|
+
task = kind === 'check-invocation' ? row.evidence?.taskId : row.taskId
|
|
76
|
+
if (typeof task !== 'string' || !id.startsWith(`${kind}:${task}:`) || (kind !== 'check-invocation' && row.schemaVersion !== 1)) throw new Error('Fusion 状态记录需要检查')
|
|
77
|
+
role = row.role === 'lead' || row.role === 'worker' ? row.role : 'other'
|
|
78
|
+
if (kind === 'usage') {
|
|
79
|
+
const value = row as NativeUsageRecord, projection = projectLedger(restoreLedger(value.ledger))
|
|
80
|
+
counts.calls = 1
|
|
81
|
+
counts.workerConversations = Number(value.role === 'worker' && (value.purpose === undefined || value.purpose === 'conversation'))
|
|
82
|
+
counts[projection.authority === 'final' ? 'finalCalls' : projection.authority === 'provisional' ? 'provisionalCalls' : 'unreportedCalls'] = 1
|
|
83
|
+
counts.compactionCalls = Number(value.purpose === 'compaction'); counts.keepaliveCalls = Number(value.purpose === 'cache-keepalive')
|
|
84
|
+
for (const [field, count] of [['input', projection.bill?.uncachedInput], ['cacheRead', projection.bill?.cacheRead], ['output', projection.bill?.output]] as const) {
|
|
85
|
+
if (count?.state === 'known') { counts[`${field}Tokens`] = count.tokens; counts[`${field}Reported`] = 1 }
|
|
86
|
+
}
|
|
87
|
+
stamp = value.startedAt; session = value.sessionId
|
|
88
|
+
active = Number(value.purpose === 'conversation' && value.endedAt === null)
|
|
89
|
+
if (!active) session = ''
|
|
90
|
+
preview = { role: value.role, purpose: value.purpose, provider: value.ledger.key.provider, model: value.ledger.key.model,
|
|
91
|
+
outcome: value.outcome, authority: projection.authority }
|
|
92
|
+
} else if (kind === 'native-effect') {
|
|
93
|
+
stamp = row.startedAt; counts.unsettled = Number(row.state === 'dispatch-started' || row.state === 'outcome-unknown')
|
|
94
|
+
preview = { role: row.role, name: row.toolName, state: row.state, failed: row.nativeIsError === true }
|
|
95
|
+
} else if (kind === 'context-request') {
|
|
96
|
+
stamp = row.checkedAt
|
|
97
|
+
preview = { role: row.role, purpose: row.purpose, inputTokens: row.measurement.inputTokens, budget: row.budget,
|
|
98
|
+
quality: row.measurement.quality, admitted: row.admitted, checkedAt: row.checkedAt }
|
|
99
|
+
} else counts.checking = Number(row.evidence.state === 'started')
|
|
100
|
+
if (typeof stamp !== 'string' || typeof session !== 'string') throw new Error('Fusion 状态记录需要检查')
|
|
101
|
+
} catch (failure) {
|
|
102
|
+
error = failure instanceof Error ? failure.message : String(failure)
|
|
103
|
+
task = ''; role = 'other'; stamp = ''; session = ''; active = 0; preview = {}; Object.assign(counts, empty())
|
|
104
|
+
}
|
|
105
|
+
this.#sql(`INSERT INTO fusion_status_facts_v1 VALUES (${Array(9 + metrics.length).fill('?').join(',')})`)
|
|
106
|
+
.run(id, task, kind, role, stamp, session, active, error, JSON.stringify(preview), ...metrics.map(key => counts[key]))
|
|
107
|
+
this.#add(task, role, counts, 1)
|
|
108
|
+
}
|
|
109
|
+
this.#sql('DELETE FROM fusion_status_dirty_v1 WHERE id=?').run(id)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#add(task: string, role: string, value: Totals, sign: number): void {
|
|
113
|
+
this.#sql(`INSERT INTO fusion_status_totals_v1 VALUES (${Array(2 + metrics.length).fill('?').join(',')})
|
|
114
|
+
ON CONFLICT(task,role) DO UPDATE SET ${metrics.map(key => `${key}=${key}+excluded.${key}`).join(',')}`)
|
|
115
|
+
.run(task, role, ...metrics.map(key => sign * Number(value[key])))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Each batch is its own transaction, so a legacy backfill does not monopolize the writer lock. */
|
|
119
|
+
flushBatch(task: string): boolean {
|
|
120
|
+
let changed = false
|
|
121
|
+
for (const kind of kinds) {
|
|
122
|
+
const batch = this.#sql('SELECT id FROM fusion_status_dirty_v1 WHERE id>=? AND id<? ORDER BY id LIMIT 256')
|
|
123
|
+
.all(`${kind}:${task}:`, `${kind}:${task};`)
|
|
124
|
+
for (const row of batch) { this.sync(String(row.id)); changed = true }
|
|
125
|
+
}
|
|
126
|
+
return changed
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#ready(task: string): void {
|
|
130
|
+
for (const kind of kinds) {
|
|
131
|
+
if (this.#sql('SELECT id FROM fusion_status_dirty_v1 WHERE id>=? AND id<? LIMIT 1').get(`${kind}:${task}:`, `${kind}:${task};`)) {
|
|
132
|
+
throw new StatusPending(task)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Bounded memory even for a legacy database; the surrounding transaction is atomic. */
|
|
138
|
+
flush(task?: string): void {
|
|
139
|
+
const ranges = task === undefined ? [undefined] : kinds.map(kind => [`${kind}:${task}:`, `${kind}:${task};`])
|
|
140
|
+
for (const range of ranges) for (;;) {
|
|
141
|
+
const batch = range
|
|
142
|
+
? this.#sql('SELECT id FROM fusion_status_dirty_v1 WHERE id>=? AND id<? ORDER BY id LIMIT 256').all(...range)
|
|
143
|
+
: this.#sql('SELECT id FROM fusion_status_dirty_v1 ORDER BY id LIMIT 256').all()
|
|
144
|
+
if (!batch.length) break
|
|
145
|
+
for (const row of batch) this.sync(String(row.id))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
rebuild(): void {
|
|
150
|
+
this.db.exec(`DELETE FROM fusion_status_facts_v1; DELETE FROM fusion_status_totals_v1;
|
|
151
|
+
INSERT OR IGNORE INTO fusion_status_dirty_v1 SELECT id FROM documents WHERE ${condition('documents')}`)
|
|
152
|
+
this.flush()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#validate(task: string): void {
|
|
156
|
+
for (const kind of kinds) {
|
|
157
|
+
const prefix = `${kind}:${task}:`
|
|
158
|
+
const error = this.#sql('SELECT error FROM fusion_status_facts_v1 WHERE error IS NOT NULL AND id>=? AND id<? LIMIT 1').get(prefix, `${kind}:${task};`)
|
|
159
|
+
if (error) throw new StatusDataError(String(error.error))
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
read(task: string): Pick<TaskStatus, 'usage' | 'requests' | 'tools' | 'contexts' | 'unsettledTools'> & { checking: boolean } {
|
|
164
|
+
this.#ready(task)
|
|
165
|
+
this.#validate(task)
|
|
166
|
+
const total = empty(), roles = { lead: empty(), worker: empty() }
|
|
167
|
+
for (const row of this.#sql('SELECT * FROM fusion_status_totals_v1 WHERE task=?').all(task)) {
|
|
168
|
+
for (const key of metrics) total[key] += Number(row[key])
|
|
169
|
+
if (row.role === 'lead' || row.role === 'worker') roles[row.role] = row as Totals
|
|
170
|
+
}
|
|
171
|
+
const own = (value: Totals): RoleUsage => ({ calls: value.calls,
|
|
172
|
+
input: { knownTokens: value.inputTokens, reportedRequests: value.inputReported, totalRequests: value.calls },
|
|
173
|
+
cacheRead: { knownTokens: value.cacheReadTokens, reportedRequests: value.cacheReadReported, totalRequests: value.calls },
|
|
174
|
+
output: { knownTokens: value.outputTokens, reportedRequests: value.outputReported, totalRequests: value.calls } })
|
|
175
|
+
const recent = (kind: string, limit: number) => this.#sql('SELECT id,stamp,preview FROM fusion_status_facts_v1 WHERE task=? AND kind=? ORDER BY stamp DESC,id LIMIT ?')
|
|
176
|
+
.all(task, kind, limit).map(row => ({ ...JSON.parse(String(row.preview)), id: String(row.id), startedAt: String(row.stamp) }))
|
|
177
|
+
return { usage: { ...own(total), finalCalls: total.finalCalls, provisionalCalls: total.provisionalCalls,
|
|
178
|
+
unreportedCalls: total.unreportedCalls, compactionCalls: total.compactionCalls, keepaliveCalls: total.keepaliveCalls,
|
|
179
|
+
actualBilledUsd: null, upstreamHttpCalls: null, byRole: { lead: own(roles.lead), worker: own(roles.worker) } },
|
|
180
|
+
requests: recent('usage', 12), tools: recent('native-effect', 8),
|
|
181
|
+
contexts: (['lead', 'worker'] as const).flatMap(role => {
|
|
182
|
+
const row = this.#sql("SELECT preview FROM fusion_status_facts_v1 WHERE task=? AND kind='context-request' AND role=? ORDER BY stamp DESC,id LIMIT 1").get(task, role)
|
|
183
|
+
return row ? [JSON.parse(String(row.preview))] : []
|
|
184
|
+
}), unsettledTools: total.unsettled, checking: total.checking > 0 }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
workerConversations(task: string): number {
|
|
188
|
+
this.#ready(task)
|
|
189
|
+
this.#validate(task)
|
|
190
|
+
return Number(this.#sql("SELECT workerConversations FROM fusion_status_totals_v1 WHERE task=? AND role='worker'").get(task)?.workerConversations ?? 0)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
hasConversation(task: string, session: string): boolean {
|
|
194
|
+
this.#ready(task)
|
|
195
|
+
this.#validate(task)
|
|
196
|
+
return Boolean(this.#sql('SELECT 1 FROM fusion_status_facts_v1 WHERE task=? AND session=? AND active=1 LIMIT 1').get(task, session))
|
|
197
|
+
}
|
|
198
|
+
}
|