sintbot 1.0.15 → 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 +118 -0
- package/CHANGELOG.json +118 -0
- package/README.md +20 -156
- package/README.zh-CN.md +20 -164
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +19 -0
- package/docs/install.md +93 -0
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
- package/docs/zh-CN/install.md +93 -0
- package/install.sh +84 -0
- package/package.json +74 -34
- 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 -4711
- 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/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 -2762
- 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 -5494
- 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,394 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// C2 effect commit cursor. The binary adapter validates a transition before
|
|
4
|
+
// calling stage(); this journal stores only opaque IDs and confirmations. An
|
|
5
|
+
// unconfirmed external effect is ambiguous after a crash and is never replayed.
|
|
6
|
+
const { createHash } = require('node:crypto')
|
|
7
|
+
|
|
8
|
+
const ID = /^[0-9a-f]{32}$/
|
|
9
|
+
const MAX_EFFECTS = 1024
|
|
10
|
+
const MAX_BATCH_MEMBERS = 1024
|
|
11
|
+
const hash = (...parts) => createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 32)
|
|
12
|
+
|
|
13
|
+
function batchId(receiptIds) {
|
|
14
|
+
if (!Array.isArray(receiptIds) || receiptIds.length < 2 || receiptIds.length > MAX_BATCH_MEMBERS ||
|
|
15
|
+
receiptIds.some(id => !ID.test(id)) || new Set(receiptIds).size !== receiptIds.length) {
|
|
16
|
+
throw new TypeError('invalid C2 batch members')
|
|
17
|
+
}
|
|
18
|
+
// Preserve dispatch order: two differently ordered prompts cannot share an
|
|
19
|
+
// external effect identity even when they contain the same accepted inputs.
|
|
20
|
+
return hash('sintbot-c2-batch-v2', String(receiptIds.length), ...receiptIds)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function batchStepId(receiptIds, ordinal) {
|
|
24
|
+
if (!Number.isSafeInteger(ordinal) || ordinal < 0) throw new TypeError('invalid C2 batch step identity')
|
|
25
|
+
return hash('sintbot-c2-batch-step-v2', batchId(receiptIds), String(ordinal))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function batchEffectId(step, index) {
|
|
29
|
+
if (!ID.test(step) || !Number.isSafeInteger(index) || index < 0 || index >= MAX_EFFECTS) {
|
|
30
|
+
throw new TypeError('invalid C2 batch effect identity')
|
|
31
|
+
}
|
|
32
|
+
return hash('sintbot-c2-batch-effect-v2', step, String(index))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stepId(taskId, ordinal) {
|
|
36
|
+
if (!ID.test(taskId) || !Number.isSafeInteger(ordinal) || ordinal < 0) throw new TypeError('invalid C2 step identity')
|
|
37
|
+
return hash('sintbot-c2-step-v1', taskId, String(ordinal))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function effectId(step, index) {
|
|
41
|
+
if (!ID.test(step) || !Number.isSafeInteger(index) || index < 0 || index >= MAX_EFFECTS) {
|
|
42
|
+
throw new TypeError('invalid C2 effect identity')
|
|
43
|
+
}
|
|
44
|
+
return hash('sintbot-c2-effect-v1', step, String(index))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readCursor(state) {
|
|
48
|
+
const cursor = Object.hasOwn(state, 'effectCursor') ? state.effectCursor : { version: 1, steps: [] }
|
|
49
|
+
if (!cursor || ![1, 2].includes(cursor.version) || !Array.isArray(cursor.steps) ||
|
|
50
|
+
Object.keys(cursor).some((key) => !['version', 'steps'].includes(key))) throw new TypeError('invalid C2 effect cursor')
|
|
51
|
+
const seen = new Set(), seenBatches = new Set()
|
|
52
|
+
for (const step of cursor.steps) {
|
|
53
|
+
const isBatch = !!step && Object.hasOwn(step, 'receiptIds')
|
|
54
|
+
const keys = isBatch ? ['id', 'batchId', 'receiptIds', 'ordinal', 'effects', 'confirmed']
|
|
55
|
+
: ['id', 'taskId', 'ordinal', 'effects', 'confirmed', 'notifiedAt']
|
|
56
|
+
if (!step || Object.keys(step).some((key) => !keys.includes(key)) ||
|
|
57
|
+
!ID.test(step.id) || !Number.isSafeInteger(step.ordinal) || step.ordinal < 0 ||
|
|
58
|
+
(isBatch ? cursor.version !== 2 || !Array.isArray(step.receiptIds) ||
|
|
59
|
+
step.batchId !== batchId(step.receiptIds) || batchStepId(step.receiptIds, step.ordinal) !== step.id
|
|
60
|
+
: !ID.test(step.taskId) || stepId(step.taskId, step.ordinal) !== step.id) ||
|
|
61
|
+
seen.has(step.id) || (isBatch && seenBatches.has(step.batchId)) ||
|
|
62
|
+
!Array.isArray(step.effects) || step.effects.length > MAX_EFFECTS ||
|
|
63
|
+
!Array.isArray(step.confirmed) ||
|
|
64
|
+
(step.notifiedAt !== undefined && (!Number.isSafeInteger(step.notifiedAt) || step.notifiedAt <= 0))) {
|
|
65
|
+
throw new TypeError('invalid C2 effect cursor step')
|
|
66
|
+
}
|
|
67
|
+
seen.add(step.id)
|
|
68
|
+
if (isBatch) seenBatches.add(step.batchId)
|
|
69
|
+
const expected = step.effects.map((_, index) => isBatch ? batchEffectId(step.id, index) : effectId(step.id, index))
|
|
70
|
+
if (new Set(step.effects).size !== step.effects.length ||
|
|
71
|
+
expected.some((id, index) => id !== step.effects[index]) ||
|
|
72
|
+
new Set(step.confirmed).size !== step.confirmed.length ||
|
|
73
|
+
step.confirmed.some((id, index) => id !== step.effects[index])) {
|
|
74
|
+
throw new TypeError('invalid C2 effect cursor IDs')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return cursor
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class EngineEffectCursor {
|
|
81
|
+
constructor(journal, { now = Date.now } = {}) {
|
|
82
|
+
if (!journal || typeof journal.commitState !== 'function' || typeof journal.snapshot !== 'function') {
|
|
83
|
+
throw new TypeError('C2 effect cursor requires the shared receipt journal')
|
|
84
|
+
}
|
|
85
|
+
if (typeof now !== 'function') throw new TypeError('C2 effect cursor clock required')
|
|
86
|
+
this.journal = journal
|
|
87
|
+
this.now = now
|
|
88
|
+
this.live = new Set() // only steps staged by this process may perform effects
|
|
89
|
+
this.blocked = new Set() // an ambiguous external failure cannot be retried here
|
|
90
|
+
this._read()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_read() {
|
|
94
|
+
const disk = this.journal.snapshot()
|
|
95
|
+
if (disk.seq !== this.journal.current.seq) throw new Error('C2 effect cursor has a stale journal owner')
|
|
96
|
+
return readCursor(disk.state)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_save(cursor, sessionResetIntent = null) {
|
|
100
|
+
this.journal.commitState({ ...this.journal.current.state, effectCursor: cursor },
|
|
101
|
+
{ sessionResetIntent })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_sessionResetIntent(step, receipts, identity) {
|
|
105
|
+
if (!identity || !Number.isSafeInteger(identity.turnToken) || identity.turnToken <= 0 ||
|
|
106
|
+
typeof identity.generation !== 'string' || !ID.test(identity.generation) ||
|
|
107
|
+
identity.resetSessionAfter !== true || !['ok', 'no-reply'].includes(identity.status) ||
|
|
108
|
+
Object.keys(identity).some(key => !['turnToken', 'generation', 'resetSessionAfter', 'status'].includes(key)) ||
|
|
109
|
+
step.ordinal !== 0) {
|
|
110
|
+
throw new TypeError('C2 session reset needs an eligible terminal and turn identity')
|
|
111
|
+
}
|
|
112
|
+
if (this.journal.current.sessionResets.some(item =>
|
|
113
|
+
item.chatId === receipts[0].chatId && item.effectId !== step.id)) {
|
|
114
|
+
throw new Error('C2 group already has an unresolved session reset')
|
|
115
|
+
}
|
|
116
|
+
return { effectId: step.id, receiptIds: step.receiptIds || [step.taskId],
|
|
117
|
+
chatId: receipts[0].chatId, turnToken: identity.turnToken,
|
|
118
|
+
generation: identity.generation, phase: 'acked', status: identity.status }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_forgetSettled(steps) {
|
|
122
|
+
for (const step of steps) {
|
|
123
|
+
this.live.delete(step.id)
|
|
124
|
+
this.blocked.delete(step.id)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_receipt(taskId) {
|
|
129
|
+
const receipt = this.journal.snapshot().pending.find((item) => item.id === taskId)
|
|
130
|
+
if (!receipt) throw new Error('C2 effect has no durable target receipt')
|
|
131
|
+
return receipt
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
_batchReceipts(receiptIds) {
|
|
135
|
+
batchId(receiptIds)
|
|
136
|
+
const byId = new Map(this.journal.snapshot().pending.map(receipt => [receipt.id, receipt]))
|
|
137
|
+
const receipts = receiptIds.map(id => byId.get(id))
|
|
138
|
+
if (receipts.some(receipt => !receipt)) throw new Error('C2 batch effect has no durable target receipt')
|
|
139
|
+
if (new Set(receipts.map(receipt => receipt.chatId)).size !== 1) {
|
|
140
|
+
throw new Error('C2 batch members must belong to one group')
|
|
141
|
+
}
|
|
142
|
+
return receipts
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Call only after the reducer output and all effect IDs have been validated.
|
|
146
|
+
// The effect count is projected from that output; no payload is stored.
|
|
147
|
+
stage(taskId, ordinal, effectCount, { sessionReset = null } = {}) {
|
|
148
|
+
if (!Number.isSafeInteger(effectCount) || effectCount < 0 || effectCount > MAX_EFFECTS) {
|
|
149
|
+
throw new TypeError('invalid C2 effect count')
|
|
150
|
+
}
|
|
151
|
+
const id = stepId(taskId, ordinal)
|
|
152
|
+
const effects = Array.from({ length: effectCount }, (_, index) => effectId(id, index))
|
|
153
|
+
const cursor = this._read()
|
|
154
|
+
const receipt = this._receipt(taskId)
|
|
155
|
+
if (sessionReset && effectCount !== 0) throw new Error('C2 reset intent requires final effect ACK')
|
|
156
|
+
if (cursor.steps.some(step => step.receiptIds?.includes(taskId))) {
|
|
157
|
+
throw new Error('C2 batch member is already bound to an effect')
|
|
158
|
+
}
|
|
159
|
+
const existing = cursor.steps.find((step) => step.id === id)
|
|
160
|
+
if (existing) {
|
|
161
|
+
if (existing.effects.length !== effectCount) throw new Error('C2 step identity changed its effects')
|
|
162
|
+
if (sessionReset) {
|
|
163
|
+
const expected = this._sessionResetIntent(existing, [receipt], sessionReset)
|
|
164
|
+
if (!this.journal.current.sessionResets.some(item => JSON.stringify(item) === JSON.stringify(expected))) {
|
|
165
|
+
throw new Error('C2 reset intent identity changed')
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { id, effects: [...existing.effects], reused: true }
|
|
169
|
+
}
|
|
170
|
+
const nextStep = { id, taskId, ordinal, effects, confirmed: [] }
|
|
171
|
+
this._save({ version: cursor.version, steps: [...cursor.steps, nextStep] },
|
|
172
|
+
sessionReset ? this._sessionResetIntent(nextStep, [receipt], sessionReset) : null)
|
|
173
|
+
this.live.add(id)
|
|
174
|
+
return { id, effects, reused: false }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A zero-effect batch records a confirmed silent terminal decision. If the
|
|
178
|
+
// following atomic receipt settlement fails, startup can finish it quietly.
|
|
179
|
+
stageBatch(receiptIds, ordinal, effectCount, { sessionReset = null } = {}) {
|
|
180
|
+
if (!Number.isSafeInteger(effectCount) || effectCount < 0 || effectCount > MAX_EFFECTS) {
|
|
181
|
+
throw new TypeError('invalid C2 batch effect count')
|
|
182
|
+
}
|
|
183
|
+
const shared = batchId(receiptIds)
|
|
184
|
+
const members = [...receiptIds]
|
|
185
|
+
const id = batchStepId(members, ordinal)
|
|
186
|
+
const effects = Array.from({ length: effectCount }, (_, index) => batchEffectId(id, index))
|
|
187
|
+
const cursor = this._read()
|
|
188
|
+
const receipts = this._batchReceipts(members)
|
|
189
|
+
if (sessionReset && effectCount !== 0) throw new Error('C2 reset intent requires final effect ACK')
|
|
190
|
+
const existing = cursor.steps.find(step => step.id === id)
|
|
191
|
+
if (existing) {
|
|
192
|
+
if (existing.effects.length !== effectCount) throw new Error('C2 batch step identity changed its effects')
|
|
193
|
+
if (sessionReset) {
|
|
194
|
+
const expected = this._sessionResetIntent(existing, receipts, sessionReset)
|
|
195
|
+
if (!this.journal.current.sessionResets.some(item => JSON.stringify(item) === JSON.stringify(expected))) {
|
|
196
|
+
throw new Error('C2 reset intent identity changed')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { id, batchId: shared, receiptIds: members, effects: [...existing.effects], reused: true }
|
|
200
|
+
}
|
|
201
|
+
const memberSet = new Set(members)
|
|
202
|
+
if (cursor.steps.some(step => step.receiptIds
|
|
203
|
+
? step.receiptIds.some(member => memberSet.has(member)) : memberSet.has(step.taskId))) {
|
|
204
|
+
throw new Error('C2 batch member is already bound to an effect')
|
|
205
|
+
}
|
|
206
|
+
const nextStep = { id, batchId: shared, receiptIds: members, ordinal, effects, confirmed: [] }
|
|
207
|
+
this._save({ version: 2, steps: [...cursor.steps, nextStep] },
|
|
208
|
+
sessionReset ? this._sessionResetIntent(nextStep, receipts, sessionReset) : null)
|
|
209
|
+
this.live.add(id)
|
|
210
|
+
return { id, batchId: shared, receiptIds: members, effects, reused: false }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async apply(id, index, perform, { sessionReset = null } = {}) {
|
|
214
|
+
if (typeof perform !== 'function') throw new TypeError('C2 effect performer required')
|
|
215
|
+
const cursor = this._read()
|
|
216
|
+
const step = cursor.steps.find((item) => item.id === id)
|
|
217
|
+
if (!step || !Number.isSafeInteger(index) || index < 0 || index >= step.effects.length) {
|
|
218
|
+
throw new TypeError('unknown C2 effect')
|
|
219
|
+
}
|
|
220
|
+
const effect = step.effects[index]
|
|
221
|
+
if (sessionReset && index !== step.effects.length - 1) {
|
|
222
|
+
throw new Error('C2 reset intent requires final effect ACK')
|
|
223
|
+
}
|
|
224
|
+
const resetIntent = sessionReset ? this._sessionResetIntent(step,
|
|
225
|
+
step.receiptIds ? this._batchReceipts(step.receiptIds) : [this._receipt(step.taskId)], sessionReset) : null
|
|
226
|
+
if (step.confirmed.includes(effect)) {
|
|
227
|
+
if (resetIntent && !this.journal.current.sessionResets.some(item =>
|
|
228
|
+
JSON.stringify(item) === JSON.stringify(resetIntent))) {
|
|
229
|
+
throw new Error('C2 confirmed effect has no matching reset intent')
|
|
230
|
+
}
|
|
231
|
+
return { id: effect, status: 'already-confirmed' }
|
|
232
|
+
}
|
|
233
|
+
if (index !== step.confirmed.length) throw new Error('C2 effects must commit in order')
|
|
234
|
+
if (!this.live.has(id) || this.blocked.has(id) || step.notifiedAt !== undefined) {
|
|
235
|
+
throw new Error('C2 unconfirmed effect requires interruption notice')
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
if (await perform(effect) !== true) throw new Error('C2 effect was not confirmed')
|
|
239
|
+
const fresh = this._read()
|
|
240
|
+
const next = fresh.steps.map((item) => item.id === id
|
|
241
|
+
? { ...item, confirmed: [...item.confirmed, effect] } : item)
|
|
242
|
+
this._save({ version: fresh.version, steps: next }, resetIntent)
|
|
243
|
+
return { id: effect, status: 'confirmed' }
|
|
244
|
+
} catch (error) {
|
|
245
|
+
this.blocked.add(id)
|
|
246
|
+
throw error
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
unconfirmed() {
|
|
251
|
+
return this._read().steps.filter((step) => step.notifiedAt === undefined)
|
|
252
|
+
.map((step) => ({ id: step.id,
|
|
253
|
+
...(step.receiptIds ? { batchId: step.batchId, receiptIds: [...step.receiptIds] } : { taskId: step.taskId }),
|
|
254
|
+
effects: step.effects.filter((id) => !step.confirmed.includes(id)) }))
|
|
255
|
+
.filter((step) => step.effects.length)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
snapshot() { return JSON.parse(JSON.stringify(this._read())) }
|
|
259
|
+
|
|
260
|
+
// Call only AFTER the task receipt is settled. Reversing that order would
|
|
261
|
+
// leave a crash window where the task can restart without its effect IDs.
|
|
262
|
+
pruneConfirmedTask(taskId) {
|
|
263
|
+
if (!ID.test(taskId)) throw new TypeError('invalid C2 task identity')
|
|
264
|
+
const cursor = this._read()
|
|
265
|
+
if (cursor.steps.some(step => step.receiptIds?.includes(taskId))) {
|
|
266
|
+
throw new Error('C2 batch member requires atomic settlement')
|
|
267
|
+
}
|
|
268
|
+
const mine = cursor.steps.filter((step) => step.taskId === taskId)
|
|
269
|
+
if (mine.some((step) => step.confirmed.length !== step.effects.length)) {
|
|
270
|
+
throw new Error('C2 task still has unconfirmed effects')
|
|
271
|
+
}
|
|
272
|
+
if (mine.length && this.journal.snapshot().pending.some((item) => item.id === taskId)) {
|
|
273
|
+
throw new Error('C2 task receipt is still pending')
|
|
274
|
+
}
|
|
275
|
+
if (mine.length) {
|
|
276
|
+
this._save({ version: cursor.version, steps: cursor.steps.filter((step) => step.taskId !== taskId) })
|
|
277
|
+
this._forgetSettled(mine)
|
|
278
|
+
}
|
|
279
|
+
return mine.length
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Normal terminal path: remove the accepted task receipt and all of its
|
|
283
|
+
// confirmed effect IDs in ONE checkpoint write. A failed write leaves both
|
|
284
|
+
// in place; neither half can disappear independently.
|
|
285
|
+
settleConfirmedTask(taskId) {
|
|
286
|
+
if (!ID.test(taskId)) throw new TypeError('invalid C2 task identity')
|
|
287
|
+
const cursor = this._read()
|
|
288
|
+
if (cursor.steps.some(step => step.receiptIds?.includes(taskId))) {
|
|
289
|
+
throw new Error('C2 batch member requires atomic settlement')
|
|
290
|
+
}
|
|
291
|
+
const mine = cursor.steps.filter((step) => step.taskId === taskId)
|
|
292
|
+
if (mine.some((step) => step.confirmed.length !== step.effects.length)) {
|
|
293
|
+
throw new Error('C2 task still has unconfirmed effects')
|
|
294
|
+
}
|
|
295
|
+
this._receipt(taskId)
|
|
296
|
+
const nextState = { ...this.journal.current.state, effectCursor: {
|
|
297
|
+
version: cursor.version, steps: cursor.steps.filter((step) => step.taskId !== taskId),
|
|
298
|
+
} }
|
|
299
|
+
this.journal.settle(taskId, nextState)
|
|
300
|
+
this._forgetSettled(mine)
|
|
301
|
+
return mine.length
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Recovery-only terminal path. The caller must have a confirmed targeted
|
|
305
|
+
// interruption notice (or a previously durable notifiedAt receipt). This
|
|
306
|
+
// single checkpoint commit clears the receipt and every effect step.
|
|
307
|
+
settleInterruptedTask(taskId, { noticeConfirmed = false } = {}) {
|
|
308
|
+
if (noticeConfirmed !== true) throw new Error('C2 interruption notice is not confirmed')
|
|
309
|
+
if (!ID.test(taskId)) throw new TypeError('invalid C2 task identity')
|
|
310
|
+
const cursor = this._read()
|
|
311
|
+
if (cursor.steps.some(step => step.receiptIds?.includes(taskId))) {
|
|
312
|
+
throw new Error('C2 batch member requires atomic settlement')
|
|
313
|
+
}
|
|
314
|
+
this._receipt(taskId)
|
|
315
|
+
const mine = cursor.steps.filter((step) => step.taskId === taskId)
|
|
316
|
+
this.journal.settle(taskId, { ...this.journal.current.state, effectCursor: {
|
|
317
|
+
version: cursor.version, steps: cursor.steps.filter((step) => step.taskId !== taskId),
|
|
318
|
+
} })
|
|
319
|
+
this._forgetSettled(mine)
|
|
320
|
+
return mine.length
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
settleConfirmedBatch(receiptIds) {
|
|
324
|
+
const shared = batchId(receiptIds)
|
|
325
|
+
const members = [...receiptIds]
|
|
326
|
+
const cursor = this._read()
|
|
327
|
+
const mine = cursor.steps.filter(step => step.batchId === shared)
|
|
328
|
+
if (!mine.length) throw new Error('unknown C2 batch effect')
|
|
329
|
+
if (mine.some(step => step.confirmed.length !== step.effects.length)) {
|
|
330
|
+
throw new Error('C2 batch still has unconfirmed effects')
|
|
331
|
+
}
|
|
332
|
+
this._batchReceipts(members)
|
|
333
|
+
this.journal.settleBatch(members, { ...this.journal.current.state, effectCursor: {
|
|
334
|
+
version: 2, steps: cursor.steps.filter(step => step.batchId !== shared),
|
|
335
|
+
} })
|
|
336
|
+
this._forgetSettled(mine)
|
|
337
|
+
return mine.length
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
settleInterruptedBatch(receiptIds) {
|
|
341
|
+
const shared = batchId(receiptIds)
|
|
342
|
+
const members = [...receiptIds]
|
|
343
|
+
const cursor = this._read()
|
|
344
|
+
const mine = cursor.steps.filter(step => step.batchId === shared)
|
|
345
|
+
if (!mine.length) throw new Error('unknown C2 batch effect')
|
|
346
|
+
if (this._batchReceipts(members).some(receipt => receipt.notifiedAt === undefined)) {
|
|
347
|
+
throw new Error('C2 batch interruption notices are not all confirmed')
|
|
348
|
+
}
|
|
349
|
+
this.journal.settleBatch(members, { ...this.journal.current.state, effectCursor: {
|
|
350
|
+
version: 2, steps: cursor.steps.filter(step => step.batchId !== shared),
|
|
351
|
+
} })
|
|
352
|
+
this._forgetSettled(mine)
|
|
353
|
+
return mine.length
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// send() must confirm a targeted interruption notice. A failed or ambiguous
|
|
357
|
+
// send leaves the step pending; a confirmed one is recorded before returning.
|
|
358
|
+
async notifyUnconfirmed(send) {
|
|
359
|
+
if (typeof send !== 'function') throw new TypeError('C2 interruption sender required')
|
|
360
|
+
const results = []
|
|
361
|
+
const unconfirmed = this.unconfirmed()
|
|
362
|
+
if (unconfirmed.some(step => step.receiptIds)) {
|
|
363
|
+
throw new Error('C2 batch effects require shared startup recovery')
|
|
364
|
+
}
|
|
365
|
+
const pending = unconfirmed.map((step) => ({ ...step, receipt: this._receipt(step.taskId) }))
|
|
366
|
+
for (const step of pending) {
|
|
367
|
+
try {
|
|
368
|
+
const at = this.now()
|
|
369
|
+
if (!Number.isSafeInteger(at) || at <= 0) throw new Error('C2 notification time required')
|
|
370
|
+
if (await send({ id: step.id, taskId: step.taskId, effects: [...step.effects],
|
|
371
|
+
target: { chatId: step.receipt.chatId, source: step.receipt.source,
|
|
372
|
+
sourceId: step.receipt.sourceId } }) !== true) {
|
|
373
|
+
throw new Error('C2 interruption notice was not confirmed')
|
|
374
|
+
}
|
|
375
|
+
const cursor = this._read()
|
|
376
|
+
this._save({ version: cursor.version, steps: cursor.steps.map((item) => item.id === step.id
|
|
377
|
+
? { ...item, notifiedAt: at } : item) })
|
|
378
|
+
this.live.delete(step.id)
|
|
379
|
+
results.push({ id: step.id, status: 'notified' })
|
|
380
|
+
} catch {
|
|
381
|
+
this.blocked.add(step.id)
|
|
382
|
+
results.push({ id: step.id, status: 'retry' })
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (results.some((item) => item.status === 'retry')) {
|
|
386
|
+
const error = new Error('C2 effect interruption notices remain pending')
|
|
387
|
+
error.results = results
|
|
388
|
+
throw error
|
|
389
|
+
}
|
|
390
|
+
return results
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = { EngineEffectCursor, stepId, effectId, batchId, batchStepId, batchEffectId, MAX_BATCH_MEMBERS }
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require('node:crypto')
|
|
4
|
+
const { engineStep, resetBinaryCore } = require('../agent/privatecore')
|
|
5
|
+
|
|
6
|
+
// Development bridge for the unified C2 entry. A prepared result reserves the
|
|
7
|
+
// coordinator while other host reducers validate; only commit publishes it.
|
|
8
|
+
// Persistence is opt-in: lifecycle state can contain turnText, so callers must
|
|
9
|
+
// provide an approved durable representation before enabling a writer.
|
|
10
|
+
class EngineStepCoordinator {
|
|
11
|
+
#committed
|
|
12
|
+
#blocked = false
|
|
13
|
+
#pending = null
|
|
14
|
+
#step
|
|
15
|
+
#reset
|
|
16
|
+
#persist
|
|
17
|
+
#journal
|
|
18
|
+
|
|
19
|
+
constructor({ state = { version: 1, seq: 0, lanes: {} }, step = engineStep,
|
|
20
|
+
reset = resetBinaryCore, persist = null, journal = null } = {}) {
|
|
21
|
+
const durable = journal?.snapshot?.()
|
|
22
|
+
if (journal) state = durable?.state
|
|
23
|
+
if (typeof step !== 'function' || typeof reset !== 'function' ||
|
|
24
|
+
(persist !== null && (typeof persist !== 'function' || persist.constructor?.name === 'AsyncFunction')) ||
|
|
25
|
+
(journal !== null && (persist !== null || typeof journal.snapshot !== 'function' ||
|
|
26
|
+
typeof journal.hasEvent !== 'function' || typeof journal.appendTransition !== 'function' ||
|
|
27
|
+
state?.version !== 2 || durable.cursor !== state.seq)) || !validState(state)) {
|
|
28
|
+
throw new TypeError('invalid C2 step coordinator')
|
|
29
|
+
}
|
|
30
|
+
this.#committed = structuredClone(state)
|
|
31
|
+
this.#step = step
|
|
32
|
+
this.#reset = reset
|
|
33
|
+
this.#persist = persist
|
|
34
|
+
this.#journal = journal
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get blocked() { return this.#blocked }
|
|
38
|
+
snapshot() { return structuredClone(this.#committed) }
|
|
39
|
+
|
|
40
|
+
prepare({ domain, scope, payload, seed, id, validate = null, receipts = null } = {}) {
|
|
41
|
+
if (this.#blocked) throw new Error('C2 step coordinator needs recovery')
|
|
42
|
+
if (this.#pending) throw new Error('C2 step coordinator has an uncommitted transition')
|
|
43
|
+
if (this.#journal && id === undefined) throw new Error('durable C2 event ID required')
|
|
44
|
+
id ??= randomUUID()
|
|
45
|
+
const token = {}
|
|
46
|
+
this.#pending = token
|
|
47
|
+
const event = { id, domain, scope, expectedCursor: this.#committed.seq, payload }
|
|
48
|
+
if (seed !== undefined) event.seed = seed
|
|
49
|
+
try {
|
|
50
|
+
if (![id, domain, scope].every(value => typeof value === 'string' &&
|
|
51
|
+
Buffer.byteLength(value) > 0 && Buffer.byteLength(value) <= 256) ||
|
|
52
|
+
!payload || typeof payload !== 'object' || Array.isArray(payload) ||
|
|
53
|
+
(seed !== undefined && (!seed || typeof seed !== 'object' || Array.isArray(seed)))) {
|
|
54
|
+
throw new TypeError('invalid C2 step event')
|
|
55
|
+
}
|
|
56
|
+
if (validate !== null && typeof validate !== 'function') throw new TypeError('invalid C2 step validator')
|
|
57
|
+
if (validate && validate.constructor?.name === 'AsyncFunction') throw new TypeError('C2 step validator must be synchronous')
|
|
58
|
+
if (this.#journal && this.#journal.hasEvent(id)) throw new Error('C2 event already committed')
|
|
59
|
+
const durableReceipts = this.#journal
|
|
60
|
+
? structuredClone(receipts ?? this.#journal.snapshot().receipts) : null
|
|
61
|
+
const admission = this.#journal
|
|
62
|
+
? { version: 1, cursor: this.#committed.seq, eventId: id, unseen: true } : undefined
|
|
63
|
+
const result = this.#step(this.snapshot(), event, admission)
|
|
64
|
+
const copy = structuredClone(result)
|
|
65
|
+
if (!validResult(copy, this.#committed.seq, id, domain, scope)) {
|
|
66
|
+
throw new Error('invalid C2 step result')
|
|
67
|
+
}
|
|
68
|
+
if (validate) {
|
|
69
|
+
const verdict = validate(structuredClone(copy))
|
|
70
|
+
if (verdict && typeof verdict.then === 'function') throw new TypeError('C2 step validator must be synchronous')
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
result: structuredClone(copy),
|
|
74
|
+
commit: () => {
|
|
75
|
+
if (this.#pending !== token || this.#blocked) throw new Error('C2 step preparation is no longer current')
|
|
76
|
+
try {
|
|
77
|
+
if (this.#journal) {
|
|
78
|
+
this.#journal.appendTransition({ eventId: id, before: this.#committed.seq,
|
|
79
|
+
after: copy.state.seq, state: copy.state, receipts: durableReceipts,
|
|
80
|
+
effectIds: copy.effects.map(effect => effect.effectId),
|
|
81
|
+
...(copy.effectDescriptors === undefined ? {} :
|
|
82
|
+
{ effectDescriptors: copy.effectDescriptors }) })
|
|
83
|
+
} else if (this.#persist) {
|
|
84
|
+
const saved = this.#persist(structuredClone(copy.state))
|
|
85
|
+
if (saved && typeof saved.then === 'function') throw new TypeError('C2 step persistence must be synchronous')
|
|
86
|
+
}
|
|
87
|
+
this.#committed = structuredClone(copy.state)
|
|
88
|
+
this.#pending = null
|
|
89
|
+
return structuredClone(copy)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
this.#pending = null
|
|
92
|
+
this.#blocked = true
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
cancel: () => {
|
|
97
|
+
if (this.#pending !== token) return false
|
|
98
|
+
this.#pending = null
|
|
99
|
+
return true
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
this.#pending = null
|
|
104
|
+
this.#blocked = true
|
|
105
|
+
throw error
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
transition(options) { return this.prepare(options).commit() }
|
|
110
|
+
|
|
111
|
+
recover(state) {
|
|
112
|
+
if (this.#journal) throw new Error('durable C2 recovery needs verified journal reopen')
|
|
113
|
+
// A failed durable writer may have committed before reporting an error.
|
|
114
|
+
// Its last trusted on-disk snapshot must be supplied by the caller.
|
|
115
|
+
if (this.#persist && state === undefined) throw new Error('C2 recovery needs durable state import')
|
|
116
|
+
if (state !== undefined && !validState(state)) throw new TypeError('invalid C2 recovery state')
|
|
117
|
+
this.#reset()
|
|
118
|
+
if (state !== undefined) this.#committed = structuredClone(state)
|
|
119
|
+
this.#pending = null
|
|
120
|
+
this.#blocked = false
|
|
121
|
+
return this.snapshot()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
recoverFromJournal(journal) {
|
|
125
|
+
if (!this.#journal || !journal || typeof journal.snapshot !== 'function' ||
|
|
126
|
+
typeof journal.hasEvent !== 'function' || typeof journal.appendTransition !== 'function') {
|
|
127
|
+
throw new TypeError('durable C2 journal required')
|
|
128
|
+
}
|
|
129
|
+
const durable = journal.snapshot()
|
|
130
|
+
if (!validState(durable.state) || durable.state.version !== 2 ||
|
|
131
|
+
durable.cursor !== durable.state.seq) throw new TypeError('invalid durable C2 state')
|
|
132
|
+
this.#reset()
|
|
133
|
+
this.#journal = journal
|
|
134
|
+
this.#committed = structuredClone(durable.state)
|
|
135
|
+
this.#pending = null
|
|
136
|
+
this.#blocked = false
|
|
137
|
+
return this.snapshot()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validState(state) {
|
|
142
|
+
if (!state || typeof state !== 'object' || Array.isArray(state) ||
|
|
143
|
+
![1, 2].includes(state.version) || !Number.isSafeInteger(state.seq) || state.seq < 0 ||
|
|
144
|
+
!state.lanes || typeof state.lanes !== 'object' || Array.isArray(state.lanes)) return false
|
|
145
|
+
if (state.version === 2) return Object.keys(state).every(key =>
|
|
146
|
+
['version', 'seq', 'lanes', 'lastEventId'].includes(key)) &&
|
|
147
|
+
(state.seq === 0 ? state.lastEventId === undefined :
|
|
148
|
+
typeof state.lastEventId === 'string' && !!state.lastEventId &&
|
|
149
|
+
Buffer.byteLength(state.lastEventId) <= 256)
|
|
150
|
+
if (state.seq === 0 && state.committedEventIds === undefined && state.lastEventId === undefined) return true
|
|
151
|
+
const ids = state.committedEventIds
|
|
152
|
+
if (!ids || typeof ids !== 'object' || Array.isArray(ids) ||
|
|
153
|
+
Object.keys(ids).length !== state.seq || Object.keys(ids).length > 100_000 ||
|
|
154
|
+
Object.entries(ids).some(([id, value]) => value !== true || !id || Buffer.byteLength(id) > 256)) return false
|
|
155
|
+
if (state.seq === 0) return state.lastEventId === undefined
|
|
156
|
+
if (typeof state.lastEventId !== 'string' || !state.lastEventId ||
|
|
157
|
+
Buffer.byteLength(state.lastEventId) > 256 || !Object.hasOwn(ids, state.lastEventId)) return false
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function validResult(result, priorSeq, id, domain, scope) {
|
|
162
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) ||
|
|
163
|
+
!validState(result.state) || result.state.seq !== priorSeq + 1 ||
|
|
164
|
+
result.cursor !== result.state.seq || result.eventId !== id ||
|
|
165
|
+
(result.state.lastEventId !== undefined && result.state.lastEventId !== id) ||
|
|
166
|
+
(result.state.committedEventIds !== undefined && !Object.hasOwn(result.state.committedEventIds, id)) ||
|
|
167
|
+
!Array.isArray(result.effects) ||
|
|
168
|
+
(result.effectDescriptors !== undefined &&
|
|
169
|
+
(!Array.isArray(result.effectDescriptors) ||
|
|
170
|
+
result.effectDescriptors.length !== result.effects.length ||
|
|
171
|
+
result.effectDescriptors.some((descriptor, index) =>
|
|
172
|
+
!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor) ||
|
|
173
|
+
descriptor.effectId !== result.effects[index]?.effectId ||
|
|
174
|
+
descriptor.type !== result.effects[index]?.type ||
|
|
175
|
+
descriptor.domain !== domain || descriptor.scope !== scope))) ||
|
|
176
|
+
!result.decision ||
|
|
177
|
+
typeof result.decision !== 'object' || Array.isArray(result.decision)) return false
|
|
178
|
+
return result.effects.every((effect, index) => effect && typeof effect === 'object' &&
|
|
179
|
+
!Array.isArray(effect) && typeof effect.type === 'string' && !!effect.type &&
|
|
180
|
+
effect.effectId === `sbot-c2-effect-v1:${Buffer.byteLength(id)}:${id}:${index}`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { EngineStepCoordinator }
|
package/src/core/store.js
CHANGED
|
@@ -20,15 +20,15 @@ function atomicWriteJSON(file, obj, opts = {}) {
|
|
|
20
20
|
// the default umask — would silently widen it back to 0644 on every save.
|
|
21
21
|
let mode = null
|
|
22
22
|
try { mode = fs.statSync(file).mode & 0o777 } catch { mode = opts.mode == null ? null : opts.mode } // 新文件:有 opts.mode 就用它,否则随 umask
|
|
23
|
-
const fd = fs.openSync(tmp, 'w')
|
|
24
|
-
try {
|
|
25
|
-
fs.writeSync(fd, data)
|
|
26
|
-
fs.fsyncSync(fd)
|
|
27
|
-
if (mode != null) fs.fchmodSync(fd, mode)
|
|
28
|
-
} finally {
|
|
29
|
-
fs.closeSync(fd)
|
|
30
|
-
}
|
|
31
23
|
try {
|
|
24
|
+
const fd = fs.openSync(tmp, 'w')
|
|
25
|
+
try {
|
|
26
|
+
fs.writeSync(fd, data)
|
|
27
|
+
fs.fsyncSync(fd)
|
|
28
|
+
if (mode != null) fs.fchmodSync(fd, mode)
|
|
29
|
+
} finally {
|
|
30
|
+
fs.closeSync(fd)
|
|
31
|
+
}
|
|
32
32
|
fs.renameSync(tmp, file)
|
|
33
33
|
} catch (e) {
|
|
34
34
|
try { fs.unlinkSync(tmp) } catch { /* best-effort cleanup */ }
|
|
@@ -89,6 +89,35 @@ class StateStore {
|
|
|
89
89
|
|
|
90
90
|
getSession(chatId) { return this._group(chatId).sessionId || null }
|
|
91
91
|
setSession(chatId, sid) { this._group(chatId).sessionId = sid; this._save() }
|
|
92
|
+
// C2 writeback: persist a new snapshot before exposing the session in this
|
|
93
|
+
// process. Mark an aborted session in the same replacement when requested;
|
|
94
|
+
// omitted/false tainted leaves the existing flag as the legacy setter does.
|
|
95
|
+
// The legacy setter keeps its existing behavior for other callers.
|
|
96
|
+
setSessionAtomic(chatId, sid, { tainted, sessionStats } = {}) {
|
|
97
|
+
if (sessionStats !== undefined && (!sessionStats ||
|
|
98
|
+
typeof sessionStats !== 'object' || Array.isArray(sessionStats) ||
|
|
99
|
+
sessionStats.sessionId !== sid ||
|
|
100
|
+
['turns', 'in', 'out'].some(key =>
|
|
101
|
+
!Number.isFinite(sessionStats[key]) || sessionStats[key] < 0) ||
|
|
102
|
+
(sessionStats.ctx !== undefined &&
|
|
103
|
+
(!Number.isFinite(sessionStats.ctx) || sessionStats.ctx < 0)) ||
|
|
104
|
+
(sessionStats.cum !== undefined && (!sessionStats.cum ||
|
|
105
|
+
typeof sessionStats.cum !== 'object' || Array.isArray(sessionStats.cum) ||
|
|
106
|
+
['in', 'cr', 'out'].some(key =>
|
|
107
|
+
!Number.isFinite(sessionStats.cum[key]) || sessionStats.cum[key] < 0))))) {
|
|
108
|
+
throw new TypeError('session statistics must match the session being written')
|
|
109
|
+
}
|
|
110
|
+
const groups = this.data.groups || {}
|
|
111
|
+
const group = { ...(groups[chatId] || {}), sessionId: sid }
|
|
112
|
+
if (tainted === true) group.tainted = true
|
|
113
|
+
if (sessionStats !== undefined) group.sessionStats = {
|
|
114
|
+
...sessionStats,
|
|
115
|
+
...(sessionStats.cum === undefined ? {} : { cum: { ...sessionStats.cum } }),
|
|
116
|
+
}
|
|
117
|
+
const next = { ...this.data, groups: { ...groups, [chatId]: group } }
|
|
118
|
+
atomicWriteJSON(this.file, next, { mode: 0o600 })
|
|
119
|
+
this.data = next
|
|
120
|
+
}
|
|
92
121
|
// 清会话连带清「被掐断」标记:标记属于那段会话,会话没了它就没有意义。
|
|
93
122
|
clearSession(chatId) { const g = this._group(chatId); delete g.sessionId; delete g.tainted; this._save() }
|
|
94
123
|
|
|
@@ -108,6 +137,21 @@ class StateStore {
|
|
|
108
137
|
putSessionStats(chatId, s) { this._group(chatId).sessionStats = s; this._save() }
|
|
109
138
|
clearSessionStats(chatId) { delete this._group(chatId).sessionStats; this._save() }
|
|
110
139
|
|
|
140
|
+
// Session writeback may be followed by a requested fresh session. Publish
|
|
141
|
+
// all three session-owned fields in one file replacement, and only replace
|
|
142
|
+
// the live snapshot after that write succeeds. A failed fsync/rename cannot
|
|
143
|
+
// make this process observe a reset that the next process will not see.
|
|
144
|
+
resetSessionAndStats(chatId) {
|
|
145
|
+
const groups = this.data.groups || {}
|
|
146
|
+
const group = { ...(groups[chatId] || {}) }
|
|
147
|
+
delete group.sessionId
|
|
148
|
+
delete group.tainted
|
|
149
|
+
delete group.sessionStats
|
|
150
|
+
const next = { ...this.data, groups: { ...groups, [chatId]: group } }
|
|
151
|
+
atomicWriteJSON(this.file, next, { mode: 0o600 })
|
|
152
|
+
this.data = next
|
|
153
|
+
}
|
|
154
|
+
|
|
111
155
|
getProvider(chatId) { return this._group(chatId).provider || null }
|
|
112
156
|
// ⚠ 人主动切模型(/model · 控制台)连带作废「额度临时切换」记录:人说了算,引擎之后不再自作主张切回。
|
|
113
157
|
setProvider(chatId, name) { const g = this._group(chatId); g.provider = name; delete g.failover; this._save() }
|