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.
Files changed (137) hide show
  1. package/CHANGELOG.en.json +99 -0
  2. package/CHANGELOG.json +99 -0
  3. package/README.md +20 -158
  4. package/README.zh-CN.md +20 -166
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +11 -35
  8. package/docs/install.md +61 -167
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
  10. package/docs/zh-CN/install.md +61 -165
  11. package/install.sh +84 -0
  12. package/package.json +73 -37
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4716
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/cli-secret.js +0 -36
  93. package/src/clilang.js +0 -31
  94. package/src/core/cron.js +0 -182
  95. package/src/core/cronfire.js +0 -120
  96. package/src/core/cronrun.js +0 -116
  97. package/src/core/engine.js +0 -2758
  98. package/src/core/interfaces.js +0 -114
  99. package/src/core/issues.js +0 -185
  100. package/src/core/ledger.js +0 -30
  101. package/src/core/opsalert.js +0 -214
  102. package/src/core/pending.js +0 -123
  103. package/src/core/proxy.js +0 -424
  104. package/src/core/quotaalert.js +0 -79
  105. package/src/core/quotaerr.js +0 -55
  106. package/src/core/scheduler.js +0 -59
  107. package/src/design.js +0 -5161
  108. package/src/gitrepo.js +0 -150
  109. package/src/health.js +0 -353
  110. package/src/installdeps.js +0 -72
  111. package/src/leftover.js +0 -174
  112. package/src/platform/boardnotice.js +0 -77
  113. package/src/platform/boardops.js +0 -36
  114. package/src/platform/boardwatch.js +0 -447
  115. package/src/platform/cli.js +0 -45
  116. package/src/platform/slack.js +0 -1289
  117. package/src/platform/takeovercard.js +0 -37
  118. package/src/platform/wecom.js +0 -938
  119. package/src/platform/wecomcli.js +0 -128
  120. package/src/platform/wecomgate.js +0 -108
  121. package/src/platform/wecomname.js +0 -43
  122. package/src/platform/wecompoll.js +0 -241
  123. package/src/preview.js +0 -581
  124. package/src/projectmemory.js +0 -175
  125. package/src/service.js +0 -317
  126. package/src/ui/auth.js +0 -31
  127. package/src/ui/browser.html +0 -392
  128. package/src/ui/browserroutes.js +0 -234
  129. package/src/ui/console.html +0 -5526
  130. package/src/ui/dashboard.js +0 -168
  131. package/src/ui/feishulogin.js +0 -136
  132. package/src/ui/server.js +0 -2314
  133. package/src/ui/sessions.js +0 -78
  134. package/src/ui/takeover.js +0 -108
  135. package/src/updater.js +0 -243
  136. package/src/upgrade.js +0 -35
  137. package/src/usage.js +0 -144
@@ -0,0 +1,333 @@
1
+ 'use strict'
2
+
3
+ // Process-local counterpart of the durable queue lane. The journal contains
4
+ // receipt-backed IDs and flags only; message bodies, reply contexts, platform
5
+ // objects and group configuration live here until dispatch or interruption.
6
+ const RECEIPT_ID = /^[0-9a-f]{32}$/
7
+ const MAX_HANDLES = 1024
8
+ const { taskId } = require('./enginecheckpoint')
9
+
10
+ function receiptId(value) {
11
+ if (typeof value !== 'string' || !RECEIPT_ID.test(value)) {
12
+ throw new TypeError('C2 payload receipt ID invalid')
13
+ }
14
+ return value
15
+ }
16
+
17
+ function authority(journal) {
18
+ if (!journal || typeof journal.snapshot !== 'function' ||
19
+ typeof journal.noticeStatus !== 'function') {
20
+ throw new TypeError('C2 payload handles need durable journal')
21
+ }
22
+ const snapshot = journal.snapshot()
23
+ if (!snapshot || !snapshot.state || snapshot.state.version !== 2 ||
24
+ !snapshot.receipts || !Array.isArray(snapshot.receipts.pending) ||
25
+ !Array.isArray(snapshot.receipts.completed)) {
26
+ throw new Error('C2 payload authority invalid')
27
+ }
28
+ return snapshot
29
+ }
30
+
31
+ function pending(snapshot, id) {
32
+ return snapshot.receipts.pending.find(item => item.id === id) || null
33
+ }
34
+
35
+ function queued(snapshot, id, chatId) {
36
+ return (snapshot.state.lanes.queue?.[chatId]?.queue || [])
37
+ .some(item => item.id === `receipt:job:${id}`)
38
+ }
39
+
40
+ function handedOff(snapshot, id, chatId) {
41
+ const lane = snapshot.state.lanes.handoff?.[chatId]
42
+ return lane?.phase === 'handoff' && lane.nextJobId === id
43
+ }
44
+
45
+ class C2PayloadHandles {
46
+ #journal
47
+ #jobs = new Map()
48
+ #blocked
49
+ #capacity
50
+
51
+ constructor({ journal, capacity = MAX_HANDLES } = {}) {
52
+ const snapshot = authority(journal)
53
+ if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > MAX_HANDLES) {
54
+ throw new TypeError('C2 payload capacity invalid')
55
+ }
56
+ this.#journal = journal
57
+ this.#capacity = capacity
58
+ // A newly created registry cannot prove ownership of preexisting queued
59
+ // bodies or callback handles. Recovery must settle them before reopening.
60
+ this.#blocked = snapshot.cursor !== 0 || snapshot.receipts.pending.length > 0 ||
61
+ Object.keys(snapshot.state.lanes).length > 0
62
+ }
63
+
64
+ get blocked() { return this.#blocked || this.#journal.blocked === true }
65
+ get size() { return this.#jobs.size }
66
+
67
+ snapshot() {
68
+ return { blocked: this.blocked, capacity: this.#capacity,
69
+ receiptIds: [...this.#jobs.keys()] }
70
+ }
71
+
72
+ preflight(receipt, job) {
73
+ if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
74
+ const id = receiptId(receipt?.id)
75
+ if (receipt.source !== 'message' || receipt.id !== taskId(receipt) ||
76
+ typeof receipt.chatId !== 'string' || !receipt.chatId ||
77
+ typeof receipt.sourceId !== 'string' || !/^om_[A-Za-z0-9_-]{1,124}$/.test(receipt.sourceId) ||
78
+ typeof receipt.quiet !== 'boolean' ||
79
+ !job || typeof job !== 'object' || !job.msg || typeof job.msg !== 'object' ||
80
+ !job.platform || typeof job.platform !== 'object' ||
81
+ job.msg._c2ReceiptId !== id || job.msg.chatId !== receipt.chatId ||
82
+ (job.msg.receiptSourceId || job.msg.msgId) !== receipt.sourceId ||
83
+ !!job.msg.quiet !== receipt.quiet) {
84
+ throw new Error('C2 payload does not match candidate source receipt')
85
+ }
86
+ if (this.#jobs.has(id)) throw new Error('C2 payload receipt already attached')
87
+ if (this.#jobs.size >= this.#capacity) throw new RangeError('C2 payload capacity exceeded')
88
+ return id
89
+ }
90
+
91
+ attach(id, job) {
92
+ receiptId(id)
93
+ if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
94
+ const snapshot = authority(this.#journal)
95
+ const receipt = pending(snapshot, id)
96
+ if (!receipt || receipt.source !== 'message' ||
97
+ !queued(snapshot, id, receipt.chatId) || this.#journal.noticeStatus(id) !== null) {
98
+ throw new Error('C2 payload has no matching accepted queue receipt')
99
+ }
100
+ if (!job || typeof job !== 'object' || !job.msg || typeof job.msg !== 'object' ||
101
+ !job.platform || typeof job.platform !== 'object' ||
102
+ job.msg._c2ReceiptId !== id || job.msg.chatId !== receipt.chatId ||
103
+ (job.msg.receiptSourceId || job.msg.msgId) !== receipt.sourceId) {
104
+ throw new Error('C2 payload does not match durable source receipt')
105
+ }
106
+ if (this.#jobs.has(id)) throw new Error('C2 payload receipt already attached')
107
+ if (this.#jobs.size >= this.#capacity) throw new RangeError('C2 payload capacity exceeded')
108
+ this.#jobs.set(id, job)
109
+ return id
110
+ }
111
+
112
+ requireQueued(id) {
113
+ receiptId(id)
114
+ if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
115
+ const snapshot = authority(this.#journal)
116
+ const receipt = pending(snapshot, id)
117
+ const job = this.#jobs.get(id)
118
+ if (!receipt || !queued(snapshot, id, receipt.chatId) || !job) {
119
+ throw new Error('C2 queued payload handle missing')
120
+ }
121
+ return job
122
+ }
123
+
124
+ takeHandoff(id) {
125
+ receiptId(id)
126
+ if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
127
+ const snapshot = authority(this.#journal)
128
+ const receipt = pending(snapshot, id)
129
+ const job = this.#jobs.get(id)
130
+ if (!receipt || !handedOff(snapshot, id, receipt.chatId) || !job) {
131
+ throw new Error('C2 handoff payload handle missing or unauthorized')
132
+ }
133
+ this.#jobs.delete(id)
134
+ return job
135
+ }
136
+
137
+ // Inspect the successor before the durable begin transition. The handle
138
+ // stays owned by this registry until that transition is fsynced.
139
+ peekHandoff(id) {
140
+ receiptId(id)
141
+ if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
142
+ const snapshot = authority(this.#journal)
143
+ const receipt = pending(snapshot, id)
144
+ const job = this.#jobs.get(id)
145
+ if (!receipt || !handedOff(snapshot, id, receipt.chatId) || !job ||
146
+ job.msg?._c2ReceiptId !== id || job.msg.chatId !== receipt.chatId ||
147
+ (job.msg.receiptSourceId || job.msg.msgId) !== receipt.sourceId ||
148
+ this.#journal.noticeStatus(id) !== null) {
149
+ throw new Error('C2 handoff payload handle missing or unauthorized')
150
+ }
151
+ return job
152
+ }
153
+
154
+ // Consume only after the exact atomic dispatch event is on disk. Validate
155
+ // every member before deleting any handle, so a partial/mismatched batch
156
+ // cannot lose its in-memory payload while the journal still owns a receipt.
157
+ consumeCommittedDispatch({ eventId, turnToken, receiptIds } = {}) {
158
+ if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
159
+ if (typeof eventId !== 'string' || !eventId ||
160
+ !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
161
+ !Array.isArray(receiptIds) || receiptIds.length === 0 ||
162
+ receiptIds.length > this.#capacity || new Set(receiptIds).size !== receiptIds.length) {
163
+ throw new TypeError('C2 committed dispatch identity invalid')
164
+ }
165
+ const snapshot = authority(this.#journal)
166
+ if (snapshot.state.lastEventId !== eventId || snapshot.cursor !== snapshot.state.seq) {
167
+ throw new Error('C2 dispatch event is not the latest durable commit')
168
+ }
169
+ const jobs = []
170
+ let chatId = null
171
+ for (const id of receiptIds) {
172
+ receiptId(id)
173
+ const receipt = pending(snapshot, id)
174
+ const job = this.#jobs.get(id)
175
+ if (!receipt || !job || this.#journal.noticeStatus(id) !== null ||
176
+ job.msg?._c2ReceiptId !== id || job.msg.chatId !== receipt.chatId ||
177
+ (job.msg.receiptSourceId || job.msg.msgId) !== receipt.sourceId ||
178
+ queued(snapshot, id, receipt.chatId)) {
179
+ throw new Error('C2 committed dispatch payload differs from receipt')
180
+ }
181
+ if (chatId !== null && chatId !== receipt.chatId) {
182
+ throw new Error('C2 committed dispatch crosses groups')
183
+ }
184
+ chatId = receipt.chatId
185
+ jobs.push(job)
186
+ }
187
+ const handoff = snapshot.state.lanes.handoff?.[chatId]
188
+ const turn = snapshot.state.lanes.turn?.[chatId]
189
+ if (handoff?.phase !== 'running' || handoff.turnToken !== turnToken ||
190
+ turn?.active !== turnToken ||
191
+ handoff.acceptedReceiptIds?.length !== (receiptIds.length > 1
192
+ ? receiptIds.length : 0) ||
193
+ receiptIds.length > 1 && receiptIds.some((id, index) =>
194
+ handoff.acceptedReceiptIds[index] !== id)) {
195
+ throw new Error('C2 committed dispatch lacks active durable turn')
196
+ }
197
+ for (const id of receiptIds) this.#jobs.delete(id)
198
+ return jobs
199
+ }
200
+
201
+ dropConfirmed(id) {
202
+ receiptId(id)
203
+ const snapshot = authority(this.#journal)
204
+ const completed = snapshot.receipts.completed.some(item => item.id === id)
205
+ const status = this.#journal.noticeStatus(id)
206
+ if (!completed || !['acked', 'quiet-settled'].includes(status)) {
207
+ throw new Error('C2 payload interruption is not durably confirmed')
208
+ }
209
+ return this.#jobs.delete(id)
210
+ }
211
+
212
+ // A platform recall has no notification to ACK. The queue removal and quiet
213
+ // receipt completion must already be one durable transition before the raw
214
+ // message handle can be forgotten. A stale or different event never grants
215
+ // permission to discard another queued payload.
216
+ dropDurablyCancelled(id, eventId, control) {
217
+ receiptId(id)
218
+ if (this.blocked || !['recall', 'stop'].includes(control) ||
219
+ eventId !== `sbot-c2-queue-drop-v1:${id}:${control}` ||
220
+ !this.#journal.hasEvent(eventId)) {
221
+ throw new Error('C2 recalled payload lacks durable event')
222
+ }
223
+ const snapshot = authority(this.#journal)
224
+ if (snapshot.state.lastEventId !== eventId ||
225
+ !snapshot.receipts.completed.some(item => item.id === id) ||
226
+ snapshot.receipts.pending.some(item => item.id === id) ||
227
+ Object.values(snapshot.state.lanes.queue || {}).some(lane =>
228
+ lane.queue.some(item => item.id === `receipt:job:${id}`)) ||
229
+ !this.#jobs.has(id)) {
230
+ throw new Error('C2 recalled payload still has live queue authority')
231
+ }
232
+ this.#jobs.delete(id)
233
+ return true
234
+ }
235
+
236
+ // A visible stop drops every queued body only after the single durable
237
+ // stop-all transition settled those exact queued receipts.
238
+ dropDurablyStopped(ids, eventId) {
239
+ if (this.blocked || !Array.isArray(ids) || new Set(ids).size !== ids.length ||
240
+ typeof eventId !== 'string' || !/^sbot-c2-stop-all-v1:/.test(eventId) ||
241
+ !this.#journal.hasEvent(eventId)) {
242
+ throw new Error('C2 stop payloads lack durable commit')
243
+ }
244
+ const snapshot = authority(this.#journal)
245
+ const completed = new Set(snapshot.receipts.completed.map(item => item.id))
246
+ const pending = new Set(snapshot.receipts.pending.map(item => item.id))
247
+ const queued = new Set(Object.values(snapshot.state.lanes.queue || {}).flatMap(lane =>
248
+ lane.queue.flatMap(item => [item.id, ...(item.batch || []).map(member => member.id)])))
249
+ if (snapshot.state.lastEventId !== eventId || ids.some(id => {
250
+ receiptId(id)
251
+ return !completed.has(id) || pending.has(id) || !this.#jobs.has(id) ||
252
+ queued.has(`receipt:job:${id}`) || queued.has(`receipt:member:${id}`)
253
+ })) {
254
+ throw new Error('C2 stop payload still has live queue authority')
255
+ }
256
+ for (const id of ids) this.#jobs.delete(id)
257
+ return true
258
+ }
259
+
260
+ auditQueued() {
261
+ const snapshot = authority(this.#journal)
262
+ if (this.blocked) throw new Error('C2 payload handles closed pending startup recovery')
263
+ for (const lane of Object.values(snapshot.state.lanes.queue || {})) {
264
+ for (const item of lane.queue) {
265
+ const id = item.id.slice('receipt:job:'.length)
266
+ if (!this.#jobs.has(id)) throw new Error('C2 queued payload handle missing')
267
+ }
268
+ }
269
+ return true
270
+ }
271
+
272
+ // A normal visible-message chain may hold many queued payloads but cannot
273
+ // silently acquire a batch or an unrelated handle while dispatch is in
274
+ // progress. Verify the entire local projection before another admission.
275
+ auditOrdinaryQueue(chatId, expectedIds) {
276
+ if (this.blocked || typeof chatId !== 'string' || !chatId ||
277
+ !Array.isArray(expectedIds) || expectedIds.length > this.#capacity ||
278
+ new Set(expectedIds).size !== expectedIds.length ||
279
+ expectedIds.some(id => !RECEIPT_ID.test(id))) {
280
+ throw new Error('C2 ordinary queue projection invalid')
281
+ }
282
+ const snapshot = authority(this.#journal)
283
+ const queue = snapshot.state.lanes.queue?.[chatId]?.queue
284
+ if (!Array.isArray(queue) || queue.length !== expectedIds.length ||
285
+ this.#jobs.size > this.#capacity) {
286
+ throw new Error('C2 ordinary queue size differs from durable lane')
287
+ }
288
+ const pendingReceipts = new Map(snapshot.receipts.pending.map(item => [item.id, item]))
289
+ for (let index = 0; index < queue.length; index++) {
290
+ const id = expectedIds[index]
291
+ const item = queue[index]
292
+ const receipt = pendingReceipts.get(id)
293
+ const job = this.#jobs.get(id)
294
+ if (item?.id !== `receipt:job:${id}` || item.batch ||
295
+ item.quiet !== false || item.polled !== false ||
296
+ item.synthetic !== false || item.wantsAck !== false ||
297
+ receipt?.source !== 'message' || receipt.chatId !== chatId ||
298
+ receipt.quiet !== false || !job ||
299
+ job.msg?._c2ReceiptId !== id || job.msg.chatId !== chatId ||
300
+ job.msg.msgId !== receipt.sourceId || job.msg.quiet ||
301
+ typeof job.platform?.replyOnce !== 'function' ||
302
+ typeof job.platform?.createStreamingCard !== 'function' ||
303
+ this.#journal.noticeStatus(id) !== null) {
304
+ throw new Error('C2 ordinary queue payload differs from durable receipt')
305
+ }
306
+ }
307
+ const localIds = [...this.#jobs.entries()]
308
+ .filter(([, job]) => job.msg?.chatId === chatId).map(([id]) => id)
309
+ if (localIds.length !== expectedIds.length ||
310
+ localIds.some((id, index) => id !== expectedIds[index])) {
311
+ throw new Error('C2 ordinary queue has extra or missing payload handle')
312
+ }
313
+ return true
314
+ }
315
+
316
+ reopenAfterRecovery() {
317
+ const snapshot = authority(this.#journal)
318
+ if (snapshot.receipts.pending.length ||
319
+ Object.values(snapshot.state.lanes.queue || {}).some(lane => lane.queue.length) ||
320
+ Object.values(snapshot.state.lanes.handoff || {}).some(lane =>
321
+ lane.phase === 'handoff' || lane.phase === 'releasing') ||
322
+ [...snapshot.effects.values()].some(status => status !== 'acked') ||
323
+ [...snapshot.notices.values()].some(notice =>
324
+ notice.status === 'attempting' || notice.status === 'unknown')) {
325
+ throw new Error('C2 payload recovery has unresolved durable work')
326
+ }
327
+ this.#jobs.clear()
328
+ this.#blocked = false
329
+ return true
330
+ }
331
+ }
332
+
333
+ module.exports = { C2PayloadHandles }
@@ -0,0 +1,179 @@
1
+ 'use strict'
2
+
3
+ const { isDeepStrictEqual } = require('node:util')
4
+ const { taskId } = require('./enginecheckpoint')
5
+ const { validatePersistentState } = require('./c2persiststate')
6
+
7
+ const RECEIPT_ID = /^[0-9a-f]{32}$/
8
+ const PENDING_FIELDS = new Set(['id', 'chatId', 'source', 'sourceId', 'acceptedAt', 'quiet', 'notifiedAt'])
9
+
10
+ function plain(value, label) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
12
+ Object.getPrototypeOf(value) !== Object.prototype ||
13
+ Reflect.ownKeys(value).some(key => typeof key !== 'string' ||
14
+ !Object.getOwnPropertyDescriptor(value, key)?.enumerable ||
15
+ !Object.hasOwn(Object.getOwnPropertyDescriptor(value, key), 'value'))) {
16
+ throw new TypeError(`${label} must be a plain data object`)
17
+ }
18
+ return value
19
+ }
20
+
21
+ function dense(value, max, label) {
22
+ if (!Array.isArray(value) || value.length > max ||
23
+ Reflect.ownKeys(value).length !== value.length + 1 ||
24
+ !Array.from({ length: value.length }, (_, index) =>
25
+ Object.getOwnPropertyDescriptor(value, index)).every(d => d && d.enumerable && Object.hasOwn(d, 'value'))) {
26
+ throw new TypeError(`${label} invalid`)
27
+ }
28
+ return value
29
+ }
30
+
31
+ function positive(value) { return Number.isSafeInteger(value) && value > 0 }
32
+
33
+ function validateReceiptSnapshot(receipts) {
34
+ plain(receipts, 'receipt snapshot')
35
+ if (Object.keys(receipts).length !== 2 || !Object.hasOwn(receipts, 'pending') ||
36
+ !Object.hasOwn(receipts, 'completed')) throw new TypeError('receipt snapshot fields invalid')
37
+ const pending = dense(receipts.pending, 100_000, 'pending receipts')
38
+ const completed = dense(receipts.completed, 100_000, 'completed receipts')
39
+ if (pending.length + completed.length > 100_000) throw new RangeError('receipt capacity exceeded')
40
+ const byId = new Map()
41
+ for (const receipt of pending) {
42
+ plain(receipt, 'pending receipt')
43
+ const keys = Object.keys(receipt)
44
+ if (![6, 7].includes(keys.length) || keys.some(key => !PENDING_FIELDS.has(key)) ||
45
+ !['id', 'chatId', 'source', 'sourceId', 'acceptedAt', 'quiet'].every(key => Object.hasOwn(receipt, key)) ||
46
+ typeof receipt.id !== 'string' || !RECEIPT_ID.test(receipt.id) ||
47
+ typeof receipt.chatId !== 'string' || !receipt.chatId ||
48
+ typeof receipt.source !== 'string' || !receipt.source ||
49
+ typeof receipt.sourceId !== 'string' || !receipt.sourceId ||
50
+ !positive(receipt.acceptedAt) || typeof receipt.quiet !== 'boolean' ||
51
+ Object.hasOwn(receipt, 'notifiedAt') && !positive(receipt.notifiedAt) ||
52
+ taskId(receipt) !== receipt.id || byId.has(receipt.id)) {
53
+ throw new TypeError('pending receipt invalid')
54
+ }
55
+ byId.set(receipt.id, receipt)
56
+ }
57
+ for (const item of completed) {
58
+ plain(item, 'completed receipt')
59
+ if (Object.keys(item).length !== 2 || !Object.hasOwn(item, 'id') || !Object.hasOwn(item, 'settledAt') ||
60
+ typeof item.id !== 'string' || !RECEIPT_ID.test(item.id) ||
61
+ !positive(item.settledAt) || byId.has(item.id)) throw new TypeError('completed receipt invalid')
62
+ byId.set(item.id, null)
63
+ }
64
+ return byId
65
+ }
66
+
67
+ function validatePersistentReceipts(receipts) {
68
+ validateReceiptSnapshot(receipts)
69
+ return true
70
+ }
71
+
72
+ // A queued job is a payload handle, not a restorable payload. The journal
73
+ // must prove that each handle still has an accepted receipt before startup
74
+ // can interrupt it. A batch may include its anchor message: the same receipt
75
+ // then appears as job and member within that one queue item.
76
+ function validatePersistentRelation(state, receipts) {
77
+ validatePersistentState(state)
78
+ const byId = validateReceiptSnapshot(receipts)
79
+ const queueScopes = state.lanes.queue || {}
80
+ const allJobIds = new Set()
81
+ const allMemberIds = new Set()
82
+ for (const [scope, lane] of Object.entries(queueScopes)) {
83
+ for (const job of lane.queue) {
84
+ if (allJobIds.has(job.id)) throw new TypeError('queue job ID repeated across scopes')
85
+ allJobIds.add(job.id)
86
+ const jobReceiptId = job.id.slice('receipt:job:'.length)
87
+ const receipt = byId.get(jobReceiptId)
88
+ if (!receipt || receipt.chatId !== scope) throw new TypeError('queue job lacks matching pending receipt')
89
+ for (const member of job.batch || []) {
90
+ if (allMemberIds.has(member.id)) throw new TypeError('queue member ID repeated')
91
+ allMemberIds.add(member.id)
92
+ const memberReceiptId = member.id.slice('receipt:member:'.length)
93
+ const memberReceipt = byId.get(memberReceiptId)
94
+ if (!memberReceipt || memberReceipt.chatId !== scope) {
95
+ throw new TypeError('queue member lacks matching pending receipt')
96
+ }
97
+ }
98
+ }
99
+ }
100
+ for (const [scope, lane] of Object.entries(state.lanes.handoff || {})) {
101
+ if (lane.phase === 'handoff' && lane.acceptedReceiptIds.length &&
102
+ !lane.acceptedReceiptIds.includes(lane.nextJobId)) {
103
+ throw new TypeError('handoff batch omits anchor receipt')
104
+ }
105
+ for (const id of lane.acceptedReceiptIds) {
106
+ const receipt = byId.get(id)
107
+ if (!receipt || receipt.chatId !== scope) {
108
+ throw new TypeError('handoff accepted receipt lacks matching pending receipt')
109
+ }
110
+ }
111
+ if (lane.nextJobId !== null) {
112
+ const receipt = byId.get(lane.nextJobId)
113
+ if (!receipt || receipt.chatId !== scope) {
114
+ throw new TypeError('handoff job lacks matching pending receipt')
115
+ }
116
+ }
117
+ }
118
+ for (const [scope, terminal] of Object.entries(state.lanes.terminal || {})) {
119
+ if (!terminal.finalized || !terminal.batchReceiptIds) continue
120
+ for (const id of terminal.batchReceiptIds) {
121
+ if (!byId.has(id)) throw new TypeError('terminal batch receipt missing')
122
+ const pending = byId.get(id)
123
+ if (pending && pending.chatId !== scope) {
124
+ throw new TypeError('terminal batch receipt belongs to another group')
125
+ }
126
+ }
127
+ const running = state.lanes.handoff?.[scope]
128
+ if (running?.phase === 'running' && running.acceptedReceiptIds.length &&
129
+ (running.acceptedReceiptIds.length !== terminal.batchReceiptIds.length ||
130
+ running.acceptedReceiptIds.some((id, index) =>
131
+ id !== terminal.batchReceiptIds[index]))) {
132
+ throw new TypeError('terminal batch differs from running handoff')
133
+ }
134
+ }
135
+ for (const [scope, intent] of Object.entries(state.lanes.stopIntent || {})) {
136
+ const control = byId.get(intent.controlReceiptId)
137
+ const running = byId.get(intent.runningReceiptId)
138
+ const handoff = state.lanes.handoff?.[scope]
139
+ const turn = state.lanes.turn?.[scope]
140
+ const runningIds = handoff?.acceptedReceiptIds?.length
141
+ ? handoff.acceptedReceiptIds : [intent.runningReceiptId]
142
+ if (!control || control.chatId !== scope || control.source !== 'message' ||
143
+ control.quiet || !running || running.chatId !== scope ||
144
+ running.source !== 'message' ||
145
+ handoff?.phase !== 'running' || handoff.turnToken !== intent.turnToken ||
146
+ !runningIds.includes(intent.runningReceiptId) ||
147
+ (runningIds.length > 1
148
+ ? !isDeepStrictEqual(intent.batchReceiptIds, runningIds)
149
+ : intent.batchReceiptIds !== undefined) ||
150
+ runningIds.some(id => {
151
+ const member = byId.get(id)
152
+ return !member || member.chatId !== scope || member.source !== 'message'
153
+ }) ||
154
+ turn?.active !== intent.turnToken ||
155
+ allJobIds.has(`receipt:job:${intent.controlReceiptId}`) ||
156
+ allMemberIds.has(`receipt:member:${intent.controlReceiptId}`)) {
157
+ throw new TypeError('stop intent lacks exact pending control and running receipts')
158
+ }
159
+ }
160
+ for (const [scope, cleanup] of Object.entries(state.lanes.cardCleanup || {})) {
161
+ const receipt = byId.get(cleanup.receiptId)
162
+ const handoff = state.lanes.handoff?.[scope]
163
+ const turn = state.lanes.turn?.[scope]
164
+ const session = state.lanes.session?.[scope]
165
+ const stop = state.lanes.stopIntent?.[scope]
166
+ if (!receipt || receipt.chatId !== scope || receipt.source !== 'message' ||
167
+ handoff?.phase !== 'running' || handoff.turnToken !== cleanup.turnToken ||
168
+ turn?.active !== cleanup.turnToken ||
169
+ session?.epoch !== cleanup.turnToken || session.closeSeen !== true ||
170
+ stop && (stop.runningReceiptId !== cleanup.receiptId ||
171
+ stop.turnToken !== cleanup.turnToken ||
172
+ stop.runnerGeneration !== cleanup.runnerGeneration)) {
173
+ throw new TypeError('card cleanup lacks exact pending running receipt and close proof')
174
+ }
175
+ }
176
+ return true
177
+ }
178
+
179
+ module.exports = { validatePersistentReceipts, validatePersistentRelation }