@ixo/oracle-runtime-workers 0.30.0 → 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 (44) 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/blocksync.ts +6 -2
  30. package/src/plugins/ixo-chain/http.ts +16 -8
  31. package/src/plugins/ixo-chain/rest.ts +7 -1
  32. package/src/plugins/yoma/catalog.ts +780 -0
  33. package/src/plugins/yoma/clef.ts +98 -0
  34. package/src/plugins/yoma/drafts.ts +339 -0
  35. package/src/plugins/yoma/engine.ts +926 -0
  36. package/src/plugins/yoma/index.ts +13 -0
  37. package/src/plugins/yoma/judge.ts +358 -0
  38. package/src/plugins/yoma/permission.ts +62 -0
  39. package/src/plugins/yoma/reads.ts +195 -0
  40. package/src/plugins/yoma/submission.ts +534 -0
  41. package/src/plugins/yoma/text.ts +16 -0
  42. package/src/plugins/yoma/work.ts +190 -0
  43. package/src/plugins/yoma/yoma.plugin.ts +2162 -0
  44. package/src/sqlite/serialization.ts +2 -0
@@ -0,0 +1,13 @@
1
+ export {
2
+ YomaPlugin,
3
+ type InstantPermission,
4
+ type YomaPluginOptions,
5
+ } from './yoma.plugin';
6
+ export {
7
+ CLEF_MODEL,
8
+ workersAiClefRunner,
9
+ type ClefRequest,
10
+ type ClefResponse,
11
+ type ClefRunner,
12
+ type WorkersAiBinding,
13
+ } from './clef';
@@ -0,0 +1,358 @@
1
+ import { choiceOf, noulOf, type ClefRequest, type ClefRunner } from './clef';
2
+
3
+ /**
4
+ * What a person's reply to a choice question means: which option, or
5
+ * options, it picks, or that it is unclear. Clef decides when the host gave
6
+ * a runner; the chat model never picks the option itself. Ported from the
7
+ * Yoma oracle's survey judge (genu-survey-oracle `src/survey/judge.ts`) with
8
+ * wording that fits any form.
9
+ *
10
+ * Without Clef, or when Clef fails, only an option number ("2") or the exact
11
+ * option text counts; anything else is unclear, so the model asks again
12
+ * with the numbered options.
13
+ */
14
+
15
+ /** At or above this, a choice is taken as the person's answer. */
16
+ export const ACCEPT_THRESHOLD = 0.7;
17
+ /** Between this and the accept threshold, a checkbox option is asked about. */
18
+ export const MAYBE_THRESHOLD = 0.3;
19
+ /** At or above this, a write-in ("Other") reads as a genuine answer. */
20
+ export const GENUINE_THRESHOLD = 0.6;
21
+ /** Longest wait for Clef: past it the reply is read literally (a chat turn must not hang on it). */
22
+ export const CLEF_TIMEOUT_MS = 4_500;
23
+
24
+ export interface ChoiceQuestion {
25
+ title: string;
26
+ description?: string;
27
+ /** Checkbox (pick one or more) rather than single choice. */
28
+ multiple: boolean;
29
+ /** The options as shown and numbered, in form order; special items ("None", "Don't know") last. */
30
+ options: string[];
31
+ /** Checkbox options picked only on their own ("None", "Refuse to answer", "Don't know"), by index. */
32
+ exclusive?: number[];
33
+ otherAllowed: boolean;
34
+ }
35
+
36
+ export type Judgement =
37
+ | {
38
+ status: 'accepted';
39
+ /** Indexes into the options, in form order; a write-in is `otherText`. */
40
+ picked: number[];
41
+ /** The person's own answer, when it is none of the options. */
42
+ otherText?: string;
43
+ /** How the answer was read. */
44
+ by: 'clef' | 'number' | 'text';
45
+ }
46
+ | {
47
+ status: 'unclear';
48
+ /** What to clear up, in plain words for the chat model. */
49
+ reason: string;
50
+ /** The options Clef leaned towards, most likely first. */
51
+ candidates?: string[];
52
+ };
53
+
54
+ const optionKey = (index: number) => `option_${index + 1}`;
55
+
56
+ const UNCLEAR_CRITERION =
57
+ 'The reply does not clearly pick one answer: it is off-topic, a question back, a refusal, a joke, or it fits two options equally.';
58
+
59
+ function questionState(q: ChoiceQuestion, reply: string, otherText?: string) {
60
+ return {
61
+ question: q.title,
62
+ ...(q.description ? { hint: q.description } : {}),
63
+ optionsShownToThePerson: q.options.map((o, i) => `${i + 1}. ${o}`),
64
+ reply,
65
+ ...(otherText ? { writeIn: otherText } : {}),
66
+ howPeopleAnswer:
67
+ 'The person answers in a chat. They may reply with an option number, with words from an option, in their own words, in slang, or in another language.',
68
+ };
69
+ }
70
+
71
+ /** The Clef request for a single-choice reply. Exported for tests. */
72
+ export function singleChoiceRequest(
73
+ q: ChoiceQuestion,
74
+ reply: string,
75
+ otherText?: string,
76
+ ): ClefRequest {
77
+ const criteria: Record<string, string> = {};
78
+ q.options.forEach((option, index) => {
79
+ criteria[optionKey(index)] = `Option ${index + 1}: "${option}"`;
80
+ });
81
+ if (q.otherAllowed)
82
+ criteria.other =
83
+ 'A different answer of their own that matches none of the listed options.';
84
+ criteria.unclear = UNCLEAR_CRITERION;
85
+ return {
86
+ model: 'clef',
87
+ state: questionState(q, reply, otherText),
88
+ questions: {
89
+ answer: {
90
+ type: 'choice',
91
+ instructions: `Which answer to "${q.title}" does the person's reply give?`,
92
+ criteria,
93
+ },
94
+ ...(q.otherAllowed
95
+ ? {
96
+ genuine_write_in: {
97
+ type: 'noul' as const,
98
+ instructions:
99
+ 'If the person gives an answer of their own instead of a listed option, is it a genuine, on-topic answer to the question (not junk, abuse or a joke)?',
100
+ },
101
+ }
102
+ : {}),
103
+ },
104
+ };
105
+ }
106
+
107
+ /** The Clef request for a checkbox reply: one yes/no per option. Exported for tests. */
108
+ export function multiChoiceRequest(
109
+ q: ChoiceQuestion,
110
+ reply: string,
111
+ otherText?: string,
112
+ ): ClefRequest {
113
+ const questions: ClefRequest['questions'] = {};
114
+ q.options.forEach((option, index) => {
115
+ questions[optionKey(index)] = {
116
+ type: 'noul',
117
+ instructions: `Does the reply say the person picks option ${index + 1}, "${option}"?`,
118
+ };
119
+ });
120
+ if (q.otherAllowed) {
121
+ questions.other = {
122
+ type: 'noul',
123
+ instructions:
124
+ 'Does the reply name something of their own that is not in the list?',
125
+ };
126
+ questions.genuine_write_in = {
127
+ type: 'noul',
128
+ instructions:
129
+ 'If the reply names something of their own, is it a genuine, on-topic answer to the question (not junk, abuse or a joke)?',
130
+ };
131
+ }
132
+ return {
133
+ model: 'clef',
134
+ state: questionState(q, reply, otherText),
135
+ questions,
136
+ };
137
+ }
138
+
139
+ const ranked = (probabilities: Record<string, number>) =>
140
+ Object.entries(probabilities).sort((a, b) => b[1] - a[1]);
141
+
142
+ function judgeSingle(
143
+ q: ChoiceQuestion,
144
+ response: Awaited<ReturnType<ClefRunner>>,
145
+ reply: string,
146
+ otherText?: string,
147
+ ): Judgement {
148
+ const answer = choiceOf(response, 'answer');
149
+ const order = ranked(answer.probabilities);
150
+ const [topKey, topP] = order[0] ?? [answer.choice, 0];
151
+ const indexOf = (key: string) => Number(key.slice('option_'.length)) - 1;
152
+ const candidates = order
153
+ .filter(([key]) => key.startsWith('option_'))
154
+ .slice(0, 2)
155
+ .map(([key]) => q.options[indexOf(key)])
156
+ .filter((o): o is string => o !== undefined);
157
+ if (topKey.startsWith('option_') && topP >= ACCEPT_THRESHOLD) {
158
+ const index = indexOf(topKey);
159
+ if (q.options[index] !== undefined)
160
+ return { status: 'accepted', picked: [index], by: 'clef' };
161
+ }
162
+ if (topKey === 'other' && topP >= ACCEPT_THRESHOLD && q.otherAllowed) {
163
+ if (noulOf(response, 'genuine_write_in').noul >= GENUINE_THRESHOLD)
164
+ return {
165
+ status: 'accepted',
166
+ picked: [],
167
+ otherText: (otherText ?? reply).trim(),
168
+ by: 'clef',
169
+ };
170
+ return {
171
+ status: 'unclear',
172
+ reason:
173
+ 'Their own answer did not read as a genuine answer to this question. Ask again, or offer the options.',
174
+ };
175
+ }
176
+ return {
177
+ status: 'unclear',
178
+ reason:
179
+ topKey === 'unclear'
180
+ ? 'The reply does not clearly answer this question. Ask it again in simpler words.'
181
+ : 'The reply could mean more than one option. Ask which one they mean.',
182
+ ...(candidates.length ? { candidates } : {}),
183
+ };
184
+ }
185
+
186
+ function judgeMulti(
187
+ q: ChoiceQuestion,
188
+ response: Awaited<ReturnType<ClefRunner>>,
189
+ otherText?: string,
190
+ ): Judgement {
191
+ const picked: number[] = [];
192
+ const maybe: string[] = [];
193
+ q.options.forEach((option, index) => {
194
+ const p = noulOf(response, optionKey(index)).noul;
195
+ if (p >= ACCEPT_THRESHOLD) picked.push(index);
196
+ else if (p >= MAYBE_THRESHOLD) maybe.push(option);
197
+ });
198
+ const namesOther = q.otherAllowed
199
+ ? noulOf(response, 'other').noul >= ACCEPT_THRESHOLD
200
+ : false;
201
+ if (maybe.length)
202
+ return {
203
+ status: 'unclear',
204
+ reason:
205
+ 'Some options were mentioned but not clearly picked. Ask about these.',
206
+ candidates: maybe,
207
+ };
208
+ const alone = aloneConflict(q, picked, namesOther);
209
+ if (alone) return alone;
210
+ if (namesOther) {
211
+ if (!otherText?.trim())
212
+ return {
213
+ status: 'unclear',
214
+ reason:
215
+ 'They named something not in the list. Ask them to name it, then pass it as otherText.',
216
+ };
217
+ if (noulOf(response, 'genuine_write_in').noul < GENUINE_THRESHOLD)
218
+ return {
219
+ status: 'unclear',
220
+ reason:
221
+ 'What they wrote in did not read as a genuine answer. Ask them to name it again, or to leave it out.',
222
+ };
223
+ return {
224
+ status: 'accepted',
225
+ picked,
226
+ otherText: otherText.trim(),
227
+ by: 'clef',
228
+ };
229
+ }
230
+ if (!picked.length)
231
+ return {
232
+ status: 'unclear',
233
+ reason:
234
+ 'No option was clearly picked. Ask again; they can pick more than one.',
235
+ };
236
+ return { status: 'accepted', picked, by: 'clef' };
237
+ }
238
+
239
+ /** A checkbox pick that combines an only-on-its-own option ("None") with others. */
240
+ function aloneConflict(
241
+ q: ChoiceQuestion,
242
+ picked: number[],
243
+ withOther = false,
244
+ ): Judgement | undefined {
245
+ const exclusive = picked.find((index) => q.exclusive?.includes(index));
246
+ if (exclusive === undefined || (picked.length === 1 && !withOther))
247
+ return undefined;
248
+ return {
249
+ status: 'unclear',
250
+ reason: `"${q.options[exclusive]}" goes on its own, but the reply also picks other options. Ask which is right.`,
251
+ candidates: picked.map((index) => q.options[index]!),
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Without Clef: the exact option text (any case), or option numbers ("2",
257
+ * "1, 3") by position. When options are themselves numbers ("1", "5", "10")
258
+ * a typed number is only ever read as that option's text, never as a
259
+ * position. Anything else is unclear.
260
+ */
261
+ export function judgeLiterally(q: ChoiceQuestion, reply: string): Judgement {
262
+ const norm = (text: string) => text.trim().toLowerCase();
263
+ const text = norm(reply);
264
+ const exact = q.options.findIndex((o) => norm(o) === text);
265
+ if (exact >= 0) return { status: 'accepted', picked: [exact], by: 'text' };
266
+ const tokens = text.split(/[\s,;&]+|\band\b/).filter(Boolean);
267
+ const numericOptions = q.options.some((o) =>
268
+ /^-?\d+([.,]\d+)?$/.test(norm(o)),
269
+ );
270
+ if (
271
+ tokens.length &&
272
+ tokens.every((n) => /^\d{1,3}$/.test(n)) &&
273
+ (q.multiple || tokens.length === 1)
274
+ ) {
275
+ const picked = tokens.map((n) =>
276
+ numericOptions
277
+ ? q.options.findIndex((o) => norm(o) === n)
278
+ : Number(n) - 1,
279
+ );
280
+ if (picked.every((index) => q.options[index] !== undefined)) {
281
+ const unique = [...new Set(picked)];
282
+ return (
283
+ aloneConflict(q, unique) ?? {
284
+ status: 'accepted',
285
+ picked: unique,
286
+ by: numericOptions ? 'text' : 'number',
287
+ }
288
+ );
289
+ }
290
+ }
291
+ return {
292
+ status: 'unclear',
293
+ reason:
294
+ 'Ask again with the options as a numbered list; they can answer with the number.',
295
+ };
296
+ }
297
+
298
+ /** Clef's answer, or `timeout` when the tool's deadline comes first. */
299
+ function withinDeadline<T>(
300
+ work: Promise<T>,
301
+ deadline: number,
302
+ signal: AbortSignal,
303
+ ): Promise<T | 'timeout'> {
304
+ const left = deadline - Date.now();
305
+ if (left <= 0) return Promise.resolve('timeout');
306
+ let timer: ReturnType<typeof setTimeout> | undefined;
307
+ let onAbort: (() => void) | undefined;
308
+ const timeout = new Promise<'timeout'>((resolve, reject) => {
309
+ timer = setTimeout(() => resolve('timeout'), left);
310
+ onAbort = () =>
311
+ reject(
312
+ signal.reason instanceof Error
313
+ ? signal.reason
314
+ : new DOMException('Aborted', 'AbortError'),
315
+ );
316
+ signal.addEventListener('abort', onAbort, { once: true });
317
+ });
318
+ return Promise.race([work, timeout]).finally(() => {
319
+ clearTimeout(timer);
320
+ if (onAbort) signal.removeEventListener('abort', onAbort);
321
+ });
322
+ }
323
+
324
+ /**
325
+ * What a reply means for one choice question. Clef first when the host
326
+ * gave a runner; an option number or the exact option text is taken
327
+ * without it. Clef is given {@link CLEF_TIMEOUT_MS} (less when the tool's
328
+ * deadline is nearer): a failure or a late answer reads the reply literally. Only the turn's own abort is rethrown.
329
+ */
330
+ export async function judgeChoice(
331
+ clef: ClefRunner | undefined,
332
+ q: ChoiceQuestion,
333
+ reply: string,
334
+ otherText: string | undefined,
335
+ limits: { signal: AbortSignal; deadline: number },
336
+ ): Promise<Judgement & { clefFailed?: true }> {
337
+ const literal = judgeLiterally(q, reply);
338
+ if (literal.status === 'accepted') return literal;
339
+ if (!clef) return literal;
340
+ try {
341
+ const response = await withinDeadline(
342
+ clef(
343
+ q.multiple
344
+ ? multiChoiceRequest(q, reply, otherText)
345
+ : singleChoiceRequest(q, reply, otherText),
346
+ ),
347
+ Math.min(limits.deadline, Date.now() + CLEF_TIMEOUT_MS),
348
+ limits.signal,
349
+ );
350
+ if (response === 'timeout') return { ...literal, clefFailed: true };
351
+ return q.multiple
352
+ ? judgeMulti(q, response, otherText)
353
+ : judgeSingle(q, response, reply, otherText);
354
+ } catch (error) {
355
+ if (limits.signal.aborted) throw error;
356
+ return { ...literal, clefFailed: true };
357
+ }
358
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Whether an account may log work on a collection: the collection's admin
3
+ * has given it a `SubmitClaimAuthorization` with quota left for that
4
+ * collection. Read from chain REST (`/cosmos/authz/v1beta1/grants`), whose
5
+ * answer for such a grant is
6
+ * `{ "@type": "/ixo.claims.v1beta1.SubmitClaimAuthorization", constraints: [{ collection_id, agent_quota, … }] }`
7
+ * (checked on mainnet, 2026-10-29).
8
+ */
9
+ import { IxoAddressSchema } from '@ixo/ixo-transaction';
10
+ import { z } from 'zod';
11
+ import type { ChainCall, Outcome } from '../ixo-chain/http';
12
+ import { restRead } from './reads';
13
+
14
+ const SUBMIT_AUTHORIZATION = '/ixo.claims.v1beta1.SubmitClaimAuthorization';
15
+
16
+ const grantsSchema = z.object({
17
+ grants: z
18
+ .array(
19
+ z.object({
20
+ authorization: z
21
+ .object({
22
+ '@type': z.string(),
23
+ constraints: z
24
+ .array(
25
+ z.object({
26
+ collection_id: z.string().optional(),
27
+ agent_quota: z.string().optional(),
28
+ }),
29
+ )
30
+ .optional(),
31
+ })
32
+ .passthrough(),
33
+ }),
34
+ )
35
+ .default([]),
36
+ });
37
+
38
+ export async function canLogWork(
39
+ call: ChainCall,
40
+ input: { address: string; admin: string; collectionId: string },
41
+ ): Promise<Outcome<boolean>> {
42
+ const address = IxoAddressSchema.safeParse(input.address);
43
+ const admin = IxoAddressSchema.safeParse(input.admin);
44
+ if (!address.success || !admin.success) return { ok: true, value: false };
45
+ const read = await restRead(
46
+ call,
47
+ `/cosmos/authz/v1beta1/grants?granter=${admin.data}&grantee=${address.data}&pagination.limit=50`,
48
+ grantsSchema,
49
+ 'authz grants',
50
+ );
51
+ if (!read.ok) return read;
52
+ const allowed = read.value.grants.some(
53
+ (grant) =>
54
+ grant.authorization['@type'] === SUBMIT_AUTHORIZATION &&
55
+ (grant.authorization.constraints ?? []).some(
56
+ (c) =>
57
+ c.collection_id === input.collectionId &&
58
+ Number(c.agent_quota ?? 0) > 0,
59
+ ),
60
+ );
61
+ return { ok: true, value: allowed };
62
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The Yoma plugin's upstream reads, all under ixo-chain's policy
3
+ * (`../ixo-chain/http.ts`): each attempt bounded in time and bytes, no
4
+ * redirects, one jittered retry on 429/5xx/network, unhealthy upstreams held
5
+ * back for 10 s, and a {@link Failure} instead of a throw.
6
+ *
7
+ * - the Jambo worker's job list (`job-list`): the entities Yoma admins
8
+ * chose and the collections they hid;
9
+ * - documents an entity links to (`document`): profiles and form
10
+ * templates, https only;
11
+ * - Blocksync (`../ixo-chain/blocksync.ts`): collections, entities, claims;
12
+ * - chain REST (`../ixo-chain/rest.ts`): authz grants.
13
+ */
14
+ import type { z } from 'zod';
15
+ import { blocksyncLookup, type AliasedField } from '../ixo-chain/blocksync';
16
+ import {
17
+ exchange,
18
+ unavailable,
19
+ withRetry,
20
+ type ChainCall,
21
+ type Failure,
22
+ type Outcome,
23
+ type Source,
24
+ } from '../ixo-chain/http';
25
+ import { restJson } from '../ixo-chain/rest';
26
+
27
+ export const LOG_TAG = '[yoma]';
28
+
29
+ /** Per-attempt wall bound for the job list and documents. */
30
+ export const DOCUMENT_ATTEMPT_TIMEOUT_MS = 8_000;
31
+ /** Largest job-list answer read. */
32
+ export const JOB_LIST_MAX_BYTES = 256 * 1024;
33
+ /** Largest entity profile read. */
34
+ export const PROFILE_MAX_BYTES = 64 * 1024;
35
+ /**
36
+ * Largest form template read: the largest Yoma form on mainnet is about
37
+ * 20 KB (the real forms in `fixtures/` are 1.9–22.4 KB), so 128 KiB leaves six times that
38
+ * while bounding what a hostile or broken document host can make each
39
+ * isolate parse and keep.
40
+ */
41
+ export const FORM_MAX_BYTES = 128 * 1024;
42
+
43
+ /**
44
+ * `GET url` as JSON parsed by `schema`, under the shared policy. `what`
45
+ * names the document in a `too_large` answer ("This form is too large to
46
+ * read here."). `healthPerUrl`: the unhealthy mark a 429/5xx sets holds back
47
+ * only this address, not its whole origin (the catalog build reads many
48
+ * addresses on one origin, each with its own retry).
49
+ */
50
+ export async function getJson<S extends z.ZodType>(
51
+ call: ChainCall,
52
+ source: Extract<Source, 'job-list' | 'document'>,
53
+ url: string,
54
+ schema: S,
55
+ opts: {
56
+ label: string;
57
+ maxBytes: number;
58
+ what: string;
59
+ notFoundOn404?: boolean;
60
+ healthPerUrl?: boolean;
61
+ },
62
+ ): Promise<Outcome<z.infer<S>>> {
63
+ const read = await getJsonOnce(call, source, url, schema, opts);
64
+ if (read.ok || read.failure.reason !== 'too_large') return read;
65
+ return {
66
+ ok: false,
67
+ failure: {
68
+ reason: 'too_large',
69
+ detail: `${opts.what} is too large to read here; it can still be opened on jambo.yoma.world.`,
70
+ retryable: false,
71
+ },
72
+ };
73
+ }
74
+
75
+ function getJsonOnce<S extends z.ZodType>(
76
+ call: ChainCall,
77
+ source: Extract<Source, 'job-list' | 'document'>,
78
+ url: string,
79
+ schema: S,
80
+ opts: {
81
+ label: string;
82
+ maxBytes: number;
83
+ notFoundOn404?: boolean;
84
+ healthPerUrl?: boolean;
85
+ },
86
+ ): Promise<Outcome<z.infer<S>>> {
87
+ let upstream: string;
88
+ try {
89
+ const parsed = new URL(url);
90
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password)
91
+ return Promise.resolve({
92
+ ok: false,
93
+ failure: unavailable(
94
+ source,
95
+ 'its address is not a plain https URL',
96
+ false,
97
+ ),
98
+ });
99
+ upstream = opts.healthPerUrl ? parsed.href : parsed.origin;
100
+ } catch {
101
+ return Promise.resolve({
102
+ ok: false,
103
+ failure: unavailable(source, 'its address is not a URL', false),
104
+ });
105
+ }
106
+ return withRetry(
107
+ call,
108
+ { label: opts.label, upstream, tag: LOG_TAG },
109
+ async () => {
110
+ const answer = await exchange(
111
+ call,
112
+ source,
113
+ url,
114
+ { method: 'GET', headers: { accept: 'application/json' } },
115
+ {
116
+ timeoutMs: DOCUMENT_ATTEMPT_TIMEOUT_MS,
117
+ maxBytes: opts.maxBytes,
118
+ notFoundOn404: opts.notFoundOn404 ?? false,
119
+ },
120
+ );
121
+ if (!answer.ok) return answer;
122
+ let body: unknown;
123
+ try {
124
+ body = JSON.parse(answer.value);
125
+ } catch {
126
+ return {
127
+ ok: false,
128
+ failure: unavailable(
129
+ source,
130
+ 'it answered something that is not JSON',
131
+ false,
132
+ ),
133
+ retry: false,
134
+ note: 'malformed JSON',
135
+ };
136
+ }
137
+ const parsed = schema.safeParse(body);
138
+ if (!parsed.success)
139
+ return {
140
+ ok: false,
141
+ failure: unavailable(
142
+ source,
143
+ 'it answered in an unexpected shape',
144
+ false,
145
+ ),
146
+ retry: false,
147
+ note: 'unexpected shape',
148
+ };
149
+ return { ok: true, value: parsed.data };
150
+ },
151
+ );
152
+ }
153
+
154
+ /** One Blocksync document; a field error is the whole read's failure here (Yoma asks few, related fields). */
155
+ export async function blocksyncRead(
156
+ call: ChainCall,
157
+ fields: readonly AliasedField[],
158
+ ): Promise<Outcome<Record<string, unknown>>> {
159
+ if (!call.blocksyncUrl)
160
+ return {
161
+ ok: false,
162
+ failure: unavailable(
163
+ 'blocksync',
164
+ 'no valid BLOCKSYNC_GRAPHQL_URL is configured',
165
+ false,
166
+ ),
167
+ };
168
+ const read = await blocksyncLookup(call, fields, { tag: LOG_TAG });
169
+ if (!read.ok) return read;
170
+ const { data, errors } = read.value;
171
+ if (errors.length || !data) {
172
+ const timeout = errors.some((e) => /statement timeout/i.test(e.message));
173
+ const failure: Failure = timeout
174
+ ? {
175
+ reason: 'index_timeout',
176
+ detail: 'The index could not answer in time; try again in a moment.',
177
+ retryable: false,
178
+ }
179
+ : unavailable('blocksync', 'it could not answer that question', true);
180
+ return { ok: false, failure };
181
+ }
182
+ return { ok: true, value: data };
183
+ }
184
+
185
+ export function restRead<S extends z.ZodType>(
186
+ call: ChainCall,
187
+ path: string,
188
+ schema: S,
189
+ label: string,
190
+ ): Promise<Outcome<z.infer<S>>> {
191
+ return restJson(call, path, schema, { label, tag: LOG_TAG });
192
+ }
193
+
194
+ /** A value as a GraphQL string literal (ids are checked against their pattern first). */
195
+ export const literal = (value: string) => JSON.stringify(value);