@ixo/oracle-runtime-workers 0.37.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,9 @@
9
9
  * dirty copy alone. Two exceptions upload right away: a copy about to be
10
10
  * evicted (the wipe must never drop unsaved turns) and a copy marked dirty
11
11
  * before the deadline existed (older objects; one upload, then the
12
- * debounce applies).
12
+ * debounce applies). A copy waiting for a delegation is not uploaded at
13
+ * all: without one the owner store refuses it, and the next delegation
14
+ * the object receives uploads it (`meta:flushWaitsForDelegation`).
13
15
  */
14
16
  export interface FlushScheduleInput {
15
17
  /** The object holds writes the owner store has not seen (and a database is open). */
@@ -19,10 +21,12 @@ export interface FlushScheduleInput {
19
21
  now: number;
20
22
  /** The idle-eviction path is about to wipe the working copy. */
21
23
  idle: boolean;
24
+ /** The last upload failed for want of a delegation; none has arrived since. */
25
+ waitingForDelegation?: boolean;
22
26
  }
23
27
 
24
28
  export type FlushScheduleDecision =
25
- | { action: 'skip'; reason: 'clean' }
29
+ | { action: 'skip'; reason: 'clean' | 'waiting-for-delegation' }
26
30
  | { action: 'flush'; reason: 'due' | 'idle' | 'no-deadline' }
27
31
  | { action: 'wait'; at: number };
28
32
 
@@ -31,6 +35,8 @@ export const FLUSH_DUE_SLACK_MS = 1000;
31
35
 
32
36
  export function decideFlush(input: FlushScheduleInput): FlushScheduleDecision {
33
37
  if (!input.dirty) return { action: 'skip', reason: 'clean' };
38
+ if (input.waitingForDelegation)
39
+ return { action: 'skip', reason: 'waiting-for-delegation' };
34
40
  if (input.idle) return { action: 'flush', reason: 'idle' };
35
41
  if (typeof input.flushAt !== 'number' || !Number.isFinite(input.flushAt))
36
42
  return { action: 'flush', reason: 'no-deadline' };
@@ -19,9 +19,20 @@ import {
19
19
  * - sends are serialised per session (parallel across sessions), so a reply
20
20
  * being retried can never overtake the message it answers, and a later
21
21
  * turn's mirrors queue behind an earlier turn's.
22
+ *
23
+ * The same chain carries the media events of a channel turn's files
24
+ * (`sendMedia`, built by `attachments/room-media.ts`): the owner object
25
+ * queues them in file order right after the person's text, so each is
26
+ * posted after that text and before the reply, whenever its upload ends.
22
27
  */
28
+ import type { EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
29
+ import { abortError, untilAborted } from '../attachments/download';
23
30
  import { matrixTxnId } from '../matrix/txn-id';
24
- import { retryGateway, type RetryGatewayOptions } from './gateway-retry';
31
+ import {
32
+ isTransientGatewayError,
33
+ retryGateway,
34
+ type RetryGatewayOptions,
35
+ } from './gateway-retry';
25
36
 
26
37
  export type MirrorAuthor = 'user' | 'oracle';
27
38
 
@@ -53,8 +64,43 @@ export interface MirrorSend {
53
64
  };
54
65
  }
55
66
 
67
+ /** The `org.ixo.qi.origin` input of a channel mirror. */
68
+ export type MirrorOrigin = NonNullable<MirrorSend['origin']>;
69
+
70
+ /**
71
+ * The media part of an `m.room.message` file event (`m.image` / `m.video` /
72
+ * `m.audio` / `m.file`): what the room shows, without the thread relation
73
+ * and the channel origin, which `mediaEventContent` adds.
74
+ */
75
+ export interface MediaMessage {
76
+ msgtype: 'm.image' | 'm.video' | 'm.audio' | 'm.file';
77
+ body: string;
78
+ filename: string;
79
+ info: { mimetype: string; size: number };
80
+ /**
81
+ * The encrypted attachment: its `mxc://` plus the `EncryptedFile` fields.
82
+ * Always encrypted: a file is only ever posted into a Megolm room.
83
+ */
84
+ file: { url: string } & EncryptedFileInfo;
85
+ }
86
+
87
+ export interface MediaMirrorSend {
88
+ roomId: string;
89
+ /** The session's marker event — the thread the file is posted under. */
90
+ threadId: string;
91
+ txnId: string;
92
+ origin?: MirrorOrigin;
93
+ media: MediaMessage;
94
+ }
95
+
56
96
  export interface RoomMirrorDeps {
57
97
  sendText(send: MirrorSend): Promise<string>;
98
+ /**
99
+ * Send one file event (`content` JSON-encoded) with a pinned transaction
100
+ * id, at background priority (`MatrixGatewayObject.sendEvent`); returns
101
+ * its event id.
102
+ */
103
+ sendEvent(roomId: string, content: string, txnId: string): Promise<string>;
58
104
  /** Keeps the (un-awaited) send alive past the request that started it: `ctx.waitUntil`. */
59
105
  keepAlive(work: Promise<unknown>): void;
60
106
  log(message: string): void;
@@ -63,6 +109,16 @@ export interface RoomMirrorDeps {
63
109
  retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
64
110
  }
65
111
 
112
+ /** The relation that posts an event in the thread of `threadId` (with the reply fallback for clients without threads). */
113
+ function threadRelation(threadId: string) {
114
+ return {
115
+ rel_type: 'm.thread',
116
+ event_id: threadId,
117
+ is_falling_back: true,
118
+ 'm.in_reply_to': { event_id: threadId },
119
+ };
120
+ }
121
+
66
122
  export function mirrorEventContent(send: MirrorSend) {
67
123
  return {
68
124
  msgtype: 'm.text',
@@ -70,12 +126,7 @@ export function mirrorEventContent(send: MirrorSend) {
70
126
  ...(send.formattedBody
71
127
  ? { format: 'org.matrix.custom.html', formatted_body: send.formattedBody }
72
128
  : {}),
73
- 'm.relates_to': {
74
- rel_type: 'm.thread',
75
- event_id: send.threadId,
76
- is_falling_back: true,
77
- 'm.in_reply_to': { event_id: send.threadId },
78
- },
129
+ 'm.relates_to': threadRelation(send.threadId),
79
130
  ...(send.origin
80
131
  ? {
81
132
  'org.ixo.qi.origin': channelOrigin(send.origin),
@@ -87,6 +138,19 @@ export function mirrorEventContent(send: MirrorSend) {
87
138
  };
88
139
  }
89
140
 
141
+ /**
142
+ * A file event in the session thread: the media part, the thread relation
143
+ * of the text mirrors and their `org.ixo.qi.origin` (never the quote, which
144
+ * the text mirror of the same message carries).
145
+ */
146
+ export function mediaEventContent(send: MediaMirrorSend) {
147
+ return {
148
+ ...send.media,
149
+ 'm.relates_to': threadRelation(send.threadId),
150
+ ...(send.origin ? { 'org.ixo.qi.origin': channelOrigin(send.origin) } : {}),
151
+ };
152
+ }
153
+
90
154
  const errorText = (err: unknown): string =>
91
155
  err instanceof Error ? err.message : String(err);
92
156
 
@@ -115,38 +179,98 @@ export class RoomMirror {
115
179
  label: string,
116
180
  required = false,
117
181
  ): Promise<void> {
118
- const previous = this.chains.get(sessionId) ?? Promise.resolve();
119
- // Starts once the previous mirror settled either way: a required mirror
120
- // that failed (and rejected its own caller) must not skip this one.
121
- const tracked: Promise<void> = previous
122
- .catch(() => undefined)
123
- .then(() => prepare())
124
- .then((send) => (send ? this.send(send, label) : undefined))
125
- .catch((err: unknown) => {
182
+ return this.chain(sessionId, async () => {
183
+ try {
184
+ const send = await prepare();
185
+ if (send)
186
+ await this.send(() => this.deps.sendText(send), send.threadId, label);
187
+ } catch (err) {
126
188
  if (required) throw err;
127
189
  this.deps.warn(
128
190
  `[user-do] Matrix replay (${label}) failed — session=${sessionId}: ${errorText(err)}`,
129
191
  );
130
- })
192
+ }
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Queue one file event behind the session's previous mirrors. When its
198
+ * turn in the chain comes, `prepare` builds the event (it waits for the
199
+ * file's upload; `null` = nothing to post) and the send follows with the
200
+ * transient retries; resolves with the event id, or null when nothing was
201
+ * posted. `signal` is honoured throughout: checked before `prepare` and
202
+ * before every send attempt, and it ends the wait on `prepare` — once it
203
+ * aborts no further attempt is made and the call rejects with its reason.
204
+ * A send that fails after the retries rejects.
205
+ */
206
+ sendMedia(
207
+ sessionId: string,
208
+ prepare: () => Promise<MediaMirrorSend | null>,
209
+ label: string,
210
+ signal?: AbortSignal,
211
+ ): Promise<string | null> {
212
+ return this.chain(sessionId, async () => {
213
+ if (signal?.aborted) throw abortError(signal);
214
+ const send = await (signal ? untilAborted(prepare(), signal) : prepare());
215
+ if (!send) return null;
216
+ const content = JSON.stringify(mediaEventContent(send));
217
+ return this.send(
218
+ () => {
219
+ if (signal?.aborted) return Promise.reject(abortError(signal));
220
+ return this.deps.sendEvent(send.roomId, content, send.txnId);
221
+ },
222
+ send.threadId,
223
+ label,
224
+ signal,
225
+ );
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Run `work` once the session's previous mirrors have settled either way
231
+ * (a required mirror that failed, and rejected its own caller, must not
232
+ * skip the next one), keep it alive past the request, and drop the chain
233
+ * once it drains.
234
+ */
235
+ private chain<T>(sessionId: string, work: () => Promise<T>): Promise<T> {
236
+ const previous = this.chains.get(sessionId) ?? Promise.resolve();
237
+ // Settles (as `work` did) once the chain entry is gone, so a caller that
238
+ // awaited it sees the session's chain drained.
239
+ const done = previous
240
+ .catch(() => undefined)
241
+ .then(work)
131
242
  .finally(() => {
132
243
  if (this.chains.get(sessionId) === tracked)
133
244
  this.chains.delete(sessionId);
134
245
  });
246
+ const tracked: Promise<void> = done.then(
247
+ () => undefined,
248
+ () => undefined,
249
+ );
135
250
  this.chains.set(sessionId, tracked);
136
251
  this.deps.keepAlive(tracked);
137
- return tracked;
252
+ return done;
138
253
  }
139
254
 
140
- private async send(send: MirrorSend, label: string): Promise<void> {
141
- const eventId = await retryGateway(() => this.deps.sendText(send), {
255
+ private async send(
256
+ post: () => Promise<string>,
257
+ threadId: string,
258
+ label: string,
259
+ signal?: AbortSignal,
260
+ ): Promise<string> {
261
+ const isTransient = this.deps.retry?.isTransient ?? isTransientGatewayError;
262
+ const eventId = await retryGateway(post, {
142
263
  ...this.deps.retry,
264
+ // An abort is final: the transient check would otherwise retry it.
265
+ isTransient: (err) => !signal?.aborted && isTransient(err),
143
266
  onRetry: (err, attempt, delayMs) =>
144
267
  this.deps.warn(
145
268
  `[user-do] Matrix replay (${label}) send failed (attempt ${attempt}) — retrying in ${delayMs} ms with the same transaction id: ${errorText(err)}`,
146
269
  ),
147
270
  });
148
271
  this.deps.log(
149
- `[user-do] Matrix replay (${label}) → ${eventId} in thread ${send.threadId}`,
272
+ `[user-do] Matrix replay (${label}) → ${eventId} in thread ${threadId}`,
150
273
  );
274
+ return eventId;
151
275
  }
152
276
  }
@@ -111,7 +111,7 @@ export function contentToText(content: BaseMessage['content']): string {
111
111
  if (typeof part === 'string') return part;
112
112
  if (part && typeof part === 'object' && 'text' in part) {
113
113
  const text = (part as { text?: unknown }).text;
114
- // The retention placeholder is model-facing only; the transcript
114
+ // The attachment placeholder is model-facing only; the transcript
115
115
  // keeps listing the attachment from its metadata.
116
116
  return typeof text === 'string' && !isAttachmentPlaceholderText(text)
117
117
  ? text
@@ -15,6 +15,7 @@ import {
15
15
  type ChannelMediaSource,
16
16
  type PreparedAttachments,
17
17
  } from '../attachments';
18
+ import type { ChannelThreadFile } from '../attachments/room-media';
18
19
  import { parseChannelServiceDids } from '../channels/auth';
19
20
  import {
20
21
  channelAttachmentInputs,
@@ -58,26 +59,64 @@ export function channelTurnLinks(
58
59
  }));
59
60
  }
60
61
 
62
+ /**
63
+ * A channel turn's files as `room-media.ts` posts them to the session
64
+ * thread: the first occurrence of each file (a file sent twice in one
65
+ * message is posted once), with its position in the message.
66
+ */
67
+ export function channelThreadFiles(req: TurnFiles): ChannelThreadFile[] {
68
+ if (req.client !== 'channel' || !req.channel?.attachments?.length) return [];
69
+ const bindingId = req.channel.bindingId;
70
+ const seen = new Set<string>();
71
+ const files: ChannelThreadFile[] = [];
72
+ req.channel.attachments.forEach((file, index) => {
73
+ const ref = channelAttachmentRef(file.sha256);
74
+ if (seen.has(ref)) return;
75
+ seen.add(ref);
76
+ files.push({
77
+ ref,
78
+ index,
79
+ link: { url: file.url, sha256: file.sha256, bindingId },
80
+ filename: file.filename,
81
+ mimetype: file.mimetype,
82
+ size: file.size,
83
+ kind: file.kind,
84
+ });
85
+ });
86
+ return files;
87
+ }
88
+
89
+ /** The https origins channel files are fetched from: the gateways of `CHANNEL_SERVICE_DID`. */
90
+ export function channelGatewayOrigins(
91
+ channelServiceDid: string | undefined,
92
+ ): string[] {
93
+ return channelMediaOrigins(parseChannelServiceDids(channelServiceDid));
94
+ }
95
+
61
96
  /**
62
97
  * The channel source of a turn in `sessionId`: a reference resolves to the
63
98
  * turn's own files first, then to the links kept for the session; only the
64
- * gateways of `CHANNEL_SERVICE_DID` are fetched from.
99
+ * gateways of `CHANNEL_SERVICE_DID` are fetched from. The store also names
100
+ * the room copy of a file posted to the session thread, which serves it
101
+ * when its link fails or has expired (`loadAttachmentBytes`).
65
102
  */
66
103
  export function turnChannelMediaSource(input: {
67
104
  sessionId: string;
68
105
  turnLinks: ReadonlyArray<ChannelMediaLink & { ref: string }>;
69
- store: Pick<ChannelAttachmentLinkStore, 'get'> | null;
106
+ store: Pick<ChannelAttachmentLinkStore, 'get' | 'eventIdOf'> | null;
70
107
  channelServiceDid: string | undefined;
71
108
  }): ChannelMediaSource {
109
+ const store = input.store;
72
110
  return createChannelMediaSource({
73
- origins: channelMediaOrigins(
74
- parseChannelServiceDids(input.channelServiceDid),
75
- ),
111
+ origins: channelGatewayOrigins(input.channelServiceDid),
76
112
  resolve: async (ref) => {
77
113
  const own = input.turnLinks.find((link) => link.ref === ref);
78
114
  if (own) return own;
79
- return input.store?.get(input.sessionId, ref);
115
+ return store?.get(input.sessionId, ref);
80
116
  },
117
+ ...(store
118
+ ? { roomEventOf: (ref: string) => store.eventIdOf(input.sessionId, ref) }
119
+ : {}),
81
120
  });
82
121
  }
83
122