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,333 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Process-local counterpart of the durable queue lane. The journal contains
|
|
4
|
+
// receipt-backed IDs and flags only; message bodies, reply contexts, platform
|
|
5
|
+
// objects and group configuration live here until dispatch or interruption.
|
|
6
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
7
|
+
const MAX_HANDLES = 1024
|
|
8
|
+
const { taskId } = require('./enginecheckpoint')
|
|
9
|
+
|
|
10
|
+
function receiptId(value) {
|
|
11
|
+
if (typeof value !== 'string' || !RECEIPT_ID.test(value)) {
|
|
12
|
+
throw new TypeError('C2 payload receipt ID invalid')
|
|
13
|
+
}
|
|
14
|
+
return value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function authority(journal) {
|
|
18
|
+
if (!journal || typeof journal.snapshot !== 'function' ||
|
|
19
|
+
typeof journal.noticeStatus !== 'function') {
|
|
20
|
+
throw new TypeError('C2 payload handles need durable journal')
|
|
21
|
+
}
|
|
22
|
+
const snapshot = journal.snapshot()
|
|
23
|
+
if (!snapshot || !snapshot.state || snapshot.state.version !== 2 ||
|
|
24
|
+
!snapshot.receipts || !Array.isArray(snapshot.receipts.pending) ||
|
|
25
|
+
!Array.isArray(snapshot.receipts.completed)) {
|
|
26
|
+
throw new Error('C2 payload authority invalid')
|
|
27
|
+
}
|
|
28
|
+
return snapshot
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pending(snapshot, id) {
|
|
32
|
+
return snapshot.receipts.pending.find(item => item.id === id) || null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function queued(snapshot, id, chatId) {
|
|
36
|
+
return (snapshot.state.lanes.queue?.[chatId]?.queue || [])
|
|
37
|
+
.some(item => item.id === `receipt:job:${id}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handedOff(snapshot, id, chatId) {
|
|
41
|
+
const lane = snapshot.state.lanes.handoff?.[chatId]
|
|
42
|
+
return lane?.phase === 'handoff' && lane.nextJobId === id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class C2PayloadHandles {
|
|
46
|
+
#journal
|
|
47
|
+
#jobs = new Map()
|
|
48
|
+
#blocked
|
|
49
|
+
#capacity
|
|
50
|
+
|
|
51
|
+
constructor({ journal, capacity = MAX_HANDLES } = {}) {
|
|
52
|
+
const snapshot = authority(journal)
|
|
53
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > MAX_HANDLES) {
|
|
54
|
+
throw new TypeError('C2 payload capacity invalid')
|
|
55
|
+
}
|
|
56
|
+
this.#journal = journal
|
|
57
|
+
this.#capacity = capacity
|
|
58
|
+
// A newly created registry cannot prove ownership of preexisting queued
|
|
59
|
+
// bodies or callback handles. Recovery must settle them before reopening.
|
|
60
|
+
this.#blocked = snapshot.cursor !== 0 || snapshot.receipts.pending.length > 0 ||
|
|
61
|
+
Object.keys(snapshot.state.lanes).length > 0
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get blocked() { return this.#blocked || this.#journal.blocked === true }
|
|
65
|
+
get size() { return this.#jobs.size }
|
|
66
|
+
|
|
67
|
+
snapshot() {
|
|
68
|
+
return { blocked: this.blocked, capacity: this.#capacity,
|
|
69
|
+
receiptIds: [...this.#jobs.keys()] }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
preflight(receipt, job) {
|
|
73
|
+
if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
|
|
74
|
+
const id = receiptId(receipt?.id)
|
|
75
|
+
if (receipt.source !== 'message' || receipt.id !== taskId(receipt) ||
|
|
76
|
+
typeof receipt.chatId !== 'string' || !receipt.chatId ||
|
|
77
|
+
typeof receipt.sourceId !== 'string' || !/^om_[A-Za-z0-9_-]{1,124}$/.test(receipt.sourceId) ||
|
|
78
|
+
typeof receipt.quiet !== 'boolean' ||
|
|
79
|
+
!job || typeof job !== 'object' || !job.msg || typeof job.msg !== 'object' ||
|
|
80
|
+
!job.platform || typeof job.platform !== 'object' ||
|
|
81
|
+
job.msg._c2ReceiptId !== id || job.msg.chatId !== receipt.chatId ||
|
|
82
|
+
(job.msg.receiptSourceId || job.msg.msgId) !== receipt.sourceId ||
|
|
83
|
+
!!job.msg.quiet !== receipt.quiet) {
|
|
84
|
+
throw new Error('C2 payload does not match candidate source receipt')
|
|
85
|
+
}
|
|
86
|
+
if (this.#jobs.has(id)) throw new Error('C2 payload receipt already attached')
|
|
87
|
+
if (this.#jobs.size >= this.#capacity) throw new RangeError('C2 payload capacity exceeded')
|
|
88
|
+
return id
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
attach(id, job) {
|
|
92
|
+
receiptId(id)
|
|
93
|
+
if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
|
|
94
|
+
const snapshot = authority(this.#journal)
|
|
95
|
+
const receipt = pending(snapshot, id)
|
|
96
|
+
if (!receipt || receipt.source !== 'message' ||
|
|
97
|
+
!queued(snapshot, id, receipt.chatId) || this.#journal.noticeStatus(id) !== null) {
|
|
98
|
+
throw new Error('C2 payload has no matching accepted queue receipt')
|
|
99
|
+
}
|
|
100
|
+
if (!job || typeof job !== 'object' || !job.msg || typeof job.msg !== 'object' ||
|
|
101
|
+
!job.platform || typeof job.platform !== 'object' ||
|
|
102
|
+
job.msg._c2ReceiptId !== id || job.msg.chatId !== receipt.chatId ||
|
|
103
|
+
(job.msg.receiptSourceId || job.msg.msgId) !== receipt.sourceId) {
|
|
104
|
+
throw new Error('C2 payload does not match durable source receipt')
|
|
105
|
+
}
|
|
106
|
+
if (this.#jobs.has(id)) throw new Error('C2 payload receipt already attached')
|
|
107
|
+
if (this.#jobs.size >= this.#capacity) throw new RangeError('C2 payload capacity exceeded')
|
|
108
|
+
this.#jobs.set(id, job)
|
|
109
|
+
return id
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
requireQueued(id) {
|
|
113
|
+
receiptId(id)
|
|
114
|
+
if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
|
|
115
|
+
const snapshot = authority(this.#journal)
|
|
116
|
+
const receipt = pending(snapshot, id)
|
|
117
|
+
const job = this.#jobs.get(id)
|
|
118
|
+
if (!receipt || !queued(snapshot, id, receipt.chatId) || !job) {
|
|
119
|
+
throw new Error('C2 queued payload handle missing')
|
|
120
|
+
}
|
|
121
|
+
return job
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
takeHandoff(id) {
|
|
125
|
+
receiptId(id)
|
|
126
|
+
if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
|
|
127
|
+
const snapshot = authority(this.#journal)
|
|
128
|
+
const receipt = pending(snapshot, id)
|
|
129
|
+
const job = this.#jobs.get(id)
|
|
130
|
+
if (!receipt || !handedOff(snapshot, id, receipt.chatId) || !job) {
|
|
131
|
+
throw new Error('C2 handoff payload handle missing or unauthorized')
|
|
132
|
+
}
|
|
133
|
+
this.#jobs.delete(id)
|
|
134
|
+
return job
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Inspect the successor before the durable begin transition. The handle
|
|
138
|
+
// stays owned by this registry until that transition is fsynced.
|
|
139
|
+
peekHandoff(id) {
|
|
140
|
+
receiptId(id)
|
|
141
|
+
if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
|
|
142
|
+
const snapshot = authority(this.#journal)
|
|
143
|
+
const receipt = pending(snapshot, id)
|
|
144
|
+
const job = this.#jobs.get(id)
|
|
145
|
+
if (!receipt || !handedOff(snapshot, id, receipt.chatId) || !job ||
|
|
146
|
+
job.msg?._c2ReceiptId !== id || job.msg.chatId !== receipt.chatId ||
|
|
147
|
+
(job.msg.receiptSourceId || job.msg.msgId) !== receipt.sourceId ||
|
|
148
|
+
this.#journal.noticeStatus(id) !== null) {
|
|
149
|
+
throw new Error('C2 handoff payload handle missing or unauthorized')
|
|
150
|
+
}
|
|
151
|
+
return job
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Consume only after the exact atomic dispatch event is on disk. Validate
|
|
155
|
+
// every member before deleting any handle, so a partial/mismatched batch
|
|
156
|
+
// cannot lose its in-memory payload while the journal still owns a receipt.
|
|
157
|
+
consumeCommittedDispatch({ eventId, turnToken, receiptIds } = {}) {
|
|
158
|
+
if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
|
|
159
|
+
if (typeof eventId !== 'string' || !eventId ||
|
|
160
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
161
|
+
!Array.isArray(receiptIds) || receiptIds.length === 0 ||
|
|
162
|
+
receiptIds.length > this.#capacity || new Set(receiptIds).size !== receiptIds.length) {
|
|
163
|
+
throw new TypeError('C2 committed dispatch identity invalid')
|
|
164
|
+
}
|
|
165
|
+
const snapshot = authority(this.#journal)
|
|
166
|
+
if (snapshot.state.lastEventId !== eventId || snapshot.cursor !== snapshot.state.seq) {
|
|
167
|
+
throw new Error('C2 dispatch event is not the latest durable commit')
|
|
168
|
+
}
|
|
169
|
+
const jobs = []
|
|
170
|
+
let chatId = null
|
|
171
|
+
for (const id of receiptIds) {
|
|
172
|
+
receiptId(id)
|
|
173
|
+
const receipt = pending(snapshot, id)
|
|
174
|
+
const job = this.#jobs.get(id)
|
|
175
|
+
if (!receipt || !job || this.#journal.noticeStatus(id) !== null ||
|
|
176
|
+
job.msg?._c2ReceiptId !== id || job.msg.chatId !== receipt.chatId ||
|
|
177
|
+
(job.msg.receiptSourceId || job.msg.msgId) !== receipt.sourceId ||
|
|
178
|
+
queued(snapshot, id, receipt.chatId)) {
|
|
179
|
+
throw new Error('C2 committed dispatch payload differs from receipt')
|
|
180
|
+
}
|
|
181
|
+
if (chatId !== null && chatId !== receipt.chatId) {
|
|
182
|
+
throw new Error('C2 committed dispatch crosses groups')
|
|
183
|
+
}
|
|
184
|
+
chatId = receipt.chatId
|
|
185
|
+
jobs.push(job)
|
|
186
|
+
}
|
|
187
|
+
const handoff = snapshot.state.lanes.handoff?.[chatId]
|
|
188
|
+
const turn = snapshot.state.lanes.turn?.[chatId]
|
|
189
|
+
if (handoff?.phase !== 'running' || handoff.turnToken !== turnToken ||
|
|
190
|
+
turn?.active !== turnToken ||
|
|
191
|
+
handoff.acceptedReceiptIds?.length !== (receiptIds.length > 1
|
|
192
|
+
? receiptIds.length : 0) ||
|
|
193
|
+
receiptIds.length > 1 && receiptIds.some((id, index) =>
|
|
194
|
+
handoff.acceptedReceiptIds[index] !== id)) {
|
|
195
|
+
throw new Error('C2 committed dispatch lacks active durable turn')
|
|
196
|
+
}
|
|
197
|
+
for (const id of receiptIds) this.#jobs.delete(id)
|
|
198
|
+
return jobs
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
dropConfirmed(id) {
|
|
202
|
+
receiptId(id)
|
|
203
|
+
const snapshot = authority(this.#journal)
|
|
204
|
+
const completed = snapshot.receipts.completed.some(item => item.id === id)
|
|
205
|
+
const status = this.#journal.noticeStatus(id)
|
|
206
|
+
if (!completed || !['acked', 'quiet-settled'].includes(status)) {
|
|
207
|
+
throw new Error('C2 payload interruption is not durably confirmed')
|
|
208
|
+
}
|
|
209
|
+
return this.#jobs.delete(id)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A platform recall has no notification to ACK. The queue removal and quiet
|
|
213
|
+
// receipt completion must already be one durable transition before the raw
|
|
214
|
+
// message handle can be forgotten. A stale or different event never grants
|
|
215
|
+
// permission to discard another queued payload.
|
|
216
|
+
dropDurablyCancelled(id, eventId, control) {
|
|
217
|
+
receiptId(id)
|
|
218
|
+
if (this.blocked || !['recall', 'stop'].includes(control) ||
|
|
219
|
+
eventId !== `sbot-c2-queue-drop-v1:${id}:${control}` ||
|
|
220
|
+
!this.#journal.hasEvent(eventId)) {
|
|
221
|
+
throw new Error('C2 recalled payload lacks durable event')
|
|
222
|
+
}
|
|
223
|
+
const snapshot = authority(this.#journal)
|
|
224
|
+
if (snapshot.state.lastEventId !== eventId ||
|
|
225
|
+
!snapshot.receipts.completed.some(item => item.id === id) ||
|
|
226
|
+
snapshot.receipts.pending.some(item => item.id === id) ||
|
|
227
|
+
Object.values(snapshot.state.lanes.queue || {}).some(lane =>
|
|
228
|
+
lane.queue.some(item => item.id === `receipt:job:${id}`)) ||
|
|
229
|
+
!this.#jobs.has(id)) {
|
|
230
|
+
throw new Error('C2 recalled payload still has live queue authority')
|
|
231
|
+
}
|
|
232
|
+
this.#jobs.delete(id)
|
|
233
|
+
return true
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A visible stop drops every queued body only after the single durable
|
|
237
|
+
// stop-all transition settled those exact queued receipts.
|
|
238
|
+
dropDurablyStopped(ids, eventId) {
|
|
239
|
+
if (this.blocked || !Array.isArray(ids) || new Set(ids).size !== ids.length ||
|
|
240
|
+
typeof eventId !== 'string' || !/^sbot-c2-stop-all-v1:/.test(eventId) ||
|
|
241
|
+
!this.#journal.hasEvent(eventId)) {
|
|
242
|
+
throw new Error('C2 stop payloads lack durable commit')
|
|
243
|
+
}
|
|
244
|
+
const snapshot = authority(this.#journal)
|
|
245
|
+
const completed = new Set(snapshot.receipts.completed.map(item => item.id))
|
|
246
|
+
const pending = new Set(snapshot.receipts.pending.map(item => item.id))
|
|
247
|
+
const queued = new Set(Object.values(snapshot.state.lanes.queue || {}).flatMap(lane =>
|
|
248
|
+
lane.queue.flatMap(item => [item.id, ...(item.batch || []).map(member => member.id)])))
|
|
249
|
+
if (snapshot.state.lastEventId !== eventId || ids.some(id => {
|
|
250
|
+
receiptId(id)
|
|
251
|
+
return !completed.has(id) || pending.has(id) || !this.#jobs.has(id) ||
|
|
252
|
+
queued.has(`receipt:job:${id}`) || queued.has(`receipt:member:${id}`)
|
|
253
|
+
})) {
|
|
254
|
+
throw new Error('C2 stop payload still has live queue authority')
|
|
255
|
+
}
|
|
256
|
+
for (const id of ids) this.#jobs.delete(id)
|
|
257
|
+
return true
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
auditQueued() {
|
|
261
|
+
const snapshot = authority(this.#journal)
|
|
262
|
+
if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
|
|
263
|
+
for (const lane of Object.values(snapshot.state.lanes.queue || {})) {
|
|
264
|
+
for (const item of lane.queue) {
|
|
265
|
+
const id = item.id.slice('receipt:job:'.length)
|
|
266
|
+
if (!this.#jobs.has(id)) throw new Error('C2 queued payload handle missing')
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return true
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A normal visible-message chain may hold many queued payloads but cannot
|
|
273
|
+
// silently acquire a batch or an unrelated handle while dispatch is in
|
|
274
|
+
// progress. Verify the entire local projection before another admission.
|
|
275
|
+
auditOrdinaryQueue(chatId, expectedIds) {
|
|
276
|
+
if (this.blocked || typeof chatId !== 'string' || !chatId ||
|
|
277
|
+
!Array.isArray(expectedIds) || expectedIds.length > this.#capacity ||
|
|
278
|
+
new Set(expectedIds).size !== expectedIds.length ||
|
|
279
|
+
expectedIds.some(id => !RECEIPT_ID.test(id))) {
|
|
280
|
+
throw new Error('C2 ordinary queue projection invalid')
|
|
281
|
+
}
|
|
282
|
+
const snapshot = authority(this.#journal)
|
|
283
|
+
const queue = snapshot.state.lanes.queue?.[chatId]?.queue
|
|
284
|
+
if (!Array.isArray(queue) || queue.length !== expectedIds.length ||
|
|
285
|
+
this.#jobs.size > this.#capacity) {
|
|
286
|
+
throw new Error('C2 ordinary queue size differs from durable lane')
|
|
287
|
+
}
|
|
288
|
+
const pendingReceipts = new Map(snapshot.receipts.pending.map(item => [item.id, item]))
|
|
289
|
+
for (let index = 0; index < queue.length; index++) {
|
|
290
|
+
const id = expectedIds[index]
|
|
291
|
+
const item = queue[index]
|
|
292
|
+
const receipt = pendingReceipts.get(id)
|
|
293
|
+
const job = this.#jobs.get(id)
|
|
294
|
+
if (item?.id !== `receipt:job:${id}` || item.batch ||
|
|
295
|
+
item.quiet !== false || item.polled !== false ||
|
|
296
|
+
item.synthetic !== false || item.wantsAck !== false ||
|
|
297
|
+
receipt?.source !== 'message' || receipt.chatId !== chatId ||
|
|
298
|
+
receipt.quiet !== false || !job ||
|
|
299
|
+
job.msg?._c2ReceiptId !== id || job.msg.chatId !== chatId ||
|
|
300
|
+
job.msg.msgId !== receipt.sourceId || job.msg.quiet ||
|
|
301
|
+
typeof job.platform?.replyOnce !== 'function' ||
|
|
302
|
+
typeof job.platform?.createStreamingCard !== 'function' ||
|
|
303
|
+
this.#journal.noticeStatus(id) !== null) {
|
|
304
|
+
throw new Error('C2 ordinary queue payload differs from durable receipt')
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const localIds = [...this.#jobs.entries()]
|
|
308
|
+
.filter(([, job]) => job.msg?.chatId === chatId).map(([id]) => id)
|
|
309
|
+
if (localIds.length !== expectedIds.length ||
|
|
310
|
+
localIds.some((id, index) => id !== expectedIds[index])) {
|
|
311
|
+
throw new Error('C2 ordinary queue has extra or missing payload handle')
|
|
312
|
+
}
|
|
313
|
+
return true
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
reopenAfterRecovery() {
|
|
317
|
+
const snapshot = authority(this.#journal)
|
|
318
|
+
if (snapshot.receipts.pending.length ||
|
|
319
|
+
Object.values(snapshot.state.lanes.queue || {}).some(lane => lane.queue.length) ||
|
|
320
|
+
Object.values(snapshot.state.lanes.handoff || {}).some(lane =>
|
|
321
|
+
lane.phase === 'handoff' || lane.phase === 'releasing') ||
|
|
322
|
+
[...snapshot.effects.values()].some(status => status !== 'acked') ||
|
|
323
|
+
[...snapshot.notices.values()].some(notice =>
|
|
324
|
+
notice.status === 'attempting' || notice.status === 'unknown')) {
|
|
325
|
+
throw new Error('C2 payload recovery has unresolved durable work')
|
|
326
|
+
}
|
|
327
|
+
this.#jobs.clear()
|
|
328
|
+
this.#blocked = false
|
|
329
|
+
return true
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = { C2PayloadHandles }
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
4
|
+
const { taskId } = require('./enginecheckpoint')
|
|
5
|
+
const { validatePersistentState } = require('./c2persiststate')
|
|
6
|
+
|
|
7
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
8
|
+
const PENDING_FIELDS = new Set(['id', 'chatId', 'source', 'sourceId', 'acceptedAt', 'quiet', 'notifiedAt'])
|
|
9
|
+
|
|
10
|
+
function plain(value, label) {
|
|
11
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
12
|
+
Object.getPrototypeOf(value) !== Object.prototype ||
|
|
13
|
+
Reflect.ownKeys(value).some(key => typeof key !== 'string' ||
|
|
14
|
+
!Object.getOwnPropertyDescriptor(value, key)?.enumerable ||
|
|
15
|
+
!Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) {
|
|
16
|
+
throw new TypeError(`${label} must be a plain data object`)
|
|
17
|
+
}
|
|
18
|
+
return value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function dense(value, max, label) {
|
|
22
|
+
if (!Array.isArray(value) || value.length > max ||
|
|
23
|
+
Reflect.ownKeys(value).length !== value.length + 1 ||
|
|
24
|
+
!Array.from({ length: value.length }, (_, index) =>
|
|
25
|
+
Object.getOwnPropertyDescriptor(value, index)).every(d => d && d.enumerable && Object.hasOwn(d, 'value'))) {
|
|
26
|
+
throw new TypeError(`${label} invalid`)
|
|
27
|
+
}
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function positive(value) { return Number.isSafeInteger(value) && value > 0 }
|
|
32
|
+
|
|
33
|
+
function validateReceiptSnapshot(receipts) {
|
|
34
|
+
plain(receipts, 'receipt snapshot')
|
|
35
|
+
if (Object.keys(receipts).length !== 2 || !Object.hasOwn(receipts, 'pending') ||
|
|
36
|
+
!Object.hasOwn(receipts, 'completed')) throw new TypeError('receipt snapshot fields invalid')
|
|
37
|
+
const pending = dense(receipts.pending, 100_000, 'pending receipts')
|
|
38
|
+
const completed = dense(receipts.completed, 100_000, 'completed receipts')
|
|
39
|
+
if (pending.length + completed.length > 100_000) throw new RangeError('receipt capacity exceeded')
|
|
40
|
+
const byId = new Map()
|
|
41
|
+
for (const receipt of pending) {
|
|
42
|
+
plain(receipt, 'pending receipt')
|
|
43
|
+
const keys = Object.keys(receipt)
|
|
44
|
+
if (![6, 7].includes(keys.length) || keys.some(key => !PENDING_FIELDS.has(key)) ||
|
|
45
|
+
!['id', 'chatId', 'source', 'sourceId', 'acceptedAt', 'quiet'].every(key => Object.hasOwn(receipt, key)) ||
|
|
46
|
+
typeof receipt.id !== 'string' || !RECEIPT_ID.test(receipt.id) ||
|
|
47
|
+
typeof receipt.chatId !== 'string' || !receipt.chatId ||
|
|
48
|
+
typeof receipt.source !== 'string' || !receipt.source ||
|
|
49
|
+
typeof receipt.sourceId !== 'string' || !receipt.sourceId ||
|
|
50
|
+
!positive(receipt.acceptedAt) || typeof receipt.quiet !== 'boolean' ||
|
|
51
|
+
Object.hasOwn(receipt, 'notifiedAt') && !positive(receipt.notifiedAt) ||
|
|
52
|
+
taskId(receipt) !== receipt.id || byId.has(receipt.id)) {
|
|
53
|
+
throw new TypeError('pending receipt invalid')
|
|
54
|
+
}
|
|
55
|
+
byId.set(receipt.id, receipt)
|
|
56
|
+
}
|
|
57
|
+
for (const item of completed) {
|
|
58
|
+
plain(item, 'completed receipt')
|
|
59
|
+
if (Object.keys(item).length !== 2 || !Object.hasOwn(item, 'id') || !Object.hasOwn(item, 'settledAt') ||
|
|
60
|
+
typeof item.id !== 'string' || !RECEIPT_ID.test(item.id) ||
|
|
61
|
+
!positive(item.settledAt) || byId.has(item.id)) throw new TypeError('completed receipt invalid')
|
|
62
|
+
byId.set(item.id, null)
|
|
63
|
+
}
|
|
64
|
+
return byId
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validatePersistentReceipts(receipts) {
|
|
68
|
+
validateReceiptSnapshot(receipts)
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A queued job is a payload handle, not a restorable payload. The journal
|
|
73
|
+
// must prove that each handle still has an accepted receipt before startup
|
|
74
|
+
// can interrupt it. A batch may include its anchor message: the same receipt
|
|
75
|
+
// then appears as job and member within that one queue item.
|
|
76
|
+
function validatePersistentRelation(state, receipts) {
|
|
77
|
+
validatePersistentState(state)
|
|
78
|
+
const byId = validateReceiptSnapshot(receipts)
|
|
79
|
+
const queueScopes = state.lanes.queue || {}
|
|
80
|
+
const allJobIds = new Set()
|
|
81
|
+
const allMemberIds = new Set()
|
|
82
|
+
for (const [scope, lane] of Object.entries(queueScopes)) {
|
|
83
|
+
for (const job of lane.queue) {
|
|
84
|
+
if (allJobIds.has(job.id)) throw new TypeError('queue job ID repeated across scopes')
|
|
85
|
+
allJobIds.add(job.id)
|
|
86
|
+
const jobReceiptId = job.id.slice('receipt:job:'.length)
|
|
87
|
+
const receipt = byId.get(jobReceiptId)
|
|
88
|
+
if (!receipt || receipt.chatId !== scope) throw new TypeError('queue job lacks matching pending receipt')
|
|
89
|
+
for (const member of job.batch || []) {
|
|
90
|
+
if (allMemberIds.has(member.id)) throw new TypeError('queue member ID repeated')
|
|
91
|
+
allMemberIds.add(member.id)
|
|
92
|
+
const memberReceiptId = member.id.slice('receipt:member:'.length)
|
|
93
|
+
const memberReceipt = byId.get(memberReceiptId)
|
|
94
|
+
if (!memberReceipt || memberReceipt.chatId !== scope) {
|
|
95
|
+
throw new TypeError('queue member lacks matching pending receipt')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const [scope, lane] of Object.entries(state.lanes.handoff || {})) {
|
|
101
|
+
if (lane.phase === 'handoff' && lane.acceptedReceiptIds.length &&
|
|
102
|
+
!lane.acceptedReceiptIds.includes(lane.nextJobId)) {
|
|
103
|
+
throw new TypeError('handoff batch omits anchor receipt')
|
|
104
|
+
}
|
|
105
|
+
for (const id of lane.acceptedReceiptIds) {
|
|
106
|
+
const receipt = byId.get(id)
|
|
107
|
+
if (!receipt || receipt.chatId !== scope) {
|
|
108
|
+
throw new TypeError('handoff accepted receipt lacks matching pending receipt')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (lane.nextJobId !== null) {
|
|
112
|
+
const receipt = byId.get(lane.nextJobId)
|
|
113
|
+
if (!receipt || receipt.chatId !== scope) {
|
|
114
|
+
throw new TypeError('handoff job lacks matching pending receipt')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const [scope, terminal] of Object.entries(state.lanes.terminal || {})) {
|
|
119
|
+
if (!terminal.finalized || !terminal.batchReceiptIds) continue
|
|
120
|
+
for (const id of terminal.batchReceiptIds) {
|
|
121
|
+
if (!byId.has(id)) throw new TypeError('terminal batch receipt missing')
|
|
122
|
+
const pending = byId.get(id)
|
|
123
|
+
if (pending && pending.chatId !== scope) {
|
|
124
|
+
throw new TypeError('terminal batch receipt belongs to another group')
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const running = state.lanes.handoff?.[scope]
|
|
128
|
+
if (running?.phase === 'running' && running.acceptedReceiptIds.length &&
|
|
129
|
+
(running.acceptedReceiptIds.length !== terminal.batchReceiptIds.length ||
|
|
130
|
+
running.acceptedReceiptIds.some((id, index) =>
|
|
131
|
+
id !== terminal.batchReceiptIds[index]))) {
|
|
132
|
+
throw new TypeError('terminal batch differs from running handoff')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const [scope, intent] of Object.entries(state.lanes.stopIntent || {})) {
|
|
136
|
+
const control = byId.get(intent.controlReceiptId)
|
|
137
|
+
const running = byId.get(intent.runningReceiptId)
|
|
138
|
+
const handoff = state.lanes.handoff?.[scope]
|
|
139
|
+
const turn = state.lanes.turn?.[scope]
|
|
140
|
+
const runningIds = handoff?.acceptedReceiptIds?.length
|
|
141
|
+
? handoff.acceptedReceiptIds : [intent.runningReceiptId]
|
|
142
|
+
if (!control || control.chatId !== scope || control.source !== 'message' ||
|
|
143
|
+
control.quiet || !running || running.chatId !== scope ||
|
|
144
|
+
running.source !== 'message' ||
|
|
145
|
+
handoff?.phase !== 'running' || handoff.turnToken !== intent.turnToken ||
|
|
146
|
+
!runningIds.includes(intent.runningReceiptId) ||
|
|
147
|
+
(runningIds.length > 1
|
|
148
|
+
? !isDeepStrictEqual(intent.batchReceiptIds, runningIds)
|
|
149
|
+
: intent.batchReceiptIds !== undefined) ||
|
|
150
|
+
runningIds.some(id => {
|
|
151
|
+
const member = byId.get(id)
|
|
152
|
+
return !member || member.chatId !== scope || member.source !== 'message'
|
|
153
|
+
}) ||
|
|
154
|
+
turn?.active !== intent.turnToken ||
|
|
155
|
+
allJobIds.has(`receipt:job:${intent.controlReceiptId}`) ||
|
|
156
|
+
allMemberIds.has(`receipt:member:${intent.controlReceiptId}`)) {
|
|
157
|
+
throw new TypeError('stop intent lacks exact pending control and running receipts')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const [scope, cleanup] of Object.entries(state.lanes.cardCleanup || {})) {
|
|
161
|
+
const receipt = byId.get(cleanup.receiptId)
|
|
162
|
+
const handoff = state.lanes.handoff?.[scope]
|
|
163
|
+
const turn = state.lanes.turn?.[scope]
|
|
164
|
+
const session = state.lanes.session?.[scope]
|
|
165
|
+
const stop = state.lanes.stopIntent?.[scope]
|
|
166
|
+
if (!receipt || receipt.chatId !== scope || receipt.source !== 'message' ||
|
|
167
|
+
handoff?.phase !== 'running' || handoff.turnToken !== cleanup.turnToken ||
|
|
168
|
+
turn?.active !== cleanup.turnToken ||
|
|
169
|
+
session?.epoch !== cleanup.turnToken || session.closeSeen !== true ||
|
|
170
|
+
stop && (stop.runningReceiptId !== cleanup.receiptId ||
|
|
171
|
+
stop.turnToken !== cleanup.turnToken ||
|
|
172
|
+
stop.runnerGeneration !== cleanup.runnerGeneration)) {
|
|
173
|
+
throw new TypeError('card cleanup lacks exact pending running receipt and close proof')
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { validatePersistentReceipts, validatePersistentRelation }
|