@ixo/oracle-runtime-workers 0.13.0 → 0.15.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 (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -17,7 +17,14 @@ import * as Y from 'yjs';
17
17
  import { deleteBlock, editBlock, findParentOf } from './ydoc-helpers';
18
18
  import { getActionDef, isEventCapable } from './actions';
19
19
  import { FlowError } from './errors';
20
+ import {
21
+ MAX_STEP_INPUTS_BYTES,
22
+ jsonByteLength,
23
+ secretLiteralMessage,
24
+ secretLiteralPaths,
25
+ } from './input-policy';
20
26
  import { readFlowSpec } from './read';
27
+ import { referencesId } from './ref-syntax';
21
28
  import {
22
29
  BLOCK_ID_PREFIX,
23
30
  blockIdToStepId,
@@ -26,17 +33,116 @@ import {
26
33
  friendlyInputsToNb,
27
34
  stepIdToBlockId,
28
35
  } from './translator';
29
- import type { Condition } from './types';
36
+ import { didSchema, type Condition, type FlowStep } from './types';
30
37
 
31
38
  const DOC = 'document';
32
39
 
33
- function requireStep(doc: YDoc, stepId: string): string {
40
+ /** The step's block id; throws `step_not_found` when the flow has no such step. */
41
+ export function requireStep(doc: YDoc, stepId: string): string {
34
42
  if (!doc.getMap('qi.flow.nodes').has(stepId)) {
35
43
  throw new FlowError('step_not_found', `No step "${stepId}" in this flow.`);
36
44
  }
37
45
  return stepIdToBlockId(stepId);
38
46
  }
39
47
 
48
+ /** The ids of the flow's steps, plus `extra` (steps about to be added). */
49
+ function stepIdsOf(doc: YDoc, extra: readonly string[]): Set<string> {
50
+ return new Set([...doc.getMap('qi.flow.nodes').keys(), ...extra]);
51
+ }
52
+
53
+ /*
54
+ * Checks. Each throws the error its setter would, without writing, so a tool
55
+ * that makes several edits can validate all of them before the first change
56
+ * (a change already made would still reach the room).
57
+ */
58
+
59
+ /**
60
+ * Throw unless `inputs` may be stored: no secret outside a reference to a
61
+ * step of this flow (or of `extraStepIds`), and within the size cap. Returns
62
+ * the serialised value to store.
63
+ */
64
+ export function checkStepInputs(
65
+ doc: YDoc,
66
+ inputs: Record<string, unknown>,
67
+ extraStepIds: readonly string[] = [],
68
+ ): string {
69
+ const secrets = secretLiteralPaths(inputs, stepIdsOf(doc, extraStepIds));
70
+ if (secrets.length > 0)
71
+ throw new FlowError('validation_failed', secretLiteralMessage(secrets));
72
+ const nb = friendlyInputsToNb(inputs) ?? {};
73
+ const bytes = jsonByteLength(nb);
74
+ if (bytes > MAX_STEP_INPUTS_BYTES)
75
+ throw new FlowError(
76
+ 'validation_failed',
77
+ `A step's inputs may be at most ${MAX_STEP_INPUTS_BYTES} bytes of JSON (got ${bytes}).`,
78
+ );
79
+ return JSON.stringify(nb);
80
+ }
81
+
82
+ /** Throw unless every condition reads a step of this flow (or of `extraStepIds`). */
83
+ export function checkConditions(
84
+ doc: YDoc,
85
+ conditions: readonly Condition[],
86
+ extraStepIds: readonly string[] = [],
87
+ ): void {
88
+ // A condition on a step that is not in the flow can never be evaluated.
89
+ const ids = stepIdsOf(doc, extraStepIds);
90
+ for (const condition of conditions)
91
+ if (!ids.has(condition.fromStep))
92
+ throw new FlowError(
93
+ 'step_not_found',
94
+ `No step "${condition.fromStep}" in this flow.`,
95
+ );
96
+ }
97
+
98
+ /** Throw unless `assignTo` is a DID (assigning grants run authority). */
99
+ export function checkAssignee(assignTo: string | undefined): void {
100
+ if (assignTo !== undefined && !didSchema.safeParse(assignTo).success)
101
+ throw new FlowError(
102
+ 'validation_failed',
103
+ `"${assignTo}" is not a DID. Assigning a step authorises that DID to run it, so it must be a did: value.`,
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Throw unless `stepId` may auto-trigger on `onEvent.fromStep`: a step of
109
+ * this flow whose action can emit events. Returns the source's block id.
110
+ */
111
+ export function checkEventTrigger(
112
+ doc: YDoc,
113
+ stepId: string,
114
+ onEvent: { fromStep: string; event: string },
115
+ ): string {
116
+ if (onEvent.fromStep.startsWith(BLOCK_ID_PREFIX)) {
117
+ throw new FlowError(
118
+ 'validation_failed',
119
+ `"${onEvent.fromStep}" is an internal block id — use the short step id ("${blockIdToStepId(onEvent.fromStep)}").`,
120
+ );
121
+ }
122
+ const sourceBlockId = requireStep(doc, onEvent.fromStep);
123
+
124
+ // Same event-capability rule validate_flow enforces at plan level.
125
+ const source = doc.getMap('qi.flow.nodes').get(onEvent.fromStep);
126
+ const registryType =
127
+ source instanceof Y.Map ? source.get('registryType') : undefined;
128
+ const can = source instanceof Y.Map ? source.get('can') : undefined;
129
+ const action =
130
+ typeof registryType === 'string'
131
+ ? registryType
132
+ : typeof can === 'string'
133
+ ? canToAction(can)
134
+ : undefined;
135
+ const def = action ? getActionDef(action) : undefined;
136
+ if (def && !isEventCapable(def)) {
137
+ throw new FlowError(
138
+ 'validation_failed',
139
+ `Step "${stepId}" can't auto-trigger on "${onEvent.fromStep}" — its action "${action}" cannot emit events. ` +
140
+ 'Use ordering (after) + an input reference instead.',
141
+ );
142
+ }
143
+ return sourceBlockId;
144
+ }
145
+
40
146
  /** Write a set of value props onto a step's block (per-block, lossless). */
41
147
  export function setStepProps(
42
148
  doc: YDoc,
@@ -47,14 +153,18 @@ export function setStepProps(
47
153
  editBlock(doc, { blockId, attributes: { props }, docName: DOC });
48
154
  }
49
155
 
156
+ /**
157
+ * Replace a step's inputs. Every path that writes inputs ends here, so the
158
+ * secret and size rules are enforced here as well as in the tool schemas.
159
+ */
50
160
  export function setStepInputs(
51
161
  doc: YDoc,
52
162
  stepId: string,
53
163
  inputs: Record<string, unknown>,
54
164
  ): void {
55
- setStepProps(doc, stepId, {
56
- inputs: JSON.stringify(friendlyInputsToNb(inputs) ?? {}),
57
- });
165
+ requireStep(doc, stepId);
166
+ const serialized = checkStepInputs(doc, inputs);
167
+ setStepExecutionProps(doc, stepId, { inputs: serialized });
58
168
  }
59
169
 
60
170
  export function setStepConditions(
@@ -62,11 +172,54 @@ export function setStepConditions(
62
172
  stepId: string,
63
173
  conditions: Condition[],
64
174
  ): void {
65
- setStepProps(doc, stepId, {
175
+ requireStep(doc, stepId);
176
+ checkConditions(doc, conditions);
177
+ setStepExecutionProps(doc, stepId, {
66
178
  conditions: conditions.length > 0 ? buildConditionsProp(conditions) : '',
67
179
  });
68
180
  }
69
181
 
182
+ export function setStepSemanticGate(
183
+ doc: YDoc,
184
+ stepId: string,
185
+ gate: FlowStep['semanticGate'] | null,
186
+ ): void {
187
+ setStepExecutionProps(doc, stepId, {
188
+ semanticGate: gate ? JSON.stringify(gate) : '',
189
+ });
190
+ }
191
+
192
+ function setStepExecutionProps(
193
+ doc: YDoc,
194
+ stepId: string,
195
+ props: Record<string, string>,
196
+ ): void {
197
+ requireStep(doc, stepId);
198
+ const nodes = doc.getMap('qi.flow.nodes');
199
+ const node = nodes.get(stepId);
200
+ if (!(node instanceof Y.Map))
201
+ throw new FlowError(
202
+ 'step_not_found',
203
+ `No compiled step "${stepId}" in this flow.`,
204
+ );
205
+ doc.transact(() => {
206
+ setStepProps(doc, stepId, props);
207
+ const existing: unknown = node.get('props');
208
+ if (existing instanceof Y.Map) {
209
+ for (const [key, value] of Object.entries(props))
210
+ existing.set(key, value);
211
+ } else {
212
+ node.set('props', {
213
+ ...(existing && typeof existing === 'object' && !Array.isArray(existing)
214
+ ? existing
215
+ : {}),
216
+ ...props,
217
+ });
218
+ }
219
+ for (const [key, value] of Object.entries(props)) node.set(key, value);
220
+ });
221
+ }
222
+
70
223
  export function setStepSchedule(
71
224
  doc: YDoc,
72
225
  stepId: string,
@@ -88,6 +241,8 @@ export function setStepAssignment(
88
241
  // The portal shows + nudges the assignee from `props.assignment`; the
89
242
  // `authorisedActors` whitelist also lets that actor run the step. Write both
90
243
  // so the assignee is visible AND authorized; clear both when unassigned.
244
+ // Because this grants run authority, only a well-formed DID is accepted.
245
+ checkAssignee(assignTo);
91
246
  setStepProps(doc, stepId, {
92
247
  assignment: assignTo
93
248
  ? JSON.stringify({ assignedActor: { did: assignTo } })
@@ -176,34 +331,7 @@ export function setStepEventTrigger(
176
331
  stepId: string,
177
332
  onEvent: { fromStep: string; event: string },
178
333
  ): void {
179
- if (onEvent.fromStep.startsWith(BLOCK_ID_PREFIX)) {
180
- throw new FlowError(
181
- 'validation_failed',
182
- `"${onEvent.fromStep}" is an internal block id — use the short step id ("${blockIdToStepId(onEvent.fromStep)}").`,
183
- );
184
- }
185
- const sourceBlockId = requireStep(doc, onEvent.fromStep);
186
-
187
- // Same event-capability rule validate_flow enforces at plan level (spec §2.3).
188
- const source = doc.getMap('qi.flow.nodes').get(onEvent.fromStep);
189
- const registryType =
190
- source instanceof Y.Map ? source.get('registryType') : undefined;
191
- const can = source instanceof Y.Map ? source.get('can') : undefined;
192
- const action =
193
- typeof registryType === 'string'
194
- ? registryType
195
- : typeof can === 'string'
196
- ? canToAction(can)
197
- : undefined;
198
- const def = action ? getActionDef(action) : undefined;
199
- if (def && !isEventCapable(def)) {
200
- throw new FlowError(
201
- 'validation_failed',
202
- `Step "${stepId}" can't auto-trigger on "${onEvent.fromStep}" — its action "${action}" cannot emit events. ` +
203
- 'Use ordering (after) + an input reference instead.',
204
- );
205
- }
206
-
334
+ const sourceBlockId = checkEventTrigger(doc, stepId, onEvent);
207
335
  setStepProps(doc, stepId, {
208
336
  trigger: JSON.stringify({
209
337
  type: 'block.event',
@@ -221,10 +349,9 @@ function referrersOf(doc: YDoc, ref: string, stepId: string): string[] {
221
349
  const referrers: string[] = [];
222
350
  for (const step of flow.steps) {
223
351
  if (step.id === stepId) continue;
224
- const refsInput = Object.values(step.inputs ?? {}).some(
225
- (v) =>
226
- typeof v === 'string' && new RegExp(`\\{\\{\\s*${stepId}\\.`).test(v),
227
- );
352
+ // Inputs nest (an email's `variables` map, arrays of recipients), and a
353
+ // reference may sit inside a longer template string.
354
+ const refsInput = referencesId(step.inputs ?? {}, stepId);
228
355
  const conds = [
229
356
  ...(step.runWhen ? [step.runWhen] : []),
230
357
  ...(step.conditions ?? []),
@@ -259,8 +386,14 @@ export function removeStep(doc: YDoc, ref: string, stepId: string): void {
259
386
  const edges = doc.getMap('qi.flow.edges');
260
387
  const toDelete: string[] = [];
261
388
  edges.forEach((value, edgeId) => {
262
- const edge = value as { source?: unknown; target?: unknown } | undefined;
263
- if (edge && (edge.source === stepId || edge.target === stepId))
389
+ // The compiler stores each edge as a Y.Map; plain objects are tolerated.
390
+ const endpoint = (key: 'source' | 'target'): unknown =>
391
+ value instanceof Y.Map
392
+ ? value.get(key)
393
+ : value && typeof value === 'object'
394
+ ? Object.getOwnPropertyDescriptor(value, key)?.value
395
+ : undefined;
396
+ if (endpoint('source') === stepId || endpoint('target') === stepId)
264
397
  toDelete.push(edgeId);
265
398
  });
266
399
  for (const edgeId of toDelete) edges.delete(edgeId);
@@ -269,9 +402,57 @@ export function removeStep(doc: YDoc, ref: string, stepId: string): void {
269
402
  deleteBlock(doc, { blockId, docName: DOC });
270
403
  }
271
404
 
405
+ /**
406
+ * Move a step's block in the document so it sits next to its neighbours in
407
+ * `order`: right before the next step's block, or right after the previous
408
+ * one's. The document also holds headings and notes between step blocks, so
409
+ * an index taken from `order` would land in the wrong place.
410
+ *
411
+ * Yjs has no move operation: the block is cloned and the original deleted, so
412
+ * an edit another client makes to the original at the same moment is lost.
413
+ * The block is left alone when it already sits between the right neighbours.
414
+ */
415
+ export function placeStepBlock(
416
+ doc: YDoc,
417
+ stepId: string,
418
+ order: readonly string[],
419
+ ): void {
420
+ const fragment = doc.getXmlFragment(DOC);
421
+ const found = findParentOf(fragment, stepIdToBlockId(stepId));
422
+ if (!found) return;
423
+ const { parent } = found;
424
+ const siblings = parent.toArray();
425
+ // A neighbour's index among the same siblings, or -1 when it has no block
426
+ // or its block lives under a different parent.
427
+ const indexIn = (id: string | undefined): number => {
428
+ if (id === undefined) return -1;
429
+ const located = findParentOf(fragment, stepIdToBlockId(id));
430
+ return located && located.parent === parent ? located.index : -1;
431
+ };
432
+ const at = order.indexOf(stepId);
433
+ const before = indexIn(order[at - 1]);
434
+ const after = indexIn(order[at + 1]);
435
+ const current = found.index;
436
+ if ((before < 0 || before < current) && (after < 0 || current < after))
437
+ return;
438
+
439
+ const element = siblings[current];
440
+ if (!(element instanceof Y.XmlElement)) return;
441
+ // Position in the sibling list once the original is removed.
442
+ const shift = (index: number): number =>
443
+ index > current ? index - 1 : index;
444
+ const target =
445
+ after >= 0 ? shift(after) : before >= 0 ? shift(before) + 1 : current;
446
+ doc.transact(() => {
447
+ const clone = element.clone();
448
+ parent.delete(current, 1);
449
+ parent.insert(target, [clone]);
450
+ });
451
+ }
452
+
272
453
  /** Reorder a step to a new 0-based index. Sequence is display-only; runtime/edges untouched. */
273
454
  export function reorderStep(doc: YDoc, stepId: string, toIndex: number): void {
274
- const blockId = requireStep(doc, stepId);
455
+ requireStep(doc, stepId);
275
456
  const orderArr = doc.getArray<string>('qi.flow.order');
276
457
  const current = orderArr.toArray();
277
458
  const from = current.indexOf(stepId);
@@ -285,19 +466,9 @@ export function reorderStep(doc: YDoc, stepId: string, toIndex: number): void {
285
466
  without.splice(target, 0, stepId);
286
467
 
287
468
  doc.transact(() => {
288
- // Move the fragment block by CLONE (yjs can't reinsert a deleted element).
289
- const fragment = doc.getXmlFragment(DOC);
290
- const found = findParentOf(fragment, blockId);
291
- if (found) {
292
- const element = found.parent.toArray()[found.index];
293
- if (element instanceof Y.XmlElement) {
294
- const clone = element.clone();
295
- found.parent.delete(found.index, 1);
296
- found.parent.insert(Math.min(target, found.parent.length), [clone]);
297
- }
298
- }
299
469
  orderArr.delete(0, orderArr.length);
300
470
  orderArr.push(without);
471
+ placeStepBlock(doc, stepId, without);
301
472
  });
302
473
  }
303
474
 
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Uniform error model (spec §6.1). Tools never throw to the agent; expected
3
3
  * failures come back as `{ ok: false, error: { code, message } }` with
4
- * friendly, leak-safe messages.
4
+ * friendly, leak-safe messages. Invalid tool input (a zod parse failure,
5
+ * including a size limit) is `validation_failed` with the schema's messages.
5
6
  */
7
+ import { z } from 'zod';
8
+
6
9
  export type FlowErrorCode =
7
10
  | 'no_flow_ref'
8
11
  | 'not_in_room'
@@ -11,6 +14,14 @@ export type FlowErrorCode =
11
14
  | 'step_not_found'
12
15
  | 'referenced'
13
16
  | 'unknown_action'
17
+ | 'needs_access'
18
+ /**
19
+ * A write the homeserver never acknowledged (server errors, a network
20
+ * failure, or the wait ran out): it may or may not have been saved. The
21
+ * same code the editor uses, so the runtime keeps the write's claim and
22
+ * does not blindly run the identical write again.
23
+ */
24
+ | 'write_not_saved'
14
25
  | 'error';
15
26
 
16
27
  export class FlowError extends Error {
@@ -33,6 +44,16 @@ export function toToolError(err: unknown): ToolErrorResult {
33
44
  if (err instanceof FlowError) {
34
45
  return { ok: false, error: { code: err.code, message: err.message } };
35
46
  }
47
+ if (err instanceof z.ZodError) {
48
+ const message = err.issues
49
+ .map((issue) =>
50
+ issue.path.length > 0
51
+ ? `${issue.path.join('.')}: ${issue.message}`
52
+ : issue.message,
53
+ )
54
+ .join(' ');
55
+ return { ok: false, error: { code: 'validation_failed', message } };
56
+ }
36
57
  const message = err instanceof Error ? err.message : String(err);
37
58
  return { ok: false, error: { code: 'error', message } };
38
59
  }
@@ -10,7 +10,12 @@
10
10
  */
11
11
  import type { Doc as YDoc } from 'yjs';
12
12
  import type { MatrixClient } from 'matrix-js-sdk';
13
- import { MatrixProviderManager, type AppConfig } from '../editor/provider';
13
+ import {
14
+ DocWriteGuard,
15
+ MatrixProviderManager,
16
+ type AppConfig,
17
+ type DocWriteFailure,
18
+ } from '../editor/provider';
14
19
  import { buildBlocknoteToolsConfig } from '../editor/editor-config';
15
20
  import { resolveEditorMatrixClient } from '../editor/editor-mx';
16
21
  import { isUserInRoom } from '../editor/room-membership';
@@ -58,17 +63,83 @@ export async function requireRoomMembership(
58
63
  }
59
64
 
60
65
  /**
61
- * Resolve a Matrix client for compile-driven authoring (`setupFlowFromBaseUcan`),
62
- * which manages its own provider/doc rather than taking the one `withFlowDoc` opens.
66
+ * The tool error for a write that did not reach the room:
67
+ * - refused as forbidden → `needs_access` (nothing was written);
68
+ * - refused with a client error (e.g. too large) → `error` (nothing was written);
69
+ * - not acknowledged (server errors, network, timeout) → `write_not_saved`:
70
+ * the change may have landed, so the flow must be re-read before any retry.
63
71
  */
64
- export async function resolveFlowsMatrixClient(
72
+ function writeFailureError(failure: DocWriteFailure): FlowError {
73
+ if (failure.kind === 'forbidden')
74
+ return new FlowError(
75
+ 'needs_access',
76
+ 'The flow was not saved: this assistant is not allowed to edit this flow. ' +
77
+ 'Ask a room admin to give it edit rights, then try again.',
78
+ );
79
+ if (!failure.mayHaveLanded)
80
+ return new FlowError(
81
+ 'error',
82
+ `The flow was not saved: the homeserver refused the change (${failure.detail}).`,
83
+ );
84
+ return new FlowError(
85
+ 'write_not_saved',
86
+ `The change may or may not have been saved: the homeserver did not acknowledge it (${failure.detail}). ` +
87
+ 'Re-read the flow (read_flow or get_step) before retrying, so the change is not applied twice.',
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Run compile-driven authoring (`setupFlowFromBaseUcan`), which opens and
93
+ * disposes its own provider, against a Matrix client whose document writes
94
+ * are bounded ({@link DocWriteGuard}, `drain` mode: the editor's own writer
95
+ * is configured to retry rejected writes for minutes). A write the
96
+ * homeserver refused or that could not be delivered becomes a tool error.
97
+ */
98
+ export async function withFlowsCompileClient<T>(
65
99
  rtCtx: RuntimeContext,
66
100
  injectedClient: MatrixClient | undefined,
67
- ): Promise<MatrixClient> {
68
- return resolveEditorMatrixClient({
101
+ fn: (client: MatrixClient) => Promise<T>,
102
+ ): Promise<T> {
103
+ const client = await resolveEditorMatrixClient({
69
104
  ...(await readMatrixCreds(rtCtx)),
70
105
  matrixClient: injectedClient,
71
106
  });
107
+ const guard = new DocWriteGuard(client, { onGiveUp: 'drain' });
108
+ try {
109
+ const result = await raceAbort(rtCtx, () => fn(guard.client));
110
+ if (guard.failure) throw writeFailureError(guard.failure);
111
+ return result;
112
+ } finally {
113
+ guard.close();
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Settle with `start()`'s result, or reject as soon as the request is
119
+ * aborted. `start` runs only when the request is still live: a promise
120
+ * created before an abort check that then throws would be left with no
121
+ * handler, and its later rejection would go unhandled. Once started, the
122
+ * work stays attached to the race, so a rejection it produces after the
123
+ * abort is handled (the caller already has the abort as its error).
124
+ */
125
+ function raceAbort<T>(
126
+ rtCtx: RuntimeContext,
127
+ start: () => Promise<T>,
128
+ ): Promise<T> {
129
+ const signal = rtCtx.abortSignal;
130
+ signal.throwIfAborted();
131
+ const work = start();
132
+ let onAbort: (() => void) | undefined;
133
+ const aborted = new Promise<never>((_resolve, reject) => {
134
+ onAbort = () => {
135
+ const reason: unknown = signal.reason;
136
+ reject(reason instanceof Error ? reason : new Error('Request aborted'));
137
+ };
138
+ signal.addEventListener('abort', onAbort, { once: true });
139
+ });
140
+ return Promise.race([work, aborted]).finally(() => {
141
+ if (onAbort) signal.removeEventListener('abort', onAbort);
142
+ });
72
143
  }
73
144
 
74
145
  function buildAppConfig(creds: MatrixCreds, roomId: string): AppConfig {
@@ -82,7 +153,15 @@ function buildAppConfig(creds: MatrixCreds, roomId: string): AppConfig {
82
153
 
83
154
  /**
84
155
  * Resolve the flow ref, enforce the room-membership guard (fail closed), open
85
- * the room's Y.Doc, run `fn`, and always dispose the provider.
156
+ * the room's Y.Doc, run `fn`, wait until every change `fn` made has reached
157
+ * the room, and always dispose the provider — on success, on a tool error,
158
+ * on abort and on a timeout.
159
+ *
160
+ * `fn` returning is not success on its own: Y.Doc edits apply locally at
161
+ * once and reach Matrix later. A forbidden write is `needs_access`; one
162
+ * refused with a client error is an `error`; one the homeserver never
163
+ * acknowledged (server errors, network, or the flush timeout ran out) is
164
+ * `write_not_saved` — see {@link writeFailureError}.
86
165
  */
87
166
  export async function withFlowDoc<T>(
88
167
  rtCtx: RuntimeContext,
@@ -90,6 +169,7 @@ export async function withFlowDoc<T>(
90
169
  injectedClient: MatrixClient | undefined,
91
170
  fn: (doc: YDoc, roomId: string) => Promise<T>,
92
171
  ): Promise<T> {
172
+ rtCtx.abortSignal.throwIfAborted();
93
173
  const roomId = resolveFlowRef(rtCtx, ref);
94
174
  await requireRoomMembership(rtCtx, roomId);
95
175
 
@@ -103,8 +183,11 @@ export async function withFlowDoc<T>(
103
183
  buildAppConfig(creds, roomId),
104
184
  );
105
185
  try {
106
- const { doc } = await manager.init();
107
- return await fn(doc, roomId);
186
+ const { doc } = await raceAbort(rtCtx, () => manager.init());
187
+ const result = await raceAbort(rtCtx, () => fn(doc, roomId));
188
+ const failure = await raceAbort(rtCtx, () => manager.flush());
189
+ if (failure) throw writeFailureError(failure);
190
+ return result;
108
191
  } finally {
109
192
  await manager.dispose();
110
193
  }
@@ -25,7 +25,7 @@ import type {
25
25
  PluginTool,
26
26
  RuntimeContext,
27
27
  } from '../../plugin-api/types';
28
- import { FLOWS_PLUGIN_NAME } from './prompts';
28
+ import { FLOWS_OPERATING_GUIDE, FLOWS_PLUGIN_NAME } from './prompts';
29
29
 
30
30
  export interface FlowsPluginOptions {
31
31
  /** A long-lived Matrix client owned by the host (or a test), shared across rooms. */
@@ -35,10 +35,13 @@ export interface FlowsPluginOptions {
35
35
  /**
36
36
  * Discovery surface (returned by `list_capabilities` / `load_capability`). The
37
37
  * `summary` is the one-line teaser; `whenToUse`/`whenNotToUse`/`examples` teach
38
- * the agent when and how to load it. The full operating contract lives in
39
- * `FLOWS_OPERATING_GUIDE` (prompts.ts), injected into the system prompt once
40
- * the capability is loaded — kept out of the manifest so it costs no tokens on
41
- * turns where flows is never used.
38
+ * the agent when and how to load it. The full operating contract,
39
+ * `FLOWS_OPERATING_GUIDE` (prompts.ts), is the plugin's `operatingGuide`, not
40
+ * part of the manifest: `load_capability` returns it when it loads flows (also
41
+ * when the capability router had only preloaded it for the turn), and the
42
+ * system prompt carries it on every later turn that starts with flows loaded
43
+ * on the thread. A one-turn router preload leaves the system prompt alone, and
44
+ * turns that never use flows pay no tokens for it.
42
45
  */
43
46
  const manifest: PluginManifest = {
44
47
  title: 'Flow Builder',
@@ -54,6 +57,7 @@ const manifest: PluginManifest = {
54
57
  whenNotToUse: [
55
58
  'Editing prose/pages/documents (use the editor).',
56
59
  'Actually executing/running/signing a step — that happens in the portal, by the user.',
60
+ 'Collecting a PIN, mnemonic or other secret — the portal asks for it when the user runs the step; never ask for one in chat or write one into a flow.',
57
61
  ],
58
62
  examples: [
59
63
  {
@@ -83,6 +87,8 @@ export class FlowsPlugin extends OraclePlugin {
83
87
  readonly name = FLOWS_PLUGIN_NAME;
84
88
  readonly version = '0.1.0';
85
89
  readonly manifest = manifest;
90
+ /** The operating contract — see the manifest comment above. */
91
+ override readonly operatingGuide = FLOWS_OPERATING_GUIDE;
86
92
 
87
93
  private readonly matrixClient?: MatrixClient;
88
94
 
@@ -19,10 +19,23 @@ import {
19
19
  } from './ydoc-helpers';
20
20
  import { setStepInputs } from './edit';
21
21
  import { FlowError } from './errors';
22
+ import {
23
+ MAX_FORM_ANSWERS_BYTES,
24
+ jsonByteLength,
25
+ secretLiteralMessage,
26
+ secretLiteralPaths,
27
+ } from './input-policy';
22
28
  import { inferPortType } from './port-types';
23
29
  import { readStep } from './read';
24
30
  import { stepIdToBlockId } from './translator';
25
31
 
32
+ /** Runtime states in which a form's answers belong to the user's submission. */
33
+ const LOCKED_FORM_STATES: readonly string[] = [
34
+ 'running',
35
+ 'completed',
36
+ 'awaiting_readback',
37
+ ];
38
+
26
39
  function isRecord(value: unknown): value is Record<string, unknown> {
27
40
  return typeof value === 'object' && value !== null;
28
41
  }
@@ -197,6 +210,27 @@ export function fillForm(
197
210
  merge = true,
198
211
  ): FillFormResult {
199
212
  const { blockId, questions } = readSurveySchema(doc, stepId);
213
+
214
+ // Once the user has submitted (or the step is executing), the stored
215
+ // answers are what downstream steps read; a pre-fill must never replace them.
216
+ const state = readRuntimeState(doc, blockId)[blockId]?.state;
217
+ if (typeof state === 'string' && LOCKED_FORM_STATES.includes(state)) {
218
+ throw new FlowError(
219
+ 'validation_failed',
220
+ `Step "${stepId}" is ${state}; its answers can no longer be pre-filled.`,
221
+ );
222
+ }
223
+ if (jsonByteLength(answers) > MAX_FORM_ANSWERS_BYTES) {
224
+ throw new FlowError(
225
+ 'validation_failed',
226
+ `Form answers may be at most ${MAX_FORM_ANSWERS_BYTES} bytes of JSON.`,
227
+ );
228
+ }
229
+ // Answers are values, not wiring: a secret answer is refused whatever it holds.
230
+ const secrets = secretLiteralPaths(answers, new Set());
231
+ if (secrets.length > 0)
232
+ throw new FlowError('validation_failed', secretLiteralMessage(secrets));
233
+
200
234
  const byName = new Map(questions.map((q) => [q.name, q]));
201
235
 
202
236
  const applied: string[] = [];
@@ -1,2 +1,4 @@
1
1
  export { FlowsPlugin, type FlowsPluginOptions } from './flows.plugin';
2
2
  export { FLOWS_PLUGIN_NAME, FLOWS_OPERATING_GUIDE } from './prompts';
3
+ export { readFlowDecisionContext } from './decision-context';
4
+ export type { FlowSpecRead, FlowStepRead } from './types';