@ixo/oracle-runtime-workers 0.29.1 → 0.31.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.
Files changed (74) hide show
  1. package/package.json +7 -5
  2. package/src/attachments/channel-links.ts +135 -0
  3. package/src/attachments/channel.ts +203 -0
  4. package/src/attachments/download.ts +23 -6
  5. package/src/attachments/index.ts +23 -0
  6. package/src/attachments/pipeline.ts +120 -42
  7. package/src/attachments/retention.ts +11 -3
  8. package/src/attachments/sanitize.ts +20 -8
  9. package/src/attachments/types.ts +7 -0
  10. package/src/attachments/view.ts +24 -7
  11. package/src/channels/contract.ts +69 -0
  12. package/src/channels/turns.ts +23 -1
  13. package/src/core/main-agent-types.ts +2 -0
  14. package/src/core/runtime-context.ts +3 -0
  15. package/src/delivery/plan.ts +3 -1
  16. package/src/do/contracts.ts +3 -0
  17. package/src/do/turn-attachments.ts +103 -0
  18. package/src/do/user-oracle-do.ts +142 -42
  19. package/src/hub-actions/client.ts +87 -7
  20. package/src/hub-actions/hub-text.ts +82 -0
  21. package/src/hub-actions/request.ts +7 -4
  22. package/src/hub-actions/store.ts +5 -1
  23. package/src/hub-actions/transfer.ts +23 -6
  24. package/src/index.ts +1 -0
  25. package/src/plugin-api/index.ts +1 -0
  26. package/src/plugin-api/types.ts +43 -2
  27. package/src/plugins/attachments/view-attachment-tool.ts +7 -3
  28. package/src/plugins/hub-actions/hub-actions.plugin.ts +96 -17
  29. package/src/plugins/ixo-chain/balances.ts +141 -0
  30. package/src/plugins/ixo-chain/blocksync.ts +17 -8
  31. package/src/plugins/ixo-chain/denoms.ts +84 -36
  32. package/src/plugins/ixo-chain/http.ts +315 -70
  33. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
  34. package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
  35. package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
  36. package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
  37. package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
  38. package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
  39. package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
  40. package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
  41. package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
  42. package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
  43. package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
  44. package/src/plugins/ixo-chain/kinds/core.ts +246 -0
  45. package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
  46. package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
  47. package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
  48. package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
  49. package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
  50. package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
  51. package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
  52. package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
  53. package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
  54. package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
  55. package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
  56. package/src/plugins/ixo-chain/kinds/types.ts +156 -0
  57. package/src/plugins/ixo-chain/lookup.ts +470 -0
  58. package/src/plugins/ixo-chain/overview.ts +1060 -0
  59. package/src/plugins/ixo-chain/queries.ts +170 -138
  60. package/src/plugins/ixo-chain/rest.ts +135 -54
  61. package/src/plugins/ixo-chain/summaries.ts +3 -2
  62. package/src/plugins/yoma/catalog.ts +780 -0
  63. package/src/plugins/yoma/clef.ts +98 -0
  64. package/src/plugins/yoma/drafts.ts +339 -0
  65. package/src/plugins/yoma/engine.ts +926 -0
  66. package/src/plugins/yoma/index.ts +13 -0
  67. package/src/plugins/yoma/judge.ts +358 -0
  68. package/src/plugins/yoma/permission.ts +62 -0
  69. package/src/plugins/yoma/reads.ts +195 -0
  70. package/src/plugins/yoma/submission.ts +534 -0
  71. package/src/plugins/yoma/text.ts +16 -0
  72. package/src/plugins/yoma/work.ts +190 -0
  73. package/src/plugins/yoma/yoma.plugin.ts +2162 -0
  74. package/src/sqlite/serialization.ts +2 -0
@@ -0,0 +1,470 @@
1
+ /**
2
+ * The `chain_lookup` executor. Every request is planned first; one its kind
3
+ * refuses answers `invalid_input` on its own and is never read. The
4
+ * Blocksync plans of a call become ONE GraphQL document (aliases `r0…r7`
5
+ * by request position); the chain plans run their REST reads and denom
6
+ * lookups at once. Every read takes one of the user's permits
7
+ * (`USER_READ_PERMITS` in `http.ts`, shared by all of the user's calls), so
8
+ * a call never has more in flight; all of it runs inside the call's 20 s
9
+ * budget and the turn's abort. Each request answers on its own, in request
10
+ * order; the answer is cut to {@link MAX_RESULT_CHARS}.
11
+ */
12
+ import type { z } from 'zod';
13
+ import type { UserIdentity } from '../../core/user-identity';
14
+ import {
15
+ blocksyncLookup,
16
+ STATEMENT_TIMEOUT,
17
+ TRANSIENT_ERROR,
18
+ type AliasedField,
19
+ type BlocksyncAnswer,
20
+ type GraphqlError,
21
+ } from './blocksync';
22
+ import {
23
+ denomDisplay,
24
+ knownDisplay,
25
+ unreadDisplay,
26
+ type IbcDenomTable,
27
+ type TokenDisplay,
28
+ } from './denoms';
29
+ import {
30
+ throwIfAborted,
31
+ tooLarge,
32
+ unavailable,
33
+ type ChainCall,
34
+ type Failure,
35
+ type Outcome,
36
+ type Source,
37
+ } from './http';
38
+ import type {
39
+ KindContext,
40
+ KindDef,
41
+ KindPlan,
42
+ KindRestOptions,
43
+ KindResult,
44
+ } from './kinds/types';
45
+ import { firstOf, type Subject } from './queries';
46
+ import { restJson, type Absent } from './rest';
47
+ import { isRecord } from './summaries';
48
+
49
+ /** Largest answer handed to the model, in JSON characters. */
50
+ export const MAX_RESULT_CHARS = 12_000;
51
+ /**
52
+ * Distinct denoms one request looks up to show its bank coins; any further
53
+ * denom is shown unread (base units, `metadataUnavailable`).
54
+ */
55
+ export const MAX_DISPLAY_DENOMS = 20;
56
+
57
+ /** One request: its kind and the arguments the kind validates. */
58
+ export interface KindRequest {
59
+ kind: KindDef;
60
+ args?: unknown;
61
+ /**
62
+ * The rows a list kind reads (1–20), used as given instead of the
63
+ * request's `args.first` scaled for the call.
64
+ */
65
+ first?: number;
66
+ }
67
+
68
+ /** What a call's requests are read with besides the call itself. */
69
+ export interface LookupOptions {
70
+ /**
71
+ * The IBC table bank coins are shown with: the network's built-in
72
+ * entries with the operator's `IXO_CHAIN_IBC_DENOMS` merged over them.
73
+ */
74
+ ibcDenoms: IbcDenomTable;
75
+ }
76
+
77
+ export type LookupResult =
78
+ | {
79
+ kind: string;
80
+ source: Source;
81
+ ok: true;
82
+ subject?: Subject;
83
+ first?: number;
84
+ data: unknown;
85
+ truncated?: boolean;
86
+ }
87
+ | ({ kind: string; source: Source; ok: false } & Failure);
88
+
89
+ const LEFT_OUT = tooLarge(
90
+ 'Left out to keep the answer small; ask for it on its own.',
91
+ );
92
+ const NO_ANSWER = unavailable('blocksync', 'no answer for this request', true);
93
+ const NO_BLOCKSYNC = unavailable(
94
+ 'blocksync',
95
+ 'no valid BLOCKSYNC_GRAPHQL_URL is configured',
96
+ false,
97
+ );
98
+ const KIND_FAILED: Failure = {
99
+ reason: 'unavailable',
100
+ detail: 'This request failed inside the oracle; asking again will not help.',
101
+ retryable: false,
102
+ };
103
+
104
+ /* ── contexts ─────────────────────────────────────────────────────────── */
105
+
106
+ /**
107
+ * The kind contexts of one call. A REST read is `restJson` over the call
108
+ * (each attempt under one of the user's permits); a denom that needs a
109
+ * lookup is looked up once per call (and through `denoms.ts`'s per-isolate
110
+ * cache), and each request looks up at most {@link MAX_DISPLAY_DENOMS}
111
+ * distinct denoms. Returns each request's context for the rows it reads.
112
+ */
113
+ export function callContexts(
114
+ call: ChainCall,
115
+ identity: UserIdentity,
116
+ options: LookupOptions,
117
+ ): (first: number) => KindContext {
118
+ function rest<S extends z.ZodType>(
119
+ path: string,
120
+ schema: S,
121
+ opts: KindRestOptions & { noneOn?: undefined },
122
+ ): Promise<Outcome<z.infer<S>>>;
123
+ function rest<S extends z.ZodType>(
124
+ path: string,
125
+ schema: S,
126
+ opts: KindRestOptions & { noneOn: readonly number[] },
127
+ ): Promise<Outcome<z.infer<S>> | Absent>;
128
+ function rest<S extends z.ZodType>(
129
+ path: string,
130
+ schema: S,
131
+ opts: KindRestOptions,
132
+ ): Promise<Outcome<z.infer<S>> | Absent> {
133
+ return restJson(call, path, schema, opts);
134
+ }
135
+ const lookups = new Map<string, Promise<TokenDisplay>>();
136
+ const lookUp = (denom: string): Promise<TokenDisplay> => {
137
+ const running = lookups.get(denom);
138
+ if (running) return running;
139
+ const pending = denomDisplay(call, denom, options.ibcDenoms);
140
+ lookups.set(denom, pending);
141
+ return pending;
142
+ };
143
+ return (first) => {
144
+ const asked = new Set<string>();
145
+ return {
146
+ call,
147
+ identity,
148
+ first,
149
+ rest,
150
+ display(denom) {
151
+ const known = knownDisplay(denom, options.ibcDenoms);
152
+ if (known) return Promise.resolve(known);
153
+ if (!asked.has(denom)) {
154
+ if (asked.size >= MAX_DISPLAY_DENOMS)
155
+ return Promise.resolve(unreadDisplay(denom));
156
+ asked.add(denom);
157
+ }
158
+ return lookUp(denom);
159
+ },
160
+ };
161
+ };
162
+ }
163
+
164
+ /** The request's own `first` when it is a whole number of at least 1 (its kind's schema checks the rest). */
165
+ function requestedFirst(args: unknown): number | undefined {
166
+ const value = isRecord(args) ? args.first : undefined;
167
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1
168
+ ? value
169
+ : undefined;
170
+ }
171
+
172
+ /* ── one request ──────────────────────────────────────────────────────── */
173
+
174
+ /**
175
+ * A kind that throws for anything but the turn's abort has a bug: its
176
+ * request fails on its own (logged with the kind and the error's class
177
+ * only: a message may carry request data) and the others still answer.
178
+ */
179
+ function kindFailed(call: ChainCall, kind: string, error: unknown): KindResult {
180
+ throwIfAborted(call.signal);
181
+ // eslint-disable-next-line no-console -- console IS the logger on Workers
182
+ console.error(
183
+ `[ixo-chain] kind ${kind} threw: ${error instanceof Error ? error.name : 'non-error value'}`,
184
+ );
185
+ return { ok: false, ...KIND_FAILED };
186
+ }
187
+
188
+ type BlocksyncPlan = Extract<KindPlan, { source: 'blocksync' }>;
189
+ type ChainPlan = Extract<KindPlan, { source: 'chain' }>;
190
+
191
+ /** A request as its plan (and, for Blocksync, its document field), or its refusal. */
192
+ type Planned =
193
+ | { plan: ChainPlan }
194
+ | { plan: BlocksyncPlan; field: AliasedField }
195
+ | { refused: KindResult };
196
+
197
+ function planOf(
198
+ call: ChainCall,
199
+ request: KindRequest,
200
+ ctx: KindContext,
201
+ alias: string,
202
+ ): Planned {
203
+ try {
204
+ const plan = request.kind.plan(request.args, ctx);
205
+ if ('invalid' in plan)
206
+ return {
207
+ refused: {
208
+ ok: false,
209
+ reason: 'invalid_input',
210
+ detail: plan.invalid,
211
+ retryable: false,
212
+ },
213
+ };
214
+ if (plan.source === 'chain') return { plan };
215
+ const field = plan.field(alias);
216
+ // A field under another alias would break every request of the document.
217
+ if (!field.startsWith(`${alias}: `))
218
+ throw new Error(`its field is not aliased ${alias}`);
219
+ return { plan, field: { alias, field } };
220
+ } catch (error) {
221
+ return { refused: kindFailed(call, request.kind.name, error) };
222
+ }
223
+ }
224
+
225
+ async function settle(
226
+ call: ChainCall,
227
+ kind: string,
228
+ answer: () => KindResult | Promise<KindResult>,
229
+ ): Promise<KindResult> {
230
+ try {
231
+ return await answer();
232
+ } catch (error) {
233
+ return kindFailed(call, kind, error);
234
+ }
235
+ }
236
+
237
+ /** The failure the document's errors give one alias, if any. */
238
+ function aliasFailure(alias: string, errors: GraphqlError[]): Failure | null {
239
+ const mine = errors.filter(
240
+ (error) => error.path.length === 0 || error.path[0] === alias,
241
+ );
242
+ if (!mine.length) return null;
243
+ if (mine.some((error) => STATEMENT_TIMEOUT.test(error.message)))
244
+ return {
245
+ reason: 'index_timeout',
246
+ detail:
247
+ 'The index could not answer in time; ask for fewer rows or a narrower filter.',
248
+ retryable: false,
249
+ };
250
+ if (mine.some((error) => TRANSIENT_ERROR.test(error.message)))
251
+ return unavailable('blocksync', 'a transient database error', true);
252
+ const message = mine[0]?.message ?? '';
253
+ return {
254
+ reason: 'unavailable',
255
+ detail: `Blocksync could not answer this request: ${message.length > 160 ? `${message.slice(0, 159)}…` : message}`,
256
+ retryable: false,
257
+ };
258
+ }
259
+
260
+ /** One alias's answer from the document, or why it has none. */
261
+ function aliasAnswer(
262
+ read: Outcome<BlocksyncAnswer>,
263
+ alias: string,
264
+ plan: BlocksyncPlan,
265
+ ): KindResult | Promise<KindResult> {
266
+ if (!read.ok) return { ok: false, ...read.failure };
267
+ const failure = aliasFailure(alias, read.value.errors);
268
+ if (failure) return { ok: false, ...failure };
269
+ // An alias the answer does not carry was not answered, which says
270
+ // nothing about whether the record exists.
271
+ const { data } = read.value;
272
+ if (!data || !(alias in data)) return { ok: false, ...NO_ANSWER };
273
+ return plan.shape(data[alias]);
274
+ }
275
+
276
+ function resultOf(
277
+ kind: string,
278
+ source: Source,
279
+ result: KindResult,
280
+ ): LookupResult {
281
+ if (!result.ok)
282
+ return {
283
+ kind,
284
+ source,
285
+ ok: false,
286
+ reason: result.reason,
287
+ detail: result.detail,
288
+ retryable: result.retryable,
289
+ };
290
+ return {
291
+ kind,
292
+ source,
293
+ ok: true,
294
+ ...(result.subject ? { subject: result.subject } : {}),
295
+ ...(result.first !== undefined ? { first: result.first } : {}),
296
+ data: result.data,
297
+ };
298
+ }
299
+
300
+ /** The alias of the request at `index`. */
301
+ const aliasOf = (index: number) => `r${index}`;
302
+
303
+ /**
304
+ * Every request's answer, in request order. Only the turn's abort is
305
+ * thrown.
306
+ */
307
+ export async function runRequests(
308
+ call: ChainCall,
309
+ identity: UserIdentity,
310
+ requests: readonly KindRequest[],
311
+ options: LookupOptions,
312
+ ): Promise<LookupResult[]> {
313
+ const contextFor = callContexts(call, identity, options);
314
+ const planned = requests.map((request, index) => ({
315
+ kind: request.kind,
316
+ planned: planOf(
317
+ call,
318
+ request,
319
+ contextFor(
320
+ request.first ?? firstOf(requestedFirst(request.args), requests.length),
321
+ ),
322
+ aliasOf(index),
323
+ ),
324
+ }));
325
+ const fields = planned.flatMap(({ planned: entry }) =>
326
+ 'field' in entry ? [entry.field] : [],
327
+ );
328
+ const document: Promise<Outcome<BlocksyncAnswer>> = !fields.length
329
+ ? Promise.resolve({ ok: true, value: { data: null, errors: [] } })
330
+ : call.blocksyncUrl
331
+ ? blocksyncLookup(call, fields)
332
+ : Promise.resolve({ ok: false, failure: NO_BLOCKSYNC });
333
+ return Promise.all(
334
+ planned.map(async ({ kind, planned: entry }) => {
335
+ if ('refused' in entry)
336
+ return resultOf(kind.name, kind.source, entry.refused);
337
+ if (!('field' in entry)) {
338
+ const { plan } = entry;
339
+ return resultOf(
340
+ kind.name,
341
+ 'chain',
342
+ await settle(call, kind.name, () => plan.run()),
343
+ );
344
+ }
345
+ const { plan, field } = entry;
346
+ const read = await document;
347
+ return resultOf(
348
+ kind.name,
349
+ 'blocksync',
350
+ await settle(call, kind.name, () =>
351
+ aliasAnswer(read, field.alias, plan),
352
+ ),
353
+ );
354
+ }),
355
+ );
356
+ }
357
+
358
+ /* ── size ─────────────────────────────────────────────────────────────── */
359
+
360
+ const size = (value: unknown) => JSON.stringify(value).length;
361
+
362
+ /** The largest array (by JSON size) with more than one element inside `value`. */
363
+ function largestArray(
364
+ value: unknown,
365
+ ): { list: unknown[]; size: number } | null {
366
+ let best: { list: unknown[]; size: number } | null = null;
367
+ const visit = (node: unknown) => {
368
+ if (Array.isArray(node)) {
369
+ if (node.length > 1) {
370
+ const bytes = size(node);
371
+ if (!best || bytes > best.size) best = { list: node, size: bytes };
372
+ }
373
+ node.forEach(visit);
374
+ } else if (isRecord(node)) Object.values(node).forEach(visit);
375
+ };
376
+ visit(value);
377
+ return best;
378
+ }
379
+
380
+ /**
381
+ * Halves the largest list inside `within` (keeping its first rows) until
382
+ * `holder` serialises within `budget` or no list is left to halve; marks
383
+ * `holder` `truncated` when anything was cut.
384
+ */
385
+ export function shrink(
386
+ holder: { truncated?: boolean },
387
+ within: unknown,
388
+ budget: number,
389
+ ): void {
390
+ while (size(holder) > budget) {
391
+ const found = largestArray(within);
392
+ if (!found) return;
393
+ found.list.length = Math.ceil(found.list.length / 2);
394
+ holder.truncated = true;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Fits `parts` (the results inside `whole`) into what
400
+ * {@link MAX_RESULT_CHARS} leaves after the envelope: each part gets an
401
+ * equal share, smallest first, and a share a part does not use passes to
402
+ * the larger parts after it. A cut re-measures only its own part.
403
+ */
404
+ function shareOut(
405
+ whole: unknown,
406
+ parts: { data: unknown; truncated?: boolean }[],
407
+ ): void {
408
+ const sizes = new Map(parts.map((part) => [part, size(part)]));
409
+ let left =
410
+ MAX_RESULT_CHARS -
411
+ (size(whole) - [...sizes.values()].reduce((sum, n) => sum + n, 0));
412
+ const bySize = [...parts].sort(
413
+ (a, b) => (sizes.get(a) ?? 0) - (sizes.get(b) ?? 0),
414
+ );
415
+ bySize.forEach((part, index) => {
416
+ shrink(part, part.data, Math.floor(left / (bySize.length - index)));
417
+ left -= size(part);
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Cuts `results` (inside `whole`) until `whole` fits
423
+ * {@link MAX_RESULT_CHARS}: lists are halved within each result's share;
424
+ * a result that cannot be cut to its share leaves the largest answers out,
425
+ * one at a time, as `too_large`.
426
+ */
427
+ export function fitResults(whole: unknown, results: LookupResult[]): void {
428
+ shareOut(
429
+ whole,
430
+ results.flatMap((result) => (result.ok ? [result] : [])),
431
+ );
432
+ while (size(whole) > MAX_RESULT_CHARS) {
433
+ let largest = -1;
434
+ results.forEach((result, index) => {
435
+ if (result.ok && (largest < 0 || size(result) > size(results[largest])))
436
+ largest = index;
437
+ });
438
+ const evicted = results[largest];
439
+ if (!evicted) break;
440
+ results[largest] = {
441
+ kind: evicted.kind,
442
+ source: evicted.source,
443
+ ok: false,
444
+ ...LEFT_OUT,
445
+ };
446
+ }
447
+ }
448
+
449
+ /** `chain_lookup`'s answer: every request's result in order, cut to size. */
450
+ export async function chainLookup(
451
+ call: ChainCall,
452
+ identity: UserIdentity,
453
+ requests: readonly KindRequest[],
454
+ options: LookupOptions,
455
+ ): Promise<{
456
+ ok: true;
457
+ network: ChainCall['network'];
458
+ fetchedAt: string;
459
+ results: LookupResult[];
460
+ }> {
461
+ const results = await runRequests(call, identity, requests, options);
462
+ const whole = {
463
+ ok: true as const,
464
+ network: call.network,
465
+ fetchedAt: new Date().toISOString(),
466
+ results,
467
+ };
468
+ fitResults(whole, results);
469
+ return whole;
470
+ }