@ixo/oracle-runtime-workers 0.13.0 → 0.16.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 +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +2003 -274
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +562 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ReplyPlan } from '../delivery/types';
|
|
3
|
+
|
|
4
|
+
export const ChannelTurnBody = z.strictObject({
|
|
5
|
+
provider: z.literal('whatsapp'),
|
|
6
|
+
bindingId: z.string().regex(/^chb_[A-Za-z0-9_-]{1,120}$/),
|
|
7
|
+
bindingRevision: z.number().int().positive().safe(),
|
|
8
|
+
requestId: z.string().min(1).max(180),
|
|
9
|
+
remoteMessageRef: z.string().regex(/^hmac:[a-f0-9]{64}$/),
|
|
10
|
+
sessionId: z.string().min(1).max(255).optional(),
|
|
11
|
+
message: z.string().min(1).max(16_000),
|
|
12
|
+
context: z.strictObject({ kind: z.literal('companion') }),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export type ChannelTurnInput = z.infer<typeof ChannelTurnBody>;
|
|
16
|
+
|
|
17
|
+
export const ChannelOrigin = z.strictObject({
|
|
18
|
+
v: z.literal(1),
|
|
19
|
+
transport: z.literal('whatsapp'),
|
|
20
|
+
binding_id: ChannelTurnBody.shape.bindingId,
|
|
21
|
+
remote_ref: ChannelTurnBody.shape.remoteMessageRef,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type ChannelOrigin = z.infer<typeof ChannelOrigin>;
|
|
25
|
+
|
|
26
|
+
export function channelOrigin(
|
|
27
|
+
channel: Pick<
|
|
28
|
+
ChannelTurnInput,
|
|
29
|
+
'provider' | 'bindingId' | 'remoteMessageRef'
|
|
30
|
+
>,
|
|
31
|
+
): ChannelOrigin {
|
|
32
|
+
return {
|
|
33
|
+
v: 1,
|
|
34
|
+
transport: channel.provider,
|
|
35
|
+
binding_id: channel.bindingId,
|
|
36
|
+
remote_ref: channel.remoteMessageRef,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ChannelIdentity {
|
|
41
|
+
callerDid: string;
|
|
42
|
+
provider: 'whatsapp';
|
|
43
|
+
bindingId: string;
|
|
44
|
+
bindingRevision: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ChannelTurnResponse {
|
|
48
|
+
requestId: string;
|
|
49
|
+
runId: string;
|
|
50
|
+
sessionId: string;
|
|
51
|
+
status:
|
|
52
|
+
| 'queued'
|
|
53
|
+
| 'running'
|
|
54
|
+
| 'recovering'
|
|
55
|
+
| 'finished'
|
|
56
|
+
| 'aborted'
|
|
57
|
+
| 'interrupted'
|
|
58
|
+
| 'failed';
|
|
59
|
+
messageId?: string;
|
|
60
|
+
text?: string;
|
|
61
|
+
/**
|
|
62
|
+
* A finished turn's Reply Plan: the parts to deliver in order (short
|
|
63
|
+
* messages and artefact links). `text` stays the whole reply as one
|
|
64
|
+
* message for gateways that predate plans.
|
|
65
|
+
*/
|
|
66
|
+
plan?: ReplyPlan;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type ChannelErrorStatus =
|
|
70
|
+
| 400
|
|
71
|
+
| 401
|
|
72
|
+
| 403
|
|
73
|
+
| 404
|
|
74
|
+
| 409
|
|
75
|
+
| 410
|
|
76
|
+
| 413
|
|
77
|
+
| 428
|
|
78
|
+
| 429
|
|
79
|
+
| 503;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The machine-readable reason a channel turn was refused. The gateway maps
|
|
83
|
+
* a refusal by `code` first and by HTTP status second. 428 (Precondition
|
|
84
|
+
* Required) means a precondition on the user's side is missing: fix it
|
|
85
|
+
* (authorize the oracle again, finish setting up the Companion room) and
|
|
86
|
+
* retry; it is never a transient outage.
|
|
87
|
+
*
|
|
88
|
+
* | code | status | meaning |
|
|
89
|
+
* | --------------------------------- | ------ | ----------------------------------------------------- |
|
|
90
|
+
* | `invalid_request` | 400 | body missing, not UTF-8, not JSON, or the wrong shape |
|
|
91
|
+
* | `unauthorized` | 401 | no valid user-rooted channel invocation |
|
|
92
|
+
* | `scope_mismatch` | 403 | the invocation does not cover this binding or body |
|
|
93
|
+
* | `identity_mismatch` | 403 | the channel identity does not match the request |
|
|
94
|
+
* | `identity_required` | 403 | the binding check got no channel identity |
|
|
95
|
+
* | `binding_inactive` | 403 | the Auth Hub reports the binding inactive |
|
|
96
|
+
* | `channel_grant_not_tool_authority`| 403 | a channel request carried a delegation header |
|
|
97
|
+
* | `session_not_found` | 404 | the session is not the user's Companion session |
|
|
98
|
+
* | `request_conflict` | 409 | the request id already belongs to another message |
|
|
99
|
+
* | `session_conflict` | 409 | the channel is already bound to another session |
|
|
100
|
+
* | `room_not_encrypted` | 409 | the Companion room is not end-to-end encrypted |
|
|
101
|
+
* | `response_expired` | 410 | the run was pruned; the request cannot run again |
|
|
102
|
+
* | `request_too_large` | 413 | the body is over 64 kB |
|
|
103
|
+
* | `room_not_ready` | 428 | the user has no Companion room yet |
|
|
104
|
+
* | `delegation_required` | 428 | no usable delegation to this oracle (see below) |
|
|
105
|
+
* | `rate_limited` | 429 | too many requests for this user |
|
|
106
|
+
* | `not_configured` | 503 | channels or binding validation are not configured |
|
|
107
|
+
* | `binding_check_unavailable` | 503 | the Auth Hub binding check failed or answered badly |
|
|
108
|
+
* | `unavailable` | 503 | any other failure (the shell logs it) |
|
|
109
|
+
*/
|
|
110
|
+
export type ChannelErrorCode =
|
|
111
|
+
| 'invalid_request'
|
|
112
|
+
| 'unauthorized'
|
|
113
|
+
| 'scope_mismatch'
|
|
114
|
+
| 'identity_mismatch'
|
|
115
|
+
| 'identity_required'
|
|
116
|
+
| 'binding_inactive'
|
|
117
|
+
| 'channel_grant_not_tool_authority'
|
|
118
|
+
| 'session_not_found'
|
|
119
|
+
| 'request_conflict'
|
|
120
|
+
| 'session_conflict'
|
|
121
|
+
| 'room_not_encrypted'
|
|
122
|
+
| 'response_expired'
|
|
123
|
+
| 'request_too_large'
|
|
124
|
+
| 'room_not_ready'
|
|
125
|
+
| 'delegation_required'
|
|
126
|
+
| 'rate_limited'
|
|
127
|
+
| 'not_configured'
|
|
128
|
+
| 'binding_check_unavailable'
|
|
129
|
+
| 'unavailable';
|
|
130
|
+
|
|
131
|
+
export type ChannelTurnOutcome =
|
|
132
|
+
| { ok: true; result: ChannelTurnResponse }
|
|
133
|
+
| {
|
|
134
|
+
ok: false;
|
|
135
|
+
status: ChannelErrorStatus;
|
|
136
|
+
code: ChannelErrorCode;
|
|
137
|
+
message: string;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export class ChannelError extends Error {
|
|
141
|
+
constructor(
|
|
142
|
+
readonly status: ChannelErrorStatus,
|
|
143
|
+
message: string,
|
|
144
|
+
readonly code: ChannelErrorCode,
|
|
145
|
+
) {
|
|
146
|
+
super(message);
|
|
147
|
+
this.name = 'ChannelError';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The least lifetime a delegation must have left for a channel turn to be
|
|
153
|
+
* admitted (seconds): a turn, its tool calls and its owner-copy flush must
|
|
154
|
+
* not outlive the delegation they run under.
|
|
155
|
+
*/
|
|
156
|
+
export const CHANNEL_DELEGATION_MIN_REMAINING_SECONDS = 900;
|
|
157
|
+
|
|
158
|
+
/** The user's delegation to this oracle as the user object holds it. */
|
|
159
|
+
export interface ChannelDelegation {
|
|
160
|
+
raw?: string;
|
|
161
|
+
/** Unix seconds: the delegation's effective expiry; absent = unknown. */
|
|
162
|
+
expiration?: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function delegationRequiredError(): ChannelError {
|
|
166
|
+
return new ChannelError(
|
|
167
|
+
428,
|
|
168
|
+
'Companion delegation required: the user must authorize this oracle again',
|
|
169
|
+
'delegation_required',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A channel turn runs under the user's stored delegation to this oracle,
|
|
175
|
+
* like a Matrix turn. Without one, with an unknown expiry, or with less than
|
|
176
|
+
* `CHANNEL_DELEGATION_MIN_REMAINING_SECONDS` left, there is no tool
|
|
177
|
+
* authority to run under: the turn is refused with an explicit 428
|
|
178
|
+
* `delegation_required` instead of running degraded (memory, files and the
|
|
179
|
+
* sandbox would fail silently).
|
|
180
|
+
*/
|
|
181
|
+
export function requireChannelDelegation(
|
|
182
|
+
delegation: ChannelDelegation | undefined,
|
|
183
|
+
nowSeconds: number,
|
|
184
|
+
): void {
|
|
185
|
+
if (
|
|
186
|
+
!delegation?.raw ||
|
|
187
|
+
typeof delegation.expiration !== 'number' ||
|
|
188
|
+
!Number.isFinite(delegation.expiration) ||
|
|
189
|
+
delegation.expiration <=
|
|
190
|
+
nowSeconds + CHANNEL_DELEGATION_MIN_REMAINING_SECONDS
|
|
191
|
+
)
|
|
192
|
+
throw delegationRequiredError();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function channelRequestHash(raw: string): Promise<string> {
|
|
196
|
+
const digest = await crypto.subtle.digest(
|
|
197
|
+
'SHA-256',
|
|
198
|
+
new TextEncoder().encode(raw),
|
|
199
|
+
);
|
|
200
|
+
return Array.from(new Uint8Array(digest), (byte) =>
|
|
201
|
+
byte.toString(16).padStart(2, '0'),
|
|
202
|
+
).join('');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function readChannelBody(request: Request): Promise<string> {
|
|
206
|
+
const reader = request.body?.getReader();
|
|
207
|
+
if (!reader)
|
|
208
|
+
throw new ChannelError(
|
|
209
|
+
400,
|
|
210
|
+
'A channel turn is required',
|
|
211
|
+
'invalid_request',
|
|
212
|
+
);
|
|
213
|
+
const chunks: Uint8Array[] = [];
|
|
214
|
+
let size = 0;
|
|
215
|
+
while (true) {
|
|
216
|
+
const chunk = await reader.read();
|
|
217
|
+
if (chunk.done) break;
|
|
218
|
+
size += chunk.value.byteLength;
|
|
219
|
+
if (size > 64_000) {
|
|
220
|
+
await reader.cancel();
|
|
221
|
+
throw new ChannelError(
|
|
222
|
+
413,
|
|
223
|
+
'Channel request is too large',
|
|
224
|
+
'request_too_large',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
chunks.push(chunk.value);
|
|
228
|
+
}
|
|
229
|
+
const bytes = new Uint8Array(size);
|
|
230
|
+
let offset = 0;
|
|
231
|
+
for (const chunk of chunks) {
|
|
232
|
+
bytes.set(chunk, offset);
|
|
233
|
+
offset += chunk.byteLength;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
|
|
237
|
+
bytes,
|
|
238
|
+
);
|
|
239
|
+
} catch {
|
|
240
|
+
throw new ChannelError(
|
|
241
|
+
400,
|
|
242
|
+
'Channel request must be UTF-8',
|
|
243
|
+
'invalid_request',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
2
|
+
import type { RunRecord } from '../do/run-store';
|
|
3
|
+
import type { TurnIdentity, TurnRequest } from '../do/contracts';
|
|
4
|
+
import { parseReplyPlan, planText } from '../delivery/schema';
|
|
5
|
+
import type { ReplyPlan } from '../delivery/types';
|
|
6
|
+
import {
|
|
7
|
+
ChannelError,
|
|
8
|
+
channelRequestHash,
|
|
9
|
+
type ChannelTurnInput,
|
|
10
|
+
type ChannelTurnResponse,
|
|
11
|
+
} from './contract';
|
|
12
|
+
|
|
13
|
+
interface ReceiptRow extends Record<string, string | number | null> {
|
|
14
|
+
binding_id: string;
|
|
15
|
+
request_id: string;
|
|
16
|
+
request_hash: string;
|
|
17
|
+
run_id: string;
|
|
18
|
+
session_id: string | null;
|
|
19
|
+
/** 1 once the assistant reply was mirrored into the Companion room. */
|
|
20
|
+
reply_mirrored: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChannelTurnsHost {
|
|
24
|
+
createSession(identity: TurnIdentity, markerTxnId: string): Promise<string>;
|
|
25
|
+
assertSession(identity: TurnIdentity, sessionId: string): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Throws a `ChannelError` (428 `delegation_required`) when the user has no
|
|
28
|
+
* usable delegation for this oracle: none, or one too close to expiry.
|
|
29
|
+
*/
|
|
30
|
+
requireDelegation(identity: TurnIdentity): Promise<void>;
|
|
31
|
+
getRun(runId: string): Promise<RunRecord | undefined>;
|
|
32
|
+
/** The Reply Plan (JSON) a finished run was built into, if any. */
|
|
33
|
+
getPlan(runId: string): Promise<string | null>;
|
|
34
|
+
wasPruned(runId: string): Promise<boolean>;
|
|
35
|
+
begin(runId: string, request: TurnRequest): Promise<RunRecord>;
|
|
36
|
+
mirror(
|
|
37
|
+
request: TurnRequest,
|
|
38
|
+
text: string,
|
|
39
|
+
author: 'user' | 'oracle',
|
|
40
|
+
): Promise<void>;
|
|
41
|
+
changed(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ChannelTurns {
|
|
45
|
+
private admission: Promise<unknown> = Promise.resolve();
|
|
46
|
+
private setupPromise: Promise<void> | undefined;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly db: DoSqliteDatabase,
|
|
50
|
+
private readonly host: ChannelTurnsHost,
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
private setup(): Promise<void> {
|
|
54
|
+
this.setupPromise ??= (async () => {
|
|
55
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_requests (
|
|
56
|
+
binding_id TEXT NOT NULL, request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
|
|
57
|
+
run_id TEXT NOT NULL UNIQUE, session_id TEXT,
|
|
58
|
+
reply_mirrored INTEGER NOT NULL DEFAULT 0,
|
|
59
|
+
PRIMARY KEY (binding_id, request_id)
|
|
60
|
+
)`);
|
|
61
|
+
// Tables created before the column existed (CREATE TABLE IF NOT
|
|
62
|
+
// EXISTS leaves them as they were).
|
|
63
|
+
const columns = await this.db.exec<{ name: string }>(
|
|
64
|
+
'PRAGMA table_info(channel_requests)',
|
|
65
|
+
);
|
|
66
|
+
if (!columns.some((c) => c.name === 'reply_mirrored'))
|
|
67
|
+
await this.db.run(
|
|
68
|
+
'ALTER TABLE channel_requests ADD COLUMN reply_mirrored INTEGER NOT NULL DEFAULT 0',
|
|
69
|
+
);
|
|
70
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS channel_sessions (
|
|
71
|
+
binding_id TEXT PRIMARY KEY, session_id TEXT NOT NULL
|
|
72
|
+
)`);
|
|
73
|
+
})().catch((error: unknown) => {
|
|
74
|
+
this.setupPromise = undefined;
|
|
75
|
+
throw error;
|
|
76
|
+
});
|
|
77
|
+
return this.setupPromise;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Admissions, reply deliveries and session releases run one at a time. */
|
|
81
|
+
private serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
82
|
+
const next = this.admission.then(work);
|
|
83
|
+
this.admission = next.catch(() => undefined);
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
submit(
|
|
88
|
+
identity: TurnIdentity,
|
|
89
|
+
input: ChannelTurnInput,
|
|
90
|
+
requestHash: string,
|
|
91
|
+
): Promise<ChannelTurnResponse> {
|
|
92
|
+
return this.serialize(() => this.admit(identity, input, requestHash));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Mirror a finished run's reply into the Companion room once: the receipt
|
|
97
|
+
* records the delivery, so later polls of the same request do not send it
|
|
98
|
+
* again. Called when the run ends; a poll retries a delivery that failed.
|
|
99
|
+
* `text` is the model's reply; a run with a Reply Plan mirrors the plan
|
|
100
|
+
* instead, as the channel user received it.
|
|
101
|
+
*/
|
|
102
|
+
deliverReply(
|
|
103
|
+
request: TurnRequest,
|
|
104
|
+
runId: string,
|
|
105
|
+
text: string,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
return this.serialize(async () => {
|
|
108
|
+
await this.deliver(request, runId, await this.reply(runId, text));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A finished run's Reply Plan, and the reply as the channel user sees it. */
|
|
113
|
+
private async reply(
|
|
114
|
+
runId: string,
|
|
115
|
+
text: string,
|
|
116
|
+
): Promise<{ plan: ReplyPlan | null; rendered: string }> {
|
|
117
|
+
const plan = parseReplyPlan(await this.host.getPlan(runId));
|
|
118
|
+
return { plan, rendered: plan ? planText(plan) : text };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The session was deleted: release every binding that pointed at it, so
|
|
123
|
+
* the binding's next message opens a new Companion session. Returns
|
|
124
|
+
* whether a binding was released.
|
|
125
|
+
*/
|
|
126
|
+
forgetSession(sessionId: string): Promise<boolean> {
|
|
127
|
+
return this.serialize(async () => {
|
|
128
|
+
await this.setup();
|
|
129
|
+
const released = await this.db.run(
|
|
130
|
+
'DELETE FROM channel_sessions WHERE session_id = ?',
|
|
131
|
+
[sessionId],
|
|
132
|
+
);
|
|
133
|
+
if (released.changes === 0) return false;
|
|
134
|
+
await this.host.changed();
|
|
135
|
+
return true;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async deliver(
|
|
140
|
+
request: TurnRequest,
|
|
141
|
+
runId: string,
|
|
142
|
+
reply: { rendered: string },
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
await this.setup();
|
|
145
|
+
const receipt = await this.db.get<{ reply_mirrored: number }>(
|
|
146
|
+
'SELECT reply_mirrored FROM channel_requests WHERE run_id = ?',
|
|
147
|
+
[runId],
|
|
148
|
+
);
|
|
149
|
+
if (Number(receipt?.reply_mirrored ?? 0) === 1) return;
|
|
150
|
+
await this.host.mirror(request, reply.rendered, 'oracle');
|
|
151
|
+
await this.db.run(
|
|
152
|
+
'UPDATE channel_requests SET reply_mirrored = 1 WHERE run_id = ?',
|
|
153
|
+
[runId],
|
|
154
|
+
);
|
|
155
|
+
await this.host.changed();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async admit(
|
|
159
|
+
identity: TurnIdentity,
|
|
160
|
+
input: ChannelTurnInput,
|
|
161
|
+
requestHash: string,
|
|
162
|
+
): Promise<ChannelTurnResponse> {
|
|
163
|
+
await this.setup();
|
|
164
|
+
if (
|
|
165
|
+
!identity.channel ||
|
|
166
|
+
identity.channel.bindingId !== input.bindingId ||
|
|
167
|
+
identity.channel.bindingRevision !== input.bindingRevision ||
|
|
168
|
+
identity.channel.provider !== input.provider
|
|
169
|
+
)
|
|
170
|
+
throw new ChannelError(
|
|
171
|
+
403,
|
|
172
|
+
'Channel identity does not match this request',
|
|
173
|
+
'identity_mismatch',
|
|
174
|
+
);
|
|
175
|
+
const runId = `channel_${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`;
|
|
176
|
+
// Checked before a receipt is written: the receipt of a pruned run is
|
|
177
|
+
// gone (run-store.ts), and a new one must not take its place.
|
|
178
|
+
if (await this.host.wasPruned(runId))
|
|
179
|
+
throw new ChannelError(
|
|
180
|
+
410,
|
|
181
|
+
'Channel response has expired; this request cannot execute again',
|
|
182
|
+
'response_expired',
|
|
183
|
+
);
|
|
184
|
+
const admitted = await this.db.run(
|
|
185
|
+
`INSERT OR IGNORE INTO channel_requests
|
|
186
|
+
(binding_id, request_id, request_hash, run_id) VALUES (?, ?, ?, ?)`,
|
|
187
|
+
[input.bindingId, input.requestId, requestHash, runId],
|
|
188
|
+
);
|
|
189
|
+
// A poll of an admitted request changes nothing.
|
|
190
|
+
if (admitted.changes === 1) await this.host.changed();
|
|
191
|
+
const receipt = await this.db.get<ReceiptRow>(
|
|
192
|
+
'SELECT * FROM channel_requests WHERE binding_id = ? AND request_id = ?',
|
|
193
|
+
[input.bindingId, input.requestId],
|
|
194
|
+
);
|
|
195
|
+
if (!receipt || receipt.request_hash !== requestHash)
|
|
196
|
+
throw new ChannelError(
|
|
197
|
+
409,
|
|
198
|
+
'This request ID already belongs to another message',
|
|
199
|
+
'request_conflict',
|
|
200
|
+
);
|
|
201
|
+
let sessionId = receipt.session_id;
|
|
202
|
+
// The session is checked when this request is bound to it, and again
|
|
203
|
+
// before the request starts a run; a poll of a run that exists already
|
|
204
|
+
// only reads its receipt and row.
|
|
205
|
+
let sessionChecked = false;
|
|
206
|
+
if (!sessionId) {
|
|
207
|
+
const binding = await this.db.get<{ session_id: string }>(
|
|
208
|
+
'SELECT session_id FROM channel_sessions WHERE binding_id = ?',
|
|
209
|
+
[input.bindingId],
|
|
210
|
+
);
|
|
211
|
+
if (binding && input.sessionId && binding.session_id !== input.sessionId)
|
|
212
|
+
throw new ChannelError(
|
|
213
|
+
409,
|
|
214
|
+
'This channel already has another session',
|
|
215
|
+
'session_conflict',
|
|
216
|
+
);
|
|
217
|
+
sessionId =
|
|
218
|
+
binding?.session_id ??
|
|
219
|
+
input.sessionId ??
|
|
220
|
+
// The marker transaction id is fixed per binding AND the request that
|
|
221
|
+
// opens the session: a retry of that request reuses the marker event,
|
|
222
|
+
// while a binding whose session was deleted gets a new one.
|
|
223
|
+
(await this.host.createSession(
|
|
224
|
+
identity,
|
|
225
|
+
`channel-session-${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`,
|
|
226
|
+
));
|
|
227
|
+
await this.host.assertSession(identity, sessionId);
|
|
228
|
+
sessionChecked = true;
|
|
229
|
+
await this.db.transaction(async () => {
|
|
230
|
+
await this.db.run(
|
|
231
|
+
'INSERT OR IGNORE INTO channel_sessions (binding_id, session_id) VALUES (?, ?)',
|
|
232
|
+
[input.bindingId, sessionId],
|
|
233
|
+
);
|
|
234
|
+
await this.db.run(
|
|
235
|
+
'UPDATE channel_requests SET session_id = ? WHERE run_id = ?',
|
|
236
|
+
[sessionId, runId],
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
await this.host.changed();
|
|
240
|
+
}
|
|
241
|
+
const request: TurnRequest = {
|
|
242
|
+
identity,
|
|
243
|
+
sessionId,
|
|
244
|
+
message: input.message,
|
|
245
|
+
client: 'channel',
|
|
246
|
+
requestId: input.requestId,
|
|
247
|
+
multitask: 'enqueue',
|
|
248
|
+
channel: {
|
|
249
|
+
provider: input.provider,
|
|
250
|
+
bindingId: input.bindingId,
|
|
251
|
+
remoteMessageRef: input.remoteMessageRef,
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
let record = await this.host.getRun(runId);
|
|
255
|
+
if (!record) {
|
|
256
|
+
if (!sessionChecked) await this.host.assertSession(identity, sessionId);
|
|
257
|
+
await this.host.requireDelegation(identity);
|
|
258
|
+
await this.host.mirror(request, request.message, 'user');
|
|
259
|
+
record = await this.host.begin(runId, request);
|
|
260
|
+
}
|
|
261
|
+
const reply =
|
|
262
|
+
record.status === 'finished'
|
|
263
|
+
? await this.reply(runId, record.partialText ?? '')
|
|
264
|
+
: null;
|
|
265
|
+
// The Companion room records the reply the channel user received, once.
|
|
266
|
+
if (reply) await this.deliver(request, runId, reply);
|
|
267
|
+
return {
|
|
268
|
+
requestId: input.requestId,
|
|
269
|
+
runId,
|
|
270
|
+
sessionId,
|
|
271
|
+
status: record.status,
|
|
272
|
+
...(record.messageId ? { messageId: record.messageId } : {}),
|
|
273
|
+
// `text` is the whole reply as one message for gateways that predate
|
|
274
|
+
// plans: the rendered plan when there is one (it carries the artefact
|
|
275
|
+
// links), else the model's text.
|
|
276
|
+
...(reply ? { text: reply.rendered } : {}),
|
|
277
|
+
...(reply?.plan ? { plan: reply.plan } : {}),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|