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.
@@ -0,0 +1,361 @@
1
+ 'use strict'
2
+
3
+ // Software updates for the multi-group service. Checking is read-only. Only a
4
+ // launchd-managed installation upgrades from the console, and only while every
5
+ // group is idle and settled: the exact stable version is installed side by
6
+ // side under the releases directory, then a detached switch process replaces
7
+ // the service entry with the same steps as the deploy scripts and rolls back
8
+ // on any failure. Foreground and development installations get instructions.
9
+ const fs = require('node:fs')
10
+ const os = require('node:os')
11
+ const path = require('node:path')
12
+ const { execFile, execFileSync, spawn } = require('node:child_process')
13
+ const { promisify } = require('node:util')
14
+ const { createHash } = require('node:crypto')
15
+ const { stableVersion, compareVersion, fetchVersion, CHECK_INTERVAL_MS } = require('./updater')
16
+ const { atomicWriteJSON } = require('./core/store')
17
+
18
+ const PACKAGE = 'sintbot'
19
+ const REGISTRIES = Object.freeze([
20
+ { registry: 'https://registry.npmjs.org', source: 'npm' },
21
+ { registry: 'https://registry.npmmirror.com', source: 'mirror' },
22
+ ])
23
+ const QUEUE_TIMEOUT_MS = 24 * 60 * 60 * 1000
24
+ const ACTIVE = Object.freeze(['queued', 'installing', 'switching'])
25
+ const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
26
+ const execFileAsync = promisify(execFile)
27
+
28
+ function readJSON(file) {
29
+ try {
30
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'))
31
+ return value && typeof value === 'object' ? value : {}
32
+ } catch { return {} }
33
+ }
34
+
35
+ function readPlist(file, { exec = execFileSync } = {}) {
36
+ return JSON.parse(exec('/usr/bin/plutil', ['-convert', 'json', '-o', '-', file],
37
+ { encoding: 'utf8', timeout: 10_000 }))
38
+ }
39
+
40
+ const real = file => { try { return fs.realpathSync(file) } catch { return null } }
41
+
42
+ // launchd names its job in XPC_SERVICE_NAME. The job counts as managing this
43
+ // process only when its plist starts this exact entry and launchd reports this
44
+ // exact PID; anything else is a foreground run.
45
+ function detectRunMode({ privateBuild, entry, env = process.env, pid = process.pid,
46
+ uid = process.getuid?.(), home = os.homedir(), exec = execFileSync } = {}) {
47
+ if (privateBuild === true) return Object.freeze({ mode: 'dev' })
48
+ const label = env.XPC_SERVICE_NAME
49
+ if (typeof label !== 'string' || !LABEL.test(label) || label === '0') {
50
+ return Object.freeze({ mode: 'foreground' })
51
+ }
52
+ const plist = path.join(home, 'Library', 'LaunchAgents', `${label}.plist`)
53
+ try {
54
+ const spec = readPlist(plist, { exec })
55
+ const args = spec.ProgramArguments
56
+ if (spec.Label !== label || !Array.isArray(args) || args.length < 3 ||
57
+ !args.every(arg => typeof arg === 'string') ||
58
+ real(args[1]) === null || real(args[1]) !== real(entry)) {
59
+ return Object.freeze({ mode: 'foreground', reason: 'launchd entry differs' })
60
+ }
61
+ const printed = exec('/bin/launchctl', ['print', `gui/${uid}/${label}`],
62
+ { encoding: 'utf8', timeout: 10_000 })
63
+ if (!new RegExp(`^\\s*pid = ${pid}\\s*$`, 'm').test(printed)) {
64
+ return Object.freeze({ mode: 'foreground', reason: 'launchd pid differs' })
65
+ }
66
+ return Object.freeze({ mode: 'managed', label, plist, entry: args[1],
67
+ logFile: typeof spec.StandardErrorPath === 'string' ? spec.StandardErrorPath : null })
68
+ } catch {
69
+ return Object.freeze({ mode: 'foreground', reason: 'launchd job unreadable' })
70
+ }
71
+ }
72
+
73
+ function registriesFromEnv(env = process.env) {
74
+ const custom = env.SINTBOT_UPDATE_REGISTRY
75
+ if (custom === undefined || custom === '') return REGISTRIES
76
+ const url = new URL(custom)
77
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('SINTBOT_UPDATE_REGISTRY must be an http(s) URL')
78
+ return Object.freeze([{ registry: url.href.replace(/\/$/, ''), source: 'custom' }])
79
+ }
80
+
81
+ function defaultReleasesRoot(env = process.env, home = os.homedir()) {
82
+ const dir = env.SINTBOT_RELEASES_DIR
83
+ if (dir !== undefined && dir !== '') {
84
+ if (!path.isAbsolute(dir) || path.resolve(dir) !== dir) throw new Error('SINTBOT_RELEASES_DIR must be a normalized absolute path')
85
+ return dir
86
+ }
87
+ return path.join(home, '.local', 'share', 'sintbot', 'releases')
88
+ }
89
+
90
+ const entryOf = prefix => path.join(prefix, 'lib', 'node_modules', PACKAGE, 'bin', 'sintbot-v2.js')
91
+
92
+ function npmBin() {
93
+ const sibling = path.join(path.dirname(process.execPath), 'npm')
94
+ return fs.existsSync(sibling) ? sibling : 'npm'
95
+ }
96
+
97
+ async function defaultInstallVersion({ version, prefix, registry }) {
98
+ const env = {}
99
+ for (const key of ['PATH', 'HOME', 'USER', 'TMPDIR', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
100
+ 'http_proxy', 'https_proxy', 'no_proxy', 'LANG', 'LC_ALL']) {
101
+ if (process.env[key] !== undefined) env[key] = process.env[key]
102
+ }
103
+ try {
104
+ await execFileAsync(npmBin(), ['install', '--global', '--prefix', prefix, `--registry=${registry}`,
105
+ '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', `${PACKAGE}@${version}`],
106
+ { timeout: 10 * 60_000, maxBuffer: 4 * 1024 * 1024, env })
107
+ } catch (error) {
108
+ throw new Error(`安装 v${version} 失败(退出码 ${error?.code ?? '未知'});没有提权,也没有改用其他版本`)
109
+ }
110
+ }
111
+
112
+ // The same verification as the public installer, in a child process so the
113
+ // service never loads modules of another version.
114
+ function defaultVerifyInstall({ version, prefix }) {
115
+ const root = path.join(prefix, 'lib', 'node_modules', PACKAGE)
116
+ // Only literal requires in this file: the package builder rejects computed ones.
117
+ const script = `
118
+ const fs = require('node:fs');
119
+ const path = require('node:path');
120
+ const { pathToFileURL } = require('node:url');
121
+ (async () => {
122
+ const root = process.argv[1];
123
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
124
+ if (pkg.name !== 'sintbot' || pkg.version !== process.argv[2] || pkg.private === true ||
125
+ pkg.bin?.sintbot !== 'bin/sintbot-v2.js') throw new Error('package identity differs');
126
+ await import(pathToFileURL(path.join(root, 'src/agent/privatecore.js')).href);
127
+ await import(pathToFileURL(path.join(root, 'src/platform/feishu.js')).href);
128
+ })().catch(() => process.exit(1));`
129
+ try {
130
+ execFileSync(process.execPath, ['-e', script, root, version], { timeout: 60_000, stdio: 'ignore', cwd: '/' })
131
+ const reported = execFileSync(process.execPath, [entryOf(prefix), 'version'],
132
+ { encoding: 'utf8', timeout: 30_000, cwd: '/' }).trim()
133
+ if (reported !== version) throw new Error('version differs')
134
+ } catch {
135
+ throw new Error(`v${version} 安装后的核对没有通过;新目录保留在 ${prefix},服务没有切换`)
136
+ }
137
+ }
138
+
139
+ function defaultSpawnSwitch({ entry, stateFile }) {
140
+ const child = spawn(process.execPath, [entry, 'upgrade-switch', '--state', stateFile],
141
+ { detached: true, stdio: 'ignore', cwd: '/', env: process.env })
142
+ child.unref()
143
+ return child.pid
144
+ }
145
+
146
+ function journalHashes(dataDir) {
147
+ const out = {}
148
+ for (const name of fs.readdirSync(dataDir).filter(name => /^c2-v2.*\.log$/.test(name)).sort()) {
149
+ out[name] = createHash('sha256').update(fs.readFileSync(path.join(dataDir, name))).digest('hex')
150
+ }
151
+ return out
152
+ }
153
+
154
+ function alive(pid) {
155
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false
156
+ try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' }
157
+ }
158
+
159
+ function createC2Updater({ currentVersion, runMode, stateFile, dataDir, configPath,
160
+ gate, beginMaintenance = () => {}, endMaintenance = () => {}, expectedGroups = () => 0,
161
+ registries = REGISTRIES, fetchImpl = globalThis.fetch, releasesRoot = defaultReleasesRoot(),
162
+ installVersion = defaultInstallVersion, verifyInstall = defaultVerifyInstall,
163
+ spawnSwitch = defaultSpawnSwitch, hashJournals = journalHashes, isAlive = alive,
164
+ now = Date.now, waitMs = 5_000, queueTimeoutMs = QUEUE_TIMEOUT_MS,
165
+ checkIntervalMs = CHECK_INTERVAL_MS, autoCheck = true, log = () => {} } = {}) {
166
+ if (typeof currentVersion !== 'string' || !runMode || typeof runMode.mode !== 'string' ||
167
+ typeof stateFile !== 'string' || !path.isAbsolute(stateFile) || typeof gate !== 'function') {
168
+ throw new TypeError('C2 updater needs the version, run mode, state file and idle gate')
169
+ }
170
+ const current = stableVersion(currentVersion)
171
+ let data = readJSON(stateFile)
172
+ let timer = null
173
+ let checking = null
174
+ let closed = false
175
+
176
+ function persist() {
177
+ try { atomicWriteJSON(stateFile, data, { mode: 0o600 }) } catch (error) { log(`update state: ${error.message}`) }
178
+ }
179
+ function setJob(patch, message) {
180
+ data.job = { ...data.job, ...patch, ...(message ? { message } : {}), updatedAt: now() }
181
+ persist()
182
+ return data.job
183
+ }
184
+ function fail(message) {
185
+ setJob({ state: 'failed' }, String(message).slice(0, 300))
186
+ return status()
187
+ }
188
+
189
+ // A switch that this process did not finish is settled from the evidence:
190
+ // the new version reached here, the switch process is still working, or it
191
+ // stopped without writing a result.
192
+ if (data.job?.state === 'switching' && data.job.to !== current && !isAlive(data.job.helperPid)) {
193
+ fail('切换过程中断,现场已保留;请检查后重新发起')
194
+ } else if (data.job?.state === 'installing') {
195
+ fail('安装被中断,可重新发起升级')
196
+ }
197
+
198
+ // The switch process writes this file too; always act on its latest bytes.
199
+ function refresh() { data = readJSON(stateFile) }
200
+ function status() {
201
+ refresh()
202
+ const latest = stableVersion(data.latestVersion)
203
+ const upgradeSupported = runMode.mode === 'managed'
204
+ return {
205
+ currentVersion, latestVersion: latest, checkedAt: Number(data.checkedAt) || null,
206
+ source: data.source || '', ignoredVersion: data.ignoredVersion || '',
207
+ available: !!current && !!latest && compareVersion(latest, current) > 0 && data.ignoredVersion !== latest,
208
+ mode: runMode.mode, upgradeSupported, announceSupported: false,
209
+ job: data.job ? { ...data.job } : null,
210
+ }
211
+ }
212
+
213
+ async function check() {
214
+ if (closed) return status()
215
+ if (checking) return checking
216
+ checking = (async () => {
217
+ let lastError = null
218
+ for (const item of registries) {
219
+ try {
220
+ const version = await fetchVersion({ fetchImpl, url: `${item.registry}/${PACKAGE}/latest` })
221
+ refresh()
222
+ data.latestVersion = version
223
+ data.checkedAt = now()
224
+ data.source = item.source
225
+ data.registry = item.registry
226
+ if (data.ignoredVersion && data.ignoredVersion !== version) data.ignoredVersion = ''
227
+ persist()
228
+ return status()
229
+ } catch (error) { lastError = error }
230
+ }
231
+ if (lastError) log(`update check unavailable: ${lastError.message || 'network error'}`)
232
+ return status()
233
+ })().finally(() => { checking = null })
234
+ return checking
235
+ }
236
+
237
+ function ignore(version) {
238
+ refresh()
239
+ const v = stableVersion(version)
240
+ if (!v || v !== stableVersion(data.latestVersion)) throw new Error('只能忽略当前检测到的版本')
241
+ data.ignoredVersion = v
242
+ persist()
243
+ return status()
244
+ }
245
+
246
+ function startUpgrade({ version } = {}) {
247
+ refresh()
248
+ if (runMode.mode !== 'managed') {
249
+ throw new Error(runMode.mode === 'dev' ? '开发版请用部署脚本升级' : '前台运行请按卡片上的步骤升级')
250
+ }
251
+ const v = stableVersion(version)
252
+ if (!current || !v || v !== stableVersion(data.latestVersion) || compareVersion(v, current) <= 0) {
253
+ throw new Error('版本信息已变化,请先重新检查')
254
+ }
255
+ if (data.job && ACTIVE.includes(data.job.state)) throw new Error('已有升级正在进行')
256
+ if (gate().includes('held')) throw new Error('有暂停的群,先处理后再升级')
257
+ data.job = { id: `${now()}-${Math.random().toString(36).slice(2, 8)}`, state: 'queued',
258
+ from: current, to: v, registry: data.registry || registries[0].registry,
259
+ createdAt: now(), updatedAt: now(), message: '等所有群空闲、记录结清后升级' }
260
+ persist()
261
+ schedule(0)
262
+ return status()
263
+ }
264
+
265
+ function schedule(ms = waitMs) {
266
+ if (closed || timer || data.job?.state !== 'queued') return
267
+ timer = setTimeout(() => { timer = null; void processQueue() }, ms)
268
+ timer.unref?.()
269
+ }
270
+
271
+ async function processQueue() {
272
+ const job = data.job
273
+ if (closed || job?.state !== 'queued') return status()
274
+ if (now() - job.createdAt > queueTimeoutMs) return fail('等待群空闲超时;可在空闲后重新发起')
275
+ const blocked = gate()
276
+ if (blocked.includes('held')) return fail('有暂停的群,升级已取消;先处理暂停的群')
277
+ if (blocked.length) { schedule(); return status() }
278
+ const prefix = path.join(releasesRoot, job.to)
279
+ if (!job.installed) {
280
+ setJob({ state: 'installing', prefix }, `正在安装 v${job.to}`)
281
+ try {
282
+ if (fs.existsSync(prefix)) {
283
+ // A directory left by an earlier attempt or a manual install is reused
284
+ // only when it verifies as exactly this version.
285
+ await verifyInstall({ version: job.to, prefix })
286
+ } else {
287
+ fs.mkdirSync(releasesRoot, { recursive: true, mode: 0o700 })
288
+ await installVersion({ version: job.to, prefix, registry: job.registry })
289
+ await verifyInstall({ version: job.to, prefix })
290
+ }
291
+ } catch (error) { return fail(error.message) }
292
+ if (closed) return status()
293
+ setJob({ state: 'queued', installed: true }, `v${job.to} 已安装,等所有群空闲后切换`)
294
+ }
295
+ // Close admission first, then prove idleness once more: nothing can start
296
+ // between this check and the switch process stopping the service.
297
+ if (gate().length) { schedule(); return status() }
298
+ beginMaintenance()
299
+ const settled = gate()
300
+ if (settled.length) { endMaintenance(); schedule(); return status() }
301
+ let hashes
302
+ try { hashes = hashJournals(dataDir) } catch (error) { endMaintenance(); return fail(`无法读取群记录:${error.message}`) }
303
+ setJob({ state: 'switching', oldEntry: runMode.entry, newEntry: entryOf(prefix), label: runMode.label,
304
+ plist: runMode.plist, logFile: runMode.logFile, dataDir, configPath,
305
+ expectedGroups: expectedGroups(), journals: hashes, servicePid: process.pid,
306
+ backupDir: path.join(dataDir, 'upgrades', new Date(now()).toISOString().replace(/[:.]/g, '-')),
307
+ steps: [] }, `正在切换到 v${job.to};期间的消息会提示稍后重发`)
308
+ try {
309
+ const pid = spawnSwitch({ entry: runMode.entry, stateFile })
310
+ setJob({ helperPid: pid })
311
+ } catch (error) {
312
+ endMaintenance()
313
+ return fail(`切换进程没有启动:${error.message}`)
314
+ }
315
+ return status()
316
+ }
317
+
318
+ // The new version marks the switch done only after every group connected.
319
+ function confirmStarted() {
320
+ refresh()
321
+ if (data.job?.state === 'switching' && data.job.to === current) {
322
+ setJob({ state: 'done', finishedAt: now() }, `已升级到 v${current}`)
323
+ }
324
+ return status()
325
+ }
326
+
327
+ if (data.job?.state === 'queued') schedule(0)
328
+ const checkTimer = autoCheck ? setInterval(() => { void check() }, checkIntervalMs) : null
329
+ checkTimer?.unref?.()
330
+ return { status, check, ignore, startUpgrade, processQueue, confirmStarted,
331
+ start() { return autoCheck ? check() : Promise.resolve(status()) },
332
+ close() { closed = true; if (checkTimer) clearInterval(checkTimer); if (timer) clearTimeout(timer); timer = null } }
333
+ }
334
+
335
+ // What keeps a console upgrade from starting: 'not-serving', 'held', 'busy'
336
+ // or 'unsettled'. The updater's own maintenance flag never blocks it, so the
337
+ // final check after admission closes can pass.
338
+ function createC2UpgradeGate({ isRunning, service, capacity, assertJournalScope }) {
339
+ return () => {
340
+ const reasons = new Set()
341
+ const live = service()
342
+ if (!isRunning() || !live) return ['not-serving']
343
+ if (live.held.size) reasons.add('held')
344
+ const shared = capacity.snapshot()
345
+ if (shared.closed) reasons.add('not-serving')
346
+ if (shared.active || shared.waiting) reasons.add('busy')
347
+ for (const [chatId, binding] of live.bindings) {
348
+ if (binding.gate.blocked) reasons.add('not-serving')
349
+ for (const rt of binding.engine.rts.values()) {
350
+ if (rt.v2NormalRouteActive || rt.v2AdmissionPending || rt.v2Controller ||
351
+ rt.v2QueueReceiptIds?.length) reasons.add('busy')
352
+ }
353
+ try { assertJournalScope(binding.journal, chatId, { requireSettled: true }) }
354
+ catch { reasons.add('unsettled') }
355
+ }
356
+ return [...reasons]
357
+ }
358
+ }
359
+
360
+ module.exports = { createC2Updater, createC2UpgradeGate, detectRunMode, registriesFromEnv, defaultReleasesRoot,
361
+ entryOf, journalHashes, readPlist, defaultVerifyInstall, REGISTRIES, PACKAGE }
@@ -0,0 +1,169 @@
1
+ 'use strict'
2
+
3
+ // Detached switch process started by the console upgrade. It runs outside the
4
+ // service's process group, so stopping the launchd job does not stop it. The
5
+ // steps are those of the deploy scripts: stop the job, wait for the writer
6
+ // lock, prove the journals unchanged, back up data and plist, preflight the
7
+ // new entry with the job's environment, replace only the entry in the plist,
8
+ // start the job and wait for every group to connect. Any failure restores the
9
+ // original plist bytes and restarts the previous version.
10
+ const fs = require('node:fs')
11
+ const path = require('node:path')
12
+ const { execFileSync, spawnSync } = require('node:child_process')
13
+ const { isDeepStrictEqual } = require('node:util')
14
+ const { atomicWriteJSON } = require('./core/store')
15
+ const { journalHashes, readPlist } = require('./c2updater')
16
+
17
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
18
+
19
+ function defaultSystem() {
20
+ const uid = process.getuid()
21
+ const run = (file, args, options = {}) => spawnSync(file, args, { encoding: 'utf8', timeout: 60_000, ...options })
22
+ return {
23
+ bootout: label => run('/bin/launchctl', ['bootout', `gui/${uid}/${label}`]),
24
+ bootstrap: plist => run('/bin/launchctl', ['bootstrap', `gui/${uid}`, plist]),
25
+ jobPid: label => {
26
+ const out = run('/bin/launchctl', ['print', `gui/${uid}/${label}`])
27
+ if (out.status !== 0) return null
28
+ const match = /^\s*pid = (\d+)\s*$/m.exec(out.stdout || '')
29
+ return match ? Number(match[1]) : 0
30
+ },
31
+ alive: pid => { try { process.kill(pid, 0); return true } catch (error) { return error.code === 'EPERM' } },
32
+ readPlist: file => readPlist(file),
33
+ writePlist: (file, value) => {
34
+ const json = `${file}.upgrade.json`, tmp = `${file}.upgrade.tmp`
35
+ fs.writeFileSync(json, JSON.stringify(value), { mode: 0o600 })
36
+ try {
37
+ execFileSync('/usr/bin/plutil', ['-convert', 'xml1', '-o', tmp, json], { timeout: 10_000 })
38
+ fs.chmodSync(tmp, 0o600)
39
+ execFileSync('/usr/bin/plutil', ['-lint', tmp], { timeout: 10_000, stdio: 'ignore' })
40
+ fs.renameSync(tmp, file)
41
+ } finally { fs.rmSync(json, { force: true }); fs.rmSync(tmp, { force: true }) }
42
+ },
43
+ backupData: (dataDir, file) => {
44
+ const base = path.basename(dataDir)
45
+ const out = run('/usr/bin/tar', ['-czf', file, '--exclude', `${base}/upgrades`, '-C', path.dirname(dataDir), base])
46
+ if (out.status !== 0) throw new Error(`data backup failed: ${(out.stderr || '').trim().slice(0, 200)}`)
47
+ fs.chmodSync(file, 0o600)
48
+ },
49
+ preflight: (spec, entry) => {
50
+ const env = { HOME: process.env.HOME, USER: process.env.USER, ...(spec.EnvironmentVariables || {}) }
51
+ const args = [entry, ...spec.ProgramArguments.slice(2), '--preflight']
52
+ return run(spec.ProgramArguments[0], args, { env, cwd: '/', timeout: 180_000 })
53
+ },
54
+ logSize: file => { try { return fs.statSync(file).size } catch { return 0 } },
55
+ logSince: (file, offset) => {
56
+ try {
57
+ const fd = fs.openSync(file, 'r')
58
+ try {
59
+ const size = fs.fstatSync(fd).size
60
+ if (size <= offset) return ''
61
+ const buffer = Buffer.alloc(Math.min(size - offset, 4 * 1024 * 1024))
62
+ fs.readSync(fd, buffer, 0, buffer.length, offset)
63
+ return buffer.toString('utf8')
64
+ } finally { fs.closeSync(fd) }
65
+ } catch { return '' }
66
+ },
67
+ }
68
+ }
69
+
70
+ async function runUpgradeSwitch({ stateFile, system = defaultSystem(), hashJournals = journalHashes,
71
+ now = Date.now, wait = sleep, stopTimeoutMs = 60_000, connectTimeoutMs = 90_000, pollMs = 500 } = {}) {
72
+ const read = () => JSON.parse(fs.readFileSync(stateFile, 'utf8'))
73
+ const state = read()
74
+ const job = state.job
75
+ if (job?.state !== 'switching' || typeof job.label !== 'string' || typeof job.plist !== 'string' ||
76
+ typeof job.oldEntry !== 'string' || typeof job.newEntry !== 'string' ||
77
+ typeof job.dataDir !== 'string' || typeof job.backupDir !== 'string') {
78
+ throw new Error('No switching upgrade job to run')
79
+ }
80
+ const steps = []
81
+ const write = (patch, message) => {
82
+ const latest = read()
83
+ if (latest.job?.id !== job.id) throw new Error('The upgrade job changed during the switch')
84
+ latest.job = { ...latest.job, ...patch, ...(message ? { message } : {}), steps: [...steps], updatedAt: now() }
85
+ atomicWriteJSON(stateFile, latest, { mode: 0o600 })
86
+ return latest.job
87
+ }
88
+ const step = (name, ok = true, detail = '') => {
89
+ steps.push({ at: now(), step: name, ok, ...(detail ? { detail: String(detail).slice(0, 300) } : {}) })
90
+ try { write({}) } catch {}
91
+ }
92
+ const lockFile = path.join(job.dataDir, 'daemon.lock')
93
+ const until = async (predicate, ms) => {
94
+ const end = now() + ms
95
+ while (now() < end) { if (await predicate()) return true; await wait(pollMs) }
96
+ return !!(await predicate())
97
+ }
98
+ const stopped = () => until(() => {
99
+ const pid = system.jobPid(job.label)
100
+ return (pid === null || pid === 0) && !system.alive(job.servicePid) && !fs.existsSync(lockFile)
101
+ }, stopTimeoutMs)
102
+ const connected = (offset, expectDone) => until(() => {
103
+ const text = system.logSince(job.logFile, offset)
104
+ if (/ held \(|multi-group failure|startup cleanup was not confirmed/.test(text)) return false
105
+ if (job.expectedGroups > 0 &&
106
+ text.includes(`SintBot Feishu ingress connected for ${job.expectedGroups} groups`)) return true
107
+ try { return expectDone && read().job?.state === 'done' } catch { return false }
108
+ }, connectTimeoutMs)
109
+
110
+ fs.mkdirSync(job.backupDir, { recursive: true, mode: 0o700 })
111
+ const original = fs.readFileSync(job.plist)
112
+ fs.writeFileSync(path.join(job.backupDir, 'service.plist'), original, { mode: 0o600 })
113
+ const spec = system.readPlist(job.plist)
114
+ if (spec.Label !== job.label || spec.ProgramArguments?.[1] !== job.oldEntry) {
115
+ write({ state: 'failed' }, '服务配置和发起升级时不一致,没有切换')
116
+ return 'failed'
117
+ }
118
+
119
+ // Restore the exact original plist and start the previous version.
120
+ async function rollback(reason) {
121
+ step('rollback', true, reason)
122
+ system.bootout(job.label)
123
+ await stopped()
124
+ fs.writeFileSync(job.plist, original, { mode: 0o600 })
125
+ const offset = system.logSize(job.logFile)
126
+ const started = system.bootstrap(job.plist)
127
+ const back = started.status === 0 && await connected(offset, false)
128
+ step('previous version started', back)
129
+ write({ state: back ? 'rolled-back' : 'failed', finishedAt: now() }, back
130
+ ? `没有切换到 v${job.to}:${reason};已换回 v${job.from}`
131
+ : `没有切换到 v${job.to}:${reason};换回 v${job.from} 后服务没有连上,请手动检查`)
132
+ return back ? 'rolled-back' : 'failed'
133
+ }
134
+
135
+ const out = system.bootout(job.label)
136
+ step('stop service', out.status === 0, out.stderr)
137
+ if (!await stopped()) return rollback('旧服务没有在规定时间内停止')
138
+ if (!isDeepStrictEqual(hashJournals(job.dataDir), job.journals)) {
139
+ // A journal changed after the idle check: never switch on top of it.
140
+ return rollback('停止过程中群记录发生了变化')
141
+ }
142
+ step('journals unchanged')
143
+ try { system.backupData(job.dataDir, path.join(job.backupDir, 'data.tgz')); step('data backed up') }
144
+ catch (error) { return rollback(error.message) }
145
+ const check = system.preflight(spec, job.newEntry)
146
+ step('new version preflight', check.status === 0, (check.stderr || '').trim().split('\n').pop())
147
+ if (check.status !== 0) return rollback('新版本预检没有通过')
148
+ const next = { ...spec, ProgramArguments: [...spec.ProgramArguments] }
149
+ next.ProgramArguments[1] = job.newEntry
150
+ try {
151
+ system.writePlist(job.plist, next)
152
+ const written = system.readPlist(job.plist)
153
+ const expected = { ...spec, ProgramArguments: next.ProgramArguments }
154
+ if (!isDeepStrictEqual(written, expected)) throw new Error('rewritten service configuration differs')
155
+ step('service entry replaced')
156
+ } catch (error) { return rollback(error.message) }
157
+ const offset = system.logSize(job.logFile)
158
+ const started = system.bootstrap(job.plist)
159
+ step('start new version', started.status === 0, started.stderr)
160
+ if (started.status !== 0 || !await connected(offset, true)) {
161
+ return rollback(`新版本启动后没有在 ${Math.round(connectTimeoutMs / 1000)} 秒内连上全部群`)
162
+ }
163
+ const latest = read().job
164
+ if (latest?.state !== 'done') write({ state: 'done', finishedAt: now() }, `已升级到 v${job.to}`)
165
+ else write({})
166
+ return 'done'
167
+ }
168
+
169
+ module.exports = { runUpgradeSwitch, defaultSystem }
@@ -29,14 +29,21 @@ function createC2V2SourceQuarantine(value, { required = false } = {}) {
29
29
 
30
30
  // Every sealed incident under <data>/incidents/*/incident.json keeps its
31
31
  // unsettled source messages out of admission for that group, on every start.
32
- // A malformed seal stops startup: an unreadable fence cannot be skipped.
32
+ // A seal written by `sintbot incident seal` also lists the receipts its
33
+ // journal had already settled, so the group's new journal never runs one of
34
+ // those messages again. A malformed seal stops startup: an unreadable fence
35
+ // cannot be skipped.
33
36
  const CHAT_ID = /^oc_[A-Za-z0-9_-]{1,124}$/
37
+ const RECEIPT_ID = /^[0-9a-f]{32}$/
34
38
  function loadC2IncidentQuarantine(stateDir, { fs = require('node:fs'), path = require('node:path') } = {}) {
35
39
  if (typeof stateDir !== 'string' || !path.isAbsolute(stateDir)) {
36
40
  throw new TypeError('C2 incident quarantine needs the installation directory')
37
41
  }
38
42
  const root = path.join(stateDir, 'incidents')
43
+ // Loaded here so the source quarantine above stays a standalone module.
44
+ const { taskId } = require('./enginecheckpoint')
39
45
  const byChat = new Map()
46
+ const settled = new Map()
40
47
  const fail = name => { throw new Error(`Sealed incident record is invalid: ${name}; preserve it and review`) }
41
48
  let entries = []
42
49
  try { entries = fs.readdirSync(root, { withFileTypes: true }) }
@@ -53,14 +60,26 @@ function loadC2IncidentQuarantine(stateDir, { fs = require('node:fs'), path = re
53
60
  try { record = JSON.parse(fs.readFileSync(file, 'utf8')) } catch { fail(entry.name) }
54
61
  if (record?.schema !== 'sbot-c2-incident-seal-v1' || !CHAT_ID.test(record.chatId || '') ||
55
62
  !Array.isArray(record.pending) ||
56
- record.pending.some(item => !FEISHU_MESSAGE_ID.test(item?.sourceId || ''))) fail(entry.name)
63
+ record.pending.some(item => !FEISHU_MESSAGE_ID.test(item?.sourceId || '')) ||
64
+ record.completedReceiptIds !== undefined && (!Array.isArray(record.completedReceiptIds) ||
65
+ record.completedReceiptIds.some(id => typeof id !== 'string' || !RECEIPT_ID.test(id)))) fail(entry.name)
57
66
  const ids = byChat.get(record.chatId) || new Set()
58
67
  for (const item of record.pending) ids.add(item.sourceId)
59
68
  byChat.set(record.chatId, ids)
69
+ if (record.completedReceiptIds?.length) {
70
+ const done = settled.get(record.chatId) || new Set()
71
+ for (const id of record.completedReceiptIds) done.add(id)
72
+ settled.set(record.chatId, done)
73
+ }
60
74
  }
61
75
  const sourceIds = Object.freeze([...byChat.values()].flatMap(ids => [...ids]))
76
+ const settledBefore = message => {
77
+ const done = settled.get(message?.chatId)
78
+ if (!done || typeof message.msgId !== 'string' || !message.msgId) return false
79
+ return done.has(taskId({ source: 'message', chatId: message.chatId, sourceId: message.msgId }))
80
+ }
62
81
  return Object.freeze({ sourceIds,
63
- rejects: message => !!byChat.get(message?.chatId)?.has(message?.msgId) })
82
+ rejects: message => !!byChat.get(message?.chatId)?.has(message?.msgId) || settledBefore(message) })
64
83
  }
65
84
 
66
85
  module.exports = { createC2V2SourceQuarantine, loadC2IncidentQuarantine }
@@ -596,6 +596,7 @@ async runDurableV2Message(msg, platform) {
596
596
  rt.v2NormalRouteActive = true
597
597
  const execute = async () => {
598
598
  let release = null
599
+ let failed = false
599
600
  try {
600
601
  let receiptId = accepted.receiptId
601
602
  if (this.durableV2AcquireCapacity) {
@@ -628,12 +629,13 @@ async runDurableV2Message(msg, platform) {
628
629
  // Seal shared grants before releasing this turn's permit. Waiting
629
630
  // siblings must not dispatch in the microtask before the service sees
630
631
  // this rejection and begins its process-wide stop.
632
+ failed = true
631
633
  this.durableV2SealCapacity?.(error)
632
634
  throw error
633
635
  } finally {
634
636
  rt.v2AdmissionPending = false
635
637
  rt.v2AdmissionController = null
636
- release?.()
638
+ if (release) this._releaseDurableV2Capacity(rt, release, failed)
637
639
  }
638
640
  }
639
641
  const execution = execute()
@@ -649,6 +651,24 @@ async runDurableV2Message(msg, platform) {
649
651
  }
650
652
  }
651
653
 
654
+ // A failed route returns its shared model permit only once its runner is
655
+ // proven closed or proven never started. Otherwise the permit stays taken
656
+ // until the process exits, so a sibling group never runs beside a child that
657
+ // may still be alive.
658
+ _releaseDurableV2Capacity(rt, release, failed) {
659
+ const closed = () => !rt.v2Controller && (rt.v2RunnerStopVerified === true ||
660
+ rt.v2RunnerNotStarted === true)
661
+ const stop = rt.v2RunnerStopPromise
662
+ if (!failed || !rt.v2Controller && (!stop || closed())) return release()
663
+ const kept = () => this.log(`group ${rt.chatId}: model permit kept until restart; runner close unproven`)
664
+ if (!stop) return kept()
665
+ stop.then(() => {
666
+ if (!closed()) return kept()
667
+ // A failed binary release closes the shared capacity itself.
668
+ try { release() } catch (error) { this.log(`group ${rt.chatId}: model permit release failed: ${error.message}`) }
669
+ }, kept)
670
+ }
671
+
652
672
  async _runDurableV2Steer(rt, receiptId) {
653
673
  const authority = this.durableV2Runtime
654
674
  const chatId = rt.chatId
@@ -1323,6 +1343,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1323
1343
  rt.v2Controller = controller
1324
1344
  rt.v2RunnerStopPromise = null
1325
1345
  rt.v2RunnerStopVerified = false
1346
+ rt.v2RunnerNotStarted = false
1326
1347
  let streamChain = Promise.resolve()
1327
1348
  let streamFailure = null
1328
1349
  const enqueueFrame = event => {
@@ -1508,6 +1529,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1508
1529
  rt.v2RunnerGeneration = null
1509
1530
  rt.v2RunningJobs = null
1510
1531
  rt.v2DispatchEffect = null
1532
+ rt.v2RunnerNotStarted = true
1511
1533
  return { effectId, accepted: false, startFailureRecovered: true }
1512
1534
  }
1513
1535
  if (capability && !accepted) {