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.
Files changed (136) hide show
  1. package/CHANGELOG.en.json +118 -0
  2. package/CHANGELOG.json +118 -0
  3. package/README.md +20 -156
  4. package/README.zh-CN.md +20 -164
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +19 -0
  8. package/docs/install.md +93 -0
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
  10. package/docs/zh-CN/install.md +93 -0
  11. package/install.sh +84 -0
  12. package/package.json +74 -34
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4711
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/clilang.js +0 -31
  93. package/src/core/cron.js +0 -182
  94. package/src/core/cronfire.js +0 -120
  95. package/src/core/cronrun.js +0 -116
  96. package/src/core/engine.js +0 -2762
  97. package/src/core/interfaces.js +0 -114
  98. package/src/core/issues.js +0 -185
  99. package/src/core/ledger.js +0 -30
  100. package/src/core/opsalert.js +0 -214
  101. package/src/core/pending.js +0 -123
  102. package/src/core/proxy.js +0 -424
  103. package/src/core/quotaalert.js +0 -79
  104. package/src/core/quotaerr.js +0 -55
  105. package/src/core/scheduler.js +0 -59
  106. package/src/design.js +0 -5161
  107. package/src/gitrepo.js +0 -150
  108. package/src/health.js +0 -353
  109. package/src/installdeps.js +0 -72
  110. package/src/leftover.js +0 -174
  111. package/src/platform/boardnotice.js +0 -77
  112. package/src/platform/boardops.js +0 -36
  113. package/src/platform/boardwatch.js +0 -447
  114. package/src/platform/cli.js +0 -45
  115. package/src/platform/slack.js +0 -1289
  116. package/src/platform/takeovercard.js +0 -37
  117. package/src/platform/wecom.js +0 -938
  118. package/src/platform/wecomcli.js +0 -128
  119. package/src/platform/wecomgate.js +0 -108
  120. package/src/platform/wecomname.js +0 -43
  121. package/src/platform/wecompoll.js +0 -241
  122. package/src/preview.js +0 -581
  123. package/src/projectmemory.js +0 -175
  124. package/src/service.js +0 -317
  125. package/src/ui/auth.js +0 -31
  126. package/src/ui/browser.html +0 -392
  127. package/src/ui/browserroutes.js +0 -234
  128. package/src/ui/console.html +0 -5494
  129. package/src/ui/dashboard.js +0 -168
  130. package/src/ui/feishulogin.js +0 -136
  131. package/src/ui/server.js +0 -2314
  132. package/src/ui/sessions.js +0 -78
  133. package/src/ui/takeover.js +0 -108
  134. package/src/updater.js +0 -243
  135. package/src/upgrade.js +0 -35
  136. 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 }