@wangmo-bit/ds-link 0.0.0-stage → 0.8.18
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/LICENSE +21 -0
- package/README.md +134 -2
- package/TESTING.md +59 -0
- package/THIRD_PARTY_NOTICES.md +313 -0
- package/assets/icon.svg +7 -0
- package/cordis.patch.yml +3 -0
- package/dist/client.js +6012 -0
- package/package.json +102 -4
- package/src/core/constants.js +188 -0
- package/src/core/downgrade.js +80 -0
- package/src/core/engine.js +4669 -0
- package/src/core/errors.js +51 -0
- package/src/core/ledger.js +1174 -0
- package/src/core/migration.js +741 -0
- package/src/core/render.js +61 -0
- package/src/core/settings.js +147 -0
- package/src/core/validation.js +454 -0
- package/src/host/adapter.js +1152 -0
- package/src/host/connection.js +113 -0
- package/src/host/entry-0.2.0.js +13 -0
- package/src/host/entry-0.3.0.js +13 -0
- package/src/host/entry-0.4.0.js +13 -0
- package/src/host/entry-0.8.10.js +13 -0
- package/src/host/entry-0.8.11.js +13 -0
- package/src/host/entry-0.8.12.js +13 -0
- package/src/host/entry-0.8.13.js +13 -0
- package/src/host/entry-0.8.14.js +13 -0
- package/src/host/entry-0.8.15.js +13 -0
- package/src/host/entry-0.8.16-pre.1.js +13 -0
- package/src/host/entry-0.8.16-pre.2.js +13 -0
- package/src/host/entry-0.8.16-pre.3.js +13 -0
- package/src/host/entry-0.8.16-pre.4.js +13 -0
- package/src/host/entry-0.8.16-pre.5.js +13 -0
- package/src/host/entry-0.8.18.js +13 -0
- package/src/host/entry-0.8.5.js +13 -0
- package/src/host/entry-0.8.6.js +13 -0
- package/src/host/entry-0.8.7.js +13 -0
- package/src/host/entry-0.8.8.js +13 -0
- package/src/host/entry-0.8.9.js +13 -0
- package/src/host/index.js +74 -0
- package/src/host/plugin.js +403 -0
- package/src/host/prompt.js +22 -0
- package/src/host/tools.js +312 -0
|
@@ -0,0 +1,4669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-independent v0.3 delivery state machine and owner control plane.
|
|
3
|
+
*
|
|
4
|
+
* The engine owns every durable transition. Host code supplies an adapter for
|
|
5
|
+
* eligibility/discovery, message construction, steer+flush, receipt observation,
|
|
6
|
+
* cold reads, native inbox transfer, and history. The engine never calls
|
|
7
|
+
* `sessionController.prompt`, never cancels a recipient's work, never captures
|
|
8
|
+
* a recipient's last assistant message, and never retries an unknown delivery.
|
|
9
|
+
*
|
|
10
|
+
* A receipt only proves the host committed a matching user/message event into
|
|
11
|
+
* the target's context. It never claims the model read, understood, or acted
|
|
12
|
+
* on it. There are no business quotas here: only message format, the two byte
|
|
13
|
+
* storage envelopes, identity/eligibility, pause/disable, and persisted
|
|
14
|
+
* unknown/terminal state can refuse work.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from 'node:crypto'
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
API_VERSION,
|
|
21
|
+
AUTO_HELD_REASONS,
|
|
22
|
+
CONTROL_ACTIONS,
|
|
23
|
+
CREATE_TERMINAL_PHASES,
|
|
24
|
+
LEGACY_SETTINGS_KEYS,
|
|
25
|
+
LIMITS,
|
|
26
|
+
PLUGIN_VERSION,
|
|
27
|
+
SNAPSHOT_FILTERS,
|
|
28
|
+
} from './constants.js'
|
|
29
|
+
import { fail, errorCode } from './errors.js'
|
|
30
|
+
import { skipMutation } from './ledger.js'
|
|
31
|
+
import { publicMigration, sha256 } from './migration.js'
|
|
32
|
+
import { renderInboundText } from './render.js'
|
|
33
|
+
import { applySettingsPatch } from './settings.js'
|
|
34
|
+
import {
|
|
35
|
+
absolutePath,
|
|
36
|
+
assertAllowedKeys,
|
|
37
|
+
assertCallBytes,
|
|
38
|
+
assertPlainObject,
|
|
39
|
+
assertProjectSegment,
|
|
40
|
+
boundedInteger,
|
|
41
|
+
callFingerprint,
|
|
42
|
+
createFingerprint,
|
|
43
|
+
fingerprintMatches,
|
|
44
|
+
firstLineExcerpt,
|
|
45
|
+
sealFingerprint,
|
|
46
|
+
isPlainObject,
|
|
47
|
+
mintOperationId,
|
|
48
|
+
normalizeContent,
|
|
49
|
+
normalizeSubject,
|
|
50
|
+
normalizeTargets,
|
|
51
|
+
normalizeTask,
|
|
52
|
+
optionalString,
|
|
53
|
+
requiredString,
|
|
54
|
+
utf8Length,
|
|
55
|
+
validateOperationId,
|
|
56
|
+
} from './validation.js'
|
|
57
|
+
|
|
58
|
+
const NOOP_LOGGER = Object.freeze({
|
|
59
|
+
debug() {},
|
|
60
|
+
info() {},
|
|
61
|
+
warn() {},
|
|
62
|
+
error() {},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
66
|
+
const ACTIVE_QUEUE_STATUSES = new Set(['pending', 'held', 'dispatching'])
|
|
67
|
+
const RECEIPT_STATUSES = new Set(['accepted', 'dispatching', 'unknown'])
|
|
68
|
+
const SOURCE_KEYS = Object.freeze(['kind', 'form', 'senderSessionId'])
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Grant the verified inbound business text on an already-emitted card. Body
|
|
72
|
+
* access needs the CURRENT requested id to equal the durable host id and the
|
|
73
|
+
* viewer to be the recipient; a plugin-id request never grants by itself, and
|
|
74
|
+
* an existing card is only upgraded — never duplicated — so the result is
|
|
75
|
+
* independent of the order the ids arrive in.
|
|
76
|
+
* @param {Record<string, any>} entry
|
|
77
|
+
* @param {Record<string, any>} message
|
|
78
|
+
* @param {string} id
|
|
79
|
+
* @param {string} sessionId
|
|
80
|
+
* @param {boolean} includeBody
|
|
81
|
+
*/
|
|
82
|
+
function grantInboundBody(entry, message, id, sessionId, includeBody) {
|
|
83
|
+
if (!includeBody || 'body' in entry) return
|
|
84
|
+
if (typeof message.durableMessageId !== 'string' || message.durableMessageId.length === 0) return
|
|
85
|
+
if (id !== message.durableMessageId || sessionId !== message.to) return
|
|
86
|
+
// An unknown delivery is not verified evidence: never present its body as
|
|
87
|
+
// confirmed text. The status metadata stays so the viewer can fall back to
|
|
88
|
+
// the conservative original-message / technical-details path.
|
|
89
|
+
if (message.status === 'unknown') return
|
|
90
|
+
if (message.redactedAt !== undefined && message.redactedAt !== null) {
|
|
91
|
+
entry.body = null
|
|
92
|
+
entry.bodyState = 'redacted'
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
entry.body = typeof message.content?.text === 'string' ? message.content.text : ''
|
|
96
|
+
entry.bodyState = 'available'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Display subject for one durable message: the sender-provided subject when
|
|
101
|
+
* present, otherwise a deterministic first-line excerpt of the body. No model
|
|
102
|
+
* call and no trust decision: the wire `subject:` header stays untrusted.
|
|
103
|
+
* @param {Record<string, any>} message
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
function displaySubject(message) {
|
|
107
|
+
if (typeof message.subject === 'string' && message.subject.length > 0) return message.subject
|
|
108
|
+
return firstLineExcerpt(message.content?.text, LIMITS.SUBJECT_EXCERPT_CHARS)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Host rejections that are proven to happen before the host starts creating
|
|
113
|
+
* a session. Anything outside this set may have created a session, so the
|
|
114
|
+
* journal stops as `uncertain` instead of guessing.
|
|
115
|
+
*/
|
|
116
|
+
const PRE_CREATE_FAILURE_CODES = new Set(['workspace/not-found', 'gateway/bad-request'])
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {unknown} error
|
|
120
|
+
* @param {string} [messageOverride]
|
|
121
|
+
* @returns {{ code: string, message: string }}
|
|
122
|
+
*/
|
|
123
|
+
function createErrorView(error, messageOverride) {
|
|
124
|
+
const message =
|
|
125
|
+
messageOverride ??
|
|
126
|
+
(typeof error?.message === 'string' && error.message.length > 0
|
|
127
|
+
? error.message
|
|
128
|
+
: 'the operation failed before its result could be confirmed')
|
|
129
|
+
return { code: errorCode(error), message: message.slice(0, 400) }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Pure, side-effect-free parsing of one create call: strict keys, exactly one
|
|
134
|
+
* location, a bounded single-line subject, and a deterministic session title.
|
|
135
|
+
* @param {Record<string, unknown>} request
|
|
136
|
+
* @returns {{ from: string, operationId: string, task: string, subject: string | undefined, resolvedTitle: string, location: Record<string, any>, fingerprint: string }}
|
|
137
|
+
*/
|
|
138
|
+
function parseCreateRequest(request) {
|
|
139
|
+
const object = assertPlainObject(request, 'create request')
|
|
140
|
+
assertAllowedKeys(
|
|
141
|
+
object,
|
|
142
|
+
['from', 'operationId', 'task', 'workspaceId', 'newProject', 'existingProject', 'title', 'subject'],
|
|
143
|
+
'create request',
|
|
144
|
+
)
|
|
145
|
+
const from = requiredString(object.from, 'from', { min: 1, max: 256 })
|
|
146
|
+
const operationId = requiredString(object.operationId, 'operationId', { max: 128 })
|
|
147
|
+
const task = normalizeTask(object.task)
|
|
148
|
+
const subject = normalizeSubject(object.subject)
|
|
149
|
+
// The first message shares the existing 64 KiB content envelope: the task
|
|
150
|
+
// body plus the optional subject must fit together, checked before any
|
|
151
|
+
// journal or filesystem effect.
|
|
152
|
+
const contentBytes = utf8Length(JSON.stringify({ text: task, references: [], paths: [] }))
|
|
153
|
+
if (contentBytes > LIMITS.MAX_CONTENT_BYTES) {
|
|
154
|
+
throw fail(
|
|
155
|
+
'invalid_argument',
|
|
156
|
+
`task content exceeds ${LIMITS.MAX_CONTENT_BYTES} serialized bytes; shorten the first task`,
|
|
157
|
+
{ contentBytes, maxBytes: LIMITS.MAX_CONTENT_BYTES },
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
assertSubjectEnvelope(subject, contentBytes)
|
|
161
|
+
const title = optionalString(object.title, 'title', { max: 500 })
|
|
162
|
+
const hasWorkspace = object.workspaceId !== undefined && object.workspaceId !== null
|
|
163
|
+
const hasProject = object.newProject !== undefined && object.newProject !== null
|
|
164
|
+
const hasExisting = object.existingProject !== undefined && object.existingProject !== null
|
|
165
|
+
const locationCount = Number(hasWorkspace) + Number(hasProject) + Number(hasExisting)
|
|
166
|
+
if (locationCount !== 1) {
|
|
167
|
+
throw fail('invalid_argument', 'provide exactly one of workspaceId, newProject, or existingProject')
|
|
168
|
+
}
|
|
169
|
+
let location
|
|
170
|
+
if (hasWorkspace) {
|
|
171
|
+
location = {
|
|
172
|
+
kind: 'workspace',
|
|
173
|
+
workspaceId: requiredString(object.workspaceId, 'workspaceId', { min: 1, max: 256 }),
|
|
174
|
+
parentDir: null,
|
|
175
|
+
directoryName: null,
|
|
176
|
+
workspacePath: null,
|
|
177
|
+
workspaceTitle: null,
|
|
178
|
+
}
|
|
179
|
+
} else if (hasExisting) {
|
|
180
|
+
const existing = assertPlainObject(object.existingProject, 'existingProject')
|
|
181
|
+
assertAllowedKeys(existing, ['path', 'title'], 'existingProject arguments')
|
|
182
|
+
location = {
|
|
183
|
+
kind: 'existing_project',
|
|
184
|
+
workspaceId: null,
|
|
185
|
+
parentDir: null,
|
|
186
|
+
directoryName: null,
|
|
187
|
+
workspacePath: absolutePath(existing.path, 'existingProject.path'),
|
|
188
|
+
workspaceTitle: optionalString(existing.title, 'existingProject.title', { max: 500 }) ?? null,
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
const project = assertPlainObject(object.newProject, 'newProject')
|
|
192
|
+
assertAllowedKeys(project, ['parentDir', 'directoryName', 'title'], 'newProject arguments')
|
|
193
|
+
location = {
|
|
194
|
+
kind: 'new_project',
|
|
195
|
+
workspaceId: null,
|
|
196
|
+
parentDir: absolutePath(project.parentDir, 'newProject.parentDir'),
|
|
197
|
+
directoryName: assertProjectSegment(project.directoryName, 'newProject.directoryName'),
|
|
198
|
+
workspacePath: null,
|
|
199
|
+
workspaceTitle: optionalString(project.title, 'newProject.title', { max: 500 }) ?? null,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const resolvedTitle = title ?? firstLineExcerpt(task, LIMITS.TITLE_EXCERPT_CHARS)
|
|
203
|
+
const fingerprint = createFingerprint({
|
|
204
|
+
task,
|
|
205
|
+
targets: [from],
|
|
206
|
+
workspaceId: location.workspaceId,
|
|
207
|
+
parentDir: location.parentDir,
|
|
208
|
+
directoryName: location.directoryName,
|
|
209
|
+
workspacePath: location.workspacePath,
|
|
210
|
+
workspaceTitle: location.workspaceTitle,
|
|
211
|
+
title: resolvedTitle,
|
|
212
|
+
...(subject === undefined ? {} : { subject }),
|
|
213
|
+
})
|
|
214
|
+
return { from, operationId, task, subject, resolvedTitle, location, fingerprint }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The optional subject shares the existing 64 KiB content envelope: the
|
|
219
|
+
* message's own contentBytes stay byte-identical to 0.3, and the subject
|
|
220
|
+
* bytes are added only for this call's budget check.
|
|
221
|
+
* @param {string | undefined} subject
|
|
222
|
+
* @param {number} contentBytes
|
|
223
|
+
*/
|
|
224
|
+
function assertSubjectEnvelope(subject, contentBytes) {
|
|
225
|
+
if (subject === undefined) return
|
|
226
|
+
const subjectBytes = utf8Length(JSON.stringify(subject))
|
|
227
|
+
if (contentBytes + subjectBytes > LIMITS.MAX_CONTENT_BYTES) {
|
|
228
|
+
throw fail(
|
|
229
|
+
'invalid_argument',
|
|
230
|
+
`content and subject exceed ${LIMITS.MAX_CONTENT_BYTES} serialized bytes; shorten the subject or body`,
|
|
231
|
+
{ contentBytes, subjectBytes, maxBytes: LIMITS.MAX_CONTENT_BYTES },
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @param {'context'|'legacy-accepted'} policy */
|
|
237
|
+
function createReceipt(policy) {
|
|
238
|
+
return {
|
|
239
|
+
policy,
|
|
240
|
+
state: policy === 'context' ? 'not_sent' : 'uncertain',
|
|
241
|
+
receivedAt: null,
|
|
242
|
+
receivedSeq: null,
|
|
243
|
+
timeoutMs: null,
|
|
244
|
+
transfer: null,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {unknown} source
|
|
250
|
+
* @param {string} senderSessionId
|
|
251
|
+
* @returns {boolean}
|
|
252
|
+
*/
|
|
253
|
+
function isStandardSource(source, senderSessionId) {
|
|
254
|
+
if (!isPlainObject(source)) return false
|
|
255
|
+
const keys = Object.keys(source)
|
|
256
|
+
if (keys.length !== SOURCE_KEYS.length) return false
|
|
257
|
+
return (
|
|
258
|
+
keys.every((key) => SOURCE_KEYS.includes(key)) &&
|
|
259
|
+
source.kind === 'agent-message' &&
|
|
260
|
+
source.form === 'relay' &&
|
|
261
|
+
source.senderSessionId === senderSessionId
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** @param {Record<string, any>} hostMessage @returns {string} */
|
|
266
|
+
function hostMessageDigest(hostMessage) {
|
|
267
|
+
return sha256(Buffer.from(JSON.stringify(hostMessage), 'utf8'))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** @param {unknown} content @returns {string} */
|
|
271
|
+
function textOfHostMessage(content) {
|
|
272
|
+
if (!Array.isArray(content)) return ''
|
|
273
|
+
return content
|
|
274
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
275
|
+
.map((block) => block.text)
|
|
276
|
+
.join('\n')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The native row may only be moved when it is exactly the durable message this
|
|
281
|
+
* ledger recorded: same host id, standard source sender, and rendered body.
|
|
282
|
+
* @param {Record<string, any>} message
|
|
283
|
+
* @param {Record<string, any>} native
|
|
284
|
+
* @returns {boolean}
|
|
285
|
+
*/
|
|
286
|
+
function hostMessageMatchesLedger(message, native) {
|
|
287
|
+
if (native?.id !== message.durableMessageId) return false
|
|
288
|
+
if (!isStandardSource(native.source, message.from)) return false
|
|
289
|
+
return textOfHostMessage(native.content) === renderInboundText(message)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Deadline arithmetic stays inside the JavaScript Date range; an unrepresentable
|
|
294
|
+
* deadline is left absent instead of being clamped or fabricated.
|
|
295
|
+
* @param {number} receivedAt
|
|
296
|
+
* @param {number|null} timeoutMs
|
|
297
|
+
* @returns {number | undefined}
|
|
298
|
+
*/
|
|
299
|
+
function representableDeadline(receivedAt, timeoutMs) {
|
|
300
|
+
if (!Number.isSafeInteger(receivedAt) || receivedAt < 0) return undefined
|
|
301
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) return undefined
|
|
302
|
+
const deadline = receivedAt + timeoutMs
|
|
303
|
+
if (!Number.isSafeInteger(deadline) || deadline > LIMITS.MAX_DATE_MS) return undefined
|
|
304
|
+
return deadline
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** @param {Record<string, any>} state @param {string} id */
|
|
308
|
+
function findMessage(state, id) {
|
|
309
|
+
return state.messages.find((message) => message?.id === id)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** @param {Record<string, any>} state @param {string} chainId */
|
|
313
|
+
function chainMessages(state, chainId) {
|
|
314
|
+
return state.messages.filter((message) => message?.chainId === chainId)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** @param {Record<string, any>} message */
|
|
318
|
+
function contentBytesOf(message) {
|
|
319
|
+
if (Number.isSafeInteger(message.contentBytes) && message.contentBytes >= 0) return message.contentBytes
|
|
320
|
+
try {
|
|
321
|
+
return Buffer.byteLength(JSON.stringify(message.content ?? {}), 'utf8')
|
|
322
|
+
} catch {
|
|
323
|
+
return 0
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** @param {Record<string, any>} state */
|
|
328
|
+
/** @param {string | null | undefined} subject @returns {number} */
|
|
329
|
+
function subjectPayloadBytes(subject) {
|
|
330
|
+
return typeof subject === 'string' && subject.length > 0 ? Buffer.byteLength(JSON.stringify(subject), 'utf8') : 0
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Bytes one create journal still contributes to the 16 MiB undelivered
|
|
335
|
+
* envelope. A journal is deduplicated only when its matching message is
|
|
336
|
+
* ACTIVE and already counted on the message side; a failed/unknown/missing
|
|
337
|
+
* match with a retained body keeps counting so neither side can silently
|
|
338
|
+
* drop the payload.
|
|
339
|
+
* @param {Record<string, any>} state
|
|
340
|
+
* @param {Record<string, any> | undefined} journal
|
|
341
|
+
* @param {Set<string>} [operationIds]
|
|
342
|
+
* @returns {number}
|
|
343
|
+
*/
|
|
344
|
+
function createJournalPayloadBytes(state, journal, operationIds) {
|
|
345
|
+
if (!isPlainObject(journal)) return 0
|
|
346
|
+
if (journal.bodyStripped === true) return 0
|
|
347
|
+
if (typeof journal.task !== 'string' || journal.task.length === 0) return 0
|
|
348
|
+
let match
|
|
349
|
+
if (typeof journal.firstMessageId === 'string' && journal.firstMessageId.length > 0) {
|
|
350
|
+
match = state.messages.find((candidate) => candidate.id === journal.firstMessageId)
|
|
351
|
+
} else {
|
|
352
|
+
const index = operationIds ?? new Set(state.messages.map((candidate) => candidate.operationId))
|
|
353
|
+
match =
|
|
354
|
+
typeof journal.sendOperationId === 'string' && index.has(journal.sendOperationId)
|
|
355
|
+
? state.messages.find((candidate) => candidate.operationId === journal.sendOperationId)
|
|
356
|
+
: undefined
|
|
357
|
+
}
|
|
358
|
+
if (match !== undefined && ACTIVE_QUEUE_STATUSES.has(match.status)) return 0
|
|
359
|
+
return Buffer.byteLength(journal.task, 'utf8') + subjectPayloadBytes(journal.subject)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function queuedBytesOf(state) {
|
|
363
|
+
let bytes = 0
|
|
364
|
+
for (const message of state.messages) {
|
|
365
|
+
if (ACTIVE_QUEUE_STATUSES.has(message.status)) {
|
|
366
|
+
bytes += contentBytesOf(message) + subjectPayloadBytes(message.subject)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (isPlainObject(state.creates)) {
|
|
370
|
+
const operationIds = new Set(state.messages.map((message) => message.operationId))
|
|
371
|
+
for (const journal of Object.values(state.creates)) {
|
|
372
|
+
bytes += createJournalPayloadBytes(state, journal, operationIds)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return bytes
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Locate the create journal that owns this internal first-message
|
|
380
|
+
* reservation (same caller + persisted sendOperationId).
|
|
381
|
+
* @param {Record<string, any>} state
|
|
382
|
+
* @param {string} from
|
|
383
|
+
* @param {string} operationId
|
|
384
|
+
* @returns {Record<string, any> | undefined}
|
|
385
|
+
*/
|
|
386
|
+
function findCreateSendReservation(state, from, operationId) {
|
|
387
|
+
return Object.values(state.creates ?? {}).find(
|
|
388
|
+
(journal) => journal?.from === from && journal?.sendOperationId === operationId,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Exact reservation match: same caller, the single actual session target,
|
|
394
|
+
* the stored task and subject, a plain request with no parent, references,
|
|
395
|
+
* or paths. Anything else must be rejected as an operationId conflict and
|
|
396
|
+
* may never consume the reservation.
|
|
397
|
+
* @param {Record<string, any> | undefined} journal
|
|
398
|
+
* @param {{ kind: string, targets: readonly string[], text: string, references: readonly any[], paths: readonly string[], subject: string | null, parentMessageId: string | null }} request
|
|
399
|
+
* @returns {boolean}
|
|
400
|
+
*/
|
|
401
|
+
function createReservationExact(journal, request) {
|
|
402
|
+
if (!isPlainObject(journal)) return false
|
|
403
|
+
return (
|
|
404
|
+
request.kind === 'request' &&
|
|
405
|
+
request.parentMessageId === null &&
|
|
406
|
+
request.targets.length === 1 &&
|
|
407
|
+
request.targets[0] === journal.sessionId &&
|
|
408
|
+
request.text === journal.task &&
|
|
409
|
+
(request.subject ?? null) === (journal.subject ?? null) &&
|
|
410
|
+
request.references.length === 0 &&
|
|
411
|
+
request.paths.length === 0
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Parse the mint timestamp of a `slop:<ms>:<uuid>` operationId.
|
|
417
|
+
* @param {unknown} operationId
|
|
418
|
+
* @returns {number | null}
|
|
419
|
+
*/
|
|
420
|
+
function parseOperationMintTime(operationId) {
|
|
421
|
+
const match = /^slop:(\d+):/.exec(typeof operationId === 'string' ? operationId : '')
|
|
422
|
+
if (match === null) return null
|
|
423
|
+
const at = Number(match[1])
|
|
424
|
+
return Number.isSafeInteger(at) && at >= 0 ? at : null
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** @param {Record<string, any>} message */
|
|
428
|
+
function isAutomaticHeld(message) {
|
|
429
|
+
return message.status === 'held' && (message.heldOverflow === true || AUTO_HELD_REASONS.includes(message.heldReason))
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @param {Record<string, any>} state
|
|
434
|
+
* @param {Record<string, any>} message
|
|
435
|
+
* @returns {string[]}
|
|
436
|
+
*/
|
|
437
|
+
function dispatchBlockers(state, message) {
|
|
438
|
+
const blockers = []
|
|
439
|
+
if (!state.settings.enabled) blockers.push('disabled')
|
|
440
|
+
if (state.settings.paused) blockers.push('paused')
|
|
441
|
+
const chain = state.chains[message.chainId]
|
|
442
|
+
if (chain === undefined) blockers.push('chain_missing')
|
|
443
|
+
else if (chain.state === 'paused') blockers.push('chain_paused')
|
|
444
|
+
return blockers
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Translate one adapter delivery outcome into a durable status. Only a
|
|
449
|
+
* synchronous pre-insertion failure may become `failed`; every ambiguous
|
|
450
|
+
* outcome after followup starts is `unknown`.
|
|
451
|
+
* @param {{ stage: string, blocked?: any, error?: any }} outcome
|
|
452
|
+
* @returns {{ status: 'accepted'|'failed'|'unknown'|'held'|'pending'|'cancelled', code?: string, message?: string }}
|
|
453
|
+
*/
|
|
454
|
+
function classifyDeliveryOutcome(outcome) {
|
|
455
|
+
if (outcome.stage === 'accepted') return { status: 'accepted' }
|
|
456
|
+
if (outcome.stage === 'blocked') {
|
|
457
|
+
const action = outcome.blocked?.action
|
|
458
|
+
if (action === 'held' || action === 'pending' || action === 'cancelled' || action === 'failed') {
|
|
459
|
+
return {
|
|
460
|
+
status: action,
|
|
461
|
+
code: outcome.blocked.code ?? outcome.blocked.reason ?? 'blocked',
|
|
462
|
+
message: outcome.blocked.message ?? 'delivery blocked before host insertion',
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return { status: 'unknown', code: 'blocked_unknown', message: 'delivery gate returned an unknown action' }
|
|
466
|
+
}
|
|
467
|
+
if (outcome.stage === 'resolve') {
|
|
468
|
+
return {
|
|
469
|
+
status: 'failed',
|
|
470
|
+
code: outcome.error?.code ?? 'resolve_failed',
|
|
471
|
+
message: 'target agent could not be resolved; nothing was inserted',
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (outcome.stage === 'followup') {
|
|
475
|
+
return {
|
|
476
|
+
status: 'unknown',
|
|
477
|
+
code: outcome.error?.code ?? 'followup_failed',
|
|
478
|
+
message: 'followup may have inserted the message; not retried automatically',
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (outcome.stage === 'flush') {
|
|
482
|
+
return {
|
|
483
|
+
status: 'unknown',
|
|
484
|
+
code: 'flush_failed',
|
|
485
|
+
message: 'host durability flush did not confirm the insertion; not retried automatically',
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return { status: 'unknown', code: 'delivery_unknown', message: 'delivery outcome is unknown' }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export class SessionLinkEngine {
|
|
492
|
+
/**
|
|
493
|
+
* @param {{
|
|
494
|
+
* ledger: import('./ledger.js').Ledger,
|
|
495
|
+
* adapter: Record<string, any>,
|
|
496
|
+
* clock?: () => number,
|
|
497
|
+
* uuid?: () => string,
|
|
498
|
+
* logger?: Record<string, (...args: any[]) => void>,
|
|
499
|
+
* maxQueuedBytes?: number,
|
|
500
|
+
* }} options
|
|
501
|
+
*/
|
|
502
|
+
constructor(options) {
|
|
503
|
+
if (options?.ledger === undefined) throw fail('internal', 'engine requires a ledger')
|
|
504
|
+
if (options?.adapter === undefined) throw fail('internal', 'engine requires a host adapter')
|
|
505
|
+
this.ledger = options.ledger
|
|
506
|
+
this.adapter = options.adapter
|
|
507
|
+
this.clock = options.clock ?? Date.now
|
|
508
|
+
this.uuid = options.uuid ?? randomUUID
|
|
509
|
+
this.logger = options.logger ?? NOOP_LOGGER
|
|
510
|
+
this.maxQueuedBytes = options.maxQueuedBytes ?? LIMITS.MAX_QUEUED_BYTES
|
|
511
|
+
if (!Number.isSafeInteger(this.maxQueuedBytes) || this.maxQueuedBytes <= 0) {
|
|
512
|
+
throw fail('invalid_argument', 'maxQueuedBytes must be a positive safe integer')
|
|
513
|
+
}
|
|
514
|
+
this.#slots = 0
|
|
515
|
+
this.#waiters = []
|
|
516
|
+
this.#tasks = new Set()
|
|
517
|
+
this.#starting = new Set()
|
|
518
|
+
this.#attempts = new Set()
|
|
519
|
+
this.#disposed = false
|
|
520
|
+
this.#disposePromise = undefined
|
|
521
|
+
this.#pumpScheduled = false
|
|
522
|
+
this.#receiptIndex = new Map()
|
|
523
|
+
this.#receiptByTarget = new Map()
|
|
524
|
+
this.#receiptRetryTargets = new Set()
|
|
525
|
+
this.#transferScheduled = false
|
|
526
|
+
this.#transferRunning = false
|
|
527
|
+
this.#createsInFlight = new Map()
|
|
528
|
+
this.#rebuildReceiptIndex(this.ledger.read())
|
|
529
|
+
this.#installReceiptObserver()
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
#slots
|
|
533
|
+
#waiters
|
|
534
|
+
#tasks
|
|
535
|
+
#starting
|
|
536
|
+
#attempts
|
|
537
|
+
#disposed
|
|
538
|
+
#disposePromise
|
|
539
|
+
#pumpScheduled
|
|
540
|
+
#receiptDispose
|
|
541
|
+
#receiptPoisoned
|
|
542
|
+
#receiptIndex
|
|
543
|
+
#receiptByTarget
|
|
544
|
+
#receiptRetryTargets
|
|
545
|
+
#transferScheduled
|
|
546
|
+
#transferRunning
|
|
547
|
+
#createsInFlight
|
|
548
|
+
#lastCleanup = { timedOut: 0, redacted: 0, prunedMessages: 0, prunedOperations: 0, prunedChains: 0, prunedCreates: 0 }
|
|
549
|
+
|
|
550
|
+
// -------------------------------------------------------------------------
|
|
551
|
+
// Operation tracking and storage gate
|
|
552
|
+
// -------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
/** @template T @param {() => Promise<T>} task @returns {Promise<T>} */
|
|
555
|
+
#trackOperation(task) {
|
|
556
|
+
if (this.#disposed) throw fail('disposed', 'session-link is shutting down')
|
|
557
|
+
if (!this.ledger.writable) throw this.#storageError()
|
|
558
|
+
const promise = (async () => task())()
|
|
559
|
+
this.#track(promise)
|
|
560
|
+
return promise
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** @template T @param {Promise<T>} promise @returns {Promise<T>} */
|
|
564
|
+
#track(promise) {
|
|
565
|
+
this.#tasks.add(promise)
|
|
566
|
+
promise.then(
|
|
567
|
+
() => this.#tasks.delete(promise),
|
|
568
|
+
() => this.#tasks.delete(promise),
|
|
569
|
+
)
|
|
570
|
+
return promise
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** @returns {never} */
|
|
574
|
+
#storageError() {
|
|
575
|
+
return fail('storage_error', 'session-link storage is in storage_error; safe reopen required', {
|
|
576
|
+
...(this.ledger.storageError ?? {}),
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// -------------------------------------------------------------------------
|
|
581
|
+
// Receipt observation
|
|
582
|
+
// -------------------------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
/** @param {Record<string, any>} state */
|
|
585
|
+
#rebuildReceiptIndex(state) {
|
|
586
|
+
this.#receiptIndex.clear()
|
|
587
|
+
this.#receiptByTarget.clear()
|
|
588
|
+
for (const message of state.messages) {
|
|
589
|
+
this.#indexReceiptMessage(message)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** @param {Record<string, any>} message */
|
|
594
|
+
#indexReceiptMessage(message) {
|
|
595
|
+
if (typeof message?.durableMessageId !== 'string' || message.durableMessageId.length === 0) return
|
|
596
|
+
const key = `${message.to}|${message.durableMessageId}`
|
|
597
|
+
if (!this.#receiptIndex.has(key)) this.#receiptIndex.set(key, message.id)
|
|
598
|
+
if (message.receipt?.state === 'received') return
|
|
599
|
+
let ids = this.#receiptByTarget.get(message.to)
|
|
600
|
+
if (ids === undefined) {
|
|
601
|
+
ids = new Set()
|
|
602
|
+
this.#receiptByTarget.set(message.to, ids)
|
|
603
|
+
}
|
|
604
|
+
ids.add(message.id)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
#installReceiptObserver() {
|
|
608
|
+
if (typeof this.adapter.installReceiptObserver !== 'function') {
|
|
609
|
+
this.#receiptPoisoned = { code: 'receipt_observer_unavailable', at: this.clock() }
|
|
610
|
+
this.logger.warn('session-link receipt observer is unavailable; live receipts cannot be confirmed')
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
try {
|
|
614
|
+
const dispose = this.adapter.installReceiptObserver((event) => this.#onReceiptEvent(event))
|
|
615
|
+
this.#receiptDispose = typeof dispose === 'function' ? dispose : undefined
|
|
616
|
+
} catch (error) {
|
|
617
|
+
this.#poisonReceipt(error)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** @param {unknown} event @returns {Promise<void>|undefined} */
|
|
622
|
+
#onReceiptEvent(event) {
|
|
623
|
+
if (this.#disposed || this.#receiptPoisoned !== undefined) return undefined
|
|
624
|
+
try {
|
|
625
|
+
const observed = this.#handleReceiptEvent(event).catch((error) => {
|
|
626
|
+
this.#poisonReceipt(error)
|
|
627
|
+
})
|
|
628
|
+
this.#track(observed)
|
|
629
|
+
// The Host adapter ignores this return value; test adapters may await it
|
|
630
|
+
// to keep an injected receipt deterministic against the same clock.
|
|
631
|
+
return observed
|
|
632
|
+
} catch (error) {
|
|
633
|
+
this.#poisonReceipt(error)
|
|
634
|
+
return undefined
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** @param {unknown} error */
|
|
639
|
+
#poisonReceipt(error) {
|
|
640
|
+
if (this.#receiptPoisoned !== undefined) return
|
|
641
|
+
this.#receiptPoisoned = { code: errorCode(error), at: this.clock() }
|
|
642
|
+
this.logger.error(`session-link receipt observation stopped: ${errorCode(error)}`)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** @param {unknown} event @returns {Promise<void>} */
|
|
646
|
+
async #handleReceiptEvent(event) {
|
|
647
|
+
if (this.#disposed || !isPlainObject(event)) return
|
|
648
|
+
if (event.kind === 'message') {
|
|
649
|
+
await this.#observeMessage(event)
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
if (event.kind === 'inbox') await this.#observeInbox(event)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** @param {Record<string, any>} event */
|
|
656
|
+
async #observeMessage(event) {
|
|
657
|
+
const sessionId = event.sessionId
|
|
658
|
+
const durableMessageId = event.messageId
|
|
659
|
+
const senderSessionId = event.senderSessionId
|
|
660
|
+
if (
|
|
661
|
+
typeof sessionId !== 'string' ||
|
|
662
|
+
typeof durableMessageId !== 'string' ||
|
|
663
|
+
typeof senderSessionId !== 'string' ||
|
|
664
|
+
!isStandardSource(event.source, senderSessionId) ||
|
|
665
|
+
!Number.isSafeInteger(event.seq) ||
|
|
666
|
+
!Number.isSafeInteger(event.time) ||
|
|
667
|
+
event.seq < 0 ||
|
|
668
|
+
event.time < 0
|
|
669
|
+
) {
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
const ledgerMessageId = this.#receiptIndex.get(`${sessionId}|${durableMessageId}`)
|
|
673
|
+
if (ledgerMessageId === undefined) return
|
|
674
|
+
const before = this.ledger.read()
|
|
675
|
+
const message = findMessage(before, ledgerMessageId)
|
|
676
|
+
if (
|
|
677
|
+
message === undefined ||
|
|
678
|
+
message.to !== sessionId ||
|
|
679
|
+
message.durableMessageId !== durableMessageId ||
|
|
680
|
+
message.from !== senderSessionId ||
|
|
681
|
+
message.receipt === undefined ||
|
|
682
|
+
message.receipt.state === 'received' ||
|
|
683
|
+
!RECEIPT_STATUSES.has(message.status)
|
|
684
|
+
) {
|
|
685
|
+
return
|
|
686
|
+
}
|
|
687
|
+
let flushed = false
|
|
688
|
+
try {
|
|
689
|
+
flushed = (await this.adapter.flushTarget(sessionId)) === true
|
|
690
|
+
} catch {
|
|
691
|
+
flushed = false
|
|
692
|
+
}
|
|
693
|
+
if (this.#disposed || this.#receiptPoisoned !== undefined) return
|
|
694
|
+
if (!flushed) {
|
|
695
|
+
this.#receiptRetryTargets.add(sessionId)
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
await this.#mutate((state) => {
|
|
699
|
+
const current = findMessage(state, ledgerMessageId)
|
|
700
|
+
if (
|
|
701
|
+
current === undefined ||
|
|
702
|
+
current.to !== sessionId ||
|
|
703
|
+
current.durableMessageId !== durableMessageId ||
|
|
704
|
+
current.from !== senderSessionId ||
|
|
705
|
+
current.receipt === undefined ||
|
|
706
|
+
current.receipt.state === 'received' ||
|
|
707
|
+
!RECEIPT_STATUSES.has(current.status)
|
|
708
|
+
) {
|
|
709
|
+
return skipMutation(undefined)
|
|
710
|
+
}
|
|
711
|
+
return this.#markReceivedInState(state, current, event.time, event.seq, this.clock()) ? true : skipMutation(undefined)
|
|
712
|
+
})
|
|
713
|
+
this.#receiptRetryTargets.delete(sessionId)
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* @param {Record<string, any>} state
|
|
718
|
+
* @param {Record<string, any>} message
|
|
719
|
+
* @param {number} receivedAt
|
|
720
|
+
* @param {number} receivedSeq
|
|
721
|
+
* @param {number} now
|
|
722
|
+
* @returns {boolean}
|
|
723
|
+
*/
|
|
724
|
+
#markReceivedInState(state, message, receivedAt, receivedSeq, now) {
|
|
725
|
+
const receipt = message.receipt
|
|
726
|
+
if (receipt === undefined || receipt.state === 'received') return false
|
|
727
|
+
receipt.state = 'received'
|
|
728
|
+
receipt.receivedAt = receivedAt
|
|
729
|
+
receipt.receivedSeq = receivedSeq
|
|
730
|
+
if (receipt.policy === 'context' && message.status === 'accepted') {
|
|
731
|
+
if (message.kind === 'request') {
|
|
732
|
+
this.#applyDeadlineFromReceipt(state, message)
|
|
733
|
+
} else if (message.kind === 'notification' || message.kind === 'reply') {
|
|
734
|
+
message.completedAt = receivedAt
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const journal = state.transfers?.[message.id]
|
|
738
|
+
if (journal !== undefined && journal.state === 'prepared') {
|
|
739
|
+
journal.state = 'moved'
|
|
740
|
+
journal.at = now
|
|
741
|
+
delete journal.error
|
|
742
|
+
receipt.transfer = { state: 'moved', at: now }
|
|
743
|
+
}
|
|
744
|
+
return true
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* @param {Record<string, any>} state
|
|
749
|
+
* @param {Record<string, any>} message
|
|
750
|
+
*/
|
|
751
|
+
#applyDeadlineFromReceipt(state, message) {
|
|
752
|
+
const receipt = message.receipt
|
|
753
|
+
if (receipt?.state !== 'received' || !Number.isSafeInteger(receipt.receivedAt)) return
|
|
754
|
+
if (message.kind !== 'request' || message.status !== 'accepted' || message.replyStatus !== 'waiting') return
|
|
755
|
+
if (Number.isSafeInteger(message.deadlineAt)) return
|
|
756
|
+
const timeoutMs = Number.isSafeInteger(receipt.timeoutMs) ? receipt.timeoutMs : state.settings.requestTimeoutMs
|
|
757
|
+
const deadlineAt = representableDeadline(receipt.receivedAt, timeoutMs)
|
|
758
|
+
if (deadlineAt !== undefined) message.deadlineAt = deadlineAt
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** @param {Record<string, any>} event */
|
|
762
|
+
async #observeInbox(event) {
|
|
763
|
+
const sessionId = event.sessionId
|
|
764
|
+
if (typeof sessionId !== 'string') return
|
|
765
|
+
const ids = this.#receiptByTarget.get(sessionId)
|
|
766
|
+
if (ids === undefined || ids.size === 0) return
|
|
767
|
+
const snapshot = this.ledger.read()
|
|
768
|
+
const checks = []
|
|
769
|
+
for (const id of ids) {
|
|
770
|
+
const message = findMessage(snapshot, id)
|
|
771
|
+
if (message === undefined || message.status !== 'accepted' || message.receipt?.state === 'received') continue
|
|
772
|
+
if (message.receipt === undefined) continue
|
|
773
|
+
const journal = snapshot.transfers?.[message.id]
|
|
774
|
+
if (journal?.state === 'prepared') continue
|
|
775
|
+
let presence
|
|
776
|
+
try {
|
|
777
|
+
presence = this.adapter.nativePending(sessionId, message.durableMessageId)
|
|
778
|
+
} catch {
|
|
779
|
+
continue
|
|
780
|
+
}
|
|
781
|
+
if (!isPlainObject(presence) || presence.known !== true) continue
|
|
782
|
+
checks.push({ id, pending: presence.pending === true })
|
|
783
|
+
}
|
|
784
|
+
if (checks.length === 0) return
|
|
785
|
+
await this.#mutate((state) => {
|
|
786
|
+
let changed = false
|
|
787
|
+
for (const check of checks) {
|
|
788
|
+
const message = findMessage(state, check.id)
|
|
789
|
+
if (message === undefined || message.status !== 'accepted') continue
|
|
790
|
+
const receipt = message.receipt
|
|
791
|
+
if (receipt === undefined || receipt.state === 'received') continue
|
|
792
|
+
if (check.pending) {
|
|
793
|
+
if (receipt.state !== 'queued') {
|
|
794
|
+
receipt.state = 'queued'
|
|
795
|
+
changed = true
|
|
796
|
+
}
|
|
797
|
+
} else if (receipt.state !== 'uncertain') {
|
|
798
|
+
receipt.state = 'uncertain'
|
|
799
|
+
changed = true
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return changed ? true : skipMutation(undefined)
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Cold, grouped catch-up. One target is read once; all candidate messages are
|
|
808
|
+
* matched from that single index. Never scans the whole history per message.
|
|
809
|
+
* @returns {Promise<void>}
|
|
810
|
+
*/
|
|
811
|
+
async #catchUpReceipts() {
|
|
812
|
+
const state = this.ledger.read()
|
|
813
|
+
const groups = new Map()
|
|
814
|
+
for (const message of state.messages) {
|
|
815
|
+
if (typeof message.durableMessageId !== 'string' || message.durableMessageId.length === 0) continue
|
|
816
|
+
if (message.receipt === undefined || message.receipt.state === 'received') continue
|
|
817
|
+
if (!RECEIPT_STATUSES.has(message.status)) continue
|
|
818
|
+
let ids = groups.get(message.to)
|
|
819
|
+
if (ids === undefined) {
|
|
820
|
+
ids = []
|
|
821
|
+
groups.set(message.to, ids)
|
|
822
|
+
}
|
|
823
|
+
ids.push(message.id)
|
|
824
|
+
}
|
|
825
|
+
for (const [targetId, ids] of groups) {
|
|
826
|
+
await this.#catchUpTarget(targetId, ids)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* @param {string} targetId
|
|
832
|
+
* @param {string[]} messageIds
|
|
833
|
+
* @returns {Promise<void>}
|
|
834
|
+
*/
|
|
835
|
+
async #catchUpTarget(targetId, messageIds) {
|
|
836
|
+
if (typeof this.adapter.readTargetReceipts !== 'function') return
|
|
837
|
+
let result
|
|
838
|
+
try {
|
|
839
|
+
result = await this.adapter.readTargetReceipts(targetId)
|
|
840
|
+
} catch (error) {
|
|
841
|
+
result = { readable: false, errorCode: errorCode(error) }
|
|
842
|
+
}
|
|
843
|
+
if (this.#disposed) return
|
|
844
|
+
if (result?.readable !== true) {
|
|
845
|
+
this.#receiptRetryTargets.add(targetId)
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
const evidence = new Map()
|
|
849
|
+
for (const entry of result.messages ?? []) {
|
|
850
|
+
if (isPlainObject(entry) && typeof entry.id === 'string') evidence.set(entry.id, entry)
|
|
851
|
+
}
|
|
852
|
+
const pending = new Set(Array.isArray(result.pendingIds) ? result.pendingIds : [])
|
|
853
|
+
const pendingKnown = result.pendingKnown === true
|
|
854
|
+
await this.#mutate((state) => {
|
|
855
|
+
let changed = false
|
|
856
|
+
for (const id of messageIds) {
|
|
857
|
+
const message = findMessage(state, id)
|
|
858
|
+
if (message === undefined || message.receipt === undefined || message.receipt.state === 'received') continue
|
|
859
|
+
const hit = evidence.get(message.durableMessageId)
|
|
860
|
+
if (
|
|
861
|
+
hit !== undefined &&
|
|
862
|
+
isStandardSource(hit.source, message.from) &&
|
|
863
|
+
Number.isSafeInteger(hit.seq) &&
|
|
864
|
+
hit.seq >= 0 &&
|
|
865
|
+
Number.isSafeInteger(hit.time) &&
|
|
866
|
+
hit.time >= 0
|
|
867
|
+
) {
|
|
868
|
+
if (this.#markReceivedInState(state, message, hit.time, hit.seq, this.clock())) changed = true
|
|
869
|
+
continue
|
|
870
|
+
}
|
|
871
|
+
if (message.status !== 'accepted' || pendingKnown !== true) continue
|
|
872
|
+
if (pending.has(message.durableMessageId)) {
|
|
873
|
+
if (message.receipt.state !== 'queued') {
|
|
874
|
+
message.receipt.state = 'queued'
|
|
875
|
+
changed = true
|
|
876
|
+
}
|
|
877
|
+
} else if (message.receipt.state !== 'uncertain') {
|
|
878
|
+
message.receipt.state = 'uncertain'
|
|
879
|
+
changed = true
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return changed ? true : skipMutation(undefined)
|
|
883
|
+
})
|
|
884
|
+
this.#receiptRetryTargets.delete(targetId)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** @returns {Promise<void>} */
|
|
888
|
+
async #retryReceiptTargets() {
|
|
889
|
+
if (this.#receiptRetryTargets.size === 0) return
|
|
890
|
+
const targets = [...this.#receiptRetryTargets].slice(0, LIMITS.RECEIPT_RETRY_TARGETS)
|
|
891
|
+
for (const targetId of targets) {
|
|
892
|
+
if (this.#disposed) return
|
|
893
|
+
const state = this.ledger.read()
|
|
894
|
+
const ids = state.messages
|
|
895
|
+
.filter(
|
|
896
|
+
(message) =>
|
|
897
|
+
message.to === targetId &&
|
|
898
|
+
typeof message.durableMessageId === 'string' &&
|
|
899
|
+
message.receipt !== undefined &&
|
|
900
|
+
message.receipt.state !== 'received' &&
|
|
901
|
+
RECEIPT_STATUSES.has(message.status),
|
|
902
|
+
)
|
|
903
|
+
.map((message) => message.id)
|
|
904
|
+
if (ids.length === 0) {
|
|
905
|
+
this.#receiptRetryTargets.delete(targetId)
|
|
906
|
+
continue
|
|
907
|
+
}
|
|
908
|
+
await this.#catchUpTarget(targetId, ids)
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* @template T
|
|
914
|
+
* @param {(state: Record<string, any>) => T} task
|
|
915
|
+
* @param {{ expectedRevision?: number, delivery?: boolean }} [options]
|
|
916
|
+
* @returns {Promise<T>}
|
|
917
|
+
*/
|
|
918
|
+
#mutate(task, options = {}) {
|
|
919
|
+
if (this.#disposed) throw fail('disposed', 'session-link is shutting down')
|
|
920
|
+
if (!this.ledger.writable) throw this.#storageError()
|
|
921
|
+
return this.ledger.mutate(task, options)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* @param {number} additionalBytes
|
|
926
|
+
* @param {Record<string, any>} state
|
|
927
|
+
*/
|
|
928
|
+
#assertQueueCapacity(state, additionalBytes, reservation) {
|
|
929
|
+
// A create journal's own reservation is subtracted only for an exact
|
|
930
|
+
// match, so the create-to-send transfer counts the payload exactly once
|
|
931
|
+
// and a mismatched request is charged in full.
|
|
932
|
+
const reservationBytes = reservation === undefined ? 0 : createJournalPayloadBytes(state, reservation)
|
|
933
|
+
const current = queuedBytesOf(state) - reservationBytes
|
|
934
|
+
if (current + additionalBytes > this.maxQueuedBytes) {
|
|
935
|
+
throw fail('queue_full', 'session-link undelivered payload capacity is full', {
|
|
936
|
+
queuedBytes: current,
|
|
937
|
+
additionalBytes,
|
|
938
|
+
maxQueuedBytes: this.maxQueuedBytes,
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// -------------------------------------------------------------------------
|
|
944
|
+
// Startup recovery
|
|
945
|
+
// -------------------------------------------------------------------------
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Cold reconcile every in-flight row without resolving an Agent. No evidence
|
|
949
|
+
* means unknown, never a resend. Then reconcile settings-driven held rows.
|
|
950
|
+
* @returns {Promise<Record<string, any>>}
|
|
951
|
+
*/
|
|
952
|
+
async start() {
|
|
953
|
+
return this.#trackOperation(async () => {
|
|
954
|
+
const now = this.clock()
|
|
955
|
+
const before = this.ledger.read()
|
|
956
|
+
const dispatching = before.messages.filter((message) => message.status === 'dispatching')
|
|
957
|
+
const evidence = new Map()
|
|
958
|
+
for (const message of dispatching) {
|
|
959
|
+
try {
|
|
960
|
+
const result = await this.adapter.readEvidence({
|
|
961
|
+
targetId: message.to,
|
|
962
|
+
durableMessageId: message.durableMessageId,
|
|
963
|
+
senderSessionId: message.from,
|
|
964
|
+
})
|
|
965
|
+
evidence.set(message.id, result)
|
|
966
|
+
} catch (error) {
|
|
967
|
+
evidence.set(message.id, { found: false, readable: false, errorCode: errorCode(error) })
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const result = await this.#mutate((state) => {
|
|
971
|
+
let recovered = 0
|
|
972
|
+
let unknown = 0
|
|
973
|
+
let changed = false
|
|
974
|
+
// A journal still in `creating` at startup means an external effect
|
|
975
|
+
// may have run without a recorded result. It becomes uncertain and
|
|
976
|
+
// is never re-run automatically, whatever a reserved id suggests.
|
|
977
|
+
for (const journal of Object.values(state.creates ?? {})) {
|
|
978
|
+
if (journal?.phase !== 'creating') continue
|
|
979
|
+
journal.phase = 'uncertain'
|
|
980
|
+
journal.error = {
|
|
981
|
+
code: 'create_interrupted',
|
|
982
|
+
message:
|
|
983
|
+
'the plugin restarted while a create effect may have been in flight; verify the workspace/session manually before retrying',
|
|
984
|
+
}
|
|
985
|
+
journal.updatedAt = now
|
|
986
|
+
changed = true
|
|
987
|
+
}
|
|
988
|
+
for (const message of dispatching) {
|
|
989
|
+
const current = findMessage(state, message.id)
|
|
990
|
+
if (current?.status !== 'dispatching') continue
|
|
991
|
+
const evidenceItem = evidence.get(message.id)
|
|
992
|
+
if (evidenceItem?.found === true && evidenceItem.identityConflict !== true) {
|
|
993
|
+
this.#acceptMessage(state, current, now, {
|
|
994
|
+
recoveryEvidence: evidenceItem.evidence ?? 'unknown',
|
|
995
|
+
inboxSeen: evidenceItem.inboxSeen === true,
|
|
996
|
+
})
|
|
997
|
+
if (
|
|
998
|
+
evidenceItem.evidence === 'message' &&
|
|
999
|
+
Number.isSafeInteger(evidenceItem.seq) &&
|
|
1000
|
+
Number.isSafeInteger(evidenceItem.time) &&
|
|
1001
|
+
evidenceItem.seq >= 0 &&
|
|
1002
|
+
evidenceItem.time >= 0
|
|
1003
|
+
) {
|
|
1004
|
+
this.#markReceivedInState(state, current, evidenceItem.time, evidenceItem.seq, now)
|
|
1005
|
+
}
|
|
1006
|
+
recovered += 1
|
|
1007
|
+
changed = true
|
|
1008
|
+
continue
|
|
1009
|
+
}
|
|
1010
|
+
this.#markUnknownInState(state, current, now, {
|
|
1011
|
+
code:
|
|
1012
|
+
evidenceItem?.identityConflict === true
|
|
1013
|
+
? 'identity_conflict'
|
|
1014
|
+
: evidenceItem?.invalidTarget === true
|
|
1015
|
+
? 'target_unavailable'
|
|
1016
|
+
: evidenceItem?.readable === false
|
|
1017
|
+
? 'evidence_unreadable'
|
|
1018
|
+
: 'no_insertion_evidence',
|
|
1019
|
+
message:
|
|
1020
|
+
evidenceItem?.identityConflict === true
|
|
1021
|
+
? 'a different sender identity used the same durable message id'
|
|
1022
|
+
: evidenceItem?.invalidTarget === true
|
|
1023
|
+
? 'target is no longer an eligible ordinary session'
|
|
1024
|
+
: evidenceItem?.readable === false
|
|
1025
|
+
? 'target history could not be read; treating the delivery as unknown'
|
|
1026
|
+
: 'no insertion evidence was found; the delivery is unknown and was not retried',
|
|
1027
|
+
})
|
|
1028
|
+
unknown += 1
|
|
1029
|
+
changed = true
|
|
1030
|
+
}
|
|
1031
|
+
const cleaned = this.#cleanupState(state, now)
|
|
1032
|
+
changed = changed || cleaned
|
|
1033
|
+
const reconciled = this.#reconcileGlobalQueue(state, now)
|
|
1034
|
+
changed = changed || reconciled.queued > 0 || reconciled.resumed > 0
|
|
1035
|
+
if (!changed) return skipMutation({ dispatching: dispatching.length, recovered, unknown, ...this.#lastCleanup })
|
|
1036
|
+
return { dispatching: dispatching.length, recovered, unknown, ...this.#lastCleanup }
|
|
1037
|
+
})
|
|
1038
|
+
this.#rebuildReceiptIndex(this.ledger.read())
|
|
1039
|
+
await this.#catchUpReceipts()
|
|
1040
|
+
await this.#recoverTransferJournals()
|
|
1041
|
+
this.#rebuildReceiptIndex(this.ledger.read())
|
|
1042
|
+
this.#schedulePump()
|
|
1043
|
+
this.#scheduleTransfer()
|
|
1044
|
+
return result
|
|
1045
|
+
})
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// -------------------------------------------------------------------------
|
|
1049
|
+
// Timeouts, retention, and disk hygiene
|
|
1050
|
+
// -------------------------------------------------------------------------
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Periodic sweep: request deadlines, body retention, metadata pruning.
|
|
1054
|
+
* @returns {Promise<Record<string, any>>}
|
|
1055
|
+
*/
|
|
1056
|
+
async tick() {
|
|
1057
|
+
if (this.#disposed) return { skipped: true }
|
|
1058
|
+
if (!this.ledger.writable) return { skipped: true, storageError: true }
|
|
1059
|
+
return this.#trackOperation(async () => {
|
|
1060
|
+
const now = this.clock()
|
|
1061
|
+
const summary = await this.#mutate((state) => {
|
|
1062
|
+
const changed = this.#cleanupState(state, now)
|
|
1063
|
+
if (!changed) return skipMutation({ timedOut: 0, redacted: 0, prunedMessages: 0, prunedOperations: 0, prunedChains: 0, prunedCreates: 0 })
|
|
1064
|
+
return { ...this.#lastCleanup }
|
|
1065
|
+
})
|
|
1066
|
+
this.#rebuildReceiptIndex(this.ledger.read())
|
|
1067
|
+
await this.#retryReceiptTargets()
|
|
1068
|
+
this.#schedulePump()
|
|
1069
|
+
this.#scheduleTransfer()
|
|
1070
|
+
return summary
|
|
1071
|
+
})
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* @param {Record<string, any>} state
|
|
1076
|
+
* @param {number} now
|
|
1077
|
+
* @returns {boolean}
|
|
1078
|
+
*/
|
|
1079
|
+
#cleanupState(state, now) {
|
|
1080
|
+
let changed = false
|
|
1081
|
+
let timedOut = 0
|
|
1082
|
+
for (const message of state.messages) {
|
|
1083
|
+
if (
|
|
1084
|
+
message.kind === 'request' &&
|
|
1085
|
+
message.status === 'accepted' &&
|
|
1086
|
+
message.replyStatus === 'waiting' &&
|
|
1087
|
+
Number.isSafeInteger(message.deadlineAt) &&
|
|
1088
|
+
message.deadlineAt <= now
|
|
1089
|
+
) {
|
|
1090
|
+
const chain = state.chains[message.chainId]
|
|
1091
|
+
if (chain?.state !== 'closed') {
|
|
1092
|
+
message.replyStatus = 'timed_out'
|
|
1093
|
+
message.timedOutAt = message.deadlineAt
|
|
1094
|
+
message.completedAt = message.timedOutAt
|
|
1095
|
+
timedOut += 1
|
|
1096
|
+
changed = true
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const retentionMs = state.settings.retentionDays * DAY_MS
|
|
1102
|
+
let redacted = 0
|
|
1103
|
+
for (const message of state.messages) {
|
|
1104
|
+
if (message.redactedAt !== undefined) continue
|
|
1105
|
+
if (message.content.text === '' && message.content.references.length === 0 && message.content.paths.length === 0) continue
|
|
1106
|
+
const completedAt = this.#completedAtFor(message, state)
|
|
1107
|
+
if (completedAt === undefined) continue
|
|
1108
|
+
if (completedAt + retentionMs > now) continue
|
|
1109
|
+
if (ACTIVE_QUEUE_STATUSES.has(message.status) || message.status === 'unknown') continue
|
|
1110
|
+
message.content = { text: '', references: [], paths: [] }
|
|
1111
|
+
message.contentBytes = 2
|
|
1112
|
+
message.redactedAt = now
|
|
1113
|
+
// Body retention applies to the 0.4 subject too; messages without the
|
|
1114
|
+
// field (0.3 rows) are byte-for-byte untouched.
|
|
1115
|
+
if (message.subject !== undefined) message.subject = null
|
|
1116
|
+
// Body retention applies to the private journal too. A completed move no
|
|
1117
|
+
// longer needs recovery evidence; an unresolved journal keeps its
|
|
1118
|
+
// identity/digest but loses the copied body once the record is redacted.
|
|
1119
|
+
const journal = state.transfers?.[message.id]
|
|
1120
|
+
if (journal?.state === 'moved') delete state.transfers[message.id]
|
|
1121
|
+
else if (journal !== undefined) journal.hostMessage.content = []
|
|
1122
|
+
redacted += 1
|
|
1123
|
+
changed = true
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Verified terminal completions, captured AFTER this round's timeout and
|
|
1127
|
+
// redaction updates but BEFORE any message pruning. A journal whose first
|
|
1128
|
+
// message is pruned right now may only age out from this same-round
|
|
1129
|
+
// evidence; a message already missing in an earlier round must never
|
|
1130
|
+
// masquerade as verified.
|
|
1131
|
+
const terminalCompletedAt = new Map()
|
|
1132
|
+
if (isPlainObject(state.creates) && Object.keys(state.creates).length > 0) {
|
|
1133
|
+
for (const message of state.messages) {
|
|
1134
|
+
if (message.status === 'unknown' || ACTIVE_QUEUE_STATUSES.has(message.status)) continue
|
|
1135
|
+
const completedAt = this.#completedAtFor(message, state)
|
|
1136
|
+
if (Number.isSafeInteger(completedAt)) terminalCompletedAt.set(message.id, completedAt)
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const metadataMs = Math.max(LIMITS.MESSAGE_RETENTION_MS, retentionMs)
|
|
1141
|
+
const messagesByChain = new Map()
|
|
1142
|
+
for (const message of state.messages) {
|
|
1143
|
+
let entries = messagesByChain.get(message.chainId)
|
|
1144
|
+
if (entries === undefined) {
|
|
1145
|
+
entries = []
|
|
1146
|
+
messagesByChain.set(message.chainId, entries)
|
|
1147
|
+
}
|
|
1148
|
+
entries.push(message)
|
|
1149
|
+
}
|
|
1150
|
+
let prunedMessages = 0
|
|
1151
|
+
let prunedChains = 0
|
|
1152
|
+
const prunedIds = new Set()
|
|
1153
|
+
const prunedChainIds = []
|
|
1154
|
+
for (const [chainId, chain] of Object.entries(state.chains)) {
|
|
1155
|
+
if (!isPlainObject(chain)) continue
|
|
1156
|
+
const messages = messagesByChain.get(chainId) ?? []
|
|
1157
|
+
if (messages.length === 0) {
|
|
1158
|
+
if (chain.state === 'closed' && Number.isSafeInteger(chain.closedAt) && chain.closedAt + metadataMs <= now) {
|
|
1159
|
+
prunedChainIds.push(chainId)
|
|
1160
|
+
changed = true
|
|
1161
|
+
}
|
|
1162
|
+
continue
|
|
1163
|
+
}
|
|
1164
|
+
let terminal = true
|
|
1165
|
+
for (const message of messages) {
|
|
1166
|
+
if (message.status === 'unknown' || ACTIVE_QUEUE_STATUSES.has(message.status)) {
|
|
1167
|
+
terminal = false
|
|
1168
|
+
break
|
|
1169
|
+
}
|
|
1170
|
+
const completedAt = this.#completedAtFor(message, state)
|
|
1171
|
+
if (completedAt === undefined || completedAt + metadataMs > now) {
|
|
1172
|
+
terminal = false
|
|
1173
|
+
break
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (!terminal) continue
|
|
1177
|
+
for (const message of messages) prunedIds.add(message.id)
|
|
1178
|
+
prunedMessages += messages.length
|
|
1179
|
+
prunedChainIds.push(chainId)
|
|
1180
|
+
changed = true
|
|
1181
|
+
}
|
|
1182
|
+
if (prunedIds.size > 0) {
|
|
1183
|
+
state.messages = state.messages.filter((message) => !prunedIds.has(message.id))
|
|
1184
|
+
if (isPlainObject(state.transfers)) {
|
|
1185
|
+
for (const id of prunedIds) delete state.transfers[id]
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
for (const chainId of prunedChainIds) {
|
|
1189
|
+
delete state.chains[chainId]
|
|
1190
|
+
prunedChains += 1
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const operationCutoff = now - LIMITS.OPERATION_RETENTION_MS
|
|
1194
|
+
let prunedOperations = 0
|
|
1195
|
+
const operations = {}
|
|
1196
|
+
for (const [key, operation] of Object.entries(state.operations)) {
|
|
1197
|
+
if (isPlainObject(operation) && Number.isSafeInteger(operation.at) && operation.at < operationCutoff) {
|
|
1198
|
+
prunedOperations += 1
|
|
1199
|
+
changed = true
|
|
1200
|
+
continue
|
|
1201
|
+
}
|
|
1202
|
+
operations[key] = operation
|
|
1203
|
+
}
|
|
1204
|
+
state.operations = operations
|
|
1205
|
+
|
|
1206
|
+
// Create journals: the private task body is dropped as soon as the first
|
|
1207
|
+
// message exists; only terminal records age out after the same 90-day
|
|
1208
|
+
// operation window, and non-terminal/uncertain records are never cleaned.
|
|
1209
|
+
// A pruned record cannot revive an old operationId: first submission
|
|
1210
|
+
// still has to pass the 24-hour operationId window.
|
|
1211
|
+
let prunedCreates = 0
|
|
1212
|
+
if (isPlainObject(state.creates)) {
|
|
1213
|
+
const keptCreates = {}
|
|
1214
|
+
for (const [key, journal] of Object.entries(state.creates)) {
|
|
1215
|
+
if (!isPlainObject(journal)) {
|
|
1216
|
+
keptCreates[key] = journal
|
|
1217
|
+
continue
|
|
1218
|
+
}
|
|
1219
|
+
if (journal.phase === 'sent' && journal.bodyStripped !== true && journal.firstMessageId !== null) {
|
|
1220
|
+
journal.task = ''
|
|
1221
|
+
journal.subject = null
|
|
1222
|
+
journal.bodyStripped = true
|
|
1223
|
+
journal.updatedAt = now
|
|
1224
|
+
changed = true
|
|
1225
|
+
}
|
|
1226
|
+
if (
|
|
1227
|
+
CREATE_TERMINAL_PHASES.includes(journal.phase) &&
|
|
1228
|
+
Number.isSafeInteger(journal.terminalAt) &&
|
|
1229
|
+
journal.terminalAt < operationCutoff
|
|
1230
|
+
) {
|
|
1231
|
+
const firstId = journal.firstMessageId
|
|
1232
|
+
const hasFirst = typeof firstId === 'string' && firstId.length > 0
|
|
1233
|
+
let mayPrune = !hasFirst
|
|
1234
|
+
if (hasFirst) {
|
|
1235
|
+
const linked = state.messages.find((candidate) => candidate.id === firstId)
|
|
1236
|
+
if (linked !== undefined) {
|
|
1237
|
+
if (linked.status === 'unknown' || ACTIVE_QUEUE_STATUSES.has(linked.status)) {
|
|
1238
|
+
// Unknown and undelivered first messages keep their root
|
|
1239
|
+
// association forever, exactly like the message side.
|
|
1240
|
+
mayPrune = false
|
|
1241
|
+
} else {
|
|
1242
|
+
// A real terminal state ages from its own completion time,
|
|
1243
|
+
// never from the earlier root terminalAt anchor.
|
|
1244
|
+
const completedAt = terminalCompletedAt.get(firstId)
|
|
1245
|
+
mayPrune = Number.isSafeInteger(completedAt) && completedAt < operationCutoff
|
|
1246
|
+
}
|
|
1247
|
+
} else {
|
|
1248
|
+
const verified = terminalCompletedAt.get(firstId)
|
|
1249
|
+
if (verified === undefined) {
|
|
1250
|
+
// Missing without same-round verified terminal evidence:
|
|
1251
|
+
// fail closed, keep the journal, and flag it for review.
|
|
1252
|
+
mayPrune = false
|
|
1253
|
+
if (journal.error === null) {
|
|
1254
|
+
journal.error = {
|
|
1255
|
+
code: 'first_message_evidence_missing',
|
|
1256
|
+
message:
|
|
1257
|
+
'the associated first message cannot be verified; this create journal is kept for manual review',
|
|
1258
|
+
}
|
|
1259
|
+
journal.updatedAt = now
|
|
1260
|
+
changed = true
|
|
1261
|
+
}
|
|
1262
|
+
} else {
|
|
1263
|
+
mayPrune = verified < operationCutoff
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (mayPrune) {
|
|
1268
|
+
prunedCreates += 1
|
|
1269
|
+
changed = true
|
|
1270
|
+
continue
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
keptCreates[key] = journal
|
|
1274
|
+
}
|
|
1275
|
+
state.creates = keptCreates
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
this.#lastCleanup = { timedOut, redacted, prunedMessages, prunedOperations, prunedChains, prunedCreates }
|
|
1279
|
+
return changed
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Completion time anchored on explicit terminal evidence. An open request
|
|
1284
|
+
* still waiting for a reply is not terminal; answered/timed-out/ended or a
|
|
1285
|
+
* request in an explicitly closed group is. Missing evidence stays absent
|
|
1286
|
+
* and retention keeps the record.
|
|
1287
|
+
* @param {Record<string, any>} message
|
|
1288
|
+
* @param {Record<string, any>} state
|
|
1289
|
+
* @returns {number | undefined}
|
|
1290
|
+
*/
|
|
1291
|
+
#completedAtFor(message, state) {
|
|
1292
|
+
if (Number.isSafeInteger(message.completedAt)) return message.completedAt
|
|
1293
|
+
if (message.status === 'failed' && Number.isSafeInteger(message.failedAt)) return message.failedAt
|
|
1294
|
+
if (message.status === 'cancelled' && Number.isSafeInteger(message.cancelledAt)) return message.cancelledAt
|
|
1295
|
+
if (message.kind === 'notification' || message.kind === 'reply') {
|
|
1296
|
+
if (message.receipt?.policy === 'context') {
|
|
1297
|
+
return message.receipt.state === 'received' && Number.isSafeInteger(message.receipt.receivedAt)
|
|
1298
|
+
? message.receipt.receivedAt
|
|
1299
|
+
: undefined
|
|
1300
|
+
}
|
|
1301
|
+
return message.status === 'accepted' && Number.isSafeInteger(message.acceptedAt) ? message.acceptedAt : undefined
|
|
1302
|
+
}
|
|
1303
|
+
if (message.kind === 'request') {
|
|
1304
|
+
if (message.replyStatus === 'answered' && Number.isSafeInteger(message.answeredAt)) return message.answeredAt
|
|
1305
|
+
if (message.replyStatus === 'timed_out' && Number.isSafeInteger(message.timedOutAt)) return message.timedOutAt
|
|
1306
|
+
if (message.status === 'accepted' && message.replyStatus === 'waiting') {
|
|
1307
|
+
const chain = state.chains[message.chainId]
|
|
1308
|
+
if (chain?.state === 'closed' && Number.isSafeInteger(chain.closedAt)) return chain.closedAt
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return undefined
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* @param {Record<string, any>} state
|
|
1316
|
+
* @param {number} now
|
|
1317
|
+
* @returns {{ queued: number, resumed: number }}
|
|
1318
|
+
*/
|
|
1319
|
+
#reconcileGlobalQueue(state, now) {
|
|
1320
|
+
let queued = 0
|
|
1321
|
+
let resumed = 0
|
|
1322
|
+
const settings = state.settings
|
|
1323
|
+
if (!settings.enabled) {
|
|
1324
|
+
// Disabling is a durable queueing transition, not just a read-time gate:
|
|
1325
|
+
// pending rows become held('disabled') in the same settings mutation so
|
|
1326
|
+
// the panel state machine never claims an undelivered row is waiting to
|
|
1327
|
+
// dispatch while session-link is closed.
|
|
1328
|
+
for (const message of state.messages) {
|
|
1329
|
+
if (message.status !== 'pending') continue
|
|
1330
|
+
if (state.chains[message.chainId] === undefined) continue
|
|
1331
|
+
message.status = 'held'
|
|
1332
|
+
message.heldAt = now
|
|
1333
|
+
message.heldReason = 'disabled'
|
|
1334
|
+
message.error = { code: 'disabled', message: 'held until session-link is enabled again' }
|
|
1335
|
+
delete message.heldOverflow
|
|
1336
|
+
message.releaseAuthorized = false
|
|
1337
|
+
queued += 1
|
|
1338
|
+
}
|
|
1339
|
+
} else if (settings.paused) {
|
|
1340
|
+
for (const message of state.messages) {
|
|
1341
|
+
if (message.status !== 'pending') continue
|
|
1342
|
+
if (state.chains[message.chainId]?.state !== 'open') continue
|
|
1343
|
+
message.status = 'held'
|
|
1344
|
+
message.heldAt = now
|
|
1345
|
+
message.heldReason = 'paused'
|
|
1346
|
+
message.error = { code: 'paused', message: 'held until the global pause is cleared' }
|
|
1347
|
+
delete message.heldOverflow
|
|
1348
|
+
message.releaseAuthorized = false
|
|
1349
|
+
queued += 1
|
|
1350
|
+
}
|
|
1351
|
+
} else {
|
|
1352
|
+
for (const message of state.messages) {
|
|
1353
|
+
if (!isAutomaticHeld(message)) continue
|
|
1354
|
+
if (state.chains[message.chainId]?.state !== 'open') continue
|
|
1355
|
+
message.status = 'pending'
|
|
1356
|
+
message.heldAt = null
|
|
1357
|
+
message.heldReason = null
|
|
1358
|
+
message.autoResumedAt = now
|
|
1359
|
+
message.error = null
|
|
1360
|
+
delete message.heldOverflow
|
|
1361
|
+
message.releaseAuthorized = false
|
|
1362
|
+
resumed += 1
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
return { queued, resumed }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* On owner resume of one chain, automatic global held rows can continue when
|
|
1370
|
+
* the global settings are open. Owner/unknown legacy held rows stay.
|
|
1371
|
+
* @param {Record<string, any>} state
|
|
1372
|
+
* @param {Record<string, any>} chain
|
|
1373
|
+
* @param {number} now
|
|
1374
|
+
* @returns {number}
|
|
1375
|
+
*/
|
|
1376
|
+
#resumeChainHeld(state, chain, now) {
|
|
1377
|
+
if (!state.settings.enabled || state.settings.paused) return 0
|
|
1378
|
+
let resumed = 0
|
|
1379
|
+
for (const message of state.messages) {
|
|
1380
|
+
if (message.chainId !== chain.id) continue
|
|
1381
|
+
if (!isAutomaticHeld(message)) continue
|
|
1382
|
+
message.status = 'pending'
|
|
1383
|
+
message.heldAt = null
|
|
1384
|
+
message.heldReason = null
|
|
1385
|
+
message.autoResumedAt = now
|
|
1386
|
+
message.error = null
|
|
1387
|
+
delete message.heldOverflow
|
|
1388
|
+
message.releaseAuthorized = false
|
|
1389
|
+
resumed += 1
|
|
1390
|
+
}
|
|
1391
|
+
return resumed
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// -------------------------------------------------------------------------
|
|
1395
|
+
// Send / reply
|
|
1396
|
+
// -------------------------------------------------------------------------
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* @param {{
|
|
1400
|
+
* from: string,
|
|
1401
|
+
* fromTitle?: string,
|
|
1402
|
+
* targets: unknown,
|
|
1403
|
+
* kind: string,
|
|
1404
|
+
* text: unknown,
|
|
1405
|
+
* operationId: string,
|
|
1406
|
+
* parentMessageId?: string,
|
|
1407
|
+
* references?: unknown,
|
|
1408
|
+
* paths?: unknown,
|
|
1409
|
+
* }} request
|
|
1410
|
+
* @returns {Promise<Record<string, any>>}
|
|
1411
|
+
*/
|
|
1412
|
+
async send(request) {
|
|
1413
|
+
const result = await this.#trackOperation(() => this.#send(request))
|
|
1414
|
+
this.#schedulePump()
|
|
1415
|
+
return result
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async #send(request) {
|
|
1419
|
+
const object = assertPlainObject(request, 'send request')
|
|
1420
|
+
assertAllowedKeys(
|
|
1421
|
+
object,
|
|
1422
|
+
['from', 'fromTitle', 'targets', 'kind', 'text', 'operationId', 'parentMessageId', 'references', 'paths', 'subject'],
|
|
1423
|
+
'send request',
|
|
1424
|
+
)
|
|
1425
|
+
const from = requiredString(object.from, 'from', { min: 1, max: 256 })
|
|
1426
|
+
const kind = requiredString(object.kind, 'kind', { max: 32 })
|
|
1427
|
+
if (kind !== 'notification' && kind !== 'request') {
|
|
1428
|
+
throw fail('invalid_argument', 'send accepts only notification or request; replies use session_link_reply')
|
|
1429
|
+
}
|
|
1430
|
+
const targets = normalizeTargets(object.targets, from)
|
|
1431
|
+
const operationId = requiredString(object.operationId, 'operationId', { max: 128 })
|
|
1432
|
+
const parentMessageId = optionalString(object.parentMessageId, 'parentMessageId', { max: 256 })
|
|
1433
|
+
const subject = normalizeSubject(object.subject)
|
|
1434
|
+
const content = normalizeContent({
|
|
1435
|
+
text: object.text,
|
|
1436
|
+
references: object.references,
|
|
1437
|
+
paths: object.paths,
|
|
1438
|
+
})
|
|
1439
|
+
assertSubjectEnvelope(subject, content.bytes)
|
|
1440
|
+
assertCallBytes(
|
|
1441
|
+
{
|
|
1442
|
+
targets: object.targets,
|
|
1443
|
+
kind,
|
|
1444
|
+
text: object.text,
|
|
1445
|
+
operationId: object.operationId,
|
|
1446
|
+
...(object.parentMessageId === undefined ? {} : { parentMessageId: object.parentMessageId }),
|
|
1447
|
+
...(object.references === undefined ? {} : { references: object.references }),
|
|
1448
|
+
...(object.paths === undefined ? {} : { paths: object.paths }),
|
|
1449
|
+
...(subject === undefined ? {} : { subject }),
|
|
1450
|
+
},
|
|
1451
|
+
'send call',
|
|
1452
|
+
)
|
|
1453
|
+
const fingerprint = callFingerprint({
|
|
1454
|
+
kind,
|
|
1455
|
+
text: content.text,
|
|
1456
|
+
references: content.references,
|
|
1457
|
+
paths: content.paths,
|
|
1458
|
+
parentMessageId: parentMessageId ?? null,
|
|
1459
|
+
targets,
|
|
1460
|
+
subject,
|
|
1461
|
+
})
|
|
1462
|
+
// An operationId owned by a create journal's first-message reservation is
|
|
1463
|
+
// only usable for the exact reserved dispatch; anything else conflicts
|
|
1464
|
+
// before it could consume or overwrite the reservation.
|
|
1465
|
+
const existingState = this.ledger.read()
|
|
1466
|
+
const sendReservation = findCreateSendReservation(existingState, from, operationId)
|
|
1467
|
+
if (
|
|
1468
|
+
sendReservation !== undefined &&
|
|
1469
|
+
!createReservationExact(sendReservation, {
|
|
1470
|
+
kind,
|
|
1471
|
+
targets,
|
|
1472
|
+
text: content.text,
|
|
1473
|
+
references: content.references,
|
|
1474
|
+
paths: content.paths,
|
|
1475
|
+
subject: subject ?? null,
|
|
1476
|
+
parentMessageId: parentMessageId ?? null,
|
|
1477
|
+
})
|
|
1478
|
+
) {
|
|
1479
|
+
throw fail('conflict_operation', 'operationId is reserved for a create first message; the payload must match exactly', {
|
|
1480
|
+
operationId,
|
|
1481
|
+
})
|
|
1482
|
+
}
|
|
1483
|
+
// Idempotent retry must win before eligibility is rechecked: a target may
|
|
1484
|
+
// have been archived since the original call, yet the caller is entitled
|
|
1485
|
+
// to the original per-target statuses and no resend.
|
|
1486
|
+
const existingOperation = this.#lookupOperation(existingState, from, operationId)
|
|
1487
|
+
if (existingOperation !== undefined) {
|
|
1488
|
+
if (!fingerprintMatches(existingOperation.fingerprint, fingerprint)) {
|
|
1489
|
+
throw fail('conflict_operation', 'operationId was already used with different content, kind, targets, or parent', {
|
|
1490
|
+
operationId,
|
|
1491
|
+
})
|
|
1492
|
+
}
|
|
1493
|
+
return this.#operationResult(existingState, existingOperation)
|
|
1494
|
+
}
|
|
1495
|
+
await this.#requireSender(from)
|
|
1496
|
+
const targetInfo = new Map()
|
|
1497
|
+
for (const target of targets) targetInfo.set(target, await this.#requireTarget(target))
|
|
1498
|
+
const fromInfo = await this.adapter.getSession(from, { fresh: true })
|
|
1499
|
+
const fromTitle = optionalString(object.fromTitle, 'fromTitle', { max: 500 }) ?? fromInfo?.title ?? from
|
|
1500
|
+
|
|
1501
|
+
const now = this.clock()
|
|
1502
|
+
let created = []
|
|
1503
|
+
const result = await this.#mutate(
|
|
1504
|
+
(state) => {
|
|
1505
|
+
const duplicate = this.#lookupOperation(state, from, operationId)
|
|
1506
|
+
if (duplicate !== undefined) {
|
|
1507
|
+
if (!fingerprintMatches(duplicate.fingerprint, fingerprint)) {
|
|
1508
|
+
throw fail('conflict_operation', 'operationId was already used with different content, kind, targets, or parent', {
|
|
1509
|
+
operationId,
|
|
1510
|
+
})
|
|
1511
|
+
}
|
|
1512
|
+
return skipMutation(this.#operationResult(state, duplicate))
|
|
1513
|
+
}
|
|
1514
|
+
validateOperationId(operationId, now)
|
|
1515
|
+
if (!state.settings.enabled) throw fail('disabled', 'session-link is disabled; enable it in settings first')
|
|
1516
|
+
|
|
1517
|
+
let chain
|
|
1518
|
+
let hop = 0
|
|
1519
|
+
if (parentMessageId !== undefined) {
|
|
1520
|
+
const parent = findMessage(state, parentMessageId)
|
|
1521
|
+
if (parent === undefined) throw fail('parent_not_found', 'parent message not found')
|
|
1522
|
+
if (parent.from !== from && parent.to !== from) throw fail('parent_forbidden', 'only a participant may continue this thread')
|
|
1523
|
+
if (parent.status !== 'accepted') {
|
|
1524
|
+
throw fail('parent_not_accepted', 'send parent must already be accepted; only explicit replies may use unconfirmed parents')
|
|
1525
|
+
}
|
|
1526
|
+
chain = state.chains[parent.chainId]
|
|
1527
|
+
if (chain === undefined) throw fail('chain_missing', 'parent chain is missing')
|
|
1528
|
+
if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
|
|
1529
|
+
if (chain.state === 'paused') throw fail('chain_paused', 'chain is paused')
|
|
1530
|
+
hop = parent.hop + 1
|
|
1531
|
+
} else {
|
|
1532
|
+
chain = {
|
|
1533
|
+
id: this.uuid(),
|
|
1534
|
+
rootOwner: from,
|
|
1535
|
+
createdAt: now,
|
|
1536
|
+
state: 'open',
|
|
1537
|
+
messageCount: 0,
|
|
1538
|
+
}
|
|
1539
|
+
state.chains[chain.id] = chain
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const reservedForSend = findCreateSendReservation(state, from, operationId)
|
|
1543
|
+
this.#assertQueueCapacity(
|
|
1544
|
+
state,
|
|
1545
|
+
(content.bytes + subjectPayloadBytes(subject)) * targets.length,
|
|
1546
|
+
createReservationExact(reservedForSend, {
|
|
1547
|
+
kind,
|
|
1548
|
+
targets,
|
|
1549
|
+
text: content.text,
|
|
1550
|
+
references: content.references,
|
|
1551
|
+
paths: content.paths,
|
|
1552
|
+
subject: subject ?? null,
|
|
1553
|
+
parentMessageId: parentMessageId ?? null,
|
|
1554
|
+
})
|
|
1555
|
+
? reservedForSend
|
|
1556
|
+
: undefined,
|
|
1557
|
+
)
|
|
1558
|
+
const held = state.settings.paused
|
|
1559
|
+
const messageIds = {}
|
|
1560
|
+
const existingCount = chainMessages(state, chain.id).length
|
|
1561
|
+
for (const target of targets) {
|
|
1562
|
+
const info = targetInfo.get(target)
|
|
1563
|
+
const message = {
|
|
1564
|
+
id: this.uuid(),
|
|
1565
|
+
operationId,
|
|
1566
|
+
from,
|
|
1567
|
+
fromTitle,
|
|
1568
|
+
to: target,
|
|
1569
|
+
toTitle: info?.title ?? target,
|
|
1570
|
+
kind,
|
|
1571
|
+
chainId: chain.id,
|
|
1572
|
+
replyTo: parentMessageId ?? null,
|
|
1573
|
+
parentMessageId: parentMessageId ?? null,
|
|
1574
|
+
hop,
|
|
1575
|
+
createdAt: now,
|
|
1576
|
+
acceptedAt: null,
|
|
1577
|
+
completedAt: null,
|
|
1578
|
+
deadlineAt: null,
|
|
1579
|
+
timedOutAt: null,
|
|
1580
|
+
lateReplyAt: null,
|
|
1581
|
+
content: structuredClone(content),
|
|
1582
|
+
contentBytes: content.bytes,
|
|
1583
|
+
subject: subject ?? null,
|
|
1584
|
+
status: held ? 'held' : 'pending',
|
|
1585
|
+
replyStatus: 'none',
|
|
1586
|
+
receipt: createReceipt('context'),
|
|
1587
|
+
deliveryAttempts: 0,
|
|
1588
|
+
durableMessageId: null,
|
|
1589
|
+
error: held
|
|
1590
|
+
? { code: 'paused', message: 'held until the global pause is cleared' }
|
|
1591
|
+
: null,
|
|
1592
|
+
heldAt: held ? now : null,
|
|
1593
|
+
heldReason: held ? 'paused' : null,
|
|
1594
|
+
}
|
|
1595
|
+
state.messages.push(message)
|
|
1596
|
+
messageIds[target] = message.id
|
|
1597
|
+
}
|
|
1598
|
+
chain.messageCount = existingCount + targets.length
|
|
1599
|
+
const operationKey = this.#operationKey(from, operationId, targets)
|
|
1600
|
+
state.operations[operationKey] = {
|
|
1601
|
+
key: operationKey,
|
|
1602
|
+
from,
|
|
1603
|
+
operationId,
|
|
1604
|
+
fingerprint: sealFingerprint(fingerprint),
|
|
1605
|
+
targets: [...targets],
|
|
1606
|
+
messageIds,
|
|
1607
|
+
at: now,
|
|
1608
|
+
}
|
|
1609
|
+
created = Object.values(messageIds)
|
|
1610
|
+
return {
|
|
1611
|
+
operationId,
|
|
1612
|
+
idempotent: false,
|
|
1613
|
+
chainId: chain.id,
|
|
1614
|
+
held,
|
|
1615
|
+
results: targets.map((target) => ({
|
|
1616
|
+
target,
|
|
1617
|
+
id: messageIds[target],
|
|
1618
|
+
status: held ? 'held' : 'pending',
|
|
1619
|
+
error: held ? { code: 'paused', message: 'held until the global pause is cleared' } : null,
|
|
1620
|
+
})),
|
|
1621
|
+
}
|
|
1622
|
+
},
|
|
1623
|
+
{ delivery: true },
|
|
1624
|
+
)
|
|
1625
|
+
|
|
1626
|
+
if (result.idempotent === true || result.held === true) return result
|
|
1627
|
+
await this.#dispatchBatch(created)
|
|
1628
|
+
return {
|
|
1629
|
+
...result,
|
|
1630
|
+
results: result.results.map((entry) => {
|
|
1631
|
+
const message = this.#message(entry.id)
|
|
1632
|
+
return {
|
|
1633
|
+
...entry,
|
|
1634
|
+
status: message?.status ?? 'missing',
|
|
1635
|
+
dispatchStatus: message?.status ?? 'missing',
|
|
1636
|
+
error: message?.error ?? null,
|
|
1637
|
+
}
|
|
1638
|
+
}),
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* @param {{
|
|
1644
|
+
* from: string,
|
|
1645
|
+
* fromTitle?: string,
|
|
1646
|
+
* messageId: string,
|
|
1647
|
+
* text: unknown,
|
|
1648
|
+
* operationId: string,
|
|
1649
|
+
* references?: unknown,
|
|
1650
|
+
* paths?: unknown,
|
|
1651
|
+
* }} request
|
|
1652
|
+
* @returns {Promise<Record<string, any>>}
|
|
1653
|
+
*/
|
|
1654
|
+
async reply(request) {
|
|
1655
|
+
const result = await this.#trackOperation(() => this.#reply(request))
|
|
1656
|
+
this.#schedulePump()
|
|
1657
|
+
return result
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
async #reply(request) {
|
|
1661
|
+
const object = assertPlainObject(request, 'reply request')
|
|
1662
|
+
assertAllowedKeys(
|
|
1663
|
+
object,
|
|
1664
|
+
['from', 'fromTitle', 'messageId', 'text', 'operationId', 'references', 'paths', 'subject'],
|
|
1665
|
+
'reply request',
|
|
1666
|
+
)
|
|
1667
|
+
const from = requiredString(object.from, 'from', { min: 1, max: 256 })
|
|
1668
|
+
const parentMessageId = requiredString(object.messageId, 'messageId', { min: 1, max: 256 })
|
|
1669
|
+
const operationId = requiredString(object.operationId, 'operationId', { max: 128 })
|
|
1670
|
+
const subject = normalizeSubject(object.subject)
|
|
1671
|
+
const content = normalizeContent({
|
|
1672
|
+
text: object.text,
|
|
1673
|
+
references: object.references,
|
|
1674
|
+
paths: object.paths,
|
|
1675
|
+
})
|
|
1676
|
+
assertSubjectEnvelope(subject, content.bytes)
|
|
1677
|
+
assertCallBytes(
|
|
1678
|
+
{
|
|
1679
|
+
messageId: object.messageId,
|
|
1680
|
+
text: object.text,
|
|
1681
|
+
operationId: object.operationId,
|
|
1682
|
+
...(object.references === undefined ? {} : { references: object.references }),
|
|
1683
|
+
...(object.paths === undefined ? {} : { paths: object.paths }),
|
|
1684
|
+
...(subject === undefined ? {} : { subject }),
|
|
1685
|
+
},
|
|
1686
|
+
'reply call',
|
|
1687
|
+
)
|
|
1688
|
+
const parentForFingerprint = this.#message(parentMessageId)
|
|
1689
|
+
if (parentForFingerprint === undefined) throw fail('parent_not_found', 'reply parent not found')
|
|
1690
|
+
const fingerprint = callFingerprint({
|
|
1691
|
+
kind: 'reply',
|
|
1692
|
+
text: content.text,
|
|
1693
|
+
references: content.references,
|
|
1694
|
+
paths: content.paths,
|
|
1695
|
+
parentMessageId,
|
|
1696
|
+
targets: [parentForFingerprint.from],
|
|
1697
|
+
subject,
|
|
1698
|
+
})
|
|
1699
|
+
const existingState = this.ledger.read()
|
|
1700
|
+
const replyReservation = findCreateSendReservation(existingState, from, operationId)
|
|
1701
|
+
if (replyReservation !== undefined) {
|
|
1702
|
+
// A reply can never be the reserved first message (kind differs), so
|
|
1703
|
+
// reusing that internal id conflicts instead of consuming the reserve.
|
|
1704
|
+
throw fail('conflict_operation', 'operationId is reserved for a create first message and cannot be reused', {
|
|
1705
|
+
operationId,
|
|
1706
|
+
})
|
|
1707
|
+
}
|
|
1708
|
+
const existingOperation = this.#lookupOperation(existingState, from, operationId)
|
|
1709
|
+
if (existingOperation !== undefined) {
|
|
1710
|
+
if (!fingerprintMatches(existingOperation.fingerprint, fingerprint)) {
|
|
1711
|
+
throw fail('conflict_operation', 'operationId was already used with different content or parent', { operationId })
|
|
1712
|
+
}
|
|
1713
|
+
return this.#operationResult(existingState, existingOperation)
|
|
1714
|
+
}
|
|
1715
|
+
await this.#requireSender(from)
|
|
1716
|
+
const parent = this.#message(parentMessageId)
|
|
1717
|
+
if (parent === undefined) throw fail('parent_not_found', 'reply parent not found')
|
|
1718
|
+
if (parent.kind !== 'request') throw fail('parent_not_request', 'only an explicit request can be answered')
|
|
1719
|
+
if (parent.to !== from) throw fail('parent_forbidden', 'only the original recipient can reply')
|
|
1720
|
+
if (parent.status !== 'accepted' && parent.status !== 'dispatching' && parent.status !== 'unknown') {
|
|
1721
|
+
throw fail('parent_not_accepted', 'parent request has not been accepted by the host')
|
|
1722
|
+
}
|
|
1723
|
+
const chain = this.ledger.read().chains[parent.chainId]
|
|
1724
|
+
if (chain === undefined) throw fail('chain_missing', 'parent chain is missing')
|
|
1725
|
+
if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
|
|
1726
|
+
if (chain.state === 'paused') throw fail('chain_paused', 'chain is paused')
|
|
1727
|
+
|
|
1728
|
+
if (parent.status === 'dispatching' || parent.status === 'unknown') {
|
|
1729
|
+
const evidence = await this.adapter.readEvidence({
|
|
1730
|
+
targetId: parent.to,
|
|
1731
|
+
durableMessageId: parent.durableMessageId,
|
|
1732
|
+
senderSessionId: parent.from,
|
|
1733
|
+
})
|
|
1734
|
+
if (evidence?.found !== true || evidence.identityConflict === true) {
|
|
1735
|
+
throw fail(
|
|
1736
|
+
'parent_unacknowledged',
|
|
1737
|
+
'the parent request has no host insertion evidence; a reply cannot use it as a parent',
|
|
1738
|
+
{ parentMessageId },
|
|
1739
|
+
)
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
const targetInfo = await this.#requireTarget(parent.from)
|
|
1744
|
+
const fromInfo = await this.adapter.getSession(from, { fresh: true })
|
|
1745
|
+
const fromTitle = optionalString(object.fromTitle, 'fromTitle', { max: 500 }) ?? fromInfo?.title ?? from
|
|
1746
|
+
const now = this.clock()
|
|
1747
|
+
let created
|
|
1748
|
+
const result = await this.#mutate(
|
|
1749
|
+
(state) => {
|
|
1750
|
+
const duplicate = this.#lookupOperation(state, from, operationId)
|
|
1751
|
+
if (duplicate !== undefined) {
|
|
1752
|
+
if (!fingerprintMatches(duplicate.fingerprint, fingerprint)) {
|
|
1753
|
+
throw fail('conflict_operation', 'operationId was already used with different content or parent', { operationId })
|
|
1754
|
+
}
|
|
1755
|
+
return skipMutation(this.#operationResult(state, duplicate))
|
|
1756
|
+
}
|
|
1757
|
+
validateOperationId(operationId, now)
|
|
1758
|
+
if (!state.settings.enabled) throw fail('disabled', 'session-link is disabled; enable it in settings first')
|
|
1759
|
+
const currentParent = findMessage(state, parentMessageId)
|
|
1760
|
+
if (currentParent === undefined) throw fail('parent_not_found', 'reply parent not found')
|
|
1761
|
+
if (currentParent.kind !== 'request') throw fail('parent_not_request', 'only an explicit request can be answered')
|
|
1762
|
+
if (currentParent.to !== from) throw fail('parent_forbidden', 'only the original recipient can reply')
|
|
1763
|
+
if (currentParent.status !== 'accepted' && currentParent.status !== 'dispatching' && currentParent.status !== 'unknown') {
|
|
1764
|
+
throw fail('parent_not_accepted', 'parent request is no longer acceptable as a reply parent')
|
|
1765
|
+
}
|
|
1766
|
+
const currentChain = state.chains[currentParent.chainId]
|
|
1767
|
+
if (currentChain === undefined) throw fail('chain_missing', 'parent chain is missing')
|
|
1768
|
+
if (currentChain.state === 'closed') throw fail('chain_closed', 'chain is closed')
|
|
1769
|
+
if (currentChain.state === 'paused') throw fail('chain_paused', 'chain is paused')
|
|
1770
|
+
const nextHop = currentParent.hop + 1
|
|
1771
|
+
this.#assertQueueCapacity(state, content.bytes + subjectPayloadBytes(subject))
|
|
1772
|
+
const held = state.settings.paused
|
|
1773
|
+
const existingCount = chainMessages(state, currentParent.chainId).length
|
|
1774
|
+
const message = {
|
|
1775
|
+
id: this.uuid(),
|
|
1776
|
+
operationId,
|
|
1777
|
+
from,
|
|
1778
|
+
fromTitle,
|
|
1779
|
+
to: currentParent.from,
|
|
1780
|
+
toTitle: targetInfo?.title ?? currentParent.from,
|
|
1781
|
+
kind: 'reply',
|
|
1782
|
+
chainId: currentParent.chainId,
|
|
1783
|
+
replyTo: currentParent.id,
|
|
1784
|
+
parentMessageId: currentParent.id,
|
|
1785
|
+
hop: nextHop,
|
|
1786
|
+
createdAt: now,
|
|
1787
|
+
acceptedAt: null,
|
|
1788
|
+
completedAt: null,
|
|
1789
|
+
deadlineAt: null,
|
|
1790
|
+
timedOutAt: null,
|
|
1791
|
+
late: currentParent.replyStatus === 'timed_out',
|
|
1792
|
+
content: structuredClone(content),
|
|
1793
|
+
contentBytes: content.bytes,
|
|
1794
|
+
subject: subject ?? null,
|
|
1795
|
+
status: held ? 'held' : 'pending',
|
|
1796
|
+
replyStatus: 'none',
|
|
1797
|
+
receipt: createReceipt('context'),
|
|
1798
|
+
deliveryAttempts: 0,
|
|
1799
|
+
durableMessageId: null,
|
|
1800
|
+
error: held
|
|
1801
|
+
? { code: 'paused', message: 'held until the global pause is cleared' }
|
|
1802
|
+
: null,
|
|
1803
|
+
heldAt: held ? now : null,
|
|
1804
|
+
heldReason: held ? 'paused' : null,
|
|
1805
|
+
}
|
|
1806
|
+
state.messages.push(message)
|
|
1807
|
+
currentChain.messageCount = existingCount + 1
|
|
1808
|
+
const operationKey = this.#operationKey(from, operationId, [currentParent.from])
|
|
1809
|
+
state.operations[operationKey] = {
|
|
1810
|
+
key: operationKey,
|
|
1811
|
+
from,
|
|
1812
|
+
operationId,
|
|
1813
|
+
fingerprint: sealFingerprint(fingerprint),
|
|
1814
|
+
targets: [currentParent.from],
|
|
1815
|
+
messageIds: { [currentParent.from]: message.id },
|
|
1816
|
+
at: now,
|
|
1817
|
+
}
|
|
1818
|
+
created = message.id
|
|
1819
|
+
return {
|
|
1820
|
+
operationId,
|
|
1821
|
+
idempotent: false,
|
|
1822
|
+
chainId: message.chainId,
|
|
1823
|
+
held,
|
|
1824
|
+
results: [
|
|
1825
|
+
{
|
|
1826
|
+
target: message.to,
|
|
1827
|
+
id: message.id,
|
|
1828
|
+
status: message.status,
|
|
1829
|
+
error: message.error,
|
|
1830
|
+
},
|
|
1831
|
+
],
|
|
1832
|
+
}
|
|
1833
|
+
},
|
|
1834
|
+
{ delivery: true },
|
|
1835
|
+
)
|
|
1836
|
+
if (result.idempotent === true || result.held === true) return result
|
|
1837
|
+
await this.#dispatchBatch([created])
|
|
1838
|
+
const message = this.#message(created)
|
|
1839
|
+
return {
|
|
1840
|
+
...result,
|
|
1841
|
+
results: [
|
|
1842
|
+
{
|
|
1843
|
+
target: message?.to,
|
|
1844
|
+
id: created,
|
|
1845
|
+
status: message?.status ?? 'missing',
|
|
1846
|
+
dispatchStatus: message?.status ?? 'missing',
|
|
1847
|
+
error: message?.error ?? null,
|
|
1848
|
+
},
|
|
1849
|
+
],
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// -------------------------------------------------------------------------
|
|
1854
|
+
// Create session + first task
|
|
1855
|
+
// -------------------------------------------------------------------------
|
|
1856
|
+
|
|
1857
|
+
/**
|
|
1858
|
+
* Create one ordinary session in an explicit location and deliver its first
|
|
1859
|
+
* task message through the standard agent-message relay. Every external
|
|
1860
|
+
* effect is preceded by a durable journal update; a crash during an effect
|
|
1861
|
+
* becomes `uncertain` and is never rebuilt automatically. One caller +
|
|
1862
|
+
* operationId may only ever run one effect sequence in this process.
|
|
1863
|
+
* @param {Record<string, unknown>} request
|
|
1864
|
+
* @returns {Promise<Record<string, any>>}
|
|
1865
|
+
*/
|
|
1866
|
+
async create(request) {
|
|
1867
|
+
const parsed = parseCreateRequest(request)
|
|
1868
|
+
const key = `${parsed.from}|${parsed.operationId}`
|
|
1869
|
+
const pending = this.#createsInFlight.get(key)
|
|
1870
|
+
if (pending !== undefined) {
|
|
1871
|
+
// Same root operationId with different normalized inputs is a conflict
|
|
1872
|
+
// even while the first call is still running.
|
|
1873
|
+
if (pending.fingerprint !== parsed.fingerprint) {
|
|
1874
|
+
throw fail('conflict_operation', 'operationId is already creating a session with different inputs', {
|
|
1875
|
+
operationId: parsed.operationId,
|
|
1876
|
+
})
|
|
1877
|
+
}
|
|
1878
|
+
return pending.promise
|
|
1879
|
+
}
|
|
1880
|
+
const run = this.#trackOperation(() => this.#create(parsed, key))
|
|
1881
|
+
this.#createsInFlight.set(key, { fingerprint: parsed.fingerprint, promise: run })
|
|
1882
|
+
try {
|
|
1883
|
+
return await run
|
|
1884
|
+
} finally {
|
|
1885
|
+
if (this.#createsInFlight.get(key)?.promise === run) this.#createsInFlight.delete(key)
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/** @param {ReturnType<typeof parseCreateRequest>} input @param {string} key */
|
|
1890
|
+
async #create(input, key) {
|
|
1891
|
+
const { from, operationId, fingerprint, location } = input
|
|
1892
|
+
const existingState = this.ledger.read()
|
|
1893
|
+
const existing = this.#lookupOperation(existingState, from, operationId)
|
|
1894
|
+
if (existing !== undefined) {
|
|
1895
|
+
// A send/reply fingerprint can never equal a create fingerprint, so a
|
|
1896
|
+
// matching record means this exact create operation already exists.
|
|
1897
|
+
if (!fingerprintMatches(existing.fingerprint, fingerprint)) {
|
|
1898
|
+
throw fail('conflict_operation', 'operationId was already used with different content, kind, targets, or parent', {
|
|
1899
|
+
operationId,
|
|
1900
|
+
})
|
|
1901
|
+
}
|
|
1902
|
+
return this.#createAdvance(key, { idempotent: true })
|
|
1903
|
+
}
|
|
1904
|
+
const now = this.clock()
|
|
1905
|
+
validateOperationId(operationId, now)
|
|
1906
|
+
if (!existingState.settings.enabled) {
|
|
1907
|
+
throw fail('disabled', 'session-link is disabled; enable it in settings first')
|
|
1908
|
+
}
|
|
1909
|
+
if (existingState.settings.paused) {
|
|
1910
|
+
throw fail('paused', 'session-link is paused; clear the global pause before creating a session')
|
|
1911
|
+
}
|
|
1912
|
+
await this.#requireSender(from)
|
|
1913
|
+
// Capability boundary: checked here, before the journal exists, so a host
|
|
1914
|
+
// without create/rename/workspace keeps mounting the original plugin.
|
|
1915
|
+
await this.adapter.assertCreateCapabilities()
|
|
1916
|
+
|
|
1917
|
+
// Read-only location resolution; any failure precedes the journal and the
|
|
1918
|
+
// first external effect, so it is a clean rejection with no side effects.
|
|
1919
|
+
let resolvedWorkspacePath = null
|
|
1920
|
+
if (location.kind === 'workspace') {
|
|
1921
|
+
const workspace = await this.adapter.getWorkspace(location.workspaceId)
|
|
1922
|
+
location.workspaceTitle = workspace.title
|
|
1923
|
+
resolvedWorkspacePath = workspace.path
|
|
1924
|
+
} else {
|
|
1925
|
+
if (location.kind === 'existing_project') {
|
|
1926
|
+
const project = await this.adapter.checkExistingProjectPath(location.workspacePath)
|
|
1927
|
+
location.workspacePath = project.path
|
|
1928
|
+
resolvedWorkspacePath = project.path
|
|
1929
|
+
} else {
|
|
1930
|
+
await this.adapter.checkProjectLocation({
|
|
1931
|
+
parentDir: location.parentDir,
|
|
1932
|
+
directoryName: location.directoryName,
|
|
1933
|
+
})
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// A pure-random reserved id, proven absent before it is offered. Even a
|
|
1938
|
+
// proven-absent id never proves a transaction: the host may still return
|
|
1939
|
+
// a different identity, and that replacement is recorded, not hidden.
|
|
1940
|
+
const requestedSessionId = `session-${this.uuid()}`
|
|
1941
|
+
if (await this.adapter.sessionExists(requestedSessionId)) {
|
|
1942
|
+
throw fail('session_id_collision', 'the reserved session id already exists; refusing to create', {
|
|
1943
|
+
requestedSessionId,
|
|
1944
|
+
})
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
const sendOperationId = mintOperationId(this.uuid(), now)
|
|
1948
|
+
const journal = {
|
|
1949
|
+
key,
|
|
1950
|
+
from,
|
|
1951
|
+
operationId,
|
|
1952
|
+
fingerprint: sealFingerprint(fingerprint),
|
|
1953
|
+
at: now,
|
|
1954
|
+
updatedAt: now,
|
|
1955
|
+
phase: 'creating',
|
|
1956
|
+
task: input.task,
|
|
1957
|
+
subject: input.subject ?? null,
|
|
1958
|
+
title: input.resolvedTitle,
|
|
1959
|
+
location: structuredClone(location),
|
|
1960
|
+
requestedSessionId,
|
|
1961
|
+
sessionId: null,
|
|
1962
|
+
workspaceId: location.kind === 'workspace' ? location.workspaceId : null,
|
|
1963
|
+
workspacePath: resolvedWorkspacePath,
|
|
1964
|
+
renameStatus: 'not_attempted',
|
|
1965
|
+
renameError: null,
|
|
1966
|
+
sendOperationId,
|
|
1967
|
+
firstMessageId: null,
|
|
1968
|
+
error: null,
|
|
1969
|
+
terminalAt: null,
|
|
1970
|
+
bodyStripped: false,
|
|
1971
|
+
}
|
|
1972
|
+
const claimed = await this.#mutate(
|
|
1973
|
+
(state) => {
|
|
1974
|
+
const duplicate = this.#lookupCreate(state, from, operationId)
|
|
1975
|
+
if (duplicate !== undefined) {
|
|
1976
|
+
if (!fingerprintMatches(duplicate.fingerprint, fingerprint)) {
|
|
1977
|
+
throw fail('conflict_operation', 'operationId was already used with different create inputs', { operationId })
|
|
1978
|
+
}
|
|
1979
|
+
return skipMutation(false)
|
|
1980
|
+
}
|
|
1981
|
+
// The intent body joins the same 16 MiB undelivered envelope, checked
|
|
1982
|
+
// before this journal is persisted and before any external effect.
|
|
1983
|
+
this.#assertQueueCapacity(state, createJournalPayloadBytes(state, journal))
|
|
1984
|
+
state.creates = { ...(state.creates ?? {}), [key]: structuredClone(journal) }
|
|
1985
|
+
return true
|
|
1986
|
+
},
|
|
1987
|
+
{ delivery: true },
|
|
1988
|
+
)
|
|
1989
|
+
if (claimed !== true) return this.#createAdvance(key, { idempotent: true })
|
|
1990
|
+
return this.#runCreateEffects(key, { idempotent: false })
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/**
|
|
1994
|
+
* Run the external effect chain for a journal this process just created.
|
|
1995
|
+
* Each effect persists its real outcome before the next effect starts; a
|
|
1996
|
+
* failure after the first side effect is reported in the returned view.
|
|
1997
|
+
* @param {string} key
|
|
1998
|
+
* @param {{ idempotent: boolean }} options
|
|
1999
|
+
*/
|
|
2000
|
+
async #runCreateEffects(key, options) {
|
|
2001
|
+
let journal = this.#createJournal(key)
|
|
2002
|
+
if (journal === undefined) throw fail('internal', 'create journal disappeared')
|
|
2003
|
+
|
|
2004
|
+
if (journal.location.kind === 'new_project' && journal.workspacePath === null) {
|
|
2005
|
+
let directory
|
|
2006
|
+
try {
|
|
2007
|
+
directory = await this.adapter.ensureProjectDirectory({
|
|
2008
|
+
parentDir: journal.location.parentDir,
|
|
2009
|
+
directoryName: journal.location.directoryName,
|
|
2010
|
+
})
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
await this.#journalCreateFailure(key, error)
|
|
2013
|
+
return this.#createView(key, options.idempotent)
|
|
2014
|
+
}
|
|
2015
|
+
await this.#mutate(
|
|
2016
|
+
(state) => {
|
|
2017
|
+
const current = state.creates?.[key]
|
|
2018
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2019
|
+
current.workspacePath = directory.path
|
|
2020
|
+
current.updatedAt = this.clock()
|
|
2021
|
+
return true
|
|
2022
|
+
},
|
|
2023
|
+
{ delivery: true },
|
|
2024
|
+
)
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
journal = this.#createJournal(key)
|
|
2028
|
+
if (journal.workspaceId === null) {
|
|
2029
|
+
let workspace
|
|
2030
|
+
try {
|
|
2031
|
+
workspace = await this.adapter.createWorkspace(
|
|
2032
|
+
journal.workspacePath,
|
|
2033
|
+
journal.location.workspaceTitle ?? (journal.location.kind === 'existing_project' ? undefined : journal.title),
|
|
2034
|
+
)
|
|
2035
|
+
} catch (error) {
|
|
2036
|
+
await this.#journalCreateFailure(key, error)
|
|
2037
|
+
return this.#createView(key, options.idempotent)
|
|
2038
|
+
}
|
|
2039
|
+
await this.#mutate(
|
|
2040
|
+
(state) => {
|
|
2041
|
+
const current = state.creates?.[key]
|
|
2042
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2043
|
+
current.workspaceId = workspace.id
|
|
2044
|
+
current.workspacePath = workspace.path
|
|
2045
|
+
current.location.workspaceTitle = workspace.title
|
|
2046
|
+
current.updatedAt = this.clock()
|
|
2047
|
+
return true
|
|
2048
|
+
},
|
|
2049
|
+
{ delivery: true },
|
|
2050
|
+
)
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
journal = this.#createJournal(key)
|
|
2054
|
+
if (journal.sessionId === null) {
|
|
2055
|
+
let created
|
|
2056
|
+
try {
|
|
2057
|
+
created = await this.adapter.createSession({
|
|
2058
|
+
workspaceId: journal.workspaceId,
|
|
2059
|
+
sessionId: journal.requestedSessionId,
|
|
2060
|
+
})
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
// The host attach failure carries the real created id in its details;
|
|
2063
|
+
// a pre-create rejection proves no session; anything else may have
|
|
2064
|
+
// created a session, so the operation stops as uncertain.
|
|
2065
|
+
const actualId = typeof error?.details?.sessionId === 'string' ? error.details.sessionId : null
|
|
2066
|
+
if (actualId !== null && actualId.length > 0) {
|
|
2067
|
+
await this.#mutate(
|
|
2068
|
+
(state) => {
|
|
2069
|
+
const current = state.creates?.[key]
|
|
2070
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2071
|
+
current.sessionId = actualId
|
|
2072
|
+
current.phase = 'attach_failed'
|
|
2073
|
+
current.terminalAt = this.clock()
|
|
2074
|
+
current.error = createErrorView(error)
|
|
2075
|
+
current.updatedAt = this.clock()
|
|
2076
|
+
return true
|
|
2077
|
+
},
|
|
2078
|
+
{ delivery: true },
|
|
2079
|
+
)
|
|
2080
|
+
return this.#createView(key, options.idempotent)
|
|
2081
|
+
}
|
|
2082
|
+
if (PRE_CREATE_FAILURE_CODES.has(errorCode(error))) {
|
|
2083
|
+
await this.#journalCreateFailure(key, error)
|
|
2084
|
+
return this.#createView(key, options.idempotent)
|
|
2085
|
+
}
|
|
2086
|
+
await this.#mutate(
|
|
2087
|
+
(state) => {
|
|
2088
|
+
const current = state.creates?.[key]
|
|
2089
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2090
|
+
current.phase = 'uncertain'
|
|
2091
|
+
current.error = createErrorView(
|
|
2092
|
+
error,
|
|
2093
|
+
'the create result could not be confirmed; a session may already exist — verify manually and do not delete anything',
|
|
2094
|
+
)
|
|
2095
|
+
current.updatedAt = this.clock()
|
|
2096
|
+
return true
|
|
2097
|
+
},
|
|
2098
|
+
{ delivery: true },
|
|
2099
|
+
)
|
|
2100
|
+
return this.#createView(key, options.idempotent)
|
|
2101
|
+
}
|
|
2102
|
+
await this.#mutate(
|
|
2103
|
+
(state) => {
|
|
2104
|
+
const current = state.creates?.[key]
|
|
2105
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2106
|
+
current.sessionId = created.sessionId
|
|
2107
|
+
current.phase = 'created'
|
|
2108
|
+
current.error = null
|
|
2109
|
+
current.updatedAt = this.clock()
|
|
2110
|
+
return true
|
|
2111
|
+
},
|
|
2112
|
+
{ delivery: true },
|
|
2113
|
+
)
|
|
2114
|
+
// The host create return only proves publication, not durability. The
|
|
2115
|
+
// actual live agent must flush before this is reported as persistent or
|
|
2116
|
+
// before the first message; a failed barrier is an actionable partial
|
|
2117
|
+
// result and is never a reason to rebuild the session.
|
|
2118
|
+
const barrier = await this.adapter.confirmSessionDurable(created.sessionId)
|
|
2119
|
+
if (barrier.ok !== true) {
|
|
2120
|
+
await this.#mutate(
|
|
2121
|
+
(state) => {
|
|
2122
|
+
const current = state.creates?.[key]
|
|
2123
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2124
|
+
current.error = { code: barrier.code, message: barrier.message.slice(0, 400) }
|
|
2125
|
+
current.updatedAt = this.clock()
|
|
2126
|
+
return true
|
|
2127
|
+
},
|
|
2128
|
+
{ delivery: true },
|
|
2129
|
+
)
|
|
2130
|
+
return this.#createView(key, options.idempotent)
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
return this.#createAdvance(key, options)
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Idempotent continuation of an existing journal: `creating` (a crash or a
|
|
2139
|
+
* stuck effect whose result cannot be proven) becomes `uncertain` and never
|
|
2140
|
+
* re-runs an effect; terminal phases replay their real state; otherwise the
|
|
2141
|
+
* flow resumes rename-once then the first send with the persisted
|
|
2142
|
+
* sendOperationId, which the standard send path deduplicates.
|
|
2143
|
+
* @param {string} key
|
|
2144
|
+
* @param {{ idempotent: boolean }} options
|
|
2145
|
+
*/
|
|
2146
|
+
async #createAdvance(key, options) {
|
|
2147
|
+
let journal = this.#createJournal(key)
|
|
2148
|
+
if (journal === undefined) throw fail('internal', 'create journal disappeared')
|
|
2149
|
+
if (journal.phase === 'creating') {
|
|
2150
|
+
await this.#mutate(
|
|
2151
|
+
(state) => {
|
|
2152
|
+
const current = state.creates?.[key]
|
|
2153
|
+
if (current === undefined || current.phase !== 'creating') return skipMutation(undefined)
|
|
2154
|
+
current.phase = 'uncertain'
|
|
2155
|
+
current.error = createErrorView(
|
|
2156
|
+
fail(
|
|
2157
|
+
'create_interrupted',
|
|
2158
|
+
'the plugin restarted while a create effect may have been in flight; verify the workspace/session manually before retrying',
|
|
2159
|
+
),
|
|
2160
|
+
)
|
|
2161
|
+
current.updatedAt = this.clock()
|
|
2162
|
+
return true
|
|
2163
|
+
},
|
|
2164
|
+
{ delivery: true },
|
|
2165
|
+
)
|
|
2166
|
+
journal = this.#createJournal(key)
|
|
2167
|
+
}
|
|
2168
|
+
const phase = journal.phase
|
|
2169
|
+
if (phase === 'uncertain' || phase === 'sent' || phase === 'attach_failed' || phase === 'failed') {
|
|
2170
|
+
return this.#createView(key, options.idempotent)
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
await this.#requireSender(journal.from)
|
|
2174
|
+
const settings = this.ledger.read().settings
|
|
2175
|
+
if (!settings.enabled) {
|
|
2176
|
+
throw fail('disabled', 'session-link is disabled; enable it in settings first')
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// Durability fence: never continue to rename or the first message unless
|
|
2180
|
+
// the created session's live agent confirms the host flush. A failed
|
|
2181
|
+
// barrier returns the real partial state; it never rebuilds anything.
|
|
2182
|
+
const barrier = await this.adapter.confirmSessionDurable(journal.sessionId)
|
|
2183
|
+
if (barrier.ok !== true) {
|
|
2184
|
+
await this.#mutate(
|
|
2185
|
+
(state) => {
|
|
2186
|
+
const current = state.creates?.[key]
|
|
2187
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2188
|
+
current.error = { code: barrier.code, message: barrier.message.slice(0, 400) }
|
|
2189
|
+
current.updatedAt = this.clock()
|
|
2190
|
+
return true
|
|
2191
|
+
},
|
|
2192
|
+
{ delivery: true },
|
|
2193
|
+
)
|
|
2194
|
+
return this.#createView(key, options.idempotent)
|
|
2195
|
+
}
|
|
2196
|
+
if (journal.error !== null) {
|
|
2197
|
+
await this.#mutate(
|
|
2198
|
+
(state) => {
|
|
2199
|
+
const current = state.creates?.[key]
|
|
2200
|
+
if (current === undefined || current.error === null) return skipMutation(undefined)
|
|
2201
|
+
current.error = null
|
|
2202
|
+
current.updatedAt = this.clock()
|
|
2203
|
+
return true
|
|
2204
|
+
},
|
|
2205
|
+
{ delivery: true },
|
|
2206
|
+
)
|
|
2207
|
+
journal = this.#createJournal(key)
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// Rename exactly once, after the real id is persisted and before the
|
|
2211
|
+
// first send. An interrupted claim is recorded as a failed rename, never
|
|
2212
|
+
// re-attempted automatically; a rename failure never blocks the first task.
|
|
2213
|
+
if (journal.renameStatus === 'pending') {
|
|
2214
|
+
await this.#mutate(
|
|
2215
|
+
(state) => {
|
|
2216
|
+
const current = state.creates?.[key]
|
|
2217
|
+
if (current === undefined || current.renameStatus !== 'pending') return skipMutation(undefined)
|
|
2218
|
+
current.renameStatus = 'rename_failed'
|
|
2219
|
+
current.renameError = 'interrupted'
|
|
2220
|
+
current.phase = 'rename_failed'
|
|
2221
|
+
current.updatedAt = this.clock()
|
|
2222
|
+
return true
|
|
2223
|
+
},
|
|
2224
|
+
{ delivery: true },
|
|
2225
|
+
)
|
|
2226
|
+
journal = this.#createJournal(key)
|
|
2227
|
+
}
|
|
2228
|
+
if (journal.renameStatus === 'not_attempted' && journal.sessionId !== null) {
|
|
2229
|
+
await this.#mutate(
|
|
2230
|
+
(state) => {
|
|
2231
|
+
const current = state.creates?.[key]
|
|
2232
|
+
if (current === undefined || current.renameStatus !== 'not_attempted') return skipMutation(undefined)
|
|
2233
|
+
current.renameStatus = 'pending'
|
|
2234
|
+
current.updatedAt = this.clock()
|
|
2235
|
+
return true
|
|
2236
|
+
},
|
|
2237
|
+
{ delivery: true },
|
|
2238
|
+
)
|
|
2239
|
+
try {
|
|
2240
|
+
await this.adapter.renameSession(journal.sessionId, journal.title)
|
|
2241
|
+
await this.#mutate(
|
|
2242
|
+
(state) => {
|
|
2243
|
+
const current = state.creates?.[key]
|
|
2244
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2245
|
+
current.renameStatus = 'renamed'
|
|
2246
|
+
current.renameError = null
|
|
2247
|
+
current.phase = 'renamed'
|
|
2248
|
+
current.updatedAt = this.clock()
|
|
2249
|
+
return true
|
|
2250
|
+
},
|
|
2251
|
+
{ delivery: true },
|
|
2252
|
+
)
|
|
2253
|
+
} catch (error) {
|
|
2254
|
+
await this.#mutate(
|
|
2255
|
+
(state) => {
|
|
2256
|
+
const current = state.creates?.[key]
|
|
2257
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2258
|
+
current.renameStatus = 'rename_failed'
|
|
2259
|
+
current.renameError = errorCode(error)
|
|
2260
|
+
current.phase = 'rename_failed'
|
|
2261
|
+
current.updatedAt = this.clock()
|
|
2262
|
+
return true
|
|
2263
|
+
},
|
|
2264
|
+
{ delivery: true },
|
|
2265
|
+
)
|
|
2266
|
+
}
|
|
2267
|
+
journal = this.#createJournal(key)
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
if (journal.sessionId === null) return this.#createView(key, options.idempotent)
|
|
2271
|
+
|
|
2272
|
+
const priorMessage =
|
|
2273
|
+
typeof journal.firstMessageId === 'string' && journal.firstMessageId.length > 0
|
|
2274
|
+
? this.ledger.read().messages.find((candidate) => candidate.id === journal.firstMessageId)
|
|
2275
|
+
: undefined
|
|
2276
|
+
if (typeof journal.firstMessageId === 'string' && journal.firstMessageId.length > 0 && priorMessage === undefined) {
|
|
2277
|
+
// The recorded first message is gone (pruned or corrupt): never guess
|
|
2278
|
+
// whether it was delivered and never send again.
|
|
2279
|
+
await this.#mutate(
|
|
2280
|
+
(state) => {
|
|
2281
|
+
const current = state.creates?.[key]
|
|
2282
|
+
if (current === undefined || current.phase === 'uncertain') return skipMutation(undefined)
|
|
2283
|
+
current.phase = 'uncertain'
|
|
2284
|
+
current.error = {
|
|
2285
|
+
code: 'first_message_evidence_missing',
|
|
2286
|
+
message: 'the recorded first message can no longer be verified; delivery is unknown and will not be retried automatically',
|
|
2287
|
+
}
|
|
2288
|
+
current.updatedAt = this.clock()
|
|
2289
|
+
return true
|
|
2290
|
+
},
|
|
2291
|
+
{ delivery: true },
|
|
2292
|
+
)
|
|
2293
|
+
return this.#createView(key, options.idempotent)
|
|
2294
|
+
}
|
|
2295
|
+
const priorFailed = priorMessage !== undefined && priorMessage.status === 'failed'
|
|
2296
|
+
if (priorMessage !== undefined && !priorFailed) {
|
|
2297
|
+
// accepted / held / pending / dispatching / unknown / cancelled keep
|
|
2298
|
+
// their original status; unknown is never re-sent. Reconcile a crash
|
|
2299
|
+
// window where the message exists but the journal never reached `sent`.
|
|
2300
|
+
if (journal.phase !== 'sent') {
|
|
2301
|
+
await this.#mutate(
|
|
2302
|
+
(state) => {
|
|
2303
|
+
const current = state.creates?.[key]
|
|
2304
|
+
if (current === undefined || current.phase === 'sent') return skipMutation(undefined)
|
|
2305
|
+
current.phase = 'sent'
|
|
2306
|
+
current.terminalAt = this.clock()
|
|
2307
|
+
current.error = null
|
|
2308
|
+
current.task = ''
|
|
2309
|
+
current.subject = null
|
|
2310
|
+
current.bodyStripped = true
|
|
2311
|
+
current.updatedAt = this.clock()
|
|
2312
|
+
return true
|
|
2313
|
+
},
|
|
2314
|
+
{ delivery: true },
|
|
2315
|
+
)
|
|
2316
|
+
}
|
|
2317
|
+
return this.#createView(key, options.idempotent)
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// Owed: no first message yet, or the previous attempt failed before the
|
|
2321
|
+
// host accepted it (standard send fails only before steer, so a failed
|
|
2322
|
+
// record is provably un-inserted and an explicit same-operation retry may
|
|
2323
|
+
// continue to the SAME session).
|
|
2324
|
+
if (typeof journal.task !== 'string' || journal.task.length === 0) {
|
|
2325
|
+
await this.#mutate(
|
|
2326
|
+
(state) => {
|
|
2327
|
+
const current = state.creates?.[key]
|
|
2328
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2329
|
+
current.error = {
|
|
2330
|
+
code: 'first_task_body_missing',
|
|
2331
|
+
message: 'the stored first task body is no longer present; it will not be reconstructed',
|
|
2332
|
+
}
|
|
2333
|
+
current.updatedAt = this.clock()
|
|
2334
|
+
return true
|
|
2335
|
+
},
|
|
2336
|
+
{ delivery: true },
|
|
2337
|
+
)
|
|
2338
|
+
return this.#createView(key, options.idempotent)
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
let sendOperationId = journal.sendOperationId
|
|
2342
|
+
if (priorFailed) {
|
|
2343
|
+
// The failed message record itself is the proof of an un-inserted
|
|
2344
|
+
// attempt, so a fresh persistent internal id is safe.
|
|
2345
|
+
sendOperationId = mintOperationId(this.uuid(), this.clock())
|
|
2346
|
+
await this.#mutate(
|
|
2347
|
+
(state) => {
|
|
2348
|
+
const current = state.creates?.[key]
|
|
2349
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2350
|
+
current.sendOperationId = sendOperationId
|
|
2351
|
+
current.updatedAt = this.clock()
|
|
2352
|
+
return true
|
|
2353
|
+
},
|
|
2354
|
+
{ delivery: true },
|
|
2355
|
+
)
|
|
2356
|
+
} else {
|
|
2357
|
+
const stateNow = this.ledger.read()
|
|
2358
|
+
const priorOperation = Object.values(stateNow.operations).some(
|
|
2359
|
+
(candidate) => candidate?.from === journal.from && candidate?.operationId === journal.sendOperationId,
|
|
2360
|
+
)
|
|
2361
|
+
const priorAnyMessage = stateNow.messages.some(
|
|
2362
|
+
(candidate) => candidate.operationId === journal.sendOperationId,
|
|
2363
|
+
)
|
|
2364
|
+
if (!priorOperation && !priorAnyMessage) {
|
|
2365
|
+
let submittable = true
|
|
2366
|
+
try {
|
|
2367
|
+
validateOperationId(journal.sendOperationId, this.clock())
|
|
2368
|
+
} catch {
|
|
2369
|
+
submittable = false
|
|
2370
|
+
}
|
|
2371
|
+
if (!submittable) {
|
|
2372
|
+
// Reissuing an expired internal id is allowed only with durable
|
|
2373
|
+
// proof it was never submitted: in never-attempted phases the send
|
|
2374
|
+
// was never called; in `sending` the absence of any operation or
|
|
2375
|
+
// message inside the 90-day tombstone horizon proves no record was
|
|
2376
|
+
// created-then-pruned. Otherwise the outcome stays unknown.
|
|
2377
|
+
const mintAt = parseOperationMintTime(journal.sendOperationId)
|
|
2378
|
+
const neverAttempted =
|
|
2379
|
+
journal.phase === 'created' || journal.phase === 'renamed' || journal.phase === 'rename_failed'
|
|
2380
|
+
const withinHorizon =
|
|
2381
|
+
mintAt !== null && this.clock() - mintAt <= LIMITS.OPERATION_RETENTION_MS
|
|
2382
|
+
if (neverAttempted || withinHorizon) {
|
|
2383
|
+
sendOperationId = mintOperationId(this.uuid(), this.clock())
|
|
2384
|
+
const reissued = sendOperationId
|
|
2385
|
+
await this.#mutate(
|
|
2386
|
+
(state) => {
|
|
2387
|
+
const current = state.creates?.[key]
|
|
2388
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2389
|
+
current.sendOperationId = reissued
|
|
2390
|
+
current.updatedAt = this.clock()
|
|
2391
|
+
return true
|
|
2392
|
+
},
|
|
2393
|
+
{ delivery: true },
|
|
2394
|
+
)
|
|
2395
|
+
} else {
|
|
2396
|
+
await this.#mutate(
|
|
2397
|
+
(state) => {
|
|
2398
|
+
const current = state.creates?.[key]
|
|
2399
|
+
if (current === undefined || current.phase === 'uncertain') return skipMutation(undefined)
|
|
2400
|
+
current.phase = 'uncertain'
|
|
2401
|
+
current.error = {
|
|
2402
|
+
code: 'create_send_unprovable',
|
|
2403
|
+
message:
|
|
2404
|
+
'the internal first-message id expired without durable proof that it was never submitted; delivery stays unknown and is not retried automatically',
|
|
2405
|
+
}
|
|
2406
|
+
current.updatedAt = this.clock()
|
|
2407
|
+
return true
|
|
2408
|
+
},
|
|
2409
|
+
{ delivery: true },
|
|
2410
|
+
)
|
|
2411
|
+
return this.#createView(key, options.idempotent)
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
// Otherwise the old id already owns a durable operation/message: reuse
|
|
2416
|
+
// it unchanged so the standard send path deduplicates instead of
|
|
2417
|
+
// inserting a second time.
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
if (journal.phase !== 'sending') {
|
|
2421
|
+
await this.#mutate(
|
|
2422
|
+
(state) => {
|
|
2423
|
+
const current = state.creates?.[key]
|
|
2424
|
+
if (current === undefined || current.firstMessageId !== null) return skipMutation(undefined)
|
|
2425
|
+
current.phase = 'sending'
|
|
2426
|
+
current.updatedAt = this.clock()
|
|
2427
|
+
return true
|
|
2428
|
+
},
|
|
2429
|
+
{ delivery: true },
|
|
2430
|
+
)
|
|
2431
|
+
}
|
|
2432
|
+
try {
|
|
2433
|
+
// The persisted sendOperationId makes this call idempotent inside the
|
|
2434
|
+
// standard send path: an already-inserted first message is returned,
|
|
2435
|
+
// never inserted a second time.
|
|
2436
|
+
const result = await this.send({
|
|
2437
|
+
from: journal.from,
|
|
2438
|
+
targets: [journal.sessionId],
|
|
2439
|
+
kind: 'request',
|
|
2440
|
+
text: journal.task,
|
|
2441
|
+
...(journal.subject === null || journal.subject === undefined ? {} : { subject: journal.subject }),
|
|
2442
|
+
operationId: sendOperationId,
|
|
2443
|
+
})
|
|
2444
|
+
const messageId = result?.results?.[0]?.id
|
|
2445
|
+
if (typeof messageId !== 'string' || messageId.length === 0) {
|
|
2446
|
+
throw fail('internal', 'the first message id is missing from the send result')
|
|
2447
|
+
}
|
|
2448
|
+
const recorded = this.ledger.read().messages.find((candidate) => candidate.id === messageId)
|
|
2449
|
+
if (recorded === undefined) {
|
|
2450
|
+
// Evidence is insufficient to classify the attempt: mark unknown and
|
|
2451
|
+
// do not send again.
|
|
2452
|
+
await this.#mutate(
|
|
2453
|
+
(state) => {
|
|
2454
|
+
const current = state.creates?.[key]
|
|
2455
|
+
if (current === undefined || current.phase === 'uncertain') return skipMutation(undefined)
|
|
2456
|
+
current.firstMessageId = messageId
|
|
2457
|
+
current.phase = 'uncertain'
|
|
2458
|
+
current.error = {
|
|
2459
|
+
code: 'first_message_evidence_missing',
|
|
2460
|
+
message: 'the first message record cannot be verified; delivery is unknown and will not be retried automatically',
|
|
2461
|
+
}
|
|
2462
|
+
current.updatedAt = this.clock()
|
|
2463
|
+
return true
|
|
2464
|
+
},
|
|
2465
|
+
{ delivery: true },
|
|
2466
|
+
)
|
|
2467
|
+
return this.#createView(key, options.idempotent)
|
|
2468
|
+
}
|
|
2469
|
+
if (recorded.status === 'failed') {
|
|
2470
|
+
// Provably un-inserted: keep the body and the `sending` phase so an
|
|
2471
|
+
// explicit retry of this same root operation re-sends to the same
|
|
2472
|
+
// session with a fresh internal id.
|
|
2473
|
+
await this.#mutate(
|
|
2474
|
+
(state) => {
|
|
2475
|
+
const current = state.creates?.[key]
|
|
2476
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2477
|
+
current.firstMessageId = messageId
|
|
2478
|
+
current.phase = 'sending'
|
|
2479
|
+
current.error = {
|
|
2480
|
+
code: recorded.error?.code ?? 'first_send_failed',
|
|
2481
|
+
message:
|
|
2482
|
+
recorded.error?.message ??
|
|
2483
|
+
'the first message was not accepted by the host; retry this same operation to continue to the same session',
|
|
2484
|
+
}
|
|
2485
|
+
current.updatedAt = this.clock()
|
|
2486
|
+
return true
|
|
2487
|
+
},
|
|
2488
|
+
{ delivery: true },
|
|
2489
|
+
)
|
|
2490
|
+
return this.#createView(key, options.idempotent)
|
|
2491
|
+
}
|
|
2492
|
+
await this.#mutate(
|
|
2493
|
+
(state) => {
|
|
2494
|
+
const current = state.creates?.[key]
|
|
2495
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2496
|
+
current.firstMessageId = messageId
|
|
2497
|
+
current.phase = 'sent'
|
|
2498
|
+
current.terminalAt = this.clock()
|
|
2499
|
+
current.error = null
|
|
2500
|
+
// The delivered message owns the body now; the journal keeps only
|
|
2501
|
+
// fingerprint, status, and association.
|
|
2502
|
+
current.task = ''
|
|
2503
|
+
current.subject = null
|
|
2504
|
+
current.bodyStripped = true
|
|
2505
|
+
current.updatedAt = this.clock()
|
|
2506
|
+
return true
|
|
2507
|
+
},
|
|
2508
|
+
{ delivery: true },
|
|
2509
|
+
)
|
|
2510
|
+
} catch (error) {
|
|
2511
|
+
await this.#mutate(
|
|
2512
|
+
(state) => {
|
|
2513
|
+
const current = state.creates?.[key]
|
|
2514
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2515
|
+
current.error = createErrorView(error)
|
|
2516
|
+
current.updatedAt = this.clock()
|
|
2517
|
+
return true
|
|
2518
|
+
},
|
|
2519
|
+
{ delivery: true },
|
|
2520
|
+
)
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
return this.#createView(key, options.idempotent)
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/** @param {string} key @returns {Record<string, any> | undefined} */
|
|
2527
|
+
#createJournal(key) {
|
|
2528
|
+
return this.ledger.read().creates?.[key]
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/** @param {string} key @param {unknown} error */
|
|
2532
|
+
async #journalCreateFailure(key, error) {
|
|
2533
|
+
await this.#mutate(
|
|
2534
|
+
(state) => {
|
|
2535
|
+
const current = state.creates?.[key]
|
|
2536
|
+
if (current === undefined) return skipMutation(undefined)
|
|
2537
|
+
current.phase = 'failed'
|
|
2538
|
+
current.terminalAt = this.clock()
|
|
2539
|
+
current.error = createErrorView(error)
|
|
2540
|
+
current.updatedAt = this.clock()
|
|
2541
|
+
return true
|
|
2542
|
+
},
|
|
2543
|
+
{ delivery: true },
|
|
2544
|
+
)
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* @param {string} key
|
|
2549
|
+
* @param {boolean} idempotent
|
|
2550
|
+
* @returns {Record<string, any>}
|
|
2551
|
+
*/
|
|
2552
|
+
#createView(key, idempotent) {
|
|
2553
|
+
const state = this.ledger.read()
|
|
2554
|
+
const journal = state.creates?.[key]
|
|
2555
|
+
if (journal === undefined) throw fail('internal', 'create journal disappeared')
|
|
2556
|
+
let firstMessage = null
|
|
2557
|
+
if (journal.firstMessageId !== null) {
|
|
2558
|
+
const message = state.messages.find((candidate) => candidate.id === journal.firstMessageId)
|
|
2559
|
+
firstMessage = {
|
|
2560
|
+
id: journal.firstMessageId,
|
|
2561
|
+
status: message?.status ?? 'missing',
|
|
2562
|
+
error: message?.error ?? null,
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
return {
|
|
2566
|
+
operationId: journal.operationId,
|
|
2567
|
+
idempotent,
|
|
2568
|
+
phase: journal.phase,
|
|
2569
|
+
workspace:
|
|
2570
|
+
journal.workspaceId !== null
|
|
2571
|
+
? {
|
|
2572
|
+
id: journal.workspaceId,
|
|
2573
|
+
path: journal.workspacePath,
|
|
2574
|
+
title: journal.location.workspaceTitle ?? journal.title,
|
|
2575
|
+
}
|
|
2576
|
+
: null,
|
|
2577
|
+
sessionId: journal.sessionId,
|
|
2578
|
+
requestedSessionId: journal.requestedSessionId,
|
|
2579
|
+
title: journal.title,
|
|
2580
|
+
titleStatus: journal.renameStatus,
|
|
2581
|
+
firstMessage,
|
|
2582
|
+
sendOperationId: journal.sendOperationId,
|
|
2583
|
+
error: journal.error ?? null,
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
// -------------------------------------------------------------------------
|
|
2588
|
+
// Owner card lookup
|
|
2589
|
+
// -------------------------------------------------------------------------
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* Owner-only bounded lookup for one session's cards. Only messages that
|
|
2593
|
+
* participate in `sessionId` (from or to) are returned, with the minimal
|
|
2594
|
+
* identity/topic/association/status fields; unknown ids and non-participant
|
|
2595
|
+
* records are reported as missing instead of fabricated.
|
|
2596
|
+
*
|
|
2597
|
+
* Optional `includeBody: true` grants the verified inbound business text
|
|
2598
|
+
* for exactly one case: the requested id equals the message's durable host
|
|
2599
|
+
* id AND `sessionId` is the recipient. The body is the raw ledger content
|
|
2600
|
+
* text (never executed, no paths read, never derived from other records or
|
|
2601
|
+
* the journal); a redacted message yields `body: null` with
|
|
2602
|
+
* `bodyState: 'redacted'`. Without the grant no body field is added, so the
|
|
2603
|
+
* default response shape is unchanged.
|
|
2604
|
+
* @param {Record<string, unknown>} request
|
|
2605
|
+
* @returns {Record<string, any>}
|
|
2606
|
+
*/
|
|
2607
|
+
cardLookup(request) {
|
|
2608
|
+
const object = assertPlainObject(request, 'card_lookup request')
|
|
2609
|
+
assertAllowedKeys(object, ['sessionId', 'ids', 'includeBody'], 'card_lookup request')
|
|
2610
|
+
if (object.includeBody !== undefined && typeof object.includeBody !== 'boolean') {
|
|
2611
|
+
throw fail('invalid_argument', 'includeBody must be a boolean')
|
|
2612
|
+
}
|
|
2613
|
+
const includeBody = object.includeBody === true
|
|
2614
|
+
const sessionId = requiredString(object.sessionId, 'sessionId', { min: 1, max: 256 })
|
|
2615
|
+
if (!Array.isArray(object.ids)) throw fail('invalid_argument', 'ids must be an array')
|
|
2616
|
+
if (object.ids.length === 0 || object.ids.length > LIMITS.MAX_CARD_LOOKUP_IDS) {
|
|
2617
|
+
throw fail(
|
|
2618
|
+
'invalid_argument',
|
|
2619
|
+
`ids must contain between 1 and ${LIMITS.MAX_CARD_LOOKUP_IDS} entries`,
|
|
2620
|
+
{ maxIds: LIMITS.MAX_CARD_LOOKUP_IDS },
|
|
2621
|
+
)
|
|
2622
|
+
}
|
|
2623
|
+
const ids = object.ids.map((id, index) => requiredString(id, `ids[${index}]`, { min: 1, max: 512 }))
|
|
2624
|
+
const state = this.ledger.read()
|
|
2625
|
+
const results = []
|
|
2626
|
+
const missing = []
|
|
2627
|
+
const emitted = new Set()
|
|
2628
|
+
// One durable message is one card, however many of its ids were asked;
|
|
2629
|
+
// the entry stays addressable so a later exact durable-id request can
|
|
2630
|
+
// still grant the body regardless of id order.
|
|
2631
|
+
const entryByMessageId = new Map()
|
|
2632
|
+
for (const id of ids) {
|
|
2633
|
+
if (emitted.has(id)) continue
|
|
2634
|
+
emitted.add(id)
|
|
2635
|
+
const message = state.messages.find(
|
|
2636
|
+
(candidate) =>
|
|
2637
|
+
candidate.id === id ||
|
|
2638
|
+
(typeof candidate.durableMessageId === 'string' && candidate.durableMessageId === id),
|
|
2639
|
+
)
|
|
2640
|
+
if (message === undefined || (message.from !== sessionId && message.to !== sessionId)) {
|
|
2641
|
+
missing.push(id)
|
|
2642
|
+
continue
|
|
2643
|
+
}
|
|
2644
|
+
const existingEntry = entryByMessageId.get(message.id)
|
|
2645
|
+
if (existingEntry !== undefined) {
|
|
2646
|
+
grantInboundBody(existingEntry, message, id, sessionId, includeBody)
|
|
2647
|
+
continue
|
|
2648
|
+
}
|
|
2649
|
+
const entry = {
|
|
2650
|
+
id: message.id,
|
|
2651
|
+
durableMessageId: message.durableMessageId ?? null,
|
|
2652
|
+
from: message.from,
|
|
2653
|
+
to: message.to,
|
|
2654
|
+
fromTitle: message.fromTitle ?? null,
|
|
2655
|
+
toTitle: message.toTitle ?? null,
|
|
2656
|
+
kind: message.kind,
|
|
2657
|
+
subject: displaySubject(message),
|
|
2658
|
+
chainId: message.chainId,
|
|
2659
|
+
parentMessageId: message.parentMessageId ?? null,
|
|
2660
|
+
status: message.status,
|
|
2661
|
+
replyStatus: message.replyStatus,
|
|
2662
|
+
receipt: structuredClone(message.receipt ?? null),
|
|
2663
|
+
}
|
|
2664
|
+
grantInboundBody(entry, message, id, sessionId, includeBody)
|
|
2665
|
+
entryByMessageId.set(message.id, entry)
|
|
2666
|
+
results.push(entry)
|
|
2667
|
+
}
|
|
2668
|
+
return { results, missing }
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// -------------------------------------------------------------------------
|
|
2672
|
+
// Identity and eligibility
|
|
2673
|
+
// -------------------------------------------------------------------------
|
|
2674
|
+
|
|
2675
|
+
/** @param {string} sessionId */
|
|
2676
|
+
async #requireSender(sessionId) {
|
|
2677
|
+
const info = await this.adapter.getSession(sessionId, { fresh: true })
|
|
2678
|
+
if (info === undefined || info === null) throw fail('identity_unregistered', 'caller session is not registered')
|
|
2679
|
+
if (info.subagent === true) throw fail('identity_subagent', 'subagent sessions cannot use session-link')
|
|
2680
|
+
if (info.archived === true) throw fail('identity_archived', 'archived sessions cannot send')
|
|
2681
|
+
if (info.registered === false) throw fail('identity_unregistered', 'caller session is not a registered ordinary session')
|
|
2682
|
+
if (info.formatStatus === 'migration-required') throw fail('identity_migration_required', 'caller session requires migration')
|
|
2683
|
+
if (info.eligible === false) throw fail('identity_unavailable', 'caller session is not an eligible ordinary session')
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
/** @param {string} sessionId */
|
|
2687
|
+
async #requireTarget(sessionId) {
|
|
2688
|
+
const info = await this.adapter.getSession(sessionId, { fresh: true })
|
|
2689
|
+
if (info === undefined || info === null) throw fail('target_unregistered', 'target session is not registered')
|
|
2690
|
+
if (info.subagent === true) throw fail('target_subagent', 'target is a subagent session')
|
|
2691
|
+
if (info.archived === true) throw fail('target_archived', 'target session is archived; archived sessions are history-only')
|
|
2692
|
+
if (info.registered === false) throw fail('target_unregistered', 'target is not a registered ordinary session')
|
|
2693
|
+
if (info.formatStatus === 'migration-required') throw fail('target_migration_required', 'target session requires migration')
|
|
2694
|
+
if (info.eligible === false) throw fail('target_unavailable', 'target session is not eligible for delivery')
|
|
2695
|
+
return info
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// -------------------------------------------------------------------------
|
|
2699
|
+
// Dispatch pipeline
|
|
2700
|
+
// -------------------------------------------------------------------------
|
|
2701
|
+
|
|
2702
|
+
/** @param {string[]} ids */
|
|
2703
|
+
async #dispatchBatch(ids) {
|
|
2704
|
+
for (const id of ids) this.#starting.add(id)
|
|
2705
|
+
try {
|
|
2706
|
+
await Promise.all(ids.map((id) => this.#withSlot(() => this.#dispatchOne(id))))
|
|
2707
|
+
} finally {
|
|
2708
|
+
for (const id of ids) this.#starting.delete(id)
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
/** @template T @param {() => Promise<T>} task @returns {Promise<T>} */
|
|
2713
|
+
async #withSlot(task) {
|
|
2714
|
+
const acquired = await this.#acquireSlot()
|
|
2715
|
+
if (!acquired) return undefined
|
|
2716
|
+
try {
|
|
2717
|
+
return await task()
|
|
2718
|
+
} finally {
|
|
2719
|
+
this.#releaseSlot()
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
/** @returns {Promise<boolean>} */
|
|
2724
|
+
async #acquireSlot() {
|
|
2725
|
+
while (true) {
|
|
2726
|
+
if (this.#disposed) return false
|
|
2727
|
+
const max = this.ledger.read().settings.maxConcurrent
|
|
2728
|
+
if (this.#slots < max) {
|
|
2729
|
+
this.#slots += 1
|
|
2730
|
+
return true
|
|
2731
|
+
}
|
|
2732
|
+
await new Promise((resolve) => this.#waiters.push(resolve))
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
#releaseSlot() {
|
|
2737
|
+
this.#slots -= 1
|
|
2738
|
+
const next = this.#waiters.shift()
|
|
2739
|
+
if (next !== undefined) next()
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
/**
|
|
2743
|
+
* A settings save that raises maxConcurrent must wake every sleeping slot
|
|
2744
|
+
* waiter immediately; the old slot owner may stay blocked in the host. Each
|
|
2745
|
+
* waiter loops back through #acquireSlot and re-reads the latest durable cap,
|
|
2746
|
+
* so waking all is safe for increases and requeues them for decreases.
|
|
2747
|
+
*/
|
|
2748
|
+
#wakeSlotWaiters() {
|
|
2749
|
+
if (this.#disposed) return
|
|
2750
|
+
const waiters = this.#waiters.splice(0)
|
|
2751
|
+
for (const resolve of waiters) resolve()
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
/** @param {string} id */
|
|
2755
|
+
async #dispatchOne(id) {
|
|
2756
|
+
if (this.#disposed || !this.ledger.writable) return
|
|
2757
|
+
const message = this.#message(id)
|
|
2758
|
+
if (message === undefined || message.status !== 'pending') return
|
|
2759
|
+
const token = { id, aborted: false }
|
|
2760
|
+
this.#attempts.add(token)
|
|
2761
|
+
try {
|
|
2762
|
+
if (this.#disposed || token.aborted) return
|
|
2763
|
+
const chain = this.ledger.read().chains[message.chainId]
|
|
2764
|
+
if (chain === undefined) {
|
|
2765
|
+
await this.#markFailed(id, 'chain_missing', 'chain is missing')
|
|
2766
|
+
return
|
|
2767
|
+
}
|
|
2768
|
+
if (chain.state === 'closed') {
|
|
2769
|
+
await this.#cancelPending(id, 'chain_closed', 'chain closed before delivery')
|
|
2770
|
+
return
|
|
2771
|
+
}
|
|
2772
|
+
if (chain.state === 'paused') return
|
|
2773
|
+
const preflight = dispatchBlockers(this.ledger.read(), message)
|
|
2774
|
+
if (preflight.includes('disabled') || preflight.includes('paused')) {
|
|
2775
|
+
await this.#heldIfPending(id, preflight.includes('disabled') ? 'disabled' : 'paused')
|
|
2776
|
+
return
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
let built
|
|
2780
|
+
try {
|
|
2781
|
+
built = await this.adapter.buildDelivery(this.#publicMessage(message))
|
|
2782
|
+
} catch {
|
|
2783
|
+
await this.#markFailed(id, 'message_build_failed', 'host message could not be constructed')
|
|
2784
|
+
return
|
|
2785
|
+
}
|
|
2786
|
+
const durableMessageId = built?.durableMessageId
|
|
2787
|
+
if (typeof durableMessageId !== 'string' || durableMessageId.length === 0) {
|
|
2788
|
+
await this.#markFailed(id, 'message_build_failed', 'host message id is missing')
|
|
2789
|
+
return
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
const marked = await this.#mutate((state) => {
|
|
2793
|
+
const current = findMessage(state, id)
|
|
2794
|
+
if (current?.status !== 'pending') return skipMutation(false)
|
|
2795
|
+
current.status = 'dispatching'
|
|
2796
|
+
current.durableMessageId = durableMessageId
|
|
2797
|
+
current.dispatchStartedAt = this.clock()
|
|
2798
|
+
delete current.heldOverflow
|
|
2799
|
+
current.deliveryAttempts = (current.deliveryAttempts ?? 0) + 1
|
|
2800
|
+
current.error = null
|
|
2801
|
+
this.#indexReceiptMessage(current)
|
|
2802
|
+
return true
|
|
2803
|
+
})
|
|
2804
|
+
if (marked !== true) return
|
|
2805
|
+
if (this.#disposed || token.aborted) return
|
|
2806
|
+
|
|
2807
|
+
let outcome
|
|
2808
|
+
try {
|
|
2809
|
+
outcome = await this.adapter.deliver({
|
|
2810
|
+
targetId: message.to,
|
|
2811
|
+
hostMessage: built.hostMessage,
|
|
2812
|
+
beforeResolve: () => this.#gateLive(id, false, token),
|
|
2813
|
+
beforeFollowup: () => this.#gateLive(id, true, token),
|
|
2814
|
+
})
|
|
2815
|
+
} catch (error) {
|
|
2816
|
+
outcome = { stage: 'followup', error: { code: errorCode(error) } }
|
|
2817
|
+
}
|
|
2818
|
+
if (this.#disposed || token.aborted) return
|
|
2819
|
+
if (!this.ledger.writable) return
|
|
2820
|
+
const classified = classifyDeliveryOutcome(outcome)
|
|
2821
|
+
if (classified.status === 'accepted') {
|
|
2822
|
+
await this.#acceptMessageById(id, this.clock(), { recoveryEvidence: undefined })
|
|
2823
|
+
await this.#reconcileAcceptedPresence(id)
|
|
2824
|
+
return
|
|
2825
|
+
}
|
|
2826
|
+
if (classified.status === 'failed') {
|
|
2827
|
+
await this.#markFailed(id, classified.code, classified.message)
|
|
2828
|
+
return
|
|
2829
|
+
}
|
|
2830
|
+
if (classified.status === 'unknown') {
|
|
2831
|
+
await this.#markUnknown(id, classified.code, classified.message)
|
|
2832
|
+
return
|
|
2833
|
+
}
|
|
2834
|
+
await this.#applyBlocked(id, classified)
|
|
2835
|
+
} finally {
|
|
2836
|
+
this.#attempts.delete(token)
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
/**
|
|
2841
|
+
* Recheck the serialized pause/disable gate immediately before resolve and
|
|
2842
|
+
* again immediately before followup. There is no business budget reservation
|
|
2843
|
+
* here; only the final synchronous commit gate remains.
|
|
2844
|
+
* @param {string} id
|
|
2845
|
+
* @param {boolean} beforeFollowup
|
|
2846
|
+
* @param {{ id: string, aborted: boolean }} token
|
|
2847
|
+
*/
|
|
2848
|
+
async #gateLive(id, beforeFollowup, token) {
|
|
2849
|
+
if (this.#disposed || token.aborted) {
|
|
2850
|
+
return { ok: false, action: 'cancelled', code: 'disposed', message: 'session-link is shutting down' }
|
|
2851
|
+
}
|
|
2852
|
+
if (!this.ledger.writable) {
|
|
2853
|
+
return { ok: false, action: 'cancelled', code: 'storage_error', message: 'session-link storage is unavailable' }
|
|
2854
|
+
}
|
|
2855
|
+
const state = this.ledger.read()
|
|
2856
|
+
const message = findMessage(state, id)
|
|
2857
|
+
const initial = this.#checkLiveState(message)
|
|
2858
|
+
if (initial.ok !== true) return initial
|
|
2859
|
+
const info = await this.adapter.getSession(message.to, { fresh: true })
|
|
2860
|
+
if (info === undefined || info === null || info.eligible === false) {
|
|
2861
|
+
return {
|
|
2862
|
+
ok: false,
|
|
2863
|
+
action: 'failed',
|
|
2864
|
+
code: info?.archived === true ? 'target_archived' : info?.subagent === true ? 'target_subagent' : 'target_unavailable',
|
|
2865
|
+
message: 'target is no longer eligible before host insertion',
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
// Re-read the durable gate synchronously after the async eligibility read.
|
|
2869
|
+
// A pause acknowledged while that read was in flight must prevent the
|
|
2870
|
+
// followup that would otherwise use the old snapshot.
|
|
2871
|
+
if (!this.ledger.writable) {
|
|
2872
|
+
return { ok: false, action: 'cancelled', code: 'storage_error', message: 'session-link storage is unavailable' }
|
|
2873
|
+
}
|
|
2874
|
+
const latest = this.ledger.read()
|
|
2875
|
+
const checked = this.#checkLiveState(findMessage(latest, id))
|
|
2876
|
+
if (checked.ok === true && beforeFollowup === true) {
|
|
2877
|
+
checked.commit = (run) => this.#commitFollowup(id, token, run)
|
|
2878
|
+
}
|
|
2879
|
+
return checked
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
/**
|
|
2883
|
+
* Final synchronous check immediately around the host followup. The caller
|
|
2884
|
+
* invokes `run()` inside this same call stack, so a pause or unload that
|
|
2885
|
+
* committed while an earlier async read was in flight cannot slip between
|
|
2886
|
+
* the final state check and the host insertion.
|
|
2887
|
+
* @param {string} id
|
|
2888
|
+
* @param {{ id: string, aborted: boolean }} token
|
|
2889
|
+
* @param {() => void} run
|
|
2890
|
+
*/
|
|
2891
|
+
#commitFollowup(id, token, run) {
|
|
2892
|
+
if (this.#disposed || token.aborted) {
|
|
2893
|
+
return { ok: false, blocked: { ok: false, action: 'cancelled', code: 'disposed', message: 'session-link is shutting down' } }
|
|
2894
|
+
}
|
|
2895
|
+
if (!this.ledger.writable) {
|
|
2896
|
+
return { ok: false, blocked: { ok: false, action: 'cancelled', code: 'storage_error', message: 'session-link storage is unavailable' } }
|
|
2897
|
+
}
|
|
2898
|
+
const state = this.ledger.read()
|
|
2899
|
+
const checked = this.#checkLiveState(findMessage(state, id))
|
|
2900
|
+
if (checked.ok !== true) return { ok: false, blocked: checked }
|
|
2901
|
+
run()
|
|
2902
|
+
return { ok: true }
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
/**
|
|
2906
|
+
* @param {Record<string, any> | undefined} message
|
|
2907
|
+
* @returns {{ ok: true } | { ok: false, action: string, code?: string, message?: string }}
|
|
2908
|
+
*/
|
|
2909
|
+
#checkLiveState(message) {
|
|
2910
|
+
if (message === undefined) {
|
|
2911
|
+
return { ok: false, action: 'cancelled', code: 'message_missing', message: 'message disappeared' }
|
|
2912
|
+
}
|
|
2913
|
+
if (message.status !== 'dispatching') {
|
|
2914
|
+
return { ok: false, action: 'cancelled', code: 'not_dispatching', message: 'message left dispatching state' }
|
|
2915
|
+
}
|
|
2916
|
+
const state = this.ledger.read()
|
|
2917
|
+
const chain = state.chains[message.chainId]
|
|
2918
|
+
if (chain === undefined) {
|
|
2919
|
+
return { ok: false, action: 'failed', code: 'chain_missing', message: 'chain is missing' }
|
|
2920
|
+
}
|
|
2921
|
+
// Close cannot withdraw an already-dispatching attempt; it is reconciled as
|
|
2922
|
+
// accepted/unknown exactly as if the close had happened after insertion.
|
|
2923
|
+
if (chain.state === 'paused') {
|
|
2924
|
+
return { ok: false, action: 'pending', code: 'chain_paused', message: 'chain paused before host insertion' }
|
|
2925
|
+
}
|
|
2926
|
+
const settings = state.settings
|
|
2927
|
+
if (!settings.enabled) {
|
|
2928
|
+
return { ok: false, action: 'held', code: 'disabled', message: 'session-link disabled before host insertion' }
|
|
2929
|
+
}
|
|
2930
|
+
if (settings.paused) {
|
|
2931
|
+
return { ok: false, action: 'held', code: 'paused', message: 'global pause set before host insertion' }
|
|
2932
|
+
}
|
|
2933
|
+
return { ok: true }
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
/**
|
|
2937
|
+
* @param {string} id
|
|
2938
|
+
* @param {{ status: string, code?: string, message?: string }} classified
|
|
2939
|
+
*/
|
|
2940
|
+
async #applyBlocked(id, classified) {
|
|
2941
|
+
await this.#mutate((state) => {
|
|
2942
|
+
const current = findMessage(state, id)
|
|
2943
|
+
if (current?.status !== 'dispatching') return skipMutation(undefined)
|
|
2944
|
+
const status = classified.status
|
|
2945
|
+
current.status = status
|
|
2946
|
+
current.error = { code: classified.code ?? 'blocked', message: classified.message ?? 'blocked' }
|
|
2947
|
+
current.releaseAuthorized = false
|
|
2948
|
+
if (status === 'held') {
|
|
2949
|
+
current.heldAt = this.clock()
|
|
2950
|
+
current.heldReason = classified.code ?? 'blocked'
|
|
2951
|
+
} else {
|
|
2952
|
+
current.heldAt = null
|
|
2953
|
+
current.heldReason = null
|
|
2954
|
+
delete current.heldOverflow
|
|
2955
|
+
}
|
|
2956
|
+
if (status === 'cancelled') {
|
|
2957
|
+
current.cancelledAt = this.clock()
|
|
2958
|
+
current.completedAt = current.cancelledAt
|
|
2959
|
+
}
|
|
2960
|
+
if (status === 'failed') {
|
|
2961
|
+
current.failedAt = this.clock()
|
|
2962
|
+
current.completedAt = current.failedAt
|
|
2963
|
+
}
|
|
2964
|
+
if (status === 'unknown') current.unknownAt = this.clock()
|
|
2965
|
+
// No host insertion happened, so the host message id cannot be recovery
|
|
2966
|
+
// evidence for a later attempt.
|
|
2967
|
+
current.durableMessageId = null
|
|
2968
|
+
return true
|
|
2969
|
+
})
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
/**
|
|
2973
|
+
* Legacy rows are only queued when native pending evidence still exists.
|
|
2974
|
+
* Context rows already became queued at acceptance; a missing legacy row is
|
|
2975
|
+
* left uncertain rather than pretending it was received.
|
|
2976
|
+
* @param {string} id
|
|
2977
|
+
* @returns {Promise<void>}
|
|
2978
|
+
*/
|
|
2979
|
+
async #reconcileAcceptedPresence(id) {
|
|
2980
|
+
if (typeof this.adapter.nativePending !== 'function' || this.#disposed) return
|
|
2981
|
+
const before = this.ledger.read()
|
|
2982
|
+
const message = findMessage(before, id)
|
|
2983
|
+
if (
|
|
2984
|
+
message?.status !== 'accepted' ||
|
|
2985
|
+
message.receipt?.policy !== 'legacy-accepted' ||
|
|
2986
|
+
message.receipt.state === 'received' ||
|
|
2987
|
+
message.receipt.state === 'queued'
|
|
2988
|
+
) {
|
|
2989
|
+
return
|
|
2990
|
+
}
|
|
2991
|
+
let presence
|
|
2992
|
+
try {
|
|
2993
|
+
presence = this.adapter.nativePending(message.to, message.durableMessageId)
|
|
2994
|
+
} catch {
|
|
2995
|
+
return
|
|
2996
|
+
}
|
|
2997
|
+
if (presence?.known !== true) return
|
|
2998
|
+
await this.#mutate((state) => {
|
|
2999
|
+
const current = findMessage(state, id)
|
|
3000
|
+
if (current?.receipt?.policy !== 'legacy-accepted' || current.receipt.state === 'received') {
|
|
3001
|
+
return skipMutation(undefined)
|
|
3002
|
+
}
|
|
3003
|
+
const next = presence.pending === true ? 'queued' : 'uncertain'
|
|
3004
|
+
if (current.receipt.state === next) return skipMutation(undefined)
|
|
3005
|
+
current.receipt.state = next
|
|
3006
|
+
return true
|
|
3007
|
+
})
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
/** @param {string} id @param {number} now @param {object} [extras] */
|
|
3011
|
+
async #acceptMessageById(id, now, extras = {}) {
|
|
3012
|
+
await this.#mutate((state) => {
|
|
3013
|
+
const message = findMessage(state, id)
|
|
3014
|
+
if (message === undefined) return skipMutation(undefined)
|
|
3015
|
+
if (message.status === 'accepted') {
|
|
3016
|
+
this.#applyAcceptedEffects(state, message, now)
|
|
3017
|
+
return true
|
|
3018
|
+
}
|
|
3019
|
+
if (message.status !== 'dispatching' && message.status !== 'unknown') return skipMutation(undefined)
|
|
3020
|
+
this.#acceptMessage(state, message, now, extras)
|
|
3021
|
+
return true
|
|
3022
|
+
})
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
/**
|
|
3026
|
+
* @param {Record<string, any>} state
|
|
3027
|
+
* @param {Record<string, any>} message
|
|
3028
|
+
* @param {number} now
|
|
3029
|
+
* @param {{ recoveryEvidence?: string, inboxSeen?: boolean }} [extras]
|
|
3030
|
+
*/
|
|
3031
|
+
#acceptMessage(state, message, now, extras = {}) {
|
|
3032
|
+
const justAccepted = message.status !== 'accepted'
|
|
3033
|
+
message.status = 'accepted'
|
|
3034
|
+
message.acceptedAt = Number.isSafeInteger(message.acceptedAt) ? message.acceptedAt : now
|
|
3035
|
+
message.dispatchStatus = 'accepted'
|
|
3036
|
+
message.error = null
|
|
3037
|
+
if (extras.recoveryEvidence !== undefined) message.recoveryEvidence = extras.recoveryEvidence
|
|
3038
|
+
if (extras.inboxSeen === true) message.inboxSeen = true
|
|
3039
|
+
this.#applyAcceptedEffects(state, message, now, { justAccepted })
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
/**
|
|
3043
|
+
* @param {Record<string, any>} state
|
|
3044
|
+
* @param {Record<string, any>} message
|
|
3045
|
+
* @param {number} now
|
|
3046
|
+
* @param {{ justAccepted?: boolean }} [options]
|
|
3047
|
+
*/
|
|
3048
|
+
#applyAcceptedEffects(state, message, now, options = {}) {
|
|
3049
|
+
if (message.receipt?.policy === 'context' && message.receipt.state === 'not_sent') {
|
|
3050
|
+
message.receipt.state = 'queued'
|
|
3051
|
+
}
|
|
3052
|
+
if (message.kind === 'request') {
|
|
3053
|
+
if (message.replyStatus === undefined || message.replyStatus === 'none') message.replyStatus = 'waiting'
|
|
3054
|
+
if (message.replyStatus === 'waiting') this.#applyRequestAcceptedTiming(state, message, options.justAccepted === true)
|
|
3055
|
+
return
|
|
3056
|
+
}
|
|
3057
|
+
if (message.kind === 'notification' || message.kind === 'reply') {
|
|
3058
|
+
if (message.receipt?.policy === 'context') {
|
|
3059
|
+
if (message.receipt.state === 'received' && Number.isSafeInteger(message.receipt.receivedAt)) {
|
|
3060
|
+
message.completedAt = message.receipt.receivedAt
|
|
3061
|
+
}
|
|
3062
|
+
} else if (message.receipt?.policy === 'legacy-accepted' || message.receipt === undefined) {
|
|
3063
|
+
if (options.justAccepted === true && !Number.isSafeInteger(message.completedAt)) {
|
|
3064
|
+
message.completedAt = message.acceptedAt
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
if (message.kind !== 'reply' || typeof message.parentMessageId !== 'string') return
|
|
3069
|
+
const parent = findMessage(state, message.parentMessageId)
|
|
3070
|
+
if (parent === undefined || parent.kind !== 'request') return
|
|
3071
|
+
if (parent.status === 'dispatching' || parent.status === 'unknown') {
|
|
3072
|
+
const parentJustAccepted = parent.status !== 'accepted'
|
|
3073
|
+
parent.status = 'accepted'
|
|
3074
|
+
parent.acceptedAt = parent.acceptedAt ?? message.acceptedAt ?? now
|
|
3075
|
+
parent.dispatchStatus = 'accepted'
|
|
3076
|
+
parent.recoveryEvidence = parent.recoveryEvidence ?? 'reply'
|
|
3077
|
+
parent.error = null
|
|
3078
|
+
this.#applyRequestAcceptedTiming(state, parent, parentJustAccepted)
|
|
3079
|
+
}
|
|
3080
|
+
if (parent.replyStatus !== 'answered') {
|
|
3081
|
+
parent.replyStatus = 'answered'
|
|
3082
|
+
parent.answeredAt = message.acceptedAt ?? now
|
|
3083
|
+
parent.completedAt = parent.answeredAt
|
|
3084
|
+
parent.answeredBy = message.id
|
|
3085
|
+
}
|
|
3086
|
+
if (parent.timedOutAt !== null && parent.timedOutAt !== undefined) {
|
|
3087
|
+
message.late = true
|
|
3088
|
+
parent.lateReplyAt = message.acceptedAt ?? now
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
/**
|
|
3093
|
+
* New context requests snapshot the current timeout and wait for exact
|
|
3094
|
+
* context evidence before a deadline exists. Legacy rows keep the 0.2 rule:
|
|
3095
|
+
* an already-existing deadline is never touched; a request that is accepted
|
|
3096
|
+
* for the first time in 0.3 gets acceptedAt + the timeout current at that
|
|
3097
|
+
* acceptance.
|
|
3098
|
+
* @param {Record<string, any>} state
|
|
3099
|
+
* @param {Record<string, any>} message
|
|
3100
|
+
* @param {boolean} justAccepted
|
|
3101
|
+
*/
|
|
3102
|
+
#applyRequestAcceptedTiming(state, message, justAccepted) {
|
|
3103
|
+
const receipt = message.receipt
|
|
3104
|
+
if (receipt?.policy === 'legacy-accepted') {
|
|
3105
|
+
if (!justAccepted || Number.isSafeInteger(message.deadlineAt)) return
|
|
3106
|
+
const deadlineAt = representableDeadline(message.acceptedAt, state.settings.requestTimeoutMs)
|
|
3107
|
+
if (deadlineAt !== undefined) message.deadlineAt = deadlineAt
|
|
3108
|
+
return
|
|
3109
|
+
}
|
|
3110
|
+
if (receipt?.policy === 'context') {
|
|
3111
|
+
if (justAccepted && receipt.timeoutMs === null) receipt.timeoutMs = state.settings.requestTimeoutMs
|
|
3112
|
+
if (receipt.state === 'received') this.#applyDeadlineFromReceipt(state, message)
|
|
3113
|
+
return
|
|
3114
|
+
}
|
|
3115
|
+
// Defensive fallback for a malformed pre-v3 row: preserve the old rule
|
|
3116
|
+
// rather than inventing a receipt-based timer.
|
|
3117
|
+
if (!Number.isSafeInteger(message.deadlineAt)) {
|
|
3118
|
+
const deadlineAt = representableDeadline(message.acceptedAt, state.settings.requestTimeoutMs)
|
|
3119
|
+
if (deadlineAt !== undefined) message.deadlineAt = deadlineAt
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
/** @param {string} id @param {string} code @param {string} message */
|
|
3124
|
+
async #markFailed(id, code, message) {
|
|
3125
|
+
await this.#mutate((state) => {
|
|
3126
|
+
const current = findMessage(state, id)
|
|
3127
|
+
if (current === undefined) return skipMutation(undefined)
|
|
3128
|
+
if (current.status === 'accepted') return skipMutation(undefined)
|
|
3129
|
+
if (current.status !== 'dispatching' && current.status !== 'pending' && current.status !== 'held') return skipMutation(undefined)
|
|
3130
|
+
current.status = 'failed'
|
|
3131
|
+
current.error = { code, message }
|
|
3132
|
+
current.failedAt = this.clock()
|
|
3133
|
+
current.completedAt = current.failedAt
|
|
3134
|
+
return true
|
|
3135
|
+
})
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
/** @param {string} id @param {string} code @param {string} message */
|
|
3139
|
+
async #markUnknown(id, code, message) {
|
|
3140
|
+
await this.#mutate((state) => {
|
|
3141
|
+
const current = findMessage(state, id)
|
|
3142
|
+
if (current === undefined) return skipMutation(undefined)
|
|
3143
|
+
if (current.status === 'accepted') return skipMutation(undefined)
|
|
3144
|
+
if (current.status !== 'dispatching') return skipMutation(undefined)
|
|
3145
|
+
this.#markUnknownInState(state, current, this.clock(), { code, message })
|
|
3146
|
+
return true
|
|
3147
|
+
})
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
/**
|
|
3151
|
+
* @param {Record<string, any>} state
|
|
3152
|
+
* @param {Record<string, any>} message
|
|
3153
|
+
* @param {number} now
|
|
3154
|
+
* @param {{ code: string, message: string }} error
|
|
3155
|
+
*/
|
|
3156
|
+
#markUnknownInState(state, message, now, error) {
|
|
3157
|
+
message.status = 'unknown'
|
|
3158
|
+
message.unknownAt = now
|
|
3159
|
+
message.error = { code: error.code, message: error.message }
|
|
3160
|
+
// The durable message id and attempt counter remain: the panel must show
|
|
3161
|
+
// that an insertion may have happened and that it is never auto-retried.
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
/** @param {string} id @param {string} code */
|
|
3165
|
+
async #heldIfPending(id, code) {
|
|
3166
|
+
await this.#mutate((state) => {
|
|
3167
|
+
const message = findMessage(state, id)
|
|
3168
|
+
if (message?.status !== 'pending') return skipMutation(undefined)
|
|
3169
|
+
message.status = 'held'
|
|
3170
|
+
message.heldAt = this.clock()
|
|
3171
|
+
message.heldReason = code
|
|
3172
|
+
message.error = { code, message: 'held until the global setting is cleared' }
|
|
3173
|
+
message.releaseAuthorized = false
|
|
3174
|
+
delete message.heldOverflow
|
|
3175
|
+
return true
|
|
3176
|
+
})
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
/** @param {string} id @param {string} code @param {string} message */
|
|
3180
|
+
async #cancelPending(id, code, message) {
|
|
3181
|
+
await this.#mutate((state) => {
|
|
3182
|
+
const current = findMessage(state, id)
|
|
3183
|
+
if (current?.status !== 'pending') return skipMutation(undefined)
|
|
3184
|
+
current.status = 'cancelled'
|
|
3185
|
+
current.cancelledAt = this.clock()
|
|
3186
|
+
current.completedAt = current.cancelledAt
|
|
3187
|
+
current.error = { code, message }
|
|
3188
|
+
return true
|
|
3189
|
+
})
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
// -------------------------------------------------------------------------
|
|
3193
|
+
// Legacy native next-turn transfer
|
|
3194
|
+
// -------------------------------------------------------------------------
|
|
3195
|
+
|
|
3196
|
+
#scheduleTransfer() {
|
|
3197
|
+
if (this.#disposed || this.#transferScheduled || this.#transferRunning) return
|
|
3198
|
+
if (!this.ledger.writable) return
|
|
3199
|
+
const settings = this.ledger.read().settings
|
|
3200
|
+
if (!settings.enabled || settings.paused) return
|
|
3201
|
+
this.#transferScheduled = true
|
|
3202
|
+
queueMicrotask(() => {
|
|
3203
|
+
this.#transferScheduled = false
|
|
3204
|
+
if (this.#disposed || !this.ledger.writable) return
|
|
3205
|
+
const task = this.#runTransferPass().catch((error) => {
|
|
3206
|
+
this.logger.warn(`session-link transfer pass failed: ${errorCode(error)}`)
|
|
3207
|
+
})
|
|
3208
|
+
this.#track(task)
|
|
3209
|
+
})
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
/** @returns {Promise<void>} */
|
|
3213
|
+
async #runTransferPass() {
|
|
3214
|
+
if (this.#disposed || this.#transferRunning || !this.ledger.writable) return
|
|
3215
|
+
const state = this.ledger.read()
|
|
3216
|
+
if (!state.settings.enabled || state.settings.paused) return
|
|
3217
|
+
this.#transferRunning = true
|
|
3218
|
+
try {
|
|
3219
|
+
const groups = this.#transferCandidates(state)
|
|
3220
|
+
let targets = 0
|
|
3221
|
+
for (const [targetId, ids] of groups) {
|
|
3222
|
+
if (this.#disposed || targets >= LIMITS.TRANSFER_TARGETS_PER_PASS) break
|
|
3223
|
+
targets += 1
|
|
3224
|
+
try {
|
|
3225
|
+
await this.#transferTarget(targetId, ids)
|
|
3226
|
+
} catch (error) {
|
|
3227
|
+
if (errorCode(error) === 'storage_error' || errorCode(error) === 'disposed') throw error
|
|
3228
|
+
this.logger.warn(`session-link transfer for ${targetId} failed: ${errorCode(error)}`)
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
} finally {
|
|
3232
|
+
this.#transferRunning = false
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
/**
|
|
3237
|
+
* Only legacy-accepted rows that are still eligible under D1 participate.
|
|
3238
|
+
* @param {Record<string, any>} state
|
|
3239
|
+
* @returns {Map<string, string[]>}
|
|
3240
|
+
*/
|
|
3241
|
+
#transferCandidates(state) {
|
|
3242
|
+
const groups = new Map()
|
|
3243
|
+
for (const message of state.messages) {
|
|
3244
|
+
const receipt = message.receipt
|
|
3245
|
+
if (receipt?.policy !== 'legacy-accepted') continue
|
|
3246
|
+
if (message.status !== 'accepted') continue
|
|
3247
|
+
if (message.kind === 'request' && message.replyStatus === 'answered') continue
|
|
3248
|
+
if (typeof message.durableMessageId !== 'string' || message.durableMessageId.length === 0) continue
|
|
3249
|
+
if (receipt.state === 'received') continue
|
|
3250
|
+
const journal = state.transfers?.[message.id]
|
|
3251
|
+
if (journal?.state === 'moved' || journal?.state === 'uncertain') continue
|
|
3252
|
+
const chain = state.chains[message.chainId]
|
|
3253
|
+
if (chain?.state !== 'open') continue
|
|
3254
|
+
let ids = groups.get(message.to)
|
|
3255
|
+
if (ids === undefined) {
|
|
3256
|
+
ids = []
|
|
3257
|
+
groups.set(message.to, ids)
|
|
3258
|
+
}
|
|
3259
|
+
ids.push(message.id)
|
|
3260
|
+
}
|
|
3261
|
+
return groups
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
/**
|
|
3265
|
+
* Recover prepared/moved journals on open. This only reads the live native
|
|
3266
|
+
* inbox (never resolves a cold Agent) plus exact receipt evidence; a changed
|
|
3267
|
+
* or missing row becomes uncertain and is never re-sent automatically.
|
|
3268
|
+
* @returns {Promise<void>}
|
|
3269
|
+
*/
|
|
3270
|
+
async #recoverTransferJournals() {
|
|
3271
|
+
if (typeof this.adapter.inspectNativeTarget !== 'function') return
|
|
3272
|
+
const state = this.ledger.read()
|
|
3273
|
+
const transfers = state.transfers
|
|
3274
|
+
if (!isPlainObject(transfers)) return
|
|
3275
|
+
for (const [messageId, journal] of Object.entries(transfers)) {
|
|
3276
|
+
if (this.#disposed) return
|
|
3277
|
+
if (journal?.state === 'uncertain') continue
|
|
3278
|
+
const message = findMessage(state, messageId)
|
|
3279
|
+
if (message === undefined) continue
|
|
3280
|
+
if (journal.state === 'prepared') {
|
|
3281
|
+
if (message.receipt?.state === 'received') {
|
|
3282
|
+
await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
|
|
3283
|
+
continue
|
|
3284
|
+
}
|
|
3285
|
+
const chain = state.chains[message.chainId]
|
|
3286
|
+
if (
|
|
3287
|
+
message.status !== 'accepted' ||
|
|
3288
|
+
(message.kind === 'request' && message.replyStatus === 'answered') ||
|
|
3289
|
+
chain?.state === 'closed'
|
|
3290
|
+
) {
|
|
3291
|
+
await this.#writeTransferState(messageId, 'uncertain', 'precondition_lost')
|
|
3292
|
+
continue
|
|
3293
|
+
}
|
|
3294
|
+
if (!state.settings.enabled || state.settings.paused) continue
|
|
3295
|
+
}
|
|
3296
|
+
let info
|
|
3297
|
+
try {
|
|
3298
|
+
info = this.adapter.inspectNativeTarget(message.to)
|
|
3299
|
+
} catch {
|
|
3300
|
+
continue
|
|
3301
|
+
}
|
|
3302
|
+
if (info?.live !== true) {
|
|
3303
|
+
if (message.receipt?.state === 'received' && journal.state !== 'moved') {
|
|
3304
|
+
await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
|
|
3305
|
+
}
|
|
3306
|
+
continue
|
|
3307
|
+
}
|
|
3308
|
+
const nextStepIds = new Set((info.nextStep ?? []).map((entry) => entry?.id))
|
|
3309
|
+
const nextTurn = (info.nextTurn ?? []).find((entry) => entry?.id === message.durableMessageId)
|
|
3310
|
+
if (nextStepIds.has(message.durableMessageId) || message.receipt?.state === 'received') {
|
|
3311
|
+
if (journal.state !== 'moved') await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
|
|
3312
|
+
continue
|
|
3313
|
+
}
|
|
3314
|
+
if (nextTurn !== undefined) {
|
|
3315
|
+
if (
|
|
3316
|
+
!isStandardSource(nextTurn.source, message.from) ||
|
|
3317
|
+
!hostMessageMatchesLedger(message, nextTurn) ||
|
|
3318
|
+
hostMessageDigest(nextTurn) !== journal.digest
|
|
3319
|
+
) {
|
|
3320
|
+
await this.#writeTransferState(messageId, 'uncertain', 'content_mismatch')
|
|
3321
|
+
continue
|
|
3322
|
+
}
|
|
3323
|
+
if (journal.state === 'moved') {
|
|
3324
|
+
// D5: the original ID is still safely in next-turn and matches, so
|
|
3325
|
+
// the same row may be attempted again once D1 still holds.
|
|
3326
|
+
await this.#writeTransferState(messageId, 'prepared', undefined)
|
|
3327
|
+
}
|
|
3328
|
+
continue
|
|
3329
|
+
}
|
|
3330
|
+
await this.#writeTransferState(messageId, 'uncertain', 'not_pending_no_evidence')
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
/**
|
|
3335
|
+
* @param {string} targetId
|
|
3336
|
+
* @param {string[]} messageIds
|
|
3337
|
+
* @returns {Promise<void>}
|
|
3338
|
+
*/
|
|
3339
|
+
async #transferTarget(targetId, messageIds) {
|
|
3340
|
+
if (typeof this.adapter.inspectNativeTarget !== 'function' || typeof this.adapter.moveNativePending !== 'function') {
|
|
3341
|
+
return
|
|
3342
|
+
}
|
|
3343
|
+
let info
|
|
3344
|
+
try {
|
|
3345
|
+
info = this.adapter.inspectNativeTarget(targetId)
|
|
3346
|
+
} catch {
|
|
3347
|
+
return
|
|
3348
|
+
}
|
|
3349
|
+
if (info?.live !== true || info.running !== true || info.archived === true || info.subagent === true) return
|
|
3350
|
+
for (const messageId of messageIds) {
|
|
3351
|
+
if (this.#disposed || !this.ledger.writable) return
|
|
3352
|
+
const state = this.ledger.read()
|
|
3353
|
+
if (!state.settings.enabled || state.settings.paused) return
|
|
3354
|
+
const message = findMessage(state, messageId)
|
|
3355
|
+
if (message === undefined || !this.#isTransferEligible(state, message)) continue
|
|
3356
|
+
const durableId = message.durableMessageId
|
|
3357
|
+
const native = (info.nextTurn ?? []).find((entry) => entry?.id === durableId)
|
|
3358
|
+
const inNextStep = (info.nextStep ?? []).some((entry) => entry?.id === durableId)
|
|
3359
|
+
const existing = state.transfers?.[messageId]
|
|
3360
|
+
if (inNextStep) {
|
|
3361
|
+
if (existing !== undefined && existing.state !== 'moved') {
|
|
3362
|
+
await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
|
|
3363
|
+
}
|
|
3364
|
+
continue
|
|
3365
|
+
}
|
|
3366
|
+
if (native === undefined) {
|
|
3367
|
+
if (existing?.state === 'prepared') {
|
|
3368
|
+
await this.#writeTransferState(messageId, 'uncertain', 'not_pending_no_evidence')
|
|
3369
|
+
}
|
|
3370
|
+
continue
|
|
3371
|
+
}
|
|
3372
|
+
if (!isStandardSource(native.source, message.from) || !hostMessageMatchesLedger(message, native)) {
|
|
3373
|
+
if (existing?.state === 'prepared') await this.#writeTransferState(messageId, 'uncertain', 'content_mismatch')
|
|
3374
|
+
continue
|
|
3375
|
+
}
|
|
3376
|
+
const digest = hostMessageDigest(native)
|
|
3377
|
+
if (existing?.state === 'prepared' && existing.digest !== digest) {
|
|
3378
|
+
await this.#writeTransferState(messageId, 'uncertain', 'content_mismatch')
|
|
3379
|
+
continue
|
|
3380
|
+
}
|
|
3381
|
+
const queuePosition = (info.nextTurn ?? []).findIndex((entry) => entry?.id === durableId)
|
|
3382
|
+
if (existing === undefined) {
|
|
3383
|
+
const prepared = await this.#persistTransferPrepared(message, native, info, digest, queuePosition)
|
|
3384
|
+
if (!prepared) continue
|
|
3385
|
+
}
|
|
3386
|
+
const guard = () => this.#transferGuard(messageId)
|
|
3387
|
+
let outcome
|
|
3388
|
+
try {
|
|
3389
|
+
outcome = this.adapter.moveNativePending({
|
|
3390
|
+
targetId,
|
|
3391
|
+
durableMessageId: durableId,
|
|
3392
|
+
senderSessionId: message.from,
|
|
3393
|
+
queuePosition,
|
|
3394
|
+
digest,
|
|
3395
|
+
guard,
|
|
3396
|
+
})
|
|
3397
|
+
} catch (error) {
|
|
3398
|
+
outcome = { outcome: 'uncertain', error: errorCode(error) }
|
|
3399
|
+
}
|
|
3400
|
+
if (!isPlainObject(outcome)) continue
|
|
3401
|
+
if (outcome.outcome === 'moved') {
|
|
3402
|
+
let flushed = false
|
|
3403
|
+
try {
|
|
3404
|
+
flushed = (await this.adapter.flushTarget(targetId)) === true
|
|
3405
|
+
} catch {
|
|
3406
|
+
flushed = false
|
|
3407
|
+
}
|
|
3408
|
+
if (this.#disposed) return
|
|
3409
|
+
if (flushed) {
|
|
3410
|
+
await this.#writeTransferState(messageId, 'moved', undefined, { at: this.clock() })
|
|
3411
|
+
} else {
|
|
3412
|
+
await this.#writeTransferState(messageId, 'uncertain', 'flush_failed')
|
|
3413
|
+
}
|
|
3414
|
+
} else if (outcome.outcome === 'rolled_back' || outcome.outcome === 'fallback_next_turn') {
|
|
3415
|
+
await this.#bumpTransferAttempt(messageId, outcome.outcome === 'fallback_next_turn' ? 'steer_fell_back_next_turn' : 'move_rolled_back')
|
|
3416
|
+
} else if (outcome.outcome === 'missing' || outcome.outcome === 'already_claimed') {
|
|
3417
|
+
await this.#writeTransferState(messageId, 'uncertain', 'not_pending_no_evidence')
|
|
3418
|
+
} else if (outcome.outcome === 'mismatch') {
|
|
3419
|
+
await this.#writeTransferState(messageId, 'uncertain', 'content_mismatch')
|
|
3420
|
+
} else if (outcome.outcome === 'uncertain' || outcome.outcome === 'threw') {
|
|
3421
|
+
await this.#writeTransferState(messageId, 'uncertain', outcome.error ?? 'move_uncertain')
|
|
3422
|
+
}
|
|
3423
|
+
if (this.#disposed) return
|
|
3424
|
+
try {
|
|
3425
|
+
info = this.adapter.inspectNativeTarget(targetId)
|
|
3426
|
+
} catch {
|
|
3427
|
+
return
|
|
3428
|
+
}
|
|
3429
|
+
if (info?.live !== true || info.running !== true) return
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
/**
|
|
3434
|
+
* @param {Record<string, any>} state
|
|
3435
|
+
* @param {Record<string, any>} message
|
|
3436
|
+
* @returns {boolean}
|
|
3437
|
+
*/
|
|
3438
|
+
#isTransferEligible(state, message) {
|
|
3439
|
+
if (message.receipt?.policy !== 'legacy-accepted') return false
|
|
3440
|
+
if (message.receipt.state === 'received') return false
|
|
3441
|
+
if (message.status !== 'accepted') return false
|
|
3442
|
+
if (message.kind === 'request' && message.replyStatus === 'answered') return false
|
|
3443
|
+
if (typeof message.durableMessageId !== 'string' || message.durableMessageId.length === 0) return false
|
|
3444
|
+
if (!state.settings.enabled || state.settings.paused) return false
|
|
3445
|
+
return state.chains[message.chainId]?.state === 'open'
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
/** @param {string} messageId @returns {boolean} */
|
|
3449
|
+
#transferGuard(messageId) {
|
|
3450
|
+
if (this.#disposed || !this.ledger.writable) return false
|
|
3451
|
+
const state = this.ledger.read()
|
|
3452
|
+
const message = findMessage(state, messageId)
|
|
3453
|
+
if (message === undefined) return false
|
|
3454
|
+
return this.#isTransferEligible(state, message)
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
/**
|
|
3458
|
+
* @param {Record<string, any>} message
|
|
3459
|
+
* @param {Record<string, any>} native
|
|
3460
|
+
* @param {Record<string, any>} info
|
|
3461
|
+
* @param {string} digest
|
|
3462
|
+
* @param {number} queuePosition
|
|
3463
|
+
* @returns {Promise<boolean>}
|
|
3464
|
+
*/
|
|
3465
|
+
async #persistTransferPrepared(message, native, info, digest, queuePosition) {
|
|
3466
|
+
const now = this.clock()
|
|
3467
|
+
let persisted = false
|
|
3468
|
+
await this.#mutate((state) => {
|
|
3469
|
+
const current = findMessage(state, message.id)
|
|
3470
|
+
if (current === undefined || !this.#isTransferEligible(state, current)) return skipMutation(undefined)
|
|
3471
|
+
state.transfers ??= {}
|
|
3472
|
+
state.transfers[message.id] = {
|
|
3473
|
+
messageId: message.id,
|
|
3474
|
+
target: current.to,
|
|
3475
|
+
durableMessageId: current.durableMessageId,
|
|
3476
|
+
hostMessage: structuredClone(native),
|
|
3477
|
+
queuePosition: queuePosition < 0 ? 0 : queuePosition,
|
|
3478
|
+
hostSessionSeq: Number.isSafeInteger(info.hostSessionSeq) ? info.hostSessionSeq : -1,
|
|
3479
|
+
at: now,
|
|
3480
|
+
digest,
|
|
3481
|
+
state: 'prepared',
|
|
3482
|
+
attempts: 0,
|
|
3483
|
+
}
|
|
3484
|
+
current.receipt.transfer = { state: 'prepared', at: now }
|
|
3485
|
+
persisted = true
|
|
3486
|
+
return true
|
|
3487
|
+
})
|
|
3488
|
+
return persisted
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3491
|
+
/**
|
|
3492
|
+
* @param {string} messageId
|
|
3493
|
+
* @param {'prepared'|'moved'|'uncertain'} transferState
|
|
3494
|
+
* @param {string} [error]
|
|
3495
|
+
* @param {{ at?: number }} [options]
|
|
3496
|
+
* @returns {Promise<void>}
|
|
3497
|
+
*/
|
|
3498
|
+
async #writeTransferState(messageId, transferState, error, options = {}) {
|
|
3499
|
+
await this.#mutate((state) => {
|
|
3500
|
+
const message = findMessage(state, messageId)
|
|
3501
|
+
const journal = state.transfers?.[messageId]
|
|
3502
|
+
if (message === undefined || journal === undefined) return skipMutation(undefined)
|
|
3503
|
+
const at = options.at ?? this.clock()
|
|
3504
|
+
journal.state = transferState
|
|
3505
|
+
journal.at = at
|
|
3506
|
+
if (error === undefined) delete journal.error
|
|
3507
|
+
else journal.error = error
|
|
3508
|
+
message.receipt.transfer = {
|
|
3509
|
+
state: transferState,
|
|
3510
|
+
at,
|
|
3511
|
+
...(error === undefined ? {} : { error }),
|
|
3512
|
+
}
|
|
3513
|
+
return true
|
|
3514
|
+
})
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
/**
|
|
3518
|
+
* @param {string} messageId
|
|
3519
|
+
* @param {string} error
|
|
3520
|
+
* @returns {Promise<void>}
|
|
3521
|
+
*/
|
|
3522
|
+
async #bumpTransferAttempt(messageId, error) {
|
|
3523
|
+
await this.#mutate((state) => {
|
|
3524
|
+
const message = findMessage(state, messageId)
|
|
3525
|
+
const journal = state.transfers?.[messageId]
|
|
3526
|
+
if (message === undefined || journal === undefined) return skipMutation(undefined)
|
|
3527
|
+
journal.attempts = (journal.attempts ?? 0) + 1
|
|
3528
|
+
if (journal.attempts >= LIMITS.TRANSFER_MAX_ATTEMPTS) {
|
|
3529
|
+
const at = this.clock()
|
|
3530
|
+
journal.state = 'uncertain'
|
|
3531
|
+
journal.at = at
|
|
3532
|
+
journal.error = error
|
|
3533
|
+
message.receipt.transfer = { state: 'uncertain', at, error }
|
|
3534
|
+
} else {
|
|
3535
|
+
message.receipt.transfer = { state: 'prepared', at: journal.at }
|
|
3536
|
+
}
|
|
3537
|
+
return true
|
|
3538
|
+
})
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
// -------------------------------------------------------------------------
|
|
3542
|
+
// Automatic pump
|
|
3543
|
+
// -------------------------------------------------------------------------
|
|
3544
|
+
|
|
3545
|
+
#schedulePump() {
|
|
3546
|
+
if (this.#disposed || this.#pumpScheduled) return
|
|
3547
|
+
this.#pumpScheduled = true
|
|
3548
|
+
queueMicrotask(() => {
|
|
3549
|
+
this.#pumpScheduled = false
|
|
3550
|
+
void this.#pump().catch(() => undefined)
|
|
3551
|
+
})
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
async #pump() {
|
|
3555
|
+
if (this.#disposed || !this.ledger.writable) return
|
|
3556
|
+
const settings = this.ledger.read().settings
|
|
3557
|
+
if (!settings.enabled || settings.paused) return
|
|
3558
|
+
while (this.#slots < settings.maxConcurrent) {
|
|
3559
|
+
if (this.#disposed) return
|
|
3560
|
+
const id = this.#nextPendingId()
|
|
3561
|
+
if (id === undefined) return
|
|
3562
|
+
this.#slots += 1
|
|
3563
|
+
this.#starting.add(id)
|
|
3564
|
+
const task = this.#dispatchOne(id)
|
|
3565
|
+
.catch((error) => this.logger.warn(`dispatch failed: ${errorCode(error)}`))
|
|
3566
|
+
.finally(() => {
|
|
3567
|
+
this.#starting.delete(id)
|
|
3568
|
+
this.#slots -= 1
|
|
3569
|
+
const next = this.#waiters.shift()
|
|
3570
|
+
if (next !== undefined) next()
|
|
3571
|
+
this.#schedulePump()
|
|
3572
|
+
})
|
|
3573
|
+
this.#track(task)
|
|
3574
|
+
}
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
/** @returns {string | undefined} */
|
|
3578
|
+
#nextPendingId() {
|
|
3579
|
+
const state = this.ledger.read()
|
|
3580
|
+
for (const message of state.messages) {
|
|
3581
|
+
if (message.status !== 'pending') continue
|
|
3582
|
+
if (this.#starting.has(message.id)) continue
|
|
3583
|
+
if (dispatchBlockers(state, message).length > 0) continue
|
|
3584
|
+
return message.id
|
|
3585
|
+
}
|
|
3586
|
+
return undefined
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
/** Wait for currently scheduled attempts and public operations to settle. */
|
|
3590
|
+
async drain() {
|
|
3591
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
3592
|
+
while (true) {
|
|
3593
|
+
const tasks = [...this.#tasks]
|
|
3594
|
+
if (tasks.length === 0 && this.#slots === 0) return
|
|
3595
|
+
if (tasks.length > 0) await Promise.allSettled(tasks)
|
|
3596
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
// -------------------------------------------------------------------------
|
|
3601
|
+
// Owner control + settings
|
|
3602
|
+
// -------------------------------------------------------------------------
|
|
3603
|
+
|
|
3604
|
+
/**
|
|
3605
|
+
* Settings CAS is independent from the message ledger revision. Exactly one
|
|
3606
|
+
* expectedSettingsRevision is accepted; old expectedRevision/legacy patches
|
|
3607
|
+
* are explicit invalid_argument/policy_removed failures.
|
|
3608
|
+
* @param {{ expectedSettingsRevision: number, patch: unknown }} request
|
|
3609
|
+
* @returns {Promise<{ revision: number, settingsRevision: number, settings: Record<string, any>, queued: number, resumed: number }>}
|
|
3610
|
+
*/
|
|
3611
|
+
async updateSettings(request) {
|
|
3612
|
+
return this.#trackOperation(() => this.#updateSettings(request))
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
async #updateSettings(request) {
|
|
3616
|
+
const object = assertPlainObject(request, 'settings request')
|
|
3617
|
+
// An old page sends expectedRevision together with removed quota fields.
|
|
3618
|
+
// The whole patch is a policy removal so the UI is told to refresh instead
|
|
3619
|
+
// of reporting a misleading argument error.
|
|
3620
|
+
if (isPlainObject(object.patch)) {
|
|
3621
|
+
const legacy = LEGACY_SETTINGS_KEYS.filter((key) => key in object.patch)
|
|
3622
|
+
if (legacy.length > 0) {
|
|
3623
|
+
throw fail('policy_removed', 'old session-link settings were removed; refresh the page and try again', {
|
|
3624
|
+
removedKeys: legacy,
|
|
3625
|
+
})
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
assertAllowedKeys(object, ['expectedSettingsRevision', 'patch'], 'settings request', 'invalid_argument')
|
|
3629
|
+
const expectedSettingsRevision = boundedInteger(object.expectedSettingsRevision, 'expectedSettingsRevision', { min: 1 })
|
|
3630
|
+
const now = this.clock()
|
|
3631
|
+
const result = await this.#mutate((state) => {
|
|
3632
|
+
if (state.settingsRevision !== expectedSettingsRevision) {
|
|
3633
|
+
throw fail('revision_conflict', 'settings changed since the panel snapshot; refresh and retry', {
|
|
3634
|
+
currentSettingsRevision: state.settingsRevision,
|
|
3635
|
+
})
|
|
3636
|
+
}
|
|
3637
|
+
const next = applySettingsPatch(state.settings, object.patch, { now })
|
|
3638
|
+
const unchanged = JSON.stringify(next) === JSON.stringify(state.settings)
|
|
3639
|
+
if (unchanged) {
|
|
3640
|
+
return skipMutation({
|
|
3641
|
+
revision: state.revision,
|
|
3642
|
+
settingsRevision: state.settingsRevision,
|
|
3643
|
+
settings: structuredClone(state.settings),
|
|
3644
|
+
queued: 0,
|
|
3645
|
+
resumed: 0,
|
|
3646
|
+
})
|
|
3647
|
+
}
|
|
3648
|
+
state.settings = next
|
|
3649
|
+
state.settingsRevision += 1
|
|
3650
|
+
const reconciled = this.#reconcileGlobalQueue(state, now)
|
|
3651
|
+
return {
|
|
3652
|
+
revision: state.revision + 1,
|
|
3653
|
+
settingsRevision: state.settingsRevision,
|
|
3654
|
+
settings: structuredClone(next),
|
|
3655
|
+
queued: reconciled.queued,
|
|
3656
|
+
resumed: reconciled.resumed,
|
|
3657
|
+
}
|
|
3658
|
+
})
|
|
3659
|
+
this.#wakeSlotWaiters()
|
|
3660
|
+
this.#schedulePump()
|
|
3661
|
+
this.#scheduleTransfer()
|
|
3662
|
+
return result
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
/**
|
|
3666
|
+
* Authenticated owner control. Every mutation is CAS-guarded on expectedRevision
|
|
3667
|
+
* and fails explicitly instead of silently overwriting another tab's change.
|
|
3668
|
+
* @param {Record<string, unknown>} request
|
|
3669
|
+
* @returns {Promise<Record<string, any>>}
|
|
3670
|
+
*/
|
|
3671
|
+
async ownerControl(request) {
|
|
3672
|
+
return this.#trackOperation(() => this.#ownerControl(request))
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
async #ownerControl(request) {
|
|
3676
|
+
const object = assertPlainObject(request, 'control request')
|
|
3677
|
+
const action = requiredString(object.action, 'action', { max: 64 })
|
|
3678
|
+
if (!CONTROL_ACTIONS.includes(action)) throw fail('invalid_argument', `unknown control action ${action}`)
|
|
3679
|
+
assertAllowedKeys(
|
|
3680
|
+
object,
|
|
3681
|
+
['expectedRevision', 'action', 'id', 'latchId', 'resolution', 'by'],
|
|
3682
|
+
'control request',
|
|
3683
|
+
)
|
|
3684
|
+
const expectedRevision = boundedInteger(object.expectedRevision, 'expectedRevision', { min: 1 })
|
|
3685
|
+
const now = this.clock()
|
|
3686
|
+
const by = optionalString(object.by, 'by', { max: 256 }) ?? 'owner'
|
|
3687
|
+
|
|
3688
|
+
if (action === 'resume_budget') {
|
|
3689
|
+
throw fail('policy_removed', 'business quotas were removed in 0.2; refresh the page', {
|
|
3690
|
+
policyRemoved: true,
|
|
3691
|
+
})
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
const id = requiredString(object.id, 'id', { min: 1, max: 512 })
|
|
3695
|
+
if (action === 'recheck') {
|
|
3696
|
+
return this.recheck(id, { revision: expectedRevision, by })
|
|
3697
|
+
}
|
|
3698
|
+
const result = await this.#mutate(
|
|
3699
|
+
(state) => {
|
|
3700
|
+
if (action === 'pause') {
|
|
3701
|
+
const chain = this.#chainById(state, id)
|
|
3702
|
+
if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
|
|
3703
|
+
if (chain.state === 'paused') throw fail('conflict', 'chain is already paused')
|
|
3704
|
+
chain.state = 'paused'
|
|
3705
|
+
chain.pausedAt = now
|
|
3706
|
+
chain.pausedReason = 'owner'
|
|
3707
|
+
return { revision: state.revision + 1, chain: this.#chainView(state, chain) }
|
|
3708
|
+
}
|
|
3709
|
+
if (action === 'resume') {
|
|
3710
|
+
const chain = this.#chainById(state, id)
|
|
3711
|
+
if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
|
|
3712
|
+
if (chain.state === 'open') throw fail('conflict', 'chain is already open')
|
|
3713
|
+
chain.state = 'open'
|
|
3714
|
+
delete chain.pausedAt
|
|
3715
|
+
delete chain.pausedReason
|
|
3716
|
+
const resumed = this.#resumeChainHeld(state, chain, now)
|
|
3717
|
+
return { revision: state.revision + 1, chain: this.#chainView(state, chain), resumed }
|
|
3718
|
+
}
|
|
3719
|
+
if (action === 'close') {
|
|
3720
|
+
const chain = this.#chainById(state, id)
|
|
3721
|
+
if (chain.state === 'closed') throw fail('conflict', 'chain is already closed')
|
|
3722
|
+
this.#closeChainInState(state, chain, now, 'owner')
|
|
3723
|
+
return { revision: state.revision + 1, chain: this.#chainView(state, chain) }
|
|
3724
|
+
}
|
|
3725
|
+
if (action === 'cancel') {
|
|
3726
|
+
const message = findMessage(state, id)
|
|
3727
|
+
if (message !== undefined) {
|
|
3728
|
+
if (!this.#cancelMessageInState(message, now, 'owner')) {
|
|
3729
|
+
throw fail('conflict', 'only a pending or held message can be cancelled')
|
|
3730
|
+
}
|
|
3731
|
+
return { revision: state.revision + 1, message: this.#messageView(state, message) }
|
|
3732
|
+
}
|
|
3733
|
+
const chain = this.#chainById(state, id)
|
|
3734
|
+
let cancelled = 0
|
|
3735
|
+
for (const message of chainMessages(state, chain.id)) {
|
|
3736
|
+
if (this.#cancelMessageInState(message, now, 'owner')) cancelled += 1
|
|
3737
|
+
}
|
|
3738
|
+
if (cancelled === 0) throw fail('conflict', 'chain has no pending or held messages')
|
|
3739
|
+
return { revision: state.revision + 1, cancelled }
|
|
3740
|
+
}
|
|
3741
|
+
if (action === 'release') {
|
|
3742
|
+
const message = findMessage(state, id)
|
|
3743
|
+
if (message !== undefined) {
|
|
3744
|
+
this.#releaseOne(state, message, now)
|
|
3745
|
+
return { revision: state.revision + 1, released: 1 }
|
|
3746
|
+
}
|
|
3747
|
+
const chain = this.#chainById(state, id)
|
|
3748
|
+
let released = 0
|
|
3749
|
+
for (const message of chainMessages(state, chain.id)) {
|
|
3750
|
+
if (message.status !== 'held') continue
|
|
3751
|
+
this.#releaseOne(state, message, now)
|
|
3752
|
+
released += 1
|
|
3753
|
+
}
|
|
3754
|
+
if (released === 0) throw fail('conflict', 'chain has no held messages')
|
|
3755
|
+
return { revision: state.revision + 1, released }
|
|
3756
|
+
}
|
|
3757
|
+
if (action === 'resolve_unknown') {
|
|
3758
|
+
const message = findMessage(state, id)
|
|
3759
|
+
if (message === undefined) throw fail('not_found', 'message not found')
|
|
3760
|
+
if (message.status !== 'unknown') throw fail('conflict', 'only an unknown record can be resolved manually')
|
|
3761
|
+
const resolution = requiredString(object.resolution, 'resolution', { max: 32 })
|
|
3762
|
+
if (resolution === 'accepted') {
|
|
3763
|
+
message.manualConfirmedAt = now
|
|
3764
|
+
message.manualConfirmedBy = by
|
|
3765
|
+
this.#acceptMessage(state, message, now, { recoveryEvidence: 'manual' })
|
|
3766
|
+
return { revision: state.revision + 1, message: this.#messageView(state, message) }
|
|
3767
|
+
}
|
|
3768
|
+
if (resolution === 'closed') {
|
|
3769
|
+
message.status = 'cancelled'
|
|
3770
|
+
message.cancelledAt = now
|
|
3771
|
+
message.completedAt = now
|
|
3772
|
+
message.resolution = 'unconfirmed'
|
|
3773
|
+
message.unconfirmedAt = now
|
|
3774
|
+
message.unconfirmedBy = by
|
|
3775
|
+
message.error = { code: 'owner_closed_unconfirmed', message: 'owner closed this record without confirming receipt' }
|
|
3776
|
+
return { revision: state.revision + 1, message: this.#messageView(state, message) }
|
|
3777
|
+
}
|
|
3778
|
+
throw fail('invalid_argument', "resolution must be 'accepted' or 'closed'")
|
|
3779
|
+
}
|
|
3780
|
+
throw fail('invalid_argument', `unknown control action ${action}`)
|
|
3781
|
+
},
|
|
3782
|
+
{ expectedRevision },
|
|
3783
|
+
)
|
|
3784
|
+
|
|
3785
|
+
this.#schedulePump()
|
|
3786
|
+
return result
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
/**
|
|
3790
|
+
* Read-only evidence recheck for an unknown/dispatching record.
|
|
3791
|
+
* @param {string} id
|
|
3792
|
+
* @param {{ revision?: number, by?: string }} [options]
|
|
3793
|
+
*/
|
|
3794
|
+
async recheck(id, options = {}) {
|
|
3795
|
+
return this.#trackOperation(() => this.#recheck(id, options))
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
async #recheck(id, options) {
|
|
3799
|
+
const before = this.#message(id)
|
|
3800
|
+
if (before === undefined) throw fail('not_found', 'message not found')
|
|
3801
|
+
if (before.status !== 'unknown' && before.status !== 'dispatching') {
|
|
3802
|
+
throw fail('conflict', 'only an unknown or dispatching record can be rechecked')
|
|
3803
|
+
}
|
|
3804
|
+
let evidence
|
|
3805
|
+
try {
|
|
3806
|
+
evidence = await this.adapter.readEvidence({
|
|
3807
|
+
targetId: before.to,
|
|
3808
|
+
durableMessageId: before.durableMessageId,
|
|
3809
|
+
senderSessionId: before.from,
|
|
3810
|
+
})
|
|
3811
|
+
} catch (error) {
|
|
3812
|
+
evidence = { found: false, readable: false, errorCode: errorCode(error) }
|
|
3813
|
+
}
|
|
3814
|
+
return this.#mutate(
|
|
3815
|
+
(state) => {
|
|
3816
|
+
const message = findMessage(state, id)
|
|
3817
|
+
if (message === undefined) throw fail('not_found', 'message not found')
|
|
3818
|
+
if (message.status !== 'unknown' && message.status !== 'dispatching') {
|
|
3819
|
+
throw fail('conflict', 'message is no longer unknown or dispatching')
|
|
3820
|
+
}
|
|
3821
|
+
if (evidence?.found === true && evidence.identityConflict !== true) {
|
|
3822
|
+
this.#acceptMessage(state, message, this.clock(), {
|
|
3823
|
+
recoveryEvidence: evidence.evidence ?? 'manual_recheck',
|
|
3824
|
+
inboxSeen: evidence.inboxSeen === true,
|
|
3825
|
+
})
|
|
3826
|
+
if (
|
|
3827
|
+
evidence.evidence === 'message' &&
|
|
3828
|
+
Number.isSafeInteger(evidence.seq) &&
|
|
3829
|
+
Number.isSafeInteger(evidence.time) &&
|
|
3830
|
+
evidence.seq >= 0 &&
|
|
3831
|
+
evidence.time >= 0
|
|
3832
|
+
) {
|
|
3833
|
+
this.#markReceivedInState(state, message, evidence.time, evidence.seq, this.clock())
|
|
3834
|
+
}
|
|
3835
|
+
return { revision: state.revision + 1, status: 'accepted', evidence: evidence.evidence ?? 'host' }
|
|
3836
|
+
}
|
|
3837
|
+
this.#markUnknownInState(state, message, this.clock(), {
|
|
3838
|
+
code:
|
|
3839
|
+
evidence?.identityConflict === true
|
|
3840
|
+
? 'identity_conflict'
|
|
3841
|
+
: evidence?.readable === false
|
|
3842
|
+
? 'evidence_unreadable'
|
|
3843
|
+
: 'no_insertion_evidence',
|
|
3844
|
+
message: 'recheck found no valid insertion evidence; the record remains unknown',
|
|
3845
|
+
})
|
|
3846
|
+
return { revision: state.revision + 1, status: 'unknown', evidence: 'none' }
|
|
3847
|
+
},
|
|
3848
|
+
options.revision === undefined ? {} : { expectedRevision: options.revision },
|
|
3849
|
+
)
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
/**
|
|
3853
|
+
* Model-facing control surface: pause/close only a chain the caller opened,
|
|
3854
|
+
* and cancel only the caller's own pending/held records. No global switches,
|
|
3855
|
+
* no budget release, no unknown resolution, no resume.
|
|
3856
|
+
* @param {{ from: string, action: string, id: string }} request
|
|
3857
|
+
*/
|
|
3858
|
+
async modelControl(request) {
|
|
3859
|
+
return this.#trackOperation(() => this.#modelControl(request))
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
async #modelControl(request) {
|
|
3863
|
+
const object = assertPlainObject(request, 'model control request')
|
|
3864
|
+
assertAllowedKeys(object, ['from', 'action', 'id'], 'model control request')
|
|
3865
|
+
const from = requiredString(object.from, 'from', { min: 1, max: 256 })
|
|
3866
|
+
const action = requiredString(object.action, 'action', { max: 32 })
|
|
3867
|
+
const id = requiredString(object.id, 'id', { min: 1, max: 512 })
|
|
3868
|
+
const now = this.clock()
|
|
3869
|
+
return this.#mutate((state) => {
|
|
3870
|
+
if (action === 'cancel') {
|
|
3871
|
+
const message = findMessage(state, id)
|
|
3872
|
+
if (message !== undefined) {
|
|
3873
|
+
if (message.from !== from) throw fail('forbidden', 'a model may cancel only its own pending messages')
|
|
3874
|
+
if (!this.#cancelMessageInState(message, now, 'model')) {
|
|
3875
|
+
throw fail('conflict', 'only a pending or held message can be cancelled')
|
|
3876
|
+
}
|
|
3877
|
+
return { revision: state.revision + 1, message: this.#messageView(state, message) }
|
|
3878
|
+
}
|
|
3879
|
+
const chain = this.#chainById(state, id)
|
|
3880
|
+
if (chain.rootOwner !== from) throw fail('forbidden', 'only the root owner may cancel a chain')
|
|
3881
|
+
let cancelled = 0
|
|
3882
|
+
for (const message of chainMessages(state, chain.id)) {
|
|
3883
|
+
if (message.from !== from) continue
|
|
3884
|
+
if (this.#cancelMessageInState(message, now, 'model')) cancelled += 1
|
|
3885
|
+
}
|
|
3886
|
+
if (cancelled === 0) throw fail('conflict', 'chain has no pending or held messages owned by the caller')
|
|
3887
|
+
return { revision: state.revision + 1, cancelled }
|
|
3888
|
+
}
|
|
3889
|
+
if (action === 'pause' || action === 'close') {
|
|
3890
|
+
const chain = this.#chainById(state, id)
|
|
3891
|
+
if (chain.rootOwner !== from) throw fail('forbidden', 'only the root owner may pause or close this chain')
|
|
3892
|
+
if (action === 'pause') {
|
|
3893
|
+
if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
|
|
3894
|
+
if (chain.state === 'paused') throw fail('conflict', 'chain is already paused')
|
|
3895
|
+
chain.state = 'paused'
|
|
3896
|
+
chain.pausedAt = now
|
|
3897
|
+
chain.pausedReason = 'root_owner'
|
|
3898
|
+
return { revision: state.revision + 1, chain: this.#chainView(state, chain) }
|
|
3899
|
+
}
|
|
3900
|
+
if (chain.state === 'closed') throw fail('conflict', 'chain is already closed')
|
|
3901
|
+
this.#closeChainInState(state, chain, now, 'root_owner')
|
|
3902
|
+
return { revision: state.revision + 1, chain: this.#chainView(state, chain) }
|
|
3903
|
+
}
|
|
3904
|
+
throw fail('invalid_argument', 'model control accepts cancel, pause, or close')
|
|
3905
|
+
})
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
// -------------------------------------------------------------------------
|
|
3909
|
+
// Discovery, history, status, snapshot
|
|
3910
|
+
// -------------------------------------------------------------------------
|
|
3911
|
+
|
|
3912
|
+
/**
|
|
3913
|
+
* @param {Record<string, unknown>} [request]
|
|
3914
|
+
* @returns {Promise<Record<string, any>>}
|
|
3915
|
+
*/
|
|
3916
|
+
async list(request = {}) {
|
|
3917
|
+
const object = assertPlainObject(request, 'list request')
|
|
3918
|
+
assertAllowedKeys(object, ['workspace', 'title', 'query', 'cursor', 'limit', 'scope'], 'list request')
|
|
3919
|
+
const scope = object.scope === undefined ? 'sessions' : requiredString(object.scope, 'scope', { max: 16 })
|
|
3920
|
+
if (scope === 'workspaces') return this.#listWorkspaces(object)
|
|
3921
|
+
if (scope !== 'sessions') throw fail('invalid_argument', "scope must be 'sessions' or 'workspaces'")
|
|
3922
|
+
const limit = boundedInteger(object.limit ?? 20, 'limit', { min: 1, max: LIMITS.MAX_PAGE })
|
|
3923
|
+
const offset = decodeCursor(object.cursor)
|
|
3924
|
+
const sessions = (await this.adapter.listSessions({ fresh: true }))
|
|
3925
|
+
.filter((session) => session.archived !== true && session.subagent !== true && session.eligible === true)
|
|
3926
|
+
.filter((session) => matchesSessionFilter(session, object))
|
|
3927
|
+
.sort((a, b) => (a.title ?? a.id).localeCompare(b.title ?? b.id))
|
|
3928
|
+
const page = sessions.slice(offset, offset + limit)
|
|
3929
|
+
return {
|
|
3930
|
+
sessions: page.map((session) => publicSession(session)),
|
|
3931
|
+
total: sessions.length,
|
|
3932
|
+
nextCursor: offset + limit < sessions.length ? encodeCursor(offset + limit) : null,
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
/**
|
|
3937
|
+
* Registered-workspace discovery, including empty workspaces a session-only
|
|
3938
|
+
* list can never show. Registry order is stable only within one snapshot:
|
|
3939
|
+
* concurrent add/remove can move rows between pages, so callers re-check by
|
|
3940
|
+
* id. Returns id/title/path only — never credentials or internals.
|
|
3941
|
+
* @param {Record<string, any>} object
|
|
3942
|
+
*/
|
|
3943
|
+
async #listWorkspaces(object) {
|
|
3944
|
+
if (object.workspace !== undefined) {
|
|
3945
|
+
throw fail('invalid_argument', 'the workspace filter is not available with scope=workspaces')
|
|
3946
|
+
}
|
|
3947
|
+
const limit = boundedInteger(object.limit ?? 20, 'limit', { min: 1, max: LIMITS.MAX_PAGE })
|
|
3948
|
+
const offset = decodeCursor(object.cursor)
|
|
3949
|
+
const titleFilter = object.title === undefined ? null : requiredString(object.title, 'title', { max: 500 })
|
|
3950
|
+
const queryFilter = object.query === undefined ? null : requiredString(object.query, 'query', { max: 500 })
|
|
3951
|
+
const workspaces = (await this.adapter.listWorkspaces()).filter((workspace) => {
|
|
3952
|
+
if (titleFilter !== null && !`${workspace.title}`.toLowerCase().includes(titleFilter.toLowerCase())) return false
|
|
3953
|
+
if (queryFilter !== null) {
|
|
3954
|
+
const haystack = `${workspace.title ?? ''} ${workspace.path ?? ''}`.toLowerCase()
|
|
3955
|
+
if (!haystack.includes(queryFilter.toLowerCase())) return false
|
|
3956
|
+
}
|
|
3957
|
+
return true
|
|
3958
|
+
})
|
|
3959
|
+
const page = workspaces.slice(offset, offset + limit)
|
|
3960
|
+
return {
|
|
3961
|
+
workspaces: page,
|
|
3962
|
+
total: workspaces.length,
|
|
3963
|
+
nextCursor: offset + limit < workspaces.length ? encodeCursor(offset + limit) : null,
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
/**
|
|
3968
|
+
* History is read-only and bounded. Archived sessions are explicitly
|
|
3969
|
+
* eligible here, while subagents remain outside the ordinary-session scope.
|
|
3970
|
+
* @param {Record<string, unknown>} request
|
|
3971
|
+
*/
|
|
3972
|
+
async history(request) {
|
|
3973
|
+
const object = assertPlainObject(request, 'history request')
|
|
3974
|
+
assertAllowedKeys(object, ['sessionId', 'mode', 'query', 'limit', 'beforeSeq'], 'history request')
|
|
3975
|
+
const sessionId = requiredString(object.sessionId, 'sessionId', { min: 1, max: 256 })
|
|
3976
|
+
const mode = object.mode === undefined ? 'recent' : requiredString(object.mode, 'mode', { max: 16 })
|
|
3977
|
+
if (mode !== 'recent' && mode !== 'search') throw fail('invalid_argument', "mode must be 'recent' or 'search'")
|
|
3978
|
+
const limit = boundedInteger(object.limit ?? 10, 'limit', { min: 1, max: LIMITS.HISTORY_MAX_MESSAGES })
|
|
3979
|
+
const beforeSeq =
|
|
3980
|
+
object.beforeSeq === undefined ? undefined : boundedInteger(object.beforeSeq, 'beforeSeq', { min: 0 })
|
|
3981
|
+
const query = mode === 'search' ? requiredString(object.query, 'query', { min: 1, max: 500 }) : undefined
|
|
3982
|
+
const info = await this.adapter.getSession(sessionId, { fresh: true })
|
|
3983
|
+
if (info === undefined || info === null) throw fail('target_unregistered', 'session is not registered')
|
|
3984
|
+
if (info.subagent === true) throw fail('target_subagent', 'subagent sessions have no session-link history')
|
|
3985
|
+
if (info.registered === false) throw fail('target_unregistered', 'session is not registered')
|
|
3986
|
+
return this.adapter.history({ sessionId, mode, query, limit, beforeSeq })
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
/** @param {{ from: string, messageId?: string, chainId?: string }} request */
|
|
3990
|
+
async status(request) {
|
|
3991
|
+
const object = assertPlainObject(request, 'status request')
|
|
3992
|
+
assertAllowedKeys(object, ['from', 'messageId', 'chainId'], 'status request')
|
|
3993
|
+
const from = requiredString(object.from, 'from', { min: 1, max: 256 })
|
|
3994
|
+
const messageId = optionalString(object.messageId, 'messageId', { max: 256 })
|
|
3995
|
+
const chainId = optionalString(object.chainId, 'chainId', { max: 256 })
|
|
3996
|
+
const state = this.ledger.read()
|
|
3997
|
+
let related = state.messages.filter((message) => message.from === from || message.to === from)
|
|
3998
|
+
if (messageId !== undefined) related = related.filter((message) => message.id === messageId)
|
|
3999
|
+
if (chainId !== undefined) related = related.filter((message) => message.chainId === chainId)
|
|
4000
|
+
related.sort((a, b) => b.createdAt - a.createdAt)
|
|
4001
|
+
const limited = related.slice(0, LIMITS.MAX_PAGE)
|
|
4002
|
+
const chains = []
|
|
4003
|
+
const seen = new Set()
|
|
4004
|
+
for (const message of limited) {
|
|
4005
|
+
if (seen.has(message.chainId)) continue
|
|
4006
|
+
seen.add(message.chainId)
|
|
4007
|
+
const chain = state.chains[message.chainId]
|
|
4008
|
+
if (chain !== undefined) chains.push(this.#chainView(state, chain))
|
|
4009
|
+
}
|
|
4010
|
+
const replies = {}
|
|
4011
|
+
for (const message of limited) {
|
|
4012
|
+
const children = state.messages.filter((candidate) => candidate.parentMessageId === message.id)
|
|
4013
|
+
if (children.length > 0) {
|
|
4014
|
+
replies[message.id] = children.slice(0, 20).map((child) => ({
|
|
4015
|
+
id: child.id,
|
|
4016
|
+
from: child.from,
|
|
4017
|
+
to: child.to,
|
|
4018
|
+
status: child.status,
|
|
4019
|
+
createdAt: child.createdAt,
|
|
4020
|
+
late: child.late === true,
|
|
4021
|
+
}))
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
return {
|
|
4025
|
+
messages: limited.map((message) => this.#messageView(state, message, from)),
|
|
4026
|
+
chains,
|
|
4027
|
+
replies,
|
|
4028
|
+
total: related.length,
|
|
4029
|
+
hasMore: related.length > limited.length,
|
|
4030
|
+
budgetLatches: [],
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
/** @param {Record<string, unknown>} [request] */
|
|
4035
|
+
async snapshot(request = {}) {
|
|
4036
|
+
const object = assertPlainObject(request, 'snapshot request')
|
|
4037
|
+
assertAllowedKeys(object, ['limit', 'offset', 'filter', 'sessionId', 'chainId', 'view', 'senderSessionId', 'receiverSessionId'], 'snapshot request')
|
|
4038
|
+
const view = object.view === undefined ? 'full' : requiredString(object.view, 'view', { max: 16 })
|
|
4039
|
+
if (!['full', 'settings', 'records', 'sessions', 'thread'].includes(view)) throw fail('invalid_argument', `unknown snapshot view ${view}`)
|
|
4040
|
+
const limit = boundedInteger(object.limit ?? LIMITS.MAX_PAGE, 'limit', { min: 1, max: LIMITS.MAX_SNAPSHOT_PAGE })
|
|
4041
|
+
const offset = boundedInteger(object.offset ?? 0, 'offset', { min: 0 })
|
|
4042
|
+
const filter = object.filter === undefined ? 'all' : requiredString(object.filter, 'filter', { max: 32 })
|
|
4043
|
+
if (!SNAPSHOT_FILTERS.includes(filter)) throw fail('invalid_argument', `unknown snapshot filter ${filter}`)
|
|
4044
|
+
const sessionId = optionalString(object.sessionId, 'sessionId', { max: 256 })
|
|
4045
|
+
const chainId = optionalString(object.chainId, 'chainId', { max: 256 })
|
|
4046
|
+
const senderSessionId = optionalString(object.senderSessionId, 'senderSessionId', { max: 256 })
|
|
4047
|
+
const receiverSessionId = optionalString(object.receiverSessionId, 'receiverSessionId', { max: 256 })
|
|
4048
|
+
if ((filter === 'received' || filter === 'sent') && sessionId === undefined) {
|
|
4049
|
+
throw fail('invalid_argument', `${filter} filter requires sessionId`)
|
|
4050
|
+
}
|
|
4051
|
+
if (filter === 'chain' && chainId === undefined) {
|
|
4052
|
+
throw fail('invalid_argument', 'chain filter requires chainId')
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
const state = this.ledger.read()
|
|
4056
|
+
if (view === 'settings') {
|
|
4057
|
+
// Fast path for the settings surface: no session discovery, no message
|
|
4058
|
+
// page, no chain views. This keeps opening the panel cheap even with a
|
|
4059
|
+
// large communication history; the records tab requests the full view.
|
|
4060
|
+
const stats = this.ledger.stats()
|
|
4061
|
+
const queuedBytes = queuedBytesOf(state)
|
|
4062
|
+
return {
|
|
4063
|
+
apiVersion: API_VERSION,
|
|
4064
|
+
version: PLUGIN_VERSION,
|
|
4065
|
+
revision: state.revision,
|
|
4066
|
+
settingsRevision: state.settingsRevision,
|
|
4067
|
+
settings: structuredClone(state.settings),
|
|
4068
|
+
messages: undefined,
|
|
4069
|
+
chains: [],
|
|
4070
|
+
budgetLatches: [],
|
|
4071
|
+
sessions: [],
|
|
4072
|
+
total: state.messages.length,
|
|
4073
|
+
hasMore: false,
|
|
4074
|
+
nextOffset: null,
|
|
4075
|
+
inFlight: this.#attempts.size,
|
|
4076
|
+
storage: this.#storageView(stats, queuedBytes),
|
|
4077
|
+
technicalLimits: this.#technicalLimits(stats),
|
|
4078
|
+
...(state.migration === undefined ? {} : { migration: publicMigration(state.migration) }),
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
if (view === 'thread') {
|
|
4082
|
+
if (chainId === undefined) throw fail('invalid_argument', 'thread view requires chainId')
|
|
4083
|
+
const thread = state.messages
|
|
4084
|
+
.filter((message) => message.chainId === chainId)
|
|
4085
|
+
.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
|
|
4086
|
+
.map((message) => this.#messageView(state, message, undefined))
|
|
4087
|
+
const chainIds = new Set([chainId])
|
|
4088
|
+
const stats = this.ledger.stats()
|
|
4089
|
+
const queuedBytes = queuedBytesOf(state)
|
|
4090
|
+
return {
|
|
4091
|
+
apiVersion: API_VERSION,
|
|
4092
|
+
version: PLUGIN_VERSION,
|
|
4093
|
+
revision: state.revision,
|
|
4094
|
+
settingsRevision: state.settingsRevision,
|
|
4095
|
+
settings: structuredClone(state.settings),
|
|
4096
|
+
messages: thread,
|
|
4097
|
+
chains: this.#chainViewsFor(state, chainIds),
|
|
4098
|
+
budgetLatches: [],
|
|
4099
|
+
sessions: [],
|
|
4100
|
+
total: thread.length,
|
|
4101
|
+
hasMore: false,
|
|
4102
|
+
nextOffset: null,
|
|
4103
|
+
inFlight: this.#attempts.size,
|
|
4104
|
+
storage: this.#storageView(stats, queuedBytes),
|
|
4105
|
+
technicalLimits: this.#technicalLimits(stats),
|
|
4106
|
+
...(state.migration === undefined ? {} : { migration: publicMigration(state.migration) }),
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
if (view === 'sessions') {
|
|
4110
|
+
const sessions = await this.adapter.listSessions({})
|
|
4111
|
+
const stats = this.ledger.stats()
|
|
4112
|
+
const queuedBytes = queuedBytesOf(state)
|
|
4113
|
+
return {
|
|
4114
|
+
apiVersion: API_VERSION,
|
|
4115
|
+
version: PLUGIN_VERSION,
|
|
4116
|
+
revision: state.revision,
|
|
4117
|
+
settingsRevision: state.settingsRevision,
|
|
4118
|
+
settings: structuredClone(state.settings),
|
|
4119
|
+
messages: undefined,
|
|
4120
|
+
chains: [],
|
|
4121
|
+
budgetLatches: [],
|
|
4122
|
+
sessions: sessions.map((session) => publicSession(session)),
|
|
4123
|
+
total: state.messages.length,
|
|
4124
|
+
hasMore: false,
|
|
4125
|
+
nextOffset: null,
|
|
4126
|
+
inFlight: this.#attempts.size,
|
|
4127
|
+
storage: this.#storageView(stats, queuedBytes),
|
|
4128
|
+
technicalLimits: this.#technicalLimits(stats),
|
|
4129
|
+
...(state.migration === undefined ? {} : { migration: publicMigration(state.migration) }),
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
let messages = state.messages
|
|
4133
|
+
if (sessionId !== undefined) {
|
|
4134
|
+
messages = messages.filter((message) => message.from === sessionId || message.to === sessionId)
|
|
4135
|
+
}
|
|
4136
|
+
if (chainId !== undefined) messages = messages.filter((message) => message.chainId === chainId)
|
|
4137
|
+
if (senderSessionId !== undefined) messages = messages.filter((message) => message.from === senderSessionId)
|
|
4138
|
+
if (receiverSessionId !== undefined) messages = messages.filter((message) => message.to === receiverSessionId)
|
|
4139
|
+
messages = messages.filter((message) => {
|
|
4140
|
+
if (filter === 'all' || filter === 'chain') return true
|
|
4141
|
+
if (filter === 'received') return message.to === sessionId
|
|
4142
|
+
if (filter === 'sent') return message.from === sessionId
|
|
4143
|
+
if (filter === 'pending_reply') {
|
|
4144
|
+
return message.kind === 'request' && message.status === 'accepted' && message.replyStatus === 'waiting'
|
|
4145
|
+
}
|
|
4146
|
+
if (filter === 'abnormal') {
|
|
4147
|
+
return (
|
|
4148
|
+
message.status === 'failed' ||
|
|
4149
|
+
message.status === 'unknown' ||
|
|
4150
|
+
(message.kind === 'request' && message.replyStatus === 'timed_out')
|
|
4151
|
+
)
|
|
4152
|
+
}
|
|
4153
|
+
if (filter === 'unknown') return message.status === 'unknown'
|
|
4154
|
+
if (filter === 'held') return message.status === 'held'
|
|
4155
|
+
return true
|
|
4156
|
+
})
|
|
4157
|
+
messages = [...messages].sort((a, b) => b.createdAt - a.createdAt || a.id.localeCompare(b.id))
|
|
4158
|
+
const total = messages.length
|
|
4159
|
+
const page = messages.slice(offset, offset + limit)
|
|
4160
|
+
|
|
4161
|
+
const chainIds = new Set(page.map((message) => message.chainId))
|
|
4162
|
+
if (chainId !== undefined) chainIds.add(chainId)
|
|
4163
|
+
const chains = this.#chainViewsFor(state, chainIds)
|
|
4164
|
+
// The records view is a fast mailbox page: it intentionally omits session
|
|
4165
|
+
// discovery (the client fetches the session list separately once) so a
|
|
4166
|
+
// large Host session store cannot block the first page of records.
|
|
4167
|
+
const sessions = view === 'records' ? [] : await this.adapter.listSessions({})
|
|
4168
|
+
const stats = this.ledger.stats()
|
|
4169
|
+
const queuedBytes = queuedBytesOf(state)
|
|
4170
|
+
const storage = this.#storageView(stats, queuedBytes)
|
|
4171
|
+
return {
|
|
4172
|
+
apiVersion: API_VERSION,
|
|
4173
|
+
version: PLUGIN_VERSION,
|
|
4174
|
+
revision: state.revision,
|
|
4175
|
+
settingsRevision: state.settingsRevision,
|
|
4176
|
+
settings: structuredClone(state.settings),
|
|
4177
|
+
messages: page.map((message) => this.#messageView(state, message, sessionId)),
|
|
4178
|
+
chains,
|
|
4179
|
+
budgetLatches: [],
|
|
4180
|
+
sessions: sessions.map((session) => publicSession(session)),
|
|
4181
|
+
total,
|
|
4182
|
+
hasMore: offset + page.length < total,
|
|
4183
|
+
nextOffset: offset + page.length < total ? offset + page.length : null,
|
|
4184
|
+
inFlight: this.#attempts.size,
|
|
4185
|
+
storage,
|
|
4186
|
+
technicalLimits: this.#technicalLimits(stats),
|
|
4187
|
+
...(state.migration === undefined ? {} : { migration: publicMigration(state.migration) }),
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
/** @param {Record<string, any>} stats @param {number} queuedBytes */
|
|
4192
|
+
#storageView(stats, queuedBytes) {
|
|
4193
|
+
const ledgerFull = stats.bytes >= stats.maxBytes
|
|
4194
|
+
const queueFull = queuedBytes >= this.maxQueuedBytes
|
|
4195
|
+
const ledgerNear = stats.bytes >= stats.maxBytes * 0.9
|
|
4196
|
+
const queueNear = queuedBytes >= this.maxQueuedBytes * 0.9
|
|
4197
|
+
let health = 'ok'
|
|
4198
|
+
if (stats.health === 'storage_error') health = 'storage_error'
|
|
4199
|
+
else if (ledgerFull || queueFull) health = 'full'
|
|
4200
|
+
else if (ledgerNear || queueNear) health = 'near_capacity'
|
|
4201
|
+
return {
|
|
4202
|
+
bytes: stats.bytes,
|
|
4203
|
+
maxBytes: stats.maxBytes,
|
|
4204
|
+
queuedBytes,
|
|
4205
|
+
maxQueuedBytes: this.maxQueuedBytes,
|
|
4206
|
+
messageCount: stats.messageCount,
|
|
4207
|
+
chainCount: stats.chainCount,
|
|
4208
|
+
health,
|
|
4209
|
+
...(stats.error === undefined ? {} : { error: structuredClone(stats.error) }),
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
|
|
4213
|
+
/** @param {Record<string, any>} stats */
|
|
4214
|
+
#technicalLimits(stats) {
|
|
4215
|
+
return {
|
|
4216
|
+
maxContentBytes: LIMITS.MAX_CONTENT_BYTES,
|
|
4217
|
+
maxCallBytes: LIMITS.MAX_CALL_BYTES,
|
|
4218
|
+
maxLedgerBytes: stats.maxBytes,
|
|
4219
|
+
maxQueuedBytes: this.maxQueuedBytes,
|
|
4220
|
+
maxReferences: LIMITS.MAX_REFERENCES,
|
|
4221
|
+
maxReferenceChars: LIMITS.MAX_REFERENCE_CHARS,
|
|
4222
|
+
maxPaths: LIMITS.MAX_PATHS,
|
|
4223
|
+
maxPathChars: LIMITS.MAX_PATH_CHARS,
|
|
4224
|
+
historyPageMax: LIMITS.HISTORY_MAX_MESSAGES,
|
|
4225
|
+
listPageMax: LIMITS.MAX_PAGE,
|
|
4226
|
+
historyCharsMax: LIMITS.HISTORY_MAX_CHARS,
|
|
4227
|
+
snapshotPageMax: LIMITS.MAX_SNAPSHOT_PAGE,
|
|
4228
|
+
operationFirstSubmitMs: LIMITS.OPERATION_PAST_MS,
|
|
4229
|
+
operationRetentionMs: LIMITS.OPERATION_RETENTION_MS,
|
|
4230
|
+
}
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
// -------------------------------------------------------------------------
|
|
4234
|
+
// State helpers
|
|
4235
|
+
// -------------------------------------------------------------------------
|
|
4236
|
+
|
|
4237
|
+
/** @param {string} id */
|
|
4238
|
+
#message(id) {
|
|
4239
|
+
return this.ledger.read().messages.find((message) => message.id === id)
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
/**
|
|
4243
|
+
* Root operationId lookup across BOTH durable operation kinds: send/reply
|
|
4244
|
+
* records and create journals. The canonical payload includes `kind`, so a
|
|
4245
|
+
* create canonical can never equal a send/reply canonical and sealed (s2)
|
|
4246
|
+
* digests of different canonicals cannot collide; any cross-kind hit is
|
|
4247
|
+
* always a conflict before `#operationResult` could run.
|
|
4248
|
+
* @param {Record<string, any>} state
|
|
4249
|
+
* @param {string} from
|
|
4250
|
+
* @param {string} operationId
|
|
4251
|
+
*/
|
|
4252
|
+
#lookupOperation(state, from, operationId) {
|
|
4253
|
+
const operation = Object.values(state.operations).find(
|
|
4254
|
+
(candidate) => candidate?.from === from && candidate?.operationId === operationId,
|
|
4255
|
+
)
|
|
4256
|
+
if (operation !== undefined) return operation
|
|
4257
|
+
return this.#lookupCreate(state, from, operationId)
|
|
4258
|
+
}
|
|
4259
|
+
|
|
4260
|
+
/**
|
|
4261
|
+
* @param {Record<string, any>} state
|
|
4262
|
+
* @param {string} from
|
|
4263
|
+
* @param {string} operationId
|
|
4264
|
+
* @returns {Record<string, any> | undefined}
|
|
4265
|
+
*/
|
|
4266
|
+
#lookupCreate(state, from, operationId) {
|
|
4267
|
+
return Object.values(state.creates ?? {}).find(
|
|
4268
|
+
(journal) => journal?.from === from && journal?.operationId === operationId,
|
|
4269
|
+
)
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
/**
|
|
4273
|
+
* @param {string} from
|
|
4274
|
+
* @param {string} operationId
|
|
4275
|
+
* @param {readonly string[]} targets
|
|
4276
|
+
*/
|
|
4277
|
+
#operationKey(from, operationId, targets) {
|
|
4278
|
+
return `${from}|${operationId}|${[...targets].sort().join(',')}`
|
|
4279
|
+
}
|
|
4280
|
+
|
|
4281
|
+
/**
|
|
4282
|
+
* @param {Record<string, any>} state
|
|
4283
|
+
* @param {Record<string, any>} operation
|
|
4284
|
+
*/
|
|
4285
|
+
#operationResult(state, operation) {
|
|
4286
|
+
return {
|
|
4287
|
+
operationId: operation.operationId,
|
|
4288
|
+
idempotent: true,
|
|
4289
|
+
chainId: state.messages.find((message) => message.id === operation.messageIds[operation.targets[0]])?.chainId,
|
|
4290
|
+
held: operation.targets.every((target) => findMessage(state, operation.messageIds[target])?.status === 'held'),
|
|
4291
|
+
results: operation.targets.map((target) => {
|
|
4292
|
+
const message = findMessage(state, operation.messageIds[target])
|
|
4293
|
+
return {
|
|
4294
|
+
target,
|
|
4295
|
+
id: operation.messageIds[target],
|
|
4296
|
+
status: message?.status ?? 'missing',
|
|
4297
|
+
dispatchStatus: message?.status ?? 'missing',
|
|
4298
|
+
error: message?.error ?? null,
|
|
4299
|
+
}
|
|
4300
|
+
}),
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
|
|
4304
|
+
/**
|
|
4305
|
+
* @param {Record<string, any>} state
|
|
4306
|
+
* @param {Record<string, any>} chain
|
|
4307
|
+
* @param {number} now
|
|
4308
|
+
* @param {string} by
|
|
4309
|
+
*/
|
|
4310
|
+
#closeChainInState(state, chain, now, by) {
|
|
4311
|
+
chain.state = 'closed'
|
|
4312
|
+
chain.closedAt = now
|
|
4313
|
+
chain.closedBy = by
|
|
4314
|
+
for (const message of chainMessages(state, chain.id)) {
|
|
4315
|
+
if (message.status === 'pending' || message.status === 'held') {
|
|
4316
|
+
message.status = 'cancelled'
|
|
4317
|
+
message.cancelledAt = now
|
|
4318
|
+
message.completedAt = now
|
|
4319
|
+
message.cancelReason = 'chain_closed'
|
|
4320
|
+
message.error = { code: 'chain_closed', message: 'chain closed before delivery' }
|
|
4321
|
+
message.releaseAuthorized = false
|
|
4322
|
+
continue
|
|
4323
|
+
}
|
|
4324
|
+
// An explicitly closed group ends an accepted but still-waiting
|
|
4325
|
+
// request. Keep its accepted/replyStatus semantics intact (no fake
|
|
4326
|
+
// answer, no work cancellation); only record the completion timestamp
|
|
4327
|
+
// used by retention/terminal cleanup.
|
|
4328
|
+
if (
|
|
4329
|
+
message.status === 'accepted' &&
|
|
4330
|
+
message.kind === 'request' &&
|
|
4331
|
+
message.replyStatus === 'waiting' &&
|
|
4332
|
+
!Number.isSafeInteger(message.completedAt)
|
|
4333
|
+
) {
|
|
4334
|
+
message.completedAt = now
|
|
4335
|
+
message.completedReason = 'chain_closed'
|
|
4336
|
+
}
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
|
|
4340
|
+
/**
|
|
4341
|
+
* @param {Record<string, any>} message
|
|
4342
|
+
* @param {number} now
|
|
4343
|
+
* @param {string} by
|
|
4344
|
+
* @returns {boolean}
|
|
4345
|
+
*/
|
|
4346
|
+
#cancelMessageInState(message, now, by) {
|
|
4347
|
+
if (message.status !== 'pending' && message.status !== 'held') return false
|
|
4348
|
+
message.status = 'cancelled'
|
|
4349
|
+
message.cancelledAt = now
|
|
4350
|
+
message.completedAt = now
|
|
4351
|
+
message.cancelledBy = by
|
|
4352
|
+
message.cancelReason = 'cancelled'
|
|
4353
|
+
message.error = { code: 'cancelled', message: 'cancelled before host insertion' }
|
|
4354
|
+
message.releaseAuthorized = false
|
|
4355
|
+
return true
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
/**
|
|
4359
|
+
* Explicit release for historical/manual held records. It never bypasses a
|
|
4360
|
+
* global pause or a paused/closed chain.
|
|
4361
|
+
* @param {Record<string, any>} state
|
|
4362
|
+
* @param {Record<string, any>} message
|
|
4363
|
+
* @param {number} now
|
|
4364
|
+
*/
|
|
4365
|
+
#releaseOne(state, message, now) {
|
|
4366
|
+
if (message.status !== 'held') throw fail('conflict', 'only a held message can be released')
|
|
4367
|
+
const chain = state.chains[message.chainId]
|
|
4368
|
+
if (chain === undefined) throw fail('chain_missing', 'chain is missing')
|
|
4369
|
+
if (chain.state === 'closed') throw fail('chain_closed', 'chain is closed')
|
|
4370
|
+
if (chain.state === 'paused') throw fail('chain_paused', 'resume the chain before releasing held messages')
|
|
4371
|
+
if (!state.settings.enabled) throw fail('disabled', 'enable session-link before releasing held messages')
|
|
4372
|
+
if (state.settings.paused) throw fail('paused', 'clear the global pause before releasing held messages')
|
|
4373
|
+
message.status = 'pending'
|
|
4374
|
+
message.releasedAt = now
|
|
4375
|
+
message.heldAt = null
|
|
4376
|
+
message.heldReason = null
|
|
4377
|
+
message.error = null
|
|
4378
|
+
delete message.heldOverflow
|
|
4379
|
+
message.releaseAuthorized = false
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4382
|
+
/** @param {Record<string, any>} state @param {string} id */
|
|
4383
|
+
#chainById(state, id) {
|
|
4384
|
+
const chain = state.chains[id]
|
|
4385
|
+
if (chain === undefined) throw fail('not_found', 'chain not found')
|
|
4386
|
+
return chain
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
/**
|
|
4390
|
+
* @param {Record<string, any>} state
|
|
4391
|
+
* @param {Record<string, any>} chain
|
|
4392
|
+
* @param {Record<string, any>} [stats]
|
|
4393
|
+
*/
|
|
4394
|
+
#chainView(state, chain, stats) {
|
|
4395
|
+
let entry = stats
|
|
4396
|
+
if (entry === undefined) {
|
|
4397
|
+
const messages = chainMessages(state, chain.id)
|
|
4398
|
+
entry = {
|
|
4399
|
+
count: messages.length,
|
|
4400
|
+
pending: messages.filter((message) => message.status === 'pending').length,
|
|
4401
|
+
held: messages.filter((message) => message.status === 'held').length,
|
|
4402
|
+
dispatching: messages.filter((message) => message.status === 'dispatching').length,
|
|
4403
|
+
unknown: messages.filter((message) => message.status === 'unknown').length,
|
|
4404
|
+
lastMessageAt: messages.reduce((latest, message) => Math.max(latest, message.createdAt ?? 0), 0),
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
return {
|
|
4408
|
+
id: chain.id,
|
|
4409
|
+
state: chain.state,
|
|
4410
|
+
rootOwner: chain.rootOwner,
|
|
4411
|
+
createdAt: chain.createdAt,
|
|
4412
|
+
...(chain.pausedAt === undefined ? {} : { pausedAt: chain.pausedAt }),
|
|
4413
|
+
...(chain.closedAt === undefined ? {} : { closedAt: chain.closedAt }),
|
|
4414
|
+
...(chain.closedBy === undefined ? {} : { closedBy: chain.closedBy }),
|
|
4415
|
+
...(chain.pausedReason === undefined ? {} : { pausedReason: chain.pausedReason }),
|
|
4416
|
+
messageCount: entry.count,
|
|
4417
|
+
pending: entry.pending,
|
|
4418
|
+
held: entry.held,
|
|
4419
|
+
dispatching: entry.dispatching,
|
|
4420
|
+
unknown: entry.unknown,
|
|
4421
|
+
lastMessageAt: entry.lastMessageAt || null,
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
|
|
4425
|
+
/**
|
|
4426
|
+
* @param {Record<string, any>} state
|
|
4427
|
+
* @param {Iterable<string>} chainIds
|
|
4428
|
+
*/
|
|
4429
|
+
#chainViewsFor(state, chainIds) {
|
|
4430
|
+
const stats = new Map()
|
|
4431
|
+
for (const id of chainIds) {
|
|
4432
|
+
if (state.chains[id] === undefined) continue
|
|
4433
|
+
stats.set(id, { count: 0, pending: 0, held: 0, dispatching: 0, unknown: 0, lastMessageAt: 0 })
|
|
4434
|
+
}
|
|
4435
|
+
if (stats.size > 0) {
|
|
4436
|
+
for (const message of state.messages) {
|
|
4437
|
+
const entry = stats.get(message.chainId)
|
|
4438
|
+
if (entry === undefined) continue
|
|
4439
|
+
entry.count += 1
|
|
4440
|
+
if (message.status === 'pending') entry.pending += 1
|
|
4441
|
+
else if (message.status === 'held') entry.held += 1
|
|
4442
|
+
else if (message.status === 'dispatching') entry.dispatching += 1
|
|
4443
|
+
else if (message.status === 'unknown') entry.unknown += 1
|
|
4444
|
+
if (Number.isSafeInteger(message.createdAt) && message.createdAt > entry.lastMessageAt) {
|
|
4445
|
+
entry.lastMessageAt = message.createdAt
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
return [...stats.entries()].map(([id, entry]) => this.#chainView(state, state.chains[id], entry))
|
|
4450
|
+
}
|
|
4451
|
+
|
|
4452
|
+
/**
|
|
4453
|
+
* @param {Record<string, any>} state
|
|
4454
|
+
* @param {Record<string, any>} message
|
|
4455
|
+
* @param {string} [sessionId]
|
|
4456
|
+
*/
|
|
4457
|
+
#messageView(state, message, sessionId) {
|
|
4458
|
+
const chain = state.chains[message.chainId]
|
|
4459
|
+
const automaticHeld = isAutomaticHeld(message)
|
|
4460
|
+
const releasable =
|
|
4461
|
+
message.status === 'held' &&
|
|
4462
|
+
state.settings.enabled &&
|
|
4463
|
+
!state.settings.paused &&
|
|
4464
|
+
chain?.state === 'open' &&
|
|
4465
|
+
!automaticHeld
|
|
4466
|
+
return {
|
|
4467
|
+
...structuredClone(message),
|
|
4468
|
+
status: message.status,
|
|
4469
|
+
dispatchStatus: message.status,
|
|
4470
|
+
text: message.content?.text ?? '',
|
|
4471
|
+
subject: displaySubject(message),
|
|
4472
|
+
direction:
|
|
4473
|
+
sessionId === undefined ? null : message.from === sessionId ? 'out' : message.to === sessionId ? 'in' : null,
|
|
4474
|
+
chainState: chain?.state ?? null,
|
|
4475
|
+
releasable,
|
|
4476
|
+
replies: state.messages
|
|
4477
|
+
.filter((candidate) => candidate.parentMessageId === message.id)
|
|
4478
|
+
.slice(0, 20)
|
|
4479
|
+
.map((candidate) => candidate.id),
|
|
4480
|
+
}
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
/** @param {Record<string, any>} message */
|
|
4484
|
+
#publicMessage(message) {
|
|
4485
|
+
return { ...structuredClone(message), rendered: renderInboundText(message) }
|
|
4486
|
+
}
|
|
4487
|
+
|
|
4488
|
+
/** @returns {{ operationId: string }} */
|
|
4489
|
+
mintOperation() {
|
|
4490
|
+
return { operationId: mintOperationId(this.uuid(), this.clock()) }
|
|
4491
|
+
}
|
|
4492
|
+
|
|
4493
|
+
/** @param {string} id */
|
|
4494
|
+
inspect(id) {
|
|
4495
|
+
const state = this.ledger.read()
|
|
4496
|
+
const message = findMessage(state, id)
|
|
4497
|
+
if (message === undefined) return undefined
|
|
4498
|
+
const chain = state.chains[message.chainId]
|
|
4499
|
+
return {
|
|
4500
|
+
message: this.#messageView(state, message),
|
|
4501
|
+
chain: chain === undefined ? null : this.#chainView(state, chain),
|
|
4502
|
+
replies: state.messages.filter((candidate) => candidate.parentMessageId === message.id),
|
|
4503
|
+
}
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4506
|
+
/**
|
|
4507
|
+
* Stop background scheduling, abort uncommitted followups, and wait at most
|
|
4508
|
+
* five seconds. If a task remains, mark still-dispatching rows unknown and
|
|
4509
|
+
* report whether it is safe to release the writer; if the mark itself cannot
|
|
4510
|
+
* complete, the caller must keep the writer lock instead of risking a
|
|
4511
|
+
* second-writer handoff.
|
|
4512
|
+
* @returns {Promise<{ drained: boolean, timedOut: boolean, markedUnknown: number, storageSafe: boolean }>}
|
|
4513
|
+
*/
|
|
4514
|
+
async dispose() {
|
|
4515
|
+
if (this.#disposePromise !== undefined) return this.#disposePromise
|
|
4516
|
+
this.#disposed = true
|
|
4517
|
+
if (this.#receiptDispose !== undefined) {
|
|
4518
|
+
try {
|
|
4519
|
+
this.#receiptDispose()
|
|
4520
|
+
} catch {
|
|
4521
|
+
// A listener already removed by the host is safe to ignore.
|
|
4522
|
+
}
|
|
4523
|
+
this.#receiptDispose = undefined
|
|
4524
|
+
}
|
|
4525
|
+
for (const token of this.#attempts) token.aborted = true
|
|
4526
|
+
for (const resolve of this.#waiters.splice(0)) resolve()
|
|
4527
|
+
this.#disposePromise = (async () => {
|
|
4528
|
+
const drained = await this.#drainWithTimeout(LIMITS.DISPOSE_DRAIN_MS)
|
|
4529
|
+
let markedUnknown = 0
|
|
4530
|
+
let markComplete = false
|
|
4531
|
+
if (drained) {
|
|
4532
|
+
markComplete = true
|
|
4533
|
+
} else if (this.ledger.writable) {
|
|
4534
|
+
try {
|
|
4535
|
+
const result = await this.#withTimeout(
|
|
4536
|
+
this.ledger.mutate((state) => {
|
|
4537
|
+
const now = this.clock()
|
|
4538
|
+
let count = 0
|
|
4539
|
+
for (const message of state.messages) {
|
|
4540
|
+
if (message.status !== 'dispatching') continue
|
|
4541
|
+
this.#markUnknownInState(state, message, now, {
|
|
4542
|
+
code: 'dispose_timeout',
|
|
4543
|
+
message: 'session-link unloaded during delivery; the insertion is unknown and was not retried',
|
|
4544
|
+
})
|
|
4545
|
+
count += 1
|
|
4546
|
+
}
|
|
4547
|
+
return count === 0 ? skipMutation(0) : count
|
|
4548
|
+
}),
|
|
4549
|
+
1000,
|
|
4550
|
+
)
|
|
4551
|
+
markedUnknown = result
|
|
4552
|
+
// A completed queued mutation proves the ledger write queue is idle
|
|
4553
|
+
// after the abort. Every late task checks `disposed` before enqueueing
|
|
4554
|
+
// another write, so the writer can be handed off even if a host await
|
|
4555
|
+
// is still outstanding.
|
|
4556
|
+
markComplete = true
|
|
4557
|
+
} catch {
|
|
4558
|
+
markComplete = false
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
return {
|
|
4562
|
+
drained,
|
|
4563
|
+
timedOut: !drained,
|
|
4564
|
+
markedUnknown,
|
|
4565
|
+
storageSafe: drained || markComplete,
|
|
4566
|
+
}
|
|
4567
|
+
})()
|
|
4568
|
+
return this.#disposePromise
|
|
4569
|
+
}
|
|
4570
|
+
|
|
4571
|
+
/** @param {number} ms @returns {Promise<boolean>} */
|
|
4572
|
+
async #drainWithTimeout(ms) {
|
|
4573
|
+
let timer
|
|
4574
|
+
const timeout = new Promise((resolve) => {
|
|
4575
|
+
timer = setTimeout(() => resolve(false), ms)
|
|
4576
|
+
timer.unref?.()
|
|
4577
|
+
})
|
|
4578
|
+
try {
|
|
4579
|
+
return await Promise.race([this.drain().then(() => true), timeout])
|
|
4580
|
+
} finally {
|
|
4581
|
+
clearTimeout(timer)
|
|
4582
|
+
}
|
|
4583
|
+
}
|
|
4584
|
+
|
|
4585
|
+
/** @template T @param {Promise<T>} promise @param {number} ms @returns {Promise<T>} */
|
|
4586
|
+
async #withTimeout(promise, ms) {
|
|
4587
|
+
let timer
|
|
4588
|
+
const timeout = new Promise((_, reject) => {
|
|
4589
|
+
timer = setTimeout(() => reject(fail('timeout', 'operation timed out')), ms)
|
|
4590
|
+
timer.unref?.()
|
|
4591
|
+
})
|
|
4592
|
+
try {
|
|
4593
|
+
return await Promise.race([promise, timeout])
|
|
4594
|
+
} finally {
|
|
4595
|
+
clearTimeout(timer)
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
}
|
|
4599
|
+
|
|
4600
|
+
/**
|
|
4601
|
+
* @param {Record<string, any>} session
|
|
4602
|
+
* @param {Record<string, unknown>} filter
|
|
4603
|
+
* @returns {boolean}
|
|
4604
|
+
*/
|
|
4605
|
+
function matchesSessionFilter(session, filter) {
|
|
4606
|
+
const workspace = filter.workspace
|
|
4607
|
+
if (workspace !== undefined) {
|
|
4608
|
+
const text = requiredString(workspace, 'workspace', { max: 500, code: 'tool_args' })
|
|
4609
|
+
const haystack = `${session.workspaceTitle ?? ''} ${session.workspaceId ?? ''}`.toLowerCase()
|
|
4610
|
+
if (!haystack.includes(text.toLowerCase())) return false
|
|
4611
|
+
}
|
|
4612
|
+
const title = filter.title
|
|
4613
|
+
if (title !== undefined) {
|
|
4614
|
+
const text = requiredString(title, 'title', { max: 500, code: 'tool_args' })
|
|
4615
|
+
if (!`${session.title ?? ''}`.toLowerCase().includes(text.toLowerCase())) return false
|
|
4616
|
+
}
|
|
4617
|
+
const query = filter.query
|
|
4618
|
+
if (query !== undefined) {
|
|
4619
|
+
const text = requiredString(query, 'query', { max: 500, code: 'tool_args' })
|
|
4620
|
+
const haystack = `${session.title ?? ''} ${session.workspaceTitle ?? ''} ${session.id}`
|
|
4621
|
+
if (!haystack.toLowerCase().includes(text.toLowerCase())) return false
|
|
4622
|
+
}
|
|
4623
|
+
return true
|
|
4624
|
+
}
|
|
4625
|
+
|
|
4626
|
+
/**
|
|
4627
|
+
* @param {Record<string, any>} session
|
|
4628
|
+
* @returns {Record<string, any>}
|
|
4629
|
+
*/
|
|
4630
|
+
export function publicSession(session) {
|
|
4631
|
+
return {
|
|
4632
|
+
id: session.id,
|
|
4633
|
+
sessionId: session.id,
|
|
4634
|
+
title: session.title ?? session.id,
|
|
4635
|
+
workspaceId: session.workspaceId ?? null,
|
|
4636
|
+
workspaceTitle: session.workspaceTitle ?? null,
|
|
4637
|
+
status: session.agentStatus ?? (session.live === true ? 'idle' : 'cold'),
|
|
4638
|
+
agentStatus: session.agentStatus ?? (session.live === true ? 'idle' : 'cold'),
|
|
4639
|
+
live: session.live === true,
|
|
4640
|
+
archived: session.archived === true,
|
|
4641
|
+
subagent: session.subagent === true,
|
|
4642
|
+
eligible: session.eligible === true,
|
|
4643
|
+
formatStatus: session.formatStatus ?? 'current',
|
|
4644
|
+
}
|
|
4645
|
+
}
|
|
4646
|
+
|
|
4647
|
+
/** @param {string|undefined} cursor @returns {number} */
|
|
4648
|
+
function decodeCursor(cursor) {
|
|
4649
|
+
if (cursor === undefined || cursor === null) return 0
|
|
4650
|
+
if (typeof cursor !== 'string' || cursor.length === 0 || cursor.length > 512) {
|
|
4651
|
+
throw fail('invalid_argument', 'cursor is invalid')
|
|
4652
|
+
}
|
|
4653
|
+
let decoded
|
|
4654
|
+
try {
|
|
4655
|
+
decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
|
|
4656
|
+
} catch {
|
|
4657
|
+
throw fail('invalid_argument', 'cursor is invalid')
|
|
4658
|
+
}
|
|
4659
|
+
if (!isPlainObject(decoded) || decoded.v !== 1 || !Number.isSafeInteger(decoded.offset) || decoded.offset < 0) {
|
|
4660
|
+
throw fail('invalid_argument', 'cursor is invalid')
|
|
4661
|
+
}
|
|
4662
|
+
if (decoded.offset > 1_000_000) throw fail('invalid_argument', 'cursor is out of range')
|
|
4663
|
+
return decoded.offset
|
|
4664
|
+
}
|
|
4665
|
+
|
|
4666
|
+
/** @param {number} offset @returns {string} */
|
|
4667
|
+
function encodeCursor(offset) {
|
|
4668
|
+
return Buffer.from(JSON.stringify({ v: 1, offset }), 'utf8').toString('base64url')
|
|
4669
|
+
}
|