@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.
- package/package.json +7 -5
- package/src/attachments/channel-links.ts +135 -0
- package/src/attachments/channel.ts +203 -0
- package/src/attachments/download.ts +23 -6
- package/src/attachments/index.ts +23 -0
- package/src/attachments/pipeline.ts +120 -42
- package/src/attachments/retention.ts +11 -3
- package/src/attachments/sanitize.ts +20 -8
- package/src/attachments/types.ts +7 -0
- package/src/attachments/view.ts +24 -7
- package/src/channels/contract.ts +69 -0
- package/src/channels/turns.ts +23 -1
- package/src/core/main-agent-types.ts +2 -0
- package/src/core/runtime-context.ts +3 -0
- package/src/delivery/plan.ts +3 -1
- package/src/do/contracts.ts +3 -0
- package/src/do/turn-attachments.ts +103 -0
- package/src/do/user-oracle-do.ts +142 -42
- package/src/hub-actions/client.ts +87 -7
- package/src/hub-actions/hub-text.ts +82 -0
- package/src/hub-actions/request.ts +7 -4
- package/src/hub-actions/store.ts +5 -1
- package/src/hub-actions/transfer.ts +23 -6
- package/src/index.ts +1 -0
- package/src/plugin-api/index.ts +1 -0
- package/src/plugin-api/types.ts +43 -2
- package/src/plugins/attachments/view-attachment-tool.ts +7 -3
- package/src/plugins/hub-actions/hub-actions.plugin.ts +96 -17
- package/src/plugins/ixo-chain/balances.ts +141 -0
- package/src/plugins/ixo-chain/blocksync.ts +17 -8
- package/src/plugins/ixo-chain/denoms.ts +84 -36
- package/src/plugins/ixo-chain/http.ts +315 -70
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
- package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
- package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
- package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
- package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
- package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
- package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
- package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
- package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
- package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
- package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
- package/src/plugins/ixo-chain/kinds/core.ts +246 -0
- package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
- package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
- package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
- package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
- package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
- package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
- package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
- package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
- package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
- package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
- package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
- package/src/plugins/ixo-chain/kinds/types.ts +156 -0
- package/src/plugins/ixo-chain/lookup.ts +470 -0
- package/src/plugins/ixo-chain/overview.ts +1060 -0
- package/src/plugins/ixo-chain/queries.ts +170 -138
- package/src/plugins/ixo-chain/rest.ts +135 -54
- package/src/plugins/ixo-chain/summaries.ts +3 -2
- package/src/plugins/yoma/catalog.ts +780 -0
- package/src/plugins/yoma/clef.ts +98 -0
- package/src/plugins/yoma/drafts.ts +339 -0
- package/src/plugins/yoma/engine.ts +926 -0
- package/src/plugins/yoma/index.ts +13 -0
- package/src/plugins/yoma/judge.ts +358 -0
- package/src/plugins/yoma/permission.ts +62 -0
- package/src/plugins/yoma/reads.ts +195 -0
- package/src/plugins/yoma/submission.ts +534 -0
- package/src/plugins/yoma/text.ts +16 -0
- package/src/plugins/yoma/work.ts +190 -0
- package/src/plugins/yoma/yoma.plugin.ts +2162 -0
- package/src/sqlite/serialization.ts +2 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Yoma's programmes and the jobs inside them: the same list jambo.yoma.world
|
|
3
|
+
* shows, worked out the same way (Jambo `jambo-yoma-v2`: `utils/projects.ts`,
|
|
4
|
+
* `utils/claims.ts`, `hooks/useProtocolCollections.ts`).
|
|
5
|
+
*
|
|
6
|
+
* 1. The Jambo worker's list of entities Yoma admins chose
|
|
7
|
+
* (`GET /v1/entities`): one programme each, in the worker's order.
|
|
8
|
+
* 2. Every claim collection those entities own, from Blocksync.
|
|
9
|
+
* 3. Minus the collections admins hid per entity (`GET /v1/collections/<entity>`).
|
|
10
|
+
*
|
|
11
|
+
* Jambo still lists collections that are not open (state PAUSED or CLOSED),
|
|
12
|
+
* have ended or are full; this does not, as the chain refuses a claim on
|
|
13
|
+
* them (x/claims `SubmitClaim`: state OPEN, dates, quota). A job is one
|
|
14
|
+
* collection; its name is its claim form's title, as on Jambo.
|
|
15
|
+
*/
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import {
|
|
18
|
+
TOOL_BUDGET_MS,
|
|
19
|
+
untilAborted,
|
|
20
|
+
unavailable,
|
|
21
|
+
type ChainCall,
|
|
22
|
+
type Failure,
|
|
23
|
+
type Outcome,
|
|
24
|
+
} from '../ixo-chain/http';
|
|
25
|
+
import {
|
|
26
|
+
blocksyncRead,
|
|
27
|
+
FORM_MAX_BYTES,
|
|
28
|
+
getJson,
|
|
29
|
+
JOB_LIST_MAX_BYTES,
|
|
30
|
+
literal,
|
|
31
|
+
PROFILE_MAX_BYTES,
|
|
32
|
+
} from './reads';
|
|
33
|
+
import { clippedText } from './text';
|
|
34
|
+
|
|
35
|
+
/** The Jambo worker that holds the list, per network. A network without one has no programmes. */
|
|
36
|
+
export const JOB_LIST_URLS: Partial<Record<ChainCall['network'], string>> = {
|
|
37
|
+
mainnet: 'https://ixo-yoma-jambo-worker.ixo-api.workers.dev',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const ENTITY_DID = /^did:ixo:entity:[0-9a-f]{32}$/;
|
|
41
|
+
export const COLLECTION_ID = /^[1-9][0-9]{0,11}$/;
|
|
42
|
+
|
|
43
|
+
/** Programmes read from the worker's list; more are left out. */
|
|
44
|
+
export const MAX_PROGRAMMES = 50;
|
|
45
|
+
/** Open collections read per Blocksync page, and the pages read; more set `truncated`. */
|
|
46
|
+
export const COLLECTIONS_PAGE = 500;
|
|
47
|
+
export const MAX_COLLECTION_PAGES = 4;
|
|
48
|
+
/** Entities asked in one Blocksync read; more are read in further reads. */
|
|
49
|
+
const ENTITIES_PER_READ = 100;
|
|
50
|
+
/** Open jobs kept after hidden, ended and full ones are dropped; more set `truncated`. */
|
|
51
|
+
export const MAX_JOBS = 300;
|
|
52
|
+
/**
|
|
53
|
+
* Reads one catalog build queues at once; they then take permits from the
|
|
54
|
+
* build's own pool (ixo-chain's {@link USER_READ_PERMITS}, keyed by network),
|
|
55
|
+
* not from the person who happened to start it.
|
|
56
|
+
*/
|
|
57
|
+
const CONCURRENCY = 6;
|
|
58
|
+
/** `x/claims` `CollectionState`: OPEN = 0, PAUSED = 1, CLOSED = 2 (Blocksync stores the number). */
|
|
59
|
+
export const COLLECTION_OPEN = 0;
|
|
60
|
+
/** How long a failed build answers without being tried again. */
|
|
61
|
+
const FAILED_TTL_MS = 30_000;
|
|
62
|
+
/** How long a protocol entity is kept per isolate. */
|
|
63
|
+
const ENTITY_TTL_MS = 10 * 60_000;
|
|
64
|
+
/** Forms kept per isolate, and their total size. */
|
|
65
|
+
const FORM_CACHE_ENTRIES = 50;
|
|
66
|
+
const FORM_CACHE_BYTES = 5 * 1024 * 1024;
|
|
67
|
+
/** How long a built list is kept per isolate. */
|
|
68
|
+
const LIST_TTL_MS = 5 * 60_000;
|
|
69
|
+
/** How long a form template is kept per isolate. */
|
|
70
|
+
const FORM_TTL_MS = 10 * 60_000;
|
|
71
|
+
|
|
72
|
+
export interface Coin {
|
|
73
|
+
denom: string;
|
|
74
|
+
amount: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface Job {
|
|
78
|
+
collectionId: string;
|
|
79
|
+
/** The entity that owns the collection: its programme. */
|
|
80
|
+
entityDid: string;
|
|
81
|
+
/** The protocol entity whose linked resources hold the forms. */
|
|
82
|
+
protocolDid: string;
|
|
83
|
+
/** The collection's admin account, which gives people permission to claim. */
|
|
84
|
+
admin: string;
|
|
85
|
+
title: string;
|
|
86
|
+
provider: string;
|
|
87
|
+
location?: string;
|
|
88
|
+
description?: string;
|
|
89
|
+
endDate?: string;
|
|
90
|
+
/** Claims still accepted (quota minus count), when the collection has a quota. */
|
|
91
|
+
slotsLeft?: number;
|
|
92
|
+
/** What an approved claim pays, as the collection lists it. Empty: no incentive. */
|
|
93
|
+
reward: Coin[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface Programme {
|
|
97
|
+
entityDid: string;
|
|
98
|
+
name: string;
|
|
99
|
+
provider: string;
|
|
100
|
+
location?: string;
|
|
101
|
+
description?: string;
|
|
102
|
+
openJobs: number;
|
|
103
|
+
/**
|
|
104
|
+
* Its hide list could not be read, so none of its jobs are shown: a job it
|
|
105
|
+
* hides must never be offered. The rest of the list stands.
|
|
106
|
+
*/
|
|
107
|
+
unavailable?: true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface Catalog {
|
|
111
|
+
programmes: Programme[];
|
|
112
|
+
jobs: Job[];
|
|
113
|
+
/** More open jobs than {@link MAX_JOBS}, or more collections than were read; the rest are left out. */
|
|
114
|
+
truncated?: true;
|
|
115
|
+
/** A piece could not be read (a hide list, a profile, a job's name): kept only briefly. */
|
|
116
|
+
incomplete?: true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A bank coin as Blocksync gives it; an amount that is not whole base units is left out. */
|
|
120
|
+
export const coinSchema = z.object({
|
|
121
|
+
denom: z.string().max(128),
|
|
122
|
+
amount: z.union([z.string(), z.number()]).transform(String),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** Positive whole-unit coins, at most five. */
|
|
126
|
+
export function positiveCoins(
|
|
127
|
+
coins: readonly Coin[] | null | undefined,
|
|
128
|
+
): Coin[] {
|
|
129
|
+
return (coins ?? [])
|
|
130
|
+
.filter((c) => /^\d{1,30}$/.test(c.amount) && Number(c.amount) > 0)
|
|
131
|
+
.slice(0, 5);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const paymentsSchema = z
|
|
135
|
+
.object({
|
|
136
|
+
approval: z
|
|
137
|
+
.object({ amount: z.array(coinSchema).max(20).optional() })
|
|
138
|
+
.passthrough()
|
|
139
|
+
.optional(),
|
|
140
|
+
})
|
|
141
|
+
.passthrough()
|
|
142
|
+
.nullable()
|
|
143
|
+
.optional();
|
|
144
|
+
|
|
145
|
+
const entitySchema = z
|
|
146
|
+
.object({
|
|
147
|
+
id: z.string(),
|
|
148
|
+
settings: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
149
|
+
service: z
|
|
150
|
+
.array(
|
|
151
|
+
z
|
|
152
|
+
.object({
|
|
153
|
+
id: z.string(),
|
|
154
|
+
type: z.string().optional(),
|
|
155
|
+
serviceEndpoint: z.string(),
|
|
156
|
+
})
|
|
157
|
+
.passthrough(),
|
|
158
|
+
)
|
|
159
|
+
.max(50)
|
|
160
|
+
.nullable()
|
|
161
|
+
.optional(),
|
|
162
|
+
linkedResource: z
|
|
163
|
+
.array(
|
|
164
|
+
z.object({ id: z.string(), serviceEndpoint: z.string() }).passthrough(),
|
|
165
|
+
)
|
|
166
|
+
.max(100)
|
|
167
|
+
.nullable()
|
|
168
|
+
.optional(),
|
|
169
|
+
})
|
|
170
|
+
.passthrough();
|
|
171
|
+
|
|
172
|
+
const collectionSchema = z.object({
|
|
173
|
+
id: z.string().regex(COLLECTION_ID),
|
|
174
|
+
entity: z.string().regex(ENTITY_DID),
|
|
175
|
+
admin: z.string(),
|
|
176
|
+
protocol: z.string(),
|
|
177
|
+
state: z.number().int(),
|
|
178
|
+
startDate: z.string().nullable(),
|
|
179
|
+
endDate: z.string().nullable(),
|
|
180
|
+
quota: z.union([z.string(), z.number()]).transform(String).nullable(),
|
|
181
|
+
count: z.union([z.string(), z.number()]).transform(String).nullable(),
|
|
182
|
+
payments: paymentsSchema,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
export type RawEntity = z.infer<typeof entitySchema>;
|
|
186
|
+
type RawCollection = z.infer<typeof collectionSchema>;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The rows of a list field (`{ nodes: [...] }`) that match `schema`; a row of
|
|
190
|
+
* another shape is left out rather than trusted.
|
|
191
|
+
*/
|
|
192
|
+
export function nodesOf<S extends z.ZodType>(
|
|
193
|
+
value: unknown,
|
|
194
|
+
schema: S,
|
|
195
|
+
): Array<z.infer<S>> {
|
|
196
|
+
const list = z.object({ nodes: z.array(z.unknown()) }).safeParse(value);
|
|
197
|
+
if (!list.success) return [];
|
|
198
|
+
return list.data.nodes.flatMap((node) => {
|
|
199
|
+
const parsed = schema.safeParse(node);
|
|
200
|
+
return parsed.success ? [parsed.data] : [];
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** An entity's Profile service endpoint, if its settings name one. */
|
|
205
|
+
function profileEndpoint(entity: RawEntity | undefined): string | undefined {
|
|
206
|
+
const profile = z
|
|
207
|
+
.object({ serviceEndpoint: z.string() })
|
|
208
|
+
.safeParse(entity?.settings?.Profile);
|
|
209
|
+
return profile.success ? profile.data.serviceEndpoint : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const entityListSchema = z.object({
|
|
213
|
+
data: z.object({ entities: z.array(z.string()).max(500) }),
|
|
214
|
+
});
|
|
215
|
+
const hideListSchema = z.object({
|
|
216
|
+
data: z
|
|
217
|
+
.object({ blacklist: z.array(z.string()).max(1000) })
|
|
218
|
+
.nullable()
|
|
219
|
+
.optional(),
|
|
220
|
+
});
|
|
221
|
+
const profileSchema = z.record(z.string(), z.unknown());
|
|
222
|
+
const templateSchema = z.record(z.string(), z.unknown());
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A service endpoint as an entity writes it (`cellnode:/public/abc` names
|
|
226
|
+
* the entity's `#cellnode` service), resolved to a URL, as Jambo's
|
|
227
|
+
* `getServiceEndpoint`. A full URL is returned as it is.
|
|
228
|
+
*/
|
|
229
|
+
export function resolveEndpoint(
|
|
230
|
+
endpoint: string,
|
|
231
|
+
services: RawEntity['service'],
|
|
232
|
+
): string {
|
|
233
|
+
const match = /^([A-Za-z][\w-]*):(.*)$/.exec(endpoint);
|
|
234
|
+
if (!match || /^https?$/i.test(match[1]!)) return endpoint;
|
|
235
|
+
const service = (services ?? []).find((s) => s.id.endsWith(`#${match[1]}`));
|
|
236
|
+
if (!service) return endpoint;
|
|
237
|
+
return `${service.serviceEndpoint.replace(/\/?$/, '/')}${match[2]!.replace(/^\//, '')}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A date the chain stores as "no date" reads as 1970 or earlier. */
|
|
241
|
+
function realDate(value: string | null | undefined): Date | undefined {
|
|
242
|
+
if (!value) return undefined;
|
|
243
|
+
const date = new Date(value);
|
|
244
|
+
return Number.isNaN(date.getTime()) || date.getFullYear() <= 1970
|
|
245
|
+
? undefined
|
|
246
|
+
: date;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function isOpen(
|
|
250
|
+
c: Pick<RawCollection, 'state' | 'startDate' | 'endDate' | 'quota' | 'count'>,
|
|
251
|
+
now: Date,
|
|
252
|
+
): boolean {
|
|
253
|
+
if (c.state !== COLLECTION_OPEN) return false;
|
|
254
|
+
const start = realDate(c.startDate);
|
|
255
|
+
const end = realDate(c.endDate);
|
|
256
|
+
if (start && start > now) return false;
|
|
257
|
+
if (end && end < now) return false;
|
|
258
|
+
const quota = Number(c.quota ?? 0);
|
|
259
|
+
return !(quota > 0 && Number(c.count ?? 0) >= quota);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const ENTITY_FIELDS = 'id settings service linkedResource';
|
|
263
|
+
|
|
264
|
+
const entityCache = new Map<string, { at: number; entity: RawEntity }>();
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Entities by id: the ones read in the last ten minutes from the isolate's
|
|
268
|
+
* cache, the rest from Blocksync, {@link ENTITIES_PER_READ} per read. Ids
|
|
269
|
+
* that fail the pattern are not asked. One read that fails fails the whole
|
|
270
|
+
* answer.
|
|
271
|
+
*/
|
|
272
|
+
export async function fetchEntities(
|
|
273
|
+
call: ChainCall,
|
|
274
|
+
ids: readonly string[],
|
|
275
|
+
): Promise<Outcome<Map<string, RawEntity>>> {
|
|
276
|
+
const asked = [...new Set(ids.filter((id) => ENTITY_DID.test(id)))];
|
|
277
|
+
const found = new Map<string, RawEntity>();
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
const missing: string[] = [];
|
|
280
|
+
for (const id of asked) {
|
|
281
|
+
const cached = entityCache.get(id);
|
|
282
|
+
if (cached && now - cached.at < ENTITY_TTL_MS) found.set(id, cached.entity);
|
|
283
|
+
else {
|
|
284
|
+
entityCache.delete(id);
|
|
285
|
+
missing.push(id);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const chunks: string[][] = [];
|
|
289
|
+
for (let at = 0; at < missing.length; at += ENTITIES_PER_READ)
|
|
290
|
+
chunks.push(missing.slice(at, at + ENTITIES_PER_READ));
|
|
291
|
+
const reads = await mapLimit(chunks, (chunk) =>
|
|
292
|
+
blocksyncRead(call, [
|
|
293
|
+
{
|
|
294
|
+
alias: 'e',
|
|
295
|
+
field: `e: entities(filter: { id: { in: [${chunk.map(literal).join(', ')}] } }, first: ${chunk.length}) { nodes { ${ENTITY_FIELDS} } }`,
|
|
296
|
+
},
|
|
297
|
+
]),
|
|
298
|
+
);
|
|
299
|
+
for (const read of reads) {
|
|
300
|
+
if (!read.ok) return read;
|
|
301
|
+
for (const entity of nodesOf(read.value.e, entitySchema)) {
|
|
302
|
+
entityCache.set(entity.id, { at: now, entity });
|
|
303
|
+
found.set(entity.id, entity);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { ok: true, value: found };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Where a protocol keeps its forms: `#bco` the application, `#vct` (or `surveyTemplate`) the claim. */
|
|
310
|
+
export type FormKind = 'application' | 'claim';
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A protocol's form resource, matched exactly as Jambo matches it
|
|
314
|
+
* (`jambo-yoma-v2` `screens/collectionForm.tsx`): the first linked
|
|
315
|
+
* resource whose id contains `#bco` (the application), or `#vct`, else
|
|
316
|
+
* `surveyTemplate` (the claim).
|
|
317
|
+
*/
|
|
318
|
+
export function formResource(protocol: RawEntity | undefined, form: FormKind) {
|
|
319
|
+
const resources = protocol?.linkedResource ?? [];
|
|
320
|
+
return form === 'application'
|
|
321
|
+
? resources.find((r) => r.id.includes('#bco'))
|
|
322
|
+
: (resources.find((r) => r.id.includes('#vct')) ??
|
|
323
|
+
resources.find((r) => r.id.includes('surveyTemplate')));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Forms by URL, least recently used first (a Map keeps insertion order). */
|
|
327
|
+
const formCache = new Map<
|
|
328
|
+
string,
|
|
329
|
+
{ at: number; bytes: number; template: unknown }
|
|
330
|
+
>();
|
|
331
|
+
let formCacheBytes = 0;
|
|
332
|
+
|
|
333
|
+
function forgetForm(url: string): void {
|
|
334
|
+
const entry = formCache.get(url);
|
|
335
|
+
if (!entry) return;
|
|
336
|
+
formCache.delete(url);
|
|
337
|
+
formCacheBytes -= entry.bytes;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function rememberForm(url: string, template: unknown): void {
|
|
341
|
+
forgetForm(url);
|
|
342
|
+
const bytes = JSON.stringify(template).length;
|
|
343
|
+
if (bytes > FORM_CACHE_BYTES) return;
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
for (const [key, entry] of formCache)
|
|
346
|
+
if (now - entry.at >= FORM_TTL_MS) forgetForm(key);
|
|
347
|
+
while (
|
|
348
|
+
formCache.size >= FORM_CACHE_ENTRIES ||
|
|
349
|
+
formCacheBytes + bytes > FORM_CACHE_BYTES
|
|
350
|
+
) {
|
|
351
|
+
const oldest = formCache.keys().next();
|
|
352
|
+
if (oldest.done) break;
|
|
353
|
+
forgetForm(oldest.value);
|
|
354
|
+
}
|
|
355
|
+
formCache.set(url, { at: now, bytes, template });
|
|
356
|
+
formCacheBytes += bytes;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* A protocol's application or claim form (a SurveyJS template).
|
|
361
|
+
* `{ ok: true, value: undefined }` only when the protocol has no such form;
|
|
362
|
+
* a form that cannot be read is a failure.
|
|
363
|
+
*/
|
|
364
|
+
export async function loadForm(
|
|
365
|
+
call: ChainCall,
|
|
366
|
+
protocol: RawEntity | undefined,
|
|
367
|
+
form: FormKind,
|
|
368
|
+
opts: { healthPerUrl?: boolean } = {},
|
|
369
|
+
): Promise<Outcome<unknown>> {
|
|
370
|
+
const resource = formResource(protocol, form);
|
|
371
|
+
if (!resource) return { ok: true, value: undefined };
|
|
372
|
+
const url = resolveEndpoint(resource.serviceEndpoint, protocol?.service);
|
|
373
|
+
const cached = formCache.get(url);
|
|
374
|
+
if (cached && Date.now() - cached.at < FORM_TTL_MS) {
|
|
375
|
+
// Touched: moves to the most recently used end.
|
|
376
|
+
formCache.delete(url);
|
|
377
|
+
formCache.set(url, cached);
|
|
378
|
+
return { ok: true, value: cached.template };
|
|
379
|
+
}
|
|
380
|
+
forgetForm(url);
|
|
381
|
+
const read = await getJson(call, 'document', url, templateSchema, {
|
|
382
|
+
label: `${form} form`,
|
|
383
|
+
maxBytes: FORM_MAX_BYTES,
|
|
384
|
+
what: 'This form',
|
|
385
|
+
...(opts.healthPerUrl ? { healthPerUrl: true } : {}),
|
|
386
|
+
});
|
|
387
|
+
if (read.ok) rememberForm(url, read.value);
|
|
388
|
+
return read;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** A form template's title, clipped. */
|
|
392
|
+
export function templateTitle(template: unknown): string | undefined {
|
|
393
|
+
const parsed = z
|
|
394
|
+
.object({
|
|
395
|
+
title: z.unknown().optional(),
|
|
396
|
+
question: z
|
|
397
|
+
.object({ title: z.unknown().optional() })
|
|
398
|
+
.passthrough()
|
|
399
|
+
.optional(),
|
|
400
|
+
})
|
|
401
|
+
.passthrough()
|
|
402
|
+
.safeParse(template);
|
|
403
|
+
if (!parsed.success) return undefined;
|
|
404
|
+
return clippedText(parsed.data.question?.title ?? parsed.data.title, 200);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
interface Profile {
|
|
408
|
+
name?: string;
|
|
409
|
+
brand?: string;
|
|
410
|
+
location?: string;
|
|
411
|
+
description?: string;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** An entity's profile document, clipped; empty when it has none, `failed` when it could not be read. */
|
|
415
|
+
async function profileOf(
|
|
416
|
+
call: ChainCall,
|
|
417
|
+
entity: RawEntity | undefined,
|
|
418
|
+
): Promise<{ profile: Profile; failed?: true }> {
|
|
419
|
+
const endpoint = profileEndpoint(entity);
|
|
420
|
+
if (!endpoint) return { profile: {} };
|
|
421
|
+
const read = await getJson(
|
|
422
|
+
call,
|
|
423
|
+
'document',
|
|
424
|
+
resolveEndpoint(endpoint, entity?.service),
|
|
425
|
+
profileSchema,
|
|
426
|
+
{
|
|
427
|
+
label: 'profile',
|
|
428
|
+
maxBytes: PROFILE_MAX_BYTES,
|
|
429
|
+
notFoundOn404: true,
|
|
430
|
+
what: 'A programme profile',
|
|
431
|
+
healthPerUrl: true,
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
if (!read.ok)
|
|
435
|
+
return read.failure.reason === 'not_found'
|
|
436
|
+
? { profile: {} }
|
|
437
|
+
: { profile: {}, failed: true };
|
|
438
|
+
const doc = read.value;
|
|
439
|
+
const name = clippedText(doc.name, 120);
|
|
440
|
+
const brand = clippedText(doc.brand, 120);
|
|
441
|
+
const location = clippedText(doc.location, 120);
|
|
442
|
+
const description = clippedText(doc.description, 600);
|
|
443
|
+
return {
|
|
444
|
+
profile: {
|
|
445
|
+
...(name ? { name } : {}),
|
|
446
|
+
...(brand ? { brand } : {}),
|
|
447
|
+
...(location ? { location } : {}),
|
|
448
|
+
...(description ? { description } : {}),
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The isolate's catalog per network: built, being built (shared), or failed a moment ago. */
|
|
454
|
+
const listCache = new Map<
|
|
455
|
+
string,
|
|
456
|
+
{ at: number; ttl: number; pending: Promise<Outcome<Catalog>> }
|
|
457
|
+
>();
|
|
458
|
+
|
|
459
|
+
/** Tests only: forget the cached list, entities and forms. */
|
|
460
|
+
export function resetCatalogCache(): void {
|
|
461
|
+
listCache.clear();
|
|
462
|
+
entityCache.clear();
|
|
463
|
+
formCache.clear();
|
|
464
|
+
formCacheBytes = 0;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The programmes and their open jobs. One build per isolate and network at
|
|
469
|
+
* a time, run on its own: its own deadline ({@link TOOL_BUDGET_MS} from its
|
|
470
|
+
* start), its own abort (only that deadline ends it) and its own permit pool,
|
|
471
|
+
* so the caller that started it can be aborted without failing the others.
|
|
472
|
+
* Every caller, the first included, waits for it with its own abort
|
|
473
|
+
* (`untilAborted`). A complete list is kept five minutes; a failed build, or
|
|
474
|
+
* one with a piece missing (a hide list, a profile, a job's name), 30
|
|
475
|
+
* seconds, so a struggling upstream is not asked by every call and a gap
|
|
476
|
+
* does not last.
|
|
477
|
+
*/
|
|
478
|
+
export async function loadCatalog(
|
|
479
|
+
call: ChainCall,
|
|
480
|
+
now = new Date(),
|
|
481
|
+
): Promise<Outcome<Catalog>> {
|
|
482
|
+
const base = JOB_LIST_URLS[call.network];
|
|
483
|
+
if (!base) return { ok: true, value: { programmes: [], jobs: [] } };
|
|
484
|
+
let entry = listCache.get(call.network);
|
|
485
|
+
if (!entry || now.getTime() - entry.at >= entry.ttl) {
|
|
486
|
+
const started = {
|
|
487
|
+
at: now.getTime(),
|
|
488
|
+
ttl: LIST_TTL_MS,
|
|
489
|
+
pending: startBuild(call, base, now),
|
|
490
|
+
};
|
|
491
|
+
entry = started;
|
|
492
|
+
listCache.set(call.network, started);
|
|
493
|
+
void started.pending.then((built) => {
|
|
494
|
+
if (!built.ok || built.value.incomplete) started.ttl = FAILED_TTL_MS;
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
return untilAborted(entry.pending, call.signal);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The build's reads: every caller's network and endpoints, but the build's own permits, deadline and abort. */
|
|
501
|
+
function startBuild(
|
|
502
|
+
caller: ChainCall,
|
|
503
|
+
base: string,
|
|
504
|
+
now: Date,
|
|
505
|
+
): Promise<Outcome<Catalog>> {
|
|
506
|
+
const controller = new AbortController();
|
|
507
|
+
const call: ChainCall = {
|
|
508
|
+
network: caller.network,
|
|
509
|
+
restUrl: caller.restUrl,
|
|
510
|
+
blocksyncUrl: caller.blocksyncUrl,
|
|
511
|
+
user: `yoma-catalog:${caller.network}`,
|
|
512
|
+
deadline: Date.now() + TOOL_BUDGET_MS,
|
|
513
|
+
signal: controller.signal,
|
|
514
|
+
fetch: caller.fetch,
|
|
515
|
+
};
|
|
516
|
+
// Every wait inside is bounded by the deadline; the abort is the backstop.
|
|
517
|
+
const timer = setTimeout(
|
|
518
|
+
() => controller.abort(new Error('catalog build deadline')),
|
|
519
|
+
TOOL_BUDGET_MS + 1_000,
|
|
520
|
+
);
|
|
521
|
+
return buildCatalog(call, base, now)
|
|
522
|
+
.catch((error: unknown): Outcome<Catalog> => {
|
|
523
|
+
console.warn(
|
|
524
|
+
`[yoma] catalog build failed: ${error instanceof Error ? error.name : 'unknown'}`,
|
|
525
|
+
);
|
|
526
|
+
return {
|
|
527
|
+
ok: false,
|
|
528
|
+
failure: unavailable('job-list', 'the list could not be built', true),
|
|
529
|
+
};
|
|
530
|
+
})
|
|
531
|
+
.finally(() => clearTimeout(timer));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** `work` over `items`, at most {@link CONCURRENCY} at a time, results in order. */
|
|
535
|
+
async function mapLimit<T, R>(
|
|
536
|
+
items: readonly T[],
|
|
537
|
+
work: (item: T) => Promise<R>,
|
|
538
|
+
): Promise<R[]> {
|
|
539
|
+
const out: R[] = new Array<R>(items.length);
|
|
540
|
+
let next = 0;
|
|
541
|
+
const lane = async () => {
|
|
542
|
+
while (next < items.length) {
|
|
543
|
+
const index = next++;
|
|
544
|
+
out[index] = await work(items[index]!);
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
await Promise.all(
|
|
548
|
+
Array.from({ length: Math.min(CONCURRENCY, items.length) }, lane),
|
|
549
|
+
);
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const pageInfoSchema = z.object({
|
|
554
|
+
pageInfo: z.object({
|
|
555
|
+
hasNextPage: z.boolean(),
|
|
556
|
+
endCursor: z.string().nullable(),
|
|
557
|
+
}),
|
|
558
|
+
});
|
|
559
|
+
const CURSOR = /^[A-Za-z0-9+/=_-]{1,500}$/;
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The OPEN collections the programmes own, page by page
|
|
563
|
+
* ({@link COLLECTIONS_PAGE} each, at most {@link MAX_COLLECTION_PAGES});
|
|
564
|
+
* `more` when the last page read was not the last. Blocksync keeps ids as
|
|
565
|
+
* text (its order puts "10" before "9"), so the caller sorts them as numbers.
|
|
566
|
+
*/
|
|
567
|
+
async function openCollections(
|
|
568
|
+
call: ChainCall,
|
|
569
|
+
entityIds: readonly string[],
|
|
570
|
+
): Promise<Outcome<{ rows: RawCollection[]; more: boolean }>> {
|
|
571
|
+
const ids = entityIds.map(literal).join(', ');
|
|
572
|
+
const rows: RawCollection[] = [];
|
|
573
|
+
let after = '';
|
|
574
|
+
for (let page = 0; page < MAX_COLLECTION_PAGES; page++) {
|
|
575
|
+
const read = await blocksyncRead(call, [
|
|
576
|
+
{
|
|
577
|
+
alias: 'c',
|
|
578
|
+
field: `c: claimCollections(filter: { entity: { in: [${ids}] }, state: { equalTo: ${COLLECTION_OPEN} } }, first: ${COLLECTIONS_PAGE}${after}, orderBy: PRIMARY_KEY_ASC) { pageInfo { hasNextPage endCursor } nodes { id entity admin protocol state startDate endDate quota count payments } }`,
|
|
579
|
+
},
|
|
580
|
+
]);
|
|
581
|
+
if (!read.ok) return read;
|
|
582
|
+
rows.push(...nodesOf(read.value.c, collectionSchema));
|
|
583
|
+
const info = pageInfoSchema.safeParse(read.value.c);
|
|
584
|
+
const cursor = info.success ? info.data.pageInfo.endCursor : null;
|
|
585
|
+
if (!info.success || !info.data.pageInfo.hasNextPage)
|
|
586
|
+
return { ok: true, value: { rows, more: false } };
|
|
587
|
+
if (!cursor || !CURSOR.test(cursor))
|
|
588
|
+
return { ok: true, value: { rows, more: true } };
|
|
589
|
+
after = `, after: ${literal(cursor)}`;
|
|
590
|
+
}
|
|
591
|
+
return { ok: true, value: { rows, more: true } };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function buildCatalog(
|
|
595
|
+
call: ChainCall,
|
|
596
|
+
base: string,
|
|
597
|
+
now: Date,
|
|
598
|
+
): Promise<Outcome<Catalog>> {
|
|
599
|
+
const listed = await getJson(
|
|
600
|
+
call,
|
|
601
|
+
'job-list',
|
|
602
|
+
`${base}/v1/entities`,
|
|
603
|
+
entityListSchema,
|
|
604
|
+
{
|
|
605
|
+
label: 'job list',
|
|
606
|
+
maxBytes: JOB_LIST_MAX_BYTES,
|
|
607
|
+
what: 'The Yoma job list',
|
|
608
|
+
},
|
|
609
|
+
);
|
|
610
|
+
if (!listed.ok) return listed;
|
|
611
|
+
const entityIds = [
|
|
612
|
+
...new Set(listed.value.data.entities.filter((id) => ENTITY_DID.test(id))),
|
|
613
|
+
].slice(0, MAX_PROGRAMMES);
|
|
614
|
+
if (!entityIds.length)
|
|
615
|
+
return { ok: true, value: { programmes: [], jobs: [] } };
|
|
616
|
+
|
|
617
|
+
// The hide lists, the collections and the programmes' entities at once.
|
|
618
|
+
// A hide list that cannot be read must not show what it hides: that
|
|
619
|
+
// programme shows as unavailable, with none of its jobs; the rest of the
|
|
620
|
+
// list stands. A 404 is a programme with nothing hidden. Each hide list,
|
|
621
|
+
// profile and form keeps its own health mark here, so one 503 holds back
|
|
622
|
+
// only that address, after its own retry, not the rest of the build.
|
|
623
|
+
const [hidden, collections, programmeEntities] = await Promise.all([
|
|
624
|
+
mapLimit(entityIds, (id) =>
|
|
625
|
+
getJson(
|
|
626
|
+
call,
|
|
627
|
+
'job-list',
|
|
628
|
+
`${base}/v1/collections/${encodeURIComponent(id)}`,
|
|
629
|
+
hideListSchema,
|
|
630
|
+
{
|
|
631
|
+
label: 'hide list',
|
|
632
|
+
maxBytes: JOB_LIST_MAX_BYTES,
|
|
633
|
+
notFoundOn404: true,
|
|
634
|
+
what: 'A hide list',
|
|
635
|
+
healthPerUrl: true,
|
|
636
|
+
},
|
|
637
|
+
),
|
|
638
|
+
),
|
|
639
|
+
openCollections(call, entityIds),
|
|
640
|
+
fetchEntities(call, entityIds),
|
|
641
|
+
]);
|
|
642
|
+
if (!collections.ok) return collections;
|
|
643
|
+
let incomplete = !programmeEntities.ok;
|
|
644
|
+
const hiddenBy = new Map<string, Set<string>>();
|
|
645
|
+
const unreadable = new Set<string>();
|
|
646
|
+
hidden.forEach((read, index) => {
|
|
647
|
+
const id = entityIds[index]!;
|
|
648
|
+
if (read.ok) hiddenBy.set(id, new Set(read.value.data?.blacklist ?? []));
|
|
649
|
+
else if (read.failure.reason === 'not_found') hiddenBy.set(id, new Set());
|
|
650
|
+
else unreadable.add(id);
|
|
651
|
+
});
|
|
652
|
+
if (unreadable.size) incomplete = true;
|
|
653
|
+
|
|
654
|
+
const order = new Map(entityIds.map((id, index) => [id, index]));
|
|
655
|
+
const allOpen = collections.value.rows
|
|
656
|
+
.filter(
|
|
657
|
+
(c) =>
|
|
658
|
+
!unreadable.has(c.entity) &&
|
|
659
|
+
!hiddenBy.get(c.entity)?.has(c.id) &&
|
|
660
|
+
isOpen(c, now),
|
|
661
|
+
)
|
|
662
|
+
.sort(
|
|
663
|
+
(x, y) =>
|
|
664
|
+
order.get(x.entity)! - order.get(y.entity)! ||
|
|
665
|
+
Number(x.id) - Number(y.id),
|
|
666
|
+
);
|
|
667
|
+
const open = allOpen.slice(0, MAX_JOBS);
|
|
668
|
+
|
|
669
|
+
// Names: profiles, and each protocol's claim form title, at once.
|
|
670
|
+
const protocolIds = [...new Set(open.map((c) => c.protocol))];
|
|
671
|
+
const entityOf = (id: string) =>
|
|
672
|
+
programmeEntities.ok ? programmeEntities.value.get(id) : undefined;
|
|
673
|
+
const [profiles, titles] = await Promise.all([
|
|
674
|
+
mapLimit(
|
|
675
|
+
entityIds,
|
|
676
|
+
async (id) => [id, await profileOf(call, entityOf(id))] as const,
|
|
677
|
+
),
|
|
678
|
+
fetchEntities(call, protocolIds).then((protocols) =>
|
|
679
|
+
protocols.ok
|
|
680
|
+
? mapLimit(protocolIds, async (id) => {
|
|
681
|
+
const form = await loadForm(
|
|
682
|
+
call,
|
|
683
|
+
protocols.value.get(id),
|
|
684
|
+
'claim',
|
|
685
|
+
{ healthPerUrl: true },
|
|
686
|
+
);
|
|
687
|
+
return [id, form.ok ? templateTitle(form.value) : null] as const;
|
|
688
|
+
})
|
|
689
|
+
: null,
|
|
690
|
+
),
|
|
691
|
+
]);
|
|
692
|
+
if (!titles || titles.some(([, title]) => title === null)) incomplete = true;
|
|
693
|
+
if (profiles.some(([, read]) => read.failed)) incomplete = true;
|
|
694
|
+
const profileOfId = new Map(profiles.map(([id, read]) => [id, read.profile]));
|
|
695
|
+
const titleOf = new Map(
|
|
696
|
+
(titles ?? []).flatMap(([id, title]) =>
|
|
697
|
+
title ? [[id, title] as const] : [],
|
|
698
|
+
),
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
const jobs = open.map((c): Job => {
|
|
702
|
+
const profile = profileOfId.get(c.entity) ?? {};
|
|
703
|
+
const quota = Number(c.quota ?? 0);
|
|
704
|
+
return {
|
|
705
|
+
collectionId: c.id,
|
|
706
|
+
entityDid: c.entity,
|
|
707
|
+
protocolDid: c.protocol,
|
|
708
|
+
admin: c.admin,
|
|
709
|
+
title: titleOf.get(c.protocol) ?? profile.name ?? `Opportunity ${c.id}`,
|
|
710
|
+
provider: profile.brand ?? profile.name ?? 'A Yoma partner',
|
|
711
|
+
...(profile.location ? { location: profile.location } : {}),
|
|
712
|
+
...(profile.description ? { description: profile.description } : {}),
|
|
713
|
+
...(realDate(c.endDate) ? { endDate: c.endDate!.slice(0, 10) } : {}),
|
|
714
|
+
...(quota > 0
|
|
715
|
+
? { slotsLeft: Math.max(0, quota - Number(c.count ?? 0)) }
|
|
716
|
+
: {}),
|
|
717
|
+
reward: positiveCoins(c.payments?.approval?.amount),
|
|
718
|
+
};
|
|
719
|
+
});
|
|
720
|
+
const programmes = entityIds.map((id): Programme => {
|
|
721
|
+
const profile = profileOfId.get(id) ?? {};
|
|
722
|
+
return {
|
|
723
|
+
entityDid: id,
|
|
724
|
+
name: profile.name ?? 'A Yoma programme',
|
|
725
|
+
provider: profile.brand ?? profile.name ?? 'A Yoma partner',
|
|
726
|
+
...(profile.location ? { location: profile.location } : {}),
|
|
727
|
+
...(profile.description ? { description: profile.description } : {}),
|
|
728
|
+
openJobs: jobs.filter((job) => job.entityDid === id).length,
|
|
729
|
+
...(unreadable.has(id) ? { unavailable: true as const } : {}),
|
|
730
|
+
};
|
|
731
|
+
});
|
|
732
|
+
return {
|
|
733
|
+
ok: true,
|
|
734
|
+
value: {
|
|
735
|
+
programmes,
|
|
736
|
+
jobs,
|
|
737
|
+
...(allOpen.length > open.length || collections.value.more
|
|
738
|
+
? { truncated: true as const }
|
|
739
|
+
: {}),
|
|
740
|
+
...(incomplete ? { incomplete: true as const } : {}),
|
|
741
|
+
},
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Words in a person's search matched against what a programme or job says about itself. */
|
|
746
|
+
export function matchesQuery(
|
|
747
|
+
item: {
|
|
748
|
+
title?: string;
|
|
749
|
+
name?: string;
|
|
750
|
+
provider?: string;
|
|
751
|
+
location?: string;
|
|
752
|
+
description?: string;
|
|
753
|
+
},
|
|
754
|
+
query: string,
|
|
755
|
+
extra: string[] = [],
|
|
756
|
+
): boolean {
|
|
757
|
+
const haystack = [
|
|
758
|
+
item.title,
|
|
759
|
+
item.name,
|
|
760
|
+
item.provider,
|
|
761
|
+
item.location,
|
|
762
|
+
item.description,
|
|
763
|
+
...extra,
|
|
764
|
+
]
|
|
765
|
+
.filter(Boolean)
|
|
766
|
+
.join(' ')
|
|
767
|
+
.toLowerCase();
|
|
768
|
+
const words = query
|
|
769
|
+
.toLowerCase()
|
|
770
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
771
|
+
.filter((word) => word.length > 2);
|
|
772
|
+
return words.length === 0 || words.some((word) => haystack.includes(word));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** The failure a missing programme or job answers with. */
|
|
776
|
+
export const notOpen = (what: string): Failure => ({
|
|
777
|
+
reason: 'not_found',
|
|
778
|
+
detail: `That ${what} is not open on Yoma right now.`,
|
|
779
|
+
retryable: false,
|
|
780
|
+
});
|