@ixo/oracle-runtime-workers 0.42.1 → 0.43.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.42.1",
3
+ "version": "0.43.0",
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": {
@@ -68,9 +68,9 @@
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
70
  "@ixo/common": "1.8.1",
71
+ "@ixo/ixo-transaction": "0.2.0",
71
72
  "@ixo/ucan": "2.4.0",
72
- "@ixo/action-interactions": "0.1.1",
73
- "@ixo/ixo-transaction": "0.2.0"
73
+ "@ixo/action-interactions": "0.2.0"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -2,6 +2,7 @@ import {
2
2
  ActionInteractionError,
3
3
  canonicalJson,
4
4
  interactionValuesDigest,
5
+ NO_ACTION_WINDOW,
5
6
  InteractionSchema,
6
7
  InteractionReferenceSchema,
7
8
  type Interaction,
@@ -35,6 +36,8 @@ const MANUAL_PREFIX = 'interaction-manual:';
35
36
  const CHUNK_COUNT_PREFIX = 'interaction-chunks:';
36
37
  /** `<id>:<index>` → one chunk of such a record's serialized values. */
37
38
  const CHUNK_PREFIX = 'interaction-value:';
39
+ /** `<cap group>:<sha256(sessionId)>:<createdAt>:<id>` → id: the cards one chat created, per cap group. */
40
+ const COUNT_PREFIX = 'interaction-count:';
38
41
  /**
39
42
  * Replays of edits only need the newest requests: an older retry fails the
40
43
  * revision compare-and-swap instead, which never applies an edit twice.
@@ -45,8 +48,8 @@ const MAX_RECORD_BYTES = 64 * 1024;
45
48
  /** A domain-write approval carries a file batch; 16 KiB stays free for the record's metadata. */
46
49
  const MAX_DOMAIN_WRITE_BYTES = 2 * 1024 * 1024 - 16 * 1024;
47
50
  const DAY_MS = 24 * 60 * 60 * 1000;
48
- const TTL_MS = 7 * DAY_MS;
49
- /** How long an expired record stays readable before retention removes its values. */
51
+ const WEEK_MS = 7 * DAY_MS;
52
+ /** How long a record stays readable after its action window, before retention removes its values. */
50
53
  const RETAIN_MS = 30 * DAY_MS;
51
54
  /** The newest records one session list returns. */
52
55
  const LIST_LIMIT = 50;
@@ -55,9 +58,10 @@ const LIST_MAX_BYTES = 8 * 1024 * 1024;
55
58
  /** The newest records one reference list returns. */
56
59
  const REFERENCE_LIST_LIMIT = 1000;
57
60
  /**
58
- * Cards one chat may create within one draft lifetime (`TTL_MS`). A question
59
- * owner is keyed by its request id, so without a cap a runaway agent could
60
- * write drafts as fast as the rate limit allows.
61
+ * Cards one chat may create within a week. Kinds with and without an action
62
+ * window are counted apart, so a chat full of charts can still prepare a
63
+ * claim. A question owner is keyed by its request id, so without a cap a
64
+ * runaway agent could write cards as fast as the rate limit allows.
61
65
  */
62
66
  const SESSION_DRAFT_LIMIT = 200;
63
67
  /** Records retention handles per prepare, so a prepare never pays for a backlog. */
@@ -67,6 +71,79 @@ const STORAGE_BATCH = 128;
67
71
 
68
72
  type Storage = Pick<DurableObjectStorage, 'get' | 'put' | 'delete' | 'list'>;
69
73
 
74
+ /**
75
+ * How long each owner kind lives. The runtime owns this table: a client can
76
+ * never choose a longer life. A new kind, a long-living one included, is one
77
+ * row.
78
+ * - `actionWindowMs`: how long the card can be answered or signed, or `null`
79
+ * when it stays open for as long as its chat (`NO_ACTION_WINDOW`).
80
+ * - `keep`: `chat` keeps the record until its chat is deleted; `window` lets
81
+ * retention remove its values 30 days after the window, keeping what stops
82
+ * a duplicate.
83
+ * - `attachable`: the person can save it to their Library, so it always
84
+ * lives with its chat.
85
+ * - `repeats`: the owner takes another operation after a reported one, when
86
+ * the prepare asks for it (a Flow claim step takes many claims).
87
+ */
88
+ type CardPolicy = { repeats: boolean } & (
89
+ | { actionWindowMs: number; keep: 'window'; attachable: false }
90
+ | { actionWindowMs: number | null; keep: 'chat'; attachable: boolean }
91
+ );
92
+ const CARD_POLICIES: Record<Interaction['owner']['kind'], CardPolicy> = {
93
+ 'flow-claim': {
94
+ actionWindowMs: WEEK_MS,
95
+ keep: 'window',
96
+ attachable: false,
97
+ repeats: true,
98
+ },
99
+ 'flow-claim-block': {
100
+ actionWindowMs: WEEK_MS,
101
+ keep: 'window',
102
+ attachable: false,
103
+ repeats: true,
104
+ },
105
+ 'flow-bid': {
106
+ actionWindowMs: WEEK_MS,
107
+ keep: 'window',
108
+ attachable: false,
109
+ repeats: false,
110
+ },
111
+ approval: {
112
+ actionWindowMs: WEEK_MS,
113
+ keep: 'window',
114
+ attachable: false,
115
+ repeats: false,
116
+ },
117
+ question: {
118
+ actionWindowMs: null,
119
+ keep: 'chat',
120
+ attachable: false,
121
+ repeats: false,
122
+ },
123
+ artifact: {
124
+ actionWindowMs: null,
125
+ keep: 'chat',
126
+ attachable: true,
127
+ repeats: false,
128
+ },
129
+ task: {
130
+ actionWindowMs: null,
131
+ keep: 'chat',
132
+ attachable: false,
133
+ repeats: false,
134
+ },
135
+ 'owner-reference': {
136
+ actionWindowMs: null,
137
+ keep: 'chat',
138
+ attachable: false,
139
+ repeats: false,
140
+ },
141
+ };
142
+ const cardPolicy = (owner: Pick<Interaction['owner'], 'kind'>) =>
143
+ CARD_POLICIES[owner.kind];
144
+ const capGroup = (owner: Pick<Interaction['owner'], 'kind'>) =>
145
+ cardPolicy(owner).actionWindowMs === null ? 'no-window' : 'window';
146
+
70
147
  async function sha256Hex(text: string): Promise<string> {
71
148
  const hash = await crypto.subtle.digest(
72
149
  'SHA-256',
@@ -82,6 +159,9 @@ const sortable = (value: number) =>
82
159
  async function sessionIndexKey(record: Interaction): Promise<string> {
83
160
  return `${SESSION_PREFIX}${await sha256Hex(record.sessionId)}:${sortable(record.createdAt)}:${record.id}`;
84
161
  }
162
+ async function countIndexKey(record: Interaction): Promise<string> {
163
+ return `${COUNT_PREFIX}${capGroup(record.owner)}:${await sha256Hex(record.sessionId)}:${sortable(record.createdAt)}:${record.id}`;
164
+ }
85
165
  const expiryIndexKey = (record: Interaction) =>
86
166
  `${EXPIRY_PREFIX}${sortable(record.expiresAt)}:${record.id}`;
87
167
  const utf8Bytes = (text: string) => new TextEncoder().encode(text).byteLength;
@@ -117,6 +197,13 @@ const unreadableRecord = () =>
117
197
  'record_unreadable',
118
198
  'This Companion draft cannot be read; check the claims list before submitting',
119
199
  );
200
+ /** A claim step with a filed claim takes another only at the person's request: a form's claim may still be in flight. */
201
+ const repeatRequired = () =>
202
+ new ActionInteractionError(
203
+ 409,
204
+ 'repeat_required',
205
+ 'This claim step already has a claim in this run, from a card or the form, and it may still be on its way; tell the person, and set repeat only if they ask for another claim',
206
+ );
120
207
  type RecentRequest = { requestId: string; fingerprint: string };
121
208
  const manualMarkerKey = (ownerKey: string) =>
122
209
  `${MANUAL_PREFIX}${ownerKey.slice(ownerKey.indexOf(':') + 1)}`;
@@ -222,14 +309,18 @@ function extendsReceipt(
222
309
  * Where an owner's existing record stands for a new card or a manual
223
310
  * submission of the same step:
224
311
  * - `blocked`: its operation reached the executor (the one-use dispatch key
225
- * was consumed) or the host reported it observed. A second submission
226
- * could duplicate the claim, so nothing replaces it.
312
+ * was consumed) or the host reported it observed, and the kind does not
313
+ * repeat or the outcome is not final. A second submission could duplicate
314
+ * the claim, so nothing replaces it.
315
+ * - `done`: a reported result of a kind that repeats. The record stays as
316
+ * it is; the ordinary form may submit again, and a card only when its
317
+ * prepare asks to repeat.
227
318
  * - `live`: an unexpired draft, or a reservation the host is dispatching
228
319
  * right now. It belongs to the card that owns it.
229
320
  * - `stale`: cancelled, expired, or a reservation that never reached the
230
321
  * executor. Safe to supersede: its dispatch check can no longer pass.
231
322
  */
232
- type OwnerState = 'blocked' | 'live' | 'stale';
323
+ type OwnerState = 'blocked' | 'done' | 'live' | 'stale';
233
324
 
234
325
  /** Personal drafts only. Execution and verified receipts remain owned by Flow. */
235
326
  export class ActionInteractionStore {
@@ -269,20 +360,23 @@ export class ActionInteractionStore {
269
360
  // that may already have reached the executor; any other draft for
270
361
  // this step is superseded, so its own dispatch check can no longer
271
362
  // pass and the step is never locked by an abandoned card. The
272
- // marker stops a card prepared later from filing the same claim.
363
+ // marker stops a card prepared later from filing the same claim,
364
+ // unless that prepare asks to repeat a kind that repeats.
273
365
  if (id && !record)
274
366
  throw new ActionInteractionError(
275
367
  409,
276
368
  'review_required',
277
369
  'This claim step has an unreadable Companion draft',
278
370
  );
279
- if (record && (await this.ownerState(tx, record)) === 'blocked')
371
+ const state = record && (await this.ownerState(tx, record));
372
+ if (state === 'blocked')
280
373
  throw new ActionInteractionError(
281
374
  409,
282
375
  'dispatch_consumed',
283
376
  'A Companion submission for this claim step already reached the claim service; check the claims list before submitting again',
284
377
  );
285
- if (record) await this.supersede(tx, record);
378
+ // A submitted claim stays as it is; another claim is a new one.
379
+ if (record && state !== 'done') await this.supersede(tx, record);
286
380
  await tx.put(manualKey, this.now());
287
381
  return { allowed: true, managed: false };
288
382
  }
@@ -394,10 +488,11 @@ export class ActionInteractionStore {
394
488
 
395
489
  /**
396
490
  * Retention for records past `expiresAt + RETAIN_MS`, a bounded batch per
397
- * call. Superseded or abandoned drafts disappear with their owner key,
398
- * indexes and request tombstones. A record whose operation reached the
399
- * executor keeps its owner key, reservation and dispatch tombstone forever
400
- * (they are what stops a duplicate claim), but loses its draft values.
491
+ * call. Only kinds that `keep` their record for their `window` are queued.
492
+ * Superseded or abandoned drafts disappear with their owner key, indexes
493
+ * and request tombstones. A record whose operation reached the executor
494
+ * keeps its owner key, reservation and dispatch tombstone forever (they are
495
+ * what stops a duplicate claim), but loses its draft values.
401
496
  */
402
497
  async prune(): Promise<number> {
403
498
  const due = await this.storage.list<string>({
@@ -407,7 +502,13 @@ export class ActionInteractionStore {
407
502
  });
408
503
  if (due.size === 0) return 0;
409
504
  await this.storage.transaction(async (tx) => {
410
- for (const [expiryKey, id] of due) await this.forget(tx, id, expiryKey);
505
+ for (const [expiryKey, id] of due) {
506
+ const record = await this.recordFrom(tx, id).catch(() => undefined);
507
+ // Queued before its kind lived with its chat: it leaves the queue.
508
+ if (record && cardPolicy(record.owner).keep === 'chat')
509
+ await tx.delete(expiryKey);
510
+ else await this.forget(tx, id, expiryKey);
511
+ }
411
512
  });
412
513
  return due.size;
413
514
  }
@@ -449,8 +550,9 @@ export class ActionInteractionStore {
449
550
 
450
551
  /**
451
552
  * Removes a record and everything that points at it, except what stops a
452
- * duplicate claim: a dispatched record keeps its owner key, reservation and
453
- * dispatch tombstone, and only loses its values and user-edit list.
553
+ * duplicate claim: a record whose operation reached the executor keeps its
554
+ * owner key, reservation and dispatch tombstone, and only loses its values
555
+ * and user-edit list.
454
556
  */
455
557
  private async forget(tx: Storage, id: string, expiryKey?: string) {
456
558
  // Only prepare requests are indexed per record (edits keep their
@@ -485,7 +587,9 @@ export class ActionInteractionStore {
485
587
  throw error;
486
588
  }
487
589
  const chunks = record ? await this.chunkKeys(tx, id) : [];
488
- if (record && (await this.ownerState(tx, record)) === 'blocked') {
590
+ if (record) doomed.push(await countIndexKey(record));
591
+ const state = record && (await this.ownerState(tx, record));
592
+ if (record && (state === 'blocked' || state === 'done')) {
489
593
  if (
490
594
  Object.keys(record.values).length ||
491
595
  record.updatedByUser.length ||
@@ -521,6 +625,8 @@ export class ActionInteractionStore {
521
625
  storage: Pick<DurableObjectStorage, 'get'>,
522
626
  record: Interaction,
523
627
  ): Promise<OwnerState> {
628
+ if (record.status === 'observed' && cardPolicy(record.owner).repeats)
629
+ return 'done';
524
630
  if (record.status === 'observed' || record.status === 'partial')
525
631
  return 'blocked';
526
632
  if (record.reservation && (await this.dispatched(storage, record)))
@@ -744,12 +850,15 @@ export class ActionInteractionStore {
744
850
  'Approval values must contain exactly one request object',
745
851
  );
746
852
  const ownerKey = await ownerStorageKey(input.owner, input.sessionId);
747
- if (
853
+ const policy = cardPolicy(input.owner);
854
+ // Only a kind that repeats can be asked for another operation.
855
+ const repeat = policy.repeats && input.repeat === true;
856
+ const filedByForm =
748
857
  (input.owner.kind === 'flow-claim' ||
749
858
  input.owner.kind === 'flow-claim-block' ||
750
859
  input.owner.kind === 'flow-bid') &&
751
- (await tx.get<number>(manualMarkerKey(ownerKey))) !== undefined
752
- )
860
+ (await tx.get<number>(manualMarkerKey(ownerKey))) !== undefined;
861
+ if (filedByForm && !policy.repeats)
753
862
  // The ordinary form already passed its dispatch check for this
754
863
  // step; a card could file the same claim a second time.
755
864
  throw new ActionInteractionError(
@@ -779,6 +888,7 @@ export class ActionInteractionStore {
779
888
  const state = await this.ownerState(tx, existing);
780
889
  const inFlight = state === 'live' && existing.status === 'reserved';
781
890
  if (
891
+ (state === 'done' && !repeat) ||
782
892
  state === 'blocked' ||
783
893
  inFlight ||
784
894
  (state === 'live' && !sameSession)
@@ -787,6 +897,7 @@ export class ActionInteractionStore {
787
897
  await remember(tx, existing.id);
788
898
  return { interaction: existing, replayed: true };
789
899
  }
900
+ if (state === 'done') throw repeatRequired();
790
901
  if (state === 'blocked')
791
902
  throw new ActionInteractionError(
792
903
  409,
@@ -811,15 +922,19 @@ export class ActionInteractionStore {
811
922
  }
812
923
  // A stale draft, or this chat's own draft for a form whose
813
924
  // collection or schema has since changed: it can no longer
814
- // dispatch, so a fresh draft takes its place.
815
- await this.supersede(tx, existing);
925
+ // dispatch, so a fresh draft takes its place. A submitted claim
926
+ // asked to repeat stays as it is.
927
+ if (state !== 'done') await this.supersede(tx, existing);
816
928
  }
817
- // Only cards created within one draft lifetime count: kept records of
818
- // sent claims and expired cards awaiting retention never lock a chat.
819
- const sessionPrefix = `${SESSION_PREFIX}${await sha256Hex(input.sessionId)}:`;
929
+ // A claim filed from the form: another one is a new claim.
930
+ if (filedByForm && !repeat) throw repeatRequired();
931
+ // Only cards created within the last week count, apart for kinds with
932
+ // and without an action window: kept records of sent claims and
933
+ // expired cards awaiting retention never lock a chat.
934
+ const counted = `${COUNT_PREFIX}${capGroup(input.owner)}:${await sha256Hex(input.sessionId)}:`;
820
935
  const recent = await tx.list<string>({
821
- prefix: sessionPrefix,
822
- start: `${sessionPrefix}${sortable(this.now() - TTL_MS)}`,
936
+ prefix: counted,
937
+ start: `${counted}${sortable(this.now() - WEEK_MS)}`,
823
938
  limit: SESSION_DRAFT_LIMIT,
824
939
  });
825
940
  if (recent.size >= SESSION_DRAFT_LIMIT)
@@ -839,12 +954,19 @@ export class ActionInteractionStore {
839
954
  updatedByUser: [],
840
955
  status: 'draft',
841
956
  createdAt: this.now(),
842
- expiresAt: this.now() + TTL_MS,
957
+ expiresAt:
958
+ policy.actionWindowMs === null
959
+ ? NO_ACTION_WINDOW
960
+ : this.now() + policy.actionWindowMs,
843
961
  });
844
962
  await tx.put({
845
963
  [ownerKey]: record.id,
846
964
  [await sessionIndexKey(record)]: record.id,
847
- [expiryIndexKey(record)]: record.id,
965
+ [await countIndexKey(record)]: record.id,
966
+ // A kind that lives with its chat never waits for retention.
967
+ ...(policy.keep === 'window'
968
+ ? { [expiryIndexKey(record)]: record.id }
969
+ : {}),
848
970
  });
849
971
  } else {
850
972
  record = await this.readFrom(tx, command.id, command.input.sessionId);
@@ -33,9 +33,9 @@ import {
33
33
  const manifest: PluginManifest = {
34
34
  title: 'Portal',
35
35
  summary:
36
- "The user's Portal UI: action cards (bids, claims, questions, approvals), forms, Topics, domains, pages, navigation.",
36
+ "The user's Portal UI: action cards (everything the person sees, answers, reviews or signs), forms, Topics, domains, pages, navigation.",
37
37
  whenToUse: [
38
- 'User asks to prepare or submit a bid or claim: call `list_action_cards`, then `prepare_action_card`.',
38
+ 'What the person sees, answers, reviews or signs is an action card: ask with `ask_question_card`; for a bid or claim call `list_action_cards`, then `prepare_action_card`. Only when the Portal declares no card tools, ask with `ask_user_question`.',
39
39
  'User asks for an action the Portal FE exposes as a browser tool (declared in `state.browserTools`).',
40
40
  "A task needs a browser-side capability the server can't do alone — open a URL in the user's tab, click a Portal button, fill a form.",
41
41
  ],
@@ -67,6 +67,13 @@ const HUMAN_DECISION_TOOLS = new Set([
67
67
  'publish_recipe_release',
68
68
  ]);
69
69
 
70
+ /**
71
+ * How the Agent asks the person something: bound on every turn that declares
72
+ * it, so a question never turns into prose because the Portal capability was
73
+ * not loaded. A chat declares one of them, never both.
74
+ */
75
+ const ASKING_TOOLS = new Set(['ask_user_question', 'ask_question_card']);
76
+
70
77
  /**
71
78
  * The Portal's Topic write tool. Its commands can take a while to settle in
72
79
  * the browser (several Matrix writes), so it gets a longer window before the
@@ -213,6 +220,9 @@ export function buildBrowserTool(
213
220
  // frontend lane, so a parked call (or a question to the user) holds
214
221
  // no slot another chat needs.
215
222
  surface: 'frontend',
223
+ ...(ASKING_TOOLS.has(descriptor.name)
224
+ ? { visibility: 'always' as const }
225
+ : {}),
216
226
  // A question to the user is a write whatever the client declares:
217
227
  // it is never asked twice after a restart once it was sent. Any
218
228
  // other tool keeps the effect its declaration names, if any.
package/src/shell/app.ts CHANGED
@@ -458,6 +458,7 @@ export function createShell(
458
458
  'observe',
459
459
  'dispatch',
460
460
  'read-task',
461
+ 'repeat',
461
462
  ],
462
463
  },
463
464
  }