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,90 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { createHash } = require('node:crypto')
|
|
4
|
+
const { validatePersistentReceipts } = require('./c2persistrelation')
|
|
5
|
+
|
|
6
|
+
// Identity construction for the prospective durable C2 path. The caller must
|
|
7
|
+
// read receipts and callback registrations from the single durable authority;
|
|
8
|
+
// this module cannot turn an in-memory or synthetic source into one.
|
|
9
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
10
|
+
const DOMAINS = new Set(['queue', 'lifecycle', 'recovery', 'failover', 'steer',
|
|
11
|
+
'slots', 'turn', 'terminal', 'handoff', 'atomicHandoff', 'intake', 'session',
|
|
12
|
+
'outbound'])
|
|
13
|
+
const KIND = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/
|
|
14
|
+
const GENERATION = /^[0-9a-f]{32}$/
|
|
15
|
+
const MAX_ORDINAL = 65_535
|
|
16
|
+
|
|
17
|
+
function checkId(value, pattern, label) {
|
|
18
|
+
if (typeof value !== 'string' || !pattern.test(value)) throw new TypeError(`invalid C2 ${label}`)
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkDomainKind(domain, kind, ordinal) {
|
|
23
|
+
if (!DOMAINS.has(domain)) throw new TypeError('invalid C2 event domain')
|
|
24
|
+
checkId(kind, KIND, 'event kind')
|
|
25
|
+
if (!Number.isSafeInteger(ordinal) || ordinal < 0 || ordinal > MAX_ORDINAL) {
|
|
26
|
+
throw new TypeError('invalid C2 event ordinal')
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fingerprint(label, parts) {
|
|
31
|
+
const hash = createHash('sha256').update(`sintbot-c2-${label}-v1\0`)
|
|
32
|
+
for (const part of parts) {
|
|
33
|
+
const bytes = Buffer.from(String(part), 'utf8')
|
|
34
|
+
hash.update(String(bytes.length)).update(':').update(bytes)
|
|
35
|
+
}
|
|
36
|
+
return hash.digest('hex')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function durableReceipt(receiptId, lookupReceipt) {
|
|
40
|
+
checkId(receiptId, RECEIPT_ID, 'receipt ID')
|
|
41
|
+
if (typeof lookupReceipt !== 'function') throw new TypeError('durable C2 receipt lookup required')
|
|
42
|
+
const receipt = lookupReceipt(receiptId)
|
|
43
|
+
validatePersistentReceipts({ pending: [receipt], completed: [] })
|
|
44
|
+
if (receipt.id !== receiptId || /^local:/i.test(receipt.sourceId) ||
|
|
45
|
+
Buffer.byteLength(receipt.sourceId) > 256 || Buffer.byteLength(receipt.chatId) > 256) {
|
|
46
|
+
throw new Error('C2 event lacks a durable source receipt')
|
|
47
|
+
}
|
|
48
|
+
return receipt
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function platformMessageEventId({ receiptId, domain, kind, ordinal = 0, lookupReceipt } = {}) {
|
|
52
|
+
checkDomainKind(domain, kind, ordinal)
|
|
53
|
+
const receipt = durableReceipt(receiptId, lookupReceipt)
|
|
54
|
+
if (receipt.source !== 'message') throw new Error('C2 platform event needs a message receipt')
|
|
55
|
+
return `sbot-c2-event-v1:${fingerprint('platform-event',
|
|
56
|
+
[receipt.source, receipt.chatId, receipt.sourceId, domain, kind, ordinal])}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function callbackRegistrationId({ receiptId, generation, domain, kind, ordinal = 0 } = {}) {
|
|
60
|
+
checkId(receiptId, RECEIPT_ID, 'receipt ID')
|
|
61
|
+
checkId(generation, GENERATION, 'turn generation')
|
|
62
|
+
checkDomainKind(domain, kind, ordinal)
|
|
63
|
+
return `sbot-c2-registration-v1:${fingerprint('callback-registration',
|
|
64
|
+
[receiptId, generation, domain, kind, ordinal])}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function callbackEventId({ receiptId, generation, domain, kind, ordinal = 0,
|
|
68
|
+
lookupReceipt, lookupRegistration } = {}) {
|
|
69
|
+
const receipt = durableReceipt(receiptId, lookupReceipt)
|
|
70
|
+
const registrationId = callbackRegistrationId({ receiptId, generation, domain, kind, ordinal })
|
|
71
|
+
if (typeof lookupRegistration !== 'function') throw new TypeError('durable C2 registration lookup required')
|
|
72
|
+
const registration = lookupRegistration(registrationId)
|
|
73
|
+
const expectedKeys = new Set(['id', 'receiptId', 'generation', 'domain', 'kind', 'ordinal', 'registeredAt'])
|
|
74
|
+
if (!registration || typeof registration !== 'object' || Array.isArray(registration) ||
|
|
75
|
+
Object.getPrototypeOf(registration) !== Object.prototype ||
|
|
76
|
+
Reflect.ownKeys(registration).length !== expectedKeys.size ||
|
|
77
|
+
Reflect.ownKeys(registration).some(key => typeof key !== 'string' || !expectedKeys.has(key) ||
|
|
78
|
+
!Object.getOwnPropertyDescriptor(registration, key)?.enumerable ||
|
|
79
|
+
!Object.hasOwn(Object.getOwnPropertyDescriptor(registration, key), 'value')) ||
|
|
80
|
+
registration.id !== registrationId || registration.receiptId !== receiptId ||
|
|
81
|
+
registration.generation !== generation || registration.domain !== domain ||
|
|
82
|
+
registration.kind !== kind || registration.ordinal !== ordinal ||
|
|
83
|
+
!Number.isSafeInteger(registration.registeredAt) || registration.registeredAt <= 0) {
|
|
84
|
+
throw new Error('C2 callback identity was not durably registered')
|
|
85
|
+
}
|
|
86
|
+
return `sbot-c2-event-v1:${fingerprint('callback-event',
|
|
87
|
+
[receipt.id, generation, domain, kind, ordinal])}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { platformMessageEventId, callbackRegistrationId, callbackEventId }
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
4
|
+
const { C2DurableJournal } = require('./c2durablejournal')
|
|
5
|
+
const { C2DurableRuntime } = require('./c2durableruntime')
|
|
6
|
+
const { EngineStepCoordinator } = require('./enginestepcoordinator')
|
|
7
|
+
const { validatePersistentReceipts, validatePersistentRelation } = require('./c2persistrelation')
|
|
8
|
+
|
|
9
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
10
|
+
|
|
11
|
+
function queuedReceiptIds(snapshot) {
|
|
12
|
+
const ids = new Set()
|
|
13
|
+
for (const lane of Object.values(snapshot.state.lanes.queue || {})) {
|
|
14
|
+
for (const job of lane.queue) {
|
|
15
|
+
ids.add(job.id.slice('receipt:job:'.length))
|
|
16
|
+
for (const member of job.batch || []) {
|
|
17
|
+
ids.add(member.id.slice('receipt:member:'.length))
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return ids
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A close ID is a journal proof only when it binds the exact current pending
|
|
25
|
+
// receipt and ACKed dispatch. The Rust boolean alone is deliberately not used
|
|
26
|
+
// as evidence. Any unrelated unconfirmed effect keeps the whole fault closed.
|
|
27
|
+
function activeFaultTurns(snapshot, journal, active, supplied) {
|
|
28
|
+
if (!Array.isArray(supplied) || supplied.length !== active.length ||
|
|
29
|
+
supplied.length === 0 || supplied.length > 32 ||
|
|
30
|
+
[...snapshot.effects.values()].some(status => status !== 'acked') ||
|
|
31
|
+
[...snapshot.notices.values()].some(item =>
|
|
32
|
+
item.status === 'attempting' || item.status === 'unknown')) {
|
|
33
|
+
throw new Error('C2 active fault has incomplete durable evidence')
|
|
34
|
+
}
|
|
35
|
+
const pending = new Map(snapshot.receipts.pending.map(item => [item.id, item]))
|
|
36
|
+
const queued = queuedReceiptIds(snapshot)
|
|
37
|
+
const expected = active.map(item => item.chatId)
|
|
38
|
+
const listed = []
|
|
39
|
+
const seenReceipts = new Set()
|
|
40
|
+
for (let index = 0; index < supplied.length; index++) {
|
|
41
|
+
const item = supplied[index]
|
|
42
|
+
if (!item || typeof item !== 'object' || Array.isArray(item) ||
|
|
43
|
+
Object.keys(item).sort().join(',') !==
|
|
44
|
+
'chatId,closeEvidenceId,receiptId,runnerClosed,runnerGeneration,turnToken' ||
|
|
45
|
+
item.chatId !== expected[index] || item.turnToken !== active[index].turnToken ||
|
|
46
|
+
!RECEIPT_ID.test(item.receiptId) || !RECEIPT_ID.test(item.runnerGeneration) ||
|
|
47
|
+
item.runnerClosed !== true || seenReceipts.has(item.receiptId) ||
|
|
48
|
+
item.closeEvidenceId !==
|
|
49
|
+
`sbot-c2-runner-closed-v1:${item.receiptId}:${item.runnerGeneration}` ||
|
|
50
|
+
pending.get(item.receiptId)?.chatId !== item.chatId ||
|
|
51
|
+
queued.has(item.receiptId) ||
|
|
52
|
+
snapshot.receipts.pending.filter(receipt =>
|
|
53
|
+
receipt.chatId === item.chatId && !queued.has(receipt.id)).length !== 1 ||
|
|
54
|
+
!isDeepStrictEqual(snapshot.state.lanes.terminal?.[item.chatId] ??
|
|
55
|
+
{ finalized: false, outcome: null },
|
|
56
|
+
{ finalized: false, outcome: null })) {
|
|
57
|
+
throw new Error('C2 active fault turn differs from pending receipt')
|
|
58
|
+
}
|
|
59
|
+
const proof = journal.lookupRunnerClosed(item.closeEvidenceId)
|
|
60
|
+
const descriptor = proof && journal.effectDescriptor(proof.dispatchEffectId)
|
|
61
|
+
if (!proof || proof.receiptId !== item.receiptId ||
|
|
62
|
+
proof.chatId !== item.chatId || proof.turnToken !== item.turnToken ||
|
|
63
|
+
proof.generation !== item.runnerGeneration ||
|
|
64
|
+
descriptor?.domain !== 'atomicHandoff' || descriptor.type !== 'dispatch' ||
|
|
65
|
+
descriptor.scope !== item.chatId || descriptor.receiptId !== item.receiptId ||
|
|
66
|
+
descriptor.turnToken !== item.turnToken ||
|
|
67
|
+
journal.effectStatus(proof.dispatchEffectId) !== 'acked') {
|
|
68
|
+
throw new Error('C2 active fault runner close evidence invalid')
|
|
69
|
+
}
|
|
70
|
+
seenReceipts.add(item.receiptId)
|
|
71
|
+
listed.push({ chatId: item.chatId, receiptId: item.receiptId,
|
|
72
|
+
turnToken: item.turnToken, runnerGeneration: item.runnerGeneration,
|
|
73
|
+
runnerClosed: true, closeEvidenceId: item.closeEvidenceId })
|
|
74
|
+
}
|
|
75
|
+
return listed
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function commitActiveFault({ journal, snapshot, turns, assertWriterLock }) {
|
|
79
|
+
const eventId = `sbot-c2-global-fault-v1:${snapshot.cursor}`
|
|
80
|
+
const coordinator = new EngineStepCoordinator({ journal })
|
|
81
|
+
const expectedSessions = structuredClone(snapshot.state.lanes.session || {})
|
|
82
|
+
for (const turn of turns) {
|
|
83
|
+
const lane = expectedSessions[turn.chatId]
|
|
84
|
+
if (!lane) continue
|
|
85
|
+
if (lane.epoch !== turn.turnToken || lane.closeSeen !== true) {
|
|
86
|
+
throw new Error('C2 global fault session lacks matching closed runner')
|
|
87
|
+
}
|
|
88
|
+
lane.settled = true
|
|
89
|
+
}
|
|
90
|
+
const prepared = coordinator.prepare({ id: eventId, domain: 'globalFault',
|
|
91
|
+
scope: 'global', payload: { type: 'abandonActiveTurns',
|
|
92
|
+
externalEffectsReconciled: true, turns },
|
|
93
|
+
validate(result) {
|
|
94
|
+
if (result.effects.length !== 0 || result.effectDescriptors?.length !== 0 ||
|
|
95
|
+
!isDeepStrictEqual(result.decision?.abandonedReceiptIds,
|
|
96
|
+
turns.map(item => item.receiptId)) ||
|
|
97
|
+
result.decision?.requiresStartupRecovery !== true ||
|
|
98
|
+
result.state.seq !== snapshot.cursor + 1 ||
|
|
99
|
+
!isDeepStrictEqual(result.state.lanes.session || {}, expectedSessions) ||
|
|
100
|
+
!isDeepStrictEqual(result.state.lanes.queue,
|
|
101
|
+
snapshot.state.lanes.queue)) {
|
|
102
|
+
throw new Error('C2 global fault reducer result differs from proven closure')
|
|
103
|
+
}
|
|
104
|
+
} })
|
|
105
|
+
if (assertWriterLock() !== true) {
|
|
106
|
+
prepared.cancel()
|
|
107
|
+
throw new Error('C2 global fault writer lock lost before commit')
|
|
108
|
+
}
|
|
109
|
+
const committed = prepared.commit()
|
|
110
|
+
if (journal.cursor !== snapshot.cursor + 1 ||
|
|
111
|
+
!journal.hasEvent(eventId) ||
|
|
112
|
+
!isDeepStrictEqual(journal.snapshot().state, committed.state) ||
|
|
113
|
+
!isDeepStrictEqual(journal.snapshot().receipts, snapshot.receipts)) {
|
|
114
|
+
throw new Error('C2 global fault durable commit unverified')
|
|
115
|
+
}
|
|
116
|
+
return committed
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Scoped reducer boundary for a future per-chat recovery path. It is safe to
|
|
120
|
+
// call from a prepare validator: even a Rust result with a valid cursor may
|
|
121
|
+
// only modify the failed chat's closure lanes. No sibling queue, turn, receipt
|
|
122
|
+
// or effect is authorized by a scoped failure.
|
|
123
|
+
function validateScopedFaultResult(before, result, chatId, turn, eventId) {
|
|
124
|
+
if (!before || !result || typeof chatId !== 'string' || !chatId ||
|
|
125
|
+
!turn || turn.chatId !== chatId || typeof eventId !== 'string' || !eventId ||
|
|
126
|
+
result.state?.seq !== before.state?.seq + 1 ||
|
|
127
|
+
result.state?.lastEventId !== eventId ||
|
|
128
|
+
!Array.isArray(result.effects) || result.effects.length !== 0 ||
|
|
129
|
+
result.effectDescriptors !== undefined &&
|
|
130
|
+
(!Array.isArray(result.effectDescriptors) || result.effectDescriptors.length !== 0) ||
|
|
131
|
+
!isDeepStrictEqual(result.decision, {
|
|
132
|
+
abandonedReceiptIds: [turn.receiptId],
|
|
133
|
+
requiresStartupRecovery: true, scope: chatId,
|
|
134
|
+
})) {
|
|
135
|
+
throw new Error('C2 scoped fault result invalid')
|
|
136
|
+
}
|
|
137
|
+
const prior = before.state.lanes || {}
|
|
138
|
+
const next = result.state.lanes || {}
|
|
139
|
+
const allowed = new Set(['turn', 'lifecycle', 'handoff', 'session'])
|
|
140
|
+
for (const domain of new Set([...Object.keys(prior), ...Object.keys(next)])) {
|
|
141
|
+
if (!allowed.has(domain)) {
|
|
142
|
+
if (!isDeepStrictEqual(next[domain], prior[domain])) {
|
|
143
|
+
throw new Error('C2 scoped fault changed unrelated lane domain')
|
|
144
|
+
}
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
const a = prior[domain] || {}, b = next[domain] || {}
|
|
148
|
+
for (const scope of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
149
|
+
if (scope !== chatId && !isDeepStrictEqual(a[scope], b[scope])) {
|
|
150
|
+
throw new Error('C2 scoped fault changed sibling lane')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// This commits only the scoped reducer transition. It does not close a live
|
|
158
|
+
// runtime, send notices, or reopen ingress; those require runtime-level
|
|
159
|
+
// per-chat capabilities. The caller must keep the instance sealed until a
|
|
160
|
+
// separate scoped recovery path is proven.
|
|
161
|
+
function commitScopedActiveFault({ journal, snapshot, turn, assertWriterLock } = {}) {
|
|
162
|
+
if (!(journal instanceof C2DurableJournal) || !snapshot ||
|
|
163
|
+
!turn || typeof turn.chatId !== 'string' || !turn.chatId ||
|
|
164
|
+
typeof assertWriterLock !== 'function' ||
|
|
165
|
+
assertWriterLock.constructor?.name === 'AsyncFunction' ||
|
|
166
|
+
assertWriterLock() !== true ||
|
|
167
|
+
!isDeepStrictEqual(journal.snapshot(), snapshot)) {
|
|
168
|
+
throw new Error('C2 scoped fault authority invalid')
|
|
169
|
+
}
|
|
170
|
+
const chatId = turn.chatId
|
|
171
|
+
const active = inventory(snapshot).activeTurns.filter(item => item.chatId === chatId)
|
|
172
|
+
if (active.length !== 1 || active[0].turnToken !== turn.turnToken) {
|
|
173
|
+
throw new Error('C2 scoped fault target turn invalid')
|
|
174
|
+
}
|
|
175
|
+
const [proven] = activeFaultTurns(snapshot, journal, active, [turn])
|
|
176
|
+
const eventId = `sbot-c2-scoped-fault-v1:${snapshot.cursor}:${chatId}`
|
|
177
|
+
if (Buffer.byteLength(eventId) > 256 || journal.hasEvent(eventId)) {
|
|
178
|
+
throw new Error('C2 scoped fault event identity invalid')
|
|
179
|
+
}
|
|
180
|
+
const coordinator = new EngineStepCoordinator({ journal })
|
|
181
|
+
const prepared = coordinator.prepare({ id: eventId, domain: 'scopedFault',
|
|
182
|
+
scope: chatId, payload: { type: 'abandonActiveTurn',
|
|
183
|
+
externalEffectsReconciled: true, turn: proven },
|
|
184
|
+
validate: result => validateScopedFaultResult(snapshot, result,
|
|
185
|
+
chatId, proven, eventId) })
|
|
186
|
+
if (assertWriterLock() !== true ||
|
|
187
|
+
!isDeepStrictEqual(journal.snapshot(), snapshot)) {
|
|
188
|
+
prepared.cancel()
|
|
189
|
+
throw new Error('C2 scoped fault authority changed before commit')
|
|
190
|
+
}
|
|
191
|
+
const committed = prepared.commit()
|
|
192
|
+
const after = journal.snapshot()
|
|
193
|
+
if (after.cursor !== snapshot.cursor + 1 || !journal.hasEvent(eventId) ||
|
|
194
|
+
!isDeepStrictEqual(after.state, committed.state) ||
|
|
195
|
+
!isDeepStrictEqual(after.receipts, snapshot.receipts) ||
|
|
196
|
+
!isDeepStrictEqual(after.effects, snapshot.effects) ||
|
|
197
|
+
!isDeepStrictEqual(after.notices, snapshot.notices)) {
|
|
198
|
+
throw new Error('C2 scoped fault durable commit unverified')
|
|
199
|
+
}
|
|
200
|
+
return committed
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// A read-only adapter keeps callers from treating the global close/rebuild as
|
|
204
|
+
// a scoped operation. C2DurableRuntime.close() drops every chat's live runner
|
|
205
|
+
// witness and open() recovers every chat, so invoking either for one chat is
|
|
206
|
+
// unsafe until the runtime and startup gate have explicit scoped authority.
|
|
207
|
+
function assessC2ScopedFault({ runtime, journal, chatId } = {}) {
|
|
208
|
+
if (!(runtime instanceof C2DurableRuntime) ||
|
|
209
|
+
!(journal instanceof C2DurableJournal) ||
|
|
210
|
+
typeof chatId !== 'string' || !chatId) {
|
|
211
|
+
throw new TypeError('C2 scoped fault needs runtime, journal and chat ID')
|
|
212
|
+
}
|
|
213
|
+
const snapshot = journal.snapshot()
|
|
214
|
+
inventory(snapshot)
|
|
215
|
+
if (!isDeepStrictEqual(runtime.snapshot(), snapshot)) {
|
|
216
|
+
return Object.freeze({ status: 'blocked', reason: 'authority-mismatch', chatId })
|
|
217
|
+
}
|
|
218
|
+
return Object.freeze({ status: 'blocked', reason: 'scoped-runtime-recovery-unavailable',
|
|
219
|
+
chatId })
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function positiveLimit(value, name) {
|
|
223
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 4096) {
|
|
224
|
+
throw new TypeError(`C2 global fault ${name} invalid`)
|
|
225
|
+
}
|
|
226
|
+
return value
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function multipleVisiblePending(receipts) {
|
|
230
|
+
const seen = new Set()
|
|
231
|
+
for (const receipt of receipts.pending) {
|
|
232
|
+
if (receipt.quiet) continue
|
|
233
|
+
if (seen.has(receipt.chatId)) return true
|
|
234
|
+
seen.add(receipt.chatId)
|
|
235
|
+
}
|
|
236
|
+
return false
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function inventory(snapshot) {
|
|
240
|
+
validatePersistentReceipts(snapshot.receipts)
|
|
241
|
+
validatePersistentRelation(snapshot.state, snapshot.receipts)
|
|
242
|
+
if (snapshot.cursor !== snapshot.state.seq) {
|
|
243
|
+
throw new Error('C2 global fault cursor differs from durable state')
|
|
244
|
+
}
|
|
245
|
+
const lanes = snapshot.state.lanes
|
|
246
|
+
const groups = new Set(snapshot.receipts.pending.map(item => item.chatId))
|
|
247
|
+
const active = []
|
|
248
|
+
for (const chatId of new Set([
|
|
249
|
+
...Object.keys(lanes.turn || {}), ...Object.keys(lanes.handoff || {}),
|
|
250
|
+
...Object.keys(lanes.lifecycle || {}),
|
|
251
|
+
])) {
|
|
252
|
+
const turn = lanes.turn?.[chatId]
|
|
253
|
+
const handoff = lanes.handoff?.[chatId]
|
|
254
|
+
const lifecycle = lanes.lifecycle?.[chatId]
|
|
255
|
+
if (turn?.active != null || handoff?.phase && handoff.phase !== 'idle' ||
|
|
256
|
+
lifecycle?.busy === true || lifecycle?.controller === true) {
|
|
257
|
+
active.push({ chatId, turnToken: turn?.active ?? handoff?.turnToken ?? null })
|
|
258
|
+
groups.add(chatId)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for (const chatId of Object.keys(lanes.queue || {})) groups.add(chatId)
|
|
262
|
+
active.sort((a, b) => a.chatId.localeCompare(b.chatId))
|
|
263
|
+
return Object.freeze({ pendingCount: snapshot.receipts.pending.length,
|
|
264
|
+
groups: [...groups].sort(), activeTurns: active,
|
|
265
|
+
unresolvedEffects: [...snapshot.effects.values()].filter(status => status !== 'acked').length,
|
|
266
|
+
ambiguousNotices: [...snapshot.notices.values()].filter(item =>
|
|
267
|
+
item.status === 'attempting' || item.status === 'unknown').length })
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Explicit daemon fault boundary. A running turn can be abandoned only after
|
|
271
|
+
// every runner close and outward effect has durable proof, under the single
|
|
272
|
+
// writer lock. Otherwise it remains sealed and requires a nonzero exit.
|
|
273
|
+
// Fresh startup then handles residual receipts and notifications once.
|
|
274
|
+
async function coordinateC2GlobalFault({ runtime, journal, assertWriterLock,
|
|
275
|
+
abortTurn, createAuthority, activeTurnProofs = null,
|
|
276
|
+
maxReceipts = 64, maxGroups = 32 } = {}) {
|
|
277
|
+
if (!(runtime instanceof C2DurableRuntime) ||
|
|
278
|
+
!(journal instanceof C2DurableJournal) ||
|
|
279
|
+
typeof assertWriterLock !== 'function' ||
|
|
280
|
+
assertWriterLock.constructor?.name === 'AsyncFunction' ||
|
|
281
|
+
typeof abortTurn !== 'function' ||
|
|
282
|
+
abortTurn.constructor?.name === 'AsyncFunction' ||
|
|
283
|
+
typeof createAuthority !== 'function' ||
|
|
284
|
+
createAuthority.constructor?.name === 'AsyncFunction') {
|
|
285
|
+
throw new TypeError('C2 global fault needs explicit durable authority and synchronous controls')
|
|
286
|
+
}
|
|
287
|
+
positiveLimit(maxReceipts, 'receipt limit')
|
|
288
|
+
positiveLimit(maxGroups, 'group limit')
|
|
289
|
+
|
|
290
|
+
// Close before reading or invoking any callback. Late platform ingress or
|
|
291
|
+
// model callbacks must be rejected while every group is being inspected.
|
|
292
|
+
runtime.close()
|
|
293
|
+
const exit = (reason, observed = null) => Object.freeze({ status: 'exit',
|
|
294
|
+
exitCode: 1, reason, inventory: observed, runtime: null, journal: null })
|
|
295
|
+
let observed
|
|
296
|
+
try {
|
|
297
|
+
if (assertWriterLock() !== true) return exit('writer-lock-lost')
|
|
298
|
+
let snapshot = journal.snapshot()
|
|
299
|
+
if (!isDeepStrictEqual(runtime.snapshot(), snapshot)) return exit('authority-mismatch')
|
|
300
|
+
let journalBytes = journal.byteLength
|
|
301
|
+
observed = inventory(snapshot)
|
|
302
|
+
if (runtime.blocked !== true || journal.blocked) return exit('authority-not-sealed', observed)
|
|
303
|
+
if (observed.pendingCount > maxReceipts || observed.groups.length > maxGroups) {
|
|
304
|
+
return exit('fault-work-exceeds-bound', observed)
|
|
305
|
+
}
|
|
306
|
+
// Recovery records and ACKs one notice per receipt. Until a single group
|
|
307
|
+
// notice can settle siblings atomically, do not issue two visible notices
|
|
308
|
+
// to the same group after a process-wide fault.
|
|
309
|
+
if (multipleVisiblePending(snapshot.receipts)) {
|
|
310
|
+
return exit('multiple-visible-receipts', observed)
|
|
311
|
+
}
|
|
312
|
+
if (observed.activeTurns.length) {
|
|
313
|
+
let allAborted = true
|
|
314
|
+
for (const turn of observed.activeTurns) {
|
|
315
|
+
try { if (abortTurn(turn) !== true) allAborted = false }
|
|
316
|
+
catch { allAborted = false }
|
|
317
|
+
// The abort callback is process-local, but it may synchronously lose
|
|
318
|
+
// the lease or touch the shared authority. Never use the pre-abort
|
|
319
|
+
// snapshot as proof after either change.
|
|
320
|
+
if (assertWriterLock() !== true) return exit('active-turn-proof-unverified', observed)
|
|
321
|
+
if (journal.byteLength !== journalBytes ||
|
|
322
|
+
!isDeepStrictEqual(journal.snapshot(), snapshot)) {
|
|
323
|
+
return exit('authority-changed-during-abort', observed)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!allAborted) return exit('active-turn-abort-unconfirmed', observed)
|
|
327
|
+
if (activeTurnProofs === null) return exit('active-turn-unrestorable', observed)
|
|
328
|
+
try {
|
|
329
|
+
const turns = activeFaultTurns(snapshot, journal,
|
|
330
|
+
observed.activeTurns, activeTurnProofs)
|
|
331
|
+
if (assertWriterLock() !== true) return exit('writer-lock-lost', observed)
|
|
332
|
+
commitActiveFault({ journal, snapshot, turns, assertWriterLock })
|
|
333
|
+
if (assertWriterLock() !== true) return exit('writer-lock-lost', observed)
|
|
334
|
+
snapshot = journal.snapshot()
|
|
335
|
+
journalBytes = journal.byteLength
|
|
336
|
+
observed = inventory(snapshot)
|
|
337
|
+
if (observed.activeTurns.length || observed.unresolvedEffects ||
|
|
338
|
+
observed.ambiguousNotices) return exit('active-fault-commit-unresolved', observed)
|
|
339
|
+
} catch {
|
|
340
|
+
return exit('active-turn-proof-unverified', observed)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (observed.ambiguousNotices || observed.unresolvedEffects &&
|
|
344
|
+
!Object.values(snapshot.state.lanes.queue || {}).some(lane => lane.queue?.length)) {
|
|
345
|
+
return exit('external-outcome-unresolved', observed)
|
|
346
|
+
}
|
|
347
|
+
if (assertWriterLock() !== true) return exit('writer-lock-lost', observed)
|
|
348
|
+
|
|
349
|
+
// The replacement must carry this same lease check into every awaited
|
|
350
|
+
// startup notification, and must reject sibling visible notices per group.
|
|
351
|
+
const replacement = createAuthority(assertWriterLock)
|
|
352
|
+
if (assertWriterLock() !== true) {
|
|
353
|
+
replacement?.runtime?.close?.()
|
|
354
|
+
return exit('rebuild-not-ready', observed)
|
|
355
|
+
}
|
|
356
|
+
if (journal.byteLength !== journalBytes ||
|
|
357
|
+
!isDeepStrictEqual(journal.snapshot(), snapshot)) {
|
|
358
|
+
replacement?.runtime?.close?.()
|
|
359
|
+
return exit('authority-changed-before-rebuild', observed)
|
|
360
|
+
}
|
|
361
|
+
if (!replacement || replacement.runtime === runtime ||
|
|
362
|
+
!(replacement.runtime instanceof C2DurableRuntime) ||
|
|
363
|
+
replacement.journal === journal ||
|
|
364
|
+
!(replacement.journal instanceof C2DurableJournal) ||
|
|
365
|
+
replacement.runtime.blocked !== true ||
|
|
366
|
+
replacement.runtime.hasRecoveryPolicy({ assertWriterLock,
|
|
367
|
+
singleNoticePerGroup: true }) !== true ||
|
|
368
|
+
!isDeepStrictEqual(replacement.journal.snapshot(), snapshot) ||
|
|
369
|
+
!isDeepStrictEqual(replacement.runtime.snapshot(), snapshot)) {
|
|
370
|
+
replacement?.runtime?.close?.()
|
|
371
|
+
return exit('fresh-authority-unverified', observed)
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
await replacement.runtime.open()
|
|
375
|
+
if (assertWriterLock() !== true || replacement.runtime.blocked ||
|
|
376
|
+
replacement.journal.blocked ||
|
|
377
|
+
!isDeepStrictEqual(replacement.runtime.snapshot(),
|
|
378
|
+
replacement.journal.snapshot())) {
|
|
379
|
+
replacement.runtime.close()
|
|
380
|
+
return exit('rebuild-not-ready', observed)
|
|
381
|
+
}
|
|
382
|
+
const recovered = inventory(replacement.journal.snapshot())
|
|
383
|
+
if (recovered.pendingCount || recovered.activeTurns.length ||
|
|
384
|
+
recovered.unresolvedEffects || recovered.ambiguousNotices) {
|
|
385
|
+
replacement.runtime.close()
|
|
386
|
+
return exit('rebuild-left-work', recovered)
|
|
387
|
+
}
|
|
388
|
+
return Object.freeze({ status: 'recovered', exitCode: 0, reason: null,
|
|
389
|
+
inventory: recovered, runtime: replacement.runtime,
|
|
390
|
+
journal: replacement.journal })
|
|
391
|
+
} catch {
|
|
392
|
+
replacement.runtime.close()
|
|
393
|
+
return exit('rebuild-failed', inventory(replacement.journal.snapshot()))
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
return exit('fault-audit-failed', observed || null)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
module.exports = { coordinateC2GlobalFault, validateScopedFaultResult,
|
|
401
|
+
assessC2ScopedFault, commitScopedActiveFault }
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
6
|
+
const { StateStore } = require('./store')
|
|
7
|
+
const verify = require('./verify')
|
|
8
|
+
|
|
9
|
+
const LOCAL_TYPES = new Set(['clearTainted', 'clearExhausted', 'verifyTurn'])
|
|
10
|
+
|
|
11
|
+
function readJson(file) {
|
|
12
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) }
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error?.code === 'ENOENT') return null
|
|
15
|
+
throw error
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Service-dev resolves a local terminal effect against the accepted flat
|
|
20
|
+
// cohort. The effect belongs to the reply anchor, which is the final ordinary
|
|
21
|
+
// job; using jobs[0] would reject a valid multi-message successor after the
|
|
22
|
+
// effect had already been attempted.
|
|
23
|
+
function resolveC2LocalEffectTurn(engine, { chatId, receiptId, turnToken } = {}) {
|
|
24
|
+
if (!engine || typeof chatId !== 'string' || !chatId ||
|
|
25
|
+
!/^[0-9a-f]{32}$/.test(receiptId || '') ||
|
|
26
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0) return null
|
|
27
|
+
const rt = engine.rts?.get(chatId)
|
|
28
|
+
const jobs = rt?.v2RunningJobs
|
|
29
|
+
const binding = rt?.v2DispatchEffect
|
|
30
|
+
const snapshot = engine.durableV2Runtime?.snapshot?.()
|
|
31
|
+
if (!Array.isArray(jobs) || jobs.length < 1 || jobs.length > 2 ||
|
|
32
|
+
jobs.some(job => !job?.msg || job.msg.batch || job.msg.synthetic ||
|
|
33
|
+
job.msg.quiet || job.msg.chatId !== chatId ||
|
|
34
|
+
!/^[0-9a-f]{32}$/.test(job.msg._c2ReceiptId || '')) ||
|
|
35
|
+
!isDeepStrictEqual(binding?.receiptIds,
|
|
36
|
+
jobs.map(job => job.msg._c2ReceiptId)) ||
|
|
37
|
+
!isDeepStrictEqual(binding?.batchReceiptIds, binding.receiptIds) ||
|
|
38
|
+
binding?.receiptIds?.at(-1) !== receiptId ||
|
|
39
|
+
binding.turnToken !== turnToken || rt.v2TurnToken !== turnToken ||
|
|
40
|
+
snapshot?.state?.lanes?.turn?.[chatId]?.active !== turnToken ||
|
|
41
|
+
snapshot.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
42
|
+
!isDeepStrictEqual(snapshot.state.lanes.handoff[chatId].acceptedReceiptIds,
|
|
43
|
+
jobs.length > 1 ? binding.receiptIds : []) ||
|
|
44
|
+
jobs.some(job => {
|
|
45
|
+
const receipt = snapshot.receipts?.pending?.find(item =>
|
|
46
|
+
item.id === job.msg._c2ReceiptId)
|
|
47
|
+
return receipt?.chatId !== chatId || receipt.source !== 'message' ||
|
|
48
|
+
receipt.sourceId !== job.msg.msgId
|
|
49
|
+
})) return null
|
|
50
|
+
const job = jobs.at(-1)
|
|
51
|
+
const { name, provider } = engine._resolveProvider(chatId, job.group)
|
|
52
|
+
return name && provider
|
|
53
|
+
? { chatId, receiptId, turnToken, providerName: name, provider } : null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The caller fsyncs beginEffectAttempt before claim(), then fsyncs ACK only
|
|
57
|
+
// after apply() returns true. A claimed attempt is one-use and process-local:
|
|
58
|
+
// an "attempting" effect found after restart cannot be claimed by this object.
|
|
59
|
+
// The journal never receives credentials, the reply body or Store data.
|
|
60
|
+
function createC2LocalEffectExecutor({ journal, store, resolveTurn,
|
|
61
|
+
assertWriterLock, verifyFile, now = Date.now } = {}) {
|
|
62
|
+
if (!journal || typeof journal.snapshot !== 'function' ||
|
|
63
|
+
typeof journal.effectDescriptor !== 'function' ||
|
|
64
|
+
typeof journal.effectStatus !== 'function' ||
|
|
65
|
+
!(store instanceof StateStore) || typeof store.file !== 'string' ||
|
|
66
|
+
typeof resolveTurn !== 'function' ||
|
|
67
|
+
resolveTurn.constructor?.name === 'AsyncFunction' ||
|
|
68
|
+
typeof assertWriterLock !== 'function' ||
|
|
69
|
+
assertWriterLock.constructor?.name === 'AsyncFunction' ||
|
|
70
|
+
typeof verifyFile !== 'string' || !path.isAbsolute(verifyFile) ||
|
|
71
|
+
typeof now !== 'function') {
|
|
72
|
+
throw new TypeError('C2 local effects need journal, Store, turn and writer authority')
|
|
73
|
+
}
|
|
74
|
+
const claims = new Set()
|
|
75
|
+
const preexistingAttempts = new Set([...journal.snapshot().effects]
|
|
76
|
+
.filter(([, status]) => status === 'attempting')
|
|
77
|
+
.map(([effectId]) => effectId))
|
|
78
|
+
|
|
79
|
+
function assertHeld() {
|
|
80
|
+
if (assertWriterLock() !== true) throw new Error('C2 local effect writer lock lost')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function identity(effectId) {
|
|
84
|
+
assertHeld()
|
|
85
|
+
const descriptor = journal.effectDescriptor(effectId)
|
|
86
|
+
if (!descriptor || descriptor.domain !== 'terminal' ||
|
|
87
|
+
!LOCAL_TYPES.has(descriptor.type) ||
|
|
88
|
+
typeof descriptor.scope !== 'string' || !descriptor.scope ||
|
|
89
|
+
typeof descriptor.receiptId !== 'string' ||
|
|
90
|
+
!/^[0-9a-f]{32}$/.test(descriptor.receiptId) ||
|
|
91
|
+
!Number.isSafeInteger(descriptor.turnToken) ||
|
|
92
|
+
descriptor.turnToken <= 0 ||
|
|
93
|
+
journal.effectStatus(effectId) !== 'attempting') {
|
|
94
|
+
throw new Error('C2 local effect lacks a fresh terminal attempt')
|
|
95
|
+
}
|
|
96
|
+
const snapshot = journal.snapshot()
|
|
97
|
+
const chatId = descriptor.scope
|
|
98
|
+
const receipt = snapshot.receipts?.pending?.find(item =>
|
|
99
|
+
item.id === descriptor.receiptId)
|
|
100
|
+
const terminal = snapshot.state?.lanes?.terminal?.[chatId]
|
|
101
|
+
const handoff = snapshot.state?.lanes?.handoff?.[chatId]
|
|
102
|
+
if (receipt?.chatId !== chatId ||
|
|
103
|
+
snapshot.state?.lanes?.turn?.[chatId]?.active !== descriptor.turnToken ||
|
|
104
|
+
handoff?.phase !== 'running' ||
|
|
105
|
+
handoff.turnToken !== descriptor.turnToken ||
|
|
106
|
+
terminal?.finalized !== true ||
|
|
107
|
+
!Array.isArray(terminal.pendingEffectIds) ||
|
|
108
|
+
!terminal.pendingEffectIds.includes(effectId) ||
|
|
109
|
+
snapshot.effects?.get(effectId) !== 'attempting') {
|
|
110
|
+
throw new Error('C2 local effect differs from the active receipt')
|
|
111
|
+
}
|
|
112
|
+
return descriptor
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function claim(effectId) {
|
|
116
|
+
if (typeof effectId !== 'string' || claims.has(effectId) ||
|
|
117
|
+
preexistingAttempts.has(effectId)) {
|
|
118
|
+
throw new Error('C2 local effect attempt already claimed')
|
|
119
|
+
}
|
|
120
|
+
identity(effectId)
|
|
121
|
+
claims.add(effectId)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function apply(effect) {
|
|
125
|
+
if (!effect || typeof effect !== 'object' || Array.isArray(effect) ||
|
|
126
|
+
typeof effect.effectId !== 'string' ||
|
|
127
|
+
!claims.delete(effect.effectId)) {
|
|
128
|
+
throw new Error('C2 local effect has no same-process claim')
|
|
129
|
+
}
|
|
130
|
+
const descriptor = identity(effect.effectId)
|
|
131
|
+
const required = descriptor.type === 'verifyTurn'
|
|
132
|
+
? ['effectId', 'success', 'type'] : ['effectId', 'type']
|
|
133
|
+
if (Object.keys(effect).sort().join(',') !== required.join(',') ||
|
|
134
|
+
effect.type !== descriptor.type ||
|
|
135
|
+
descriptor.type === 'verifyTurn' && effect.success !== true) {
|
|
136
|
+
throw new Error('C2 local effect payload differs from durable descriptor')
|
|
137
|
+
}
|
|
138
|
+
const turn = resolveTurn({ chatId: descriptor.scope,
|
|
139
|
+
receiptId: descriptor.receiptId, turnToken: descriptor.turnToken })
|
|
140
|
+
if (!turn || turn.chatId !== descriptor.scope ||
|
|
141
|
+
turn.receiptId !== descriptor.receiptId ||
|
|
142
|
+
turn.turnToken !== descriptor.turnToken ||
|
|
143
|
+
typeof turn.providerName !== 'string' || !turn.providerName ||
|
|
144
|
+
!turn.provider || typeof turn.provider !== 'object') {
|
|
145
|
+
throw new Error('C2 local effect turn identity unavailable')
|
|
146
|
+
}
|
|
147
|
+
const chatId = descriptor.scope
|
|
148
|
+
if (effect.type === 'clearTainted') {
|
|
149
|
+
store.clearTainted(chatId)
|
|
150
|
+
const disk = readJson(store.file)
|
|
151
|
+
if (store.isTainted(chatId) || disk?.groups?.[chatId]?.tainted) {
|
|
152
|
+
throw new Error('C2 tainted state was not durably cleared')
|
|
153
|
+
}
|
|
154
|
+
} else if (effect.type === 'clearExhausted') {
|
|
155
|
+
store.clearExhausted(turn.providerName)
|
|
156
|
+
const disk = readJson(store.file)
|
|
157
|
+
if (store.data.exhausted?.[turn.providerName] !== undefined ||
|
|
158
|
+
disk?.exhausted?.[turn.providerName] !== undefined) {
|
|
159
|
+
throw new Error('C2 exhausted state was not durably cleared')
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
const fp = verify.providerFp(turn.provider)
|
|
163
|
+
if (!fp) {
|
|
164
|
+
if (turn.provider.baseUrl || turn.provider.apiKey) {
|
|
165
|
+
throw new Error('C2 verification credential identity incomplete')
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
const stamp = now()
|
|
169
|
+
if (!Number.isSafeInteger(stamp) || stamp <= 0) {
|
|
170
|
+
throw new Error('C2 verification time invalid')
|
|
171
|
+
}
|
|
172
|
+
verify.recordTurn(turn.provider, true, '', { file: verifyFile, now: stamp })
|
|
173
|
+
const record = readJson(verifyFile)?.[fp]
|
|
174
|
+
const recordedAt = Date.parse(record?.at || '')
|
|
175
|
+
if (record?.ok !== true || record.reason ||
|
|
176
|
+
!Number.isFinite(recordedAt) ||
|
|
177
|
+
recordedAt < stamp - verify.REFRESH_MS || recordedAt > stamp + 1000) {
|
|
178
|
+
throw new Error('C2 credential verification was not durably confirmed')
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
assertHeld()
|
|
183
|
+
if (journal.effectStatus(effect.effectId) !== 'attempting') {
|
|
184
|
+
throw new Error('C2 local effect attempt changed before ACK')
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
apply.claim = claim
|
|
190
|
+
return apply
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { createC2LocalEffectExecutor, resolveC2LocalEffectTurn }
|