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
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
4
|
+
const { recoverMissingPayload } = require('./c2startuprecovery')
|
|
5
|
+
const { pruneConfirmedTerminal } = require('./c2terminalprune')
|
|
6
|
+
const { validatePersistentReceipts, validatePersistentRelation } = require('./c2persistrelation')
|
|
7
|
+
|
|
8
|
+
const STARTUP_EFFECT = /^sbot-c2-effect-v1:(\d+):(sbot-c2-startup-queue-v1:.+):0$/
|
|
9
|
+
const FEISHU_REPLY_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
10
|
+
|
|
11
|
+
function validTime(value) {
|
|
12
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError('C2 startup time invalid')
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function startupEffect(effectId, journal) {
|
|
17
|
+
const match = STARTUP_EFFECT.exec(effectId)
|
|
18
|
+
return !!match && Number(match[1]) === Buffer.byteLength(match[2]) &&
|
|
19
|
+
journal.hasEvent(match[2])
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function startupEffectForScope(effectId, journal, scope) {
|
|
23
|
+
const match = STARTUP_EFFECT.exec(effectId)
|
|
24
|
+
return !!match && startupEffect(effectId, journal) &&
|
|
25
|
+
match[2].startsWith(`sbot-c2-startup-queue-v1:${scope}:`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function siblingProjection(snapshot, scope, journal, ownIds) {
|
|
29
|
+
const lanes = {}
|
|
30
|
+
for (const [domain, byScope] of Object.entries(snapshot.state.lanes)) {
|
|
31
|
+
lanes[domain] = Object.fromEntries(Object.entries(byScope)
|
|
32
|
+
.filter(([chatId]) => chatId !== scope))
|
|
33
|
+
}
|
|
34
|
+
const siblingReceipts = {
|
|
35
|
+
pending: snapshot.receipts.pending.filter(item => item.chatId !== scope),
|
|
36
|
+
completed: snapshot.receipts.completed.filter(item => !ownIds.has(item.id)),
|
|
37
|
+
}
|
|
38
|
+
const effects = new Map([...snapshot.effects].filter(([id]) =>
|
|
39
|
+
journal.effectDescriptor(id)?.scope !== scope &&
|
|
40
|
+
!startupEffectForScope(id, journal, scope)))
|
|
41
|
+
const notices = new Map([...snapshot.notices].filter(([id]) => !ownIds.has(id)))
|
|
42
|
+
return { lanes, receipts: siblingReceipts, effects, notices }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertSiblingsUnchanged(before, after, scope, journal) {
|
|
46
|
+
const ownIds = new Set(before.receipts.pending.filter(item => item.chatId === scope)
|
|
47
|
+
.map(item => item.id))
|
|
48
|
+
if (!isDeepStrictEqual(siblingProjection(before, scope, journal, ownIds),
|
|
49
|
+
siblingProjection(after, scope, journal, ownIds))) {
|
|
50
|
+
throw new Error('C2 scoped recovery changed sibling durable state')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assertAuthority(journal, coordinator) {
|
|
55
|
+
const snapshot = journal.snapshot()
|
|
56
|
+
validatePersistentReceipts(snapshot.receipts)
|
|
57
|
+
validatePersistentRelation(snapshot.state, snapshot.receipts)
|
|
58
|
+
if (snapshot.cursor !== snapshot.state.seq ||
|
|
59
|
+
!isDeepStrictEqual(coordinator.snapshot(), snapshot.state)) {
|
|
60
|
+
throw new Error('C2 startup state differs from durable authority')
|
|
61
|
+
}
|
|
62
|
+
return snapshot
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unstartedEventId(proof) {
|
|
66
|
+
return `sbot-c2-runner-not-started-commit-v1:${proof.receiptId}:${proof.turnToken}:${proof.generation}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function provedNotStartedEffect(effectId, journal) {
|
|
70
|
+
if (journal.effectStatus(effectId) !== 'notStarted') return false
|
|
71
|
+
const proofs = journal.runnerNotStartedProofs().filter(item =>
|
|
72
|
+
item.dispatchEffectId === effectId)
|
|
73
|
+
return proofs.length === 1 && journal.hasEvent(unstartedEventId(proofs[0]))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resumeUnstartedDispatch(journal, coordinator, scope, assertLease) {
|
|
77
|
+
for (const proof of journal.runnerNotStartedProofs()) {
|
|
78
|
+
if (scope !== null && proof.chatId !== scope) continue
|
|
79
|
+
const eventId = unstartedEventId(proof)
|
|
80
|
+
if (journal.hasEvent(eventId)) continue
|
|
81
|
+
assertLease()
|
|
82
|
+
const before = assertAuthority(journal, coordinator)
|
|
83
|
+
const descriptor = journal.effectDescriptor(proof.dispatchEffectId)
|
|
84
|
+
if (journal.effectStatus(proof.dispatchEffectId) !== 'notStarted' ||
|
|
85
|
+
descriptor?.domain !== 'atomicHandoff' || descriptor.type !== 'dispatch' ||
|
|
86
|
+
descriptor.scope !== proof.chatId || descriptor.receiptId !== proof.receiptId ||
|
|
87
|
+
descriptor.turnToken !== proof.turnToken ||
|
|
88
|
+
!before.receipts.pending.some(item => item.id === proof.receiptId &&
|
|
89
|
+
item.chatId === proof.chatId) ||
|
|
90
|
+
before.state.lanes.turn?.[proof.chatId]?.active !== proof.turnToken ||
|
|
91
|
+
before.state.lanes.handoff?.[proof.chatId]?.phase !== 'running') {
|
|
92
|
+
throw new Error('C2 startup not-started proof differs from active dispatch')
|
|
93
|
+
}
|
|
94
|
+
const result = coordinator.transition({ id: eventId,
|
|
95
|
+
domain: 'runnerStartFailure', scope: proof.chatId,
|
|
96
|
+
payload: { type: 'abortUnstartedDispatch', receiptId: proof.receiptId,
|
|
97
|
+
turnToken: proof.turnToken, runnerGeneration: proof.generation,
|
|
98
|
+
dispatchEffectId: proof.dispatchEffectId,
|
|
99
|
+
notStartedEvidenceId: proof.evidenceId },
|
|
100
|
+
validate: candidate => {
|
|
101
|
+
if (candidate.effects.length ||
|
|
102
|
+
!isDeepStrictEqual(candidate.decision,
|
|
103
|
+
{ abandonedReceiptId: proof.receiptId,
|
|
104
|
+
requiresStartupRecovery: true, scope: proof.chatId,
|
|
105
|
+
dispatchEffectId: proof.dispatchEffectId })) {
|
|
106
|
+
throw new Error('C2 startup not-started decision invalid')
|
|
107
|
+
}
|
|
108
|
+
} })
|
|
109
|
+
const after = assertAuthority(journal, coordinator)
|
|
110
|
+
if (result.decision.requiresStartupRecovery !== true ||
|
|
111
|
+
!isDeepStrictEqual(before.receipts, after.receipts) ||
|
|
112
|
+
after.state.lanes.turn?.[proof.chatId]?.active !== null ||
|
|
113
|
+
after.state.lanes.handoff?.[proof.chatId]?.phase !== 'idle') {
|
|
114
|
+
throw new Error('C2 startup not-started transition changed receipts')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// A process restart has no runner, controller, body or scheduled handoff
|
|
120
|
+
// capability. Even an atomically settled terminal receipt cannot make an old
|
|
121
|
+
// running turn safe to resume or let its successor begin automatically.
|
|
122
|
+
function assertNoUnrestorableRun(snapshot, scope = null) {
|
|
123
|
+
const scoped = byScope => scope === null ? Object.values(byScope || {}) :
|
|
124
|
+
Object.hasOwn(byScope || {}, scope) ? [byScope[scope]] : []
|
|
125
|
+
if (scoped(snapshot.state.lanes.stopIntent).length ||
|
|
126
|
+
scoped(snapshot.state.lanes.turn).some(lane => lane.active !== null) ||
|
|
127
|
+
scoped(snapshot.state.lanes.handoff).some(lane => lane.phase !== 'idle') ||
|
|
128
|
+
scoped(snapshot.state.lanes.lifecycle).some(lane =>
|
|
129
|
+
lane.busy === true || lane.controller === true)) {
|
|
130
|
+
throw new Error('C2 startup has unrecoverable running turn or handoff')
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The current journal settles one receipt per confirmed notice. Until it can
|
|
135
|
+
// atomically settle several receipts from one group, a restart may send at
|
|
136
|
+
// most one nonquiet notification per group. Ambiguous siblings stay sealed.
|
|
137
|
+
function assertUniqueGroupNotices(snapshot, scope = null) {
|
|
138
|
+
const seen = new Set()
|
|
139
|
+
for (const receipt of snapshot.receipts.pending) {
|
|
140
|
+
if (scope !== null && receipt.chatId !== scope) continue
|
|
141
|
+
if (receipt.quiet) continue
|
|
142
|
+
if (seen.has(receipt.chatId)) {
|
|
143
|
+
throw new Error('C2 startup has multiple visible receipts in one group')
|
|
144
|
+
}
|
|
145
|
+
seen.add(receipt.chatId)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Read-only precondition for any future per-chat startup recovery. This does
|
|
150
|
+
// not open the global gate, send notices, settle receipts or change a sibling.
|
|
151
|
+
// A scoped writer must supply a separate queue interruption and notice path.
|
|
152
|
+
function assessScopedRecovery(journal, coordinator, chatId) {
|
|
153
|
+
if (typeof chatId !== 'string' || !chatId ||
|
|
154
|
+
!journal || typeof journal.snapshot !== 'function' ||
|
|
155
|
+
typeof coordinator?.snapshot !== 'function') {
|
|
156
|
+
throw new TypeError('C2 scoped recovery needs chat ID and durable authority')
|
|
157
|
+
}
|
|
158
|
+
const snapshot = assertAuthority(journal, coordinator)
|
|
159
|
+
const lanes = snapshot.state.lanes
|
|
160
|
+
const own = snapshot.receipts.pending.filter(item => item.chatId === chatId)
|
|
161
|
+
const visible = own.filter(item => !item.quiet)
|
|
162
|
+
if (visible.length > 1) throw new Error('C2 scoped recovery has multiple visible receipts')
|
|
163
|
+
if (lanes.turn?.[chatId]?.active != null ||
|
|
164
|
+
lanes.handoff?.[chatId]?.phase && lanes.handoff[chatId].phase !== 'idle' ||
|
|
165
|
+
lanes.lifecycle?.[chatId]?.busy === true ||
|
|
166
|
+
lanes.lifecycle?.[chatId]?.controller === true) {
|
|
167
|
+
throw new Error('C2 scoped recovery has unrecoverable target turn')
|
|
168
|
+
}
|
|
169
|
+
// Unknown external outcomes anywhere in the same journal keep the writer
|
|
170
|
+
// sealed. In particular, a scoped notification cannot safely infer whether
|
|
171
|
+
// another chat has already emitted a reply after an ACK was lost.
|
|
172
|
+
if ([...snapshot.effects.values()].some(status => status !== 'acked') ||
|
|
173
|
+
[...snapshot.notices.values()].some(notice =>
|
|
174
|
+
notice.status === 'attempting' || notice.status === 'unknown')) {
|
|
175
|
+
throw new Error('C2 scoped recovery has unresolved external outcome')
|
|
176
|
+
}
|
|
177
|
+
if (lanes.queue?.[chatId]?.queue?.length) {
|
|
178
|
+
throw new Error('C2 scoped recovery needs scoped queue interruption')
|
|
179
|
+
}
|
|
180
|
+
return Object.freeze({ chatId, pendingReceiptCount: own.length,
|
|
181
|
+
siblingPendingReceiptCount: snapshot.receipts.pending.length - own.length,
|
|
182
|
+
cursor: snapshot.cursor, canOpen: false,
|
|
183
|
+
reason: 'scoped-ingress-and-settlement-unavailable' })
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Development-only startup gate. The caller owns the daemon single-writer
|
|
187
|
+
// lock and a platform sender that returns a confirmed reply ID. A begun notice
|
|
188
|
+
// whose outcome is lost remains ambiguous on disk and is never retried here.
|
|
189
|
+
async function recoverAndOpen({ gate, journal, coordinator, resolveTarget, send,
|
|
190
|
+
now = Date.now, assertWriterLock = null, singleNoticePerGroup = false,
|
|
191
|
+
scope = null } = {}) {
|
|
192
|
+
if (!gate || typeof gate.close !== 'function' || typeof gate.open !== 'function' ||
|
|
193
|
+
typeof gate.isClosed !== 'function' || !journal ||
|
|
194
|
+
!['snapshot', 'hasEvent', 'effectStatus', 'effectDescriptor',
|
|
195
|
+
'runnerNotStartedProofs', 'terminalSettlement',
|
|
196
|
+
'noticeStatus', 'beginNotice',
|
|
197
|
+
'recordNoticeOutcome', 'settleQuietReceipt', 'recordEffect'].every(name =>
|
|
198
|
+
typeof journal[name] === 'function') ||
|
|
199
|
+
!coordinator || typeof coordinator.snapshot !== 'function' ||
|
|
200
|
+
typeof coordinator.transition !== 'function' ||
|
|
201
|
+
typeof resolveTarget !== 'function' || typeof send !== 'function' ||
|
|
202
|
+
typeof now !== 'function' ||
|
|
203
|
+
(assertWriterLock !== null && typeof assertWriterLock !== 'function') ||
|
|
204
|
+
typeof singleNoticePerGroup !== 'boolean') {
|
|
205
|
+
throw new TypeError('C2 recovery gate needs durable authority and confirmed sender')
|
|
206
|
+
}
|
|
207
|
+
if (scope !== null && (typeof scope !== 'string' ||
|
|
208
|
+
!/^[A-Za-z0-9_-]{1,128}$/.test(scope) || gate.scope !== scope ||
|
|
209
|
+
typeof assertWriterLock !== 'function')) {
|
|
210
|
+
throw new TypeError('C2 scoped recovery needs matching scoped gate and writer lock')
|
|
211
|
+
}
|
|
212
|
+
const assertLease = () => {
|
|
213
|
+
if (assertWriterLock && assertWriterLock() !== true) {
|
|
214
|
+
throw new Error('C2 startup writer lock lost')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
gate.close()
|
|
218
|
+
if (gate.isClosed() !== true) throw new Error('C2 startup ingress is not closed')
|
|
219
|
+
|
|
220
|
+
assertLease()
|
|
221
|
+
resumeUnstartedDispatch(journal, coordinator, scope, assertLease)
|
|
222
|
+
const beforeRecovery = assertAuthority(journal, coordinator)
|
|
223
|
+
assertNoUnrestorableRun(beforeRecovery, scope)
|
|
224
|
+
if (singleNoticePerGroup) assertUniqueGroupNotices(beforeRecovery, scope)
|
|
225
|
+
if (scope !== null && ([...beforeRecovery.effects].some(([effectId, status]) =>
|
|
226
|
+
status !== 'acked' && !provedNotStartedEffect(effectId, journal) &&
|
|
227
|
+
(status !== 'pending' || !startupEffectForScope(effectId, journal, scope))) ||
|
|
228
|
+
[...beforeRecovery.notices.values()].some(notice =>
|
|
229
|
+
notice.status === 'attempting' || notice.status === 'unknown'))) {
|
|
230
|
+
throw new Error('C2 scoped recovery has unresolved external outcome')
|
|
231
|
+
}
|
|
232
|
+
assertLease()
|
|
233
|
+
recoverMissingPayload({ gate, journal, coordinator, scope })
|
|
234
|
+
const initial = assertAuthority(journal, coordinator)
|
|
235
|
+
if (scope !== null) assertSiblingsUnchanged(beforeRecovery, initial, scope, journal)
|
|
236
|
+
assertNoUnrestorableRun(initial, scope)
|
|
237
|
+
if (singleNoticePerGroup) assertUniqueGroupNotices(initial, scope)
|
|
238
|
+
assertLease()
|
|
239
|
+
const pending = initial.receipts.pending.filter(item => scope === null || item.chatId === scope)
|
|
240
|
+
.sort((a, b) =>
|
|
241
|
+
a.chatId.localeCompare(b.chatId) || a.id.localeCompare(b.id))
|
|
242
|
+
// Prove that all receipts can be completed without a queue or handoff owner.
|
|
243
|
+
// Resolve every external target before the first durable notice intent.
|
|
244
|
+
const hypothetical = { pending: initial.receipts.pending.filter(item =>
|
|
245
|
+
scope !== null && item.chatId !== scope), completed: [
|
|
246
|
+
...initial.receipts.completed,
|
|
247
|
+
...pending.map(item => ({ id: item.id, settledAt: validTime(now()) })),
|
|
248
|
+
] }
|
|
249
|
+
validatePersistentRelation(initial.state, hypothetical)
|
|
250
|
+
const completed = new Map(initial.receipts.completed.map(item => [item.id, item.settledAt]))
|
|
251
|
+
const terminalFinalIds = new Set(Object.values(initial.state.lanes.terminal || {})
|
|
252
|
+
.filter(lane => lane.finalized).map(lane => lane.pendingEffectIds.at(-1)))
|
|
253
|
+
for (const [effectId, status] of initial.effects) {
|
|
254
|
+
const descriptor = journal.effectDescriptor(effectId)
|
|
255
|
+
if (status === 'acked' && (terminalFinalIds.has(effectId) ||
|
|
256
|
+
descriptor?.domain === 'terminal' &&
|
|
257
|
+
['finalReply', 'quietCompletion'].includes(descriptor.type))) {
|
|
258
|
+
const settlement = journal.terminalSettlement(effectId)
|
|
259
|
+
if (!descriptor || !settlement || settlement.effectId !== effectId ||
|
|
260
|
+
settlement.receiptId !== descriptor.receiptId ||
|
|
261
|
+
settlement.chatId !== descriptor.scope ||
|
|
262
|
+
settlement.turnToken !== descriptor.turnToken ||
|
|
263
|
+
completed.get(descriptor.receiptId) !== settlement.settledAt) {
|
|
264
|
+
throw new Error('C2 terminal effect ACK lacks atomic receipt settlement')
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (status !== 'acked' && !provedNotStartedEffect(effectId, journal) &&
|
|
268
|
+
(status !== 'pending' || !startupEffect(effectId, journal) ||
|
|
269
|
+
scope !== null && !startupEffectForScope(effectId, journal, scope))) {
|
|
270
|
+
throw new Error('C2 startup has unresolved external effect')
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// A terminal ACK and its receipt may already have shared one fsynced record
|
|
274
|
+
// when the process died before the separate, idempotent lane prune. Keep
|
|
275
|
+
// ingress closed until each such lane is durably removed. No external send
|
|
276
|
+
// or receipt settlement is needed for this cleanup.
|
|
277
|
+
for (const terminalScope of (scope === null
|
|
278
|
+
? Object.keys(initial.state.lanes.terminal || {}).sort()
|
|
279
|
+
: Object.hasOwn(initial.state.lanes.terminal || {}, scope) ? [scope] : [])) {
|
|
280
|
+
assertLease()
|
|
281
|
+
const lane = initial.state.lanes.terminal[terminalScope]
|
|
282
|
+
const finalEffectId = lane.pendingEffectIds?.at(-1)
|
|
283
|
+
if (!lane.finalized || !finalEffectId ||
|
|
284
|
+
!journal.terminalSettlement(finalEffectId)) {
|
|
285
|
+
throw new Error('C2 startup terminal lane lacks atomic settlement proof')
|
|
286
|
+
}
|
|
287
|
+
pruneConfirmedTerminal({ journal, coordinator, scope: terminalScope })
|
|
288
|
+
}
|
|
289
|
+
if (scope !== null) assertSiblingsUnchanged(initial,
|
|
290
|
+
assertAuthority(journal, coordinator), scope, journal)
|
|
291
|
+
const targets = new Map()
|
|
292
|
+
for (const receipt of pending) {
|
|
293
|
+
assertLease()
|
|
294
|
+
if (receipt.quiet) continue
|
|
295
|
+
if (journal.noticeStatus(receipt.id) !== null) {
|
|
296
|
+
throw new Error('C2 startup notice attempt is ambiguous')
|
|
297
|
+
}
|
|
298
|
+
const target = await resolveTarget({ id: receipt.id, chatId: receipt.chatId,
|
|
299
|
+
source: receipt.source, sourceId: receipt.sourceId })
|
|
300
|
+
assertLease()
|
|
301
|
+
const afterTarget = assertAuthority(journal, coordinator)
|
|
302
|
+
if (scope !== null) assertSiblingsUnchanged(initial, afterTarget, scope, journal)
|
|
303
|
+
if (!target || typeof target !== 'object' || Array.isArray(target) ||
|
|
304
|
+
target.receiptId !== receipt.id || target.chatId !== receipt.chatId ||
|
|
305
|
+
target.sourceId !== receipt.sourceId || !Object.hasOwn(target, 'replyCtx') ||
|
|
306
|
+
target.replyCtx == null) {
|
|
307
|
+
throw new Error('C2 startup notification target is unverified')
|
|
308
|
+
}
|
|
309
|
+
targets.set(receipt.id, target)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const settled = []
|
|
313
|
+
for (const receipt of pending) {
|
|
314
|
+
assertLease()
|
|
315
|
+
if (!gate.isClosed()) throw new Error('C2 startup ingress opened during recovery')
|
|
316
|
+
if (receipt.quiet) {
|
|
317
|
+
journal.settleQuietReceipt({ receiptId: receipt.id, chatId: receipt.chatId,
|
|
318
|
+
settledAt: validTime(now()) })
|
|
319
|
+
} else {
|
|
320
|
+
const attemptedAt = validTime(now())
|
|
321
|
+
journal.beginNotice({ receiptId: receipt.id, chatId: receipt.chatId, attemptedAt })
|
|
322
|
+
assertLease()
|
|
323
|
+
let replyId
|
|
324
|
+
try { replyId = await send({ id: receipt.id, chatId: receipt.chatId,
|
|
325
|
+
source: receipt.source, sourceId: receipt.sourceId }, targets.get(receipt.id)) }
|
|
326
|
+
catch { replyId = null }
|
|
327
|
+
// If the lease was lost during the SDK call, keep the fsynced attempt
|
|
328
|
+
// ambiguous. Another process must not treat its outcome as replayable.
|
|
329
|
+
assertLease()
|
|
330
|
+
const at = Math.max(attemptedAt, validTime(now()))
|
|
331
|
+
if (typeof replyId !== 'string' || !FEISHU_REPLY_ID.test(replyId)) {
|
|
332
|
+
journal.recordNoticeOutcome({ receiptId: receipt.id, chatId: receipt.chatId,
|
|
333
|
+
status: 'unknown', resolvedAt: at })
|
|
334
|
+
throw new Error('C2 startup notification outcome is unknown')
|
|
335
|
+
}
|
|
336
|
+
journal.recordNoticeOutcome({ receiptId: receipt.id, chatId: receipt.chatId,
|
|
337
|
+
status: 'acked', settledAt: at })
|
|
338
|
+
}
|
|
339
|
+
const after = assertAuthority(journal, coordinator)
|
|
340
|
+
if (scope !== null) assertSiblingsUnchanged(initial, after, scope, journal)
|
|
341
|
+
if (after.receipts.pending.some(item => item.id === receipt.id) ||
|
|
342
|
+
!after.receipts.completed.some(item => item.id === receipt.id)) {
|
|
343
|
+
throw new Error('C2 startup receipt was not durably settled')
|
|
344
|
+
}
|
|
345
|
+
settled.push(receipt.id)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The interruptMissingPayload effect is an internal recovery instruction.
|
|
349
|
+
// It is complete only after all affected receipts have been settled.
|
|
350
|
+
const beforeOpen = assertAuthority(journal, coordinator)
|
|
351
|
+
for (const [effectId, status] of beforeOpen.effects) {
|
|
352
|
+
assertLease()
|
|
353
|
+
if (status === 'pending' && startupEffect(effectId, journal) &&
|
|
354
|
+
(scope === null || startupEffectForScope(effectId, journal, scope))) {
|
|
355
|
+
journal.recordEffect(effectId, 'acked')
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const final = assertAuthority(journal, coordinator)
|
|
359
|
+
if (scope !== null) assertSiblingsUnchanged(initial, final, scope, journal)
|
|
360
|
+
if (final.receipts.pending.some(item => scope === null || item.chatId === scope) ||
|
|
361
|
+
(scope === null
|
|
362
|
+
? Object.values(final.state.lanes.queue || {}).some(lane => lane.queue.length)
|
|
363
|
+
: (final.state.lanes.queue?.[scope]?.queue?.length || 0) > 0) ||
|
|
364
|
+
(scope === null
|
|
365
|
+
? Object.keys(final.state.lanes.terminal || {}).length > 0
|
|
366
|
+
: Object.hasOwn(final.state.lanes.terminal || {}, scope)) ||
|
|
367
|
+
[...final.effects].some(([effectId, status]) =>
|
|
368
|
+
status !== 'acked' && !provedNotStartedEffect(effectId, journal)) ||
|
|
369
|
+
[...final.notices.values()].some(notice =>
|
|
370
|
+
notice.status === 'attempting' || notice.status === 'unknown')) {
|
|
371
|
+
throw new Error('C2 startup has unresolved durable work')
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
assertLease()
|
|
375
|
+
gate.open()
|
|
376
|
+
if (gate.isClosed() !== false) throw new Error('C2 startup ingress did not open')
|
|
377
|
+
assertLease()
|
|
378
|
+
} catch (error) {
|
|
379
|
+
gate.close()
|
|
380
|
+
throw error
|
|
381
|
+
}
|
|
382
|
+
return { settledReceiptIds: settled, ingressClosed: false }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = { recoverAndOpen, assessScopedRecovery }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Assemble the same user-facing runner inputs as Engine._runTurn after the
|
|
4
|
+
// host has prepared its timestamped prompt and manifest. This only assembles
|
|
5
|
+
// process-local arguments. It neither starts a child nor persists text,
|
|
6
|
+
// credentials, callback handles, or environment values.
|
|
7
|
+
function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider,
|
|
8
|
+
config, sessionId = null, signal, extraEnv, onTurnAccepted, onSpawnFailed, onDurableEvent,
|
|
9
|
+
onEvent = () => {}, onSteerReady, onSteerLanded, log = () => {} } = {}) {
|
|
10
|
+
if (typeof prompt !== 'string' || !prompt ||
|
|
11
|
+
typeof appendSystemPrompt !== 'string' || !appendSystemPrompt ||
|
|
12
|
+
!msg || typeof msg.chatId !== 'string' || !msg.chatId ||
|
|
13
|
+
!group || typeof group.workdir !== 'string' || !group.workdir ||
|
|
14
|
+
!provider || !['claude', 'codex'].includes(provider.runner) ||
|
|
15
|
+
!config || !config.agent || typeof config.agent !== 'object' ||
|
|
16
|
+
(sessionId !== null && (typeof sessionId !== 'string' || !sessionId)) ||
|
|
17
|
+
!signal || typeof signal.aborted !== 'boolean' ||
|
|
18
|
+
!extraEnv || typeof extraEnv !== 'object' || Array.isArray(extraEnv) ||
|
|
19
|
+
extraEnv.SBOT_CHAT_ID !== msg.chatId ||
|
|
20
|
+
Object.values(extraEnv).some(value => typeof value !== 'string') ||
|
|
21
|
+
typeof onTurnAccepted !== 'function' ||
|
|
22
|
+
(onSpawnFailed !== undefined && typeof onSpawnFailed !== 'function') ||
|
|
23
|
+
typeof onDurableEvent !== 'function' ||
|
|
24
|
+
typeof onEvent !== 'function' || typeof log !== 'function') {
|
|
25
|
+
throw new TypeError('C2 runner input requires an exact process-local turn')
|
|
26
|
+
}
|
|
27
|
+
const isCodex = provider.runner === 'codex'
|
|
28
|
+
const codexSteer = isCodex && group.codexSteer !== false
|
|
29
|
+
if ((onSteerReady !== undefined && typeof onSteerReady !== 'function') ||
|
|
30
|
+
(onSteerLanded !== undefined && typeof onSteerLanded !== 'function') ||
|
|
31
|
+
(codexSteer && (typeof onSteerReady !== 'function' ||
|
|
32
|
+
typeof onSteerLanded !== 'function'))) {
|
|
33
|
+
throw new TypeError('C2 Codex follow-up needs durable control callbacks')
|
|
34
|
+
}
|
|
35
|
+
const rawCompact = Number(config.agent.autocompactTokens) || 0
|
|
36
|
+
const autocompactTokens = rawCompact > 0
|
|
37
|
+
? Math.min(1_000_000, Math.max(100_000, Math.floor(rawCompact))) : 0
|
|
38
|
+
return {
|
|
39
|
+
bin: config.agent.bin,
|
|
40
|
+
prompt, appendSystemPrompt, provider,
|
|
41
|
+
codexApp: isCodex && group.codexApp === true,
|
|
42
|
+
codexSteer,
|
|
43
|
+
codexProjectDocFallback: isCodex && group.codexProjectDocFallback === true,
|
|
44
|
+
onSteerReady, onSteerLanded,
|
|
45
|
+
model: group.model,
|
|
46
|
+
workdir: group.workdir,
|
|
47
|
+
sessionId,
|
|
48
|
+
permissionMode: group.permissionMode || 'dontAsk',
|
|
49
|
+
autocompactTokens,
|
|
50
|
+
allowedTools: group.allowedTools,
|
|
51
|
+
disallowedTools: group.disallowedTools,
|
|
52
|
+
plugins: group.plugins,
|
|
53
|
+
signal,
|
|
54
|
+
extraEnv: { ...extraEnv },
|
|
55
|
+
log,
|
|
56
|
+
onEvent,
|
|
57
|
+
onDurableEvent,
|
|
58
|
+
onTurnAccepted,
|
|
59
|
+
onSpawnFailed,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { prepareC2RunnerInput }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { C2DurableJournal } = require('./c2durablejournal')
|
|
4
|
+
const { C2DurableRuntime } = require('./c2durableruntime')
|
|
5
|
+
|
|
6
|
+
// The daemon supplies its single-writer lock and installs `accept` as its
|
|
7
|
+
// ingress callback before calling start(). This opt-in bridge never installs
|
|
8
|
+
// a route or starts a service by itself. In particular, journal recovery may
|
|
9
|
+
// await a platform notice while the callback remains closed.
|
|
10
|
+
function createC2ServiceStartGate({ enabled = false, journal, assertWriterLock,
|
|
11
|
+
resolveTarget, send, now, maxEventCount, maxJournalBytes,
|
|
12
|
+
maxCompletionEvents, maxCompletionBytes, singleNoticePerGroup = false } = {}) {
|
|
13
|
+
if (enabled !== true || !(journal instanceof C2DurableJournal) ||
|
|
14
|
+
typeof assertWriterLock !== 'function' ||
|
|
15
|
+
assertWriterLock.constructor?.name === 'AsyncFunction' ||
|
|
16
|
+
typeof resolveTarget !== 'function' || typeof send !== 'function' ||
|
|
17
|
+
typeof singleNoticePerGroup !== 'boolean') {
|
|
18
|
+
throw new TypeError('C2 service start requires explicit durable authority and writer lock')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const runtime = new C2DurableRuntime({ journal,
|
|
22
|
+
resolveTarget: (...args) => {
|
|
23
|
+
assertHeld()
|
|
24
|
+
return resolveTarget(...args)
|
|
25
|
+
},
|
|
26
|
+
send: async (...args) => {
|
|
27
|
+
assertHeld()
|
|
28
|
+
const result = await send(...args)
|
|
29
|
+
assertHeld()
|
|
30
|
+
return result
|
|
31
|
+
}, now,
|
|
32
|
+
maxEventCount, maxJournalBytes, maxCompletionEvents, maxCompletionBytes,
|
|
33
|
+
assertWriterLock, singleNoticePerGroup })
|
|
34
|
+
let started = false
|
|
35
|
+
let starting = false
|
|
36
|
+
|
|
37
|
+
function assertHeld() {
|
|
38
|
+
if (assertWriterLock() !== true) {
|
|
39
|
+
throw new Error('C2 service writer lock is not held')
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function accept(options) {
|
|
44
|
+
if (!started || starting || runtime.blocked) {
|
|
45
|
+
throw new Error('C2 service ingress is closed')
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
assertHeld()
|
|
49
|
+
return runtime.enqueueMessage(options)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
runtime.close()
|
|
52
|
+
started = false
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function start() {
|
|
58
|
+
if (starting) throw new Error('C2 service startup already in progress')
|
|
59
|
+
if (started) {
|
|
60
|
+
try { assertHeld() } catch (error) {
|
|
61
|
+
stop()
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
if (runtime.blocked) throw new Error('C2 service authority closed')
|
|
65
|
+
return runtime.snapshot()
|
|
66
|
+
}
|
|
67
|
+
starting = true
|
|
68
|
+
try {
|
|
69
|
+
assertHeld()
|
|
70
|
+
const snapshot = await runtime.open()
|
|
71
|
+
// A lock lost during an awaited notice must not expose the reopened
|
|
72
|
+
// runtime to the platform callback.
|
|
73
|
+
assertHeld()
|
|
74
|
+
if (runtime.blocked) throw new Error('C2 service authority remained closed')
|
|
75
|
+
started = true
|
|
76
|
+
return snapshot
|
|
77
|
+
} catch (error) {
|
|
78
|
+
runtime.close()
|
|
79
|
+
started = false
|
|
80
|
+
throw error
|
|
81
|
+
} finally { starting = false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stop() {
|
|
85
|
+
started = false
|
|
86
|
+
runtime.close()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { start, stop, accept,
|
|
90
|
+
get blocked() { return !started || starting || runtime.blocked },
|
|
91
|
+
// An Engine integration may use the authority only after start() proves
|
|
92
|
+
// recovery and the writer lock. It cannot bypass the closed ingress gate.
|
|
93
|
+
get runtime() {
|
|
94
|
+
if (!started || starting || runtime.blocked) return null
|
|
95
|
+
try { assertHeld() } catch (error) {
|
|
96
|
+
stop()
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
return runtime
|
|
100
|
+
} }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { createC2ServiceStartGate }
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Pure preparation for the v2 runner-close session transition. The caller
|
|
4
|
+
// must use its existing single-writer coordinator to commit the returned
|
|
5
|
+
// event, then fsync an effect attempt before touching Store. This helper has
|
|
6
|
+
// no access to the model session ID after it computes the presence bit.
|
|
7
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
8
|
+
|
|
9
|
+
const HEX32 = /^[0-9a-f]{32}$/
|
|
10
|
+
|
|
11
|
+
function prepareClosedSessionEvent({ journal, receiptId, chatId, turnToken,
|
|
12
|
+
generation, sessionId = null, aborted, verifyGeneration } = {}) {
|
|
13
|
+
if (!journal || typeof journal.snapshot !== 'function' ||
|
|
14
|
+
typeof journal.lookupRunnerClosed !== 'function' ||
|
|
15
|
+
typeof journal.effectDescriptor !== 'function' ||
|
|
16
|
+
typeof journal.effectStatus !== 'function' ||
|
|
17
|
+
!HEX32.test(receiptId || '') || !HEX32.test(generation || '') ||
|
|
18
|
+
typeof chatId !== 'string' || !chatId ||
|
|
19
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
20
|
+
(sessionId !== null && (typeof sessionId !== 'string' || !sessionId)) ||
|
|
21
|
+
typeof aborted !== 'boolean' || typeof verifyGeneration !== 'function' ||
|
|
22
|
+
verifyGeneration.constructor?.name === 'AsyncFunction') {
|
|
23
|
+
throw new TypeError('C2 closed session needs exact runner identity')
|
|
24
|
+
}
|
|
25
|
+
const before = journal.snapshot()
|
|
26
|
+
const evidenceId = `sbot-c2-runner-closed-v1:${receiptId}:${generation}`
|
|
27
|
+
const proof = journal.lookupRunnerClosed(evidenceId)
|
|
28
|
+
const dispatch = proof && journal.effectDescriptor(proof.dispatchEffectId)
|
|
29
|
+
const pending = before.receipts?.pending?.find(item => item.id === receiptId)
|
|
30
|
+
const gate = before.state?.lanes?.turn?.[chatId]
|
|
31
|
+
const handoff = before.state?.lanes?.handoff?.[chatId]
|
|
32
|
+
const session = before.state?.lanes?.session?.[chatId]
|
|
33
|
+
if (journal.blocked || before.state?.version !== 2 ||
|
|
34
|
+
pending?.chatId !== chatId || proof?.receiptId !== receiptId ||
|
|
35
|
+
proof.chatId !== chatId || proof.turnToken !== turnToken ||
|
|
36
|
+
proof.generation !== generation || proof.evidenceId !== evidenceId ||
|
|
37
|
+
dispatch?.type !== 'dispatch' || dispatch.scope !== chatId ||
|
|
38
|
+
dispatch.receiptId !== receiptId || dispatch.turnToken !== turnToken ||
|
|
39
|
+
journal.effectStatus(proof.dispatchEffectId) !== 'acked' ||
|
|
40
|
+
gate?.active !== turnToken || gate.epoch !== turnToken ||
|
|
41
|
+
handoff?.phase !== 'running' || handoff.turnToken !== turnToken ||
|
|
42
|
+
(session && (session.epoch > turnToken ||
|
|
43
|
+
session.epoch === turnToken && session.closeSeen === true)) ||
|
|
44
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
|
|
45
|
+
throw new Error('C2 closed session lacks accepted running runner proof')
|
|
46
|
+
}
|
|
47
|
+
const eventId = `sbot-c2-session-close-v1:${receiptId}:${generation}`
|
|
48
|
+
if (journal.hasEvent?.(eventId)) throw new Error('C2 session close already committed')
|
|
49
|
+
const payload = { type: 'runnerClosedCommit', receiptId, generation, turnToken,
|
|
50
|
+
closeEvidenceId: evidenceId, sessionIdPresent: sessionId !== null, aborted }
|
|
51
|
+
const expectedTypes = sessionId === null ? [] : aborted
|
|
52
|
+
? ['writeSession', 'markTainted'] : ['writeSession']
|
|
53
|
+
const validate = candidate => {
|
|
54
|
+
const lane = candidate?.state?.lanes?.session?.[chatId]
|
|
55
|
+
const effects = candidate?.effects
|
|
56
|
+
const descriptors = candidate?.effectDescriptors
|
|
57
|
+
if (candidate?.eventId !== eventId || candidate?.cursor !== before.cursor + 1 ||
|
|
58
|
+
lane?.epoch !== turnToken || lane.closeSeen !== true || lane.settled !== false ||
|
|
59
|
+
!Array.isArray(effects) || !isDeepStrictEqual(effects.map(item => item.type), expectedTypes) ||
|
|
60
|
+
!Array.isArray(descriptors) || descriptors.length !== effects.length ||
|
|
61
|
+
descriptors.some((item, index) => item.effectId !== effects[index].effectId ||
|
|
62
|
+
item.domain !== 'session' || item.scope !== chatId ||
|
|
63
|
+
item.type !== effects[index].type || item.turnToken !== turnToken ||
|
|
64
|
+
item.receiptId !== receiptId || item.targetId !== null)) {
|
|
65
|
+
throw new Error('C2 closed session transition differs from accepted runner')
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze({ id: eventId, domain: 'session', scope: chatId,
|
|
69
|
+
payload: Object.freeze(payload), validate })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { prepareClosedSessionEvent }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const FEISHU_MESSAGE_ID = /^om_[A-Za-z0-9_-]{1,124}$/
|
|
4
|
+
|
|
5
|
+
// An operator must carry these exact source IDs across every restart of a
|
|
6
|
+
// restricted trial. A fresh journal alone cannot recognize messages already
|
|
7
|
+
// accepted by different, preserved authorities.
|
|
8
|
+
function createC2V2SourceQuarantine(value, { required = false } = {}) {
|
|
9
|
+
if (typeof required !== 'boolean') throw new TypeError('C2 source quarantine mode invalid')
|
|
10
|
+
const raw = Array.isArray(value) ? value : [value]
|
|
11
|
+
if (!raw.length || raw.length > 2 ||
|
|
12
|
+
(raw.length === 2 && (raw[0] === undefined || raw[0] === '') &&
|
|
13
|
+
raw[1] !== undefined && raw[1] !== '')) {
|
|
14
|
+
throw new TypeError('C2 v2 blocked source list invalid')
|
|
15
|
+
}
|
|
16
|
+
const ids = raw.filter(id => id !== undefined && id !== '')
|
|
17
|
+
if (!ids.length) {
|
|
18
|
+
if (required) throw new Error('C2 v2 formal service needs an explicit blocked source message ID')
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
if (ids.some(id => typeof id !== 'string' || !FEISHU_MESSAGE_ID.test(id)) ||
|
|
22
|
+
new Set(ids).size !== ids.length) {
|
|
23
|
+
throw new TypeError('C2 v2 blocked source message ID invalid')
|
|
24
|
+
}
|
|
25
|
+
const sourceIds = Object.freeze([...ids])
|
|
26
|
+
return Object.freeze({ sourceId: sourceIds[0], sourceIds,
|
|
27
|
+
rejects: message => sourceIds.includes(message?.msgId) })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { createC2V2SourceQuarantine }
|