sintbot 1.4.0 → 1.4.2
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 +38 -0
- package/CHANGELOG.json +38 -0
- package/bin/sintbot-v2.js +94 -16
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +13 -5
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +13 -5
- package/install.sh +1 -1
- package/package.json +5 -1
- package/src/agent/claudecode.js +7 -2
- package/src/agent/codexapp.js +7 -2
- package/src/agent/codexcode.js +8 -3
- package/src/c2configcontrol.js +13 -3
- package/src/c2console.js +15 -7
- package/src/c2incident.js +125 -0
- package/src/c2install.js +1 -1
- package/src/c2multigroupservice.js +157 -26
- package/src/c2updater.js +361 -0
- package/src/c2upgradeswitch.js +169 -0
- package/src/core/c2sourcequarantine.js +22 -3
- package/src/core/v2engine.js +23 -1
- package/src/ui/console.html +41 -18
- package/src/updater.js +243 -0
package/src/c2console.js
CHANGED
|
@@ -17,12 +17,15 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
|
17
17
|
'/api/groups/order', '/api/groups/policy', '/api/groups/redact',
|
|
18
18
|
'/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
|
|
19
19
|
'/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
|
|
20
|
-
'/api/inbox/dismiss', '/api/issues/status'
|
|
20
|
+
'/api/inbox/dismiss', '/api/issues/status',
|
|
21
|
+
// Software updates: check and ignore anywhere; install only when launchd
|
|
22
|
+
// manages this exact entry (the updater refuses every other run mode).
|
|
23
|
+
'/api/update/check', '/api/update/start', '/api/update/ignore']
|
|
21
24
|
const SUPPORTED_RUNNERS = ['claude', 'codex']
|
|
22
25
|
|
|
23
|
-
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
|
|
26
|
+
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
|
|
24
27
|
assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true, now = Date.now } = {}) {
|
|
25
|
-
for (const callback of [getBindings, getCapacity, getConfig, assertWriterLock,
|
|
28
|
+
for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
|
|
26
29
|
validateConfig, applyConfig, beforeWrite, now]) {
|
|
27
30
|
if (typeof callback !== 'function') throw new TypeError('C2 console needs live authority and configuration hooks')
|
|
28
31
|
}
|
|
@@ -32,7 +35,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
|
|
|
32
35
|
const startedAt = now()
|
|
33
36
|
const capabilities = Object.freeze({ mode: 'durable-v2',
|
|
34
37
|
reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
|
|
35
|
-
summary: '
|
|
38
|
+
summary: '实时状态、认证管理、停止任务、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改。开新会话、解除绑定、控制台插话、定时任务和画布暂不可用。',
|
|
36
39
|
allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
|
|
37
40
|
function held() {
|
|
38
41
|
if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
|
|
@@ -45,10 +48,12 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
|
|
|
45
48
|
function getLiveState() {
|
|
46
49
|
const cfg = getConfig()
|
|
47
50
|
const capacity = getCapacity()
|
|
51
|
+
const heldGroups = getHeld()
|
|
48
52
|
const groups = {}
|
|
49
53
|
let queue = 0
|
|
50
54
|
for (const [chatId, group] of Object.entries(cfg.groups || {})) {
|
|
51
55
|
const binding = bindings().get(chatId)
|
|
56
|
+
const hold = heldGroups.get(chatId)
|
|
52
57
|
const rt = binding?.engine?.rts.get(chatId)
|
|
53
58
|
const snapshot = binding?.journal?.snapshot()
|
|
54
59
|
const pending = snapshot?.receipts?.pending || []
|
|
@@ -63,14 +68,17 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig,
|
|
|
63
68
|
waitingForCapacity: !!rt?.v2AdmissionPending,
|
|
64
69
|
sinceMs: rt?.v2StartedAt ? Math.max(0, now() - rt.v2StartedAt) : 0,
|
|
65
70
|
sessionId: binding?.engine?.store?.getSession(chatId) || '',
|
|
71
|
+
// A held group needs the operator: its last result is unknown.
|
|
72
|
+
...(hold ? { held: true, heldReason: hold.reason, heldSinceMs: Math.max(0, now() - hold.since) } : {}),
|
|
66
73
|
}
|
|
67
74
|
}
|
|
68
75
|
const activeBindingsReady = Object.entries(cfg.groups || {})
|
|
69
|
-
.filter(([, group]) => !group.paused
|
|
76
|
+
.filter(([chatId, group]) => !group.paused && !heldGroups.has(chatId))
|
|
77
|
+
.every(([chatId]) => bindings().has(chatId))
|
|
70
78
|
return { running: assertWriterLock() === true && !capacity.closed && activeBindingsReady,
|
|
71
79
|
uptimeMs: Math.max(0, now() - startedAt),
|
|
72
80
|
concurrency: { active: capacity.active, max: capacity.maxConcurrent },
|
|
73
|
-
queue, waiting: capacity.waiting, groups }
|
|
81
|
+
queue, waiting: capacity.waiting, held: heldGroups.size, groups }
|
|
74
82
|
}
|
|
75
83
|
function getActivity() {
|
|
76
84
|
const cfg = getConfig(), items = []
|
|
@@ -128,7 +136,7 @@ function createC2Console(options = {}) {
|
|
|
128
136
|
throw new TypeError('C2 console requires explicit installation paths')
|
|
129
137
|
}
|
|
130
138
|
const facade = createC2ConsoleFacade(options)
|
|
131
|
-
const ui = createUiServer({ configPath, host, log, getConnHealth,
|
|
139
|
+
const ui = createUiServer({ configPath, host, log, getConnHealth, updater: options.updater || null,
|
|
132
140
|
onHostChange: options.onHostChange,
|
|
133
141
|
getLiveState: facade.getLiveState, control: facade.control, runtimePolicy: facade,
|
|
134
142
|
issuesPath: path.join(stateDir, 'issues.jsonl'),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Operator tool for a group the service holds after an unresolved fault.
|
|
4
|
+
// With the service stopped and the writer lock held, it moves that group's
|
|
5
|
+
// journal unchanged under <data>/incidents/, records the source messages it
|
|
6
|
+
// left unsettled and the receipts it had settled, then gives the group a new
|
|
7
|
+
// empty journal under the same mapped name. Nothing is settled, rewritten,
|
|
8
|
+
// replayed or notified; every later start refuses those messages from
|
|
9
|
+
// incident.json (see loadC2IncidentQuarantine).
|
|
10
|
+
const fs = require('node:fs')
|
|
11
|
+
const path = require('node:path')
|
|
12
|
+
const { createHash } = require('node:crypto')
|
|
13
|
+
const { C2DurableJournal } = require('./core/c2durablejournal')
|
|
14
|
+
const { assertJournalScope, assertPrivateDirectory, VALIDATORS } = require('./c2install')
|
|
15
|
+
|
|
16
|
+
const GROUP_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
|
|
17
|
+
const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
18
|
+
const SCHEMA = 'sbot-c2-incident-seal-v1'
|
|
19
|
+
|
|
20
|
+
const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
21
|
+
function privateFile(file, label) {
|
|
22
|
+
const st = fs.lstatSync(file)
|
|
23
|
+
if (!st.isFile() || st.isSymbolicLink() ||
|
|
24
|
+
typeof process.getuid === 'function' && st.uid !== process.getuid() ||
|
|
25
|
+
process.platform !== 'win32' && (st.mode & 0o077)) {
|
|
26
|
+
throw new Error(`${label} is not a private regular file owned by this user; preserve it and review`)
|
|
27
|
+
}
|
|
28
|
+
return st
|
|
29
|
+
}
|
|
30
|
+
function writeNew(file, value) {
|
|
31
|
+
const fd = fs.openSync(file, 'wx', 0o600)
|
|
32
|
+
try { fs.writeSync(fd, JSON.stringify(value, null, 2) + '\n'); fs.fsyncSync(fd) }
|
|
33
|
+
finally { fs.closeSync(fd) }
|
|
34
|
+
}
|
|
35
|
+
function fsyncDir(dir) {
|
|
36
|
+
const fd = fs.openSync(dir, 'r')
|
|
37
|
+
try { fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
|
|
38
|
+
}
|
|
39
|
+
const pad = n => String(n).padStart(2, '0')
|
|
40
|
+
function stamp(ms) {
|
|
41
|
+
const d = new Date(ms)
|
|
42
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sealC2GroupIncident({ installation, chatId, reason = 'operator sealed a held group',
|
|
46
|
+
now = Date.now, acquireLock = require('./core/instancelock').acquire, version = '' } = {}) {
|
|
47
|
+
if (typeof chatId !== 'string' || !GROUP_ID.test(chatId)) {
|
|
48
|
+
throw new Error('Name the held group with --group oc_…')
|
|
49
|
+
}
|
|
50
|
+
if (typeof reason !== 'string' || !reason.trim() || reason.length > 500) {
|
|
51
|
+
throw new Error('The incident reason must be 1 to 500 characters')
|
|
52
|
+
}
|
|
53
|
+
const { stateDir, routes } = installation || {}
|
|
54
|
+
const route = routes?.find(item => item.chatId === chatId)
|
|
55
|
+
if (!route) throw new Error('This group is not in the configuration')
|
|
56
|
+
if ((installation.addedRoutes || []).some(item => item.chatId === chatId)) {
|
|
57
|
+
throw new Error('This group has no journal yet; nothing to seal')
|
|
58
|
+
}
|
|
59
|
+
assertPrivateDirectory(stateDir)
|
|
60
|
+
const lockFile = path.join(stateDir, 'daemon.lock')
|
|
61
|
+
const lock = acquireLock(lockFile)
|
|
62
|
+
if (!lock?.ok) throw new Error('The service is running; stop it before sealing an incident')
|
|
63
|
+
const held = () => {
|
|
64
|
+
try { return fs.readFileSync(lockFile, 'utf8').trim() === String(process.pid) } catch { return false }
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const file = route.file
|
|
68
|
+
const stat = privateFile(file, 'The group journal')
|
|
69
|
+
const before = { sha256: sha256(file), bytes: stat.size }
|
|
70
|
+
let journal
|
|
71
|
+
try { journal = C2DurableJournal.open(file, VALIDATORS) } catch (error) {
|
|
72
|
+
throw new Error(`The group journal cannot be read (${error.message}); preserve it unchanged and review it manually`)
|
|
73
|
+
}
|
|
74
|
+
const snapshot = assertJournalScope(journal, chatId)
|
|
75
|
+
let settled = true
|
|
76
|
+
try { assertJournalScope(journal, chatId, { requireSettled: true }) } catch { settled = false }
|
|
77
|
+
if (settled) throw new Error('This group journal is settled; nothing to seal. Start the service normally')
|
|
78
|
+
if (sha256(file) !== before.sha256) {
|
|
79
|
+
throw new Error('Opening the group journal changed it; preserve every file for review')
|
|
80
|
+
}
|
|
81
|
+
const pending = snapshot.receipts.pending
|
|
82
|
+
if (pending.some(item => item.source !== 'message' || !FEISHU_MESSAGE_ID.test(item.sourceId))) {
|
|
83
|
+
throw new Error('The journal holds a receipt that is not a Feishu message; review it manually')
|
|
84
|
+
}
|
|
85
|
+
const incidents = path.join(stateDir, 'incidents')
|
|
86
|
+
fs.mkdirSync(incidents, { recursive: true, mode: 0o700 })
|
|
87
|
+
fs.chmodSync(incidents, 0o700)
|
|
88
|
+
const sealedAt = now()
|
|
89
|
+
const dir = path.join(incidents, `${stamp(sealedAt)}-group-${chatId.slice(-8)}`)
|
|
90
|
+
fs.mkdirSync(dir, { mode: 0o700 })
|
|
91
|
+
writeNew(path.join(dir, 'incident.json'), { schema: SCHEMA, chatId,
|
|
92
|
+
journal: path.basename(file), sha256: before.sha256, bytes: before.bytes,
|
|
93
|
+
cursor: snapshot.cursor,
|
|
94
|
+
pending: pending.map(item => ({ id: item.id, sourceId: item.sourceId })),
|
|
95
|
+
completedReceiptIds: snapshot.receipts.completed.map(item => item.id),
|
|
96
|
+
sealedAt, reason: reason.trim(),
|
|
97
|
+
handling: 'frozen unchanged; receipts not settled; turn not replayed; source messages refused at admission from incident.json',
|
|
98
|
+
tool: 'sintbot incident seal', version })
|
|
99
|
+
if (!held()) throw new Error('The writer lock was lost before sealing; preserve every file for review')
|
|
100
|
+
const sealed = path.join(dir, path.basename(file))
|
|
101
|
+
fs.renameSync(file, sealed)
|
|
102
|
+
fsyncDir(stateDir); fsyncDir(dir)
|
|
103
|
+
if (sha256(sealed) !== before.sha256 || fs.existsSync(file)) {
|
|
104
|
+
throw new Error('The sealed journal differs; preserve every file for review')
|
|
105
|
+
}
|
|
106
|
+
if (!held()) throw new Error('The writer lock was lost before the new journal; preserve every file for review')
|
|
107
|
+
C2DurableJournal.initialize(file, { state: { version: 2, seq: 0, lanes: {} },
|
|
108
|
+
receipts: { pending: [], completed: [] } }, VALIDATORS)
|
|
109
|
+
fs.chmodSync(file, 0o600)
|
|
110
|
+
const fresh = C2DurableJournal.open(file, VALIDATORS)
|
|
111
|
+
const empty = assertJournalScope(fresh, chatId, { requireSettled: true })
|
|
112
|
+
if (fresh.blocked || empty.cursor !== 0 || empty.eventCount !== 0 ||
|
|
113
|
+
empty.receipts.pending.length || empty.receipts.completed.length ||
|
|
114
|
+
empty.effects.size || empty.notices.size || Object.keys(empty.state.lanes).length) {
|
|
115
|
+
throw new Error('New group journal verification failed; preserve every file for review')
|
|
116
|
+
}
|
|
117
|
+
const result = Object.freeze({ incident: dir, sealed, sealedSha256: before.sha256,
|
|
118
|
+
newJournal: file, newJournalSha256: sha256(file),
|
|
119
|
+
refusedSources: pending.length, settledRemembered: snapshot.receipts.completed.length })
|
|
120
|
+
writeNew(path.join(dir, 'result.json'), result)
|
|
121
|
+
return result
|
|
122
|
+
} finally { lock.release() }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { sealC2GroupIncident }
|
package/src/c2install.js
CHANGED
|
@@ -201,7 +201,7 @@ function assertJournalScope(journal, chatId, { requireSettled = false } = {}) {
|
|
|
201
201
|
Object.values(snap.state.lanes.queue || {}).some(lane => lane.queue.length) ||
|
|
202
202
|
Object.values(snap.state.lanes.session || {}).some(lane => lane.settled !== true) ||
|
|
203
203
|
['terminal', 'stopIntent', 'cardCleanup', 'steer'].some(domain => Object.keys(snap.state.lanes[domain] || {}).length))) {
|
|
204
|
-
throw new Error('
|
|
204
|
+
throw new Error('Group journal has unresolved work; preserve it and explicitly review recovery')
|
|
205
205
|
}
|
|
206
206
|
return snap
|
|
207
207
|
}
|
|
@@ -4,6 +4,9 @@ const { isC2InputRejection, answerC2InputRejection, rejectC2Input } = require('.
|
|
|
4
4
|
const { coordinateC2GlobalFault } = require('./core/c2globalfault')
|
|
5
5
|
|
|
6
6
|
const RECOVERING_NOTICE = '上一个任务出错,正在收尾,这条消息没有处理,请稍后重发。'
|
|
7
|
+
const HELD_NOTICE = '本群上一个任务结果不明,已暂停处理,等待人工确认;其他群不受影响。'
|
|
8
|
+
const UPGRADING_NOTICE = 'SintBot 正在升级,这条消息没有处理,请一分钟后重发。'
|
|
9
|
+
const reasonOf = error => String(error?.message || error || 'unknown').slice(0, 300)
|
|
7
10
|
|
|
8
11
|
// Turns a group binding still holds: a running turn, a non-idle handoff or a
|
|
9
12
|
// busy lifecycle. Scoped to one group's own journal.
|
|
@@ -23,12 +26,20 @@ function activeTurns(snapshot) {
|
|
|
23
26
|
return turns.sort((a, b) => a.chatId.localeCompare(b.chatId))
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
const runtimesOf = binding => binding?.engine?.rts instanceof Map ? [...binding.engine.rts.values()] : []
|
|
30
|
+
|
|
26
31
|
// Process-local organization only. Each binding owns a distinct durable
|
|
27
32
|
// authority; the transport and writer lease belong to this single process.
|
|
33
|
+
// A fault that only one group's journal can explain holds that group: its
|
|
34
|
+
// sealed binding stays as evidence, nothing is replayed or retried, and every
|
|
35
|
+
// other group keeps serving. Only a shared resource (writer lease, transport,
|
|
36
|
+
// model capacity) stops the process. `holds` lists groups whose journal could
|
|
37
|
+
// not be opened before the service was created.
|
|
28
38
|
function createC2MultiGroupService({ routes, assertWriterLock,
|
|
29
39
|
createTransport, createBinding, onFatal = () => {}, closeCapacity = () => {},
|
|
30
40
|
quarantine = null, stopTimeoutMs = 2_000, drainTimeoutMs = 12_000,
|
|
31
|
-
createRecoveryAuthority = null,
|
|
41
|
+
createRecoveryAuthority = null, holds = new Map(), sharedFault = () => null,
|
|
42
|
+
now = Date.now, log = () => {} } = {}) {
|
|
32
43
|
if (!Array.isArray(routes) || !routes.length ||
|
|
33
44
|
new Set(routes.map(route => route.chatId)).size !== routes.length ||
|
|
34
45
|
routes.some(route => !route || typeof route.chatId !== 'string' ||
|
|
@@ -36,7 +47,9 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
36
47
|
typeof assertWriterLock !== 'function' ||
|
|
37
48
|
typeof createTransport !== 'function' || typeof createBinding !== 'function' ||
|
|
38
49
|
typeof onFatal !== 'function' || typeof closeCapacity !== 'function' ||
|
|
39
|
-
typeof log !== 'function' ||
|
|
50
|
+
typeof log !== 'function' || typeof sharedFault !== 'function' ||
|
|
51
|
+
typeof now !== 'function' || !(holds instanceof Map) ||
|
|
52
|
+
[...holds.keys()].some(chatId => !routes.some(route => route.chatId === chatId)) ||
|
|
40
53
|
createRecoveryAuthority !== null && typeof createRecoveryAuthority !== 'function' ||
|
|
41
54
|
!Number.isSafeInteger(stopTimeoutMs) || stopTimeoutMs < 1 ||
|
|
42
55
|
!Number.isSafeInteger(drainTimeoutMs) || drainTimeoutMs < 1) {
|
|
@@ -44,6 +57,10 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
44
57
|
}
|
|
45
58
|
const configured = new Map(routes.map(route => [route.chatId, route]))
|
|
46
59
|
const bindings = new Map()
|
|
60
|
+
// chatId -> { reason, source: 'startup' | 'runtime', since, binding }
|
|
61
|
+
const suspended = new Map()
|
|
62
|
+
// Set while a console upgrade switches the service: no message is admitted.
|
|
63
|
+
let maintenance = false
|
|
47
64
|
let transport = null
|
|
48
65
|
let started = false
|
|
49
66
|
let starting = false
|
|
@@ -92,7 +109,8 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
92
109
|
() => transport.stopPlatform(), stopTimeoutMs) === true
|
|
93
110
|
const drained = (await Promise.all([...bindings.values()].map(binding =>
|
|
94
111
|
bounded(() => binding.engine.drainDurableV2(drainTimeoutMs), drainTimeoutMs + 100))))
|
|
95
|
-
.every(value => value === true)
|
|
112
|
+
.every(value => value === true) && [...suspended.values()].every(entry =>
|
|
113
|
+
runtimesOf(entry.binding).every(rt => !rt.v2Controller))
|
|
96
114
|
return Object.freeze({ reason, code: code === 0 && startupStopped && platformStopped && drained ? 0 : 1,
|
|
97
115
|
startupStopped, platformStopped, drained })
|
|
98
116
|
})()
|
|
@@ -105,6 +123,22 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
105
123
|
await onFatal(error, result)
|
|
106
124
|
return result
|
|
107
125
|
}
|
|
126
|
+
// Seal one group in place. Its binding and journal are kept unchanged as
|
|
127
|
+
// evidence; a later restart reopens it only through the normal recovery
|
|
128
|
+
// gate, or after its incident has been sealed by the operator.
|
|
129
|
+
function suspend(chatId, error, { source, binding = null }) {
|
|
130
|
+
if (suspended.has(chatId)) return suspended.get(chatId)
|
|
131
|
+
const entry = Object.freeze({ reason: reasonOf(error), source, since: now(), binding })
|
|
132
|
+
suspended.set(chatId, entry)
|
|
133
|
+
for (const sealed of new Set([binding, bindings.get(chatId)])) {
|
|
134
|
+
if (!sealed) continue
|
|
135
|
+
try { sealed.gate.stop() } catch {}
|
|
136
|
+
try { sealed.engine.shutdown() } catch {}
|
|
137
|
+
}
|
|
138
|
+
bindings.delete(chatId)
|
|
139
|
+
log(`group ${chatId}: held (${entry.reason}); other groups keep serving`)
|
|
140
|
+
return entry
|
|
141
|
+
}
|
|
108
142
|
function allowed(chatId) {
|
|
109
143
|
const route = configured.get(chatId)
|
|
110
144
|
if (!route || route.paused === true) return false
|
|
@@ -155,6 +189,34 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
155
189
|
work.then(() => recovering.delete(chatId), () => recovering.delete(chatId))
|
|
156
190
|
return work
|
|
157
191
|
}
|
|
192
|
+
// One group's route, recall or ingress failed. Close the failed turn when
|
|
193
|
+
// its journal proves every effect; otherwise hold the group alone. Returns
|
|
194
|
+
// 'recovered', 'held', 'fatal' (process stop begun) or 'stopping'.
|
|
195
|
+
async function groupFault(chatId, binding, error) {
|
|
196
|
+
const shared = () => !writerHeld() ? 'writer lock lost' : sharedFault()
|
|
197
|
+
if (shared()) { await fatal(error); return 'fatal' }
|
|
198
|
+
if (!started || stopPromise) return 'stopping'
|
|
199
|
+
if (suspended.has(chatId)) return 'held'
|
|
200
|
+
const current = bindings.get(chatId)
|
|
201
|
+
if (!binding || current !== binding && !failedBindings.has(binding)) {
|
|
202
|
+
// An error from a binding this service never published is not attributable.
|
|
203
|
+
await fatal(error); return 'fatal'
|
|
204
|
+
}
|
|
205
|
+
if (!createRecoveryAuthority) { await fatal(error); return 'fatal' }
|
|
206
|
+
try {
|
|
207
|
+
await recoverGroup(chatId, binding, error)
|
|
208
|
+
} catch (failure) {
|
|
209
|
+
log(`group ${chatId}: failed turn could not be closed: ${reasonOf(failure)}`)
|
|
210
|
+
if (shared()) { await fatal(failure); return 'fatal' }
|
|
211
|
+
if (!started || stopPromise) return 'stopping'
|
|
212
|
+
suspend(chatId, failure, { source: 'runtime', binding })
|
|
213
|
+
}
|
|
214
|
+
return suspended.has(chatId) ? 'held' : 'recovered'
|
|
215
|
+
}
|
|
216
|
+
function heldReply(msg, sourceAdapter) {
|
|
217
|
+
return answerC2InputRejection({ error: rejectC2Input('C2 group held',
|
|
218
|
+
{ reason: 'group-held', notice: HELD_NOTICE }), msg, platform: sourceAdapter, log })
|
|
219
|
+
}
|
|
158
220
|
// Open one more group on the live transport: a newly added route or one
|
|
159
221
|
// paused since startup. It runs the same recovery gate as startup; only a
|
|
160
222
|
// fully opened binding is published. Failure leaves the group unserved.
|
|
@@ -162,13 +224,16 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
162
224
|
if (!route || typeof route.chatId !== 'string' || typeof route.file !== 'string') {
|
|
163
225
|
return Promise.reject(new TypeError('C2 online route needs an explicit group and journal'))
|
|
164
226
|
}
|
|
227
|
+
if (suspended.has(route.chatId)) {
|
|
228
|
+
return Promise.reject(new Error('This group is held after an unresolved fault; seal its incident and restart'))
|
|
229
|
+
}
|
|
165
230
|
if (bindings.has(route.chatId)) return Promise.resolve(bindings.get(route.chatId))
|
|
166
231
|
if (activating.has(route.chatId)) return activating.get(route.chatId)
|
|
167
232
|
const opening = (async () => {
|
|
168
|
-
if (!started || starting || stopPromise) throw new Error('C2 service is not serving')
|
|
233
|
+
if (!started || starting || stopPromise || maintenance) throw new Error('C2 service is not serving')
|
|
169
234
|
held()
|
|
170
235
|
const binding = await createBinding({ route, transport })
|
|
171
|
-
if (!started || stopPromise || !writerHeld() ||
|
|
236
|
+
if (!started || stopPromise || !writerHeld() || suspended.has(route.chatId) ||
|
|
172
237
|
!binding || !binding.gate || !binding.engine || binding.gate.blocked) {
|
|
173
238
|
binding?.gate?.stop?.()
|
|
174
239
|
binding?.engine?.shutdown?.()
|
|
@@ -184,40 +249,64 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
184
249
|
return opening
|
|
185
250
|
}
|
|
186
251
|
function routeMessage(msg, sourceAdapter) {
|
|
252
|
+
const route = configured.get(msg?.chatId)
|
|
253
|
+
if (route && route.paused !== true && suspended.has(msg.chatId)) {
|
|
254
|
+
// A held group admits nothing; a replay of a sealed source stays silent.
|
|
255
|
+
if (quarantine?.rejects(msg)) return { quarantined: true }
|
|
256
|
+
if (started && !stopPromise) return heldReply(msg, sourceAdapter)
|
|
257
|
+
}
|
|
187
258
|
// Foreign/paused group events are not faults and never get a receipt.
|
|
188
259
|
if (!allowed(msg?.chatId)) return { ignored: true, reason: 'unconfigured-or-paused' }
|
|
189
260
|
if (quarantine?.rejects(msg)) return { quarantined: true }
|
|
261
|
+
if (maintenance && started && !stopPromise) {
|
|
262
|
+
return answerC2InputRejection({ error: rejectC2Input('C2 service upgrading',
|
|
263
|
+
{ reason: 'upgrading', notice: UPGRADING_NOTICE }), msg, platform: sourceAdapter, log })
|
|
264
|
+
}
|
|
190
265
|
if (recovering.has(msg.chatId) && started && !stopPromise) {
|
|
191
266
|
// The group's failed turn is being closed; nothing is admitted meanwhile.
|
|
192
267
|
return answerC2InputRejection({ error: rejectC2Input('C2 group recovering',
|
|
193
268
|
{ reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
|
|
194
269
|
}
|
|
270
|
+
let binding
|
|
195
271
|
try {
|
|
196
272
|
held()
|
|
197
|
-
|
|
198
|
-
if (!started || starting || stopPromise || !binding
|
|
273
|
+
binding = bindings.get(msg.chatId)
|
|
274
|
+
if (!started || starting || stopPromise || !binding) {
|
|
199
275
|
throw new Error('C2 group ingress authority closed')
|
|
200
276
|
}
|
|
277
|
+
if (binding.gate.blocked) {
|
|
278
|
+
// A turn still running under a closed authority delivers its own
|
|
279
|
+
// rejection; until then this message, which has no receipt, is
|
|
280
|
+
// refused. An idle closed group is closed as its own fault here.
|
|
281
|
+
if (runtimesOf(binding).some(rt => rt.v2NormalRouteActive)) {
|
|
282
|
+
return answerC2InputRejection({ error: rejectC2Input('C2 group closing',
|
|
283
|
+
{ reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
|
|
284
|
+
}
|
|
285
|
+
return groupFault(msg.chatId, binding, new Error('C2 group ingress authority closed'))
|
|
286
|
+
.then(async outcome => {
|
|
287
|
+
if (outcome === 'held') return heldReply(msg, sourceAdapter)
|
|
288
|
+
if (outcome === 'recovered') {
|
|
289
|
+
return answerC2InputRejection({ error: rejectC2Input('C2 group recovered',
|
|
290
|
+
{ reason: 'group-recovering', notice: RECOVERING_NOTICE }), msg, platform: sourceAdapter, log })
|
|
291
|
+
}
|
|
292
|
+
throw new Error('C2 group ingress authority closed')
|
|
293
|
+
})
|
|
294
|
+
}
|
|
201
295
|
const running = binding.engine.runDurableV2Message(msg, sourceAdapter)
|
|
202
296
|
return Promise.resolve(running).catch(async error => {
|
|
203
297
|
// A refused command or premature /stop wrote nothing durable. Answer it
|
|
204
|
-
// once and keep every group serving
|
|
298
|
+
// once and keep every group serving.
|
|
205
299
|
if (isC2InputRejection(error) && started && !stopPromise &&
|
|
206
300
|
!binding.gate.blocked && writerHeld()) {
|
|
207
301
|
log(`group ${msg.chatId}: refused input before receipt (${error.reason})`)
|
|
208
302
|
return answerC2InputRejection({ error, msg, platform: sourceAdapter, log })
|
|
209
303
|
}
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const recovered = await recoverGroup(msg.chatId, binding, error).then(() => true, failure => {
|
|
217
|
-
log(`group ${msg.chatId}: failed turn could not be closed: ${failure?.message || String(failure)}`)
|
|
218
|
-
return false
|
|
219
|
-
})
|
|
220
|
-
if (recovered) return { recovered: true, reason: 'group-fault-closed' }
|
|
304
|
+
// Any other route error belongs to this group alone unless a shared
|
|
305
|
+
// resource failed. Errors from a superseded binding join its fault.
|
|
306
|
+
if (started && !stopPromise) {
|
|
307
|
+
const outcome = await groupFault(msg.chatId, binding, error)
|
|
308
|
+
if (outcome === 'recovered') return { recovered: true, reason: 'group-fault-closed' }
|
|
309
|
+
if (outcome === 'held') return { held: true, reason: 'group-held' }
|
|
221
310
|
}
|
|
222
311
|
await fatal(error)
|
|
223
312
|
throw error
|
|
@@ -236,15 +325,23 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
236
325
|
}
|
|
237
326
|
}
|
|
238
327
|
function recall(event) {
|
|
328
|
+
if (suspended.has(event?.chatId)) return { ignored: true, reason: 'group-held' }
|
|
329
|
+
if (maintenance) return { ignored: true, reason: 'upgrading' }
|
|
239
330
|
if (!allowed(event?.chatId)) return { ignored: true }
|
|
331
|
+
let binding
|
|
240
332
|
try {
|
|
241
333
|
held()
|
|
242
|
-
|
|
334
|
+
binding = bindings.get(event.chatId)
|
|
243
335
|
if (!started || stopPromise || !binding) return { ignored: true, reason: 'ingress-closed' }
|
|
244
336
|
const handled = binding.engine.handleRecall?.(event) === true
|
|
245
337
|
if (binding.runtime?.blocked) throw new Error('Recall closed its durable authority')
|
|
246
338
|
return handled ? { handled: true } : { ignored: true, reason: 'unsupported-or-inactive-recall' }
|
|
247
|
-
} catch (error) {
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (!binding || !writerHeld() || !started || stopPromise) return fatal(error)
|
|
341
|
+
return groupFault(event.chatId, binding, error).then(outcome =>
|
|
342
|
+
outcome === 'recovered' || outcome === 'held'
|
|
343
|
+
? { ignored: true, reason: `group-${outcome}` } : fatal(error))
|
|
344
|
+
}
|
|
248
345
|
}
|
|
249
346
|
async function start({ preflightOnly = false } = {}) {
|
|
250
347
|
if (started || starting || stopPromise) throw new Error('C2 service already started or stopped')
|
|
@@ -260,9 +357,19 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
260
357
|
typeof transport.stopPlatform !== 'function') {
|
|
261
358
|
throw new TypeError('C2 transport lacks a single start/stop boundary')
|
|
262
359
|
}
|
|
360
|
+
for (const [chatId, reason] of holds) suspend(chatId, reason, { source: 'startup' })
|
|
263
361
|
for (const route of routes) {
|
|
264
|
-
if (route.paused) continue //
|
|
265
|
-
|
|
362
|
+
if (route.paused || suspended.has(route.chatId)) continue // Paused journals are audited before start.
|
|
363
|
+
let binding
|
|
364
|
+
try {
|
|
365
|
+
binding = await createBinding({ route, transport })
|
|
366
|
+
} catch (error) {
|
|
367
|
+
held()
|
|
368
|
+
if (stopPromise) throw new Error('C2 service stopped during group recovery')
|
|
369
|
+
// This group's journal could not be recovered; it alone stays closed.
|
|
370
|
+
suspend(route.chatId, error, { source: 'startup' })
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
266
373
|
held()
|
|
267
374
|
if (stopPromise) {
|
|
268
375
|
binding?.gate?.stop?.()
|
|
@@ -273,11 +380,19 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
273
380
|
if (!binding || !binding.gate || !binding.engine || binding.gate.blocked) {
|
|
274
381
|
binding?.gate?.stop?.()
|
|
275
382
|
binding?.engine?.shutdown?.()
|
|
276
|
-
|
|
383
|
+
suspend(route.chatId, 'C2 group binding did not open its durable authority', { source: 'startup' })
|
|
384
|
+
continue
|
|
277
385
|
}
|
|
278
386
|
bindings.set(route.chatId, binding)
|
|
279
387
|
}
|
|
280
388
|
held()
|
|
389
|
+
const heldList = [...suspended].map(([chatId, entry]) => `${chatId}: ${entry.reason}`)
|
|
390
|
+
if (preflightOnly && heldList.length) {
|
|
391
|
+
throw new Error(`Startup preflight would hold ${heldList.length} group(s): ${heldList.join('; ')}`)
|
|
392
|
+
}
|
|
393
|
+
if (heldList.length && !bindings.size) {
|
|
394
|
+
throw new Error(`Every active group is held: ${heldList.join('; ')}`)
|
|
395
|
+
}
|
|
281
396
|
if (preflightOnly) {
|
|
282
397
|
finishStartup()
|
|
283
398
|
await stop('preflight', 0)
|
|
@@ -291,14 +406,30 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
291
406
|
onCardAction: unsupported('card action'), onBoardUpdated: unsupported('board update') })
|
|
292
407
|
held()
|
|
293
408
|
if (stopPromise) throw new Error('C2 service stopped during connection')
|
|
294
|
-
return { groups: bindings.size, connected: true }
|
|
409
|
+
return { groups: bindings.size, connected: true, ...(suspended.size ? { held: heldGroups() } : {}) }
|
|
295
410
|
} catch (error) {
|
|
296
411
|
finishStartup()
|
|
297
412
|
await stop('startup-failed', 1)
|
|
298
413
|
throw error
|
|
299
414
|
} finally { starting = false; finishStartup() }
|
|
300
415
|
}
|
|
301
|
-
|
|
416
|
+
function heldGroups() {
|
|
417
|
+
return [...suspended].map(([chatId, entry]) => Object.freeze({ chatId,
|
|
418
|
+
reason: entry.reason, source: entry.source, since: entry.since }))
|
|
419
|
+
}
|
|
420
|
+
function beginMaintenance() {
|
|
421
|
+
if (!maintenance) log('upgrade: admission closed for the service switch')
|
|
422
|
+
maintenance = true
|
|
423
|
+
}
|
|
424
|
+
function endMaintenance() {
|
|
425
|
+
if (maintenance) log('upgrade: admission reopened')
|
|
426
|
+
maintenance = false
|
|
427
|
+
}
|
|
428
|
+
return { start, stop, routeMessage, addRoute, heldGroups, beginMaintenance, endMaintenance,
|
|
429
|
+
get inMaintenance() { return maintenance },
|
|
430
|
+
get bindings() { return new Map(bindings) },
|
|
431
|
+
get held() { return new Map([...suspended].map(([chatId, entry]) =>
|
|
432
|
+
[chatId, { reason: entry.reason, source: entry.source, since: entry.since }])) } }
|
|
302
433
|
}
|
|
303
434
|
|
|
304
435
|
module.exports = { createC2MultiGroupService }
|