sintbot 1.4.1 → 1.4.3

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 }
@@ -6,6 +6,7 @@
6
6
  // outbound executor a second time.
7
7
  const { createC2CardEffectBoundary, confirmCardOperation } = require('./c2cardeffectbridge')
8
8
  const { prepareTerminalEffectBridge, terminalReplyText } = require('./c2terminaleffectbridge')
9
+ const { stripMilestones } = require('./turnprogress')
9
10
 
10
11
  function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
11
12
  receiptId, chatId, turnToken, generation, verifyGeneration,
@@ -130,7 +131,7 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
130
131
  return true
131
132
  } catch (error) { return fail(error) }
132
133
  },
133
- async complete({ payload, replyText, paused = false, footer = '' } = {}) {
134
+ async complete({ payload, replyText, paused = false, footer = '', milestones = [] } = {}) {
134
135
  if (phase !== 'open' && phase !== 'plain') {
135
136
  throw new Error('C2 card terminal path is not open')
136
137
  }
@@ -143,7 +144,8 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
143
144
  typeof terminalPayload.quiet !== 'boolean' || terminalPayload.quiet !== receipt.quiet ||
144
145
  typeof terminalPayload.resultText !== 'string' ||
145
146
  typeof terminalPayload.bufferText !== 'string' ||
146
- typeof footer !== 'string') {
147
+ typeof footer !== 'string' || !Array.isArray(milestones) ||
148
+ !milestones.every(item => typeof item === 'string' && item.trim())) {
147
149
  throw new TypeError('C2 card terminal payload differs from running turn')
148
150
  }
149
151
  if (card && await card.flush() !== true) {
@@ -184,8 +186,20 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
184
186
  // is therefore not suitable for a durable final effect.
185
187
  return sendPlainReply(target, visibleText)
186
188
  }
189
+ // Display only: milestone lines leave the answer and go into the
190
+ // card's "process" fold; an answer that was only milestones shows
191
+ // them as its body. The durable reply text stays exact.
192
+ let shownText = visibleText
193
+ const finishOpts = { footer }
194
+ if (milestones.length) {
195
+ const stripped = stripMilestones(visibleText)
196
+ if (stripped.trim()) {
197
+ shownText = stripped
198
+ finishOpts.milestones = [...milestones]
199
+ } else shownText = milestones.map(item => '▶ ' + item).join('\n')
200
+ }
187
201
  let calls = 0
188
- const finished = await card.finish(visibleText, { footer,
202
+ const finished = await card.finish(shownText, { ...finishOpts,
189
203
  terminalOperationBoundary: async operation => {
190
204
  if (!operation || operation.kind !== 'card.update' ||
191
205
  operation.cardId == null || typeof operation.invoke !== 'function' ||
@@ -205,6 +205,12 @@ class C2DurableRuntime {
205
205
  get blocked() { return this.#closed || this.#opening || this.#externalEffectInFlight || !this.#ingress ||
206
206
  this.#ingress.blocked || this.#journal.blocked }
207
207
 
208
+ // Closed or sealed for good. Unlike `blocked`, an outbound effect that is
209
+ // still awaiting its platform response does not count: a streaming card's
210
+ // next frame queues behind it instead of failing the turn.
211
+ get closed() { return this.#closed || !this.#ingress || this.#ingress.blocked ||
212
+ this.#journal.blocked }
213
+
208
214
  hasRecoveryPolicy({ assertWriterLock, singleNoticePerGroup } = {}) {
209
215
  return typeof assertWriterLock === 'function' &&
210
216
  this.#assertWriterLock === assertWriterLock &&