@ixo/oracle-runtime-workers 0.21.2 → 0.23.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/README.md +16 -13
- package/package.json +10 -4
- package/src/action-proposals/policy.ts +54 -0
- package/src/action-proposals/reconcile.ts +87 -0
- package/src/action-proposals/store.ts +863 -0
- package/src/action-proposals/topic-budget.ts +84 -0
- package/src/action-proposals/topic-chain.ts +272 -0
- package/src/action-proposals/topic-effects.ts +44 -0
- package/src/action-proposals/topic-errors.ts +79 -0
- package/src/action-proposals/topic-history.ts +267 -0
- package/src/action-proposals/topic-matrix.ts +292 -0
- package/src/action-proposals/topic-prepare.ts +299 -0
- package/src/action-proposals/topic-store.ts +999 -0
- package/src/action-proposals/topic-verify.ts +279 -0
- package/src/action-proposals/verify.ts +223 -0
- package/src/channels/auth.ts +6 -1
- package/src/channels/contract.ts +4 -0
- package/src/channels/turns.ts +1 -0
- package/src/core/env.ts +107 -5
- package/src/core/index.ts +8 -2
- package/src/core/main-agent-types.ts +16 -0
- package/src/core/main-agent.ts +26 -0
- package/src/core/middlewares/tool-execution.ts +87 -46
- package/src/core/middlewares/tool-marks.ts +54 -19
- package/src/core/runtime-context.ts +27 -1
- package/src/core/state.ts +2 -0
- package/src/core/tool-scheduler.ts +129 -24
- package/src/delivery/plan.ts +43 -2
- package/src/delivery/schema.ts +25 -1
- package/src/delivery/types.ts +13 -2
- package/src/do/active-runs.ts +171 -0
- package/src/do/ambient.ts +6 -0
- package/src/do/contracts.ts +86 -3
- package/src/do/run-coordinator.ts +30 -0
- package/src/do/run-request.ts +15 -2
- package/src/do/run-store-test-do.ts +10 -0
- package/src/do/run-store.ts +66 -13
- package/src/do/turn-body.ts +13 -1
- package/src/do/user-oracle-do.ts +1420 -17
- package/src/hub-actions/client.ts +163 -0
- package/src/hub-actions/request.ts +110 -0
- package/src/hub-actions/store.ts +485 -0
- package/src/matrix/gateway-do.ts +176 -0
- package/src/matrix/reply-parts.ts +8 -2
- package/src/plugin-api/tool-helper.ts +6 -0
- package/src/plugin-api/types.ts +117 -7
- package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
- package/src/plugins/action-proposals/evidence.ts +67 -0
- package/src/plugins/agui/agui.plugin.ts +7 -5
- package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +5 -2
- package/src/plugins/portal/declared-tools.ts +12 -0
- package/src/plugins/portal/portal.plugin.ts +22 -8
- package/src/realtime/frontend-call-registry.ts +147 -27
- package/src/realtime/realtime-endpoint.ts +335 -41
- package/src/realtime/session-socket-hub.ts +90 -19
- package/src/shell/app.ts +218 -2
- package/src/sqlite/sessions-store.ts +22 -0
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
* (`serializeAttachment`), so the set is rebuilt losslessly from
|
|
11
11
|
* `ctx.getWebSockets()` whenever the object wakes from hibernation or
|
|
12
12
|
* restarts. Nothing here is kept only in memory.
|
|
13
|
+
*
|
|
14
|
+
* A socket is a foreground executor (a tab showing the chat: it renders the
|
|
15
|
+
* session's events and runs any frontend call) unless its CONNECT declared
|
|
16
|
+
* itself a background executor: then it runs only the browser tools it
|
|
17
|
+
* named — never an AG-UI action, which renders on the chat's own surface or
|
|
18
|
+
* asks the user (a wallet signature) — and receives no fan-out.
|
|
13
19
|
*/
|
|
20
|
+
import type { FrontendCallKind } from './frontend-call-registry';
|
|
14
21
|
import { ENGINE_PING, encodeEvent } from './socket-io-codec';
|
|
15
22
|
|
|
16
23
|
export interface SocketAttachment {
|
|
@@ -32,8 +39,40 @@ export interface SocketAttachment {
|
|
|
32
39
|
* engine.io pongs do not count — a background tab answers those too.
|
|
33
40
|
*/
|
|
34
41
|
lastActiveAt?: number;
|
|
42
|
+
/** Set when the CONNECT declared a background executor; absent = foreground. */
|
|
43
|
+
executor?: 'background';
|
|
44
|
+
/** The tools a background executor can run (validated, bounded; see `realtime-endpoint.ts`). */
|
|
45
|
+
tools?: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An executor socket with its attachment. */
|
|
49
|
+
export interface HubEntry<S> {
|
|
50
|
+
socket: S;
|
|
51
|
+
attachment: SocketAttachment;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isForeground = (attachment: SocketAttachment): boolean =>
|
|
55
|
+
attachment.executor !== 'background';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the socket may run a call of `kind` to `toolName`: any call in
|
|
59
|
+
* the foreground; in the background only a browser tool it declared.
|
|
60
|
+
*/
|
|
61
|
+
export function canRunTool(
|
|
62
|
+
attachment: SocketAttachment,
|
|
63
|
+
kind: FrontendCallKind,
|
|
64
|
+
toolName: string,
|
|
65
|
+
): boolean {
|
|
66
|
+
return (
|
|
67
|
+
isForeground(attachment) ||
|
|
68
|
+
(kind === 'browser' && (attachment.tools ?? []).includes(toolName))
|
|
69
|
+
);
|
|
35
70
|
}
|
|
36
71
|
|
|
72
|
+
/** Most recent client event, else open time (see `executorFor`). */
|
|
73
|
+
const recency = (attachment: SocketAttachment): number =>
|
|
74
|
+
attachment.lastActiveAt ?? attachment.openedAt;
|
|
75
|
+
|
|
37
76
|
/** The subset of the Workers `WebSocket` the hub needs (tests use fakes). */
|
|
38
77
|
export interface HubSocket {
|
|
39
78
|
send(message: string): void;
|
|
@@ -148,14 +187,14 @@ export class SessionSocketHub<S extends HubSocket = HubSocket> {
|
|
|
148
187
|
}
|
|
149
188
|
|
|
150
189
|
/** Every socket with its heartbeat bookkeeping (diagnostics). */
|
|
151
|
-
entries(): Array<
|
|
190
|
+
entries(): Array<HubEntry<S>> {
|
|
152
191
|
return [...this.sockets].map(([socket, attachment]) => ({
|
|
153
192
|
socket,
|
|
154
193
|
attachment,
|
|
155
194
|
}));
|
|
156
195
|
}
|
|
157
196
|
|
|
158
|
-
/** Authenticated sockets subscribed to `sessionId
|
|
197
|
+
/** Authenticated sockets subscribed to `sessionId`, background executors included. */
|
|
159
198
|
forSession(sessionId: string): S[] {
|
|
160
199
|
const out: S[] = [];
|
|
161
200
|
for (const [socket, meta] of this.sockets) {
|
|
@@ -168,42 +207,74 @@ export class SessionSocketHub<S extends HubSocket = HubSocket> {
|
|
|
168
207
|
return this.forSession(sessionId).length > 0;
|
|
169
208
|
}
|
|
170
209
|
|
|
171
|
-
/**
|
|
210
|
+
/** Whether a foreground (chat-rendering) socket of `sessionId` is authenticated. */
|
|
211
|
+
hasForegroundSession(sessionId: string): boolean {
|
|
212
|
+
for (const meta of this.sockets.values())
|
|
213
|
+
if (meta.userDid && meta.sessionId === sessionId && isForeground(meta))
|
|
214
|
+
return true;
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A client event arrived on the socket: persist the time (see `executorFor`). */
|
|
172
219
|
noteActivity(socket: S): void {
|
|
173
220
|
if (this.sockets.has(socket))
|
|
174
221
|
this.update(socket, { lastActiveAt: this.now() });
|
|
175
222
|
}
|
|
176
223
|
|
|
177
224
|
/**
|
|
178
|
-
* The authenticated socket of `sessionId` a frontend call
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
225
|
+
* The authenticated socket of `sessionId` a frontend call of `kind` to
|
|
226
|
+
* `toolName` runs on. A foreground socket first: the one with the most recent client
|
|
227
|
+
* event, else the one opened last — the tab the user acted in last is the
|
|
228
|
+
* likeliest to be in front; the tab that connected last is often just a
|
|
229
|
+
* background tab that reconnected after a laptop woke. Without one, a
|
|
230
|
+
* background executor that declared the browser tool `toolName`, by the
|
|
231
|
+
* same recency.
|
|
232
|
+
* Ties go to the socket registered later. Undefined when no socket of the
|
|
233
|
+
* session can run the tool.
|
|
183
234
|
*/
|
|
184
|
-
|
|
235
|
+
executorFor(
|
|
185
236
|
sessionId: string,
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
let
|
|
237
|
+
kind: FrontendCallKind,
|
|
238
|
+
toolName: string,
|
|
239
|
+
): HubEntry<S> | undefined {
|
|
240
|
+
let foreground: HubEntry<S> | undefined;
|
|
241
|
+
let background: HubEntry<S> | undefined;
|
|
190
242
|
for (const [socket, attachment] of this.sockets) {
|
|
191
243
|
if (!attachment.userDid || attachment.sessionId !== sessionId) continue;
|
|
192
|
-
if (
|
|
193
|
-
|
|
244
|
+
if (isForeground(attachment)) {
|
|
245
|
+
if (
|
|
246
|
+
!foreground ||
|
|
247
|
+
recency(attachment) >= recency(foreground.attachment)
|
|
248
|
+
)
|
|
249
|
+
foreground = { socket, attachment };
|
|
250
|
+
} else if (
|
|
251
|
+
canRunTool(attachment, kind, toolName) &&
|
|
252
|
+
(!background || recency(attachment) >= recency(background.attachment))
|
|
253
|
+
)
|
|
254
|
+
background = { socket, attachment };
|
|
194
255
|
}
|
|
195
|
-
return
|
|
256
|
+
return foreground ?? background;
|
|
196
257
|
}
|
|
197
258
|
|
|
198
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* Fan an event out to the session's authenticated FOREGROUND sockets: a
|
|
261
|
+
* background executor runs calls, it does not render the chat.
|
|
262
|
+
*/
|
|
199
263
|
emitToSession(
|
|
200
264
|
sessionId: string,
|
|
201
265
|
eventName: string,
|
|
202
266
|
payload: unknown,
|
|
203
267
|
): number {
|
|
204
|
-
|
|
268
|
+
// Encoded once, and only when a socket will take it: most events of a
|
|
269
|
+
// session nobody has open (or only background executors) cost nothing.
|
|
270
|
+
let frame: string | undefined;
|
|
205
271
|
let sent = 0;
|
|
206
|
-
|
|
272
|
+
// A failed send removes that socket; deleting the entry being visited
|
|
273
|
+
// is safe while iterating a Map.
|
|
274
|
+
for (const [socket, meta] of this.sockets) {
|
|
275
|
+
if (!meta.userDid || meta.sessionId !== sessionId || !isForeground(meta))
|
|
276
|
+
continue;
|
|
277
|
+
frame ??= encodeEvent(eventName, payload);
|
|
207
278
|
if (this.trySend(socket, frame)) sent += 1;
|
|
208
279
|
}
|
|
209
280
|
return sent;
|
package/src/shell/app.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionProposalWireSchema,
|
|
3
|
+
TopicReceiptReferencesSchema,
|
|
4
|
+
BeginTopicStepSchema,
|
|
5
|
+
BeginTopicStepResultSchema,
|
|
6
|
+
} from '@ixo/ixo-transaction/topic-proposals';
|
|
7
|
+
import {
|
|
8
|
+
ActionProposalClaimSchema,
|
|
9
|
+
ActionProposalIdSchema,
|
|
10
|
+
ActionProposalReconcileSchema,
|
|
11
|
+
} from '@ixo/ixo-transaction/action-proposals';
|
|
12
|
+
import { ActionProposalConflict } from '../action-proposals/store';
|
|
1
13
|
import {
|
|
2
14
|
authenticateChannel,
|
|
3
15
|
assertActiveChannelBindingCached,
|
|
@@ -65,6 +77,23 @@ import {
|
|
|
65
77
|
type FeedbackShellOptions,
|
|
66
78
|
} from '../feedback/submit';
|
|
67
79
|
import { z } from 'zod';
|
|
80
|
+
import { parseOutcome } from '@ixo/action-protocol';
|
|
81
|
+
|
|
82
|
+
async function equalOutcomeKeys(
|
|
83
|
+
provided: string | null,
|
|
84
|
+
expected: string | undefined,
|
|
85
|
+
): Promise<boolean> {
|
|
86
|
+
if (!provided || !expected || expected.length < 32 || provided.length > 512)
|
|
87
|
+
return false;
|
|
88
|
+
const digest = async (value: string) =>
|
|
89
|
+
new Uint8Array(
|
|
90
|
+
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)),
|
|
91
|
+
);
|
|
92
|
+
const [a, b] = await Promise.all([digest(provided), digest(expected)]);
|
|
93
|
+
let difference = 0;
|
|
94
|
+
for (let i = 0; i < a.length; i++) difference |= (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
95
|
+
return difference === 0;
|
|
96
|
+
}
|
|
68
97
|
|
|
69
98
|
const DebugEventBody = z.object({
|
|
70
99
|
type: z
|
|
@@ -213,7 +242,15 @@ export function isDidShaped(value: string): boolean {
|
|
|
213
242
|
*/
|
|
214
243
|
export function rateLimitKey(
|
|
215
244
|
env: Pick<OracleWorkerEnv, 'ORACLE_DID'>,
|
|
216
|
-
scope:
|
|
245
|
+
scope:
|
|
246
|
+
| 'user'
|
|
247
|
+
| 'poll'
|
|
248
|
+
| 'channel'
|
|
249
|
+
| 'action'
|
|
250
|
+
| 'artifact'
|
|
251
|
+
| 'socket'
|
|
252
|
+
| 'status'
|
|
253
|
+
| 'start',
|
|
217
254
|
subject: string,
|
|
218
255
|
): string {
|
|
219
256
|
return `${env.ORACLE_DID}|${scope}|${subject}`;
|
|
@@ -417,6 +454,38 @@ export function createShell(
|
|
|
417
454
|
);
|
|
418
455
|
});
|
|
419
456
|
|
|
457
|
+
app.use('/api/internal/actions/outcomes', limitBody(64 * 1024));
|
|
458
|
+
app.post('/api/internal/actions/outcomes', async (c) => {
|
|
459
|
+
if (
|
|
460
|
+
c.env.HUB_ACTIONS_ENABLED !== 'true' ||
|
|
461
|
+
!(await equalOutcomeKeys(
|
|
462
|
+
c.req.header('x-auth-hub-outcome-key') ?? null,
|
|
463
|
+
c.env.AUTH_HUB_ACTION_OUTCOME_KEY,
|
|
464
|
+
))
|
|
465
|
+
)
|
|
466
|
+
return c.json({ code: 'unauthorized' }, 401);
|
|
467
|
+
const body = await c.req.raw.text();
|
|
468
|
+
if (body.length > 64_000) return c.json({ code: 'request_too_large' }, 413);
|
|
469
|
+
try {
|
|
470
|
+
const outcome = parseOutcome(JSON.parse(body));
|
|
471
|
+
await userStub(c.env, outcome.ownerDid).recordHubActionOutcome(outcome);
|
|
472
|
+
return c.json({
|
|
473
|
+
accepted: true,
|
|
474
|
+
eventId: outcome.eventId,
|
|
475
|
+
accountId: outcome.reference.accountId,
|
|
476
|
+
actionId: outcome.reference.id,
|
|
477
|
+
revision: outcome.authorizedRevision,
|
|
478
|
+
digest: outcome.authorizedDigest,
|
|
479
|
+
sequence: outcome.sequence,
|
|
480
|
+
});
|
|
481
|
+
} catch (error) {
|
|
482
|
+
console.warn(
|
|
483
|
+
`[hub-actions] outcome refused: ${error instanceof Error ? error.message : String(error)}`,
|
|
484
|
+
);
|
|
485
|
+
return c.json({ code: 'outcome_refused' }, 409);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
420
489
|
app.post('/channels/turn', async (c) => {
|
|
421
490
|
try {
|
|
422
491
|
if (parseChannelServiceDids(c.env.CHANNEL_SERVICE_DID).length === 0)
|
|
@@ -471,6 +540,8 @@ export function createShell(
|
|
|
471
540
|
outcome.status,
|
|
472
541
|
);
|
|
473
542
|
const result = outcome.result;
|
|
543
|
+
if (result.actionOutcomes?.length || result.hubActionOutcomes?.length)
|
|
544
|
+
await assertActiveChannelBindingCached(identity, c.env);
|
|
474
545
|
return c.json(
|
|
475
546
|
result,
|
|
476
547
|
['queued', 'running', 'recovering'].includes(result.status) ? 202 : 200,
|
|
@@ -523,13 +594,25 @@ export function createShell(
|
|
|
523
594
|
// unbounded LLM spend; the key carries the oracle's DID (`rateLimitKey`).
|
|
524
595
|
// Auth-excluded routes (health/models/matrix status) ran `next()` above
|
|
525
596
|
// and never reach here. Absent binding = no limiting (local harness / tests).
|
|
597
|
+
// The chat lists' status poll (`GET /runs/active`) spends its own budget
|
|
598
|
+
// (`poll` scope, same binding): a tab polling every 4 s never pushes the
|
|
599
|
+
// user's messages into 429, nor the other way round.
|
|
526
600
|
app.use('*', async (c, next) => {
|
|
527
601
|
const limiter = c.env.RATE_LIMIT;
|
|
528
602
|
if (!limiter) return next();
|
|
529
603
|
const auth = c.get('auth') as AuthResult | undefined;
|
|
530
604
|
if (!auth) return next();
|
|
605
|
+
// The chat lists' status poll and the card routes (Portal's review
|
|
606
|
+
// page) each have their own bucket: polling a card or the run list
|
|
607
|
+
// while chatting must not push turns into 429.
|
|
608
|
+
const scope =
|
|
609
|
+
c.req.method === 'GET' && c.req.path === '/runs/active'
|
|
610
|
+
? 'poll'
|
|
611
|
+
: c.req.path.startsWith('/action-proposals/')
|
|
612
|
+
? 'action'
|
|
613
|
+
: 'user';
|
|
531
614
|
const { success } = await limiter.limit({
|
|
532
|
-
key: rateLimitKey(c.env,
|
|
615
|
+
key: rateLimitKey(c.env, scope, auth.userDid),
|
|
533
616
|
});
|
|
534
617
|
if (!success)
|
|
535
618
|
return c.json(
|
|
@@ -543,6 +626,129 @@ export function createShell(
|
|
|
543
626
|
return next();
|
|
544
627
|
});
|
|
545
628
|
|
|
629
|
+
// What the owner object answers a proposal command with: the card; a
|
|
630
|
+
// conflict (the card or the authority it was sealed on changed: the Portal
|
|
631
|
+
// re-reads and retries, 409); or an authority read that failed and may
|
|
632
|
+
// succeed later (503, with a generic message).
|
|
633
|
+
const ProposalReply = z.discriminatedUnion('ok', [
|
|
634
|
+
z.object({ ok: z.literal(true), proposal: ActionProposalWireSchema }),
|
|
635
|
+
z.object({
|
|
636
|
+
ok: z.literal(false),
|
|
637
|
+
error: z.string(),
|
|
638
|
+
kind: z.enum(['conflict', 'unavailable']).default('conflict'),
|
|
639
|
+
}),
|
|
640
|
+
]);
|
|
641
|
+
const refusalStatus = (kind: 'conflict' | 'unavailable') =>
|
|
642
|
+
kind === 'unavailable' ? 503 : 409;
|
|
643
|
+
|
|
644
|
+
app.get('/action-proposals/:proposalId', async (c) => {
|
|
645
|
+
c.header('Cache-Control', 'private, no-store');
|
|
646
|
+
c.header('Vary', 'Authorization');
|
|
647
|
+
const parsed = ActionProposalIdSchema.safeParse(c.req.param('proposalId'));
|
|
648
|
+
if (!parsed.success) return c.json({ error: 'Invalid proposal ID' }, 400);
|
|
649
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
650
|
+
const proposal = await userStub(c.env, identity.userDid).actionProposal(
|
|
651
|
+
identity,
|
|
652
|
+
parsed.data,
|
|
653
|
+
);
|
|
654
|
+
if (!proposal) return c.json({ error: 'Proposal not found' }, 404);
|
|
655
|
+
const result = ProposalReply.parse(JSON.parse(proposal));
|
|
656
|
+
return result.ok
|
|
657
|
+
? c.json({ proposal: result.proposal })
|
|
658
|
+
: c.json({ error: result.error }, refusalStatus(result.kind));
|
|
659
|
+
});
|
|
660
|
+
for (const operation of ['claim', 'reject', 'revoke', 'reconcile'] as const) {
|
|
661
|
+
app.post(
|
|
662
|
+
`/action-proposals/:proposalId/${operation}`,
|
|
663
|
+
limitBody(4096),
|
|
664
|
+
async (c) => {
|
|
665
|
+
c.header('Cache-Control', 'private, no-store');
|
|
666
|
+
c.header('Vary', 'Authorization');
|
|
667
|
+
const id = ActionProposalIdSchema.safeParse(c.req.param('proposalId'));
|
|
668
|
+
const body = await c.req.json().catch(() => null);
|
|
669
|
+
const parsed = (
|
|
670
|
+
operation === 'reconcile'
|
|
671
|
+
? z.union([
|
|
672
|
+
ActionProposalReconcileSchema,
|
|
673
|
+
TopicReceiptReferencesSchema,
|
|
674
|
+
])
|
|
675
|
+
: ActionProposalClaimSchema
|
|
676
|
+
).safeParse(body);
|
|
677
|
+
if (!id.success || !parsed.success)
|
|
678
|
+
return c.json({ error: 'Invalid proposal command' }, 400);
|
|
679
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
680
|
+
try {
|
|
681
|
+
const command =
|
|
682
|
+
operation === 'reconcile'
|
|
683
|
+
? 'v' in parsed.data
|
|
684
|
+
? {
|
|
685
|
+
kind: 'topic-reconcile' as const,
|
|
686
|
+
input: TopicReceiptReferencesSchema.parse(parsed.data),
|
|
687
|
+
}
|
|
688
|
+
: {
|
|
689
|
+
kind: operation,
|
|
690
|
+
...ActionProposalReconcileSchema.parse(parsed.data),
|
|
691
|
+
}
|
|
692
|
+
: {
|
|
693
|
+
kind: operation,
|
|
694
|
+
...ActionProposalClaimSchema.parse(parsed.data),
|
|
695
|
+
};
|
|
696
|
+
const proposal = await userStub(
|
|
697
|
+
c.env,
|
|
698
|
+
identity.userDid,
|
|
699
|
+
).actionProposal(identity, id.data, command);
|
|
700
|
+
if (!proposal) return c.json({ error: 'Proposal not found' }, 404);
|
|
701
|
+
const result = ProposalReply.parse(JSON.parse(proposal));
|
|
702
|
+
return result.ok
|
|
703
|
+
? c.json({ proposal: result.proposal })
|
|
704
|
+
: c.json({ error: result.error }, refusalStatus(result.kind));
|
|
705
|
+
} catch (error) {
|
|
706
|
+
if (
|
|
707
|
+
error instanceof ActionProposalConflict ||
|
|
708
|
+
(error instanceof Error && error.name === 'ActionProposalConflict')
|
|
709
|
+
)
|
|
710
|
+
return c.json({ error: error.message }, 409);
|
|
711
|
+
throw error;
|
|
712
|
+
}
|
|
713
|
+
},
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
app.post(
|
|
718
|
+
'/action-proposals/:proposalId/begin-step',
|
|
719
|
+
limitBody(4096),
|
|
720
|
+
async (c) => {
|
|
721
|
+
c.header('Cache-Control', 'private, no-store');
|
|
722
|
+
c.header('Vary', 'Authorization');
|
|
723
|
+
const id = ActionProposalIdSchema.safeParse(c.req.param('proposalId'));
|
|
724
|
+
const body = BeginTopicStepSchema.safeParse(
|
|
725
|
+
await c.req.json().catch(() => null),
|
|
726
|
+
);
|
|
727
|
+
if (!id.success || !body.success)
|
|
728
|
+
return c.json({ error: 'Invalid topic step command' }, 400);
|
|
729
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
730
|
+
const raw = await userStub(c.env, identity.userDid).beginTopicStep(
|
|
731
|
+
identity,
|
|
732
|
+
id.data,
|
|
733
|
+
body.data,
|
|
734
|
+
);
|
|
735
|
+
if (!raw) return c.json({ error: 'Proposal not found' }, 404);
|
|
736
|
+
const response = z
|
|
737
|
+
.discriminatedUnion('ok', [
|
|
738
|
+
z.object({ ok: z.literal(true), result: BeginTopicStepResultSchema }),
|
|
739
|
+
z.object({
|
|
740
|
+
ok: z.literal(false),
|
|
741
|
+
error: z.string(),
|
|
742
|
+
kind: z.enum(['conflict', 'unavailable']).default('conflict'),
|
|
743
|
+
}),
|
|
744
|
+
])
|
|
745
|
+
.parse(JSON.parse(raw));
|
|
746
|
+
return response.ok
|
|
747
|
+
? c.json(response.result)
|
|
748
|
+
: c.json({ error: response.error }, refusalStatus(response.kind));
|
|
749
|
+
},
|
|
750
|
+
);
|
|
751
|
+
|
|
546
752
|
// --- sessions ----------------------------------------------------------------
|
|
547
753
|
app.post('/sessions', async (c) => {
|
|
548
754
|
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
@@ -668,6 +874,7 @@ export function createShell(
|
|
|
668
874
|
return c.json({ message: 'sessionId is required' }, 400);
|
|
669
875
|
const success = await userStub(c.env, identity.userDid).abortTurn(
|
|
670
876
|
body.sessionId,
|
|
877
|
+
identity,
|
|
671
878
|
);
|
|
672
879
|
return c.json({ success });
|
|
673
880
|
});
|
|
@@ -745,6 +952,15 @@ export function createShell(
|
|
|
745
952
|
);
|
|
746
953
|
return c.json({ run });
|
|
747
954
|
});
|
|
955
|
+
// Every active run of the caller's chats (task runs excluded, no
|
|
956
|
+
// `partialText`), oldest first: a client's chat lists poll it to show
|
|
957
|
+
// which chats are busy. Registered before `/runs/:runId`, which would
|
|
958
|
+
// otherwise take `active` for a run id.
|
|
959
|
+
app.get('/runs/active', async (c) => {
|
|
960
|
+
const identity = identityOf(c.get('auth'), c.req.raw.headers);
|
|
961
|
+
const runs = await userStub(c.env, identity.userDid).activeRuns(identity);
|
|
962
|
+
return c.json({ runs });
|
|
963
|
+
});
|
|
748
964
|
// Re-join a run's SSE stream after a cursor (`?after=<seq>`, the `id:` of
|
|
749
965
|
// the last frame the client saw). Frames after it are replayed, then the
|
|
750
966
|
// live tail until `done`. A run that ended replays and closes at once.
|
|
@@ -220,6 +220,28 @@ export class SessionsStore {
|
|
|
220
220
|
return { sessions: [], total: Number(count?.n ?? 0) };
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The room of each of these sessions that has a row (`null`: a row
|
|
225
|
+
* without a room). One statement for the lot; sessions without a row are
|
|
226
|
+
* absent from the map.
|
|
227
|
+
*/
|
|
228
|
+
async roomsOf(
|
|
229
|
+
sessionIds: readonly string[],
|
|
230
|
+
): Promise<Map<string, string | null>> {
|
|
231
|
+
const rooms = new Map<string, string | null>();
|
|
232
|
+
if (sessionIds.length === 0) return rooms;
|
|
233
|
+
await this.setup();
|
|
234
|
+
const rows = await this.db.exec<{
|
|
235
|
+
session_id: string;
|
|
236
|
+
room_id: string | null;
|
|
237
|
+
}>(
|
|
238
|
+
`SELECT session_id, room_id FROM sessions WHERE session_id IN (${sessionIds.map(() => '?').join(', ')})`,
|
|
239
|
+
[...sessionIds],
|
|
240
|
+
);
|
|
241
|
+
for (const row of rows) rooms.set(row.session_id, row.room_id);
|
|
242
|
+
return rooms;
|
|
243
|
+
}
|
|
244
|
+
|
|
223
245
|
async getSession(sessionId: string): Promise<SessionRecord | undefined> {
|
|
224
246
|
await this.setup();
|
|
225
247
|
const row = await this.db.get<SessionRow>(
|