sintbot 1.0.16 → 1.3.0
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/CHANGELOG.en.json +99 -0
- package/CHANGELOG.json +99 -0
- package/README.md +20 -158
- package/README.zh-CN.md +20 -166
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/install.md +61 -167
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/zh-CN/install.md +61 -165
- package/install.sh +84 -0
- package/package.json +73 -37
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4716
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/cli-secret.js +0 -36
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2758
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5526
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
|
@@ -0,0 +1,1560 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Development-only C2 authority. The daemon lock must be held by the caller.
|
|
4
|
+
// No Engine path uses this journal until state and receipt projections are wired.
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const path = require('node:path')
|
|
7
|
+
const { createHash, randomUUID } = require('node:crypto')
|
|
8
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
9
|
+
const { callbackRegistrationId } = require('./c2eventidentity')
|
|
10
|
+
const { validatePersistentReceipts } = require('./c2persistrelation')
|
|
11
|
+
|
|
12
|
+
const MAGIC = Buffer.from('SBOT-C2-JOURNAL-v1\n')
|
|
13
|
+
const FRAME_HEADER = 36 // uint32be payload length, then SHA-256 of payload
|
|
14
|
+
const MAX_RECORD_BYTES = 8 * 1024 * 1024
|
|
15
|
+
const MAX_CHECKPOINT_BYTES = 64 * 1024 * 1024
|
|
16
|
+
const MAX_CHECKPOINT_ITEMS = 1_000_000
|
|
17
|
+
const REGISTRATION_ID = /^sbot-c2-registration-v1:[0-9a-f]{64}$/
|
|
18
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
19
|
+
const GENERATION = /^[0-9a-f]{32}$/
|
|
20
|
+
const RUNNER_CLOSED_ID = /^sbot-c2-runner-closed-v1:[0-9a-f]{32}:[0-9a-f]{32}$/
|
|
21
|
+
const RUNNER_NOT_STARTED_ID = /^sbot-c2-runner-not-started-v1:[0-9a-f]{32}:[0-9a-f]{32}$/
|
|
22
|
+
const DESCRIPTOR_TYPES = Object.fromEntries(Object.entries({
|
|
23
|
+
queue: 'dispatch dropQueued interruptMissingPayload recalledQueued queueNotice',
|
|
24
|
+
lifecycle: 'turnStarted noActiveTask clearQueue abortController forceRelease continueBatch recallAccepted recordDone armHardTimer clearHardTimer releaseLock dequeue clearSession',
|
|
25
|
+
recovery: 'enqueueFront freshSession sayFreshRetry sayEmpty markExhausted alertDown notifyNoTarget probeHome applyFailover notifySwitched',
|
|
26
|
+
failover: 'clearExhausted markExhausted alertUp alertDown setPending clearPending applyFailover clearExhaustedHome notifyBack notifySwitched probeHome setProbing touchCheckedAt callProbe setPendingBack touchUntil logProbeError',
|
|
27
|
+
intake: 'notifyOrphan sendUnboundGuide clearQueue handleCommand persistReceipt dispatch',
|
|
28
|
+
slots: 'grant reject',
|
|
29
|
+
session: 'writeSession markTainted resetSessionAndStats',
|
|
30
|
+
steer: 'followupKind eligible acceptControl drainQueued callSteer enqueue pullForSteer relocateCard replyQueued propagateSteerError replySteered applyLanded markLandedRecalled forgetUnconfirmed enqueueFront clearControl',
|
|
31
|
+
terminal: 'markTainted clearTainted clearExhausted verifyTurn finalReply quietCompletion',
|
|
32
|
+
outbound: 'card.create message.reply message.create cardElement.content card.update card.settings message.delete',
|
|
33
|
+
turn: 'turnToken effectAllowed',
|
|
34
|
+
handoff: 'attemptQueueRelease retryQueueRelease sealGroup notifyAccepted holdHandoff scheduleDispatch clearHandoff dispatch',
|
|
35
|
+
atomicHandoff: 'dispatch attemptQueueRelease clearHandoff dropQueued holdHandoff scheduleDispatch',
|
|
36
|
+
control: 'stopAck',
|
|
37
|
+
}).map(([domain, types]) => [domain, new Set(types.split(' '))]))
|
|
38
|
+
const DESCRIPTOR_FIELDS = ['effectId', 'domain', 'scope', 'type', 'turnToken',
|
|
39
|
+
'receiptId', 'targetId']
|
|
40
|
+
const REGISTRATION_FIELDS = new Set(['id', 'receiptId', 'generation', 'domain', 'kind',
|
|
41
|
+
'ordinal', 'registeredAt'])
|
|
42
|
+
|
|
43
|
+
function id(value, name, max = 256) {
|
|
44
|
+
if (typeof value !== 'string' || !value || Buffer.byteLength(value) > max) {
|
|
45
|
+
throw new TypeError(`invalid C2 ${name}`)
|
|
46
|
+
}
|
|
47
|
+
return value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function effectDescriptor(value) {
|
|
51
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
52
|
+
Object.getPrototypeOf(value) !== Object.prototype ||
|
|
53
|
+
Reflect.ownKeys(value).length !== DESCRIPTOR_FIELDS.length ||
|
|
54
|
+
Reflect.ownKeys(value).some(key => typeof key !== 'string' ||
|
|
55
|
+
!DESCRIPTOR_FIELDS.includes(key) ||
|
|
56
|
+
!Object.getOwnPropertyDescriptor(value, key)?.enumerable ||
|
|
57
|
+
!Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) {
|
|
58
|
+
throw new TypeError('invalid C2 effect descriptor fields')
|
|
59
|
+
}
|
|
60
|
+
const copy = exactJson(value)
|
|
61
|
+
id(copy.effectId, 'effect ID', 512)
|
|
62
|
+
if (!Object.hasOwn(DESCRIPTOR_TYPES, copy.domain) ||
|
|
63
|
+
typeof copy.scope !== 'string' || !/^[A-Za-z0-9:_-]{1,256}$/.test(copy.scope) ||
|
|
64
|
+
!DESCRIPTOR_TYPES[copy.domain].has(copy.type) ||
|
|
65
|
+
!(copy.turnToken === null || Number.isSafeInteger(copy.turnToken) && copy.turnToken > 0) ||
|
|
66
|
+
!(copy.receiptId === null || typeof copy.receiptId === 'string' && RECEIPT_ID.test(copy.receiptId)) ||
|
|
67
|
+
!(copy.targetId === null || typeof copy.targetId === 'string' &&
|
|
68
|
+
(['queue', 'handoff', 'atomicHandoff'].includes(copy.domain) &&
|
|
69
|
+
copy.type === 'dispatch' && copy.receiptId !== null &&
|
|
70
|
+
(copy.targetId === copy.receiptId || copy.targetId === `receipt:job:${copy.receiptId}`) ||
|
|
71
|
+
copy.domain === 'control' && copy.type === 'stopAck' &&
|
|
72
|
+
copy.receiptId !== null && copy.targetId === `receipt:control:${copy.receiptId}`)) ||
|
|
73
|
+
(copy.domain === 'control' &&
|
|
74
|
+
(copy.type !== 'stopAck' || copy.turnToken !== null ||
|
|
75
|
+
copy.receiptId === null || copy.targetId !== `receipt:control:${copy.receiptId}`)) ||
|
|
76
|
+
(['terminal', 'outbound'].includes(copy.domain) &&
|
|
77
|
+
(copy.domain !== 'terminal' || ['finalReply', 'quietCompletion'].includes(copy.type)) &&
|
|
78
|
+
(copy.turnToken === null || copy.receiptId === null || copy.targetId !== null))) {
|
|
79
|
+
throw new TypeError('invalid C2 effect descriptor identity')
|
|
80
|
+
}
|
|
81
|
+
return copy
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function exactJson(value) {
|
|
85
|
+
const bytes = JSON.stringify(value)
|
|
86
|
+
if (typeof bytes !== 'string') throw new TypeError('C2 journal value is not JSON')
|
|
87
|
+
const copy = JSON.parse(bytes)
|
|
88
|
+
if (!isDeepStrictEqual(value, copy)) throw new TypeError('C2 journal value is not exact JSON')
|
|
89
|
+
function check(item) {
|
|
90
|
+
if (item && typeof item === 'object') {
|
|
91
|
+
if (Object.hasOwn(item, '__proto__')) throw new TypeError('unsafe C2 journal key')
|
|
92
|
+
for (const child of Object.values(item)) check(child)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
check(copy)
|
|
96
|
+
return copy
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function frame(record, maxBytes = MAX_RECORD_BYTES) {
|
|
100
|
+
const payload = Buffer.from(JSON.stringify(record))
|
|
101
|
+
if (!payload.length || payload.length > maxBytes) throw new RangeError('C2 journal record too large')
|
|
102
|
+
const result = Buffer.allocUnsafe(FRAME_HEADER + payload.length)
|
|
103
|
+
result.writeUInt32BE(payload.length, 0)
|
|
104
|
+
createHash('sha256').update(payload).digest().copy(result, 4)
|
|
105
|
+
payload.copy(result, FRAME_HEADER)
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function identity(stat) { return `${stat.dev}:${stat.ino}` }
|
|
110
|
+
|
|
111
|
+
function writeAll(fd, bytes, position) {
|
|
112
|
+
let written = 0
|
|
113
|
+
while (written < bytes.length) {
|
|
114
|
+
const size = fs.writeSync(fd, bytes, written, bytes.length - written, position + written)
|
|
115
|
+
if (size <= 0) throw new Error('short C2 journal write')
|
|
116
|
+
written += size
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readAll(fd, bytes, position) {
|
|
121
|
+
let read = 0
|
|
122
|
+
while (read < bytes.length) {
|
|
123
|
+
const size = fs.readSync(fd, bytes, read, bytes.length - read, position + read)
|
|
124
|
+
if (size <= 0) throw new Error('short C2 journal read')
|
|
125
|
+
read += size
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateSnapshots(record, validateState, validateReceipts, validateRelation) {
|
|
130
|
+
const state = exactJson(record.state)
|
|
131
|
+
const receipts = exactJson(record.receipts)
|
|
132
|
+
if (!state || typeof state !== 'object' || Array.isArray(state) ||
|
|
133
|
+
!receipts || typeof receipts !== 'object' || Array.isArray(receipts)) {
|
|
134
|
+
throw new TypeError('C2 journal snapshots must be objects')
|
|
135
|
+
}
|
|
136
|
+
if (validateState(state) !== true || validateReceipts(receipts) !== true ||
|
|
137
|
+
validateRelation(state, receipts) !== true) {
|
|
138
|
+
throw new TypeError('C2 journal projection rejected')
|
|
139
|
+
}
|
|
140
|
+
return { state, receipts }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validateReceiptProgress(before, after) {
|
|
144
|
+
const oldPending = new Map(before.pending.map(item => [item.id, item]))
|
|
145
|
+
const oldCompleted = new Map(before.completed.map(item => [item.id, item]))
|
|
146
|
+
const newPending = new Map(after.pending.map(item => [item.id, item]))
|
|
147
|
+
const newCompleted = new Map(after.completed.map(item => [item.id, item]))
|
|
148
|
+
for (const [receiptId, item] of oldCompleted) {
|
|
149
|
+
if (!isDeepStrictEqual(newCompleted.get(receiptId), item)) {
|
|
150
|
+
throw new Error('C2 completed receipt cannot disappear or change')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const [receiptId, item] of oldPending) {
|
|
154
|
+
const current = newPending.get(receiptId)
|
|
155
|
+
if (!current) {
|
|
156
|
+
if (!newCompleted.has(receiptId)) throw new Error('C2 pending receipt cannot disappear')
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
const { notifiedAt: oldNotice, ...oldStable } = item
|
|
160
|
+
const { notifiedAt: newNotice, ...newStable } = current
|
|
161
|
+
if (!isDeepStrictEqual(oldStable, newStable) ||
|
|
162
|
+
(oldNotice !== undefined && oldNotice !== newNotice)) {
|
|
163
|
+
throw new Error('C2 pending receipt identity cannot change')
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const receiptId of newPending.keys()) {
|
|
167
|
+
if (oldCompleted.has(receiptId)) throw new Error('C2 completed receipt cannot be reopened')
|
|
168
|
+
}
|
|
169
|
+
for (const receiptId of newCompleted.keys()) {
|
|
170
|
+
if (!oldPending.has(receiptId) && !oldCompleted.has(receiptId)) {
|
|
171
|
+
throw new Error('C2 receipt cannot complete without prior acceptance')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateRegistration(value, receipts, registrations) {
|
|
177
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
178
|
+
Object.getPrototypeOf(value) !== Object.prototype ||
|
|
179
|
+
Reflect.ownKeys(value).length !== REGISTRATION_FIELDS.size ||
|
|
180
|
+
Reflect.ownKeys(value).some(key => typeof key !== 'string' || !REGISTRATION_FIELDS.has(key) ||
|
|
181
|
+
!Object.getOwnPropertyDescriptor(value, key)?.enumerable ||
|
|
182
|
+
!Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) {
|
|
183
|
+
throw new TypeError('invalid C2 callback registration')
|
|
184
|
+
}
|
|
185
|
+
const registration = exactJson(value)
|
|
186
|
+
if (registration.id !== callbackRegistrationId(registration) ||
|
|
187
|
+
!Number.isSafeInteger(registration.registeredAt) || registration.registeredAt <= 0 ||
|
|
188
|
+
registrations.has(registration.id)) {
|
|
189
|
+
throw new Error('invalid or duplicate C2 callback registration')
|
|
190
|
+
}
|
|
191
|
+
const receipt = receipts.pending.find(item => item.id === registration.receiptId)
|
|
192
|
+
if (!receipt) throw new Error('C2 callback registration requires matching pending receipt')
|
|
193
|
+
validatePersistentReceipts({ pending: [receipt], completed: [] })
|
|
194
|
+
if (/^local:/i.test(receipt.sourceId) || Buffer.byteLength(receipt.sourceId) > 256 ||
|
|
195
|
+
Buffer.byteLength(receipt.chatId) > 256) {
|
|
196
|
+
throw new Error('C2 callback registration requires durable source receipt')
|
|
197
|
+
}
|
|
198
|
+
return registration
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function noticeFields(value, names) {
|
|
202
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
203
|
+
Object.getPrototypeOf(value) !== Object.prototype ||
|
|
204
|
+
Reflect.ownKeys(value).length !== names.length ||
|
|
205
|
+
Reflect.ownKeys(value).some(key => typeof key !== 'string' || !names.includes(key) ||
|
|
206
|
+
!Object.getOwnPropertyDescriptor(value, key)?.enumerable ||
|
|
207
|
+
!Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) {
|
|
208
|
+
throw new TypeError('invalid C2 notice fields')
|
|
209
|
+
}
|
|
210
|
+
return value
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function noticeIdentity(receiptId, chatId) {
|
|
214
|
+
if (typeof receiptId !== 'string' || !RECEIPT_ID.test(receiptId) ||
|
|
215
|
+
typeof chatId !== 'string' || !chatId || Buffer.byteLength(chatId) > 256) {
|
|
216
|
+
throw new TypeError('invalid C2 notice target')
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function noticeTime(value) {
|
|
221
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError('invalid C2 notice time')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function runnerClosedId(receiptId, generation) {
|
|
225
|
+
if (!RECEIPT_ID.test(receiptId || '') || !GENERATION.test(generation || '')) {
|
|
226
|
+
throw new TypeError('invalid C2 runner closure identity')
|
|
227
|
+
}
|
|
228
|
+
return `sbot-c2-runner-closed-v1:${receiptId}:${generation}`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function runnerNotStartedId(receiptId, generation) {
|
|
232
|
+
if (!RECEIPT_ID.test(receiptId || '') || !GENERATION.test(generation || '')) {
|
|
233
|
+
throw new TypeError('invalid C2 runner not-started identity')
|
|
234
|
+
}
|
|
235
|
+
return `sbot-c2-runner-not-started-v1:${receiptId}:${generation}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function completedReceipt(receipts, receiptId, settledAt) {
|
|
239
|
+
return { pending: receipts.pending.filter(item => item.id !== receiptId),
|
|
240
|
+
completed: [...receipts.completed, { id: receiptId, settledAt }] }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function completedBatch(receipts, receiptIds, settledAt) {
|
|
244
|
+
const ids = new Set(receiptIds)
|
|
245
|
+
return { pending: receipts.pending.filter(item => !ids.has(item.id)),
|
|
246
|
+
completed: [...receipts.completed,
|
|
247
|
+
...receiptIds.map(id => ({ id, settledAt }))] }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function terminalEffectForReceipt(descriptors, receiptId) {
|
|
251
|
+
return [...descriptors.values()].some(item =>
|
|
252
|
+
(item.domain === 'terminal' && ['finalReply', 'quietCompletion'].includes(item.type) ||
|
|
253
|
+
item.domain === 'control' && item.type === 'stopAck') &&
|
|
254
|
+
item.receiptId === receiptId)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function terminalBatchOwnsReceipt(state, receiptId) {
|
|
258
|
+
return Object.values(state.lanes.terminal || {}).some(lane =>
|
|
259
|
+
lane.finalized === true && lane.batchReceiptIds?.includes(receiptId))
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function validateStopAckAuthority(state, receipts, effects, descriptors, attempts) {
|
|
263
|
+
for (const descriptor of descriptors.values()) {
|
|
264
|
+
if (descriptor.domain !== 'control' || descriptor.type !== 'stopAck') continue
|
|
265
|
+
const status = effects.get(descriptor.effectId)
|
|
266
|
+
const pending = receipts.pending.find(item => item.id === descriptor.receiptId)
|
|
267
|
+
const completed = receipts.completed.some(item => item.id === descriptor.receiptId)
|
|
268
|
+
if (descriptor.targetId !== `receipt:control:${descriptor.receiptId}` ||
|
|
269
|
+
descriptor.turnToken !== null ||
|
|
270
|
+
(status === 'acked' ? !completed || pending || !attempts.has(descriptor.effectId) :
|
|
271
|
+
!pending || completed || pending.chatId !== descriptor.scope ||
|
|
272
|
+
pending.source !== 'message' || pending.quiet ||
|
|
273
|
+
state.lanes.stopIntent?.[descriptor.scope] !== undefined)) {
|
|
274
|
+
throw new Error('C2 stop acknowledgement lacks exact durable receipt authority')
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function stopAckAttemptAllowed(state, receipts, descriptor) {
|
|
280
|
+
if (descriptor?.domain !== 'control' || descriptor.type !== 'stopAck') return true
|
|
281
|
+
const receipt = receipts.pending.find(item => item.id === descriptor.receiptId)
|
|
282
|
+
return !!receipt && receipt.chatId === descriptor.scope &&
|
|
283
|
+
receipt.source === 'message' && receipt.quiet === false &&
|
|
284
|
+
descriptor.targetId === `receipt:control:${receipt.id}` &&
|
|
285
|
+
state.lanes.stopIntent?.[descriptor.scope] === undefined &&
|
|
286
|
+
state.lanes.turn?.[descriptor.scope]?.active == null &&
|
|
287
|
+
state.lanes.handoff?.[descriptor.scope]?.phase === 'idle' &&
|
|
288
|
+
(state.lanes.queue?.[descriptor.scope]?.queue?.length ?? 0) === 0
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function stopAckSettlementCandidate(record, authority, requireLocal) {
|
|
292
|
+
const { state, receipts, effects, attempts, descriptors, notices, local,
|
|
293
|
+
validateState, validateReceipts, validateRelation } = authority
|
|
294
|
+
if (Object.keys(record).sort().join(',') !==
|
|
295
|
+
'chatId,cursor,effectId,kind,receiptId,settledAt' ||
|
|
296
|
+
record.kind !== 'stopAckSettlement' || record.cursor !== state.seq) {
|
|
297
|
+
throw new Error('invalid C2 stop acknowledgement settlement')
|
|
298
|
+
}
|
|
299
|
+
id(record.effectId, 'effect ID', 512)
|
|
300
|
+
noticeIdentity(record.receiptId, record.chatId)
|
|
301
|
+
noticeTime(record.settledAt)
|
|
302
|
+
const descriptor = descriptors.get(record.effectId)
|
|
303
|
+
const receipt = receipts.pending.find(item => item.id === record.receiptId)
|
|
304
|
+
if (!descriptor || descriptor.domain !== 'control' || descriptor.type !== 'stopAck' ||
|
|
305
|
+
descriptor.scope !== record.chatId || descriptor.receiptId !== record.receiptId ||
|
|
306
|
+
descriptor.targetId !== `receipt:control:${record.receiptId}` ||
|
|
307
|
+
descriptor.turnToken !== null || !receipt || receipt.chatId !== record.chatId ||
|
|
308
|
+
receipt.source !== 'message' || receipt.quiet || notices.has(record.receiptId) ||
|
|
309
|
+
effects.get(record.effectId) !== 'attempting' ||
|
|
310
|
+
!attempts.has(record.effectId) || record.settledAt < attempts.get(record.effectId) ||
|
|
311
|
+
requireLocal && !local.has(record.effectId) ||
|
|
312
|
+
!stopAckAttemptAllowed(state, receipts, descriptor)) {
|
|
313
|
+
throw new Error('C2 stop acknowledgement lacks confirmed local attempt')
|
|
314
|
+
}
|
|
315
|
+
const candidate = completedReceipt(receipts, record.receiptId, record.settledAt)
|
|
316
|
+
validateSnapshots({ state, receipts: candidate }, validateState,
|
|
317
|
+
validateReceipts, validateRelation)
|
|
318
|
+
validateReceiptProgress(receipts, candidate)
|
|
319
|
+
return candidate
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function terminalAttemptAllowed(state, receipts, effects, descriptor) {
|
|
323
|
+
if (descriptor?.domain !== 'terminal' || descriptor.type !== 'finalReply') return true
|
|
324
|
+
const receipt = receipts.pending.find(item => item.id === descriptor.receiptId)
|
|
325
|
+
const handoff = state.lanes.handoff?.[descriptor.scope]
|
|
326
|
+
const lane = state.lanes.terminal?.[descriptor.scope]
|
|
327
|
+
return !!receipt && receipt.chatId === descriptor.scope && !receipt.quiet &&
|
|
328
|
+
lane?.finalized === true && lane.outcome?.silent === false &&
|
|
329
|
+
lane.pendingEffectIds?.at(-1) === descriptor.effectId &&
|
|
330
|
+
lane.pendingEffectIds.slice(0, -1).every(id => effects.get(id) === 'acked') &&
|
|
331
|
+
state.lanes.turn?.[descriptor.scope]?.active === descriptor.turnToken &&
|
|
332
|
+
handoff?.phase === 'running' && handoff.turnToken === descriptor.turnToken
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function queueNoticeAttemptAllowed(state, receipts, descriptor) {
|
|
336
|
+
if (descriptor?.domain !== 'queue' || descriptor.type !== 'queueNotice') return true
|
|
337
|
+
const receipt = receipts.pending.find(item => item.id === descriptor.receiptId)
|
|
338
|
+
const queue = state.lanes.queue?.[descriptor.scope]?.queue
|
|
339
|
+
const turn = state.lanes.turn?.[descriptor.scope]
|
|
340
|
+
const handoff = state.lanes.handoff?.[descriptor.scope]
|
|
341
|
+
return !!receipt && receipt.chatId === descriptor.scope &&
|
|
342
|
+
receipt.source === 'message' && receipt.quiet === false &&
|
|
343
|
+
descriptor.targetId === null && descriptor.turnToken === null &&
|
|
344
|
+
Array.isArray(queue) && queue.some(item =>
|
|
345
|
+
item.id === `receipt:job:${receipt.id}` && item.quiet === false &&
|
|
346
|
+
item.polled === false && item.batch === undefined) &&
|
|
347
|
+
Number.isSafeInteger(turn?.active) && turn.active > 0 &&
|
|
348
|
+
handoff?.phase === 'running' && handoff.turnToken === turn.active
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function runnerAcceptanceAuthority(effectId, registrationId, authority,
|
|
352
|
+
{ effectStatus = 'acked', requireRunning = true } = {}) {
|
|
353
|
+
const { state, receipts, effects, attempts, descriptors, registrations } = authority
|
|
354
|
+
id(effectId, 'effect ID', 512)
|
|
355
|
+
if (typeof registrationId !== 'string' || !REGISTRATION_ID.test(registrationId)) {
|
|
356
|
+
throw new TypeError('invalid C2 runner acceptance registration')
|
|
357
|
+
}
|
|
358
|
+
const descriptor = descriptors.get(effectId)
|
|
359
|
+
const registration = registrations.get(registrationId)
|
|
360
|
+
const receipt = [...receipts.pending, ...receipts.completed]
|
|
361
|
+
.find(item => item.id === descriptor?.receiptId)
|
|
362
|
+
if (effects.get(effectId) !== effectStatus || !attempts.has(effectId) ||
|
|
363
|
+
descriptor?.domain !== 'atomicHandoff' || descriptor.type !== 'dispatch' ||
|
|
364
|
+
!RECEIPT_ID.test(descriptor.receiptId || '') ||
|
|
365
|
+
!Number.isSafeInteger(descriptor.turnToken) || descriptor.turnToken <= 0 ||
|
|
366
|
+
!receipt || (receipt.chatId !== undefined && receipt.chatId !== descriptor.scope) ||
|
|
367
|
+
registration?.receiptId !== descriptor.receiptId ||
|
|
368
|
+
registration.domain !== 'atomicHandoff' || registration.kind !== 'runnerAccepted' ||
|
|
369
|
+
registration.ordinal !== 0 ||
|
|
370
|
+
registration.registeredAt > attempts.get(effectId) ||
|
|
371
|
+
(requireRunning && (state.lanes.turn?.[descriptor.scope]?.active !== descriptor.turnToken ||
|
|
372
|
+
state.lanes.handoff?.[descriptor.scope]?.phase !== 'running' ||
|
|
373
|
+
state.lanes.handoff[descriptor.scope].turnToken !== descriptor.turnToken ||
|
|
374
|
+
!receipts.pending.some(item => item.id === descriptor.receiptId &&
|
|
375
|
+
item.chatId === descriptor.scope)))) {
|
|
376
|
+
throw new Error('C2 runner acceptance lacks exact durable dispatch')
|
|
377
|
+
}
|
|
378
|
+
return { descriptor, registration }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function runnerClosureCandidate(record, authority, requireRunning = true) {
|
|
382
|
+
if (Object.keys(record).sort().join(',') !==
|
|
383
|
+
'chatId,closedAt,cursor,dispatchEffectId,evidenceId,generation,kind,receiptId,turnToken' ||
|
|
384
|
+
record.kind !== 'runnerClosed' || record.cursor !== authority.state.seq ||
|
|
385
|
+
record.evidenceId !== runnerClosedId(record.receiptId, record.generation) ||
|
|
386
|
+
typeof record.chatId !== 'string' || !record.chatId ||
|
|
387
|
+
!Number.isSafeInteger(record.turnToken) || record.turnToken <= 0 ||
|
|
388
|
+
authority.closures.has(record.evidenceId)) {
|
|
389
|
+
throw new Error('invalid C2 runner closure record')
|
|
390
|
+
}
|
|
391
|
+
noticeTime(record.closedAt)
|
|
392
|
+
const registrationId = authority.acceptances.get(record.dispatchEffectId)
|
|
393
|
+
const { descriptor, registration } = runnerAcceptanceAuthority(
|
|
394
|
+
record.dispatchEffectId, registrationId, authority, { requireRunning })
|
|
395
|
+
if (descriptor.receiptId !== record.receiptId || descriptor.scope !== record.chatId ||
|
|
396
|
+
descriptor.turnToken !== record.turnToken ||
|
|
397
|
+
registration.generation !== record.generation ||
|
|
398
|
+
record.closedAt < authority.attempts.get(record.dispatchEffectId)) {
|
|
399
|
+
throw new Error('C2 runner closure differs from accepted dispatch')
|
|
400
|
+
}
|
|
401
|
+
return { receiptId: record.receiptId, chatId: record.chatId,
|
|
402
|
+
turnToken: record.turnToken, generation: record.generation,
|
|
403
|
+
dispatchEffectId: record.dispatchEffectId, closedAt: record.closedAt }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function runnerNotStartedCandidate(record, authority, requireRunning = true) {
|
|
407
|
+
if (Object.keys(record).sort().join(',') !==
|
|
408
|
+
'chatId,cursor,dispatchEffectId,evidenceId,generation,kind,receiptId,resolvedAt,turnToken' ||
|
|
409
|
+
record.kind !== 'runnerNotStarted' || record.cursor !== authority.state.seq ||
|
|
410
|
+
record.evidenceId !== runnerNotStartedId(record.receiptId, record.generation) ||
|
|
411
|
+
typeof record.chatId !== 'string' || !record.chatId ||
|
|
412
|
+
!Number.isSafeInteger(record.turnToken) || record.turnToken <= 0 ||
|
|
413
|
+
authority.notStarts.has(record.evidenceId)) {
|
|
414
|
+
throw new Error('invalid C2 runner not-started record')
|
|
415
|
+
}
|
|
416
|
+
noticeTime(record.resolvedAt)
|
|
417
|
+
const descriptor = authority.descriptors.get(record.dispatchEffectId)
|
|
418
|
+
const registrationId = callbackRegistrationId({ receiptId: record.receiptId,
|
|
419
|
+
generation: record.generation, domain: 'atomicHandoff', kind: 'runnerAccepted' })
|
|
420
|
+
const registration = authority.registrations.get(registrationId)
|
|
421
|
+
if (authority.effects.get(record.dispatchEffectId) !== (requireRunning ? 'attempting' : 'notStarted') ||
|
|
422
|
+
!authority.attempts.has(record.dispatchEffectId) ||
|
|
423
|
+
record.resolvedAt < authority.attempts.get(record.dispatchEffectId) ||
|
|
424
|
+
descriptor?.domain !== 'atomicHandoff' || descriptor.type !== 'dispatch' ||
|
|
425
|
+
descriptor.scope !== record.chatId || descriptor.receiptId !== record.receiptId ||
|
|
426
|
+
descriptor.turnToken !== record.turnToken ||
|
|
427
|
+
registration?.receiptId !== record.receiptId ||
|
|
428
|
+
registration.generation !== record.generation ||
|
|
429
|
+
registration.domain !== 'atomicHandoff' || registration.kind !== 'runnerAccepted' ||
|
|
430
|
+
registration.ordinal !== 0 ||
|
|
431
|
+
registration.registeredAt > authority.attempts.get(record.dispatchEffectId) ||
|
|
432
|
+
authority.acceptances.has(record.dispatchEffectId) ||
|
|
433
|
+
[...authority.closures.values()].some(item => item.dispatchEffectId === record.dispatchEffectId) ||
|
|
434
|
+
!(requireRunning ? authority.receipts.pending :
|
|
435
|
+
[...authority.receipts.pending, ...authority.receipts.completed])
|
|
436
|
+
.some(item => item.id === record.receiptId &&
|
|
437
|
+
(item.chatId === undefined || item.chatId === record.chatId))) {
|
|
438
|
+
throw new Error('C2 runner not-started proof differs from dispatch')
|
|
439
|
+
}
|
|
440
|
+
if (requireRunning) {
|
|
441
|
+
const turn = authority.state.lanes.turn?.[record.chatId]
|
|
442
|
+
const handoff = authority.state.lanes.handoff?.[record.chatId]
|
|
443
|
+
if (turn?.epoch !== record.turnToken || turn.active !== record.turnToken ||
|
|
444
|
+
handoff?.phase !== 'running' || handoff.turnToken !== record.turnToken ||
|
|
445
|
+
handoff.attempt !== 0 || handoff.nextJobId !== null ||
|
|
446
|
+
!Array.isArray(handoff.acceptedReceiptIds) || handoff.acceptedReceiptIds.length ||
|
|
447
|
+
['terminal', 'session', 'outbound', 'cardCleanup', 'stopIntent']
|
|
448
|
+
.some(domain => Object.hasOwn(authority.state.lanes[domain] || {}, record.chatId))) {
|
|
449
|
+
throw new Error('C2 runner not-started proof has active side effects')
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return { receiptId: record.receiptId, chatId: record.chatId,
|
|
453
|
+
turnToken: record.turnToken, generation: record.generation,
|
|
454
|
+
dispatchEffectId: record.dispatchEffectId, resolvedAt: record.resolvedAt }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function terminalSettlementCandidate(record, authority, requireLocal) {
|
|
458
|
+
const { state, receipts, effects, attempts, descriptors, notices, local,
|
|
459
|
+
validateState, validateReceipts, validateRelation } = authority
|
|
460
|
+
const batchRecord = Object.hasOwn(record, 'batchReceiptIds')
|
|
461
|
+
if (Object.keys(record).sort().join(',') !==
|
|
462
|
+
(batchRecord
|
|
463
|
+
? 'batchReceiptIds,chatId,cursor,effectId,kind,receiptId,settledAt,status,turnToken'
|
|
464
|
+
: 'chatId,cursor,effectId,kind,receiptId,settledAt,status,turnToken') ||
|
|
465
|
+
record.kind !== 'terminalSettlement' || record.status !== 'acked' ||
|
|
466
|
+
record.cursor !== state.seq) throw new Error('invalid C2 terminal settlement record')
|
|
467
|
+
id(record.effectId, 'effect ID', 512)
|
|
468
|
+
noticeIdentity(record.receiptId, record.chatId)
|
|
469
|
+
noticeTime(record.settledAt)
|
|
470
|
+
if (!Number.isSafeInteger(record.turnToken) || record.turnToken <= 0) {
|
|
471
|
+
throw new Error('invalid C2 terminal settlement turn token')
|
|
472
|
+
}
|
|
473
|
+
const descriptor = descriptors.get(record.effectId)
|
|
474
|
+
const receipt = receipts.pending.find(item => item.id === record.receiptId)
|
|
475
|
+
const lane = state.lanes.terminal?.[record.chatId]
|
|
476
|
+
const handoff = state.lanes.handoff?.[record.chatId]
|
|
477
|
+
const receiptIds = lane?.batchReceiptIds || [record.receiptId]
|
|
478
|
+
if (batchRecord !== !!lane?.batchReceiptIds ||
|
|
479
|
+
!Array.isArray(receiptIds) || !receiptIds.length ||
|
|
480
|
+
new Set(receiptIds).size !== receiptIds.length ||
|
|
481
|
+
!receiptIds.includes(record.receiptId) ||
|
|
482
|
+
lane?.replyTargetReceiptId !== undefined &&
|
|
483
|
+
lane.replyTargetReceiptId !== record.receiptId ||
|
|
484
|
+
batchRecord && !isDeepStrictEqual(record.batchReceiptIds, receiptIds) ||
|
|
485
|
+
receiptIds.some(id => !RECEIPT_ID.test(id) ||
|
|
486
|
+
receipts.pending.find(item => item.id === id)?.chatId !== record.chatId ||
|
|
487
|
+
notices.has(id))) {
|
|
488
|
+
throw new Error('C2 terminal batch settlement receipt authority invalid')
|
|
489
|
+
}
|
|
490
|
+
if (!descriptor || descriptor.domain !== 'terminal' ||
|
|
491
|
+
!['finalReply', 'quietCompletion'].includes(descriptor.type) ||
|
|
492
|
+
descriptor.scope !== record.chatId ||
|
|
493
|
+
descriptor.turnToken !== record.turnToken ||
|
|
494
|
+
descriptor.receiptId !== record.receiptId || descriptor.targetId !== null ||
|
|
495
|
+
!receipt || receipt.chatId !== record.chatId || notices.has(record.receiptId) ||
|
|
496
|
+
!lane || lane.finalized !== true || !Array.isArray(lane.pendingEffectIds) ||
|
|
497
|
+
lane.pendingEffectIds.at(-1) !== record.effectId ||
|
|
498
|
+
!lane.pendingEffectIds.slice(0, -1).every(effectId => effects.get(effectId) === 'acked') ||
|
|
499
|
+
state.lanes.turn?.[record.chatId]?.active !== null ||
|
|
500
|
+
!handoff || !['idle', 'handoff'].includes(handoff.phase) ||
|
|
501
|
+
handoff.turnToken !== record.turnToken ||
|
|
502
|
+
handoff.nextJobId === record.receiptId ||
|
|
503
|
+
handoff.acceptedReceiptIds?.includes(record.receiptId)) {
|
|
504
|
+
throw new Error('C2 terminal settlement lacks released effect and receipt authority')
|
|
505
|
+
}
|
|
506
|
+
const shouldQuiet = receipt.quiet || lane.outcome?.silent === true
|
|
507
|
+
if (descriptor.type === 'quietCompletion' ?
|
|
508
|
+
!shouldQuiet || effects.get(record.effectId) !== 'pending' ||
|
|
509
|
+
attempts.has(record.effectId) :
|
|
510
|
+
shouldQuiet || effects.get(record.effectId) !== 'attempting' ||
|
|
511
|
+
!attempts.has(record.effectId) ||
|
|
512
|
+
record.settledAt < attempts.get(record.effectId) ||
|
|
513
|
+
requireLocal && !local.has(record.effectId)) {
|
|
514
|
+
throw new Error('C2 terminal settlement effect attempt invalid')
|
|
515
|
+
}
|
|
516
|
+
const candidate = completedBatch(receipts, receiptIds, record.settledAt)
|
|
517
|
+
validateSnapshots({ state, receipts: candidate }, validateState, validateReceipts,
|
|
518
|
+
validateRelation)
|
|
519
|
+
validateReceiptProgress(receipts, candidate)
|
|
520
|
+
return candidate
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
class C2DurableJournal {
|
|
524
|
+
#file
|
|
525
|
+
#validateState
|
|
526
|
+
#validateReceipts
|
|
527
|
+
#validateRelation
|
|
528
|
+
#cursor = 0
|
|
529
|
+
#state
|
|
530
|
+
#receipts
|
|
531
|
+
#eventIds = new Set()
|
|
532
|
+
#effects = new Map()
|
|
533
|
+
#effectAttempts = new Map()
|
|
534
|
+
#effectDescriptors = new Map()
|
|
535
|
+
#terminalSettlements = new Map()
|
|
536
|
+
#registrations = new Map()
|
|
537
|
+
#runnerAcceptances = new Map()
|
|
538
|
+
#runnerClosures = new Map()
|
|
539
|
+
#runnerNotStarts = new Map()
|
|
540
|
+
#notices = new Map()
|
|
541
|
+
// Only the process that durably began a notice may confirm its ACK. A
|
|
542
|
+
// restarted process sees an attempt as ambiguous and must never resend it.
|
|
543
|
+
#localNoticeIntents = new Set()
|
|
544
|
+
// An effect ACK belongs only to the process that fsynced its attempt.
|
|
545
|
+
#localEffectIntents = new Set()
|
|
546
|
+
#offset = 0
|
|
547
|
+
#fileIdentity = null
|
|
548
|
+
#blocked = false
|
|
549
|
+
|
|
550
|
+
constructor(file, { validateState, validateReceipts, validateRelation } = {}) {
|
|
551
|
+
if (typeof file !== 'string' || !path.isAbsolute(file) ||
|
|
552
|
+
typeof validateState !== 'function' || typeof validateReceipts !== 'function' ||
|
|
553
|
+
typeof validateRelation !== 'function') {
|
|
554
|
+
throw new TypeError('C2 journal needs an absolute file and strict projection validators')
|
|
555
|
+
}
|
|
556
|
+
this.#file = file
|
|
557
|
+
this.#validateState = validateState
|
|
558
|
+
this.#validateReceipts = validateReceipts
|
|
559
|
+
this.#validateRelation = validateRelation
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Explicit install/migration step only. Opening an absent journal never
|
|
563
|
+
// initializes an empty authority or silently adopts an older checkpoint.
|
|
564
|
+
static initialize(file, { state, receipts }, options) {
|
|
565
|
+
const journal = new C2DurableJournal(file, options)
|
|
566
|
+
const snapshots = validateSnapshots({ state, receipts }, journal.#validateState,
|
|
567
|
+
journal.#validateReceipts, journal.#validateRelation)
|
|
568
|
+
if (snapshots.state.seq !== 0) throw new TypeError('C2 journal genesis must have cursor zero')
|
|
569
|
+
const genesis = frame({ kind: 'genesis', ...snapshots })
|
|
570
|
+
const fd = fs.openSync(file, 'wx', 0o600)
|
|
571
|
+
try {
|
|
572
|
+
writeAll(fd, MAGIC, 0)
|
|
573
|
+
writeAll(fd, genesis, MAGIC.length)
|
|
574
|
+
fs.fsyncSync(fd)
|
|
575
|
+
const dirFd = fs.openSync(path.dirname(file), 'r')
|
|
576
|
+
try { fs.fsyncSync(dirFd) } finally { fs.closeSync(dirFd) }
|
|
577
|
+
} finally { fs.closeSync(fd) }
|
|
578
|
+
return C2DurableJournal.open(file, options)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
static open(file, options) {
|
|
582
|
+
const journal = new C2DurableJournal(file, options)
|
|
583
|
+
journal.#replay()
|
|
584
|
+
return journal
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
get blocked() { return this.#blocked }
|
|
588
|
+
get cursor() { return this.#cursor }
|
|
589
|
+
get byteLength() { return this.#offset }
|
|
590
|
+
hasEvent(eventId) { return this.#eventIds.has(id(eventId, 'event ID')) }
|
|
591
|
+
effectStatus(effectId) { return this.#effects.get(id(effectId, 'effect ID', 512)) ?? null }
|
|
592
|
+
effectDescriptor(effectId) {
|
|
593
|
+
const value = this.#effectDescriptors.get(id(effectId, 'effect ID', 512))
|
|
594
|
+
return value ? structuredClone(value) : null
|
|
595
|
+
}
|
|
596
|
+
terminalSettlement(effectId) {
|
|
597
|
+
const value = this.#terminalSettlements.get(id(effectId, 'effect ID', 512))
|
|
598
|
+
return value ? { effectId, ...structuredClone(value) } : null
|
|
599
|
+
}
|
|
600
|
+
noticeStatus(receiptId) {
|
|
601
|
+
if (typeof receiptId !== 'string' || !RECEIPT_ID.test(receiptId)) {
|
|
602
|
+
throw new TypeError('invalid C2 notice receipt ID')
|
|
603
|
+
}
|
|
604
|
+
return this.#notices.get(receiptId)?.status ?? null
|
|
605
|
+
}
|
|
606
|
+
lookupRegistration(registrationId) {
|
|
607
|
+
if (typeof registrationId !== 'string' || !REGISTRATION_ID.test(registrationId)) {
|
|
608
|
+
throw new TypeError('invalid C2 callback registration ID')
|
|
609
|
+
}
|
|
610
|
+
const registration = this.#registrations.get(registrationId)
|
|
611
|
+
return registration ? structuredClone(registration) : null
|
|
612
|
+
}
|
|
613
|
+
lookupRunnerClosed(evidenceId) {
|
|
614
|
+
if (typeof evidenceId !== 'string' || !RUNNER_CLOSED_ID.test(evidenceId)) {
|
|
615
|
+
throw new TypeError('invalid C2 runner closure evidence ID')
|
|
616
|
+
}
|
|
617
|
+
const proof = this.#runnerClosures.get(evidenceId)
|
|
618
|
+
return proof ? { evidenceId, ...structuredClone(proof) } : null
|
|
619
|
+
}
|
|
620
|
+
runnerClosedProofs() {
|
|
621
|
+
return [...this.#runnerClosures].map(([evidenceId, proof]) =>
|
|
622
|
+
({ evidenceId, ...structuredClone(proof) }))
|
|
623
|
+
}
|
|
624
|
+
lookupRunnerNotStarted(evidenceId) {
|
|
625
|
+
if (typeof evidenceId !== 'string' || !RUNNER_NOT_STARTED_ID.test(evidenceId)) {
|
|
626
|
+
throw new TypeError('invalid C2 runner not-started evidence ID')
|
|
627
|
+
}
|
|
628
|
+
const proof = this.#runnerNotStarts.get(evidenceId)
|
|
629
|
+
return proof ? { evidenceId, ...structuredClone(proof) } : null
|
|
630
|
+
}
|
|
631
|
+
runnerNotStartedProofs() {
|
|
632
|
+
return [...this.#runnerNotStarts].map(([evidenceId, proof]) =>
|
|
633
|
+
({ evidenceId, ...structuredClone(proof) }))
|
|
634
|
+
}
|
|
635
|
+
snapshot() {
|
|
636
|
+
return { cursor: this.#cursor, state: structuredClone(this.#state),
|
|
637
|
+
receipts: structuredClone(this.#receipts), eventCount: this.#eventIds.size,
|
|
638
|
+
effects: new Map(this.#effects), notices: new Map(this.#notices) }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
#settlementAuthority() {
|
|
642
|
+
return { state: this.#state, receipts: this.#receipts, effects: this.#effects,
|
|
643
|
+
attempts: this.#effectAttempts, descriptors: this.#effectDescriptors,
|
|
644
|
+
notices: this.#notices, local: this.#localEffectIntents,
|
|
645
|
+
validateState: this.#validateState, validateReceipts: this.#validateReceipts,
|
|
646
|
+
validateRelation: this.#validateRelation }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
#runnerAuthority() {
|
|
650
|
+
return { state: this.#state, receipts: this.#receipts,
|
|
651
|
+
effects: this.#effects, attempts: this.#effectAttempts,
|
|
652
|
+
descriptors: this.#effectDescriptors, registrations: this.#registrations,
|
|
653
|
+
acceptances: this.#runnerAcceptances, closures: this.#runnerClosures }
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
#notStartedAuthority() {
|
|
657
|
+
return { ...this.#runnerAuthority(), notStarts: this.#runnerNotStarts }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
appendTransition({ eventId, before, after, state, receipts, effectIds = [], effectDescriptors }) {
|
|
661
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
662
|
+
if ([...this.#notices.values()].some(item => item.status === 'attempting' || item.status === 'unknown')) {
|
|
663
|
+
throw new Error('C2 transition blocked by ambiguous notice')
|
|
664
|
+
}
|
|
665
|
+
id(eventId, 'event ID')
|
|
666
|
+
if (this.#eventIds.has(eventId)) throw new Error('C2 event already committed')
|
|
667
|
+
if (before !== this.#cursor || after !== before + 1 || !Number.isSafeInteger(after)) {
|
|
668
|
+
throw new Error('C2 journal cursor discontinuity')
|
|
669
|
+
}
|
|
670
|
+
if (!Array.isArray(effectIds) || effectIds.length > 4096) throw new TypeError('invalid C2 effects')
|
|
671
|
+
for (const effectId of effectIds) {
|
|
672
|
+
id(effectId, 'effect ID', 512)
|
|
673
|
+
if (this.#effects.has(effectId)) throw new Error('C2 effect already committed')
|
|
674
|
+
}
|
|
675
|
+
if (new Set(effectIds).size !== effectIds.length) throw new Error('duplicate C2 effect ID')
|
|
676
|
+
let descriptors
|
|
677
|
+
if (effectDescriptors !== undefined) {
|
|
678
|
+
if (!Array.isArray(effectDescriptors) || effectDescriptors.length !== effectIds.length) {
|
|
679
|
+
throw new TypeError('C2 effect descriptors must match effect IDs')
|
|
680
|
+
}
|
|
681
|
+
descriptors = effectDescriptors.map((raw, index) => {
|
|
682
|
+
const descriptor = effectDescriptor(raw)
|
|
683
|
+
if (descriptor.effectId !== effectIds[index]) {
|
|
684
|
+
throw new Error('C2 effect descriptor ID or order mismatch')
|
|
685
|
+
}
|
|
686
|
+
return descriptor
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
const snapshots = validateSnapshots({ state, receipts }, this.#validateState,
|
|
690
|
+
this.#validateReceipts, this.#validateRelation)
|
|
691
|
+
if (snapshots.state.seq !== after) throw new Error('C2 state cursor mismatch')
|
|
692
|
+
validateReceiptProgress(this.#receipts, snapshots.receipts)
|
|
693
|
+
const nextEffects = new Map(this.#effects)
|
|
694
|
+
const nextDescriptors = new Map(this.#effectDescriptors)
|
|
695
|
+
for (const effectId of effectIds) nextEffects.set(effectId, 'pending')
|
|
696
|
+
for (const descriptor of descriptors || []) {
|
|
697
|
+
nextDescriptors.set(descriptor.effectId, descriptor)
|
|
698
|
+
}
|
|
699
|
+
validateStopAckAuthority(snapshots.state, snapshots.receipts,
|
|
700
|
+
nextEffects, nextDescriptors, this.#effectAttempts)
|
|
701
|
+
const record = { kind: 'transition', eventId, before, after, ...snapshots,
|
|
702
|
+
effectIds: [...effectIds], ...(descriptors ? { effectDescriptors: descriptors } : {}) }
|
|
703
|
+
this.#append(record)
|
|
704
|
+
this.#apply(record)
|
|
705
|
+
return this.snapshot()
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Legacy confirmation for effects with no durable external attempt. It does
|
|
709
|
+
// not grant permission to perform an external send. External effect runners
|
|
710
|
+
// must use beginEffectAttempt/recordEffectOutcome instead.
|
|
711
|
+
recordEffect(effectId, status) {
|
|
712
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
713
|
+
id(effectId, 'effect ID', 512)
|
|
714
|
+
if (!['acked', 'unknown'].includes(status) || this.#effects.get(effectId) !== 'pending' ||
|
|
715
|
+
['finalReply', 'quietCompletion', 'stopAck', 'queueNotice'].includes(this.#effectDescriptors.get(effectId)?.type)) {
|
|
716
|
+
throw new Error('invalid C2 effect confirmation')
|
|
717
|
+
}
|
|
718
|
+
const record = { kind: 'effect', effectId, status, cursor: this.#cursor }
|
|
719
|
+
this.#append(record)
|
|
720
|
+
this.#apply(record)
|
|
721
|
+
return status
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Resolve the external target and verify the active turn before this fsync.
|
|
725
|
+
// Only after it succeeds may the caller attempt the effect. A replayed
|
|
726
|
+
// attempting status is ambiguous and can never authorize another send.
|
|
727
|
+
beginEffectAttempt(value) {
|
|
728
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
729
|
+
noticeFields(value, ['effectId', 'attemptedAt'])
|
|
730
|
+
const { effectId, attemptedAt } = value
|
|
731
|
+
id(effectId, 'effect ID', 512)
|
|
732
|
+
noticeTime(attemptedAt)
|
|
733
|
+
if (this.#effects.get(effectId) !== 'pending' || this.#effectAttempts.has(effectId) ||
|
|
734
|
+
this.#effectDescriptors.get(effectId)?.type === 'quietCompletion' ||
|
|
735
|
+
!terminalAttemptAllowed(this.#state, this.#receipts, this.#effects,
|
|
736
|
+
this.#effectDescriptors.get(effectId)) ||
|
|
737
|
+
!queueNoticeAttemptAllowed(this.#state, this.#receipts,
|
|
738
|
+
this.#effectDescriptors.get(effectId)) ||
|
|
739
|
+
!stopAckAttemptAllowed(this.#state, this.#receipts,
|
|
740
|
+
this.#effectDescriptors.get(effectId))) {
|
|
741
|
+
throw new Error('C2 effect has no unattempted durable authority')
|
|
742
|
+
}
|
|
743
|
+
const record = { kind: 'effectIntent', cursor: this.#cursor, effectId, attemptedAt }
|
|
744
|
+
this.#append(record)
|
|
745
|
+
this.#apply(record)
|
|
746
|
+
this.#localEffectIntents.add(effectId)
|
|
747
|
+
return 'attempting'
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ACK or unknown must be persisted by the process that began the attempt.
|
|
751
|
+
// An outcome write failure blocks this writer; reopen retains ambiguity.
|
|
752
|
+
recordEffectOutcome(value) {
|
|
753
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
754
|
+
const keys = value?.registrationId === undefined
|
|
755
|
+
? ['effectId', 'status', 'resolvedAt']
|
|
756
|
+
: ['effectId', 'status', 'resolvedAt', 'registrationId']
|
|
757
|
+
noticeFields(value, keys)
|
|
758
|
+
const { effectId, status, resolvedAt, registrationId } = value
|
|
759
|
+
id(effectId, 'effect ID', 512)
|
|
760
|
+
noticeTime(resolvedAt)
|
|
761
|
+
if (!['acked', 'unknown'].includes(status) ||
|
|
762
|
+
status === 'acked' && ['finalReply', 'stopAck'].includes(this.#effectDescriptors.get(effectId)?.type) ||
|
|
763
|
+
!this.#localEffectIntents.has(effectId) ||
|
|
764
|
+
this.#effects.get(effectId) !== 'attempting' ||
|
|
765
|
+
resolvedAt < this.#effectAttempts.get(effectId)) {
|
|
766
|
+
throw new Error('C2 effect has no current local attempt')
|
|
767
|
+
}
|
|
768
|
+
if (registrationId !== undefined) {
|
|
769
|
+
if (status !== 'acked' || this.#runnerAcceptances.has(effectId)) {
|
|
770
|
+
throw new Error('C2 runner acceptance must ACK one dispatch')
|
|
771
|
+
}
|
|
772
|
+
runnerAcceptanceAuthority(effectId, registrationId, this.#runnerAuthority(),
|
|
773
|
+
{ effectStatus: 'attempting' })
|
|
774
|
+
}
|
|
775
|
+
const record = { kind: 'effectOutcome', cursor: this.#cursor, effectId,
|
|
776
|
+
status, resolvedAt, ...(registrationId === undefined ? {} : { registrationId }) }
|
|
777
|
+
this.#append(record)
|
|
778
|
+
this.#apply(record)
|
|
779
|
+
this.#localEffectIntents.delete(effectId)
|
|
780
|
+
return status
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// This fsynced record is a host assertion that the accepted runner has
|
|
784
|
+
// closed. It never authorizes a new model launch or outward effect. The host
|
|
785
|
+
// must prove process exit/close before calling and keep the single-writer
|
|
786
|
+
// lease; the journal validates exact durable acceptance and active turn.
|
|
787
|
+
recordRunnerClosed({ receiptId, chatId, turnToken, generation,
|
|
788
|
+
dispatchEffectId, closedAt } = {}) {
|
|
789
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
790
|
+
const evidenceId = runnerClosedId(receiptId, generation)
|
|
791
|
+
const record = { kind: 'runnerClosed', cursor: this.#cursor,
|
|
792
|
+
evidenceId, receiptId, chatId, turnToken, generation,
|
|
793
|
+
dispatchEffectId, closedAt }
|
|
794
|
+
runnerClosureCandidate(record, this.#runnerAuthority())
|
|
795
|
+
this.#append(record)
|
|
796
|
+
this.#apply(record)
|
|
797
|
+
return evidenceId
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Only the process which began this dispatch attempt may assert that its
|
|
801
|
+
// child never spawned. The independent status survives a crash before the
|
|
802
|
+
// Rust lane transition; it is neither an ACK nor an unknown external result.
|
|
803
|
+
recordRunnerNotStarted({ receiptId, chatId, turnToken, generation,
|
|
804
|
+
dispatchEffectId, resolvedAt } = {}) {
|
|
805
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
806
|
+
if (!this.#localEffectIntents.has(dispatchEffectId)) {
|
|
807
|
+
throw new Error('C2 runner not-started proof lacks local attempt')
|
|
808
|
+
}
|
|
809
|
+
const evidenceId = runnerNotStartedId(receiptId, generation)
|
|
810
|
+
const record = { kind: 'runnerNotStarted', cursor: this.#cursor,
|
|
811
|
+
evidenceId, receiptId, chatId, turnToken, generation,
|
|
812
|
+
dispatchEffectId, resolvedAt }
|
|
813
|
+
runnerNotStartedCandidate(record, this.#notStartedAuthority())
|
|
814
|
+
this.#append(record)
|
|
815
|
+
this.#apply(record)
|
|
816
|
+
this.#localEffectIntents.delete(dispatchEffectId)
|
|
817
|
+
return evidenceId
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Final reply delivery (or quiet zero-call completion) and receipt closure
|
|
821
|
+
// share one fsynced record. The old turn must first release its queue/handoff
|
|
822
|
+
// authority while the receipt remains pending. A successor may begin only
|
|
823
|
+
// after this settlement and terminal prune.
|
|
824
|
+
settleTerminalEffect(value) {
|
|
825
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
826
|
+
noticeFields(value, value?.batchReceiptIds === undefined
|
|
827
|
+
? ['effectId', 'receiptId', 'chatId', 'turnToken', 'status', 'settledAt']
|
|
828
|
+
: ['effectId', 'receiptId', 'chatId', 'turnToken', 'status', 'settledAt',
|
|
829
|
+
'batchReceiptIds'])
|
|
830
|
+
const record = { kind: 'terminalSettlement', cursor: this.#cursor, ...value }
|
|
831
|
+
terminalSettlementCandidate(record, this.#settlementAuthority(), true)
|
|
832
|
+
this.#append(record)
|
|
833
|
+
this.#apply(record)
|
|
834
|
+
this.#localEffectIntents.delete(value.effectId)
|
|
835
|
+
return 'acked'
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// The stop reply and its distinct control receipt close in one fsynced
|
|
839
|
+
// record after a confirmed attempt in this process.
|
|
840
|
+
settleStopAck({ effectId, receiptId, chatId, settledAt } = {}) {
|
|
841
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
842
|
+
const record = { kind: 'stopAckSettlement', cursor: this.#cursor,
|
|
843
|
+
effectId, receiptId, chatId, settledAt }
|
|
844
|
+
stopAckSettlementCandidate(record, this.#settlementAuthority(), true)
|
|
845
|
+
this.#append(record)
|
|
846
|
+
this.#apply(record)
|
|
847
|
+
this.#localEffectIntents.delete(effectId)
|
|
848
|
+
return 'acked'
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// The caller must resolve the platform reply target before this fsync and
|
|
852
|
+
// must not send unless this call succeeds. An attempt left on disk after a
|
|
853
|
+
// crash is ambiguous; beginNotice will never authorize a second send.
|
|
854
|
+
beginNotice(value) {
|
|
855
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
856
|
+
noticeFields(value, ['receiptId', 'chatId', 'attemptedAt'])
|
|
857
|
+
const { receiptId, chatId, attemptedAt } = value
|
|
858
|
+
noticeIdentity(receiptId, chatId)
|
|
859
|
+
noticeTime(attemptedAt)
|
|
860
|
+
const receipt = this.#receipts.pending.find(item => item.id === receiptId)
|
|
861
|
+
if (!receipt || receipt.chatId !== chatId || receipt.quiet ||
|
|
862
|
+
this.#notices.has(receiptId) ||
|
|
863
|
+
terminalEffectForReceipt(this.#effectDescriptors, receiptId) ||
|
|
864
|
+
terminalBatchOwnsReceipt(this.#state, receiptId)) {
|
|
865
|
+
throw new Error('C2 notice requires an unattempted nonquiet pending receipt in this group')
|
|
866
|
+
}
|
|
867
|
+
validatePersistentReceipts({ pending: [receipt], completed: [] })
|
|
868
|
+
// Reject before sending if current state still owns the receipt (queue or
|
|
869
|
+
// handoff). Completion must already be legal at the instant of intent.
|
|
870
|
+
const candidate = completedReceipt(this.#receipts, receiptId, attemptedAt)
|
|
871
|
+
validateSnapshots({ state: this.#state, receipts: candidate }, this.#validateState,
|
|
872
|
+
this.#validateReceipts, this.#validateRelation)
|
|
873
|
+
const record = { kind: 'noticeIntent', cursor: this.#cursor, receiptId, chatId, attemptedAt }
|
|
874
|
+
this.#append(record)
|
|
875
|
+
this.#apply(record)
|
|
876
|
+
this.#localNoticeIntents.add(receiptId)
|
|
877
|
+
return 'attempting'
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// An acknowledged reply and receipt completion share one checksummed,
|
|
881
|
+
// fsynced record. A failed write leaves the journal blocked; reopening may
|
|
882
|
+
// reveal acked or an ambiguous attempt, never permission to resend.
|
|
883
|
+
recordNoticeOutcome(value) {
|
|
884
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
885
|
+
const names = value?.status === 'acked'
|
|
886
|
+
? ['receiptId', 'chatId', 'status', 'settledAt']
|
|
887
|
+
: ['receiptId', 'chatId', 'status', 'resolvedAt']
|
|
888
|
+
noticeFields(value, names)
|
|
889
|
+
const { receiptId, chatId, status } = value
|
|
890
|
+
noticeIdentity(receiptId, chatId)
|
|
891
|
+
if (!['acked', 'unknown'].includes(status) || !this.#localNoticeIntents.has(receiptId) ||
|
|
892
|
+
this.#notices.get(receiptId)?.status !== 'attempting' ||
|
|
893
|
+
this.#notices.get(receiptId)?.chatId !== chatId) {
|
|
894
|
+
throw new Error('C2 notice has no current local attempt')
|
|
895
|
+
}
|
|
896
|
+
const at = status === 'acked' ? value.settledAt : value.resolvedAt
|
|
897
|
+
noticeTime(at)
|
|
898
|
+
if (at < this.#notices.get(receiptId).attemptedAt) throw new Error('C2 notice time regressed')
|
|
899
|
+
if (status === 'acked') {
|
|
900
|
+
const candidate = completedReceipt(this.#receipts, receiptId, at)
|
|
901
|
+
validateSnapshots({ state: this.#state, receipts: candidate }, this.#validateState,
|
|
902
|
+
this.#validateReceipts, this.#validateRelation)
|
|
903
|
+
validateReceiptProgress(this.#receipts, candidate)
|
|
904
|
+
}
|
|
905
|
+
const record = { kind: 'noticeOutcome', cursor: this.#cursor, receiptId, chatId,
|
|
906
|
+
status, ...(status === 'acked' ? { settledAt: at } : { resolvedAt: at }) }
|
|
907
|
+
this.#append(record)
|
|
908
|
+
this.#apply(record)
|
|
909
|
+
this.#localNoticeIntents.delete(receiptId)
|
|
910
|
+
return status
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// A quiet receipt has no external effect, so it can be settled in one
|
|
914
|
+
// durable record once queue/handoff authority has been cleared.
|
|
915
|
+
settleQuietReceipt(value) {
|
|
916
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
917
|
+
noticeFields(value, ['receiptId', 'chatId', 'settledAt'])
|
|
918
|
+
const { receiptId, chatId, settledAt } = value
|
|
919
|
+
noticeIdentity(receiptId, chatId)
|
|
920
|
+
noticeTime(settledAt)
|
|
921
|
+
const receipt = this.#receipts.pending.find(item => item.id === receiptId)
|
|
922
|
+
if (!receipt || receipt.chatId !== chatId || receipt.quiet !== true ||
|
|
923
|
+
this.#notices.has(receiptId) ||
|
|
924
|
+
terminalEffectForReceipt(this.#effectDescriptors, receiptId)) {
|
|
925
|
+
throw new Error('C2 quiet settlement requires a quiet pending receipt in this group')
|
|
926
|
+
}
|
|
927
|
+
const candidate = completedReceipt(this.#receipts, receiptId, settledAt)
|
|
928
|
+
validateSnapshots({ state: this.#state, receipts: candidate }, this.#validateState,
|
|
929
|
+
this.#validateReceipts, this.#validateRelation)
|
|
930
|
+
validateReceiptProgress(this.#receipts, candidate)
|
|
931
|
+
const record = { kind: 'quietSettlement', cursor: this.#cursor, receiptId, chatId, settledAt }
|
|
932
|
+
this.#append(record)
|
|
933
|
+
this.#apply(record)
|
|
934
|
+
return 'settled'
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// A callback must be registered while its accepted receipt is pending.
|
|
938
|
+
// The record and fsync complete before the caller may expose the callback.
|
|
939
|
+
registerCallback(value) {
|
|
940
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
941
|
+
const registration = validateRegistration(value, this.#receipts, this.#registrations)
|
|
942
|
+
if (this.#notices.has(registration.receiptId)) {
|
|
943
|
+
throw new Error('C2 callback cannot register against a notice attempt')
|
|
944
|
+
}
|
|
945
|
+
const record = { kind: 'registration', cursor: this.#cursor, registration }
|
|
946
|
+
this.#append(record)
|
|
947
|
+
this.#apply(record)
|
|
948
|
+
return structuredClone(registration)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Explicit offline maintenance under the daemon's single-writer lock. The
|
|
952
|
+
// checkpoint keeps every event tombstone and every unresolved effect/notice;
|
|
953
|
+
// it only removes repeated historical state and receipt snapshots. A local
|
|
954
|
+
// notice sender must finish before replacement, since its ACK authority is
|
|
955
|
+
// deliberately not transferable to a reopened journal.
|
|
956
|
+
compactMaintenance() {
|
|
957
|
+
if (this.#blocked) throw new Error('C2 journal needs reopen after write failure')
|
|
958
|
+
if (this.#localNoticeIntents.size) throw new Error('C2 notice send is in flight')
|
|
959
|
+
if (this.#localEffectIntents.size) throw new Error('C2 effect send is in flight')
|
|
960
|
+
if ([...this.#effects.values()].some(status => status === 'pending')) {
|
|
961
|
+
throw new Error('C2 effect may be in flight')
|
|
962
|
+
}
|
|
963
|
+
const old = fs.lstatSync(this.#file, { bigint: true })
|
|
964
|
+
if (!old.isFile() || identity(old) !== this.#fileIdentity ||
|
|
965
|
+
old.size !== BigInt(this.#offset)) {
|
|
966
|
+
this.#blocked = true
|
|
967
|
+
throw new Error('C2 journal changed outside writer')
|
|
968
|
+
}
|
|
969
|
+
const record = { kind: 'checkpoint', cursor: this.#cursor,
|
|
970
|
+
state: this.#state, receipts: this.#receipts,
|
|
971
|
+
eventIds: [...this.#eventIds], effects: [...this.#effects],
|
|
972
|
+
effectAttempts: [...this.#effectAttempts],
|
|
973
|
+
effectDescriptors: [...this.#effectDescriptors],
|
|
974
|
+
terminalSettlements: [...this.#terminalSettlements],
|
|
975
|
+
runnerAcceptances: [...this.#runnerAcceptances],
|
|
976
|
+
runnerClosures: [...this.#runnerClosures],
|
|
977
|
+
runnerNotStarts: [...this.#runnerNotStarts],
|
|
978
|
+
registrations: [...this.#registrations.values()], notices: [...this.#notices] }
|
|
979
|
+
const bytes = frame(record, MAX_CHECKPOINT_BYTES)
|
|
980
|
+
const dir = path.dirname(this.#file)
|
|
981
|
+
const temp = path.join(dir, `.${path.basename(this.#file)}.compact-${randomUUID()}`)
|
|
982
|
+
let renamed = false
|
|
983
|
+
try {
|
|
984
|
+
const fd = fs.openSync(temp, 'wx', 0o600)
|
|
985
|
+
try {
|
|
986
|
+
writeAll(fd, MAGIC, 0)
|
|
987
|
+
writeAll(fd, bytes, MAGIC.length)
|
|
988
|
+
fs.fsyncSync(fd)
|
|
989
|
+
} finally { fs.closeSync(fd) }
|
|
990
|
+
const verified = C2DurableJournal.open(temp, {
|
|
991
|
+
validateState: this.#validateState,
|
|
992
|
+
validateReceipts: this.#validateReceipts,
|
|
993
|
+
validateRelation: this.#validateRelation,
|
|
994
|
+
})
|
|
995
|
+
if (verified.#cursor !== this.#cursor ||
|
|
996
|
+
!isDeepStrictEqual(verified.#state, this.#state) ||
|
|
997
|
+
!isDeepStrictEqual(verified.#receipts, this.#receipts) ||
|
|
998
|
+
!isDeepStrictEqual(verified.#eventIds, this.#eventIds) ||
|
|
999
|
+
!isDeepStrictEqual(verified.#effects, this.#effects) ||
|
|
1000
|
+
!isDeepStrictEqual(verified.#effectAttempts, this.#effectAttempts) ||
|
|
1001
|
+
!isDeepStrictEqual(verified.#effectDescriptors, this.#effectDescriptors) ||
|
|
1002
|
+
!isDeepStrictEqual(verified.#terminalSettlements, this.#terminalSettlements) ||
|
|
1003
|
+
!isDeepStrictEqual(verified.#runnerAcceptances, this.#runnerAcceptances) ||
|
|
1004
|
+
!isDeepStrictEqual(verified.#runnerClosures, this.#runnerClosures) ||
|
|
1005
|
+
!isDeepStrictEqual(verified.#runnerNotStarts, this.#runnerNotStarts) ||
|
|
1006
|
+
!isDeepStrictEqual(verified.#registrations, this.#registrations) ||
|
|
1007
|
+
!isDeepStrictEqual(verified.#notices, this.#notices)) {
|
|
1008
|
+
throw new Error('C2 compacted authority differs from source')
|
|
1009
|
+
}
|
|
1010
|
+
const beforeBytes = this.#offset
|
|
1011
|
+
fs.renameSync(temp, this.#file)
|
|
1012
|
+
renamed = true
|
|
1013
|
+
const dirFd = fs.openSync(dir, 'r')
|
|
1014
|
+
try { fs.fsyncSync(dirFd) } finally { fs.closeSync(dirFd) }
|
|
1015
|
+
const current = fs.statSync(this.#file, { bigint: true })
|
|
1016
|
+
this.#offset = MAGIC.length + bytes.length
|
|
1017
|
+
this.#fileIdentity = identity(current)
|
|
1018
|
+
return { beforeBytes, afterBytes: this.#offset, cursor: this.#cursor,
|
|
1019
|
+
eventCount: this.#eventIds.size }
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
if (renamed) this.#blocked = true
|
|
1022
|
+
throw error
|
|
1023
|
+
} finally {
|
|
1024
|
+
if (!renamed) { try { fs.unlinkSync(temp) } catch (error) { if (error.code !== 'ENOENT') throw error } }
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
#append(record) {
|
|
1029
|
+
const bytes = frame(record)
|
|
1030
|
+
let fd
|
|
1031
|
+
try {
|
|
1032
|
+
fd = fs.openSync(this.#file, 'r+')
|
|
1033
|
+
const stat = fs.fstatSync(fd, { bigint: true })
|
|
1034
|
+
if (stat.size !== BigInt(this.#offset) || identity(stat) !== this.#fileIdentity) {
|
|
1035
|
+
throw new Error('C2 journal changed outside writer')
|
|
1036
|
+
}
|
|
1037
|
+
writeAll(fd, bytes, this.#offset)
|
|
1038
|
+
fs.fsyncSync(fd)
|
|
1039
|
+
this.#offset += bytes.length
|
|
1040
|
+
} catch (error) {
|
|
1041
|
+
this.#blocked = true
|
|
1042
|
+
throw error
|
|
1043
|
+
} finally { if (fd !== undefined) fs.closeSync(fd) }
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
#apply(record) {
|
|
1047
|
+
if (record.kind === 'checkpoint') {
|
|
1048
|
+
const keys = Object.keys(record).sort().join(',')
|
|
1049
|
+
if (this.#state !== undefined || ![
|
|
1050
|
+
'cursor,effects,eventIds,kind,notices,receipts,registrations,state',
|
|
1051
|
+
'cursor,effectAttempts,effects,eventIds,kind,notices,receipts,registrations,state',
|
|
1052
|
+
'cursor,effectAttempts,effectDescriptors,effects,eventIds,kind,notices,receipts,registrations,state',
|
|
1053
|
+
'cursor,effectAttempts,effectDescriptors,effects,eventIds,kind,notices,receipts,registrations,state,terminalSettlements',
|
|
1054
|
+
'cursor,effectAttempts,effectDescriptors,effects,eventIds,kind,notices,receipts,registrations,runnerAcceptances,runnerClosures,state,terminalSettlements',
|
|
1055
|
+
'cursor,effectAttempts,effectDescriptors,effects,eventIds,kind,notices,receipts,registrations,runnerAcceptances,runnerClosures,runnerNotStarts,state,terminalSettlements',
|
|
1056
|
+
].includes(keys) ||
|
|
1057
|
+
!Number.isSafeInteger(record.cursor) || record.cursor < 0) {
|
|
1058
|
+
throw new Error('invalid C2 journal checkpoint')
|
|
1059
|
+
}
|
|
1060
|
+
const snapshots = validateSnapshots(record, this.#validateState,
|
|
1061
|
+
this.#validateReceipts, this.#validateRelation)
|
|
1062
|
+
if (snapshots.state.seq !== record.cursor ||
|
|
1063
|
+
!Array.isArray(record.eventIds) || record.eventIds.length !== record.cursor ||
|
|
1064
|
+
!Array.isArray(record.effects) || !Array.isArray(record.registrations) ||
|
|
1065
|
+
!Array.isArray(record.notices) ||
|
|
1066
|
+
(record.effectAttempts !== undefined && !Array.isArray(record.effectAttempts)) ||
|
|
1067
|
+
(record.effectDescriptors !== undefined && !Array.isArray(record.effectDescriptors)) ||
|
|
1068
|
+
(record.terminalSettlements !== undefined && !Array.isArray(record.terminalSettlements)) ||
|
|
1069
|
+
(record.runnerAcceptances !== undefined && !Array.isArray(record.runnerAcceptances)) ||
|
|
1070
|
+
(record.runnerClosures !== undefined && !Array.isArray(record.runnerClosures)) ||
|
|
1071
|
+
(record.runnerNotStarts !== undefined && !Array.isArray(record.runnerNotStarts)) ||
|
|
1072
|
+
[record.eventIds, record.effects, record.effectAttempts || [],
|
|
1073
|
+
record.effectDescriptors || [], record.terminalSettlements || [],
|
|
1074
|
+
record.runnerAcceptances || [], record.runnerClosures || [],
|
|
1075
|
+
record.runnerNotStarts || [],
|
|
1076
|
+
record.registrations, record.notices]
|
|
1077
|
+
.some(items => items.length > MAX_CHECKPOINT_ITEMS)) {
|
|
1078
|
+
throw new Error('invalid C2 checkpoint index')
|
|
1079
|
+
}
|
|
1080
|
+
const events = new Set()
|
|
1081
|
+
for (const eventId of record.eventIds) {
|
|
1082
|
+
id(eventId, 'event ID')
|
|
1083
|
+
if (events.has(eventId)) throw new Error('duplicate C2 checkpoint event')
|
|
1084
|
+
events.add(eventId)
|
|
1085
|
+
}
|
|
1086
|
+
if (snapshots.state.lastEventId !== undefined &&
|
|
1087
|
+
!events.has(snapshots.state.lastEventId)) throw new Error('C2 checkpoint last event missing')
|
|
1088
|
+
const effects = new Map()
|
|
1089
|
+
for (const item of record.effects) {
|
|
1090
|
+
if (!Array.isArray(item) || item.length !== 2 ||
|
|
1091
|
+
!['pending', 'attempting', 'acked', 'unknown', 'notStarted'].includes(item[1])) {
|
|
1092
|
+
throw new Error('invalid C2 checkpoint effect')
|
|
1093
|
+
}
|
|
1094
|
+
id(item[0], 'effect ID', 512)
|
|
1095
|
+
if (effects.has(item[0])) throw new Error('duplicate C2 checkpoint effect')
|
|
1096
|
+
effects.set(item[0], item[1])
|
|
1097
|
+
}
|
|
1098
|
+
const effectAttempts = new Map()
|
|
1099
|
+
for (const item of record.effectAttempts || []) {
|
|
1100
|
+
if (!Array.isArray(item) || item.length !== 2) {
|
|
1101
|
+
throw new Error('invalid C2 checkpoint effect attempt')
|
|
1102
|
+
}
|
|
1103
|
+
id(item[0], 'effect ID', 512)
|
|
1104
|
+
noticeTime(item[1])
|
|
1105
|
+
if (!['attempting', 'acked', 'unknown', 'notStarted'].includes(effects.get(item[0])) ||
|
|
1106
|
+
effectAttempts.has(item[0])) {
|
|
1107
|
+
throw new Error('invalid C2 checkpoint effect attempt')
|
|
1108
|
+
}
|
|
1109
|
+
effectAttempts.set(item[0], item[1])
|
|
1110
|
+
}
|
|
1111
|
+
if ([...effects].some(([effectId, status]) =>
|
|
1112
|
+
status === 'attempting' && !effectAttempts.has(effectId))) {
|
|
1113
|
+
throw new Error('C2 checkpoint attempting effect lacks attempt')
|
|
1114
|
+
}
|
|
1115
|
+
const effectDescriptors = new Map()
|
|
1116
|
+
for (const item of record.effectDescriptors || []) {
|
|
1117
|
+
if (!Array.isArray(item) || item.length !== 2) {
|
|
1118
|
+
throw new Error('invalid C2 checkpoint effect descriptor')
|
|
1119
|
+
}
|
|
1120
|
+
const descriptor = effectDescriptor(item[1])
|
|
1121
|
+
if (item[0] !== descriptor.effectId || !effects.has(item[0]) ||
|
|
1122
|
+
effectDescriptors.has(item[0])) {
|
|
1123
|
+
throw new Error('invalid C2 checkpoint effect descriptor')
|
|
1124
|
+
}
|
|
1125
|
+
effectDescriptors.set(item[0], descriptor)
|
|
1126
|
+
}
|
|
1127
|
+
const receipts = new Set([...snapshots.receipts.pending,
|
|
1128
|
+
...snapshots.receipts.completed].map(item => item.id))
|
|
1129
|
+
const registrations = new Map()
|
|
1130
|
+
for (const raw of record.registrations) {
|
|
1131
|
+
const registration = exactJson(raw)
|
|
1132
|
+
if (!registration || typeof registration !== 'object' || Array.isArray(registration) ||
|
|
1133
|
+
Object.keys(registration).length !== REGISTRATION_FIELDS.size ||
|
|
1134
|
+
Object.keys(registration).some(key => !REGISTRATION_FIELDS.has(key)) ||
|
|
1135
|
+
registration.id !== callbackRegistrationId(registration) ||
|
|
1136
|
+
!Number.isSafeInteger(registration.registeredAt) || registration.registeredAt <= 0 ||
|
|
1137
|
+
!receipts.has(registration.receiptId) || registrations.has(registration.id)) {
|
|
1138
|
+
throw new Error('invalid C2 checkpoint registration')
|
|
1139
|
+
}
|
|
1140
|
+
registrations.set(registration.id, registration)
|
|
1141
|
+
}
|
|
1142
|
+
const runnerAcceptances = new Map()
|
|
1143
|
+
for (const entry of record.runnerAcceptances || []) {
|
|
1144
|
+
if (!Array.isArray(entry) || entry.length !== 2 ||
|
|
1145
|
+
runnerAcceptances.has(entry[0])) {
|
|
1146
|
+
throw new Error('invalid C2 checkpoint runner acceptance')
|
|
1147
|
+
}
|
|
1148
|
+
runnerAcceptanceAuthority(entry[0], entry[1], {
|
|
1149
|
+
state: snapshots.state, receipts: snapshots.receipts, effects,
|
|
1150
|
+
attempts: effectAttempts, descriptors: effectDescriptors, registrations,
|
|
1151
|
+
}, { requireRunning: false })
|
|
1152
|
+
runnerAcceptances.set(entry[0], entry[1])
|
|
1153
|
+
}
|
|
1154
|
+
const runnerClosures = new Map()
|
|
1155
|
+
for (const entry of record.runnerClosures || []) {
|
|
1156
|
+
if (!Array.isArray(entry) || entry.length !== 2 ||
|
|
1157
|
+
runnerClosures.has(entry[0])) {
|
|
1158
|
+
throw new Error('invalid C2 checkpoint runner closure')
|
|
1159
|
+
}
|
|
1160
|
+
const raw = exactJson(entry[1])
|
|
1161
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) ||
|
|
1162
|
+
Object.keys(raw).sort().join(',') !==
|
|
1163
|
+
'chatId,closedAt,dispatchEffectId,generation,receiptId,turnToken') {
|
|
1164
|
+
throw new Error('invalid C2 checkpoint runner closure fields')
|
|
1165
|
+
}
|
|
1166
|
+
const proof = runnerClosureCandidate({ kind: 'runnerClosed',
|
|
1167
|
+
cursor: record.cursor, evidenceId: entry[0], ...raw }, {
|
|
1168
|
+
state: snapshots.state, receipts: snapshots.receipts, effects,
|
|
1169
|
+
attempts: effectAttempts, descriptors: effectDescriptors, registrations,
|
|
1170
|
+
acceptances: runnerAcceptances, closures: runnerClosures,
|
|
1171
|
+
}, false)
|
|
1172
|
+
runnerClosures.set(entry[0], proof)
|
|
1173
|
+
}
|
|
1174
|
+
const runnerNotStarts = new Map()
|
|
1175
|
+
for (const entry of record.runnerNotStarts || []) {
|
|
1176
|
+
if (!Array.isArray(entry) || entry.length !== 2 || runnerNotStarts.has(entry[0])) {
|
|
1177
|
+
throw new Error('invalid C2 checkpoint runner not-started proof')
|
|
1178
|
+
}
|
|
1179
|
+
const raw = exactJson(entry[1])
|
|
1180
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) ||
|
|
1181
|
+
Object.keys(raw).sort().join(',') !==
|
|
1182
|
+
'chatId,dispatchEffectId,generation,receiptId,resolvedAt,turnToken') {
|
|
1183
|
+
throw new Error('invalid C2 checkpoint runner not-started fields')
|
|
1184
|
+
}
|
|
1185
|
+
const proof = runnerNotStartedCandidate({ kind: 'runnerNotStarted',
|
|
1186
|
+
cursor: record.cursor, evidenceId: entry[0], ...raw }, {
|
|
1187
|
+
state: snapshots.state, receipts: snapshots.receipts, effects,
|
|
1188
|
+
attempts: effectAttempts, descriptors: effectDescriptors, registrations,
|
|
1189
|
+
acceptances: runnerAcceptances, closures: runnerClosures,
|
|
1190
|
+
notStarts: runnerNotStarts,
|
|
1191
|
+
}, false)
|
|
1192
|
+
runnerNotStarts.set(entry[0], proof)
|
|
1193
|
+
}
|
|
1194
|
+
if ([...effects].some(([effectId, status]) => status === 'notStarted' &&
|
|
1195
|
+
![...runnerNotStarts.values()].some(proof => proof.dispatchEffectId === effectId))) {
|
|
1196
|
+
throw new Error('C2 checkpoint not-started effect lacks proof')
|
|
1197
|
+
}
|
|
1198
|
+
const notices = new Map()
|
|
1199
|
+
const pendingIds = new Set(snapshots.receipts.pending.map(item => item.id))
|
|
1200
|
+
const completedIds = new Set(snapshots.receipts.completed.map(item => item.id))
|
|
1201
|
+
for (const entry of record.notices) {
|
|
1202
|
+
if (!Array.isArray(entry) || entry.length !== 2) throw new Error('invalid C2 checkpoint notice')
|
|
1203
|
+
const [receiptId, raw] = entry
|
|
1204
|
+
const notice = exactJson(raw)
|
|
1205
|
+
if (!notice || typeof notice !== 'object' || Array.isArray(notice)) {
|
|
1206
|
+
throw new Error('invalid C2 checkpoint notice')
|
|
1207
|
+
}
|
|
1208
|
+
noticeIdentity(receiptId, notice.chatId)
|
|
1209
|
+
const keys = Object.keys(notice).sort().join(',')
|
|
1210
|
+
if (notice.status === 'quiet-settled') {
|
|
1211
|
+
if (keys !== 'chatId,status' || !completedIds.has(receiptId)) {
|
|
1212
|
+
throw new Error('invalid C2 checkpoint quiet notice')
|
|
1213
|
+
}
|
|
1214
|
+
} else if (['attempting', 'acked', 'unknown'].includes(notice.status)) {
|
|
1215
|
+
if (keys !== 'attemptedAt,chatId,status' ||
|
|
1216
|
+
(notice.status === 'acked' ? !completedIds.has(receiptId) : !pendingIds.has(receiptId))) {
|
|
1217
|
+
throw new Error('invalid C2 checkpoint notice state')
|
|
1218
|
+
}
|
|
1219
|
+
noticeTime(notice.attemptedAt)
|
|
1220
|
+
} else throw new Error('invalid C2 checkpoint notice status')
|
|
1221
|
+
const pending = snapshots.receipts.pending.find(item => item.id === receiptId)
|
|
1222
|
+
if (pending && pending.chatId !== notice.chatId || notices.has(receiptId)) {
|
|
1223
|
+
throw new Error('invalid C2 checkpoint notice target')
|
|
1224
|
+
}
|
|
1225
|
+
notices.set(receiptId, notice)
|
|
1226
|
+
}
|
|
1227
|
+
const terminalSettlements = new Map()
|
|
1228
|
+
const settledReceipts = new Set()
|
|
1229
|
+
for (const entry of record.terminalSettlements || []) {
|
|
1230
|
+
if (!Array.isArray(entry) || entry.length !== 2) {
|
|
1231
|
+
throw new Error('invalid C2 checkpoint terminal settlement')
|
|
1232
|
+
}
|
|
1233
|
+
const [effectId, raw] = entry
|
|
1234
|
+
id(effectId, 'effect ID', 512)
|
|
1235
|
+
const item = exactJson(raw)
|
|
1236
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) ||
|
|
1237
|
+
!['chatId,receiptId,settledAt,turnToken',
|
|
1238
|
+
'batchReceiptIds,chatId,receiptId,settledAt,turnToken']
|
|
1239
|
+
.includes(Object.keys(item).sort().join(','))) {
|
|
1240
|
+
throw new Error('invalid C2 checkpoint terminal settlement')
|
|
1241
|
+
}
|
|
1242
|
+
noticeIdentity(item.receiptId, item.chatId)
|
|
1243
|
+
noticeTime(item.settledAt)
|
|
1244
|
+
const descriptor = effectDescriptors.get(effectId)
|
|
1245
|
+
const settledIds = item.batchReceiptIds || [item.receiptId]
|
|
1246
|
+
if (!Array.isArray(settledIds) || !settledIds.length ||
|
|
1247
|
+
new Set(settledIds).size !== settledIds.length ||
|
|
1248
|
+
!settledIds.includes(item.receiptId) ||
|
|
1249
|
+
item.batchReceiptIds && item.batchReceiptIds.length < 2 ||
|
|
1250
|
+
!Number.isSafeInteger(item.turnToken) || item.turnToken <= 0 ||
|
|
1251
|
+
!descriptor || descriptor.domain !== 'terminal' ||
|
|
1252
|
+
!['finalReply', 'quietCompletion'].includes(descriptor.type) ||
|
|
1253
|
+
descriptor.scope !== item.chatId || descriptor.receiptId !== item.receiptId ||
|
|
1254
|
+
descriptor.turnToken !== item.turnToken || effects.get(effectId) !== 'acked' ||
|
|
1255
|
+
(descriptor.type === 'finalReply') !== effectAttempts.has(effectId) ||
|
|
1256
|
+
settledIds.some(id => !RECEIPT_ID.test(id) ||
|
|
1257
|
+
!snapshots.receipts.completed.some(receipt =>
|
|
1258
|
+
receipt.id === id && receipt.settledAt === item.settledAt) ||
|
|
1259
|
+
notices.has(id) || settledReceipts.has(id)) ||
|
|
1260
|
+
terminalSettlements.has(effectId)) {
|
|
1261
|
+
throw new Error('invalid C2 checkpoint terminal settlement')
|
|
1262
|
+
}
|
|
1263
|
+
terminalSettlements.set(effectId, item)
|
|
1264
|
+
for (const id of settledIds) settledReceipts.add(id)
|
|
1265
|
+
}
|
|
1266
|
+
for (const [effectId, descriptor] of effectDescriptors) {
|
|
1267
|
+
if (descriptor.domain !== 'terminal' ||
|
|
1268
|
+
!['finalReply', 'quietCompletion'].includes(descriptor.type)) continue
|
|
1269
|
+
const status = effects.get(effectId)
|
|
1270
|
+
if (status === 'acked' ? !terminalSettlements.has(effectId) :
|
|
1271
|
+
!snapshots.receipts.pending.some(receipt =>
|
|
1272
|
+
receipt.id === descriptor.receiptId && receipt.chatId === descriptor.scope)) {
|
|
1273
|
+
throw new Error('C2 checkpoint terminal effect lacks atomic settlement authority')
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
validateStopAckAuthority(snapshots.state, snapshots.receipts, effects,
|
|
1277
|
+
effectDescriptors, effectAttempts)
|
|
1278
|
+
this.#state = snapshots.state
|
|
1279
|
+
this.#receipts = snapshots.receipts
|
|
1280
|
+
this.#cursor = record.cursor
|
|
1281
|
+
this.#eventIds = events
|
|
1282
|
+
this.#effects = effects
|
|
1283
|
+
this.#effectAttempts = effectAttempts
|
|
1284
|
+
this.#effectDescriptors = effectDescriptors
|
|
1285
|
+
this.#terminalSettlements = terminalSettlements
|
|
1286
|
+
this.#registrations = registrations
|
|
1287
|
+
this.#runnerAcceptances = runnerAcceptances
|
|
1288
|
+
this.#runnerClosures = runnerClosures
|
|
1289
|
+
this.#runnerNotStarts = runnerNotStarts
|
|
1290
|
+
this.#notices = notices
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
if (record.kind === 'genesis') {
|
|
1294
|
+
if (this.#state !== undefined) throw new Error('duplicate C2 journal genesis')
|
|
1295
|
+
const snapshots = validateSnapshots(record, this.#validateState,
|
|
1296
|
+
this.#validateReceipts, this.#validateRelation)
|
|
1297
|
+
if (snapshots.state.seq !== 0 || Object.keys(record).sort().join(',') !== 'kind,receipts,state') {
|
|
1298
|
+
throw new Error('invalid C2 journal genesis')
|
|
1299
|
+
}
|
|
1300
|
+
this.#state = snapshots.state
|
|
1301
|
+
this.#receipts = snapshots.receipts
|
|
1302
|
+
return
|
|
1303
|
+
}
|
|
1304
|
+
if (this.#state === undefined) throw new Error('C2 journal lacks genesis')
|
|
1305
|
+
if (record.kind === 'transition') {
|
|
1306
|
+
if ([...this.#notices.values()].some(item => item.status === 'attempting' || item.status === 'unknown')) {
|
|
1307
|
+
throw new Error('C2 transition blocked by ambiguous notice')
|
|
1308
|
+
}
|
|
1309
|
+
id(record.eventId, 'event ID')
|
|
1310
|
+
const keys = Object.keys(record).sort().join(',')
|
|
1311
|
+
if (record.before !== this.#cursor || record.after !== this.#cursor + 1 ||
|
|
1312
|
+
!Number.isSafeInteger(record.after) || this.#eventIds.has(record.eventId) ||
|
|
1313
|
+
!Array.isArray(record.effectIds) || record.effectIds.length > 4096 ||
|
|
1314
|
+
!['after,before,effectIds,eventId,kind,receipts,state',
|
|
1315
|
+
'after,before,effectDescriptors,effectIds,eventId,kind,receipts,state'].includes(keys)) {
|
|
1316
|
+
throw new Error('invalid C2 journal transition')
|
|
1317
|
+
}
|
|
1318
|
+
const snapshots = validateSnapshots(record, this.#validateState,
|
|
1319
|
+
this.#validateReceipts, this.#validateRelation)
|
|
1320
|
+
if (snapshots.state.seq !== record.after) throw new Error('C2 journal state cursor mismatch')
|
|
1321
|
+
validateReceiptProgress(this.#receipts, snapshots.receipts)
|
|
1322
|
+
const unique = new Set(record.effectIds)
|
|
1323
|
+
if (unique.size !== record.effectIds.length) throw new Error('duplicate C2 journal effects')
|
|
1324
|
+
for (const effectId of unique) {
|
|
1325
|
+
id(effectId, 'effect ID', 512)
|
|
1326
|
+
if (this.#effects.has(effectId)) throw new Error('duplicate C2 journal effect')
|
|
1327
|
+
}
|
|
1328
|
+
let descriptors = []
|
|
1329
|
+
if (record.effectDescriptors !== undefined) {
|
|
1330
|
+
if (!Array.isArray(record.effectDescriptors) ||
|
|
1331
|
+
record.effectDescriptors.length !== record.effectIds.length) {
|
|
1332
|
+
throw new Error('invalid C2 journal effect descriptors')
|
|
1333
|
+
}
|
|
1334
|
+
descriptors = record.effectDescriptors.map((raw, index) => {
|
|
1335
|
+
const descriptor = effectDescriptor(raw)
|
|
1336
|
+
if (descriptor.effectId !== record.effectIds[index]) {
|
|
1337
|
+
throw new Error('C2 journal effect descriptor mismatch')
|
|
1338
|
+
}
|
|
1339
|
+
return descriptor
|
|
1340
|
+
})
|
|
1341
|
+
}
|
|
1342
|
+
this.#state = snapshots.state
|
|
1343
|
+
this.#receipts = snapshots.receipts
|
|
1344
|
+
this.#cursor = record.after
|
|
1345
|
+
this.#eventIds.add(record.eventId)
|
|
1346
|
+
for (const effectId of unique) this.#effects.set(effectId, 'pending')
|
|
1347
|
+
for (const descriptor of descriptors) this.#effectDescriptors.set(descriptor.effectId, descriptor)
|
|
1348
|
+
validateStopAckAuthority(this.#state, this.#receipts, this.#effects,
|
|
1349
|
+
this.#effectDescriptors, this.#effectAttempts)
|
|
1350
|
+
return
|
|
1351
|
+
}
|
|
1352
|
+
if (record.kind === 'effect') {
|
|
1353
|
+
id(record.effectId, 'effect ID', 512)
|
|
1354
|
+
if (!['acked', 'unknown'].includes(record.status) || record.cursor !== this.#cursor ||
|
|
1355
|
+
this.#effects.get(record.effectId) !== 'pending' ||
|
|
1356
|
+
['finalReply', 'quietCompletion', 'stopAck', 'queueNotice'].includes(this.#effectDescriptors.get(record.effectId)?.type) ||
|
|
1357
|
+
Object.keys(record).sort().join(',') !== 'cursor,effectId,kind,status') {
|
|
1358
|
+
throw new Error('invalid C2 journal effect confirmation')
|
|
1359
|
+
}
|
|
1360
|
+
this.#effects.set(record.effectId, record.status)
|
|
1361
|
+
return
|
|
1362
|
+
}
|
|
1363
|
+
if (record.kind === 'effectIntent') {
|
|
1364
|
+
if (Object.keys(record).sort().join(',') !== 'attemptedAt,cursor,effectId,kind' ||
|
|
1365
|
+
record.cursor !== this.#cursor) throw new Error('invalid C2 effect intent record')
|
|
1366
|
+
id(record.effectId, 'effect ID', 512)
|
|
1367
|
+
noticeTime(record.attemptedAt)
|
|
1368
|
+
if (this.#effects.get(record.effectId) !== 'pending' ||
|
|
1369
|
+
this.#effectAttempts.has(record.effectId) ||
|
|
1370
|
+
this.#effectDescriptors.get(record.effectId)?.type === 'quietCompletion' ||
|
|
1371
|
+
!terminalAttemptAllowed(this.#state, this.#receipts, this.#effects,
|
|
1372
|
+
this.#effectDescriptors.get(record.effectId)) ||
|
|
1373
|
+
!queueNoticeAttemptAllowed(this.#state, this.#receipts,
|
|
1374
|
+
this.#effectDescriptors.get(record.effectId)) ||
|
|
1375
|
+
!stopAckAttemptAllowed(this.#state, this.#receipts,
|
|
1376
|
+
this.#effectDescriptors.get(record.effectId))) {
|
|
1377
|
+
throw new Error('invalid C2 effect intent target')
|
|
1378
|
+
}
|
|
1379
|
+
this.#effects.set(record.effectId, 'attempting')
|
|
1380
|
+
this.#effectAttempts.set(record.effectId, record.attemptedAt)
|
|
1381
|
+
return
|
|
1382
|
+
}
|
|
1383
|
+
if (record.kind === 'effectOutcome') {
|
|
1384
|
+
if (!['cursor,effectId,kind,resolvedAt,status',
|
|
1385
|
+
'cursor,effectId,kind,registrationId,resolvedAt,status']
|
|
1386
|
+
.includes(Object.keys(record).sort().join(',')) ||
|
|
1387
|
+
record.cursor !== this.#cursor ||
|
|
1388
|
+
!['acked', 'unknown'].includes(record.status)) {
|
|
1389
|
+
throw new Error('invalid C2 effect outcome record')
|
|
1390
|
+
}
|
|
1391
|
+
id(record.effectId, 'effect ID', 512)
|
|
1392
|
+
noticeTime(record.resolvedAt)
|
|
1393
|
+
if (this.#effects.get(record.effectId) !== 'attempting' ||
|
|
1394
|
+
record.status === 'acked' &&
|
|
1395
|
+
['finalReply', 'stopAck'].includes(this.#effectDescriptors.get(record.effectId)?.type) ||
|
|
1396
|
+
record.resolvedAt < this.#effectAttempts.get(record.effectId)) {
|
|
1397
|
+
throw new Error('invalid C2 effect outcome target')
|
|
1398
|
+
}
|
|
1399
|
+
if (record.registrationId !== undefined) {
|
|
1400
|
+
if (record.status !== 'acked' || this.#runnerAcceptances.has(record.effectId)) {
|
|
1401
|
+
throw new Error('invalid C2 runner acceptance outcome')
|
|
1402
|
+
}
|
|
1403
|
+
runnerAcceptanceAuthority(record.effectId, record.registrationId,
|
|
1404
|
+
this.#runnerAuthority(), { effectStatus: 'attempting' })
|
|
1405
|
+
this.#runnerAcceptances.set(record.effectId, record.registrationId)
|
|
1406
|
+
}
|
|
1407
|
+
this.#effects.set(record.effectId, record.status)
|
|
1408
|
+
return
|
|
1409
|
+
}
|
|
1410
|
+
if (record.kind === 'runnerClosed') {
|
|
1411
|
+
const proof = runnerClosureCandidate(record, this.#runnerAuthority())
|
|
1412
|
+
this.#runnerClosures.set(record.evidenceId, proof)
|
|
1413
|
+
return
|
|
1414
|
+
}
|
|
1415
|
+
if (record.kind === 'runnerNotStarted') {
|
|
1416
|
+
const proof = runnerNotStartedCandidate(record, this.#notStartedAuthority())
|
|
1417
|
+
this.#runnerNotStarts.set(record.evidenceId, proof)
|
|
1418
|
+
this.#effects.set(record.dispatchEffectId, 'notStarted')
|
|
1419
|
+
return
|
|
1420
|
+
}
|
|
1421
|
+
if (record.kind === 'terminalSettlement') {
|
|
1422
|
+
const candidate = terminalSettlementCandidate(record, this.#settlementAuthority(), false)
|
|
1423
|
+
if (this.#terminalSettlements.has(record.effectId)) {
|
|
1424
|
+
throw new Error('duplicate C2 terminal settlement')
|
|
1425
|
+
}
|
|
1426
|
+
this.#effects.set(record.effectId, 'acked')
|
|
1427
|
+
this.#receipts = candidate
|
|
1428
|
+
this.#terminalSettlements.set(record.effectId, { receiptId: record.receiptId,
|
|
1429
|
+
chatId: record.chatId, turnToken: record.turnToken, settledAt: record.settledAt,
|
|
1430
|
+
...(record.batchReceiptIds === undefined ? {} : {
|
|
1431
|
+
batchReceiptIds: [...record.batchReceiptIds] }) })
|
|
1432
|
+
return
|
|
1433
|
+
}
|
|
1434
|
+
if (record.kind === 'stopAckSettlement') {
|
|
1435
|
+
const candidate = stopAckSettlementCandidate(record, this.#settlementAuthority(), false)
|
|
1436
|
+
this.#effects.set(record.effectId, 'acked')
|
|
1437
|
+
this.#receipts = candidate
|
|
1438
|
+
validateStopAckAuthority(this.#state, this.#receipts, this.#effects,
|
|
1439
|
+
this.#effectDescriptors, this.#effectAttempts)
|
|
1440
|
+
return
|
|
1441
|
+
}
|
|
1442
|
+
if (record.kind === 'registration') {
|
|
1443
|
+
if (record.cursor !== this.#cursor ||
|
|
1444
|
+
Object.keys(record).sort().join(',') !== 'cursor,kind,registration') {
|
|
1445
|
+
throw new Error('invalid C2 journal registration record')
|
|
1446
|
+
}
|
|
1447
|
+
const registration = validateRegistration(record.registration, this.#receipts,
|
|
1448
|
+
this.#registrations)
|
|
1449
|
+
if (this.#notices.has(registration.receiptId)) {
|
|
1450
|
+
throw new Error('C2 callback cannot register against a notice attempt')
|
|
1451
|
+
}
|
|
1452
|
+
this.#registrations.set(registration.id, registration)
|
|
1453
|
+
return
|
|
1454
|
+
}
|
|
1455
|
+
if (record.kind === 'noticeIntent') {
|
|
1456
|
+
if (Object.keys(record).sort().join(',') !== 'attemptedAt,chatId,cursor,kind,receiptId' ||
|
|
1457
|
+
record.cursor !== this.#cursor) throw new Error('invalid C2 notice intent record')
|
|
1458
|
+
noticeIdentity(record.receiptId, record.chatId)
|
|
1459
|
+
noticeTime(record.attemptedAt)
|
|
1460
|
+
const receipt = this.#receipts.pending.find(item => item.id === record.receiptId)
|
|
1461
|
+
if (!receipt || receipt.chatId !== record.chatId || receipt.quiet ||
|
|
1462
|
+
this.#notices.has(record.receiptId) ||
|
|
1463
|
+
terminalEffectForReceipt(this.#effectDescriptors, record.receiptId) ||
|
|
1464
|
+
terminalBatchOwnsReceipt(this.#state, record.receiptId)) {
|
|
1465
|
+
throw new Error('invalid C2 notice intent target')
|
|
1466
|
+
}
|
|
1467
|
+
validatePersistentReceipts({ pending: [receipt], completed: [] })
|
|
1468
|
+
validateSnapshots({ state: this.#state,
|
|
1469
|
+
receipts: completedReceipt(this.#receipts, record.receiptId, record.attemptedAt) },
|
|
1470
|
+
this.#validateState, this.#validateReceipts, this.#validateRelation)
|
|
1471
|
+
this.#notices.set(record.receiptId, { chatId: record.chatId,
|
|
1472
|
+
status: 'attempting', attemptedAt: record.attemptedAt })
|
|
1473
|
+
return
|
|
1474
|
+
}
|
|
1475
|
+
if (record.kind === 'noticeOutcome') {
|
|
1476
|
+
const names = record.status === 'acked'
|
|
1477
|
+
? 'chatId,cursor,kind,receiptId,settledAt,status'
|
|
1478
|
+
: 'chatId,cursor,kind,receiptId,resolvedAt,status'
|
|
1479
|
+
if (Object.keys(record).sort().join(',') !== names || record.cursor !== this.#cursor ||
|
|
1480
|
+
!['acked', 'unknown'].includes(record.status)) throw new Error('invalid C2 notice outcome record')
|
|
1481
|
+
noticeIdentity(record.receiptId, record.chatId)
|
|
1482
|
+
const notice = this.#notices.get(record.receiptId)
|
|
1483
|
+
if (!notice || notice.chatId !== record.chatId || notice.status !== 'attempting') {
|
|
1484
|
+
throw new Error('invalid C2 notice outcome target')
|
|
1485
|
+
}
|
|
1486
|
+
const at = record.status === 'acked' ? record.settledAt : record.resolvedAt
|
|
1487
|
+
noticeTime(at)
|
|
1488
|
+
if (at < notice.attemptedAt) throw new Error('C2 notice time regressed')
|
|
1489
|
+
if (record.status === 'acked') {
|
|
1490
|
+
const candidate = completedReceipt(this.#receipts, record.receiptId, at)
|
|
1491
|
+
validateSnapshots({ state: this.#state, receipts: candidate }, this.#validateState,
|
|
1492
|
+
this.#validateReceipts, this.#validateRelation)
|
|
1493
|
+
validateReceiptProgress(this.#receipts, candidate)
|
|
1494
|
+
this.#receipts = candidate
|
|
1495
|
+
}
|
|
1496
|
+
this.#notices.set(record.receiptId, { ...notice, status: record.status })
|
|
1497
|
+
return
|
|
1498
|
+
}
|
|
1499
|
+
if (record.kind === 'quietSettlement') {
|
|
1500
|
+
if (Object.keys(record).sort().join(',') !== 'chatId,cursor,kind,receiptId,settledAt' ||
|
|
1501
|
+
record.cursor !== this.#cursor) throw new Error('invalid C2 quiet settlement record')
|
|
1502
|
+
noticeIdentity(record.receiptId, record.chatId)
|
|
1503
|
+
noticeTime(record.settledAt)
|
|
1504
|
+
const receipt = this.#receipts.pending.find(item => item.id === record.receiptId)
|
|
1505
|
+
if (!receipt || receipt.chatId !== record.chatId || receipt.quiet !== true ||
|
|
1506
|
+
this.#notices.has(record.receiptId) ||
|
|
1507
|
+
terminalEffectForReceipt(this.#effectDescriptors, record.receiptId)) {
|
|
1508
|
+
throw new Error('invalid C2 quiet settlement target')
|
|
1509
|
+
}
|
|
1510
|
+
const candidate = completedReceipt(this.#receipts, record.receiptId, record.settledAt)
|
|
1511
|
+
validateSnapshots({ state: this.#state, receipts: candidate }, this.#validateState,
|
|
1512
|
+
this.#validateReceipts, this.#validateRelation)
|
|
1513
|
+
validateReceiptProgress(this.#receipts, candidate)
|
|
1514
|
+
this.#receipts = candidate
|
|
1515
|
+
this.#notices.set(record.receiptId, { chatId: record.chatId, status: 'quiet-settled' })
|
|
1516
|
+
return
|
|
1517
|
+
}
|
|
1518
|
+
throw new Error('unknown C2 journal record')
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
#replay() {
|
|
1522
|
+
const fd = fs.openSync(this.#file, 'r+')
|
|
1523
|
+
try {
|
|
1524
|
+
const size = fs.fstatSync(fd).size
|
|
1525
|
+
if (size < MAGIC.length) throw new Error('truncated C2 journal header')
|
|
1526
|
+
const magic = Buffer.alloc(MAGIC.length)
|
|
1527
|
+
readAll(fd, magic, 0)
|
|
1528
|
+
if (!magic.equals(MAGIC)) throw new Error('invalid C2 journal header')
|
|
1529
|
+
let offset = MAGIC.length
|
|
1530
|
+
while (offset < size) {
|
|
1531
|
+
if (size - offset < FRAME_HEADER) break
|
|
1532
|
+
const header = Buffer.alloc(FRAME_HEADER)
|
|
1533
|
+
readAll(fd, header, offset)
|
|
1534
|
+
const length = header.readUInt32BE(0)
|
|
1535
|
+
if (!length || length > MAX_CHECKPOINT_BYTES) throw new Error('invalid C2 journal frame length')
|
|
1536
|
+
if (size - offset - FRAME_HEADER < length) break
|
|
1537
|
+
const payload = Buffer.alloc(length)
|
|
1538
|
+
readAll(fd, payload, offset + FRAME_HEADER)
|
|
1539
|
+
const hash = createHash('sha256').update(payload).digest()
|
|
1540
|
+
if (!hash.equals(header.subarray(4))) throw new Error('C2 journal checksum mismatch')
|
|
1541
|
+
let record
|
|
1542
|
+
try { record = JSON.parse(payload.toString('utf8')) } catch { throw new Error('invalid C2 journal JSON') }
|
|
1543
|
+
if (length > MAX_RECORD_BYTES && record.kind !== 'checkpoint') {
|
|
1544
|
+
throw new Error('oversized C2 journal record')
|
|
1545
|
+
}
|
|
1546
|
+
this.#apply(record)
|
|
1547
|
+
offset += FRAME_HEADER + length
|
|
1548
|
+
}
|
|
1549
|
+
if (this.#state === undefined) throw new Error('C2 journal lacks complete genesis')
|
|
1550
|
+
if (offset < size) {
|
|
1551
|
+
fs.ftruncateSync(fd, offset)
|
|
1552
|
+
fs.fsyncSync(fd)
|
|
1553
|
+
}
|
|
1554
|
+
this.#offset = offset
|
|
1555
|
+
this.#fileIdentity = identity(fs.fstatSync(fd, { bigint: true }))
|
|
1556
|
+
} finally { fs.closeSync(fd) }
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
module.exports = { C2DurableJournal }
|