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.
- package/CHANGELOG.en.json +42 -0
- package/CHANGELOG.json +42 -0
- package/bin/sintbot-v2.js +33 -2
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +6 -4
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +6 -4
- package/install.sh +1 -1
- package/package.json +5 -1
- package/src/c2configcontrol.js +3 -2
- package/src/c2console.js +6 -3
- package/src/c2multigroupservice.js +19 -2
- package/src/c2updater.js +361 -0
- package/src/c2upgradeswitch.js +169 -0
- package/src/core/c2cardterminalpath.js +17 -3
- package/src/core/c2durableruntime.js +6 -0
- package/src/core/turnprogress.js +135 -0
- package/src/core/v2engine.js +141 -19
- package/src/ui/console.html +36 -15
- package/src/updater.js +243 -0
package/src/c2updater.js
ADDED
|
@@ -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(
|
|
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 &&
|