@ixo/oracle-runtime-workers 0.34.1 → 0.35.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.34.1",
3
+ "version": "0.35.1",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -67,7 +67,7 @@
67
67
  "protobufjs": "^7.6.5",
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
- "@ixo/common": "1.7.1",
70
+ "@ixo/common": "1.7.2",
71
71
  "@ixo/ixo-transaction": "0.2.0",
72
72
  "@ixo/ucan": "2.3.0"
73
73
  },
@@ -7,6 +7,8 @@
7
7
  * (`GET /v1/entities`): one programme each, in the worker's order.
8
8
  * 2. Every claim collection those entities own, from Blocksync.
9
9
  * 3. Minus the collections admins hid per entity (`GET /v1/collections/<entity>`).
10
+ * 4. Plus the host's bot-only jobs ({@link BotOnlyJob}): test collections
11
+ * offered in chat but not on jambo.yoma.world, after Jambo's programmes.
10
12
  *
11
13
  * Jambo still lists collections that are not open (state PAUSED or CLOSED),
12
14
  * have ended or are full; this does not, as the chain refuses a claim on
@@ -107,6 +109,23 @@ export interface Programme {
107
109
  unavailable?: true;
108
110
  }
109
111
 
112
+ /**
113
+ * A job the host offers in chat only, strictly for testing on a production
114
+ * collection: the test runs on mainnet with real cards and payments, while
115
+ * jambo.yoma.world, and so the partners' and opportunity providers' own
116
+ * listings and results, never show it. Not for real opportunities: those
117
+ * belong on Jambo's list. One collection of a programme's entity, shown
118
+ * whether or not Jambo lists the entity. Its programme comes after Jambo's
119
+ * (an entity Jambo lists keeps its place) and shows only its bot-only jobs;
120
+ * Jambo's hide list does not apply to it. The chain still decides whether it
121
+ * is open, so closing or pausing the collection ends the test.
122
+ */
123
+ export interface BotOnlyJob {
124
+ /** The entity that owns the collection (`did:ixo:entity:…`). */
125
+ programme: string;
126
+ collectionId: string;
127
+ }
128
+
110
129
  export interface Catalog {
111
130
  programmes: Programme[];
112
131
  jobs: Job[];
@@ -473,23 +492,34 @@ export function resetCatalogCache(): void {
473
492
  * (`untilAborted`). A complete list is kept five minutes; a failed build, or
474
493
  * one with a piece missing (a hide list, a profile, a job's name), 30
475
494
  * seconds, so a struggling upstream is not asked by every call and a gap
476
- * does not last.
495
+ * does not last. `botOnly`: the host's bot-only jobs (malformed ones are
496
+ * left out).
477
497
  */
478
498
  export async function loadCatalog(
479
499
  call: ChainCall,
480
500
  now = new Date(),
501
+ botOnly: readonly BotOnlyJob[] = [],
481
502
  ): Promise<Outcome<Catalog>> {
482
503
  const base = JOB_LIST_URLS[call.network];
483
- if (!base) return { ok: true, value: { programmes: [], jobs: [] } };
484
- let entry = listCache.get(call.network);
504
+ const extra = botOnly.filter(
505
+ (job) =>
506
+ ENTITY_DID.test(job.programme) && COLLECTION_ID.test(job.collectionId),
507
+ );
508
+ if (!base && !extra.length)
509
+ return { ok: true, value: { programmes: [], jobs: [] } };
510
+ const key = [
511
+ call.network,
512
+ ...extra.map((job) => `${job.programme}/${job.collectionId}`),
513
+ ].join('|');
514
+ let entry = listCache.get(key);
485
515
  if (!entry || now.getTime() - entry.at >= entry.ttl) {
486
516
  const started = {
487
517
  at: now.getTime(),
488
518
  ttl: LIST_TTL_MS,
489
- pending: startBuild(call, base, now),
519
+ pending: startBuild(call, base, now, extra),
490
520
  };
491
521
  entry = started;
492
- listCache.set(call.network, started);
522
+ listCache.set(key, started);
493
523
  void started.pending.then((built) => {
494
524
  if (!built.ok || built.value.incomplete) started.ttl = FAILED_TTL_MS;
495
525
  });
@@ -500,8 +530,9 @@ export async function loadCatalog(
500
530
  /** The build's reads: every caller's network and endpoints, but the build's own permits, deadline and abort. */
501
531
  function startBuild(
502
532
  caller: ChainCall,
503
- base: string,
533
+ base: string | undefined,
504
534
  now: Date,
535
+ botOnly: readonly BotOnlyJob[],
505
536
  ): Promise<Outcome<Catalog>> {
506
537
  const controller = new AbortController();
507
538
  const call: ChainCall = {
@@ -518,7 +549,7 @@ function startBuild(
518
549
  () => controller.abort(new Error('catalog build deadline')),
519
550
  TOOL_BUDGET_MS + 1_000,
520
551
  );
521
- return buildCatalog(call, base, now)
552
+ return buildCatalog(call, base, now, botOnly)
522
553
  .catch((error: unknown): Outcome<Catalog> => {
523
554
  console.warn(
524
555
  `[yoma] catalog build failed: ${error instanceof Error ? error.name : 'unknown'}`,
@@ -593,24 +624,43 @@ async function openCollections(
593
624
 
594
625
  async function buildCatalog(
595
626
  call: ChainCall,
596
- base: string,
627
+ base: string | undefined,
597
628
  now: Date,
629
+ botOnly: readonly BotOnlyJob[],
598
630
  ): 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;
631
+ let jamboIds: string[] = [];
632
+ if (base) {
633
+ const listed = await getJson(
634
+ call,
635
+ 'job-list',
636
+ `${base}/v1/entities`,
637
+ entityListSchema,
638
+ {
639
+ label: 'job list',
640
+ maxBytes: JOB_LIST_MAX_BYTES,
641
+ what: 'The Yoma job list',
642
+ },
643
+ );
644
+ if (!listed.ok) return listed;
645
+ jamboIds = [
646
+ ...new Set(
647
+ listed.value.data.entities.filter((id) => ENTITY_DID.test(id)),
648
+ ),
649
+ ].slice(0, MAX_PROGRAMMES);
650
+ }
651
+ // Bot-only programmes after Jambo's; one Jambo lists keeps its place.
652
+ const onJambo = new Set(jamboIds);
611
653
  const entityIds = [
612
- ...new Set(listed.value.data.entities.filter((id) => ENTITY_DID.test(id))),
613
- ].slice(0, MAX_PROGRAMMES);
654
+ ...jamboIds,
655
+ ...new Set(
656
+ botOnly.map((job) => job.programme).filter((id) => !onJambo.has(id)),
657
+ ),
658
+ ];
659
+ const botOnlyJobs = new Set(
660
+ botOnly.map((job) => `${job.programme}/${job.collectionId}`),
661
+ );
662
+ const isBotOnly = (c: { entity: string; id: string }) =>
663
+ botOnlyJobs.has(`${c.entity}/${c.id}`);
614
664
  if (!entityIds.length)
615
665
  return { ok: true, value: { programmes: [], jobs: [] } };
616
666
 
@@ -620,8 +670,9 @@ async function buildCatalog(
620
670
  // list stands. A 404 is a programme with nothing hidden. Each hide list,
621
671
  // profile and form keeps its own health mark here, so one 503 holds back
622
672
  // only that address, after its own retry, not the rest of the build.
673
+ // A bot-only programme has no hide list: only its bot-only jobs show.
623
674
  const [hidden, collections, programmeEntities] = await Promise.all([
624
- mapLimit(entityIds, (id) =>
675
+ mapLimit(jamboIds, (id) =>
625
676
  getJson(
626
677
  call,
627
678
  'job-list',
@@ -644,7 +695,7 @@ async function buildCatalog(
644
695
  const hiddenBy = new Map<string, Set<string>>();
645
696
  const unreadable = new Set<string>();
646
697
  hidden.forEach((read, index) => {
647
- const id = entityIds[index]!;
698
+ const id = jamboIds[index]!;
648
699
  if (read.ok) hiddenBy.set(id, new Set(read.value.data?.blacklist ?? []));
649
700
  else if (read.failure.reason === 'not_found') hiddenBy.set(id, new Set());
650
701
  else unreadable.add(id);
@@ -655,8 +706,10 @@ async function buildCatalog(
655
706
  const allOpen = collections.value.rows
656
707
  .filter(
657
708
  (c) =>
658
- !unreadable.has(c.entity) &&
659
- !hiddenBy.get(c.entity)?.has(c.id) &&
709
+ (isBotOnly(c) ||
710
+ (onJambo.has(c.entity) &&
711
+ !unreadable.has(c.entity) &&
712
+ !hiddenBy.get(c.entity)?.has(c.id))) &&
660
713
  isOpen(c, now),
661
714
  )
662
715
  .sort(
@@ -3,6 +3,7 @@ export {
3
3
  type InstantPermission,
4
4
  type YomaPluginOptions,
5
5
  } from './yoma.plugin';
6
+ export type { BotOnlyJob } from './catalog';
6
7
  export {
7
8
  CLEF_MODEL,
8
9
  workersAiClefRunner,
@@ -25,6 +25,7 @@ import {
25
25
  loadForm,
26
26
  matchesQuery,
27
27
  notOpen,
28
+ type BotOnlyJob,
28
29
  type Catalog,
29
30
  type FormKind,
30
31
  type Job,
@@ -633,6 +634,8 @@ export interface YomaPluginOptions {
633
634
  getClef?: () => ClefRunner | undefined;
634
635
  /** Collections where a finished application gives permission at once, granted by the host. Absent: every job waits for acceptance. */
635
636
  instantPermission?: InstantPermission;
637
+ /** Test collections offered in chat but not on jambo.yoma.world, so partners' and opportunity providers' listings are untouched (see {@link BotOnlyJob}). Testing only. */
638
+ botOnlyJobs?: readonly BotOnlyJob[];
636
639
  }
637
640
 
638
641
  const manifest: PluginManifest = {
@@ -805,7 +808,8 @@ export class YomaPlugin extends OraclePlugin {
805
808
  if (!call) return NO_NETWORK;
806
809
  // The list is read once for every request that needs it.
807
810
  let catalog: Promise<Outcome<Catalog>> | undefined;
808
- const getCatalog = () => (catalog ??= loadCatalog(call));
811
+ const getCatalog = () =>
812
+ (catalog ??= loadCatalog(call, undefined, this.options.botOnlyJobs));
809
813
  const results: Array<Record<string, unknown>> = [];
810
814
  for (const request of input.requests) {
811
815
  try {
@@ -1701,7 +1705,7 @@ export class YomaPlugin extends OraclePlugin {
1701
1705
  }
1702
1706
  let job: Job | undefined;
1703
1707
  if (!draft) {
1704
- const read = await loadCatalog(call);
1708
+ const read = await loadCatalog(call, undefined, this.options.botOnlyJobs);
1705
1709
  if (!read.ok) return failed(read.failure);
1706
1710
  job = read.value.jobs.find((j) => j.collectionId === input.collectionId);
1707
1711
  if (!job) return failed(notOpen('job'));