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.
- package/CHANGELOG.en.json +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +138 -20
- package/README.zh-CN.md +144 -20
- package/assets/design-styles/acid-pop.md +155 -0
- package/assets/design-styles/archive-index.md +161 -0
- package/assets/design-styles/art-deco.md +164 -0
- package/assets/design-styles/cinematic-noir.md +178 -0
- package/assets/design-styles/editorial.md +121 -0
- package/assets/design-styles/geometric-pop.md +122 -0
- package/assets/design-styles/hand-drawn.md +117 -0
- package/assets/design-styles/islamic-geometry.md +155 -0
- package/assets/design-styles/liquid-glass.md +183 -0
- package/assets/design-styles/neo-chinese.md +136 -0
- package/assets/design-styles/precision-mono.md +156 -0
- package/assets/design-styles/quiet-luxury.md +163 -0
- package/assets/design-styles/riso-print.md +116 -0
- package/assets/design-styles/soft-friendly.md +174 -0
- package/assets/design-styles/swiss-grid.md +156 -0
- package/assets/design-styles/tech-dark.md +182 -0
- package/bin/sintbot-v2.js +291 -263
- package/docs/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/install.md +80 -48
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/zh-CN/install.md +80 -48
- package/install.sh +34 -81
- package/package.json +60 -5
- package/src/agent/claudecode.js +32 -3
- package/src/agent/claudemodels.js +60 -0
- package/src/agent/claudestatus.js +74 -0
- package/src/agent/claudeusage.js +200 -0
- package/src/agent/codexapp.js +21 -4
- package/src/agent/codexcode.js +8 -3
- package/src/agent/codexstatus.js +181 -0
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +214 -0
- package/src/c2console.js +149 -0
- package/src/c2incident.js +125 -0
- package/src/c2install.js +247 -37
- package/src/c2migration.js +100 -0
- package/src/c2multigroupcapacity.js +103 -0
- package/src/c2multigroupservice.js +418 -0
- package/src/cdp.js +254 -0
- package/src/config.js +13 -2
- package/src/core/c2durablejournal.js +26 -9
- package/src/core/c2durableruntime.js +104 -1
- package/src/core/c2inputrejection.js +45 -0
- package/src/core/c2localeffectexecutor.js +12 -1
- package/src/core/c2payloadhandles.js +11 -0
- package/src/core/c2persistrelation.js +20 -0
- package/src/core/c2persiststate.js +28 -2
- package/src/core/c2platformrecoverysender.js +1 -1
- package/src/core/c2sourcequarantine.js +56 -1
- package/src/core/c2terminalprune.js +2 -1
- package/src/core/engineslotsadapter.js +108 -0
- package/src/core/issues.js +185 -0
- package/src/core/ledger.js +30 -0
- package/src/core/proxy.js +424 -0
- package/src/core/store.js +12 -0
- package/src/core/v2engine.js +427 -31
- package/src/design.js +5161 -0
- package/src/gitrepo.js +150 -0
- package/src/leftover.js +174 -0
- package/src/platform/slack.js +1294 -0
- package/src/platform/wecom.js +961 -0
- package/src/platform/wecomcli.js +128 -0
- package/src/platform/wecomname.js +43 -0
- package/src/platform/wecompoll.js +241 -0
- package/src/preview.js +581 -0
- package/src/projectmemory.js +175 -0
- package/src/ui/auth.js +31 -0
- package/src/ui/browser.html +392 -0
- package/src/ui/browserroutes.js +234 -0
- package/src/ui/console.html +5719 -0
- package/src/ui/dashboard.js +168 -0
- package/src/ui/feishulogin.js +136 -0
- package/src/ui/serverbase.js +2371 -0
- package/src/ui/sessions.js +78 -0
- package/src/ui/takeover.js +108 -0
- 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 }
|