@ixo/oracle-runtime-workers 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/channels/auth.ts +2 -1
- package/src/channels/contract.ts +104 -0
- package/src/channels/replies.ts +58 -0
- package/src/channels/routing.ts +222 -0
- package/src/channels/turns.ts +1252 -16
- package/src/core/env.ts +17 -0
- package/src/do/contracts.ts +19 -0
- package/src/do/room-mirror.ts +8 -1
- package/src/do/run-coordinator.ts +27 -0
- package/src/do/transcript.ts +14 -11
- package/src/do/user-oracle-do.ts +269 -63
- package/src/memory/session-history-indexer.ts +442 -103
- package/src/memory/user-context-fetcher.ts +13 -0
- package/src/memory/user-context-invalidation.ts +2 -2
- package/src/plugins/action-proposals/evidence.ts +7 -8
- package/src/plugins/tasks/middleware.ts +4 -9
- package/src/shell/app.ts +70 -0
- package/src/sqlite/serialization.ts +20 -0
- package/src/sqlite/sessions-store.ts +43 -0
package/src/channels/turns.ts
CHANGED
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
/* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
|
|
2
|
+
import type { z } from 'zod';
|
|
2
3
|
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
4
|
+
import { UNTITLED_SESSION } from '../sqlite/sessions-store';
|
|
3
5
|
import type { RunRecord } from '../do/run-store';
|
|
4
6
|
import type { TurnIdentity, TurnRequest } from '../do/contracts';
|
|
5
7
|
import { parseReplyPlan, planText } from '../delivery/schema';
|
|
6
8
|
import type { ReplyPlan } from '../delivery/types';
|
|
7
9
|
import {
|
|
8
10
|
ChannelError,
|
|
11
|
+
type ChannelControlResult,
|
|
12
|
+
StoredChannelControlResult,
|
|
13
|
+
type ChannelControlInput,
|
|
14
|
+
type ChannelReplyContext,
|
|
9
15
|
channelRequestHash,
|
|
10
16
|
channelRunId,
|
|
11
17
|
StaleSessionError,
|
|
18
|
+
type ChannelTopic,
|
|
12
19
|
type ChannelTurnInput,
|
|
13
20
|
type ChannelTurnResponse,
|
|
14
21
|
} from './contract';
|
|
15
22
|
import { isRoomForbidden } from './failures';
|
|
23
|
+
import {
|
|
24
|
+
ambiguousThreadText,
|
|
25
|
+
channelSessionDecision,
|
|
26
|
+
CHANNEL_THREAD_LIMIT,
|
|
27
|
+
decideChannelRoute,
|
|
28
|
+
routeAssignmentSchema,
|
|
29
|
+
selectChannelCandidates,
|
|
30
|
+
type ChannelRouteAssignment,
|
|
31
|
+
type ChannelRouteObservation,
|
|
32
|
+
type ChannelRoutingOptions,
|
|
33
|
+
type ChannelThreadCandidate,
|
|
34
|
+
} from './routing';
|
|
16
35
|
|
|
17
36
|
interface ReceiptRow extends Record<string, string | number | null> {
|
|
18
37
|
binding_id: string;
|
|
@@ -35,6 +54,7 @@ interface ReceiptRow extends Record<string, string | number | null> {
|
|
|
35
54
|
mirror_retry_at: number | null;
|
|
36
55
|
/** Epoch ms the receipt was written (rows from before the column: its migration). */
|
|
37
56
|
created_at: number | null;
|
|
57
|
+
routing_json: string | null;
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
/**
|
|
@@ -66,7 +86,131 @@ const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
|
|
|
66
86
|
const errorText = (error: unknown): string =>
|
|
67
87
|
error instanceof Error ? error.message : String(error);
|
|
68
88
|
|
|
89
|
+
/**
|
|
90
|
+
* How long one routed submission may hold its binding's lane. Classifier
|
|
91
|
+
* and Matrix calls are bounded on their own; this keeps one that hangs
|
|
92
|
+
* anyway from holding every later message of the binding.
|
|
93
|
+
*/
|
|
94
|
+
const ROUTING_LANE_DEADLINE_MS = 20_000;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A JSON column written by this module, read back. A row that no longer
|
|
98
|
+
* parses (written by another version, say) is treated as absent instead of
|
|
99
|
+
* failing every request that reads it; the warning names only the kind.
|
|
100
|
+
*/
|
|
101
|
+
function readStored<T>(
|
|
102
|
+
schema: z.ZodType<T>,
|
|
103
|
+
raw: string,
|
|
104
|
+
kind: string,
|
|
105
|
+
): T | undefined {
|
|
106
|
+
let value: unknown;
|
|
107
|
+
try {
|
|
108
|
+
value = JSON.parse(raw);
|
|
109
|
+
} catch {
|
|
110
|
+
value = undefined;
|
|
111
|
+
}
|
|
112
|
+
const parsed = schema.safeParse(value);
|
|
113
|
+
if (parsed.success) return parsed.data;
|
|
114
|
+
console.warn(`[channels] ignoring an unreadable stored ${kind}`);
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `work`, refused with a 503 once `ms` passed; the timer never outlives it. */
|
|
119
|
+
async function withDeadline<T>(work: Promise<T>, ms: number): Promise<T> {
|
|
120
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
121
|
+
try {
|
|
122
|
+
return await Promise.race([
|
|
123
|
+
work,
|
|
124
|
+
new Promise<never>((_resolve, reject) => {
|
|
125
|
+
timer = setTimeout(
|
|
126
|
+
() =>
|
|
127
|
+
reject(
|
|
128
|
+
new ChannelError(
|
|
129
|
+
503,
|
|
130
|
+
'The channel is busy with an earlier message; try again',
|
|
131
|
+
'unavailable',
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
ms,
|
|
135
|
+
);
|
|
136
|
+
}),
|
|
137
|
+
]);
|
|
138
|
+
} finally {
|
|
139
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What the sessions store holds about one session. */
|
|
144
|
+
export interface ChannelSessionInfo {
|
|
145
|
+
/** The stored title, if any: 'Untitled' and blank ones count as none. */
|
|
146
|
+
title: string | null;
|
|
147
|
+
/** Epoch ms. */
|
|
148
|
+
createdAt: number;
|
|
149
|
+
/** Epoch ms of the last activity. */
|
|
150
|
+
updatedAt: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The most sessions `topics` lists. */
|
|
154
|
+
const CHANNEL_TOPICS_LIMIT = 20;
|
|
155
|
+
/** The most binding rows `topics` reads, the most recently bound first: the rest are not listed. */
|
|
156
|
+
const CHANNEL_TOPICS_SCAN = 200;
|
|
157
|
+
/** Code points a title or a last line may have on the wire. */
|
|
158
|
+
const CHANNEL_TOPIC_LINE_LIMIT = 200;
|
|
159
|
+
const CHANNEL_TITLE_LIMIT = 80;
|
|
160
|
+
/** Receipts looked through, newest first, for one that is not a routing question. */
|
|
161
|
+
const CHANNEL_TOPIC_LOOKBACK = 5;
|
|
162
|
+
/** Ids per statement: a Durable Object binds at most 100 parameters. */
|
|
163
|
+
const CHANNEL_IDS_PER_STATEMENT = 50;
|
|
164
|
+
|
|
165
|
+
function clipCodePoints(text: string, limit: number): string {
|
|
166
|
+
const points = Array.from(text);
|
|
167
|
+
return points.length > limit ? points.slice(0, limit).join('') : text;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Text as a single line of plain characters: whitespace runs become one
|
|
172
|
+
* space; control, format and line or paragraph separator characters (bidi
|
|
173
|
+
* overrides, zero-width marks) are removed.
|
|
174
|
+
*/
|
|
175
|
+
function plainLine(text: string): string {
|
|
176
|
+
return text
|
|
177
|
+
.replace(/\s+/gu, ' ')
|
|
178
|
+
.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '')
|
|
179
|
+
.replace(/\s+/gu, ' ')
|
|
180
|
+
.trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A line of a conversation as `topics` shows it, or null when nothing is left. */
|
|
184
|
+
function topicLine(text: string | null | undefined): string | null {
|
|
185
|
+
const line = clipCodePoints(plainLine(text ?? ''), CHANNEL_TOPIC_LINE_LIMIT);
|
|
186
|
+
return line.length > 0 ? line : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isPlaceholderTitle(title: string): boolean {
|
|
190
|
+
return title.toLowerCase() === UNTITLED_SESSION.toLowerCase();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A stored title as the wire carries it: null when untitled, at most 200 code points. */
|
|
194
|
+
function wireTitle(title: string | null | undefined): string | null {
|
|
195
|
+
const line = plainLine(title ?? '');
|
|
196
|
+
return line.length > 0 && !isPlaceholderTitle(line)
|
|
197
|
+
? clipCodePoints(line, CHANNEL_TOPIC_LINE_LIMIT)
|
|
198
|
+
: null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A person's new title: one plain line of 1..80 code points that is not the
|
|
203
|
+
* placeholder, which the auto-titler would overwrite.
|
|
204
|
+
*/
|
|
205
|
+
function cleanTitle(title: string): string {
|
|
206
|
+
const cleaned = clipCodePoints(plainLine(title), CHANNEL_TITLE_LIMIT);
|
|
207
|
+
if (cleaned.length === 0 || isPlaceholderTitle(cleaned))
|
|
208
|
+
throw new ChannelError(400, 'Invalid title', 'invalid_request');
|
|
209
|
+
return cleaned;
|
|
210
|
+
}
|
|
211
|
+
|
|
69
212
|
export interface ChannelTurnsHost {
|
|
213
|
+
routing?: ChannelRoutingOptions;
|
|
70
214
|
createSession(identity: TurnIdentity, markerTxnId: string): Promise<string>;
|
|
71
215
|
/**
|
|
72
216
|
* Throws `StaleSessionError` when the object holds no such session (it
|
|
@@ -79,6 +223,18 @@ export interface ChannelTurnsHost {
|
|
|
79
223
|
* usable delegation for this oracle: none, or one too close to expiry.
|
|
80
224
|
*/
|
|
81
225
|
requireDelegation(identity: TurnIdentity): Promise<void>;
|
|
226
|
+
/**
|
|
227
|
+
* Abort the named runs (unknown or ended ones are skipped). Signals all
|
|
228
|
+
* of them first, then waits at most one shared grace; a run may still be
|
|
229
|
+
* winding down when it resolves.
|
|
230
|
+
*/
|
|
231
|
+
stopRuns(runIds: readonly string[]): Promise<void>;
|
|
232
|
+
/** The sessions store's rows for `sessionIds`; a session it holds none for (deleted) is absent. */
|
|
233
|
+
describeSessions(
|
|
234
|
+
sessionIds: readonly string[],
|
|
235
|
+
): Promise<Map<string, ChannelSessionInfo>>;
|
|
236
|
+
/** Set a session's title, replacing any; false when it holds no such session. */
|
|
237
|
+
renameSession(sessionId: string, title: string): Promise<boolean>;
|
|
82
238
|
getRun(runId: string): Promise<RunRecord | undefined>;
|
|
83
239
|
/** The Reply Plan (JSON) a finished run was built into, if any. */
|
|
84
240
|
getPlan(runId: string): Promise<string | null>;
|
|
@@ -106,7 +262,16 @@ export interface ChannelTurnsHost {
|
|
|
106
262
|
/** What admission found under the lock, before any Matrix I/O. */
|
|
107
263
|
type Received =
|
|
108
264
|
| { kind: 'run'; response: ChannelTurnResponse }
|
|
109
|
-
| {
|
|
265
|
+
| {
|
|
266
|
+
kind: 'new';
|
|
267
|
+
sessionId: string | null;
|
|
268
|
+
/** The receipt's stored route assignment, as step 1 read it. */
|
|
269
|
+
routingJson: string | null;
|
|
270
|
+
};
|
|
271
|
+
interface PreparedChannelTurn {
|
|
272
|
+
sessionId: string;
|
|
273
|
+
routing?: ChannelRouteAssignment;
|
|
274
|
+
}
|
|
110
275
|
|
|
111
276
|
/**
|
|
112
277
|
* Durable channel requests. A request is admitted in three steps, so the
|
|
@@ -135,12 +300,31 @@ export class ChannelTurns {
|
|
|
135
300
|
private draining: Promise<void> | null = null;
|
|
136
301
|
/** A pass was asked for while one ran: run another when it ends. */
|
|
137
302
|
private drainAgain = false;
|
|
303
|
+
/**
|
|
304
|
+
* Routed submissions, ordered per binding: the lane's tail, and the
|
|
305
|
+
* submission in flight per request id, which a retry of the same request
|
|
306
|
+
* joins instead of queueing behind itself.
|
|
307
|
+
*/
|
|
308
|
+
private readonly routingLanes = new Map<
|
|
309
|
+
string,
|
|
310
|
+
{
|
|
311
|
+
tail: Promise<unknown>;
|
|
312
|
+
requests: Map<
|
|
313
|
+
string,
|
|
314
|
+
{ requestHash: string; response: Promise<ChannelTurnResponse> }
|
|
315
|
+
>;
|
|
316
|
+
}
|
|
317
|
+
>();
|
|
138
318
|
|
|
139
319
|
constructor(
|
|
140
320
|
private readonly db: DoSqliteDatabase,
|
|
141
321
|
private readonly host: ChannelTurnsHost,
|
|
142
322
|
) {}
|
|
143
323
|
|
|
324
|
+
private get routing(): ChannelRoutingOptions | undefined {
|
|
325
|
+
return this.host.routing;
|
|
326
|
+
}
|
|
327
|
+
|
|
144
328
|
private now(): number {
|
|
145
329
|
return this.host.now?.() ?? Date.now();
|
|
146
330
|
}
|
|
@@ -186,6 +370,8 @@ export class ChannelTurns {
|
|
|
186
370
|
);
|
|
187
371
|
await add('mirror_failures', 'INTEGER NOT NULL DEFAULT 0');
|
|
188
372
|
await add('mirror_retry_at', 'INTEGER');
|
|
373
|
+
await add('routing_json', 'TEXT');
|
|
374
|
+
await add('routing_observation', 'TEXT');
|
|
189
375
|
if (await add('created_at', 'INTEGER'))
|
|
190
376
|
await this.db.run(
|
|
191
377
|
'UPDATE channel_requests SET created_at = ? WHERE created_at IS NULL',
|
|
@@ -197,9 +383,73 @@ export class ChannelTurns {
|
|
|
197
383
|
await this.db.run(
|
|
198
384
|
'CREATE INDEX IF NOT EXISTS idx_channel_requests_created ON channel_requests(created_at)',
|
|
199
385
|
);
|
|
386
|
+
await this.db.run(
|
|
387
|
+
'CREATE INDEX IF NOT EXISTS idx_channel_requests_session ON channel_requests(binding_id, session_id)',
|
|
388
|
+
);
|
|
389
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_controls (
|
|
390
|
+
binding_id TEXT NOT NULL, request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
|
|
391
|
+
result TEXT, replacement_session_id TEXT, created_at INTEGER NOT NULL,
|
|
392
|
+
PRIMARY KEY(binding_id, request_id)
|
|
393
|
+
)`);
|
|
394
|
+
if (
|
|
395
|
+
!(
|
|
396
|
+
await this.db.exec<{ name: string }>(
|
|
397
|
+
'PRAGMA table_info(channel_controls)',
|
|
398
|
+
)
|
|
399
|
+
).some((column) => column.name === 'replacement_session_id')
|
|
400
|
+
)
|
|
401
|
+
await this.db.run(
|
|
402
|
+
'ALTER TABLE channel_controls ADD COLUMN replacement_session_id TEXT',
|
|
403
|
+
);
|
|
404
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_cancellations (
|
|
405
|
+
binding_id TEXT NOT NULL, request_id TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
406
|
+
PRIMARY KEY(binding_id, request_id)
|
|
407
|
+
)`);
|
|
408
|
+
await this.db.run(
|
|
409
|
+
'CREATE INDEX IF NOT EXISTS idx_channel_controls_created ON channel_controls(created_at)',
|
|
410
|
+
);
|
|
411
|
+
await this.db.run(
|
|
412
|
+
'CREATE INDEX IF NOT EXISTS idx_channel_cancellations_created ON channel_cancellations(created_at)',
|
|
413
|
+
);
|
|
414
|
+
// The sessions a binding has used, per consent revision: what `/resume`
|
|
415
|
+
// may return to. Not seeded from older data: a session bound before
|
|
416
|
+
// this table existed is recognised by its request receipts instead
|
|
417
|
+
// (`usedByBinding`), which hold the same evidence.
|
|
418
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_session_bindings (
|
|
419
|
+
binding_id TEXT NOT NULL, binding_revision INTEGER NOT NULL, session_id TEXT NOT NULL,
|
|
420
|
+
created_at INTEGER NOT NULL,
|
|
421
|
+
PRIMARY KEY(binding_id, binding_revision, session_id)
|
|
422
|
+
)`);
|
|
200
423
|
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_sessions (
|
|
201
424
|
binding_id TEXT PRIMARY KEY, session_id TEXT NOT NULL
|
|
202
425
|
)`);
|
|
426
|
+
const sessionColumns = await this.db.exec<{ name: string }>(
|
|
427
|
+
'PRAGMA table_info(channel_sessions)',
|
|
428
|
+
);
|
|
429
|
+
for (const [name, definition] of [
|
|
430
|
+
['generation', 'INTEGER NOT NULL DEFAULT 0'],
|
|
431
|
+
['force_next', 'INTEGER NOT NULL DEFAULT 0'],
|
|
432
|
+
]) {
|
|
433
|
+
if (!sessionColumns.some((column) => column.name === name))
|
|
434
|
+
await this.db.run(
|
|
435
|
+
`ALTER TABLE channel_sessions ADD COLUMN ${name} ${definition}`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (this.routing?.mode !== undefined && this.routing.mode !== 'off') {
|
|
439
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_thread_index (
|
|
440
|
+
binding_id TEXT NOT NULL, binding_revision INTEGER NOT NULL, session_id TEXT NOT NULL,
|
|
441
|
+
topic TEXT NOT NULL, recent_user TEXT NOT NULL, recent_reply TEXT NOT NULL,
|
|
442
|
+
last_run_id TEXT NOT NULL, updated_at INTEGER NOT NULL,
|
|
443
|
+
PRIMARY KEY(binding_id, binding_revision, session_id)
|
|
444
|
+
)`);
|
|
445
|
+
// How far `channel_requests` was read into the thread index, per
|
|
446
|
+
// binding and revision: later turns read only the receipts after it.
|
|
447
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_thread_seeded (
|
|
448
|
+
binding_id TEXT NOT NULL, binding_revision INTEGER NOT NULL,
|
|
449
|
+
seeded_rowid INTEGER NOT NULL,
|
|
450
|
+
PRIMARY KEY(binding_id, binding_revision)
|
|
451
|
+
)`);
|
|
452
|
+
}
|
|
203
453
|
})().catch((error: unknown) => {
|
|
204
454
|
this.setupPromise = undefined;
|
|
205
455
|
throw error;
|
|
@@ -207,6 +457,492 @@ export class ChannelTurns {
|
|
|
207
457
|
return this.setupPromise;
|
|
208
458
|
}
|
|
209
459
|
|
|
460
|
+
/** SQLite admission is serialized; stopping runs and Matrix I/O never hold that lock. */
|
|
461
|
+
async control(
|
|
462
|
+
identity: TurnIdentity,
|
|
463
|
+
input: ChannelControlInput,
|
|
464
|
+
requestHash: string,
|
|
465
|
+
): Promise<ChannelControlResult> {
|
|
466
|
+
if (
|
|
467
|
+
!identity.channel ||
|
|
468
|
+
identity.channel.bindingId !== input.bindingId ||
|
|
469
|
+
identity.channel.bindingRevision !== input.bindingRevision ||
|
|
470
|
+
identity.channel.provider !== input.provider
|
|
471
|
+
)
|
|
472
|
+
throw new ChannelError(
|
|
473
|
+
403,
|
|
474
|
+
'Channel identity does not match this request',
|
|
475
|
+
'identity_mismatch',
|
|
476
|
+
);
|
|
477
|
+
if (
|
|
478
|
+
(input.command.kind === 'status' ||
|
|
479
|
+
input.command.kind === 'topics' ||
|
|
480
|
+
input.command.kind === 'rename') &&
|
|
481
|
+
input.cancelRequestIds.length
|
|
482
|
+
)
|
|
483
|
+
throw new ChannelError(
|
|
484
|
+
400,
|
|
485
|
+
`A ${input.command.kind} request cannot cancel work`,
|
|
486
|
+
'invalid_request',
|
|
487
|
+
);
|
|
488
|
+
const renamed =
|
|
489
|
+
input.command.kind === 'rename'
|
|
490
|
+
? cleanTitle(input.command.title)
|
|
491
|
+
: undefined;
|
|
492
|
+
const saved = await this.serialize(async () => {
|
|
493
|
+
await this.setup();
|
|
494
|
+
const row = await this.db.get<{
|
|
495
|
+
request_hash: string;
|
|
496
|
+
result: string | null;
|
|
497
|
+
replacement_session_id: string | null;
|
|
498
|
+
}>(
|
|
499
|
+
'SELECT request_hash,result,replacement_session_id FROM channel_controls WHERE binding_id=? AND request_id=?',
|
|
500
|
+
[input.bindingId, input.requestId],
|
|
501
|
+
);
|
|
502
|
+
if (row && row.request_hash !== requestHash)
|
|
503
|
+
throw new ChannelError(
|
|
504
|
+
409,
|
|
505
|
+
'Control request changed',
|
|
506
|
+
'request_conflict',
|
|
507
|
+
);
|
|
508
|
+
const result = row?.result
|
|
509
|
+
? readStored(StoredChannelControlResult, row.result, 'control result')
|
|
510
|
+
: undefined;
|
|
511
|
+
if (result) return { result };
|
|
512
|
+
await this.assertNotCancelled(input);
|
|
513
|
+
if (!row) {
|
|
514
|
+
await this.db.run(
|
|
515
|
+
'INSERT INTO channel_controls(binding_id,request_id,request_hash,created_at) VALUES(?,?,?,?)',
|
|
516
|
+
[input.bindingId, input.requestId, requestHash, this.now()],
|
|
517
|
+
);
|
|
518
|
+
await this.host.changed();
|
|
519
|
+
}
|
|
520
|
+
return { replacement: row?.replacement_session_id ?? undefined };
|
|
521
|
+
});
|
|
522
|
+
if (saved.result) return saved.result;
|
|
523
|
+
// Refused destinations leave the existing binding and its work
|
|
524
|
+
// untouched, and take their receipt with them: a corrected body under
|
|
525
|
+
// the same id is the same control, not a conflict.
|
|
526
|
+
try {
|
|
527
|
+
if (input.command.kind === 'resume') {
|
|
528
|
+
await this.host.assertSession(identity, input.command.sessionId);
|
|
529
|
+
if (!(await this.usedByBinding(input, input.command.sessionId)))
|
|
530
|
+
throw new ChannelError(
|
|
531
|
+
404,
|
|
532
|
+
'Oracle session not found',
|
|
533
|
+
'session_not_found',
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if (input.command.kind === 'new')
|
|
537
|
+
await this.host.requireDelegation(identity);
|
|
538
|
+
if (input.command.kind === 'rename') {
|
|
539
|
+
if (!(await this.boundSession(input.bindingId)))
|
|
540
|
+
throw new ChannelError(
|
|
541
|
+
404,
|
|
542
|
+
'This conversation has no topic to rename',
|
|
543
|
+
'session_not_found',
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
} catch (error) {
|
|
547
|
+
await this.serialize(async () => {
|
|
548
|
+
const dropped = await this.db.run(
|
|
549
|
+
'DELETE FROM channel_controls WHERE binding_id=? AND request_id=? AND result IS NULL',
|
|
550
|
+
[input.bindingId, input.requestId],
|
|
551
|
+
);
|
|
552
|
+
if (dropped.changes > 0) await this.host.changed();
|
|
553
|
+
});
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
await this.serialize(async () => {
|
|
557
|
+
await this.assertNotCancelled(input);
|
|
558
|
+
let reserved = 0;
|
|
559
|
+
for (const requestId of input.cancelRequestIds)
|
|
560
|
+
reserved += (
|
|
561
|
+
await this.db.run(
|
|
562
|
+
'INSERT OR IGNORE INTO channel_cancellations(binding_id,request_id,created_at) VALUES(?,?,?)',
|
|
563
|
+
[input.bindingId, requestId, this.now()],
|
|
564
|
+
)
|
|
565
|
+
).changes;
|
|
566
|
+
if (reserved > 0) await this.host.changed();
|
|
567
|
+
});
|
|
568
|
+
// The reservations above are durable and checked before every attempt,
|
|
569
|
+
// so nothing named can start any more. A run that is still winding down
|
|
570
|
+
// (a tool call in flight) is not waited for past one shared grace: a
|
|
571
|
+
// retry of this control could not stop it any sooner.
|
|
572
|
+
if (input.cancelRequestIds.length)
|
|
573
|
+
await this.host.stopRuns(
|
|
574
|
+
await Promise.all(
|
|
575
|
+
input.cancelRequestIds.map((requestId) =>
|
|
576
|
+
channelRunId(input.bindingId, requestId),
|
|
577
|
+
),
|
|
578
|
+
),
|
|
579
|
+
);
|
|
580
|
+
let replacement: string | undefined;
|
|
581
|
+
if (input.command.kind === 'new') {
|
|
582
|
+
replacement = saved.replacement;
|
|
583
|
+
if (!replacement) {
|
|
584
|
+
replacement = await this.host.createSession(
|
|
585
|
+
identity,
|
|
586
|
+
`channel-reset-${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`,
|
|
587
|
+
);
|
|
588
|
+
// A retry reuses the session instead of asking Matrix again.
|
|
589
|
+
const created = replacement;
|
|
590
|
+
await this.serialize(async () => {
|
|
591
|
+
const stored = await this.db.run(
|
|
592
|
+
'UPDATE channel_controls SET replacement_session_id=? WHERE binding_id=? AND request_id=? AND replacement_session_id IS NULL',
|
|
593
|
+
[created, input.bindingId, input.requestId],
|
|
594
|
+
);
|
|
595
|
+
if (stored.changes > 0) await this.host.changed();
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
await this.host.assertSession(identity, replacement);
|
|
599
|
+
} else if (input.command.kind === 'resume')
|
|
600
|
+
replacement = input.command.sessionId;
|
|
601
|
+
const listing =
|
|
602
|
+
input.command.kind === 'topics'
|
|
603
|
+
? await this.listTopics(input)
|
|
604
|
+
: undefined;
|
|
605
|
+
return this.serialize(async () => {
|
|
606
|
+
// A later stop can cancel this control while its Matrix call is outstanding.
|
|
607
|
+
await this.assertNotCancelled(input);
|
|
608
|
+
const completed = await this.db.get<{ result: string | null }>(
|
|
609
|
+
'SELECT result FROM channel_controls WHERE binding_id=? AND request_id=?',
|
|
610
|
+
[input.bindingId, input.requestId],
|
|
611
|
+
);
|
|
612
|
+
const already = completed?.result
|
|
613
|
+
? readStored(
|
|
614
|
+
StoredChannelControlResult,
|
|
615
|
+
completed.result,
|
|
616
|
+
'control result',
|
|
617
|
+
)
|
|
618
|
+
: undefined;
|
|
619
|
+
if (already) return already;
|
|
620
|
+
if (replacement) {
|
|
621
|
+
await this.db.run(
|
|
622
|
+
'INSERT INTO channel_sessions(binding_id,session_id,generation,force_next) VALUES(?,?,1,1) ON CONFLICT(binding_id) DO UPDATE SET session_id=excluded.session_id,generation=channel_sessions.generation+1,force_next=1',
|
|
623
|
+
[input.bindingId, replacement],
|
|
624
|
+
);
|
|
625
|
+
await this.recordSessionBinding(input, replacement);
|
|
626
|
+
}
|
|
627
|
+
const sessionId = listing
|
|
628
|
+
? listing.bound
|
|
629
|
+
: (replacement ?? (await this.boundSession(input.bindingId)));
|
|
630
|
+
if (renamed !== undefined) {
|
|
631
|
+
// Bound now, not as it was when the title was checked: a reset in
|
|
632
|
+
// between moves the name to the topic the person is in.
|
|
633
|
+
if (
|
|
634
|
+
!sessionId ||
|
|
635
|
+
!(await this.host.renameSession(sessionId, renamed))
|
|
636
|
+
) {
|
|
637
|
+
// Refused after the checks passed: the receipt goes with it, so a
|
|
638
|
+
// corrected retry under the same id is not a conflict.
|
|
639
|
+
const dropped = await this.db.run(
|
|
640
|
+
'DELETE FROM channel_controls WHERE binding_id=? AND request_id=? AND result IS NULL',
|
|
641
|
+
[input.bindingId, input.requestId],
|
|
642
|
+
);
|
|
643
|
+
if (dropped.changes > 0) await this.host.changed();
|
|
644
|
+
throw new ChannelError(
|
|
645
|
+
404,
|
|
646
|
+
'This conversation has no topic to rename',
|
|
647
|
+
'session_not_found',
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
await this.host.changed();
|
|
651
|
+
}
|
|
652
|
+
const result: ChannelControlResult = {
|
|
653
|
+
requestId: input.requestId,
|
|
654
|
+
sessionId,
|
|
655
|
+
command: input.command.kind,
|
|
656
|
+
title: await this.titleOf(sessionId),
|
|
657
|
+
...(listing ? { topics: listing.topics } : {}),
|
|
658
|
+
};
|
|
659
|
+
await this.db.run(
|
|
660
|
+
'UPDATE channel_controls SET result=? WHERE binding_id=? AND request_id=?',
|
|
661
|
+
[JSON.stringify(result), input.bindingId, input.requestId],
|
|
662
|
+
);
|
|
663
|
+
await this.host.changed();
|
|
664
|
+
return result;
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* A session's title for use inside `*…*`: one plain line without the
|
|
670
|
+
* characters that would end or restyle the markup, at most 80 code points;
|
|
671
|
+
* null when untitled or unknown.
|
|
672
|
+
*/
|
|
673
|
+
private async boldTitle(sessionId: string | null): Promise<string | null> {
|
|
674
|
+
if (!sessionId) return null;
|
|
675
|
+
const title = wireTitle(
|
|
676
|
+
(await this.host.describeSessions([sessionId])).get(sessionId)?.title,
|
|
677
|
+
);
|
|
678
|
+
if (!title) return null;
|
|
679
|
+
const plain = clipCodePoints(
|
|
680
|
+
plainLine(title.replace(/[*_~`]/gu, '')),
|
|
681
|
+
CHANNEL_TITLE_LIMIT,
|
|
682
|
+
);
|
|
683
|
+
return plain.length > 0 ? plain : null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** The title of a session as the wire carries it: null when untitled or unknown. */
|
|
687
|
+
private async titleOf(sessionId: string | null): Promise<string | null> {
|
|
688
|
+
if (!sessionId) return null;
|
|
689
|
+
const found = await this.host.describeSessions([sessionId]);
|
|
690
|
+
return wireTitle(found.get(sessionId)?.title);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* The sessions this binding used at its current consent revision (the
|
|
695
|
+
* bound one among them), most recent activity first, and the bound session
|
|
696
|
+
* the list was built from. Read-only apart from forgetting the binding
|
|
697
|
+
* rows of sessions the user deleted.
|
|
698
|
+
*/
|
|
699
|
+
private async listTopics(
|
|
700
|
+
input: Pick<ChannelTurnInput, 'bindingId' | 'bindingRevision'>,
|
|
701
|
+
): Promise<{ bound: string | null; topics: ChannelTopic[] }> {
|
|
702
|
+
const used = await this.db.exec<{ session_id: string }>(
|
|
703
|
+
'SELECT session_id FROM channel_session_bindings WHERE binding_id=? AND binding_revision=? ORDER BY created_at DESC, rowid DESC LIMIT ?',
|
|
704
|
+
[input.bindingId, input.bindingRevision, CHANNEL_TOPICS_SCAN],
|
|
705
|
+
);
|
|
706
|
+
const bound = await this.boundSession(input.bindingId);
|
|
707
|
+
const ids = new Set(used.map((row) => row.session_id));
|
|
708
|
+
if (bound) ids.add(bound);
|
|
709
|
+
const described = await this.host.describeSessions([...ids]);
|
|
710
|
+
const deleted = [...ids].filter((sessionId) => !described.has(sessionId));
|
|
711
|
+
if (deleted.length)
|
|
712
|
+
await this.serialize(async () => {
|
|
713
|
+
let removed = 0;
|
|
714
|
+
for (let at = 0; at < deleted.length; at += CHANNEL_IDS_PER_STATEMENT) {
|
|
715
|
+
const chunk = deleted.slice(at, at + CHANNEL_IDS_PER_STATEMENT);
|
|
716
|
+
removed += (
|
|
717
|
+
await this.db.run(
|
|
718
|
+
`DELETE FROM channel_session_bindings WHERE binding_id=? AND session_id IN (${chunk.map(() => '?').join(', ')})`,
|
|
719
|
+
[input.bindingId, ...chunk],
|
|
720
|
+
)
|
|
721
|
+
).changes;
|
|
722
|
+
}
|
|
723
|
+
if (removed > 0) await this.host.changed();
|
|
724
|
+
});
|
|
725
|
+
const byActivity = [...described]
|
|
726
|
+
.map(([sessionId, info]) => ({ sessionId, info }))
|
|
727
|
+
.sort((a, b) => b.info.updatedAt - a.info.updatedAt);
|
|
728
|
+
let kept = byActivity.slice(0, CHANNEL_TOPICS_LIMIT);
|
|
729
|
+
const boundEntry = byActivity.find((entry) => entry.sessionId === bound);
|
|
730
|
+
if (boundEntry && !kept.includes(boundEntry))
|
|
731
|
+
kept = [...kept.slice(0, CHANNEL_TOPICS_LIMIT - 1), boundEntry].sort(
|
|
732
|
+
(a, b) => b.info.updatedAt - a.info.updatedAt,
|
|
733
|
+
);
|
|
734
|
+
const newest = await this.newestReceipts(
|
|
735
|
+
input.bindingId,
|
|
736
|
+
kept.map((entry) => entry.sessionId),
|
|
737
|
+
);
|
|
738
|
+
const topics: ChannelTopic[] = [];
|
|
739
|
+
for (const { sessionId, info } of kept) {
|
|
740
|
+
const line = await this.topicLines(
|
|
741
|
+
input,
|
|
742
|
+
sessionId,
|
|
743
|
+
newest.get(sessionId),
|
|
744
|
+
);
|
|
745
|
+
topics.push({
|
|
746
|
+
sessionId,
|
|
747
|
+
title: wireTitle(info.title),
|
|
748
|
+
createdAt: info.createdAt,
|
|
749
|
+
updatedAt: info.updatedAt,
|
|
750
|
+
...line,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
return { bound, topics };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** The run of the newest request receipt of each session, in one statement. */
|
|
757
|
+
private async newestReceipts(
|
|
758
|
+
bindingId: string,
|
|
759
|
+
sessionIds: readonly string[],
|
|
760
|
+
): Promise<Map<string, string>> {
|
|
761
|
+
const newest = new Map<string, string>();
|
|
762
|
+
if (sessionIds.length === 0) return newest;
|
|
763
|
+
const marks = sessionIds.map(() => '?').join(', ');
|
|
764
|
+
const rows = await this.db.exec<{ session_id: string; run_id: string }>(
|
|
765
|
+
`SELECT session_id, run_id FROM channel_requests WHERE binding_id=? AND rowid IN (SELECT MAX(rowid) FROM channel_requests WHERE binding_id=? AND session_id IN (${marks}) GROUP BY session_id)`,
|
|
766
|
+
[bindingId, bindingId, ...sessionIds],
|
|
767
|
+
);
|
|
768
|
+
for (const row of rows) newest.set(row.session_id, row.run_id);
|
|
769
|
+
return newest;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* What the person last said in a session and the reply to it, from the
|
|
774
|
+
* newest receipt that is not a routing question (those are the oracle's
|
|
775
|
+
* words, not the topic's). Null lines when there is none, when it was sent
|
|
776
|
+
* under another consent revision, or when its stored request is unreadable.
|
|
777
|
+
*/
|
|
778
|
+
private async topicLines(
|
|
779
|
+
input: Pick<ChannelTurnInput, 'bindingId' | 'bindingRevision'>,
|
|
780
|
+
sessionId: string,
|
|
781
|
+
newestRunId: string | undefined,
|
|
782
|
+
): Promise<{ lastUser: string | null; lastReply: string | null }> {
|
|
783
|
+
const none = { lastUser: null, lastReply: null };
|
|
784
|
+
if (!newestRunId) return none;
|
|
785
|
+
let found = await this.topicReceipt(newestRunId);
|
|
786
|
+
if (found?.request.channel?.routingClarification) {
|
|
787
|
+
found = undefined;
|
|
788
|
+
const recent = await this.db.exec<{ run_id: string }>(
|
|
789
|
+
'SELECT run_id FROM channel_requests WHERE binding_id=? AND session_id=? ORDER BY rowid DESC LIMIT ?',
|
|
790
|
+
[input.bindingId, sessionId, CHANNEL_TOPIC_LOOKBACK],
|
|
791
|
+
);
|
|
792
|
+
for (const row of recent) {
|
|
793
|
+
const candidate = await this.topicReceipt(row.run_id);
|
|
794
|
+
if (candidate && !candidate.request.channel?.routingClarification) {
|
|
795
|
+
found = candidate;
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
// Text from before the person's latest consent is not quoted.
|
|
801
|
+
if (
|
|
802
|
+
!found ||
|
|
803
|
+
found.request.identity.channel?.bindingRevision !== input.bindingRevision
|
|
804
|
+
)
|
|
805
|
+
return none;
|
|
806
|
+
return {
|
|
807
|
+
lastUser: topicLine(found.request.message),
|
|
808
|
+
lastReply: topicLine(
|
|
809
|
+
found.run.status === 'finished' ? found.run.partialText : null,
|
|
810
|
+
),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** A run and its stored request; undefined when either is missing or unreadable. */
|
|
815
|
+
private async topicReceipt(
|
|
816
|
+
runId: string,
|
|
817
|
+
): Promise<{ run: RunRecord; request: TurnRequest } | undefined> {
|
|
818
|
+
const run = await this.host.getRun(runId);
|
|
819
|
+
if (!run) return undefined;
|
|
820
|
+
try {
|
|
821
|
+
return { run, request: this.host.requestOf(run) };
|
|
822
|
+
} catch {
|
|
823
|
+
return undefined;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Note that the binding, at its current consent revision, uses `sessionId`,
|
|
829
|
+
* and when it last did: `created_at` is the time of the latest binding, so
|
|
830
|
+
* `topics` reads the most recently bound sessions.
|
|
831
|
+
*/
|
|
832
|
+
private async recordSessionBinding(
|
|
833
|
+
input: Pick<ChannelTurnInput, 'bindingId' | 'bindingRevision'>,
|
|
834
|
+
sessionId: string,
|
|
835
|
+
): Promise<void> {
|
|
836
|
+
const recorded = await this.db.run(
|
|
837
|
+
'INSERT INTO channel_session_bindings(binding_id,binding_revision,session_id,created_at) VALUES(?,?,?,?) ON CONFLICT(binding_id,binding_revision,session_id) DO UPDATE SET created_at=excluded.created_at WHERE excluded.created_at > channel_session_bindings.created_at',
|
|
838
|
+
[input.bindingId, input.bindingRevision, sessionId, this.now()],
|
|
839
|
+
);
|
|
840
|
+
if (recorded.changes > 0) await this.host.changed();
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Whether this binding used `sessionId` at its current consent revision:
|
|
845
|
+
* the only sessions `/resume` may return to. A Portal conversation, or
|
|
846
|
+
* one this binding used under an earlier consent, is not one of them.
|
|
847
|
+
*/
|
|
848
|
+
private async usedByBinding(
|
|
849
|
+
input: Pick<ChannelTurnInput, 'bindingId' | 'bindingRevision'>,
|
|
850
|
+
sessionId: string,
|
|
851
|
+
): Promise<boolean> {
|
|
852
|
+
if (
|
|
853
|
+
await this.db.get(
|
|
854
|
+
'SELECT 1 FROM channel_session_bindings WHERE binding_id=? AND binding_revision=? AND session_id=?',
|
|
855
|
+
[input.bindingId, input.bindingRevision, sessionId],
|
|
856
|
+
)
|
|
857
|
+
)
|
|
858
|
+
return true;
|
|
859
|
+
// A session bound before `channel_session_bindings` existed: the stored
|
|
860
|
+
// request of the binding's latest receipt in it carries the revision.
|
|
861
|
+
// Revisions only grow, so no older receipt can match when it does not.
|
|
862
|
+
const latest = await this.db.get<{ run_id: string }>(
|
|
863
|
+
'SELECT run_id FROM channel_requests WHERE binding_id=? AND session_id=? ORDER BY rowid DESC LIMIT 1',
|
|
864
|
+
[input.bindingId, sessionId],
|
|
865
|
+
);
|
|
866
|
+
const run = latest ? await this.host.getRun(latest.run_id) : undefined;
|
|
867
|
+
return (
|
|
868
|
+
run !== undefined &&
|
|
869
|
+
this.host.requestOf(run).identity.channel?.bindingRevision ===
|
|
870
|
+
input.bindingRevision
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async isCancelled(
|
|
875
|
+
input: Pick<ChannelTurnInput, 'bindingId' | 'requestId'>,
|
|
876
|
+
): Promise<boolean> {
|
|
877
|
+
await this.setup();
|
|
878
|
+
return Boolean(
|
|
879
|
+
await this.db.get(
|
|
880
|
+
'SELECT 1 FROM channel_cancellations WHERE binding_id=? AND request_id=?',
|
|
881
|
+
[input.bindingId, input.requestId],
|
|
882
|
+
),
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private async assertNotCancelled(
|
|
887
|
+
input: Pick<ChannelTurnInput, 'bindingId' | 'requestId'>,
|
|
888
|
+
): Promise<void> {
|
|
889
|
+
if (await this.isCancelled(input))
|
|
890
|
+
throw new ChannelError(
|
|
891
|
+
409,
|
|
892
|
+
'This message was cancelled by its owner',
|
|
893
|
+
'request_cancelled',
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private async replyContext(
|
|
898
|
+
input: ChannelTurnInput,
|
|
899
|
+
sessionId: string,
|
|
900
|
+
): Promise<ChannelReplyContext | undefined> {
|
|
901
|
+
const reference = input.replyTo;
|
|
902
|
+
if (!reference) return undefined;
|
|
903
|
+
const unknown: ChannelReplyContext = {
|
|
904
|
+
kind: 'unavailable',
|
|
905
|
+
reason: 'unknown',
|
|
906
|
+
};
|
|
907
|
+
if (reference.kind === 'unavailable') return unknown;
|
|
908
|
+
const receipt = await this.receipt({
|
|
909
|
+
...input,
|
|
910
|
+
requestId: reference.requestId,
|
|
911
|
+
});
|
|
912
|
+
if (!receipt?.session_id) return unknown;
|
|
913
|
+
// The quote is this binding's, but its conversation is not the one the
|
|
914
|
+
// turn runs in (a routed quote whose session could not be selected
|
|
915
|
+
// lands here too): its text stays out of this conversation.
|
|
916
|
+
if (receipt.session_id !== sessionId)
|
|
917
|
+
return { kind: 'unavailable', reason: 'other_conversation' };
|
|
918
|
+
const run = await this.host.getRun(receipt.run_id);
|
|
919
|
+
if (!run) return unknown;
|
|
920
|
+
let text: string;
|
|
921
|
+
if (reference.author === 'user') text = this.host.requestOf(run).message;
|
|
922
|
+
else {
|
|
923
|
+
if (run.status !== 'finished') return unknown;
|
|
924
|
+
const reply = await this.reply(run.runId, run.partialText ?? '');
|
|
925
|
+
const chunk = reference.partId
|
|
926
|
+
? /^(.*)\.([1-9]\d*)$/.exec(reference.partId)
|
|
927
|
+
: null;
|
|
928
|
+
const matched =
|
|
929
|
+
reference.partId && reply.plan
|
|
930
|
+
? (reply.plan.parts.find((p) => p.partId === reference.partId) ??
|
|
931
|
+
(chunk?.[1]
|
|
932
|
+
? reply.plan.parts.find((p) => p.partId === chunk[1])
|
|
933
|
+
: undefined))
|
|
934
|
+
: undefined;
|
|
935
|
+
if (reference.partId && reply.plan && !matched) return unknown;
|
|
936
|
+
text = matched ? planText({ parts: [matched] }) : reply.rendered;
|
|
937
|
+
}
|
|
938
|
+
return {
|
|
939
|
+
kind: 'resolved',
|
|
940
|
+
requestId: reference.requestId,
|
|
941
|
+
author: reference.author,
|
|
942
|
+
text: text.slice(0, 16000),
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
210
946
|
/**
|
|
211
947
|
* The SQLite steps of admissions and session releases run one at a time.
|
|
212
948
|
* Nothing that waits on Matrix runs under it.
|
|
@@ -217,7 +953,51 @@ export class ChannelTurns {
|
|
|
217
953
|
return next;
|
|
218
954
|
}
|
|
219
955
|
|
|
220
|
-
|
|
956
|
+
submit(
|
|
957
|
+
identity: TurnIdentity,
|
|
958
|
+
input: ChannelTurnInput,
|
|
959
|
+
requestHash: string,
|
|
960
|
+
): Promise<ChannelTurnResponse> {
|
|
961
|
+
if (!this.routingEnabled(input))
|
|
962
|
+
return this.submitTurn(identity, input, requestHash);
|
|
963
|
+
// Classifier/Matrix I/O is ordered per binding, outside the admission
|
|
964
|
+
// lock. Controls and other bindings can still make progress.
|
|
965
|
+
let lane = this.routingLanes.get(input.bindingId);
|
|
966
|
+
if (!lane) {
|
|
967
|
+
lane = { tail: Promise.resolve(), requests: new Map() };
|
|
968
|
+
this.routingLanes.set(input.bindingId, lane);
|
|
969
|
+
}
|
|
970
|
+
// The gateway retries a request it timed out on: the retry answers with
|
|
971
|
+
// the submission still in flight instead of queueing behind it.
|
|
972
|
+
const inFlight = lane.requests.get(input.requestId);
|
|
973
|
+
if (inFlight?.requestHash === requestHash) return inFlight.response;
|
|
974
|
+
const owner = lane;
|
|
975
|
+
const next = owner.tail
|
|
976
|
+
.catch(() => undefined)
|
|
977
|
+
.then(() =>
|
|
978
|
+
withDeadline(
|
|
979
|
+
this.submitTurn(identity, input, requestHash),
|
|
980
|
+
ROUTING_LANE_DEADLINE_MS,
|
|
981
|
+
),
|
|
982
|
+
);
|
|
983
|
+
owner.tail = next;
|
|
984
|
+
if (!inFlight)
|
|
985
|
+
owner.requests.set(input.requestId, { requestHash, response: next });
|
|
986
|
+
void next
|
|
987
|
+
.finally(() => {
|
|
988
|
+
if (owner.requests.get(input.requestId)?.response === next)
|
|
989
|
+
owner.requests.delete(input.requestId);
|
|
990
|
+
if (
|
|
991
|
+
owner.tail === next &&
|
|
992
|
+
this.routingLanes.get(input.bindingId) === owner
|
|
993
|
+
)
|
|
994
|
+
this.routingLanes.delete(input.bindingId);
|
|
995
|
+
})
|
|
996
|
+
.catch(() => undefined);
|
|
997
|
+
return next;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
private async submitTurn(
|
|
221
1001
|
identity: TurnIdentity,
|
|
222
1002
|
input: ChannelTurnInput,
|
|
223
1003
|
requestHash: string,
|
|
@@ -242,9 +1022,14 @@ export class ChannelTurns {
|
|
|
242
1022
|
this.receive(input, requestHash, runId),
|
|
243
1023
|
);
|
|
244
1024
|
if (received.kind === 'run') return received.response;
|
|
245
|
-
let
|
|
1025
|
+
let prepared: PreparedChannelTurn;
|
|
246
1026
|
try {
|
|
247
|
-
|
|
1027
|
+
prepared = await this.prepare(
|
|
1028
|
+
identity,
|
|
1029
|
+
input,
|
|
1030
|
+
received.sessionId,
|
|
1031
|
+
received.routingJson,
|
|
1032
|
+
);
|
|
248
1033
|
} catch (error) {
|
|
249
1034
|
const stale =
|
|
250
1035
|
error instanceof StaleSessionError && received.sessionId !== null;
|
|
@@ -263,7 +1048,7 @@ export class ChannelTurns {
|
|
|
263
1048
|
throw error;
|
|
264
1049
|
}
|
|
265
1050
|
return this.serialize(() =>
|
|
266
|
-
this.start(identity, input, requestHash, runId,
|
|
1051
|
+
this.start(identity, input, requestHash, runId, prepared),
|
|
267
1052
|
);
|
|
268
1053
|
}
|
|
269
1054
|
}
|
|
@@ -313,7 +1098,48 @@ export class ChannelTurns {
|
|
|
313
1098
|
text: string,
|
|
314
1099
|
): Promise<{ plan: ReplyPlan | null; rendered: string }> {
|
|
315
1100
|
const plan = parseReplyPlan(await this.host.getPlan(runId));
|
|
316
|
-
|
|
1101
|
+
const row = await this.db.get<{ routing_json: string | null }>(
|
|
1102
|
+
'SELECT routing_json FROM channel_requests WHERE run_id=?',
|
|
1103
|
+
[runId],
|
|
1104
|
+
);
|
|
1105
|
+
const route = row?.routing_json
|
|
1106
|
+
? readStored(routeAssignmentSchema, row.routing_json, 'route assignment')
|
|
1107
|
+
: undefined;
|
|
1108
|
+
let notice: string | undefined;
|
|
1109
|
+
if (route?.mode === 'on' && route.destination.kind === 'new') {
|
|
1110
|
+
const before = await this.boldTitle(route.baseline);
|
|
1111
|
+
notice = before
|
|
1112
|
+
? `Starting a fresh topic for this. Send /resume and pick *${before}* to go back.`
|
|
1113
|
+
: 'Starting a fresh topic for this. Send /resume to go back to an earlier topic.';
|
|
1114
|
+
} else if (
|
|
1115
|
+
route?.mode === 'on' &&
|
|
1116
|
+
route.destination.kind === 'session' &&
|
|
1117
|
+
route.destination.sessionId !== route.baseline
|
|
1118
|
+
) {
|
|
1119
|
+
const to = await this.boldTitle(route.destination.sessionId);
|
|
1120
|
+
notice = `Back to ${to ? `*${to}*` : 'an earlier topic'}. Send /resume to pick another topic.`;
|
|
1121
|
+
}
|
|
1122
|
+
if (!notice) return { plan, rendered: plan ? planText(plan) : text };
|
|
1123
|
+
const decorated =
|
|
1124
|
+
plan && plan.parts.length < 999
|
|
1125
|
+
? {
|
|
1126
|
+
...plan,
|
|
1127
|
+
parts: [
|
|
1128
|
+
{
|
|
1129
|
+
partId: 'channel_thread_notice',
|
|
1130
|
+
kind: 'text' as const,
|
|
1131
|
+
text: notice,
|
|
1132
|
+
},
|
|
1133
|
+
...plan.parts,
|
|
1134
|
+
],
|
|
1135
|
+
}
|
|
1136
|
+
: null;
|
|
1137
|
+
return {
|
|
1138
|
+
plan: decorated,
|
|
1139
|
+
rendered: decorated
|
|
1140
|
+
? planText(decorated)
|
|
1141
|
+
: `${notice}\n\n${plan ? planText(plan) : text}`,
|
|
1142
|
+
};
|
|
317
1143
|
}
|
|
318
1144
|
|
|
319
1145
|
/**
|
|
@@ -327,6 +1153,22 @@ export class ChannelTurns {
|
|
|
327
1153
|
// A first message still opening a session must not hand the deleted
|
|
328
1154
|
// one to the binding's next message.
|
|
329
1155
|
this.opening.clear();
|
|
1156
|
+
if (
|
|
1157
|
+
await this.db.get(
|
|
1158
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='channel_thread_index'",
|
|
1159
|
+
)
|
|
1160
|
+
) {
|
|
1161
|
+
const removed = await this.db.run(
|
|
1162
|
+
'DELETE FROM channel_thread_index WHERE session_id=?',
|
|
1163
|
+
[sessionId],
|
|
1164
|
+
);
|
|
1165
|
+
if (removed.changes) await this.host.changed();
|
|
1166
|
+
}
|
|
1167
|
+
const unbound = await this.db.run(
|
|
1168
|
+
'DELETE FROM channel_session_bindings WHERE session_id=?',
|
|
1169
|
+
[sessionId],
|
|
1170
|
+
);
|
|
1171
|
+
if (unbound.changes) await this.host.changed();
|
|
330
1172
|
const released = await this.db.run(
|
|
331
1173
|
'DELETE FROM channel_sessions WHERE session_id = ?',
|
|
332
1174
|
[sessionId],
|
|
@@ -367,6 +1209,15 @@ export class ChannelTurns {
|
|
|
367
1209
|
)`,
|
|
368
1210
|
[before, CHANNEL_PRUNE_BATCH],
|
|
369
1211
|
);
|
|
1212
|
+
let controlsPruned = 0;
|
|
1213
|
+
for (const table of ['channel_controls', 'channel_cancellations']) {
|
|
1214
|
+
const pruned = await this.db.run(
|
|
1215
|
+
`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} WHERE created_at<? LIMIT ?)`,
|
|
1216
|
+
[before, CHANNEL_PRUNE_BATCH],
|
|
1217
|
+
);
|
|
1218
|
+
controlsPruned += pruned.changes;
|
|
1219
|
+
}
|
|
1220
|
+
if (controlsPruned) await this.host.changed();
|
|
370
1221
|
const tombstones = await this.host.pruneTombstones(
|
|
371
1222
|
before,
|
|
372
1223
|
CHANNEL_PRUNE_BATCH,
|
|
@@ -383,6 +1234,7 @@ export class ChannelTurns {
|
|
|
383
1234
|
const owedAt = typeof owed?.at === 'number' ? owed.at : null;
|
|
384
1235
|
if (owedAt !== null && owedAt <= now) this.kick();
|
|
385
1236
|
if (
|
|
1237
|
+
controlsPruned >= CHANNEL_PRUNE_BATCH ||
|
|
386
1238
|
receipts.changes >= CHANNEL_PRUNE_BATCH ||
|
|
387
1239
|
tombstones >= CHANNEL_PRUNE_BATCH
|
|
388
1240
|
)
|
|
@@ -407,6 +1259,280 @@ export class ChannelTurns {
|
|
|
407
1259
|
return binding?.session_id ?? null;
|
|
408
1260
|
}
|
|
409
1261
|
|
|
1262
|
+
private routingEnabled(input: ChannelTurnInput): boolean {
|
|
1263
|
+
return Boolean(
|
|
1264
|
+
this.routing &&
|
|
1265
|
+
this.routing.mode !== 'off' &&
|
|
1266
|
+
(!this.routing.bindings.length ||
|
|
1267
|
+
this.routing.bindings.includes(input.bindingId)),
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
private async routingState(
|
|
1272
|
+
bindingId: string,
|
|
1273
|
+
): Promise<{ generation: number; force_next: number }> {
|
|
1274
|
+
return (
|
|
1275
|
+
(await this.db.get<{ generation: number; force_next: number }>(
|
|
1276
|
+
'SELECT generation,force_next FROM channel_sessions WHERE binding_id=?',
|
|
1277
|
+
[bindingId],
|
|
1278
|
+
)) ?? { generation: 0, force_next: 0 }
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
private async indexThread(
|
|
1283
|
+
input: ChannelTurnInput,
|
|
1284
|
+
sessionId: string,
|
|
1285
|
+
runId: string,
|
|
1286
|
+
at = this.now(),
|
|
1287
|
+
): Promise<void> {
|
|
1288
|
+
await this.db.run(
|
|
1289
|
+
`INSERT INTO channel_thread_index
|
|
1290
|
+
(binding_id,binding_revision,session_id,topic,recent_user,recent_reply,last_run_id,updated_at)
|
|
1291
|
+
VALUES(?,?,?,?,?,'',?,?) ON CONFLICT(binding_id,binding_revision,session_id)
|
|
1292
|
+
DO UPDATE SET recent_user=excluded.recent_user,last_run_id=excluded.last_run_id,updated_at=excluded.updated_at`,
|
|
1293
|
+
[
|
|
1294
|
+
input.bindingId,
|
|
1295
|
+
input.bindingRevision,
|
|
1296
|
+
sessionId,
|
|
1297
|
+
input.message.slice(0, 300),
|
|
1298
|
+
input.message.slice(0, 800),
|
|
1299
|
+
runId,
|
|
1300
|
+
at,
|
|
1301
|
+
],
|
|
1302
|
+
);
|
|
1303
|
+
await this.db.run(
|
|
1304
|
+
`DELETE FROM channel_thread_index WHERE binding_id=? AND rowid NOT IN
|
|
1305
|
+
(SELECT rowid FROM channel_thread_index WHERE binding_id=? ORDER BY updated_at DESC,rowid DESC LIMIT ?)`,
|
|
1306
|
+
[input.bindingId, input.bindingId, CHANNEL_THREAD_LIMIT],
|
|
1307
|
+
);
|
|
1308
|
+
await this.host.changed();
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
private async routingCandidates(
|
|
1312
|
+
input: ChannelTurnInput,
|
|
1313
|
+
baseline: string,
|
|
1314
|
+
): Promise<ChannelThreadCandidate[]> {
|
|
1315
|
+
const routing = this.routing;
|
|
1316
|
+
if (!routing) return [];
|
|
1317
|
+
// Index the receipts written since the binding's last read: all of them
|
|
1318
|
+
// when an operator first enables routing, then the ones written while
|
|
1319
|
+
// routing was off (routed turns index themselves as they start). The
|
|
1320
|
+
// canonical stored request supplies revision isolation for older receipts.
|
|
1321
|
+
const seeded = await this.db.get<{ seeded_rowid: number }>(
|
|
1322
|
+
'SELECT seeded_rowid FROM channel_thread_seeded WHERE binding_id=? AND binding_revision=?',
|
|
1323
|
+
[input.bindingId, input.bindingRevision],
|
|
1324
|
+
);
|
|
1325
|
+
const after = Number(seeded?.seeded_rowid ?? 0);
|
|
1326
|
+
const newest = await this.db.get<{ last: number | null }>(
|
|
1327
|
+
'SELECT MAX(rowid) AS last FROM channel_requests WHERE binding_id=?',
|
|
1328
|
+
[input.bindingId],
|
|
1329
|
+
);
|
|
1330
|
+
const upTo = Number(newest?.last ?? 0);
|
|
1331
|
+
const prior =
|
|
1332
|
+
upTo > after
|
|
1333
|
+
? await this.db.exec<{ session_id: string; run_id: string }>(
|
|
1334
|
+
`SELECT session_id,run_id,MAX(rowid) AS last_row FROM channel_requests
|
|
1335
|
+
WHERE binding_id=? AND rowid>? AND session_id IS NOT NULL GROUP BY session_id
|
|
1336
|
+
ORDER BY last_row DESC LIMIT ?`,
|
|
1337
|
+
[input.bindingId, after, CHANNEL_THREAD_LIMIT],
|
|
1338
|
+
)
|
|
1339
|
+
: [];
|
|
1340
|
+
for (const row of prior.reverse()) {
|
|
1341
|
+
const indexed = await this.db.get<{ last_run_id: string }>(
|
|
1342
|
+
'SELECT last_run_id FROM channel_thread_index WHERE binding_id=? AND binding_revision=? AND session_id=?',
|
|
1343
|
+
[input.bindingId, input.bindingRevision, row.session_id],
|
|
1344
|
+
);
|
|
1345
|
+
if (indexed?.last_run_id === row.run_id) continue;
|
|
1346
|
+
const run = await this.host.getRun(row.run_id);
|
|
1347
|
+
if (!run) continue;
|
|
1348
|
+
const request = this.host.requestOf(run);
|
|
1349
|
+
if (request.channel?.routingClarification) continue;
|
|
1350
|
+
if (
|
|
1351
|
+
request.identity.channel?.bindingRevision !== input.bindingRevision ||
|
|
1352
|
+
request.channel?.bindingId !== input.bindingId
|
|
1353
|
+
)
|
|
1354
|
+
continue;
|
|
1355
|
+
await this.indexThread(
|
|
1356
|
+
{ ...input, message: request.message },
|
|
1357
|
+
row.session_id,
|
|
1358
|
+
row.run_id,
|
|
1359
|
+
Date.parse(run.updatedAt),
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
if (upTo > after) {
|
|
1363
|
+
await this.db.run(
|
|
1364
|
+
`INSERT INTO channel_thread_seeded(binding_id,binding_revision,seeded_rowid) VALUES(?,?,?)
|
|
1365
|
+
ON CONFLICT(binding_id,binding_revision) DO UPDATE SET seeded_rowid=excluded.seeded_rowid`,
|
|
1366
|
+
[input.bindingId, input.bindingRevision, upTo],
|
|
1367
|
+
);
|
|
1368
|
+
await this.host.changed();
|
|
1369
|
+
}
|
|
1370
|
+
const indexed = await this.db.exec<{
|
|
1371
|
+
session_id: string;
|
|
1372
|
+
topic: string;
|
|
1373
|
+
recent_user: string;
|
|
1374
|
+
recent_reply: string;
|
|
1375
|
+
last_run_id: string;
|
|
1376
|
+
}>(
|
|
1377
|
+
'SELECT * FROM channel_thread_index WHERE binding_id=? AND binding_revision=? ORDER BY updated_at DESC,rowid DESC LIMIT ?',
|
|
1378
|
+
[input.bindingId, input.bindingRevision, CHANNEL_THREAD_LIMIT],
|
|
1379
|
+
);
|
|
1380
|
+
const candidates: ChannelThreadCandidate[] = [];
|
|
1381
|
+
for (const row of indexed) {
|
|
1382
|
+
if (!(await routing.ownsSession(row.session_id))) continue;
|
|
1383
|
+
const run = await this.host.getRun(row.last_run_id);
|
|
1384
|
+
candidates.push({
|
|
1385
|
+
sessionId: row.session_id,
|
|
1386
|
+
topic: row.topic,
|
|
1387
|
+
recentUser: row.recent_user,
|
|
1388
|
+
recentReply:
|
|
1389
|
+
run?.status === 'finished'
|
|
1390
|
+
? (run.partialText ?? '').slice(0, 800)
|
|
1391
|
+
: row.recent_reply,
|
|
1392
|
+
status: run?.status ?? 'history',
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
return selectChannelCandidates(candidates, input.message, baseline);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* The request's route: the one stored on its receipt, else (routing on
|
|
1400
|
+
* for the binding) a new one, stored before anything runs. `storedJson`
|
|
1401
|
+
* is the receipt's assignment as admission read it, so a turn the router
|
|
1402
|
+
* does not handle (the default) reads nothing more, while an assigned
|
|
1403
|
+
* request keeps its destination across a flag change.
|
|
1404
|
+
*/
|
|
1405
|
+
private async assignRoute(
|
|
1406
|
+
input: ChannelTurnInput,
|
|
1407
|
+
baseline: string,
|
|
1408
|
+
storedJson: string | null,
|
|
1409
|
+
): Promise<ChannelRouteAssignment | undefined> {
|
|
1410
|
+
const routing = this.routing;
|
|
1411
|
+
if (!this.routingEnabled(input) || !routing || routing.mode === 'off')
|
|
1412
|
+
return storedJson
|
|
1413
|
+
? readStored(routeAssignmentSchema, storedJson, 'route assignment')
|
|
1414
|
+
: undefined;
|
|
1415
|
+
// Routed turns of a binding run one at a time (`submit`), so the
|
|
1416
|
+
// receipt read here is current.
|
|
1417
|
+
const receipt = await this.receipt(input);
|
|
1418
|
+
if (receipt?.routing_json)
|
|
1419
|
+
return readStored(
|
|
1420
|
+
routeAssignmentSchema,
|
|
1421
|
+
receipt.routing_json,
|
|
1422
|
+
'route assignment',
|
|
1423
|
+
);
|
|
1424
|
+
const mode = routing.mode;
|
|
1425
|
+
const state = await this.routingState(input.bindingId);
|
|
1426
|
+
const assignment: ChannelRouteAssignment = {
|
|
1427
|
+
mode,
|
|
1428
|
+
baseline,
|
|
1429
|
+
generation: state.generation,
|
|
1430
|
+
destination: { kind: 'session', sessionId: baseline },
|
|
1431
|
+
reason: 'first-turn',
|
|
1432
|
+
};
|
|
1433
|
+
const recordObservation = async (observation: ChannelRouteObservation) => {
|
|
1434
|
+
const scoped = { ...observation, mode };
|
|
1435
|
+
await this.db.run(
|
|
1436
|
+
'UPDATE channel_requests SET routing_observation=? WHERE binding_id=? AND request_id=?',
|
|
1437
|
+
[JSON.stringify(scoped), input.bindingId, input.requestId],
|
|
1438
|
+
);
|
|
1439
|
+
await this.host.changed();
|
|
1440
|
+
routing.observe({ ...scoped, requestId: input.requestId });
|
|
1441
|
+
};
|
|
1442
|
+
let evaluate: (() => Promise<ChannelRouteObservation>) | undefined;
|
|
1443
|
+
if (state.force_next || input.sessionPolicy === 'continue')
|
|
1444
|
+
assignment.reason = 'explicit-control';
|
|
1445
|
+
else if (input.replyTo) {
|
|
1446
|
+
assignment.reason = 'quoted-message';
|
|
1447
|
+
const referenced =
|
|
1448
|
+
input.replyTo.kind === 'known'
|
|
1449
|
+
? await this.receipt({ ...input, requestId: input.replyTo.requestId })
|
|
1450
|
+
: undefined;
|
|
1451
|
+
const run = referenced
|
|
1452
|
+
? await this.host.getRun(referenced.run_id)
|
|
1453
|
+
: undefined;
|
|
1454
|
+
const request = run ? this.host.requestOf(run) : undefined;
|
|
1455
|
+
const session = referenced?.session_id;
|
|
1456
|
+
if (
|
|
1457
|
+
session &&
|
|
1458
|
+
request?.identity.channel?.bindingRevision === input.bindingRevision &&
|
|
1459
|
+
(await routing.ownsSession(session)) &&
|
|
1460
|
+
(await this.replyContext(input, session))?.kind === 'resolved'
|
|
1461
|
+
) {
|
|
1462
|
+
if (mode === 'on')
|
|
1463
|
+
assignment.destination = { kind: 'session', sessionId: session };
|
|
1464
|
+
} else if (mode === 'on')
|
|
1465
|
+
assignment.destination = { kind: 'clarify', text: ambiguousThreadText };
|
|
1466
|
+
} else {
|
|
1467
|
+
const candidates = await this.routingCandidates(input, baseline);
|
|
1468
|
+
if (candidates.length) {
|
|
1469
|
+
const evidence = {
|
|
1470
|
+
message: input.message,
|
|
1471
|
+
currentSessionId: baseline,
|
|
1472
|
+
candidates,
|
|
1473
|
+
};
|
|
1474
|
+
const evidenceHash = await channelRequestHash(
|
|
1475
|
+
JSON.stringify(channelSessionDecision.prepare(evidence)),
|
|
1476
|
+
);
|
|
1477
|
+
const evaluateCandidates =
|
|
1478
|
+
async (): Promise<ChannelRouteObservation> => {
|
|
1479
|
+
const started = this.now();
|
|
1480
|
+
try {
|
|
1481
|
+
const evaluation = await routing.evaluator.evaluate(
|
|
1482
|
+
channelSessionDecision,
|
|
1483
|
+
evidence,
|
|
1484
|
+
);
|
|
1485
|
+
return {
|
|
1486
|
+
...decideChannelRoute(
|
|
1487
|
+
evaluation,
|
|
1488
|
+
candidates,
|
|
1489
|
+
routing.minProbability,
|
|
1490
|
+
),
|
|
1491
|
+
evidenceHash,
|
|
1492
|
+
latencyMs: this.now() - started,
|
|
1493
|
+
};
|
|
1494
|
+
} catch {
|
|
1495
|
+
// A classifier outage (error, timeout, unusable answer) must
|
|
1496
|
+
// not hold the user's message: it stays in the current
|
|
1497
|
+
// conversation, as with routing off.
|
|
1498
|
+
return {
|
|
1499
|
+
mode,
|
|
1500
|
+
reason: 'provider-unavailable',
|
|
1501
|
+
evidenceHash,
|
|
1502
|
+
latencyMs: this.now() - started,
|
|
1503
|
+
destination: { kind: 'session', sessionId: baseline },
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
evaluate = evaluateCandidates;
|
|
1508
|
+
if (mode === 'on') {
|
|
1509
|
+
const observation = await evaluateCandidates();
|
|
1510
|
+
assignment.reason = observation.reason;
|
|
1511
|
+
assignment.destination = observation.destination ?? {
|
|
1512
|
+
kind: 'clarify',
|
|
1513
|
+
text: ambiguousThreadText,
|
|
1514
|
+
};
|
|
1515
|
+
await recordObservation(observation);
|
|
1516
|
+
} else assignment.reason = 'shadow';
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
await this.serialize(async () => {
|
|
1520
|
+
await this.assertNotCancelled(input);
|
|
1521
|
+
await this.db.run(
|
|
1522
|
+
'UPDATE channel_requests SET routing_json=? WHERE binding_id=? AND request_id=? AND routing_json IS NULL',
|
|
1523
|
+
[JSON.stringify(assignment), input.bindingId, input.requestId],
|
|
1524
|
+
);
|
|
1525
|
+
await this.host.changed();
|
|
1526
|
+
});
|
|
1527
|
+
if (mode === 'shadow' && evaluate)
|
|
1528
|
+
this.host.keepAlive(
|
|
1529
|
+
evaluate()
|
|
1530
|
+
.then(recordObservation)
|
|
1531
|
+
.catch(() => undefined),
|
|
1532
|
+
);
|
|
1533
|
+
return assignment;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
410
1536
|
/** Step 1 (under the lock): the receipt, and the run if there is one. */
|
|
411
1537
|
private async receive(
|
|
412
1538
|
input: ChannelTurnInput,
|
|
@@ -414,6 +1540,24 @@ export class ChannelTurns {
|
|
|
414
1540
|
runId: string,
|
|
415
1541
|
): Promise<Received> {
|
|
416
1542
|
await this.setup();
|
|
1543
|
+
// A request that has its run is answered from it, also once a stop
|
|
1544
|
+
// named it: a stop that arrived after the run finished must not hide
|
|
1545
|
+
// the reply the user is owed.
|
|
1546
|
+
const existing = await this.receipt(input);
|
|
1547
|
+
if (existing?.session_id && existing.request_hash === requestHash) {
|
|
1548
|
+
const record = await this.host.getRun(runId);
|
|
1549
|
+
if (record)
|
|
1550
|
+
return {
|
|
1551
|
+
kind: 'run',
|
|
1552
|
+
response: await this.respond(
|
|
1553
|
+
input,
|
|
1554
|
+
existing,
|
|
1555
|
+
existing.session_id,
|
|
1556
|
+
record,
|
|
1557
|
+
),
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
await this.assertNotCancelled(input);
|
|
417
1561
|
// Checked before a receipt is written: the receipt of a pruned run is
|
|
418
1562
|
// gone (run-store.ts), and a new one must not take its place.
|
|
419
1563
|
if (await this.host.wasPruned(runId))
|
|
@@ -449,10 +1593,20 @@ export class ChannelTurns {
|
|
|
449
1593
|
record,
|
|
450
1594
|
),
|
|
451
1595
|
};
|
|
452
|
-
return {
|
|
1596
|
+
return {
|
|
1597
|
+
kind: 'new',
|
|
1598
|
+
sessionId: receipt.session_id,
|
|
1599
|
+
routingJson: receipt.routing_json,
|
|
1600
|
+
};
|
|
453
1601
|
}
|
|
454
1602
|
const bound = await this.boundSession(input.bindingId);
|
|
455
|
-
if (
|
|
1603
|
+
if (
|
|
1604
|
+
bound &&
|
|
1605
|
+
input.sessionId &&
|
|
1606
|
+
bound !== input.sessionId &&
|
|
1607
|
+
!receipt.routing_json &&
|
|
1608
|
+
!(this.routingEnabled(input) && this.routing?.mode === 'on')
|
|
1609
|
+
) {
|
|
456
1610
|
await this.dropReceipt(runId);
|
|
457
1611
|
throw new ChannelError(
|
|
458
1612
|
409,
|
|
@@ -460,7 +1614,11 @@ export class ChannelTurns {
|
|
|
460
1614
|
'session_conflict',
|
|
461
1615
|
);
|
|
462
1616
|
}
|
|
463
|
-
return {
|
|
1617
|
+
return {
|
|
1618
|
+
kind: 'new',
|
|
1619
|
+
sessionId: bound ?? input.sessionId ?? null,
|
|
1620
|
+
routingJson: receipt.routing_json,
|
|
1621
|
+
};
|
|
464
1622
|
}
|
|
465
1623
|
|
|
466
1624
|
/**
|
|
@@ -472,14 +1630,39 @@ export class ChannelTurns {
|
|
|
472
1630
|
identity: TurnIdentity,
|
|
473
1631
|
input: ChannelTurnInput,
|
|
474
1632
|
sessionId: string | null,
|
|
475
|
-
|
|
1633
|
+
storedRouting: string | null,
|
|
1634
|
+
): Promise<PreparedChannelTurn> {
|
|
476
1635
|
await this.host.requireDelegation(identity);
|
|
477
1636
|
const session =
|
|
478
1637
|
sessionId ??
|
|
479
1638
|
(await this.boundSession(input.bindingId)) ??
|
|
480
1639
|
(await this.openSession(identity, input));
|
|
481
1640
|
await this.host.assertSession(identity, session);
|
|
482
|
-
|
|
1641
|
+
const routing = await this.assignRoute(input, session, storedRouting);
|
|
1642
|
+
if (!routing) return { sessionId: session };
|
|
1643
|
+
const destination = routing.destination;
|
|
1644
|
+
const selected =
|
|
1645
|
+
destination.kind === 'new'
|
|
1646
|
+
? ((await this.receipt(input))?.session_id ??
|
|
1647
|
+
(await this.host.createSession(
|
|
1648
|
+
identity,
|
|
1649
|
+
`channel-route-new-${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`,
|
|
1650
|
+
)))
|
|
1651
|
+
: destination.kind === 'session'
|
|
1652
|
+
? destination.sessionId
|
|
1653
|
+
: routing.baseline;
|
|
1654
|
+
try {
|
|
1655
|
+
await this.host.assertSession(identity, selected);
|
|
1656
|
+
} catch (error) {
|
|
1657
|
+
if (error instanceof StaleSessionError)
|
|
1658
|
+
throw new ChannelError(
|
|
1659
|
+
404,
|
|
1660
|
+
'The selected conversation no longer exists; send a new message',
|
|
1661
|
+
'session_not_found',
|
|
1662
|
+
);
|
|
1663
|
+
throw error;
|
|
1664
|
+
}
|
|
1665
|
+
return { sessionId: selected, routing };
|
|
483
1666
|
}
|
|
484
1667
|
|
|
485
1668
|
/**
|
|
@@ -516,13 +1699,16 @@ export class ChannelTurns {
|
|
|
516
1699
|
private async refuse(runId: string): Promise<boolean> {
|
|
517
1700
|
await this.setup();
|
|
518
1701
|
if (await this.host.getRun(runId)) return true;
|
|
519
|
-
await this.dropReceipt(runId);
|
|
1702
|
+
await this.dropReceipt(runId, true);
|
|
520
1703
|
return false;
|
|
521
1704
|
}
|
|
522
1705
|
|
|
523
|
-
private async dropReceipt(
|
|
1706
|
+
private async dropReceipt(
|
|
1707
|
+
runId: string,
|
|
1708
|
+
preserveRouting = false,
|
|
1709
|
+
): Promise<void> {
|
|
524
1710
|
const dropped = await this.db.run(
|
|
525
|
-
|
|
1711
|
+
`DELETE FROM channel_requests WHERE run_id = ?${preserveRouting ? ' AND routing_json IS NULL' : ''}`,
|
|
526
1712
|
[runId],
|
|
527
1713
|
);
|
|
528
1714
|
if (dropped.changes > 0) await this.host.changed();
|
|
@@ -568,9 +1754,12 @@ export class ChannelTurns {
|
|
|
568
1754
|
input: ChannelTurnInput,
|
|
569
1755
|
requestHash: string,
|
|
570
1756
|
runId: string,
|
|
571
|
-
|
|
1757
|
+
prepared: PreparedChannelTurn,
|
|
572
1758
|
): Promise<ChannelTurnResponse> {
|
|
1759
|
+
const { sessionId } = prepared;
|
|
1760
|
+
let routing = prepared.routing;
|
|
573
1761
|
await this.setup();
|
|
1762
|
+
await this.assertNotCancelled(input);
|
|
574
1763
|
let receipt = await this.receipt(input);
|
|
575
1764
|
if (!receipt) {
|
|
576
1765
|
// A concurrent copy of this request was refused and dropped the
|
|
@@ -589,7 +1778,29 @@ export class ChannelTurns {
|
|
|
589
1778
|
'request_conflict',
|
|
590
1779
|
);
|
|
591
1780
|
let session = receipt.session_id ?? sessionId;
|
|
592
|
-
if (
|
|
1781
|
+
if (
|
|
1782
|
+
!receipt.session_id &&
|
|
1783
|
+
routing?.mode === 'on' &&
|
|
1784
|
+
(await this.routingState(input.bindingId)).generation !==
|
|
1785
|
+
routing.generation
|
|
1786
|
+
) {
|
|
1787
|
+
// An explicit /new or /resume completed while this request was being
|
|
1788
|
+
// classified: the user's choice wins. The request runs as with
|
|
1789
|
+
// routing off, in the session the control bound.
|
|
1790
|
+
await this.db.run(
|
|
1791
|
+
'UPDATE channel_requests SET routing_json=NULL WHERE run_id=?',
|
|
1792
|
+
[runId],
|
|
1793
|
+
);
|
|
1794
|
+
await this.host.changed();
|
|
1795
|
+
routing = undefined;
|
|
1796
|
+
session = (await this.boundSession(input.bindingId)) ?? sessionId;
|
|
1797
|
+
} else if (!receipt.session_id && routing?.mode === 'on') {
|
|
1798
|
+
await this.db.run(
|
|
1799
|
+
'INSERT INTO channel_sessions(binding_id,session_id) VALUES(?,?) ON CONFLICT(binding_id) DO UPDATE SET session_id=excluded.session_id,force_next=0',
|
|
1800
|
+
[input.bindingId, sessionId],
|
|
1801
|
+
);
|
|
1802
|
+
} else if (!receipt.session_id) {
|
|
1803
|
+
// Routing off, or in shadow (which never switches the session).
|
|
593
1804
|
await this.db.run(
|
|
594
1805
|
'INSERT OR IGNORE INTO channel_sessions (binding_id, session_id) VALUES (?, ?)',
|
|
595
1806
|
[input.bindingId, sessionId],
|
|
@@ -630,6 +1841,14 @@ export class ChannelTurns {
|
|
|
630
1841
|
[session, runId],
|
|
631
1842
|
);
|
|
632
1843
|
await this.host.changed();
|
|
1844
|
+
await this.recordSessionBinding(input, session);
|
|
1845
|
+
await this.db.run(
|
|
1846
|
+
'UPDATE channel_sessions SET force_next=0 WHERE binding_id=? AND force_next<>0',
|
|
1847
|
+
[input.bindingId],
|
|
1848
|
+
);
|
|
1849
|
+
if (this.routingEnabled(input) && routing?.destination.kind !== 'clarify')
|
|
1850
|
+
await this.indexThread(input, session, runId);
|
|
1851
|
+
const replyContext = await this.replyContext(input, session);
|
|
633
1852
|
const started = await this.host.begin(runId, {
|
|
634
1853
|
identity,
|
|
635
1854
|
sessionId: session,
|
|
@@ -642,6 +1861,11 @@ export class ChannelTurns {
|
|
|
642
1861
|
bindingId: input.bindingId,
|
|
643
1862
|
bindingRevision: input.bindingRevision,
|
|
644
1863
|
remoteMessageRef: input.remoteMessageRef,
|
|
1864
|
+
...(replyContext ? { replyContext } : {}),
|
|
1865
|
+
...(input.replyTo ? { replyTo: input.replyTo } : {}),
|
|
1866
|
+
...(routing?.destination.kind === 'clarify'
|
|
1867
|
+
? { routingClarification: routing.destination.text }
|
|
1868
|
+
: {}),
|
|
645
1869
|
},
|
|
646
1870
|
});
|
|
647
1871
|
this.kick();
|
|
@@ -678,6 +1902,7 @@ export class ChannelTurns {
|
|
|
678
1902
|
requestId: input.requestId,
|
|
679
1903
|
runId: record.runId,
|
|
680
1904
|
sessionId,
|
|
1905
|
+
sessionTitle: await this.titleOf(sessionId),
|
|
681
1906
|
status: record.status,
|
|
682
1907
|
...(record.messageId ? { messageId: record.messageId } : {}),
|
|
683
1908
|
// `text` is the whole reply as one message for gateways that predate
|
|
@@ -747,6 +1972,17 @@ export class ChannelTurns {
|
|
|
747
1972
|
// run in that gap. Left as it is; a receipt whose run never began is
|
|
748
1973
|
// pruned with the others (`housekeeping`).
|
|
749
1974
|
if (!record) continue;
|
|
1975
|
+
if (
|
|
1976
|
+
this.routing &&
|
|
1977
|
+
this.routing.mode !== 'off' &&
|
|
1978
|
+
record.status === 'finished'
|
|
1979
|
+
) {
|
|
1980
|
+
const refreshed = await this.db.run(
|
|
1981
|
+
'UPDATE channel_thread_index SET recent_reply=? WHERE last_run_id=?',
|
|
1982
|
+
[(record.partialText ?? '').slice(0, 800), row.run_id],
|
|
1983
|
+
);
|
|
1984
|
+
if (refreshed.changes > 0) await this.host.changed();
|
|
1985
|
+
}
|
|
750
1986
|
const request = this.host.requestOf(record);
|
|
751
1987
|
let author: 'user' | 'oracle' = 'user';
|
|
752
1988
|
try {
|