sintbot 1.3.0 → 1.4.1

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 (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. package/src/usage.js +144 -0
@@ -0,0 +1,103 @@
1
+ 'use strict'
2
+
3
+ const { BinarySemaphore } = require('./core/engineslotsadapter')
4
+ const MAX_CONCURRENT = 32
5
+
6
+ function cancelled(reason) {
7
+ const error = new Error('The model concurrency wait was cancelled before a permit was delivered')
8
+ error.code = 'C2_CAPACITY_WAIT_CANCELLED'
9
+ if (reason !== undefined) error.cause = reason
10
+ return error
11
+ }
12
+
13
+ // Shared by all per-group Engines. Rust owns capacity and grant order;
14
+ // Node retains only Promise/AbortSignal handles. The caller must durably stage
15
+ // a receipt before awaiting acquire, and commit dispatch only AFTER acquiring.
16
+ // A waiting task is therefore a durable queued receipt, not an attempted model
17
+ // effect. This class neither ACKs nor discards a receipt or dispatch.
18
+ class C2MultiGroupCapacity {
19
+ #slots
20
+ #records = new Set()
21
+ #closed = false
22
+ #maxConcurrent
23
+
24
+ constructor({ maxConcurrent } = {}) {
25
+ if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1 ||
26
+ maxConcurrent > MAX_CONCURRENT) {
27
+ throw new TypeError(`Model concurrency must be an integer from 1 to ${MAX_CONCURRENT}`)
28
+ }
29
+ this.#maxConcurrent = maxConcurrent
30
+ this.#slots = new BinarySemaphore(maxConcurrent)
31
+ }
32
+
33
+ snapshot() {
34
+ return Object.freeze({ closed: this.#closed,
35
+ maxConcurrent: this.#maxConcurrent,
36
+ active: [...this.#records].filter(record => record.phase === 'active').length,
37
+ waiting: [...this.#records].filter(record => record.phase === 'waiting').length })
38
+ }
39
+
40
+ close(reason) {
41
+ this.#closed = true
42
+ for (const record of this.#records) {
43
+ if (record.phase === 'waiting') record.controller.abort(reason)
44
+ }
45
+ // Already delivered permits remain held until their owners release them
46
+ // after the real runner/turn closes. Shutdown is not proof of closure.
47
+ }
48
+
49
+ async acquire({ signal } = {}) {
50
+ if (signal !== undefined && signal !== null &&
51
+ (typeof signal.aborted !== 'boolean' ||
52
+ typeof signal.addEventListener !== 'function' ||
53
+ typeof signal.removeEventListener !== 'function')) {
54
+ throw new TypeError('Model concurrency signal is invalid')
55
+ }
56
+ if (this.#closed || signal?.aborted) throw cancelled(signal?.reason)
57
+ const controller = new AbortController()
58
+ const forwardAbort = () => controller.abort(signal.reason)
59
+ if (signal) signal.addEventListener('abort', forwardAbort, { once: true })
60
+ const record = { controller, phase: 'waiting' }
61
+ this.#records.add(record)
62
+ let acquired = false, released = false
63
+ const detach = () => {
64
+ if (signal) signal.removeEventListener('abort', forwardAbort)
65
+ }
66
+ const release = () => {
67
+ if (released) return
68
+ released = true
69
+ this.#records.delete(record)
70
+ detach()
71
+ if (!acquired) return
72
+ try { this.#slots.release() }
73
+ catch (error) {
74
+ // Never repair a failed binary transition with a JavaScript counter.
75
+ this.close(error)
76
+ throw error
77
+ }
78
+ }
79
+ try {
80
+ try {
81
+ await this.#slots.acquire(controller.signal)
82
+ acquired = true
83
+ } catch (error) {
84
+ if (controller.signal.aborted) throw cancelled(controller.signal.reason)
85
+ this.close(error)
86
+ throw error
87
+ }
88
+ // A release can grant a waiter immediately before shutdown/cancellation.
89
+ // Recheck before delivering a permit and before the caller can dispatch.
90
+ if (this.#closed || controller.signal.aborted) {
91
+ throw cancelled(controller.signal.reason)
92
+ }
93
+ record.phase = 'active'
94
+ detach()
95
+ return release
96
+ } catch (error) {
97
+ release()
98
+ throw error
99
+ }
100
+ }
101
+ }
102
+
103
+ module.exports = { C2MultiGroupCapacity, MAX_CONCURRENT }
@@ -0,0 +1,418 @@
1
+ 'use strict'
2
+
3
+ const { isC2InputRejection, answerC2InputRejection, rejectC2Input } = require('./core/c2inputrejection')
4
+ const { coordinateC2GlobalFault } = require('./core/c2globalfault')
5
+
6
+ const RECOVERING_NOTICE = '上一个任务出错,正在收尾,这条消息没有处理,请稍后重发。'
7
+ const HELD_NOTICE = '本群上一个任务结果不明,已暂停处理,等待人工确认;其他群不受影响。'
8
+ const reasonOf = error => String(error?.message || error || 'unknown').slice(0, 300)
9
+
10
+ // Turns a group binding still holds: a running turn, a non-idle handoff or a
11
+ // busy lifecycle. Scoped to one group's own journal.
12
+ function activeTurns(snapshot) {
13
+ const lanes = snapshot.state.lanes
14
+ const turns = []
15
+ for (const chatId of new Set([...Object.keys(lanes.turn || {}),
16
+ ...Object.keys(lanes.handoff || {}), ...Object.keys(lanes.lifecycle || {})])) {
17
+ const turn = lanes.turn?.[chatId]
18
+ const handoff = lanes.handoff?.[chatId]
19
+ const lifecycle = lanes.lifecycle?.[chatId]
20
+ if (turn?.active != null || handoff?.phase && handoff.phase !== 'idle' ||
21
+ lifecycle?.busy === true || lifecycle?.controller === true) {
22
+ turns.push({ chatId, turnToken: turn?.active ?? handoff?.turnToken ?? null })
23
+ }
24
+ }
25
+ return turns.sort((a, b) => a.chatId.localeCompare(b.chatId))
26
+ }
27
+
28
+ const runtimesOf = binding => binding?.engine?.rts instanceof Map ? [...binding.engine.rts.values()] : []
29
+
30
+ // Process-local organization only. Each binding owns a distinct durable
31
+ // authority; the transport and writer lease belong to this single process.
32
+ // A fault that only one group's journal can explain holds that group: its
33
+ // sealed binding stays as evidence, nothing is replayed or retried, and every
34
+ // other group keeps serving. Only a shared resource (writer lease, transport,
35
+ // model capacity) stops the process. `holds` lists groups whose journal could
36
+ // not be opened before the service was created.
37
+ function createC2MultiGroupService({ routes, assertWriterLock,
38
+ createTransport, createBinding, onFatal = () => {}, closeCapacity = () => {},
39
+ quarantine = null, stopTimeoutMs = 2_000, drainTimeoutMs = 12_000,
40
+ createRecoveryAuthority = null, holds = new Map(), sharedFault = () => null,
41
+ now = Date.now, log = () => {} } = {}) {
42
+ if (!Array.isArray(routes) || !routes.length ||
43
+ new Set(routes.map(route => route.chatId)).size !== routes.length ||
44
+ routes.some(route => !route || typeof route.chatId !== 'string' ||
45
+ typeof route.file !== 'string') ||
46
+ typeof assertWriterLock !== 'function' ||
47
+ typeof createTransport !== 'function' || typeof createBinding !== 'function' ||
48
+ typeof onFatal !== 'function' || typeof closeCapacity !== 'function' ||
49
+ typeof log !== 'function' || typeof sharedFault !== 'function' ||
50
+ typeof now !== 'function' || !(holds instanceof Map) ||
51
+ [...holds.keys()].some(chatId => !routes.some(route => route.chatId === chatId)) ||
52
+ createRecoveryAuthority !== null && typeof createRecoveryAuthority !== 'function' ||
53
+ !Number.isSafeInteger(stopTimeoutMs) || stopTimeoutMs < 1 ||
54
+ !Number.isSafeInteger(drainTimeoutMs) || drainTimeoutMs < 1) {
55
+ throw new TypeError('C2 multi-group service needs explicit routes and controls')
56
+ }
57
+ const configured = new Map(routes.map(route => [route.chatId, route]))
58
+ const bindings = new Map()
59
+ // chatId -> { reason, source: 'startup' | 'runtime', since, binding }
60
+ const suspended = new Map()
61
+ let transport = null
62
+ let started = false
63
+ let starting = false
64
+ let transportStarted = false
65
+ let stopPromise = null
66
+ let failed = false
67
+ let startupDone = null
68
+ let startupComplete = false
69
+
70
+ function held() {
71
+ if (assertWriterLock() !== true) throw new Error('C2 service writer lock lost')
72
+ }
73
+ function writerHeld() {
74
+ try { return assertWriterLock() === true } catch { return false }
75
+ }
76
+ async function bounded(operation, ms) {
77
+ let timer
78
+ try {
79
+ return await Promise.race([Promise.resolve().then(operation),
80
+ new Promise(resolve => { timer = setTimeout(() => resolve(false), ms) })])
81
+ } catch { return false } finally { clearTimeout(timer) }
82
+ }
83
+ function stop(reason = 'operator-stop', code = 0) {
84
+ if (stopPromise) return stopPromise
85
+ started = false
86
+ // Seal every authority synchronously before awaiting transport shutdown.
87
+ closeCapacity()
88
+ for (const binding of bindings.values()) {
89
+ binding.gate.stop()
90
+ binding.engine.shutdown()
91
+ }
92
+ const startupInProgress = !!startupDone && !startupComplete
93
+ stopPromise = (async () => {
94
+ // A signal can arrive while a recovery binding or socket start awaits.
95
+ // Keep the lease until startup observes the closed state, then seal any
96
+ // binding that finished after the initial synchronous sweep.
97
+ const startupStopped = !startupInProgress || await bounded(
98
+ () => startupDone.then(() => true), drainTimeoutMs) === true
99
+ if (startupInProgress) {
100
+ for (const binding of bindings.values()) {
101
+ binding.gate.stop()
102
+ binding.engine.shutdown()
103
+ }
104
+ }
105
+ const platformStopped = !transportStarted || await bounded(
106
+ () => transport.stopPlatform(), stopTimeoutMs) === true
107
+ const drained = (await Promise.all([...bindings.values()].map(binding =>
108
+ bounded(() => binding.engine.drainDurableV2(drainTimeoutMs), drainTimeoutMs + 100))))
109
+ .every(value => value === true) && [...suspended.values()].every(entry =>
110
+ runtimesOf(entry.binding).every(rt => !rt.v2Controller))
111
+ return Object.freeze({ reason, code: code === 0 && startupStopped && platformStopped && drained ? 0 : 1,
112
+ startupStopped, platformStopped, drained })
113
+ })()
114
+ return stopPromise
115
+ }
116
+ async function fatal(error) {
117
+ if (failed) return stopPromise
118
+ failed = true
119
+ const result = await stop('C2 multi-group ingress failed', 1)
120
+ await onFatal(error, result)
121
+ return result
122
+ }
123
+ // Seal one group in place. Its binding and journal are kept unchanged as
124
+ // evidence; a later restart reopens it only through the normal recovery
125
+ // gate, or after its incident has been sealed by the operator.
126
+ function suspend(chatId, error, { source, binding = null }) {
127
+ if (suspended.has(chatId)) return suspended.get(chatId)
128
+ const entry = Object.freeze({ reason: reasonOf(error), source, since: now(), binding })
129
+ suspended.set(chatId, entry)
130
+ for (const sealed of new Set([binding, bindings.get(chatId)])) {
131
+ if (!sealed) continue
132
+ try { sealed.gate.stop() } catch {}
133
+ try { sealed.engine.shutdown() } catch {}
134
+ }
135
+ bindings.delete(chatId)
136
+ log(`group ${chatId}: held (${entry.reason}); other groups keep serving`)
137
+ return entry
138
+ }
139
+ function allowed(chatId) {
140
+ const route = configured.get(chatId)
141
+ if (!route || route.paused === true) return false
142
+ // Once serving, every open route has a binding. A route still being
143
+ // opened online is not served yet and its messages get no receipt.
144
+ return !started || bindings.has(chatId)
145
+ }
146
+ const activating = new Map()
147
+ const recovering = new Map()
148
+ const failedBindings = new WeakSet()
149
+ // Same bounded transition as the single-group entry: seal this binding,
150
+ // wait for its runner to close, abandon the proved active turn with one
151
+ // notice per receipt (never a retry), then open a fresh binding.
152
+ function recoverGroup(chatId, binding, error) {
153
+ if (recovering.has(chatId)) return recovering.get(chatId)
154
+ if (failedBindings.has(binding) && bindings.get(chatId) !== binding) return Promise.resolve()
155
+ failedBindings.add(binding)
156
+ const route = configured.get(chatId)
157
+ const work = (async () => {
158
+ log(`group ${chatId}: closing failed turn (${error?.message || String(error)})`)
159
+ binding.gate.stop()
160
+ binding.engine.shutdown()
161
+ await bounded(() => binding.engine.drainDurableV2(drainTimeoutMs), drainTimeoutMs + 100)
162
+ const quiescent = [...binding.engine.rts.values()].every(rt =>
163
+ !rt.v2Controller && !rt.v2NormalRouteActive)
164
+ if (!quiescent || !writerHeld() || !route) throw new Error('C2 group fault is not quiescent')
165
+ const turns = activeTurns(binding.journal.snapshot())
166
+ const proofs = turns.length ? binding.engine.durableV2ActiveFaultProofs?.(turns) || null : null
167
+ const result = await coordinateC2GlobalFault({ runtime: binding.runtime,
168
+ journal: binding.journal, assertWriterLock,
169
+ abortTurn: turn => binding.engine.abortDurableV2FaultTurn?.(turn) === true,
170
+ activeTurnProofs: proofs,
171
+ createAuthority: lock => createRecoveryAuthority({ route, transport, assertWriterLock: lock }) })
172
+ if (result.status !== 'recovered' || !writerHeld()) {
173
+ throw new Error(`C2 group recovery sealed: ${result.reason || 'writer lock lost'}`)
174
+ }
175
+ result.runtime.close()
176
+ if (!started || stopPromise) throw new Error('C2 service stopped during group recovery')
177
+ const next = await createBinding({ route, transport, journal: result.journal })
178
+ if (!next || !next.gate || !next.engine || next.gate.blocked || !writerHeld()) {
179
+ next?.gate?.stop?.(); next?.engine?.shutdown?.()
180
+ throw new Error('C2 group binding did not reopen after recovery')
181
+ }
182
+ bindings.set(chatId, next)
183
+ log(`group ${chatId}: failed turn closed; group reopened`)
184
+ })()
185
+ recovering.set(chatId, work)
186
+ work.then(() => recovering.delete(chatId), () => recovering.delete(chatId))
187
+ return work
188
+ }
189
+ // One group's route, recall or ingress failed. Close the failed turn when
190
+ // its journal proves every effect; otherwise hold the group alone. Returns
191
+ // 'recovered', 'held', 'fatal' (process stop begun) or 'stopping'.
192
+ async function groupFault(chatId, binding, error) {
193
+ const shared = () => !writerHeld() ? 'writer lock lost' : sharedFault()
194
+ if (shared()) { await fatal(error); return 'fatal' }
195
+ if (!started || stopPromise) return 'stopping'
196
+ if (suspended.has(chatId)) return 'held'
197
+ const current = bindings.get(chatId)
198
+ if (!binding || current !== binding && !failedBindings.has(binding)) {
199
+ // An error from a binding this service never published is not attributable.
200
+ await fatal(error); return 'fatal'
201
+ }
202
+ if (!createRecoveryAuthority) { await fatal(error); return 'fatal' }
203
+ try {
204
+ await recoverGroup(chatId, binding, error)
205
+ } catch (failure) {
206
+ log(`group ${chatId}: failed turn could not be closed: ${reasonOf(failure)}`)
207
+ if (shared()) { await fatal(failure); return 'fatal' }
208
+ if (!started || stopPromise) return 'stopping'
209
+ suspend(chatId, failure, { source: 'runtime', binding })
210
+ }
211
+ return suspended.has(chatId) ? 'held' : 'recovered'
212
+ }
213
+ function heldReply(msg, sourceAdapter) {
214
+ return answerC2InputRejection({ error: rejectC2Input('C2 group held',
215
+ { reason: 'group-held', notice: HELD_NOTICE }), msg, platform: sourceAdapter, log })
216
+ }
217
+ // Open one more group on the live transport: a newly added route or one
218
+ // paused since startup. It runs the same recovery gate as startup; only a
219
+ // fully opened binding is published. Failure leaves the group unserved.
220
+ function addRoute(route) {
221
+ if (!route || typeof route.chatId !== 'string' || typeof route.file !== 'string') {
222
+ return Promise.reject(new TypeError('C2 online route needs an explicit group and journal'))
223
+ }
224
+ if (suspended.has(route.chatId)) {
225
+ return Promise.reject(new Error('This group is held after an unresolved fault; seal its incident and restart'))
226
+ }
227
+ if (bindings.has(route.chatId)) return Promise.resolve(bindings.get(route.chatId))
228
+ if (activating.has(route.chatId)) return activating.get(route.chatId)
229
+ const opening = (async () => {
230
+ if (!started || starting || stopPromise) throw new Error('C2 service is not serving')
231
+ held()
232
+ const binding = await createBinding({ route, transport })
233
+ if (!started || stopPromise || !writerHeld() || suspended.has(route.chatId) ||
234
+ !binding || !binding.gate || !binding.engine || binding.gate.blocked) {
235
+ binding?.gate?.stop?.()
236
+ binding?.engine?.shutdown?.()
237
+ throw new Error('C2 group binding did not open its durable authority')
238
+ }
239
+ configured.set(route.chatId, route)
240
+ bindings.set(route.chatId, binding)
241
+ log(`group ${route.chatId}: opened online`)
242
+ return binding
243
+ })()
244
+ activating.set(route.chatId, opening)
245
+ opening.then(() => activating.delete(route.chatId), () => activating.delete(route.chatId))
246
+ return opening
247
+ }
248
+ function routeMessage(msg, sourceAdapter) {
249
+ const route = configured.get(msg?.chatId)
250
+ if (route && route.paused !== true && suspended.has(msg.chatId)) {
251
+ // A held group admits nothing; a replay of a sealed source stays silent.
252
+ if (quarantine?.rejects(msg)) return { quarantined: true }
253
+ if (started && !stopPromise) return heldReply(msg, sourceAdapter)
254
+ }
255
+ // Foreign/paused group events are not faults and never get a receipt.
256
+ if (!allowed(msg?.chatId)) return { ignored: true, reason: 'unconfigured-or-paused' }
257
+ if (quarantine?.rejects(msg)) return { quarantined: true }
258
+ if (recovering.has(msg.chatId) && started && !stopPromise) {
259
+ // The group's failed turn is being closed; nothing is admitted meanwhile.
260
+ return answerC2InputRejection({ error: rejectC2Input('C2 group recovering',
261
+ { reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
262
+ }
263
+ let binding
264
+ try {
265
+ held()
266
+ binding = bindings.get(msg.chatId)
267
+ if (!started || starting || stopPromise || !binding) {
268
+ throw new Error('C2 group ingress authority closed')
269
+ }
270
+ if (binding.gate.blocked) {
271
+ // A turn still running under a closed authority delivers its own
272
+ // rejection; until then this message, which has no receipt, is
273
+ // refused. An idle closed group is closed as its own fault here.
274
+ if (runtimesOf(binding).some(rt => rt.v2NormalRouteActive)) {
275
+ return answerC2InputRejection({ error: rejectC2Input('C2 group closing',
276
+ { reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
277
+ }
278
+ return groupFault(msg.chatId, binding, new Error('C2 group ingress authority closed'))
279
+ .then(async outcome => {
280
+ if (outcome === 'held') return heldReply(msg, sourceAdapter)
281
+ if (outcome === 'recovered') {
282
+ return answerC2InputRejection({ error: rejectC2Input('C2 group recovered',
283
+ { reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
284
+ }
285
+ throw new Error('C2 group ingress authority closed')
286
+ })
287
+ }
288
+ const running = binding.engine.runDurableV2Message(msg, sourceAdapter)
289
+ return Promise.resolve(running).catch(async error => {
290
+ // A refused command or premature /stop wrote nothing durable. Answer it
291
+ // once and keep every group serving.
292
+ if (isC2InputRejection(error) && started && !stopPromise &&
293
+ !binding.gate.blocked && writerHeld()) {
294
+ log(`group ${msg.chatId}: refused input before receipt (${error.reason})`)
295
+ return answerC2InputRejection({ error, msg, platform: sourceAdapter, log })
296
+ }
297
+ // Any other route error belongs to this group alone unless a shared
298
+ // resource failed. Errors from a superseded binding join its fault.
299
+ if (started && !stopPromise) {
300
+ const outcome = await groupFault(msg.chatId, binding, error)
301
+ if (outcome === 'recovered') return { recovered: true, reason: 'group-fault-closed' }
302
+ if (outcome === 'held') return { held: true, reason: 'group-held' }
303
+ }
304
+ await fatal(error)
305
+ throw error
306
+ })
307
+ } catch (error) {
308
+ void fatal(error)
309
+ throw error
310
+ }
311
+ }
312
+ function unsupported(source) {
313
+ return event => {
314
+ // An event without group identity cannot safely be attributed. It has
315
+ // no accepted receipt and is ignored instead of stopping unrelated work.
316
+ if (!allowed(event?.chatId)) return { ignored: true }
317
+ return { ignored: true, reason: `unsupported-${source}` }
318
+ }
319
+ }
320
+ function recall(event) {
321
+ if (suspended.has(event?.chatId)) return { ignored: true, reason: 'group-held' }
322
+ if (!allowed(event?.chatId)) return { ignored: true }
323
+ let binding
324
+ try {
325
+ held()
326
+ binding = bindings.get(event.chatId)
327
+ if (!started || stopPromise || !binding) return { ignored: true, reason: 'ingress-closed' }
328
+ const handled = binding.engine.handleRecall?.(event) === true
329
+ if (binding.runtime?.blocked) throw new Error('Recall closed its durable authority')
330
+ return handled ? { handled: true } : { ignored: true, reason: 'unsupported-or-inactive-recall' }
331
+ } catch (error) {
332
+ if (!binding || !writerHeld() || !started || stopPromise) return fatal(error)
333
+ return groupFault(event.chatId, binding, error).then(outcome =>
334
+ outcome === 'recovered' || outcome === 'held'
335
+ ? { ignored: true, reason: `group-${outcome}` } : fatal(error))
336
+ }
337
+ }
338
+ async function start({ preflightOnly = false } = {}) {
339
+ if (started || starting || stopPromise) throw new Error('C2 service already started or stopped')
340
+ starting = true
341
+ let finishStartup
342
+ startupDone = new Promise(resolve => {
343
+ finishStartup = () => { startupComplete = true; resolve() }
344
+ })
345
+ try {
346
+ held()
347
+ transport = createTransport()
348
+ if (!transport || typeof transport.platform?.start !== 'function' ||
349
+ typeof transport.stopPlatform !== 'function') {
350
+ throw new TypeError('C2 transport lacks a single start/stop boundary')
351
+ }
352
+ for (const [chatId, reason] of holds) suspend(chatId, reason, { source: 'startup' })
353
+ for (const route of routes) {
354
+ if (route.paused || suspended.has(route.chatId)) continue // Paused journals are audited before start.
355
+ let binding
356
+ try {
357
+ binding = await createBinding({ route, transport })
358
+ } catch (error) {
359
+ held()
360
+ if (stopPromise) throw new Error('C2 service stopped during group recovery')
361
+ // This group's journal could not be recovered; it alone stays closed.
362
+ suspend(route.chatId, error, { source: 'startup' })
363
+ continue
364
+ }
365
+ held()
366
+ if (stopPromise) {
367
+ binding?.gate?.stop?.()
368
+ binding?.engine?.shutdown?.()
369
+ if (binding?.engine) bindings.set(route.chatId, binding)
370
+ throw new Error('C2 service stopped during group recovery')
371
+ }
372
+ if (!binding || !binding.gate || !binding.engine || binding.gate.blocked) {
373
+ binding?.gate?.stop?.()
374
+ binding?.engine?.shutdown?.()
375
+ suspend(route.chatId, 'C2 group binding did not open its durable authority', { source: 'startup' })
376
+ continue
377
+ }
378
+ bindings.set(route.chatId, binding)
379
+ }
380
+ held()
381
+ const heldList = [...suspended].map(([chatId, entry]) => `${chatId}: ${entry.reason}`)
382
+ if (preflightOnly && heldList.length) {
383
+ throw new Error(`Startup preflight would hold ${heldList.length} group(s): ${heldList.join('; ')}`)
384
+ }
385
+ if (heldList.length && !bindings.size) {
386
+ throw new Error(`Every active group is held: ${heldList.join('; ')}`)
387
+ }
388
+ if (preflightOnly) {
389
+ finishStartup()
390
+ await stop('preflight', 0)
391
+ return { groups: bindings.size, connected: false }
392
+ }
393
+ starting = false
394
+ started = true
395
+ transportStarted = true
396
+ await transport.platform.start(routeMessage, { onFatal: fatal,
397
+ onRecall: recall, onReaction: unsupported('reaction'),
398
+ onCardAction: unsupported('card action'), onBoardUpdated: unsupported('board update') })
399
+ held()
400
+ if (stopPromise) throw new Error('C2 service stopped during connection')
401
+ return { groups: bindings.size, connected: true, ...(suspended.size ? { held: heldGroups() } : {}) }
402
+ } catch (error) {
403
+ finishStartup()
404
+ await stop('startup-failed', 1)
405
+ throw error
406
+ } finally { starting = false; finishStartup() }
407
+ }
408
+ function heldGroups() {
409
+ return [...suspended].map(([chatId, entry]) => Object.freeze({ chatId,
410
+ reason: entry.reason, source: entry.source, since: entry.since }))
411
+ }
412
+ return { start, stop, routeMessage, addRoute, heldGroups,
413
+ get bindings() { return new Map(bindings) },
414
+ get held() { return new Map([...suspended].map(([chatId, entry]) =>
415
+ [chatId, { reason: entry.reason, source: entry.source, since: entry.since }])) } }
416
+ }
417
+
418
+ module.exports = { createC2MultiGroupService }