@ixo/oracle-runtime-workers 0.22.0 → 0.24.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 +15 -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 +93 -6
- package/src/core/main-agent-types.ts +8 -0
- package/src/core/main-agent.ts +2 -0
- package/src/core/runtime-context.ts +12 -0
- package/src/delivery/plan.ts +43 -2
- package/src/delivery/schema.ts +25 -1
- package/src/delivery/types.ts +13 -2
- package/src/do/ambient.ts +6 -0
- package/src/do/contracts.ts +39 -0
- package/src/do/user-oracle-do.ts +1275 -8
- 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/memory/session-history-indexer.ts +30 -3
- package/src/memory/user-context-fetcher.ts +36 -12
- package/src/plugin-api/types.ts +54 -0
- package/src/plugins/action-proposals/action-proposals.plugin.ts +486 -0
- package/src/plugins/action-proposals/evidence.ts +67 -0
- package/src/plugins/hub-actions/hub-actions.plugin.ts +244 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/memory/index.ts +4 -0
- package/src/plugins/memory/memory-entity.ts +49 -0
- package/src/plugins/memory/memory-tools.ts +7 -4
- package/src/plugins/memory/memory-ucan.ts +10 -1
- package/src/plugins/memory/memory.plugin.ts +22 -7
- package/src/shell/app.ts +195 -1
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalJson,
|
|
3
|
+
parseOutcome,
|
|
4
|
+
parseReference,
|
|
5
|
+
type ActionOutcomeV1,
|
|
6
|
+
type ActionOperationV1,
|
|
7
|
+
type ActionReferenceV1,
|
|
8
|
+
} from '@ixo/action-protocol';
|
|
9
|
+
import type { DoSqliteDatabase } from '../sqlite/database';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Outcomes after which the Hub never reports another for the action
|
|
13
|
+
* (`expired` and `cancelled`: never executed, protocol 0.4.0).
|
|
14
|
+
*/
|
|
15
|
+
export const TERMINAL_HUB_STATUSES: ReadonlySet<ActionOutcomeV1['status']> =
|
|
16
|
+
new Set(['succeeded', 'failed', 'declined', 'expired', 'cancelled']);
|
|
17
|
+
/** Status refreshes of a non-terminal action back off 15 s -> 10 min. */
|
|
18
|
+
export const HUB_CHECK_BASE_MS = 15_000;
|
|
19
|
+
export const HUB_CHECK_MAX_MS = 10 * 60_000;
|
|
20
|
+
/** A non-terminal action is no longer refreshed this long after its review expired. */
|
|
21
|
+
export const HUB_CHECK_AFTER_EXPIRY_MS = 24 * 60 * 60_000;
|
|
22
|
+
/** Prepares (Hub POSTs) per agent run, per user per hour and per user per day. */
|
|
23
|
+
export const HUB_ACTIONS_PER_RUN = 2;
|
|
24
|
+
export const HUB_ACTIONS_PER_HOUR = 5;
|
|
25
|
+
export const HUB_ACTIONS_PER_DAY = 10;
|
|
26
|
+
/** Rows are dropped this long after their review expired. */
|
|
27
|
+
const HUB_RETENTION_SECONDS = 30 * 24 * 60 * 60;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A refusal the model can act on: a cap reached, a request that expired, or
|
|
31
|
+
* an operation the owner's own state rules out (an unknown connection, an
|
|
32
|
+
* email whose earlier send is unconfirmed: `priorActionId`).
|
|
33
|
+
*/
|
|
34
|
+
export class HubActionRefused extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
readonly kind: 'limit' | 'expired' | 'refused',
|
|
37
|
+
message: string,
|
|
38
|
+
readonly priorActionId?: string,
|
|
39
|
+
) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'HubActionRefused';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface HubActionOrigin {
|
|
46
|
+
ownerDid: string;
|
|
47
|
+
sessionId: string;
|
|
48
|
+
runId: string;
|
|
49
|
+
requestId: string;
|
|
50
|
+
bindingId: string;
|
|
51
|
+
bindingRevision: number;
|
|
52
|
+
requestKey: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ActionRow extends Record<
|
|
56
|
+
string,
|
|
57
|
+
string | number | bigint | null | Uint8Array
|
|
58
|
+
> {
|
|
59
|
+
reference: string;
|
|
60
|
+
owner_did: string;
|
|
61
|
+
session_id: string;
|
|
62
|
+
run_id: string;
|
|
63
|
+
request_id: string;
|
|
64
|
+
binding_id: string;
|
|
65
|
+
binding_revision: number;
|
|
66
|
+
request_key: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class HubActionStore {
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly db: DoSqliteDatabase,
|
|
72
|
+
private readonly now = Date.now,
|
|
73
|
+
) {}
|
|
74
|
+
|
|
75
|
+
async setup(): Promise<void> {
|
|
76
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS hub_actions (
|
|
77
|
+
action_id TEXT PRIMARY KEY, owner_did TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
78
|
+
run_id TEXT NOT NULL, request_id TEXT NOT NULL, binding_id TEXT NOT NULL,
|
|
79
|
+
binding_revision INTEGER NOT NULL, request_key TEXT NOT NULL UNIQUE,
|
|
80
|
+
reference TEXT NOT NULL
|
|
81
|
+
)`);
|
|
82
|
+
await this.db.run(`CREATE INDEX IF NOT EXISTS hub_actions_turn
|
|
83
|
+
ON hub_actions(owner_did, session_id, run_id)`);
|
|
84
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS hub_action_requests (
|
|
85
|
+
request_key TEXT PRIMARY KEY, owner_did TEXT NOT NULL, session_id TEXT NOT NULL,
|
|
86
|
+
run_id TEXT NOT NULL, request_id TEXT NOT NULL, binding_id TEXT NOT NULL,
|
|
87
|
+
binding_revision INTEGER NOT NULL, operation TEXT NOT NULL, expires_at INTEGER NOT NULL
|
|
88
|
+
)`);
|
|
89
|
+
await this.db.run(`CREATE TABLE IF NOT EXISTS hub_action_outcomes (
|
|
90
|
+
event_id TEXT PRIMARY KEY, action_id TEXT NOT NULL, sequence INTEGER NOT NULL,
|
|
91
|
+
outcome TEXT NOT NULL, FOREIGN KEY(action_id) REFERENCES hub_actions(action_id)
|
|
92
|
+
)`);
|
|
93
|
+
// The unique index below covers the same columns.
|
|
94
|
+
await this.db.run(`DROP INDEX IF EXISTS hub_action_outcomes_action`);
|
|
95
|
+
await this.db
|
|
96
|
+
.run(`CREATE UNIQUE INDEX IF NOT EXISTS hub_action_outcomes_sequence
|
|
97
|
+
ON hub_action_outcomes(action_id, sequence)`);
|
|
98
|
+
// Columns added after the first shape (flag-gated, so only test and
|
|
99
|
+
// devnet objects hold it): adding a column is instant in SQLite.
|
|
100
|
+
const actionColumns = await this.db.exec<{ name: string }>(
|
|
101
|
+
`PRAGMA table_info(hub_actions)`,
|
|
102
|
+
);
|
|
103
|
+
if (!actionColumns.some((c) => c.name === 'next_check_at'))
|
|
104
|
+
await this.db.run(
|
|
105
|
+
`ALTER TABLE hub_actions ADD COLUMN next_check_at INTEGER NOT NULL DEFAULT 0`,
|
|
106
|
+
);
|
|
107
|
+
if (!actionColumns.some((c) => c.name === 'check_attempts'))
|
|
108
|
+
await this.db.run(
|
|
109
|
+
`ALTER TABLE hub_actions ADD COLUMN check_attempts INTEGER NOT NULL DEFAULT 0`,
|
|
110
|
+
);
|
|
111
|
+
if (!actionColumns.some((c) => c.name === 'expires_at'))
|
|
112
|
+
await this.db.run(
|
|
113
|
+
`ALTER TABLE hub_actions ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0`,
|
|
114
|
+
);
|
|
115
|
+
const requestColumns = await this.db.exec<{ name: string }>(
|
|
116
|
+
`PRAGMA table_info(hub_action_requests)`,
|
|
117
|
+
);
|
|
118
|
+
if (!requestColumns.some((c) => c.name === 'created_at'))
|
|
119
|
+
await this.db.run(
|
|
120
|
+
`ALTER TABLE hub_action_requests ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0`,
|
|
121
|
+
);
|
|
122
|
+
await this.db.run(`CREATE INDEX IF NOT EXISTS hub_action_requests_expires
|
|
123
|
+
ON hub_action_requests(expires_at)`);
|
|
124
|
+
await this.db.run(`CREATE INDEX IF NOT EXISTS hub_action_requests_owner
|
|
125
|
+
ON hub_action_requests(owner_did, created_at)`);
|
|
126
|
+
await this.db.run(
|
|
127
|
+
`UPDATE hub_actions SET expires_at = json_extract(reference, '$.expiresAt') WHERE expires_at = 0`,
|
|
128
|
+
);
|
|
129
|
+
await this.db.run(`CREATE INDEX IF NOT EXISTS hub_actions_expires
|
|
130
|
+
ON hub_actions(expires_at)`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Drop requests, actions and outcomes whose review expired more than
|
|
135
|
+
* 30 days ago (bounded per call). Returns how many rows went.
|
|
136
|
+
*/
|
|
137
|
+
async prune(): Promise<number> {
|
|
138
|
+
const before = Math.floor(this.now() / 1000) - HUB_RETENTION_SECONDS;
|
|
139
|
+
return this.db.transaction(async () => {
|
|
140
|
+
const requests = await this.db.run(
|
|
141
|
+
`DELETE FROM hub_action_requests WHERE request_key IN (SELECT request_key FROM hub_action_requests WHERE expires_at < ? LIMIT 64)`,
|
|
142
|
+
[before],
|
|
143
|
+
);
|
|
144
|
+
const actions = await this.db.exec<{ action_id: string }>(
|
|
145
|
+
`SELECT action_id FROM hub_actions WHERE expires_at < ? LIMIT 64`,
|
|
146
|
+
[before],
|
|
147
|
+
);
|
|
148
|
+
for (const { action_id } of actions) {
|
|
149
|
+
await this.db.run(
|
|
150
|
+
`DELETE FROM hub_action_outcomes WHERE action_id = ?`,
|
|
151
|
+
[action_id],
|
|
152
|
+
);
|
|
153
|
+
await this.db.run(`DELETE FROM hub_actions WHERE action_id = ?`, [
|
|
154
|
+
action_id,
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
return requests.changes + actions.length;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The newest Gmail send to the same mailbox (the same `connectionId`, or
|
|
163
|
+
* the same account label: a reconnect gets a new id) that is still in
|
|
164
|
+
* flight: approved and not finished, its latest outcome `authorized`,
|
|
165
|
+
* `submitted` or `unknown` (the connector could not tell whether it
|
|
166
|
+
* went). Its action id, or null. One Gmail send in flight per mailbox:
|
|
167
|
+
* while one exists no other email is prepared.
|
|
168
|
+
*/
|
|
169
|
+
async gmailSendInFlight(
|
|
170
|
+
ownerDid: string,
|
|
171
|
+
connectionId: string,
|
|
172
|
+
accountLabel: string,
|
|
173
|
+
): Promise<string | null> {
|
|
174
|
+
const row = await this.db.get<{ action_id: string }>(
|
|
175
|
+
`SELECT a.action_id FROM hub_action_requests r JOIN hub_actions a ON a.request_key = r.request_key
|
|
176
|
+
WHERE r.owner_did = ? AND json_extract(r.operation, '$.kind') = 'gmail.message.send.v1'
|
|
177
|
+
AND (json_extract(r.operation, '$.connectionId') = ?
|
|
178
|
+
OR lower(json_extract(r.operation, '$.accountLabel')) = lower(?))
|
|
179
|
+
AND (SELECT json_extract(o.outcome, '$.status') FROM hub_action_outcomes o
|
|
180
|
+
WHERE o.action_id = a.action_id ORDER BY o.sequence DESC LIMIT 1)
|
|
181
|
+
IN ('authorized', 'submitted', 'unknown')
|
|
182
|
+
ORDER BY r.created_at DESC LIMIT 1`,
|
|
183
|
+
[ownerDid, connectionId, accountLabel],
|
|
184
|
+
);
|
|
185
|
+
return row?.action_id ?? null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The reference already recorded for a request key, if any. */
|
|
189
|
+
async byRequestKey(requestKey: string): Promise<ActionReferenceV1 | null> {
|
|
190
|
+
const row = await this.db.get<{ reference: string }>(
|
|
191
|
+
'SELECT reference FROM hub_actions WHERE request_key = ?',
|
|
192
|
+
[requestKey],
|
|
193
|
+
);
|
|
194
|
+
return row ? parseReference(JSON.parse(row.reference)) : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A new request (not a retry of a reserved key) within the caps:
|
|
199
|
+
* `HUB_ACTIONS_PER_RUN` per run, `HUB_ACTIONS_PER_HOUR` and
|
|
200
|
+
* `HUB_ACTIONS_PER_DAY` per owner.
|
|
201
|
+
*/
|
|
202
|
+
private async assertCapacity(origin: HubActionOrigin): Promise<void> {
|
|
203
|
+
const run = await this.db.get<{ n: number }>(
|
|
204
|
+
'SELECT COUNT(*) AS n FROM hub_action_requests WHERE owner_did = ? AND session_id = ? AND run_id = ?',
|
|
205
|
+
[origin.ownerDid, origin.sessionId, origin.runId],
|
|
206
|
+
);
|
|
207
|
+
if ((run?.n ?? 0) >= HUB_ACTIONS_PER_RUN)
|
|
208
|
+
throw new HubActionRefused(
|
|
209
|
+
'limit',
|
|
210
|
+
'This request already prepared the most reviews one turn may prepare.',
|
|
211
|
+
);
|
|
212
|
+
const now = this.now();
|
|
213
|
+
const counts = await this.db.get<{ hour: number; day: number }>(
|
|
214
|
+
'SELECT SUM(created_at > ?) AS hour, COUNT(*) AS day FROM hub_action_requests WHERE owner_did = ? AND created_at > ?',
|
|
215
|
+
[now - 60 * 60_000, origin.ownerDid, now - 24 * 60 * 60_000],
|
|
216
|
+
);
|
|
217
|
+
if ((counts?.hour ?? 0) >= HUB_ACTIONS_PER_HOUR)
|
|
218
|
+
throw new HubActionRefused(
|
|
219
|
+
'limit',
|
|
220
|
+
'Too many reviews were prepared in the last hour; try again later.',
|
|
221
|
+
);
|
|
222
|
+
if ((counts?.day ?? 0) >= HUB_ACTIONS_PER_DAY)
|
|
223
|
+
throw new HubActionRefused(
|
|
224
|
+
'limit',
|
|
225
|
+
'Too many reviews were prepared today; try again tomorrow.',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async reserve(
|
|
230
|
+
origin: HubActionOrigin,
|
|
231
|
+
operation: ActionOperationV1,
|
|
232
|
+
proposedExpiry: number,
|
|
233
|
+
): Promise<number> {
|
|
234
|
+
return this.db.transaction(async () => {
|
|
235
|
+
const previous = await this.db.get<
|
|
236
|
+
ActionRow & { operation: string; expires_at: number }
|
|
237
|
+
>('SELECT * FROM hub_action_requests WHERE request_key = ?', [
|
|
238
|
+
origin.requestKey,
|
|
239
|
+
]);
|
|
240
|
+
const encoded = canonicalJson(operation);
|
|
241
|
+
if (previous) {
|
|
242
|
+
if (
|
|
243
|
+
previous.owner_did !== origin.ownerDid ||
|
|
244
|
+
previous.session_id !== origin.sessionId ||
|
|
245
|
+
previous.run_id !== origin.runId ||
|
|
246
|
+
previous.request_id !== origin.requestId ||
|
|
247
|
+
previous.binding_id !== origin.bindingId ||
|
|
248
|
+
previous.binding_revision !== origin.bindingRevision ||
|
|
249
|
+
previous.operation !== encoded
|
|
250
|
+
)
|
|
251
|
+
throw new Error(
|
|
252
|
+
'Hub action request key belongs to another operation',
|
|
253
|
+
);
|
|
254
|
+
return previous.expires_at;
|
|
255
|
+
}
|
|
256
|
+
await this.assertCapacity(origin);
|
|
257
|
+
await this.db.run(
|
|
258
|
+
`INSERT INTO hub_action_requests(request_key,owner_did,session_id,run_id,request_id,binding_id,binding_revision,operation,expires_at,created_at)
|
|
259
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
260
|
+
[
|
|
261
|
+
origin.requestKey,
|
|
262
|
+
origin.ownerDid,
|
|
263
|
+
origin.sessionId,
|
|
264
|
+
origin.runId,
|
|
265
|
+
origin.requestId,
|
|
266
|
+
origin.bindingId,
|
|
267
|
+
origin.bindingRevision,
|
|
268
|
+
encoded,
|
|
269
|
+
proposedExpiry,
|
|
270
|
+
this.now(),
|
|
271
|
+
],
|
|
272
|
+
);
|
|
273
|
+
return proposedExpiry;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async record(
|
|
278
|
+
origin: HubActionOrigin,
|
|
279
|
+
input: ActionReferenceV1,
|
|
280
|
+
): Promise<ActionReferenceV1> {
|
|
281
|
+
const reference = parseReference(input);
|
|
282
|
+
if (
|
|
283
|
+
reference.bindingId !== origin.bindingId ||
|
|
284
|
+
reference.bindingRevision !== origin.bindingRevision
|
|
285
|
+
)
|
|
286
|
+
throw new Error(
|
|
287
|
+
'Hub action reference does not match the channel binding',
|
|
288
|
+
);
|
|
289
|
+
return this.db.transaction(async () => {
|
|
290
|
+
const previous = await this.db.get<ActionRow>(
|
|
291
|
+
'SELECT * FROM hub_actions WHERE request_key = ?',
|
|
292
|
+
[origin.requestKey],
|
|
293
|
+
);
|
|
294
|
+
if (previous) {
|
|
295
|
+
const old = parseReference(JSON.parse(previous.reference));
|
|
296
|
+
if (
|
|
297
|
+
previous.owner_did !== origin.ownerDid ||
|
|
298
|
+
previous.session_id !== origin.sessionId ||
|
|
299
|
+
previous.run_id !== origin.runId ||
|
|
300
|
+
previous.request_id !== origin.requestId ||
|
|
301
|
+
previous.binding_id !== origin.bindingId ||
|
|
302
|
+
previous.binding_revision !== origin.bindingRevision ||
|
|
303
|
+
canonicalJson(old) !== canonicalJson(reference)
|
|
304
|
+
)
|
|
305
|
+
throw new Error('Hub action request key belongs to another action');
|
|
306
|
+
return old;
|
|
307
|
+
}
|
|
308
|
+
await this.db.run(
|
|
309
|
+
`INSERT INTO hub_actions(action_id,owner_did,session_id,run_id,request_id,binding_id,binding_revision,request_key,reference,expires_at)
|
|
310
|
+
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
311
|
+
[
|
|
312
|
+
reference.id,
|
|
313
|
+
origin.ownerDid,
|
|
314
|
+
origin.sessionId,
|
|
315
|
+
origin.runId,
|
|
316
|
+
origin.requestId,
|
|
317
|
+
origin.bindingId,
|
|
318
|
+
origin.bindingRevision,
|
|
319
|
+
origin.requestKey,
|
|
320
|
+
JSON.stringify(reference),
|
|
321
|
+
reference.expiresAt,
|
|
322
|
+
],
|
|
323
|
+
);
|
|
324
|
+
return reference;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async get(
|
|
329
|
+
ownerDid: string,
|
|
330
|
+
actionId: string,
|
|
331
|
+
): Promise<{ origin: HubActionOrigin; reference: ActionReferenceV1 } | null> {
|
|
332
|
+
const row = await this.db.get<ActionRow>(
|
|
333
|
+
'SELECT * FROM hub_actions WHERE owner_did = ? AND action_id = ?',
|
|
334
|
+
[ownerDid, actionId],
|
|
335
|
+
);
|
|
336
|
+
if (!row) return null;
|
|
337
|
+
return {
|
|
338
|
+
origin: {
|
|
339
|
+
ownerDid: row.owner_did,
|
|
340
|
+
sessionId: row.session_id,
|
|
341
|
+
runId: row.run_id,
|
|
342
|
+
requestId: row.request_id,
|
|
343
|
+
bindingId: row.binding_id,
|
|
344
|
+
bindingRevision: row.binding_revision,
|
|
345
|
+
requestKey: row.request_key,
|
|
346
|
+
},
|
|
347
|
+
reference: parseReference(JSON.parse(row.reference)),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async forRun(
|
|
352
|
+
ownerDid: string,
|
|
353
|
+
sessionId: string,
|
|
354
|
+
runId: string,
|
|
355
|
+
): Promise<ActionReferenceV1[]> {
|
|
356
|
+
const rows = await this.db.exec<ActionRow>(
|
|
357
|
+
'SELECT * FROM hub_actions WHERE owner_did = ? AND session_id = ? AND run_id = ? ORDER BY action_id',
|
|
358
|
+
[ownerDid, sessionId, runId],
|
|
359
|
+
);
|
|
360
|
+
return rows.map((row) => parseReference(JSON.parse(row.reference)));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The run's actions with their latest stored outcome and refresh
|
|
365
|
+
* schedule, from SQLite only.
|
|
366
|
+
*/
|
|
367
|
+
async runState(
|
|
368
|
+
ownerDid: string,
|
|
369
|
+
sessionId: string,
|
|
370
|
+
runId: string,
|
|
371
|
+
): Promise<
|
|
372
|
+
{
|
|
373
|
+
reference: ActionReferenceV1;
|
|
374
|
+
latest: ActionOutcomeV1 | null;
|
|
375
|
+
nextCheckAt: number;
|
|
376
|
+
checkAttempts: number;
|
|
377
|
+
}[]
|
|
378
|
+
> {
|
|
379
|
+
const rows = await this.db.exec<{
|
|
380
|
+
reference: string;
|
|
381
|
+
next_check_at: number;
|
|
382
|
+
check_attempts: number;
|
|
383
|
+
outcome: string | null;
|
|
384
|
+
}>(
|
|
385
|
+
`SELECT a.reference, a.next_check_at, a.check_attempts, (
|
|
386
|
+
SELECT e.outcome FROM hub_action_outcomes e WHERE e.action_id=a.action_id
|
|
387
|
+
ORDER BY e.sequence DESC LIMIT 1) AS outcome
|
|
388
|
+
FROM hub_actions a WHERE a.owner_did=? AND a.session_id=? AND a.run_id=?
|
|
389
|
+
ORDER BY a.action_id`,
|
|
390
|
+
[ownerDid, sessionId, runId],
|
|
391
|
+
);
|
|
392
|
+
return rows.map((row) => ({
|
|
393
|
+
reference: parseReference(JSON.parse(row.reference)),
|
|
394
|
+
latest: row.outcome ? parseOutcome(JSON.parse(row.outcome)) : null,
|
|
395
|
+
nextCheckAt: row.next_check_at,
|
|
396
|
+
checkAttempts: row.check_attempts,
|
|
397
|
+
}));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Spend one status refresh: the next waits 15 s, doubling to 10 min. */
|
|
401
|
+
async scheduleCheck(actionId: string, attempts: number): Promise<void> {
|
|
402
|
+
await this.db.run(
|
|
403
|
+
'UPDATE hub_actions SET next_check_at = ?, check_attempts = ? WHERE action_id = ?',
|
|
404
|
+
[
|
|
405
|
+
this.now() +
|
|
406
|
+
Math.min(HUB_CHECK_BASE_MS * 2 ** attempts, HUB_CHECK_MAX_MS),
|
|
407
|
+
attempts + 1,
|
|
408
|
+
actionId,
|
|
409
|
+
],
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Store an authenticated outcome. Returns whether it was new: a repeat
|
|
415
|
+
* (same event) or an older one than the latest stored (a late or stale
|
|
416
|
+
* read; sequences only move forward) changes nothing.
|
|
417
|
+
*/
|
|
418
|
+
async recordOutcome(input: ActionOutcomeV1): Promise<boolean> {
|
|
419
|
+
const outcome = parseOutcome(input);
|
|
420
|
+
const held = await this.get(outcome.ownerDid, outcome.reference.id);
|
|
421
|
+
if (
|
|
422
|
+
!held ||
|
|
423
|
+
canonicalJson(held.reference) !== canonicalJson(outcome.reference)
|
|
424
|
+
)
|
|
425
|
+
throw new Error('Hub outcome does not match a prepared action');
|
|
426
|
+
if (
|
|
427
|
+
outcome.authorizedRevision < held.reference.revision ||
|
|
428
|
+
(outcome.authorizedRevision === held.reference.revision &&
|
|
429
|
+
outcome.authorizedDigest !== held.reference.digest)
|
|
430
|
+
)
|
|
431
|
+
throw new Error(
|
|
432
|
+
'Hub outcome revision does not descend from the reference',
|
|
433
|
+
);
|
|
434
|
+
return this.db.transaction(async () => {
|
|
435
|
+
const existing = await this.db.get<{ outcome: string }>(
|
|
436
|
+
'SELECT outcome FROM hub_action_outcomes WHERE event_id = ?',
|
|
437
|
+
[outcome.eventId],
|
|
438
|
+
);
|
|
439
|
+
if (existing) {
|
|
440
|
+
if (
|
|
441
|
+
canonicalJson(JSON.parse(existing.outcome)) !== canonicalJson(outcome)
|
|
442
|
+
)
|
|
443
|
+
throw new Error('Hub outcome event ID conflict');
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
const latest = await this.db.get<{ sequence: number }>(
|
|
447
|
+
'SELECT MAX(sequence) AS sequence FROM hub_action_outcomes WHERE action_id = ?',
|
|
448
|
+
[outcome.reference.id],
|
|
449
|
+
);
|
|
450
|
+
if (latest?.sequence != null && outcome.sequence < latest.sequence)
|
|
451
|
+
return false;
|
|
452
|
+
const sameSequence = await this.db.get<{ outcome: string }>(
|
|
453
|
+
'SELECT outcome FROM hub_action_outcomes WHERE action_id = ? AND sequence = ?',
|
|
454
|
+
[outcome.reference.id, outcome.sequence],
|
|
455
|
+
);
|
|
456
|
+
if (sameSequence) throw new Error('Hub outcome sequence conflict');
|
|
457
|
+
await this.db.run(
|
|
458
|
+
'INSERT INTO hub_action_outcomes(event_id,action_id,sequence,outcome) VALUES(?,?,?,?)',
|
|
459
|
+
[
|
|
460
|
+
outcome.eventId,
|
|
461
|
+
outcome.reference.id,
|
|
462
|
+
outcome.sequence,
|
|
463
|
+
JSON.stringify(outcome),
|
|
464
|
+
],
|
|
465
|
+
);
|
|
466
|
+
return true;
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async outcomesForRun(
|
|
471
|
+
ownerDid: string,
|
|
472
|
+
sessionId: string,
|
|
473
|
+
runId: string,
|
|
474
|
+
): Promise<ActionOutcomeV1[]> {
|
|
475
|
+
const rows = await this.db.exec<{ outcome: string }>(
|
|
476
|
+
`SELECT o.outcome FROM hub_actions a JOIN hub_action_outcomes o ON o.event_id=(
|
|
477
|
+
SELECT e.event_id FROM hub_action_outcomes e WHERE e.action_id=a.action_id
|
|
478
|
+
ORDER BY e.sequence DESC LIMIT 1)
|
|
479
|
+
WHERE a.owner_did=? AND a.session_id=? AND a.run_id=?
|
|
480
|
+
ORDER BY a.action_id`,
|
|
481
|
+
[ownerDid, sessionId, runId],
|
|
482
|
+
);
|
|
483
|
+
return rows.map((row) => parseOutcome(JSON.parse(row.outcome)));
|
|
484
|
+
}
|
|
485
|
+
}
|
package/src/matrix/gateway-do.ts
CHANGED
|
@@ -249,6 +249,40 @@ function isForbidden(err: unknown): boolean {
|
|
|
249
249
|
);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/** A topic read's own ceiling when the caller names no deadline. */
|
|
253
|
+
const TOPIC_READ_MS = 10_000;
|
|
254
|
+
/** Relations pages one topic history read may walk (32 relations each). */
|
|
255
|
+
const TOPIC_HISTORY_MAX_PAGES = 16;
|
|
256
|
+
/** Decrypted bytes one topic history read may hand back over the stub. */
|
|
257
|
+
const TOPIC_HISTORY_MAX_BYTES = 2 * 1024 * 1024;
|
|
258
|
+
/** Milliseconds left before `deadlineAt`, at least one. */
|
|
259
|
+
function remaining(deadlineAt: number): number {
|
|
260
|
+
return Math.max(1, Math.min(TOPIC_READ_MS, deadlineAt - Date.now()));
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* `work`, or a rejection at `deadlineAt`. The work itself is not cancelled
|
|
264
|
+
* (the SDK's decrypt wait takes no signal); the caller just stops waiting.
|
|
265
|
+
*/
|
|
266
|
+
async function withinDeadline<T>(
|
|
267
|
+
work: Promise<T>,
|
|
268
|
+
deadlineAt: number,
|
|
269
|
+
): Promise<T> {
|
|
270
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
271
|
+
try {
|
|
272
|
+
return await Promise.race([
|
|
273
|
+
work,
|
|
274
|
+
new Promise<never>((_resolve, reject) => {
|
|
275
|
+
timer = setTimeout(
|
|
276
|
+
() => reject(new Error('Topic history verification timed out')),
|
|
277
|
+
Math.max(0, deadlineAt - Date.now()),
|
|
278
|
+
);
|
|
279
|
+
}),
|
|
280
|
+
]);
|
|
281
|
+
} finally {
|
|
282
|
+
if (timer) clearTimeout(timer);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
252
286
|
function sleep(ms: number): Promise<void> {
|
|
253
287
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
254
288
|
}
|
|
@@ -1194,6 +1228,148 @@ export class MatrixGatewayDO
|
|
|
1194
1228
|
return { roomId };
|
|
1195
1229
|
}
|
|
1196
1230
|
|
|
1231
|
+
async getTopicRoomState(roomId: string): Promise<JsonString> {
|
|
1232
|
+
const response = await this.fetchCapped<unknown>(
|
|
1233
|
+
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state`,
|
|
1234
|
+
{},
|
|
1235
|
+
{ capBytes: 524288, timeoutMs: 10000 },
|
|
1236
|
+
);
|
|
1237
|
+
if (response.oversized || !response.json)
|
|
1238
|
+
throw new Error('Topic room state exceeds verification bounds');
|
|
1239
|
+
return JSON.stringify(response.json);
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* One topic event: `/event` once, with its room binding and state key.
|
|
1243
|
+
* A plaintext event (every state event) is returned from that read; an
|
|
1244
|
+
* encrypted one needs the SDK's decrypting read (a second GET: its
|
|
1245
|
+
* decrypt API takes an event id, not an event). The decrypt wait is cut
|
|
1246
|
+
* at the caller's deadline.
|
|
1247
|
+
*/
|
|
1248
|
+
async getTopicEvent(
|
|
1249
|
+
roomId: string,
|
|
1250
|
+
eventId: string,
|
|
1251
|
+
opts: { deadlineAt?: number } = {},
|
|
1252
|
+
): Promise<JsonString | null> {
|
|
1253
|
+
const deadlineAt = Math.min(
|
|
1254
|
+
opts.deadlineAt ?? Infinity,
|
|
1255
|
+
Date.now() + TOPIC_READ_MS,
|
|
1256
|
+
);
|
|
1257
|
+
const raw = await this.fetchCapped<Record<string, unknown>>(
|
|
1258
|
+
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`,
|
|
1259
|
+
{},
|
|
1260
|
+
{ capBytes: 262144, timeoutMs: remaining(deadlineAt) },
|
|
1261
|
+
);
|
|
1262
|
+
if (raw.oversized || !raw.json)
|
|
1263
|
+
throw new Error('Topic event exceeds verification bounds');
|
|
1264
|
+
if (raw.json.room_id !== undefined && raw.json.room_id !== roomId)
|
|
1265
|
+
throw new Error('Topic event room binding mismatch');
|
|
1266
|
+
const stateKey =
|
|
1267
|
+
typeof raw.json.state_key === 'string'
|
|
1268
|
+
? { state_key: raw.json.state_key }
|
|
1269
|
+
: {};
|
|
1270
|
+
if (raw.json.type !== 'm.room.encrypted')
|
|
1271
|
+
return JSON.stringify({
|
|
1272
|
+
event_id: raw.json.event_id,
|
|
1273
|
+
type: raw.json.type,
|
|
1274
|
+
content: raw.json.content,
|
|
1275
|
+
sender: raw.json.sender,
|
|
1276
|
+
origin_server_ts: raw.json.origin_server_ts,
|
|
1277
|
+
...stateKey,
|
|
1278
|
+
room_id: roomId,
|
|
1279
|
+
});
|
|
1280
|
+
const decrypted = await withinDeadline(
|
|
1281
|
+
this.getEvent(roomId, eventId),
|
|
1282
|
+
deadlineAt,
|
|
1283
|
+
);
|
|
1284
|
+
if (!decrypted) return null;
|
|
1285
|
+
return JSON.stringify({
|
|
1286
|
+
...JSON.parse(decrypted),
|
|
1287
|
+
...stateKey,
|
|
1288
|
+
room_id: roomId,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* The `m.reference` relations of a topic root, decrypted. Only `sender`'s
|
|
1293
|
+
* relations are decrypted and counted (another member's cost nothing and
|
|
1294
|
+
* cannot fork or overflow the read); at most 256 of them and
|
|
1295
|
+
* `TOPIC_HISTORY_MAX_BYTES` decrypted, within the caller's deadline.
|
|
1296
|
+
*/
|
|
1297
|
+
async getTopicHistory(
|
|
1298
|
+
roomId: string,
|
|
1299
|
+
root: string,
|
|
1300
|
+
opts: { deadlineAt?: number; sender?: string } = {},
|
|
1301
|
+
): Promise<JsonString> {
|
|
1302
|
+
const events: unknown[] = [];
|
|
1303
|
+
let bytes = 0;
|
|
1304
|
+
const deadlineAt = Math.min(
|
|
1305
|
+
opts.deadlineAt ?? Infinity,
|
|
1306
|
+
Date.now() + TOPIC_READ_MS,
|
|
1307
|
+
);
|
|
1308
|
+
const timedOut = () => {
|
|
1309
|
+
if (Date.now() >= deadlineAt)
|
|
1310
|
+
throw new Error('Topic history verification timed out');
|
|
1311
|
+
};
|
|
1312
|
+
let from: string | undefined;
|
|
1313
|
+
for (let page = 0; page < TOPIC_HISTORY_MAX_PAGES; page++) {
|
|
1314
|
+
timedOut();
|
|
1315
|
+
const response = await this.fetchCapped<{
|
|
1316
|
+
chunk: {
|
|
1317
|
+
event_id?: string;
|
|
1318
|
+
room_id?: string;
|
|
1319
|
+
state_key?: string;
|
|
1320
|
+
sender?: string;
|
|
1321
|
+
}[];
|
|
1322
|
+
next_batch?: string;
|
|
1323
|
+
}>(
|
|
1324
|
+
`/_matrix/client/v1/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(root)}/m.reference`,
|
|
1325
|
+
{ limit: '32', ...(from ? { from } : {}) },
|
|
1326
|
+
{ capBytes: 524288, timeoutMs: remaining(deadlineAt) },
|
|
1327
|
+
);
|
|
1328
|
+
if (response.oversized || !response.json)
|
|
1329
|
+
throw new Error('Topic history exceeds verification bounds');
|
|
1330
|
+
// The relations page already carries each event's room, sender and
|
|
1331
|
+
// state key; only the decryption read is needed per kept event, eight
|
|
1332
|
+
// at a time, which keeps a 60-event topic inside the caller's budget
|
|
1333
|
+
// without flooding the homeserver from this one shared object.
|
|
1334
|
+
const chunk = response.json.chunk.filter(
|
|
1335
|
+
(event) => opts.sender === undefined || event.sender === opts.sender,
|
|
1336
|
+
);
|
|
1337
|
+
if (events.length + chunk.length > 256)
|
|
1338
|
+
throw new Error('Topic history exceeds 256 events');
|
|
1339
|
+
for (let i = 0; i < chunk.length; i += 8) {
|
|
1340
|
+
timedOut();
|
|
1341
|
+
const values = await Promise.all(
|
|
1342
|
+
chunk.slice(i, i + 8).map(async (event) => {
|
|
1343
|
+
if (!event.event_id)
|
|
1344
|
+
throw new Error('Topic history event has no ID');
|
|
1345
|
+
if (event.room_id !== undefined && event.room_id !== roomId)
|
|
1346
|
+
throw new Error('Topic history event room binding mismatch');
|
|
1347
|
+
timedOut();
|
|
1348
|
+
const decrypted = await withinDeadline(
|
|
1349
|
+
this.getEvent(roomId, event.event_id),
|
|
1350
|
+
deadlineAt,
|
|
1351
|
+
);
|
|
1352
|
+
if (!decrypted) throw new Error('Topic history is incomplete');
|
|
1353
|
+
bytes += decrypted.length;
|
|
1354
|
+
if (bytes > TOPIC_HISTORY_MAX_BYTES)
|
|
1355
|
+
throw new Error('Topic history exceeds verification bounds');
|
|
1356
|
+
return {
|
|
1357
|
+
...(JSON.parse(decrypted) as Record<string, unknown>),
|
|
1358
|
+
...(typeof event.state_key === 'string'
|
|
1359
|
+
? { state_key: event.state_key }
|
|
1360
|
+
: {}),
|
|
1361
|
+
room_id: roomId,
|
|
1362
|
+
};
|
|
1363
|
+
}),
|
|
1364
|
+
);
|
|
1365
|
+
events.push(...values);
|
|
1366
|
+
}
|
|
1367
|
+
if (!response.json.next_batch) return JSON.stringify(events);
|
|
1368
|
+
from = response.json.next_batch;
|
|
1369
|
+
}
|
|
1370
|
+
throw new Error('Topic history exceeds verification bounds');
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1197
1373
|
override async getRoomState(roomId: string): Promise<JsonString> {
|
|
1198
1374
|
const deadline = Date.now() + PENDING_INVITE_GRACE_MS;
|
|
1199
1375
|
for (;;) {
|
|
@@ -60,10 +60,16 @@ export function replyPartContent(part: ReplyPart): {
|
|
|
60
60
|
body: part.text,
|
|
61
61
|
formattedBody: renderRoomMarkdown(part.text),
|
|
62
62
|
};
|
|
63
|
-
const { title, url } =
|
|
63
|
+
const { title, url } =
|
|
64
|
+
part.kind === 'action'
|
|
65
|
+
? part.action
|
|
66
|
+
: part.kind === 'hub-action'
|
|
67
|
+
? { title: part.summary, url: part.actionReference.url }
|
|
68
|
+
: part.artifact;
|
|
69
|
+
const label = part.kind === 'artifact' ? 'Open document' : 'Review action';
|
|
64
70
|
return {
|
|
65
71
|
body: `${title}\n${url}`,
|
|
66
|
-
formattedBody: `<p><strong>${escapeHtml(title)}</strong><br><a href="${escapeHtml(url)}"
|
|
72
|
+
formattedBody: `<p><strong>${escapeHtml(title)}</strong><br><a href="${escapeHtml(url)}">${label}</a></p>`,
|
|
67
73
|
};
|
|
68
74
|
}
|
|
69
75
|
|