sintbot 1.0.16 → 1.3.0
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 +99 -0
- package/CHANGELOG.json +99 -0
- package/README.md +20 -158
- package/README.zh-CN.md +20 -166
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/install.md +61 -167
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
- package/docs/zh-CN/install.md +61 -165
- package/install.sh +84 -0
- package/package.json +73 -37
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4716
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/cli-secret.js +0 -36
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2758
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5526
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
|
Binary file
|
package/src/agent/runner.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
// Runner dispatch — selects the AgentRunner by provider so the rest of
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// it to the codex bin when routing there. probeVersion (the Claude-bin startup probe) is
|
|
6
|
-
// re-exported; codex has its own probe via codexcode.probeVersion (used by health.js).
|
|
1
|
+
// Runner dispatch — selects the AgentRunner by provider so the rest of SintBot calls one
|
|
2
|
+
// `runTurn`. Codex uses App Server by default and `codex exec` when the group
|
|
3
|
+
// explicitly disables busy follow-up; all other providers use Claude Code. The caller's `bin` is
|
|
4
|
+
// the Claude bin (cfg.agent.bin) — override it when routing to Codex.
|
|
7
5
|
|
|
8
6
|
const claudeAgent = require('./claudecode')
|
|
9
7
|
const codexAgent = require('./codexcode')
|
|
8
|
+
const codexApp = require('./codexapp')
|
|
9
|
+
const { routeBackend } = require('./privatecore')
|
|
10
10
|
|
|
11
11
|
function runTurn(opts) {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const route = routeBackend(opts && opts.provider && opts.provider.runner, opts && opts.codexApp, opts && opts.codexSteer, opts && opts.codexProjectDocFallback)
|
|
13
|
+
if (route !== 'claude') {
|
|
14
|
+
const codexOpts = { ...opts, bin: (opts.provider && opts.provider.bin) || 'codex' }
|
|
15
|
+
return route === 'codex-app' ? codexApp.runTurn(codexOpts) : codexAgent.runTurn(codexOpts)
|
|
14
16
|
}
|
|
15
17
|
return claudeAgent.runTurn(opts)
|
|
16
18
|
}
|
package/src/agent/winexec.js
CHANGED
|
@@ -11,19 +11,49 @@
|
|
|
11
11
|
// same as calling the bin directly.
|
|
12
12
|
|
|
13
13
|
const fs = require('node:fs')
|
|
14
|
+
const os = require('node:os')
|
|
14
15
|
const path = require('node:path')
|
|
15
16
|
|
|
16
17
|
const IS_WIN = process.platform === 'win32'
|
|
17
18
|
|
|
19
|
+
const isFile = (p) => { try { return fs.statSync(p).isFile() } catch { return false } }
|
|
20
|
+
|
|
21
|
+
// nvm 把每个 node 版本各装一套,bin 目录名里带版本号;新到旧排,先试最近装的那套
|
|
22
|
+
function nvmBinDirs(home) {
|
|
23
|
+
const root = path.join(home, '.nvm', 'versions', 'node')
|
|
24
|
+
try { return fs.readdirSync(root).sort().reverse().map((v) => path.join(root, v, 'bin')) } catch { return [] }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 配置钉死的绝对路径指空时,找**同一个程序**的另一份 —— 不切模型、不清会话、不报错。
|
|
28
|
+
// 典型情况:CLI 自更新把新版装进 nvm 前缀、homebrew 那份被清掉,配置钉的路径指空
|
|
29
|
+
// ⇒ 每轮都失败,而新版明明就在 ~/.nvm/…/bin 里。候选顺序 = PATH 各目录 → 原生安装器 ~/.local/bin →
|
|
30
|
+
// homebrew / usr-local → nvm 各版本。只管「绝对路径且文件不在」;裸名字照旧交给 PATH。
|
|
31
|
+
// 返回 { bin, fallback }:fallback 非空 = 这次用的不是配置那条,调用方记一行日志让人去改配置。
|
|
32
|
+
const FIXED_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'] // 两个常见全局前缀;单测把它换成空,免得本机真实安装混进沙箱
|
|
33
|
+
function locateBin(bin, { fixedDirs = FIXED_DIRS } = {}) {
|
|
34
|
+
if (!bin || !path.isAbsolute(bin) || isFile(bin)) return { bin, fallback: null }
|
|
35
|
+
const home = os.homedir()
|
|
36
|
+
const dirs = [...(process.env.PATH || '').split(path.delimiter), path.join(home, '.local', 'bin'), ...fixedDirs, ...nvmBinDirs(home)]
|
|
37
|
+
const name = path.basename(bin)
|
|
38
|
+
for (const d of dirs) {
|
|
39
|
+
if (!d) continue
|
|
40
|
+
const p = path.join(d, name)
|
|
41
|
+
if (p !== bin && isFile(p)) return { bin: p, fallback: p }
|
|
42
|
+
}
|
|
43
|
+
return { bin, fallback: null }
|
|
44
|
+
}
|
|
45
|
+
|
|
18
46
|
function resolveSpawn(bin, args) {
|
|
19
|
-
|
|
47
|
+
const loc = locateBin(bin) // 所有起 agent 进程的地方(轮次 / 旗子探针 / 版本探针 / 登录态探针)都经这里,一处兜底全覆盖
|
|
48
|
+
bin = loc.bin
|
|
49
|
+
if (!IS_WIN) return { file: bin, spawnArgs: args, fallback: loc.fallback }
|
|
20
50
|
const resolved = resolveWinExecutable(bin)
|
|
21
|
-
if (!resolved) return { file: bin, spawnArgs: args } // not found → let ENOENT surface with the caller's hint
|
|
51
|
+
if (!resolved) return { file: bin, spawnArgs: args, fallback: loc.fallback } // not found → let ENOENT surface with the caller's hint
|
|
22
52
|
const ext = path.extname(resolved).toLowerCase()
|
|
23
53
|
if (ext === '.cmd' || ext === '.bat') {
|
|
24
|
-
return { file: process.env.ComSpec || 'cmd.exe', spawnArgs: ['/d', '/s', '/c', resolved, ...args] }
|
|
54
|
+
return { file: process.env.ComSpec || 'cmd.exe', spawnArgs: ['/d', '/s', '/c', resolved, ...args], fallback: loc.fallback }
|
|
25
55
|
}
|
|
26
|
-
return { file: resolved, spawnArgs: args } // real .exe → spawn directly, cleanest
|
|
56
|
+
return { file: resolved, spawnArgs: args, fallback: loc.fallback } // real .exe → spawn directly, cleanest
|
|
27
57
|
}
|
|
28
58
|
|
|
29
59
|
// A minimal `where`: resolve a bare command against PATH + PATHEXT. PATHEXT order
|
|
@@ -47,4 +77,4 @@ function resolveWinExecutable(bin) {
|
|
|
47
77
|
return null
|
|
48
78
|
}
|
|
49
79
|
|
|
50
|
-
module.exports = { IS_WIN, resolveSpawn, resolveWinExecutable }
|
|
80
|
+
module.exports = { IS_WIN, resolveSpawn, resolveWinExecutable, locateBin }
|
package/src/c2install.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// First-install filesystem preparation only. Existing authorities and legacy
|
|
4
|
+
// state are never adopted, cleared, migrated or replayed by this entry.
|
|
5
|
+
const fs = require('node:fs')
|
|
6
|
+
const os = require('node:os')
|
|
7
|
+
const path = require('node:path')
|
|
8
|
+
const { loadConfig, platformsConfigured } = require('./config')
|
|
9
|
+
const { C2DurableJournal } = require('./core/c2durablejournal')
|
|
10
|
+
const { validatePersistentState } = require('./core/c2persiststate')
|
|
11
|
+
const { validatePersistentReceipts, validatePersistentRelation } = require('./core/c2persistrelation')
|
|
12
|
+
const VALIDATORS = { validateState: validatePersistentState,
|
|
13
|
+
validateReceipts: validatePersistentReceipts, validateRelation: validatePersistentRelation }
|
|
14
|
+
|
|
15
|
+
function loadInstallation(explicitConfig) {
|
|
16
|
+
const stateDir = path.join(os.homedir(), '.sbot')
|
|
17
|
+
const file = process.env.SBOT_C2_V2_JOURNAL || path.join(stateDir, 'c2-v2.log')
|
|
18
|
+
if (!path.isAbsolute(file) || file !== path.resolve(file) ||
|
|
19
|
+
path.dirname(file) !== stateDir || !/^c2-v2(?:[._-][a-zA-Z0-9._-]+)?\.log$/.test(path.basename(file))) {
|
|
20
|
+
throw new Error('The journal must be a c2-v2*.log file directly in the installation state directory')
|
|
21
|
+
}
|
|
22
|
+
const configFile = explicitConfig || process.env.SBOT_CONFIG || path.join(stateDir, 'config.json')
|
|
23
|
+
if (!path.isAbsolute(configFile) || !fs.existsSync(configFile)) {
|
|
24
|
+
throw new Error('An existing configuration file with an absolute path is required')
|
|
25
|
+
}
|
|
26
|
+
const cfg = loadConfig(configFile)
|
|
27
|
+
const configured = platformsConfigured(cfg)
|
|
28
|
+
const groups = Object.entries(cfg.groups || {})
|
|
29
|
+
if (groups.length !== 1) throw new Error('This release requires exactly one explicit Feishu group')
|
|
30
|
+
const [serviceChatId, group] = groups[0]
|
|
31
|
+
const provider = cfg.providers?.[group?.provider || cfg.defaultProvider]
|
|
32
|
+
if (path.resolve(cfg._path || '') !== path.resolve(configFile) ||
|
|
33
|
+
!configured.feishu || configured.wecom || configured.slack || cfg.wecom || cfg.slack ||
|
|
34
|
+
(cfg._groupPlatform?.inferred || []).length ||
|
|
35
|
+
!/^oc_[A-Za-z0-9_-]{1,124}$/.test(serviceChatId) || !group ||
|
|
36
|
+
group.platform !== 'feishu' || group.paused || group.board ||
|
|
37
|
+
group.crons && (!Array.isArray(group.crons) || group.crons.length) ||
|
|
38
|
+
provider?.runner !== 'codex' || group.codexSteer !== false || group.codexApp === true ||
|
|
39
|
+
cfg.alerts?.chatId || cfg.ui && cfg.ui.enabled !== false) {
|
|
40
|
+
throw new Error('This release accepts one explicit Feishu Codex message route, with codexSteer:false and without canvas, scheduled tasks, console or synthetic sources')
|
|
41
|
+
}
|
|
42
|
+
if (cfg.feishu.onlyWhenMentioned === true &&
|
|
43
|
+
!/^ou_[a-zA-Z0-9_-]+$/.test(cfg.feishu.botOpenId || '')) {
|
|
44
|
+
throw new Error('Mention-only messages require the Feishu botOpenId; otherwise set onlyWhenMentioned:false for the dedicated group')
|
|
45
|
+
}
|
|
46
|
+
if (typeof group.workdir !== 'string' || !path.isAbsolute(group.workdir) ||
|
|
47
|
+
!fs.existsSync(group.workdir) || !fs.statSync(group.workdir).isDirectory()) {
|
|
48
|
+
throw new Error('The group workdir must be an existing absolute project directory')
|
|
49
|
+
}
|
|
50
|
+
return { stateDir, file, cfg, groups, serviceChatId, provider }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertPrivateDirectory(dir) {
|
|
54
|
+
const stat = fs.lstatSync(dir)
|
|
55
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() ||
|
|
56
|
+
typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
57
|
+
throw new Error('The installation state directory must be a real directory owned by this user')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function absent(file, label) {
|
|
62
|
+
try { fs.lstatSync(file) } catch (error) { if (error.code === 'ENOENT') return; throw error }
|
|
63
|
+
throw new Error(`${label} already exists; initialization never overwrites or migrates existing state`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertFirstInstall(stateDir, file) {
|
|
67
|
+
assertPrivateDirectory(stateDir)
|
|
68
|
+
for (const name of ['state.json', 'pending-turns.json', 'engine-receipts.json']) {
|
|
69
|
+
absent(path.join(stateDir, name), name)
|
|
70
|
+
}
|
|
71
|
+
absent(file, 'Journal')
|
|
72
|
+
// An independently named journal must not make existing authority disappear.
|
|
73
|
+
if (fs.readdirSync(stateDir).some(name => /^c2-v2.*\.log$/.test(name))) {
|
|
74
|
+
throw new Error('An existing journal requires review; initialization cannot replace its authority')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function initializeInstallation(explicitConfig) {
|
|
79
|
+
const { stateDir, file } = loadInstallation(explicitConfig)
|
|
80
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
81
|
+
assertFirstInstall(stateDir, file)
|
|
82
|
+
const lockFile = path.join(stateDir, 'daemon.lock')
|
|
83
|
+
// Unlike normal start, initialization refuses even stale locks.
|
|
84
|
+
absent(lockFile, 'Service lock')
|
|
85
|
+
let fd
|
|
86
|
+
let ownsLock = false
|
|
87
|
+
try {
|
|
88
|
+
fd = fs.openSync(lockFile, 'wx', 0o600)
|
|
89
|
+
ownsLock = true
|
|
90
|
+
fs.writeSync(fd, String(process.pid))
|
|
91
|
+
fs.fsyncSync(fd)
|
|
92
|
+
fs.closeSync(fd); fd = undefined
|
|
93
|
+
assertFirstInstall(stateDir, file)
|
|
94
|
+
fs.chmodSync(stateDir, 0o700)
|
|
95
|
+
C2DurableJournal.initialize(file, {
|
|
96
|
+
state: { version: 2, seq: 0, lanes: {} },
|
|
97
|
+
receipts: { pending: [], completed: [] },
|
|
98
|
+
}, VALIDATORS)
|
|
99
|
+
const journal = C2DurableJournal.open(file, VALIDATORS)
|
|
100
|
+
const snapshot = journal.snapshot()
|
|
101
|
+
if (journal.blocked || snapshot.cursor !== 0 || snapshot.eventCount !== 0 ||
|
|
102
|
+
snapshot.receipts.pending.length || snapshot.receipts.completed.length ||
|
|
103
|
+
snapshot.effects.size || snapshot.notices.size || Object.keys(snapshot.state.lanes).length) {
|
|
104
|
+
throw new Error('New journal verification failed; preserve the file for review')
|
|
105
|
+
}
|
|
106
|
+
return file
|
|
107
|
+
} finally {
|
|
108
|
+
if (fd !== undefined) fs.closeSync(fd)
|
|
109
|
+
if (ownsLock) {
|
|
110
|
+
try {
|
|
111
|
+
if (fs.readFileSync(lockFile, 'utf8').trim() === String(process.pid)) fs.unlinkSync(lockFile)
|
|
112
|
+
} catch { /* Do not remove a lock that no longer belongs to this operation. */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { loadInstallation, initializeInstallation, assertPrivateDirectory }
|
package/src/config.js
CHANGED
|
@@ -40,7 +40,8 @@ const DEFAULTS = {
|
|
|
40
40
|
// 写死会**覆盖 CLI 的默认**,于是每次 Anthropic 出新一代,sbot 的默认就悄悄落后一代
|
|
41
41
|
// (2026-08-03 核对时正卡在 opus-4-8,而 CLI 默认已是 Opus 5)。要钉死型号在控制台选。
|
|
42
42
|
providers: { claude: {} },
|
|
43
|
-
// "<chatId>": { workdir, provider?, model?, allowedTools?: [], disallowedTools?: [] }
|
|
43
|
+
// "<chatId>": { workdir, provider?, model?, codexSteer?: false, allowedTools?: [], disallowedTools?: [] }
|
|
44
|
+
// codexSteer defaults on for Codex groups; explicit false opts out.
|
|
44
45
|
// — one project per group; permissions are scoped to that group's own workdir.
|
|
45
46
|
groups: {},
|
|
46
47
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { C2DurableRuntime } = require('./c2durableruntime')
|
|
4
|
+
|
|
5
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
6
|
+
const GENERATION = /^[0-9a-f]{32}$/
|
|
7
|
+
|
|
8
|
+
function nonempty(value) { return typeof value === 'string' && value.length > 0 }
|
|
9
|
+
|
|
10
|
+
// This is deliberately narrower than a generic Feishu success check. A card
|
|
11
|
+
// or message ID is required when later operations depend on that target. The
|
|
12
|
+
// live target and SDK response remain in memory; only the runtime's hashed
|
|
13
|
+
// operation identity and effect outcome may enter its journal.
|
|
14
|
+
function confirmCardOperation(response, operation) {
|
|
15
|
+
if (!operation || typeof operation !== 'object') return false
|
|
16
|
+
switch (operation.kind) {
|
|
17
|
+
case 'card.create':
|
|
18
|
+
return response?.code === 0 && nonempty(response?.data?.card_id)
|
|
19
|
+
case 'message.reply':
|
|
20
|
+
case 'message.create':
|
|
21
|
+
return response?.code === 0 &&
|
|
22
|
+
nonempty(response?.data?.message_id || response?.message_id)
|
|
23
|
+
case 'cardElement.content':
|
|
24
|
+
return response === true
|
|
25
|
+
case 'card.update':
|
|
26
|
+
case 'card.settings':
|
|
27
|
+
case 'message.delete':
|
|
28
|
+
return response?.code === 0
|
|
29
|
+
default:
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Pass the returned function only as Feishu's explicit operationBoundary for
|
|
35
|
+
// this one live turn. Normal card behavior is unaffected unless the caller
|
|
36
|
+
// opts in; a failed or ambiguous operation must propagate instead of falling
|
|
37
|
+
// through to a second SDK call.
|
|
38
|
+
function createC2CardEffectBoundary({ runtime, receiptId, chatId, turnToken,
|
|
39
|
+
generation, verifyGeneration } = {}) {
|
|
40
|
+
if (!(runtime instanceof C2DurableRuntime) || !RECEIPT_ID.test(receiptId) ||
|
|
41
|
+
!nonempty(chatId) || !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
42
|
+
!GENERATION.test(generation) || typeof verifyGeneration !== 'function' ||
|
|
43
|
+
verifyGeneration.constructor?.name === 'AsyncFunction') {
|
|
44
|
+
throw new TypeError('C2 card effect bridge needs a live turn authority')
|
|
45
|
+
}
|
|
46
|
+
return operation => runtime.performOutboundCardOperation({ receiptId, chatId,
|
|
47
|
+
turnToken, generation, verifyGeneration, operation,
|
|
48
|
+
confirm: confirmCardOperation })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { createC2CardEffectBoundary, confirmCardOperation }
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Opt-in, process-local composition for one message receipt. A streaming card's
|
|
4
|
+
// earlier operations use independent outbound effects; its final card.update
|
|
5
|
+
// belongs to the terminal finalReply effect and therefore cannot enter the
|
|
6
|
+
// outbound executor a second time.
|
|
7
|
+
const { createC2CardEffectBoundary, confirmCardOperation } = require('./c2cardeffectbridge')
|
|
8
|
+
const { prepareTerminalEffectBridge, terminalReplyText } = require('./c2terminaleffectbridge')
|
|
9
|
+
|
|
10
|
+
function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
|
|
11
|
+
receiptId, chatId, turnToken, generation, verifyGeneration,
|
|
12
|
+
applyLocalEffect, assertWriterLock, sendPlainReply = null, redactText = value => value,
|
|
13
|
+
now = Date.now } = {}) {
|
|
14
|
+
const receipt = runtime?.snapshot?.().receipts?.pending?.find(item => item.id === receiptId)
|
|
15
|
+
if (!receipt || receipt.source !== 'message' || receipt.chatId !== chatId ||
|
|
16
|
+
!replyCtx || replyCtx.chatId !== chatId ||
|
|
17
|
+
replyCtx.messageId !== receipt.sourceId ||
|
|
18
|
+
!platform || (typeof platform.createStreamingCard !== 'function' &&
|
|
19
|
+
typeof sendPlainReply !== 'function')) {
|
|
20
|
+
throw new TypeError('C2 card terminal path needs one exact message target')
|
|
21
|
+
}
|
|
22
|
+
if (sendPlainReply !== null && typeof sendPlainReply !== 'function') {
|
|
23
|
+
throw new TypeError('C2 plain reply needs one strict sender')
|
|
24
|
+
}
|
|
25
|
+
if (typeof redactText !== 'function' ||
|
|
26
|
+
redactText.constructor?.name === 'AsyncFunction') {
|
|
27
|
+
throw new TypeError('C2 card text redaction must be synchronous')
|
|
28
|
+
}
|
|
29
|
+
const targetCtx = Object.freeze({ ...replyCtx })
|
|
30
|
+
// Registration is durable before open() can expose any runner callback.
|
|
31
|
+
const terminal = prepareTerminalEffectBridge({ runtime, payloadHandles,
|
|
32
|
+
receiptId, chatId, turnToken, generation, verifyGeneration,
|
|
33
|
+
applyLocalEffect, assertWriterLock, now })
|
|
34
|
+
const operationBoundary = createC2CardEffectBoundary({ runtime, receiptId,
|
|
35
|
+
chatId, turnToken, generation, verifyGeneration })
|
|
36
|
+
let phase = 'prepared'
|
|
37
|
+
let card = null
|
|
38
|
+
let suppressedCard = false
|
|
39
|
+
const fail = error => { phase = 'closed'; runtime.close(); throw error }
|
|
40
|
+
const canUsePlain = before => {
|
|
41
|
+
if (!sendPlainReply || runtime.blocked) return false
|
|
42
|
+
const after = runtime.snapshot()
|
|
43
|
+
// The trusted platform adapter must route every card SDK call through the
|
|
44
|
+
// operation boundary. Once an outbound effect exists, even a rejected SDK
|
|
45
|
+
// response is ambiguous and a second delivery would risk a duplicate.
|
|
46
|
+
return after.cursor === before.cursor &&
|
|
47
|
+
after.effects.size === before.effects.size &&
|
|
48
|
+
[...after.effects.keys()].every(id => before.effects.has(id))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
async open({ suppressCard = false } = {}) {
|
|
53
|
+
if (phase !== 'prepared') throw new Error('C2 card terminal path already opened')
|
|
54
|
+
if (typeof suppressCard !== 'boolean') throw new TypeError('C2 card suppression must be boolean')
|
|
55
|
+
if (suppressCard) {
|
|
56
|
+
// A silent turn with no assistant output never exposed a card in v1.
|
|
57
|
+
// No platform operation or outbound effect is needed before settlement.
|
|
58
|
+
suppressedCard = true
|
|
59
|
+
phase = 'plain'
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
phase = 'opening'
|
|
63
|
+
const before = runtime.snapshot()
|
|
64
|
+
if (typeof platform.createStreamingCard !== 'function') {
|
|
65
|
+
if (!canUsePlain(before)) return fail(new Error('C2 plain reply lacks unopened card proof'))
|
|
66
|
+
phase = 'plain'
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
card = await platform.createStreamingCard(targetCtx,
|
|
71
|
+
{ operationKey: receiptId, operationBoundary })
|
|
72
|
+
if (!card && canUsePlain(before)) {
|
|
73
|
+
phase = 'plain'
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
if (!card || typeof card.update !== 'function' ||
|
|
77
|
+
typeof card.flush !== 'function' || typeof card.finish !== 'function' ||
|
|
78
|
+
typeof card.recall !== 'function') {
|
|
79
|
+
throw new Error('C2 card terminal path has no durable card')
|
|
80
|
+
}
|
|
81
|
+
phase = 'open'
|
|
82
|
+
return true
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (canUsePlain(before)) {
|
|
85
|
+
phase = 'plain'
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
return fail(error)
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
update(text) {
|
|
92
|
+
if (phase !== 'open' && phase !== 'plain') {
|
|
93
|
+
throw new Error('C2 card terminal path is not open')
|
|
94
|
+
}
|
|
95
|
+
if (suppressedCard) throw new Error('C2 suppressed card cannot stream an update')
|
|
96
|
+
if (typeof text !== 'string') throw new TypeError('C2 card update needs process-local text')
|
|
97
|
+
if (card) card.update(redactText(text))
|
|
98
|
+
},
|
|
99
|
+
async flushForTerminal() {
|
|
100
|
+
if (phase !== 'open' || !card || await card.flush() !== true) {
|
|
101
|
+
return fail(new Error('C2 stream frame flush unconfirmed'))
|
|
102
|
+
}
|
|
103
|
+
return true
|
|
104
|
+
},
|
|
105
|
+
cancellationTarget() {
|
|
106
|
+
if (!['open', 'recalled'].includes(phase) || !card ||
|
|
107
|
+
typeof card.durableTarget !== 'function') {
|
|
108
|
+
throw new Error('C2 cancellation card target unavailable')
|
|
109
|
+
}
|
|
110
|
+
const target = card.durableTarget()
|
|
111
|
+
if (target?.chatId !== chatId || !target.cardId || !target.msgId) {
|
|
112
|
+
throw new Error('C2 cancellation card target differs from turn')
|
|
113
|
+
}
|
|
114
|
+
return target
|
|
115
|
+
},
|
|
116
|
+
async recallForCancellation() {
|
|
117
|
+
if (phase === 'plain' && !card) {
|
|
118
|
+
phase = 'recalled'
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
if (phase !== 'open' || !card) {
|
|
122
|
+
throw new Error('C2 cancellation has no open streaming card')
|
|
123
|
+
}
|
|
124
|
+
phase = 'recalling'
|
|
125
|
+
try {
|
|
126
|
+
if (await card.flush() !== true || await card.recall() !== true) {
|
|
127
|
+
throw new Error('C2 cancellation card recall unconfirmed')
|
|
128
|
+
}
|
|
129
|
+
phase = 'recalled'
|
|
130
|
+
return true
|
|
131
|
+
} catch (error) { return fail(error) }
|
|
132
|
+
},
|
|
133
|
+
async complete({ payload, replyText, paused = false, footer = '' } = {}) {
|
|
134
|
+
if (phase !== 'open' && phase !== 'plain') {
|
|
135
|
+
throw new Error('C2 card terminal path is not open')
|
|
136
|
+
}
|
|
137
|
+
phase = 'completing'
|
|
138
|
+
try {
|
|
139
|
+
const terminalPayload = payload && typeof payload === 'object' &&
|
|
140
|
+
!Array.isArray(payload) ? { ...payload } : null
|
|
141
|
+
if (!terminalPayload || terminalPayload.type !== 'finalizeEffectCommit' ||
|
|
142
|
+
terminalPayload.receiptId !== receiptId || terminalPayload.turnToken !== turnToken ||
|
|
143
|
+
typeof terminalPayload.quiet !== 'boolean' || terminalPayload.quiet !== receipt.quiet ||
|
|
144
|
+
typeof terminalPayload.resultText !== 'string' ||
|
|
145
|
+
typeof terminalPayload.bufferText !== 'string' ||
|
|
146
|
+
typeof footer !== 'string') {
|
|
147
|
+
throw new TypeError('C2 card terminal payload differs from running turn')
|
|
148
|
+
}
|
|
149
|
+
if (card && await card.flush() !== true) {
|
|
150
|
+
throw new Error('C2 card prior operations lack confirmed flush')
|
|
151
|
+
}
|
|
152
|
+
// Mirrors the private reducer's silent predicate. A posted card must
|
|
153
|
+
// be durably recalled before the zero-send quiet settlement.
|
|
154
|
+
const quiet = receipt.quiet || terminalPayload.resultText.trim() === 'NO_REPLY' ||
|
|
155
|
+
(!terminalPayload.resultText.trim() && terminalPayload.bufferText.trim() === 'NO_REPLY')
|
|
156
|
+
if (suppressedCard && !quiet) {
|
|
157
|
+
throw new Error('C2 suppressed card needs a silent terminal')
|
|
158
|
+
}
|
|
159
|
+
let result
|
|
160
|
+
if (quiet) {
|
|
161
|
+
if (replyText !== undefined) {
|
|
162
|
+
throw new Error('C2 quiet card cannot carry reply text')
|
|
163
|
+
}
|
|
164
|
+
if (card && await card.recall() !== true) {
|
|
165
|
+
throw new Error('C2 quiet card recall unconfirmed')
|
|
166
|
+
}
|
|
167
|
+
result = await terminal.complete({ payload: terminalPayload, paused })
|
|
168
|
+
} else {
|
|
169
|
+
if (typeof replyText !== 'string' || !replyText ||
|
|
170
|
+
replyText !== terminalReplyText(terminalPayload)) {
|
|
171
|
+
throw new TypeError('C2 visible card needs exact process-local final text')
|
|
172
|
+
}
|
|
173
|
+
const visibleText = redactText(replyText)
|
|
174
|
+
if (typeof visibleText !== 'string' || !visibleText) {
|
|
175
|
+
throw new TypeError('C2 visible card redaction removed final text')
|
|
176
|
+
}
|
|
177
|
+
result = await terminal.complete({ payload: terminalPayload, replyText, paused,
|
|
178
|
+
resolveTarget: item => ({ receiptId: item.id, chatId: item.chatId,
|
|
179
|
+
sourceId: item.sourceId, replyCtx: targetCtx }),
|
|
180
|
+
sendReply: async (target, text) => {
|
|
181
|
+
if (!card) {
|
|
182
|
+
// The caller supplies a strict single-call sender. The normal
|
|
183
|
+
// platform.reply has a hidden reply-to-direct-send retry and
|
|
184
|
+
// is therefore not suitable for a durable final effect.
|
|
185
|
+
return sendPlainReply(target, visibleText)
|
|
186
|
+
}
|
|
187
|
+
let calls = 0
|
|
188
|
+
const finished = await card.finish(visibleText, { footer,
|
|
189
|
+
terminalOperationBoundary: async operation => {
|
|
190
|
+
if (!operation || operation.kind !== 'card.update' ||
|
|
191
|
+
operation.cardId == null || typeof operation.invoke !== 'function' ||
|
|
192
|
+
++calls !== 1) {
|
|
193
|
+
throw new Error('C2 final card needs exactly one update operation')
|
|
194
|
+
}
|
|
195
|
+
const response = await operation.invoke()
|
|
196
|
+
if (!confirmCardOperation(response, operation)) {
|
|
197
|
+
throw new Error('C2 final card update unconfirmed')
|
|
198
|
+
}
|
|
199
|
+
return response
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
return { finished, calls }
|
|
203
|
+
},
|
|
204
|
+
confirmReply: value => card
|
|
205
|
+
? value?.finished === true && value.calls === 1
|
|
206
|
+
: value?.code === 0 && typeof (value.data?.message_id || value.message_id) === 'string' &&
|
|
207
|
+
!!(value.data?.message_id || value.message_id) })
|
|
208
|
+
}
|
|
209
|
+
phase = 'complete'
|
|
210
|
+
return result
|
|
211
|
+
} catch (error) { return fail(error) }
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
module.exports = { prepareCardTerminalPath }
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
4
|
+
const { taskId } = require('./enginecheckpoint')
|
|
5
|
+
const { platformMessageEventId } = require('./c2eventidentity')
|
|
6
|
+
const { validatePersistentReceipts, validatePersistentRelation } = require('./c2persistrelation')
|
|
7
|
+
const { EngineStepCoordinator } = require('./enginestepcoordinator')
|
|
8
|
+
|
|
9
|
+
const RECEIPT_KEYS = ['id', 'source', 'chatId', 'sourceId', 'acceptedAt', 'quiet']
|
|
10
|
+
const FLAGS = ['quiet', 'polled', 'synthetic', 'wantsAck', 'resetSessionAfter',
|
|
11
|
+
'emptyRetried', 'freshRetried', 'firstResumed', 'failedOver']
|
|
12
|
+
const MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
13
|
+
const SCOPE = /^[A-Za-z0-9_-]{1,128}$/
|
|
14
|
+
|
|
15
|
+
function exactKeys(value, keys, label) {
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
17
|
+
Object.getPrototypeOf(value) !== Object.prototype ||
|
|
18
|
+
Reflect.ownKeys(value).length !== keys.length ||
|
|
19
|
+
Reflect.ownKeys(value).some(key => typeof key !== 'string' || !keys.includes(key) ||
|
|
20
|
+
!Object.getOwnPropertyDescriptor(value, key)?.enumerable ||
|
|
21
|
+
!Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) {
|
|
22
|
+
throw new TypeError(`invalid C2 ${label}`)
|
|
23
|
+
}
|
|
24
|
+
return value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Development-only first acceptance. The platform source ID must already be
|
|
28
|
+
// stable, but its receipt is still a candidate here. Identity and receipt are
|
|
29
|
+
// published only by the same fsynced queue transition; a crash before commit
|
|
30
|
+
// leaves neither accepted. The daemon's single-writer lock is required.
|
|
31
|
+
class C2DurableIngress {
|
|
32
|
+
#journal
|
|
33
|
+
#host
|
|
34
|
+
#startupBlocked
|
|
35
|
+
#gate = null
|
|
36
|
+
|
|
37
|
+
constructor(journal, { host = null } = {}) {
|
|
38
|
+
if (!journal || typeof journal.snapshot !== 'function' ||
|
|
39
|
+
typeof journal.hasEvent !== 'function' ||
|
|
40
|
+
typeof journal.appendTransition !== 'function') {
|
|
41
|
+
throw new TypeError('C2 durable ingress needs a journal')
|
|
42
|
+
}
|
|
43
|
+
this.#journal = journal
|
|
44
|
+
this.#host = host || new EngineStepCoordinator({ journal })
|
|
45
|
+
if (host && (typeof host.snapshot !== 'function' || typeof host.prepare !== 'function' ||
|
|
46
|
+
host.blocked || !isDeepStrictEqual(host.snapshot(), journal.snapshot().state))) {
|
|
47
|
+
throw new Error('C2 ingress coordinator differs from durable authority')
|
|
48
|
+
}
|
|
49
|
+
// A newly constructed adapter has no proof of prior payloads, callbacks
|
|
50
|
+
// or external effects. Only a fresh genesis may admit first acceptance;
|
|
51
|
+
// formal startup recovery must supply a separate verified reopen path.
|
|
52
|
+
const snapshot = journal.snapshot()
|
|
53
|
+
this.#startupBlocked = snapshot.cursor !== 0 ||
|
|
54
|
+
snapshot.receipts.pending.length !== 0 ||
|
|
55
|
+
Object.keys(snapshot.state.lanes).length !== 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Recovery may settle old receipts, but it cannot recreate lost payloads or
|
|
59
|
+
// runner handles. A new ingress adapter is authorized only by the opened
|
|
60
|
+
// startup gate and a completely quiescent durable snapshot.
|
|
61
|
+
static resumeAfterVerifiedRecovery({ journal, coordinator, gate } = {}) {
|
|
62
|
+
if (!journal || !coordinator || typeof coordinator.snapshot !== 'function' ||
|
|
63
|
+
coordinator.blocked ||
|
|
64
|
+
!gate || typeof gate.isClosed !== 'function' || gate.isClosed() !== false ||
|
|
65
|
+
journal.blocked) throw new Error('C2 ingress recovery gate is not verified open')
|
|
66
|
+
const snapshot = journal.snapshot()
|
|
67
|
+
if (snapshot.state?.version !== 2 || snapshot.cursor !== snapshot.state.seq ||
|
|
68
|
+
!isDeepStrictEqual(coordinator.snapshot(), snapshot.state) ||
|
|
69
|
+
!snapshot.effects || !(snapshot.effects instanceof Map) ||
|
|
70
|
+
!snapshot.notices || !(snapshot.notices instanceof Map)) {
|
|
71
|
+
throw new Error('C2 ingress differs from verified durable authority')
|
|
72
|
+
}
|
|
73
|
+
validatePersistentReceipts(snapshot.receipts)
|
|
74
|
+
validatePersistentRelation(snapshot.state, snapshot.receipts)
|
|
75
|
+
const lanes = snapshot.state.lanes
|
|
76
|
+
const unresolved = snapshot.receipts.pending.length > 0 ||
|
|
77
|
+
Object.values(lanes.queue || {}).some(lane => lane.queue.length > 0) ||
|
|
78
|
+
[...snapshot.effects].some(([effectId, status]) => status !== 'acked' &&
|
|
79
|
+
!(status === 'notStarted' && journal.runnerNotStartedProofs().some(proof =>
|
|
80
|
+
proof.dispatchEffectId === effectId && journal.hasEvent(
|
|
81
|
+
`sbot-c2-runner-not-started-commit-v1:${proof.receiptId}:${proof.turnToken}:${proof.generation}`)))) ||
|
|
82
|
+
[...snapshot.notices.values()].some(notice =>
|
|
83
|
+
notice.status === 'attempting' || notice.status === 'unknown') ||
|
|
84
|
+
Object.values(lanes.lifecycle || {}).some(lane => lane.busy || lane.controller) ||
|
|
85
|
+
Object.values(lanes.turn || {}).some(lane => lane.active !== null) ||
|
|
86
|
+
Object.values(lanes.terminal || {}).some(lane => lane.finalized) ||
|
|
87
|
+
Object.values(lanes.handoff || {}).some(lane => lane.phase !== 'idle') ||
|
|
88
|
+
Object.values(lanes.intake || {}).some(lane => lane.pending !== null) ||
|
|
89
|
+
Object.values(lanes.session || {}).some(lane => !lane.settled)
|
|
90
|
+
if (unresolved) throw new Error('C2 ingress has unresolved durable or host work')
|
|
91
|
+
const ingress = new C2DurableIngress(journal, { host: coordinator })
|
|
92
|
+
ingress.#startupBlocked = false
|
|
93
|
+
ingress.#gate = gate
|
|
94
|
+
return ingress
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get blocked() { return this.#startupBlocked || this.#host.blocked || this.#journal.blocked ||
|
|
98
|
+
(this.#gate !== null && this.#gate.isClosed() !== false) }
|
|
99
|
+
snapshot() { return this.#journal.snapshot() }
|
|
100
|
+
|
|
101
|
+
enqueueMessage({ receipt, flags, front = false } = {}) {
|
|
102
|
+
if (this.blocked) throw new Error('C2 ingress closed pending startup recovery')
|
|
103
|
+
exactKeys(receipt, RECEIPT_KEYS, 'message receipt')
|
|
104
|
+
exactKeys(flags, FLAGS, 'queue flags')
|
|
105
|
+
if (receipt.source !== 'message' || typeof receipt.sourceId !== 'string' ||
|
|
106
|
+
!MESSAGE_ID.test(receipt.sourceId) || typeof receipt.chatId !== 'string' ||
|
|
107
|
+
!SCOPE.test(receipt.chatId) || ['__proto__', 'constructor', 'prototype'].includes(receipt.chatId) ||
|
|
108
|
+
receipt.id !== taskId(receipt) ||
|
|
109
|
+
flags.quiet !== receipt.quiet || flags.synthetic !== false ||
|
|
110
|
+
typeof front !== 'boolean') {
|
|
111
|
+
throw new TypeError('C2 ingress needs a stable platform message and matching flags')
|
|
112
|
+
}
|
|
113
|
+
for (const key of FLAGS) {
|
|
114
|
+
if (typeof flags[key] !== 'boolean') throw new TypeError(`invalid C2 queue flag ${key}`)
|
|
115
|
+
}
|
|
116
|
+
validatePersistentReceipts({ pending: [receipt], completed: [] })
|
|
117
|
+
const before = this.#journal.snapshot()
|
|
118
|
+
if (before.state.version !== 2 || this.#host.snapshot().seq !== before.cursor ||
|
|
119
|
+
!isDeepStrictEqual(this.#host.snapshot(), before.state)) {
|
|
120
|
+
throw new Error('C2 ingress differs from durable authority')
|
|
121
|
+
}
|
|
122
|
+
if (before.receipts.pending.some(item => item.id === receipt.id) ||
|
|
123
|
+
before.receipts.completed.some(item => item.id === receipt.id)) {
|
|
124
|
+
throw new Error('C2 message receipt already accepted')
|
|
125
|
+
}
|
|
126
|
+
const id = platformMessageEventId({ receiptId: receipt.id, domain: 'queue',
|
|
127
|
+
kind: 'enqueue', lookupReceipt: lookupId => lookupId === receipt.id ? receipt : null })
|
|
128
|
+
if (this.#journal.hasEvent(id)) throw new Error('C2 platform event already committed')
|
|
129
|
+
const job = { id: `receipt:job:${receipt.id}` }
|
|
130
|
+
for (const key of FLAGS) job[key] = flags[key]
|
|
131
|
+
const receipts = { pending: [...before.receipts.pending, structuredClone(receipt)],
|
|
132
|
+
completed: before.receipts.completed }
|
|
133
|
+
const prepared = this.#host.prepare({ id, domain: 'queue', scope: receipt.chatId,
|
|
134
|
+
...(before.state.lanes.queue?.[receipt.chatId] ? {} : { seed: { queue: [] } }),
|
|
135
|
+
payload: { type: 'enqueue', job, front }, receipts,
|
|
136
|
+
validate: result => {
|
|
137
|
+
const lane = result.state?.lanes?.queue?.[receipt.chatId]
|
|
138
|
+
if (!lane || !Array.isArray(lane.queue) ||
|
|
139
|
+
lane.queue.filter(item => item.id === job.id).length !== 1 ||
|
|
140
|
+
result.effects.length !== 0) {
|
|
141
|
+
throw new Error('C2 ingress returned invalid queue result')
|
|
142
|
+
}
|
|
143
|
+
} })
|
|
144
|
+
return prepared.commit()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { C2DurableIngress }
|