@substrat-run/engine-assistant 0.0.0-stage → 0.1.28

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/dist/index.js ADDED
@@ -0,0 +1,811 @@
1
+ /**
2
+ * engine-assistant — composed **by call**, not by event.
3
+ *
4
+ * The record of a chat between a person and an assistant. It owns the invariants of that
5
+ * record and nothing about the model that speaks for it: it never talks to a model, a
6
+ * network or a clock of its own. A host (the platform's assistant host, or a vertical that
7
+ * composes it) runs the loop, and calls these functions to write what happened.
8
+ *
9
+ * What the engine guarantees, each of which a test pins:
10
+ *
11
+ * 1. **The record is append-only and numbered.** Events are rows `(thread, seq)`, `seq`
12
+ * from 1, never updated or removed. `seq` is the cursor a client resumes after.
13
+ * 2. **Only the lease holder writes the assistant's side.** `acquireRun` takes a
14
+ * single-writer lease; `appendEvents` is fenced by it, so a holder that was replaced
15
+ * while still running (a zombie) cannot write. A holder that stopped beating can be
16
+ * replaced.
17
+ * 3. **A gated tool runs once, with the input the user saw.** A tool call that asks for
18
+ * approval is bound to `approvalDigest` of the tagged tuple (deployed version, approver,
19
+ * thread, step, call, operation, expiry, nonce, input; docs/rfc/assistant.md §6.0 point
20
+ * 3), and the bindings are stored beside it. `resolveApproval` RECOMPUTES that digest
21
+ * with the principal who answered and the version the answer arrived under, so an
22
+ * approval from anyone but the thread's subject, for another thread, call or operation,
23
+ * under another deploy, or after the expiry, does not match. `claimExecution` recomputes
24
+ * it with the operation and input the host is about to run, before the expiry. Both
25
+ * moves are conditional writes (`… WHERE state = ? AND digest = ?`, one row changed or
26
+ * refused), so single use is the record's own, not a read that came before it. A
27
+ * `tool-output` for a gated call is refused unless it was claimed. The model cannot
28
+ * change the arguments after the user said yes.
29
+ * 4. **The two sides of the record are written by different keys.** What the user did
30
+ * (`user-message`, `turn-start`, `approval-resolved`) is written by `sendMessage` and
31
+ * `resolveApproval` under `assistant:use`, as the owner. What the assistant did is
32
+ * written by `appendEvents` under `assistant:run`, which is held by a SYSTEM principal
33
+ * (the assistant host's) and by no role a person has — the manifest declares it
34
+ * `holder: 'system'`, and `lint:permissions` refuses a role or entity grant shape that
35
+ * grants it (tools/reserved-keys.mts). Even the run key writes only
36
+ * inside a turn the user opened: with no turn open, `appendEvents` is `no_turn_open`,
37
+ * so a run cannot keep a resting thread busy and lock its owner out.
38
+ * 5. **An event is bounded.** A body over `MAX_EVENT_BYTES` is refused: a large tool
39
+ * result belongs in the operation that produced it, referred to by id, not copied into
40
+ * a Durable Object row.
41
+ *
42
+ * `appendEvents` and the per-delta rows do not emit a platform event each. Forty text deltas
43
+ * make one answer, and an outbox row apiece would be the record written twice. The platform
44
+ * learns the milestones (`events.ts`).
45
+ *
46
+ * Deferred, and stated so an absence reads as intent: message branches (regenerate, edit and
47
+ * resend), participants other than the owner and the assistant (a human agent), the support
48
+ * handoff, and memory. Each is additive: a column, an event type, a table.
49
+ */
50
+ import { z } from 'zod';
51
+ import { CURSOR_FIELD_SEPARATOR, moduleManifest, pageOverFold, permissionKey, subjectErasureOf, substratError, } from '@substrat-run/contracts';
52
+ import { ASSISTANT_STREAM_VERSION, approvalDigest, assistantStreamBody, assistantMessageFile, isRestingEvent, } from '@substrat-run/contracts/assistant-stream';
53
+ import { assertAllowed, operationsFor, ulid, } from '@substrat-run/kernel';
54
+ import { assistantEntities, eventRow, runRow, threadRow, toolCallRow } from './entities.js';
55
+ import { emitAssistantEvent, assistantEmitDeclarations } from './events.js';
56
+ import { assistantOperations } from './operations.js';
57
+ import { columnsOf, returns } from './seam.js';
58
+ import { MAX_EVENT_BYTES, acquireRunInput, appendEventsInput, claimExecutionInput, createThreadInput, listThreadsInput, readEventsInput, releaseRunInput, resolveApprovalInput, sendMessageInput, thread as threadShape, threadIdInput, utf8Length, } from './shapes.js';
59
+ export { MAX_EVENT_BYTES, MAX_MESSAGE_BYTES } from './shapes.js';
60
+ export { assistantEntities, threadRow, eventRow, runRow, toolCallRow } from './entities.js';
61
+ export { assistantOperations, ASSISTANT_PERMISSIONS } from './operations.js';
62
+ export {} from './events.js';
63
+ /**
64
+ * The conflict reasons this engine raises, narrowing the platform's `conflict` code (#113).
65
+ * Additive only: new reasons may appear, existing ones do not change spelling.
66
+ */
67
+ export const ASSISTANT_CONFLICT_REASONS = [
68
+ 'thread_closed',
69
+ 'turn_in_progress',
70
+ 'approval_pending',
71
+ 'no_approval_pending',
72
+ 'run_in_progress',
73
+ 'run_fenced',
74
+ 'not_a_run_event',
75
+ 'call_state',
76
+ 'approval_required',
77
+ 'approval_denied',
78
+ 'execution_not_claimed',
79
+ 'already_executed',
80
+ 'already_resolved',
81
+ 'digest_mismatch',
82
+ 'approval_expired',
83
+ 'no_turn_open',
84
+ ];
85
+ const conflict = (reason, message) => substratError('conflict', message, { reason });
86
+ export const ASSISTANT_PERM = {
87
+ use: permissionKey.parse('assistant:use'),
88
+ audit: permissionKey.parse('assistant:audit'),
89
+ run: permissionKey.parse('assistant:run'),
90
+ };
91
+ export const assistantManifest = moduleManifest.parse({
92
+ id: '@substrat-run/engine-assistant',
93
+ // Versions the manifest SHAPE, not the package (#976), and read by `src/model.ts`.
94
+ version: '0.0.1',
95
+ kernelContract: '^0.0.1',
96
+ permissions: [
97
+ {
98
+ key: 'assistant:use',
99
+ description: "Open threads, read your own, send messages and approve or deny the assistant's proposals",
100
+ },
101
+ {
102
+ key: 'assistant:audit',
103
+ description: "Read other principals' threads (never write to them). Only together with assistant:use, which every read checks first",
104
+ },
105
+ {
106
+ key: 'assistant:run',
107
+ description: "Drive a run: take the lease, append the assistant's events, claim a tool's execution. For a system principal (the assistant host's) only — never a role a person holds",
108
+ // A person holding it could write the assistant's side of their own thread.
109
+ holder: 'system',
110
+ },
111
+ ],
112
+ events: { emits: assistantEmitDeclarations, consumes: [] },
113
+ migrations: { journalDir: './migrations', compatibleFrom: '0.0.1' },
114
+ // Byte access is brokered by the host AFTER checking thread ownership. Using `use`
115
+ // here would let every person with that scope-wide key open every thread's files.
116
+ attachmentTargets: [{ entityType: 'assistant-thread', readPermission: 'assistant:run', writePermission: 'assistant:run' }],
117
+ entityRelations: [],
118
+ // K-46: derived, so the content tables' `delete` erasure is in the manifest a host reads.
119
+ erasure: subjectErasureOf(assistantEntities),
120
+ entitlementKey: 'assistant',
121
+ schedules: [],
122
+ });
123
+ export const assistantMigrations = [
124
+ {
125
+ version: '0001-init',
126
+ sql: `
127
+ CREATE TABLE assistant_threads (
128
+ id TEXT PRIMARY KEY,
129
+ subject_id TEXT NOT NULL,
130
+ title TEXT,
131
+ status TEXT NOT NULL CHECK (status IN ('open','closed')),
132
+ created_at TEXT NOT NULL,
133
+ last_event_at TEXT NOT NULL,
134
+ closed_at TEXT
135
+ );
136
+ CREATE INDEX assistant_threads_by_subject
137
+ ON assistant_threads (subject_id, last_event_at);
138
+ CREATE TABLE assistant_events (
139
+ thread_id TEXT NOT NULL,
140
+ seq INTEGER NOT NULL,
141
+ type TEXT NOT NULL,
142
+ body TEXT NOT NULL,
143
+ subject_id TEXT NOT NULL,
144
+ created_at TEXT NOT NULL,
145
+ PRIMARY KEY (thread_id, seq)
146
+ );
147
+ CREATE INDEX assistant_events_by_subject ON assistant_events (subject_id);
148
+ CREATE TABLE assistant_runs (
149
+ thread_id TEXT PRIMARY KEY,
150
+ run_id TEXT NOT NULL,
151
+ subject_id TEXT NOT NULL,
152
+ beat_at TEXT NOT NULL
153
+ );
154
+ CREATE INDEX assistant_runs_by_subject ON assistant_runs (subject_id);
155
+ CREATE TABLE assistant_tool_calls (
156
+ thread_id TEXT NOT NULL,
157
+ call_id TEXT NOT NULL,
158
+ tool TEXT NOT NULL,
159
+ state TEXT NOT NULL CHECK (state IN
160
+ ('running','awaiting-approval','approved','denied','executing','done','failed')),
161
+ digest TEXT,
162
+ step TEXT,
163
+ operation TEXT,
164
+ version TEXT,
165
+ expires_at TEXT,
166
+ nonce TEXT,
167
+ request_seq INTEGER,
168
+ decided_by TEXT,
169
+ decided_at TEXT,
170
+ subject_id TEXT NOT NULL,
171
+ created_at TEXT NOT NULL,
172
+ updated_at TEXT NOT NULL,
173
+ PRIMARY KEY (thread_id, call_id),
174
+ -- An approval's bindings are written together, or not at all.
175
+ CHECK ((digest IS NULL AND step IS NULL AND operation IS NULL AND version IS NULL
176
+ AND expires_at IS NULL AND nonce IS NULL AND request_seq IS NULL)
177
+ OR (digest IS NOT NULL AND step IS NOT NULL AND operation IS NOT NULL AND version IS NOT NULL
178
+ AND expires_at IS NOT NULL AND nonce IS NOT NULL AND request_seq IS NOT NULL))
179
+ );
180
+ CREATE INDEX assistant_tool_calls_by_subject ON assistant_tool_calls (subject_id);
181
+ `,
182
+ },
183
+ ];
184
+ const THREAD_COLUMNS = columnsOf(threadRow);
185
+ const EVENT_COLUMNS = columnsOf(eventRow);
186
+ const RUN_COLUMNS = columnsOf(runRow);
187
+ const CALL_COLUMNS = columnsOf(toolCallRow);
188
+ /** A stored row, parsed BEFORE anything is made of it (#771). */
189
+ const storedThread = (r) => returns(threadRow, `thread row ${r.id}`, r);
190
+ const storedRun = (r) => returns(runRow, `run row ${r.thread_id}`, r);
191
+ const storedCall = (r) => returns(toolCallRow, `tool call row ${r.thread_id}/${r.call_id}`, r);
192
+ const toThread = (raw) => {
193
+ const r = storedThread(raw);
194
+ return returns(threadShape, `thread ${r.id}`, {
195
+ id: r.id,
196
+ subject: r.subject_id,
197
+ title: r.title,
198
+ status: r.status,
199
+ createdAt: r.created_at,
200
+ lastEventAt: r.last_event_at,
201
+ closedAt: r.closed_at,
202
+ });
203
+ };
204
+ /** A stored event, parsed back through the stream protocol's own schema, with `seq` as its id. */
205
+ const toEvent = (raw) => {
206
+ const r = returns(eventRow, `event row ${raw.thread_id}/${raw.seq}`, raw);
207
+ const body = returns(assistantStreamBody, `event ${r.thread_id}/${r.seq}`, JSON.parse(r.body));
208
+ return { ...body, id: r.seq };
209
+ };
210
+ function getThreadRow(ctx, threadId) {
211
+ const row = ctx.sql.query(`SELECT ${THREAD_COLUMNS} FROM assistant_threads WHERE id = ?`, [threadId])[0];
212
+ if (!row)
213
+ throw substratError('not_found', `thread not found: ${threadId}`);
214
+ return storedThread(row);
215
+ }
216
+ /** The caller's own thread, or `not_found` — never `forbidden`, so existence is not probed. */
217
+ function ownThreadRow(ctx, threadId) {
218
+ const row = getThreadRow(ctx, threadId);
219
+ if (row.subject_id !== String(ctx.principal))
220
+ throw substratError('not_found', `thread not found: ${threadId}`);
221
+ return row;
222
+ }
223
+ function openThreadRow(row) {
224
+ if (row.status === 'closed')
225
+ throw conflict('thread_closed', `thread ${row.id} is closed`);
226
+ return row;
227
+ }
228
+ function lastEvent(ctx, threadId) {
229
+ return ctx.sql.query(`SELECT seq, type FROM assistant_events WHERE thread_id = ? ORDER BY seq DESC LIMIT 1`, [threadId])[0];
230
+ }
231
+ function nextSeq(ctx, threadId) {
232
+ return ((ctx.sql.query(`SELECT MAX(seq) AS seq FROM assistant_events WHERE thread_id = ?`, [
233
+ threadId,
234
+ ])[0]?.seq ?? 0) + 1);
235
+ }
236
+ function callRow(ctx, threadId, callId) {
237
+ const row = ctx.sql.query(`SELECT ${CALL_COLUMNS} FROM assistant_tool_calls WHERE thread_id = ? AND call_id = ?`, [threadId, callId])[0];
238
+ return row ? storedCall(row) : undefined;
239
+ }
240
+ function awaitingApproval(ctx, threadId) {
241
+ return (ctx.sql.query(`SELECT COUNT(*) AS n FROM assistant_tool_calls WHERE thread_id = ? AND state = 'awaiting-approval'`, [threadId])[0]?.n ?? 0);
242
+ }
243
+ /** The call's approval bindings, or `null` for an ungated call (the table holds all or none). */
244
+ function gateOf(call) {
245
+ if (call.digest === null)
246
+ return null;
247
+ return {
248
+ digest: call.digest,
249
+ step: call.step,
250
+ operation: call.operation,
251
+ version: call.version,
252
+ expiresAt: call.expires_at,
253
+ nonce: call.nonce,
254
+ requestSeq: call.request_seq,
255
+ };
256
+ }
257
+ /** The tuple to recompute: the stored bindings, with what the caller brings in their place. */
258
+ function bindingOf(row, callId, gate, given) {
259
+ return {
260
+ version: given.version,
261
+ approver: given.approver,
262
+ thread: row.id,
263
+ step: gate.step,
264
+ call: callId,
265
+ operation: given.operation,
266
+ expiresAt: gate.expiresAt,
267
+ nonce: gate.nonce,
268
+ input: given.input,
269
+ };
270
+ }
271
+ /** `approvalDigest`, with a binding it cannot digest refused as invalid input rather than thrown raw. */
272
+ async function digestOrRefuse(binding) {
273
+ try {
274
+ return await approvalDigest(binding);
275
+ }
276
+ catch (err) {
277
+ throw substratError('validation_failed', `the approval's bindings cannot be digested: ${err.message}`);
278
+ }
279
+ }
280
+ /** True at and after `expiresAt`, by the invocation's clock. */
281
+ function expired(ctx, expiresAt) {
282
+ return Date.parse(ctx.now()) >= Date.parse(expiresAt);
283
+ }
284
+ /** The approval-request event a call's bindings came from: where its input is kept. */
285
+ function requestEvent(ctx, threadId, seq, callId) {
286
+ const raw = ctx.sql.query(`SELECT ${EVENT_COLUMNS} FROM assistant_events WHERE thread_id = ? AND seq = ?`, [
287
+ threadId,
288
+ seq,
289
+ ])[0];
290
+ const event = raw ? toEvent(raw) : undefined;
291
+ if (!event || event.t !== 'approval-request' || event.callId !== callId) {
292
+ throw substratError('internal', `the approval request for ${callId} is not at seq ${seq} of thread ${threadId}`);
293
+ }
294
+ return event;
295
+ }
296
+ /** Insert one event and move the thread's activity mark. Returns the event as a client sees it. */
297
+ function insertEvent(ctx, row, body) {
298
+ const serialised = JSON.stringify(body);
299
+ // Bytes, not characters: the Durable Object row limit is in bytes.
300
+ // A user's message never gets here over the bound: `sendMessageInput` bounds it in bytes.
301
+ if (utf8Length(serialised) > MAX_EVENT_BYTES) {
302
+ throw substratError('validation_failed', `a ${body.t} event is over ${MAX_EVENT_BYTES} bytes — keep a large tool result in the operation that produced it and refer to it by id`);
303
+ }
304
+ const seq = nextSeq(ctx, row.id);
305
+ const now = ctx.now();
306
+ ctx.sql.exec(`INSERT INTO assistant_events (thread_id, seq, type, body, subject_id, created_at) VALUES (?, ?, ?, ?, ?, ?)`, [row.id, seq, body.t, serialised, row.subject_id, now]);
307
+ ctx.sql.exec(`UPDATE assistant_threads SET last_event_at = ? WHERE id = ?`, [now, row.id]);
308
+ return { ...body, id: seq };
309
+ }
310
+ const threadRef = (threadId) => ({ entityType: 'assistant-thread', entityId: threadId });
311
+ // ---------------------------------------------------------------------------
312
+ // In-scope functions (K-16). The registered operations below are their default bindings.
313
+ // ---------------------------------------------------------------------------
314
+ /**
315
+ * Open a thread for the caller. The caller's principal is the subject: a thread is created
316
+ * for yourself, never on someone else's behalf.
317
+ */
318
+ export function createThread(ctx, rawInput) {
319
+ const input = createThreadInput.parse(rawInput ?? {});
320
+ const id = ulid();
321
+ const now = ctx.now();
322
+ ctx.sql.exec(`INSERT INTO assistant_threads (id, subject_id, title, status, created_at, last_event_at, closed_at)
323
+ VALUES (?, ?, ?, 'open', ?, ?, NULL)`, [id, String(ctx.principal), input.title ?? null, now, now]);
324
+ emitAssistantEvent(ctx, String(ctx.principal), {
325
+ type: 'assistant.thread-created',
326
+ entity: threadRef(id),
327
+ payload: { threadId: id },
328
+ });
329
+ return toThread(getThreadRow(ctx, id));
330
+ }
331
+ /** One thread. The CALLER decides who may see it; see `assertThreadVisible`. */
332
+ export function getThread(ctx, rawInput) {
333
+ return toThread(getThreadRow(ctx, threadIdInput.parse(rawInput).threadId));
334
+ }
335
+ /**
336
+ * The thread, if the caller may see it: the owner, or someone holding `assistant:audit`.
337
+ * `not_found` otherwise, so a thread's existence is not probed.
338
+ */
339
+ export async function assertThreadVisible(ctx, threadId) {
340
+ const row = getThreadRow(ctx, threadId);
341
+ if (row.subject_id === String(ctx.principal))
342
+ return row;
343
+ if ((await ctx.check(ASSISTANT_PERM.audit)).allowed)
344
+ return row;
345
+ throw substratError('not_found', `thread not found: ${threadId}`);
346
+ }
347
+ /** Threads for one principal, most recently active first. Unpaged: a vertical folding them. */
348
+ export function listThreads(ctx, rawInput) {
349
+ const input = listThreadsInput.parse(rawInput ?? {});
350
+ const subject = input.subject ?? String(ctx.principal);
351
+ return ctx.sql
352
+ .query(`SELECT ${THREAD_COLUMNS} FROM assistant_threads
353
+ WHERE subject_id = ? ${input.status ? 'AND status = ?' : ''}
354
+ ORDER BY last_event_at DESC, id DESC`, input.status ? [subject, input.status] : [subject])
355
+ .map(toThread);
356
+ }
357
+ /** The record after a cursor. The CALLER decides who may read it. */
358
+ export function readEvents(ctx, rawInput) {
359
+ const input = readEventsInput.parse(rawInput);
360
+ getThreadRow(ctx, input.threadId);
361
+ const events = ctx.sql
362
+ .query(`SELECT ${EVENT_COLUMNS} FROM assistant_events WHERE thread_id = ? AND seq > ? ORDER BY seq LIMIT ?`, [input.threadId, input.after, input.limit])
363
+ .map(toEvent);
364
+ return { events, lastSeq: events.length ? events[events.length - 1].id : input.after };
365
+ }
366
+ /**
367
+ * The user says something, which begins a turn. As the owner, under `assistant:use`.
368
+ *
369
+ * Refused while a turn is running (the host is mid-answer) and while an approval is waiting
370
+ * (the user must answer it, or deny it, first), so the record never holds two turns at once.
371
+ */
372
+ export function sendMessage(ctx, rawInput) {
373
+ const input = sendMessageInput.parse(rawInput);
374
+ const row = openThreadRow(ownThreadRow(ctx, input.threadId));
375
+ if (awaitingApproval(ctx, row.id) > 0) {
376
+ throw conflict('approval_pending', 'an approval is waiting; approve or deny it before saying anything else');
377
+ }
378
+ const last = lastEvent(ctx, row.id);
379
+ if (last && !isRestingEvent({ t: last.type })) {
380
+ throw conflict('turn_in_progress', 'the assistant is still answering the last message');
381
+ }
382
+ const files = input.attachmentIds?.map((id) => {
383
+ const file = ctx.sql.query(`SELECT id, filename, content_type AS contentType, size, sha256 FROM _substrat_attachments
384
+ WHERE id = ? AND entity_type = 'assistant-thread' AND entity_id = ?`, [id, row.id])[0];
385
+ if (!file)
386
+ throw substratError('not_found', `file not found in thread: ${id}`);
387
+ return returns(assistantMessageFile, `message file ${id}`, file);
388
+ });
389
+ const first = insertEvent(ctx, row, { t: 'user-message', text: input.text, ...(files ? { files } : {}) });
390
+ const second = insertEvent(ctx, row, { t: 'turn-start', v: ASSISTANT_STREAM_VERSION });
391
+ emitAssistantEvent(ctx, row.subject_id, {
392
+ type: 'assistant.turn-started',
393
+ entity: threadRef(row.id),
394
+ payload: { threadId: row.id },
395
+ });
396
+ return { firstSeq: first.id, lastSeq: second.id };
397
+ }
398
+ /** Ownership is required even when the caller can audit other people's threads. */
399
+ export function authorizeFileUpload(ctx, rawInput) {
400
+ const input = threadIdInput.parse(rawInput);
401
+ return toThread(openThreadRow(ownThreadRow(ctx, input.threadId)));
402
+ }
403
+ /**
404
+ * The user approves or denies a gated tool call. As the owner, under `assistant:use`.
405
+ *
406
+ * An APPROVAL must carry the stored digest (the one the request showed), and that digest must
407
+ * equal the one RECOMPUTED from the stored bindings with the principal who is answering as
408
+ * the approver and `input.version` (the deployed version the host serves the answer under)
409
+ * as the version: an approval of anything else — another input, call, thread or operation,
410
+ * another approver, another deploy — is `digest_mismatch`, never a quiet approval of what is
411
+ * stored. One that arrives at or after the request's expiry is `approval_expired`.
412
+ *
413
+ * A DENIAL needs only the digest the user was shown, with no version or expiry: saying no
414
+ * runs nothing, and it is how the owner clears a request that lapsed or that a deploy made
415
+ * stale, which would otherwise refuse every message (`approval_pending`) for ever. A denial
416
+ * does not end the turn: `approval-resolved` resumes it, like an approval, and the turn stays
417
+ * open (`turn_in_progress` for the owner) until the host writes `turn-finish` or
418
+ * `turn-error`. The owner has no way to end a turn without a run; a host that does not come
419
+ * back leaves closing the thread as the owner's only way out, so a host must run after every
420
+ * answer, a denial included.
421
+ *
422
+ * Single use is the write itself: `UPDATE … WHERE state = 'awaiting-approval' AND digest = ?`,
423
+ * refused unless it changed exactly one row, whatever was read before it.
424
+ *
425
+ * Not fenced by the lease: a run that asked for approval has come to rest, and the user
426
+ * acts between runs. The host starts the next run after this returns.
427
+ */
428
+ export async function resolveApproval(ctx, rawInput) {
429
+ const input = resolveApprovalInput.parse(rawInput);
430
+ const row = openThreadRow(ownThreadRow(ctx, input.threadId));
431
+ const call = callRow(ctx, row.id, input.callId);
432
+ if (!call)
433
+ throw substratError('not_found', `no tool call ${input.callId} on thread ${row.id}`);
434
+ if (call.state === 'approved' || call.state === 'denied' || call.state === 'executing' || call.state === 'done') {
435
+ throw conflict('already_resolved', `tool call ${input.callId} was already resolved`);
436
+ }
437
+ const gate = gateOf(call);
438
+ if (call.state !== 'awaiting-approval' || !gate) {
439
+ throw conflict('call_state', `tool call ${input.callId} is not waiting for approval (it is ${call.state})`);
440
+ }
441
+ if (input.approved) {
442
+ if (expired(ctx, gate.expiresAt)) {
443
+ throw conflict('approval_expired', `the approval request for ${input.callId} expired at ${gate.expiresAt}`);
444
+ }
445
+ const request = requestEvent(ctx, row.id, gate.requestSeq, input.callId);
446
+ const expected = await digestOrRefuse(bindingOf(row, call.call_id, gate, { approver: String(ctx.principal), version: input.version, operation: gate.operation, input: request.input }));
447
+ // Both: the answer is the digest the user was shown (the stored one), AND that digest is
448
+ // what the bindings recompute to with this approver and this version. Checking only the
449
+ // second would accept a digest a client recomputed for another version.
450
+ if (input.digest !== gate.digest || expected !== gate.digest) {
451
+ throw conflict('digest_mismatch', 'the approval does not match the request: another input, call, approver or deployed version');
452
+ }
453
+ }
454
+ else if (input.digest !== gate.digest) {
455
+ throw conflict('digest_mismatch', 'the denial is for a different request than the one waiting');
456
+ }
457
+ const now = ctx.now();
458
+ const { changes } = ctx.sql.exec(`UPDATE assistant_tool_calls SET state = ?, decided_by = ?, decided_at = ?, updated_at = ?
459
+ WHERE thread_id = ? AND call_id = ? AND state = 'awaiting-approval' AND digest = ?`, [input.approved ? 'approved' : 'denied', String(ctx.principal), now, now, row.id, input.callId, gate.digest]);
460
+ if (changes !== 1)
461
+ throw conflict('already_resolved', `tool call ${input.callId} was already resolved`);
462
+ const event = insertEvent(ctx, row, {
463
+ t: 'approval-resolved',
464
+ callId: input.callId,
465
+ approved: input.approved,
466
+ approver: ctx.principal,
467
+ digest: gate.digest,
468
+ });
469
+ emitAssistantEvent(ctx, row.subject_id, {
470
+ type: 'assistant.approval-resolved',
471
+ entity: threadRef(row.id),
472
+ payload: { threadId: row.id, callId: input.callId, tool: call.tool, approved: input.approved },
473
+ });
474
+ return { seq: event.id };
475
+ }
476
+ /** Close a thread. As the owner. Nothing is appended to a closed thread. */
477
+ export function closeThread(ctx, rawInput) {
478
+ const input = threadIdInput.parse(rawInput);
479
+ const row = ownThreadRow(ctx, input.threadId);
480
+ if (row.status === 'closed')
481
+ return toThread(row);
482
+ ctx.sql.exec(`UPDATE assistant_threads SET status = 'closed', closed_at = ? WHERE id = ?`, [ctx.now(), row.id]);
483
+ ctx.sql.exec(`DELETE FROM assistant_runs WHERE thread_id = ?`, [row.id]);
484
+ emitAssistantEvent(ctx, row.subject_id, {
485
+ type: 'assistant.thread-closed',
486
+ entity: threadRef(row.id),
487
+ payload: { threadId: row.id },
488
+ });
489
+ return toThread(getThreadRow(ctx, row.id));
490
+ }
491
+ const DEFAULT_STALE_MS = 15_000;
492
+ /**
493
+ * Take the single-writer lease on a thread. A holder that has beaten within `staleMs` keeps
494
+ * it (`run_in_progress`); one that has not can be replaced, and `tookOver` says so. The same
495
+ * run asking again simply refreshes its beat.
496
+ */
497
+ export function acquireRun(ctx, rawInput) {
498
+ const input = acquireRunInput.parse(rawInput);
499
+ const row = openThreadRow(getThreadRow(ctx, input.threadId));
500
+ const now = ctx.now();
501
+ const held = ctx.sql.query(`SELECT ${RUN_COLUMNS} FROM assistant_runs WHERE thread_id = ?`, [row.id])[0];
502
+ const current = held ? storedRun(held) : undefined;
503
+ const staleMs = input.staleMs ?? DEFAULT_STALE_MS;
504
+ if (current && current.run_id !== input.runId && Date.parse(now) - Date.parse(current.beat_at) < staleMs) {
505
+ throw conflict('run_in_progress', `thread ${row.id} is being answered by another run`);
506
+ }
507
+ const tookOver = current !== undefined && current.run_id !== input.runId;
508
+ ctx.sql.exec(`INSERT INTO assistant_runs (thread_id, run_id, subject_id, beat_at) VALUES (?, ?, ?, ?)
509
+ ON CONFLICT (thread_id) DO UPDATE SET run_id = excluded.run_id, beat_at = excluded.beat_at`, [row.id, input.runId, row.subject_id, now]);
510
+ return { tookOver };
511
+ }
512
+ /** Give the lease back. A run that no longer holds it releases nothing. */
513
+ export function releaseRun(ctx, rawInput) {
514
+ const input = releaseRunInput.parse(rawInput);
515
+ const { changes } = ctx.sql.exec(`DELETE FROM assistant_runs WHERE thread_id = ? AND run_id = ?`, [
516
+ input.threadId,
517
+ input.runId,
518
+ ]);
519
+ return { released: changes > 0 };
520
+ }
521
+ /** Throws `run_fenced` unless `runId` holds the lease; refreshes its beat when it does. */
522
+ function fence(ctx, threadId, runId) {
523
+ const held = ctx.sql.query(`SELECT ${RUN_COLUMNS} FROM assistant_runs WHERE thread_id = ?`, [threadId])[0];
524
+ if (!held || storedRun(held).run_id !== runId) {
525
+ throw conflict('run_fenced', `run ${runId} does not hold the lease on thread ${threadId}`);
526
+ }
527
+ ctx.sql.exec(`UPDATE assistant_runs SET beat_at = ? WHERE thread_id = ?`, [ctx.now(), threadId]);
528
+ }
529
+ /** The events a run may NOT write: they are the user's, written by `sendMessage` and `resolveApproval`. */
530
+ const USER_SIDE = new Set(['user-message', 'turn-start', 'approval-resolved']);
531
+ /**
532
+ * Append what the assistant said and did, fenced by the lease, and enforce the tool-call
533
+ * state machine on the way in.
534
+ *
535
+ * tool-input → `running` (a repeat of a running call is a step restart, and allowed)
536
+ * approval-request → `awaiting-approval`, with its bindings stored; the digest must be
537
+ * `approvalDigest` of those bindings with the thread's subject as the
538
+ * approver, and the expiry still ahead
539
+ * tool-output → `done`; for a gated call only from `executing`, i.e. after
540
+ * `claimExecution`
541
+ * tool-error → `failed`
542
+ * turn-finish → `awaiting-approval` only with an approval waiting, `done` only without
543
+ *
544
+ * Every event is refused (`no_turn_open`) unless a turn is open: the last event is not a
545
+ * resting one (`turn-finish`, `turn-error`), and the thread has at least one.
546
+ *
547
+ * `tool-error` may also fail an `approved` call that was never claimed: the host could not
548
+ * run it (the operation refused, the credential ended). That is the host's word, which is why
549
+ * the run key is a system principal's.
550
+ *
551
+ * The whole batch is in the caller's transaction: a refusal anywhere leaves none of it.
552
+ */
553
+ export async function appendEvents(ctx, rawInput) {
554
+ const input = appendEventsInput.parse(rawInput);
555
+ const row = openThreadRow(getThreadRow(ctx, input.threadId));
556
+ fence(ctx, row.id, input.runId);
557
+ const stored = [];
558
+ // A run writes only inside a turn the user opened: after a `turn-start` (or the
559
+ // `approval-resolved` that resumes one) and before the turn rests. Tracked through the
560
+ // batch, so a run cannot rest the turn and go on writing in the same call.
561
+ let last = lastEvent(ctx, row.id)?.type;
562
+ for (const body of input.events) {
563
+ if (USER_SIDE.has(body.t)) {
564
+ throw conflict('not_a_run_event', `a run cannot write a ${body.t} event; that is the user's side of the record`);
565
+ }
566
+ if (last === undefined || isRestingEvent({ t: last })) {
567
+ throw conflict('no_turn_open', `thread ${row.id} has no turn open; the user's next message opens one`);
568
+ }
569
+ await applyToolState(ctx, row, body);
570
+ stored.push(insertEvent(ctx, row, body));
571
+ last = body.t;
572
+ }
573
+ return { events: stored };
574
+ }
575
+ async function applyToolState(ctx, row, body) {
576
+ const now = ctx.now();
577
+ switch (body.t) {
578
+ case 'tool-input': {
579
+ const call = callRow(ctx, row.id, body.callId);
580
+ if (!call) {
581
+ ctx.sql.exec(`INSERT INTO assistant_tool_calls
582
+ (thread_id, call_id, tool, state, digest, decided_by, decided_at, subject_id, created_at, updated_at)
583
+ VALUES (?, ?, ?, 'running', NULL, NULL, NULL, ?, ?, ?)`, [row.id, body.callId, body.tool, row.subject_id, now, now]);
584
+ }
585
+ else if (call.state !== 'running' || call.tool !== body.tool) {
586
+ throw conflict('call_state', `tool call ${body.callId} already exists and is ${call.state}`);
587
+ }
588
+ return;
589
+ }
590
+ case 'approval-request': {
591
+ const call = callRow(ctx, row.id, body.callId);
592
+ if (!call || call.tool !== body.tool) {
593
+ throw conflict('call_state', `approval requested for ${body.callId}, which has no matching tool-input`);
594
+ }
595
+ const expected = await digestOrRefuse({
596
+ version: body.version,
597
+ approver: row.subject_id,
598
+ thread: row.id,
599
+ step: body.step,
600
+ call: body.callId,
601
+ operation: body.operation,
602
+ expiresAt: body.expiresAt,
603
+ nonce: body.nonce,
604
+ input: body.input,
605
+ });
606
+ if (expected !== body.digest) {
607
+ throw conflict('digest_mismatch', "the digest is not the digest of this request's bindings");
608
+ }
609
+ // A step restart may ask again for a call already waiting on the same request.
610
+ if (call.state === 'awaiting-approval' && call.digest === body.digest)
611
+ return;
612
+ if (call.state !== 'running') {
613
+ throw conflict('call_state', `tool call ${body.callId} is ${call.state}, not running`);
614
+ }
615
+ if (expired(ctx, body.expiresAt)) {
616
+ throw conflict('approval_expired', `the approval request for ${body.callId} expires at ${body.expiresAt}, which has passed`);
617
+ }
618
+ // The event is inserted right after this returns, at the next seq.
619
+ const requestSeq = nextSeq(ctx, row.id);
620
+ const { changes } = ctx.sql.exec(`UPDATE assistant_tool_calls
621
+ SET state = 'awaiting-approval', digest = ?, step = ?, operation = ?, version = ?,
622
+ expires_at = ?, nonce = ?, request_seq = ?, updated_at = ?
623
+ WHERE thread_id = ? AND call_id = ? AND state = 'running'`, [body.digest, body.step, body.operation, body.version, body.expiresAt, body.nonce, requestSeq, now, row.id, body.callId]);
624
+ if (changes !== 1)
625
+ throw conflict('call_state', `tool call ${body.callId} is no longer running`);
626
+ emitAssistantEvent(ctx, row.subject_id, {
627
+ type: 'assistant.approval-requested',
628
+ entity: threadRef(row.id),
629
+ payload: { threadId: row.id, callId: body.callId, tool: body.tool },
630
+ });
631
+ return;
632
+ }
633
+ case 'tool-output': {
634
+ const call = callRow(ctx, row.id, body.callId);
635
+ if (!call)
636
+ throw conflict('call_state', `output for ${body.callId}, which has no tool-input`);
637
+ if (call.digest !== null) {
638
+ // A gated call: only a claimed execution may produce output.
639
+ if (call.state === 'awaiting-approval')
640
+ throw conflict('approval_required', `tool call ${body.callId} is waiting for approval`);
641
+ if (call.state === 'denied')
642
+ throw conflict('approval_denied', `tool call ${body.callId} was denied`);
643
+ if (call.state === 'approved')
644
+ throw conflict('execution_not_claimed', `tool call ${body.callId} was approved but its execution was never claimed`);
645
+ if (call.state !== 'executing')
646
+ throw conflict('call_state', `tool call ${body.callId} is ${call.state}`);
647
+ }
648
+ else if (call.state !== 'running') {
649
+ throw conflict('call_state', `tool call ${body.callId} is ${call.state}, not running`);
650
+ }
651
+ ctx.sql.exec(`UPDATE assistant_tool_calls SET state = 'done', updated_at = ? WHERE thread_id = ? AND call_id = ?`, [
652
+ now,
653
+ row.id,
654
+ body.callId,
655
+ ]);
656
+ return;
657
+ }
658
+ case 'tool-error': {
659
+ const call = callRow(ctx, row.id, body.callId);
660
+ if (!call)
661
+ throw conflict('call_state', `error for ${body.callId}, which has no tool-input`);
662
+ if (call.state !== 'running' && call.state !== 'executing' && call.state !== 'approved') {
663
+ throw conflict('call_state', `tool call ${body.callId} is ${call.state}`);
664
+ }
665
+ ctx.sql.exec(`UPDATE assistant_tool_calls SET state = 'failed', updated_at = ? WHERE thread_id = ? AND call_id = ?`, [
666
+ now,
667
+ row.id,
668
+ body.callId,
669
+ ]);
670
+ return;
671
+ }
672
+ case 'turn-finish': {
673
+ const waiting = awaitingApproval(ctx, row.id);
674
+ if (body.reason === 'awaiting-approval' && waiting === 0) {
675
+ throw conflict('no_approval_pending', 'a turn cannot finish awaiting approval when none is waiting');
676
+ }
677
+ if (body.reason === 'done' && waiting > 0) {
678
+ throw conflict('approval_pending', 'a turn cannot finish while an approval is still waiting');
679
+ }
680
+ emitAssistantEvent(ctx, row.subject_id, {
681
+ type: 'assistant.turn-finished',
682
+ entity: threadRef(row.id),
683
+ payload: { threadId: row.id, reason: body.reason },
684
+ });
685
+ return;
686
+ }
687
+ default:
688
+ return;
689
+ }
690
+ }
691
+ /**
692
+ * Claim the one execution of an approved tool call, with the operation, the deployed version
693
+ * and the input about to be run.
694
+ *
695
+ * This is the gate in front of a write: the host calls it BEFORE invoking the operation, and
696
+ * runs the operation only if it returns. The digest is recomputed from the stored bindings
697
+ * with the claim's operation, version and input and the principal who approved, and must be
698
+ * the one the user approved (`digest_mismatch` otherwise: the model, or a bug, changed the
699
+ * input, or the host is about to run another operation or under another deploy). The call
700
+ * must be approved (not waiting, not denied), the request's expiry still ahead
701
+ * (`approval_expired`: an approval is spent when it is claimed, and one not spent in time
702
+ * runs nothing), and not already claimed (`already_executed`), so a restarted loop cannot run
703
+ * a write twice — that last is the conditional write itself, `… WHERE state = 'approved'`.
704
+ * Fenced by the lease.
705
+ */
706
+ export async function claimExecution(ctx, rawInput) {
707
+ const input = claimExecutionInput.parse(rawInput);
708
+ const row = openThreadRow(getThreadRow(ctx, input.threadId));
709
+ fence(ctx, row.id, input.runId);
710
+ const call = callRow(ctx, row.id, input.callId);
711
+ if (!call)
712
+ throw substratError('not_found', `no tool call ${input.callId} on thread ${row.id}`);
713
+ const gate = gateOf(call);
714
+ if (!gate)
715
+ throw conflict('call_state', `tool call ${input.callId} is not gated; there is nothing to claim`);
716
+ if (call.state === 'awaiting-approval')
717
+ throw conflict('approval_required', `tool call ${input.callId} is waiting for approval`);
718
+ if (call.state === 'denied')
719
+ throw conflict('approval_denied', `tool call ${input.callId} was denied`);
720
+ if (call.state === 'executing' || call.state === 'done' || call.state === 'failed') {
721
+ throw conflict('already_executed', `tool call ${input.callId} was already claimed (it is ${call.state})`);
722
+ }
723
+ if (call.state !== 'approved' || call.decided_by === null)
724
+ throw conflict('call_state', `tool call ${input.callId} is ${call.state}`);
725
+ if (expired(ctx, gate.expiresAt)) {
726
+ throw conflict('approval_expired', `the approval for ${input.callId} expired at ${gate.expiresAt} before it was used`);
727
+ }
728
+ const expected = await digestOrRefuse(bindingOf(row, call.call_id, gate, { approver: call.decided_by, version: input.version, operation: input.operation, input: input.input }));
729
+ if (expected !== gate.digest) {
730
+ throw conflict('digest_mismatch', 'what is about to run is not what the user approved: another input, operation or deployed version');
731
+ }
732
+ const { changes } = ctx.sql.exec(`UPDATE assistant_tool_calls SET state = 'executing', updated_at = ?
733
+ WHERE thread_id = ? AND call_id = ? AND state = 'approved' AND digest = ?`, [ctx.now(), row.id, input.callId, gate.digest]);
734
+ if (changes !== 1)
735
+ throw conflict('already_executed', `tool call ${input.callId} was already claimed`);
736
+ return { claimed: true };
737
+ }
738
+ const createThreadOp = async (ctx, input) => {
739
+ assertAllowed(await ctx.check(ASSISTANT_PERM.use));
740
+ return createThread(ctx, input);
741
+ };
742
+ const listThreadsOp = async (ctx, input) => {
743
+ assertAllowed(await ctx.check(ASSISTANT_PERM.use));
744
+ const asked = input?.subject;
745
+ if (asked !== undefined && asked !== String(ctx.principal))
746
+ assertAllowed(await ctx.check(ASSISTANT_PERM.audit));
747
+ const page = { ...(input?.limit !== undefined ? { limit: input.limit } : {}), ...(input?.cursor !== undefined ? { cursor: input.cursor } : {}) };
748
+ // Most recent first; the SQL orders by (last_event_at, id) and the cursor is that pair.
749
+ return pageOverFold(listThreads(ctx, { ...(input?.status ? { status: input.status } : {}), ...(asked ? { subject: asked } : {}) }), page, (t) => `${t.lastEventAt}${CURSOR_FIELD_SEPARATOR}${t.id}`, 'desc');
750
+ };
751
+ const getThreadOp = async (ctx, input) => {
752
+ assertAllowed(await ctx.check(ASSISTANT_PERM.use));
753
+ await assertThreadVisible(ctx, input.threadId);
754
+ return getThread(ctx, input);
755
+ };
756
+ const readEventsOp = async (ctx, input) => {
757
+ assertAllowed(await ctx.check(ASSISTANT_PERM.use));
758
+ await assertThreadVisible(ctx, input.threadId);
759
+ return readEvents(ctx, input);
760
+ };
761
+ const sendMessageOp = async (ctx, input) => {
762
+ assertAllowed(await ctx.check(ASSISTANT_PERM.use));
763
+ return sendMessage(ctx, input);
764
+ };
765
+ const authorizeFileUploadOp = async (ctx, input) => {
766
+ assertAllowed(await ctx.check(ASSISTANT_PERM.use));
767
+ return authorizeFileUpload(ctx, input);
768
+ };
769
+ const resolveApprovalOp = async (ctx, input) => {
770
+ assertAllowed(await ctx.check(ASSISTANT_PERM.use));
771
+ return resolveApproval(ctx, input);
772
+ };
773
+ const closeThreadOp = async (ctx, input) => {
774
+ assertAllowed(await ctx.check(ASSISTANT_PERM.use));
775
+ return closeThread(ctx, input);
776
+ };
777
+ const acquireRunOp = async (ctx, input) => {
778
+ assertAllowed(await ctx.check(ASSISTANT_PERM.run));
779
+ return acquireRun(ctx, input);
780
+ };
781
+ const releaseRunOp = async (ctx, input) => {
782
+ assertAllowed(await ctx.check(ASSISTANT_PERM.run));
783
+ return releaseRun(ctx, input);
784
+ };
785
+ const appendEventsOp = async (ctx, input) => {
786
+ assertAllowed(await ctx.check(ASSISTANT_PERM.run));
787
+ return appendEvents(ctx, input);
788
+ };
789
+ const claimExecutionOp = async (ctx, input) => {
790
+ assertAllowed(await ctx.check(ASSISTANT_PERM.run));
791
+ return claimExecution(ctx, input);
792
+ };
793
+ export const assistantModule = {
794
+ manifest: assistantManifest,
795
+ migrations: assistantMigrations,
796
+ ...operationsFor(assistantOperations)({
797
+ 'assistant/create-thread': createThreadOp,
798
+ 'assistant/list-threads': listThreadsOp,
799
+ 'assistant/get-thread': getThreadOp,
800
+ 'assistant/read-events': readEventsOp,
801
+ 'assistant/send-message': sendMessageOp,
802
+ 'assistant/authorize-file-upload': authorizeFileUploadOp,
803
+ 'assistant/resolve-approval': resolveApprovalOp,
804
+ 'assistant/close-thread': closeThreadOp,
805
+ 'assistant/acquire-run': acquireRunOp,
806
+ 'assistant/release-run': releaseRunOp,
807
+ 'assistant/append-events': appendEventsOp,
808
+ 'assistant/claim-execution': claimExecutionOp,
809
+ }),
810
+ };
811
+ //# sourceMappingURL=index.js.map