@nylorun/runtime 0.9.0-beta → 0.11.0-beta

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 (192) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +110 -37
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/media.d.ts +2 -2
  6. package/dist/adapters/media.js +2 -2
  7. package/dist/adapters/observe.d.ts +2 -1
  8. package/dist/adapters/observe.js +2 -3
  9. package/dist/adapters/sandbox/virtual.js +1 -1
  10. package/dist/adapters/streams/s2.d.ts +18 -0
  11. package/dist/adapters/streams/s2.js +361 -0
  12. package/dist/assets.d.ts +2 -2
  13. package/dist/assets.js +5 -3
  14. package/dist/core/executors.d.ts +15 -3
  15. package/dist/core/executors.js +40 -5
  16. package/dist/core/flow-host.d.ts +225 -0
  17. package/dist/core/flow-host.js +518 -0
  18. package/dist/core/limits.d.ts +40 -0
  19. package/dist/core/limits.js +76 -0
  20. package/dist/core/runtime.d.ts +5 -90
  21. package/dist/core/runtime.js +3 -1447
  22. package/dist/core/sandbox-routes.d.ts +40 -0
  23. package/dist/core/sandbox-routes.js +106 -0
  24. package/dist/core/turn-manifest.d.ts +50 -0
  25. package/dist/core/turn-manifest.js +66 -0
  26. package/dist/execution/memory.d.ts +54 -0
  27. package/dist/execution/memory.js +217 -0
  28. package/dist/execution/types.d.ts +125 -0
  29. package/dist/execution/types.js +52 -0
  30. package/dist/host/config-for.d.ts +17 -0
  31. package/dist/host/config-for.js +27 -0
  32. package/dist/host/config.d.ts +16 -0
  33. package/dist/host/create-host.d.ts +56 -0
  34. package/dist/host/create-host.js +420 -0
  35. package/dist/host/environment.d.ts +13 -0
  36. package/dist/host/environment.js +53 -0
  37. package/dist/host/execution.d.ts +61 -0
  38. package/dist/host/execution.js +69 -0
  39. package/dist/host/http.d.ts +39 -0
  40. package/dist/host/http.js +142 -0
  41. package/dist/host/index.d.ts +8 -0
  42. package/dist/host/index.js +7 -0
  43. package/dist/host/logger.d.ts +3 -0
  44. package/dist/host/logger.js +18 -0
  45. package/dist/host/main.d.ts +3 -0
  46. package/dist/host/main.js +201 -0
  47. package/dist/host/stack-config.d.ts +72 -0
  48. package/dist/host/stack-config.js +193 -0
  49. package/dist/index.d.ts +7 -2
  50. package/dist/index.js +4 -2
  51. package/dist/infra/database.d.ts +16 -0
  52. package/dist/infra/database.js +31 -0
  53. package/dist/infra/execution.d.ts +39 -0
  54. package/dist/infra/execution.js +68 -0
  55. package/dist/infra/index.d.ts +42 -0
  56. package/dist/infra/index.js +56 -0
  57. package/dist/infra/readiness.d.ts +33 -0
  58. package/dist/infra/readiness.js +53 -0
  59. package/dist/infra/streams.d.ts +17 -0
  60. package/dist/infra/streams.js +33 -0
  61. package/dist/infra/workers.d.ts +39 -0
  62. package/dist/infra/workers.js +31 -0
  63. package/dist/mcp/connect.d.ts +1 -0
  64. package/dist/mcp/connect.js +2 -0
  65. package/dist/mcp/pool.d.ts +3 -1
  66. package/dist/mcp/pool.js +2 -1
  67. package/dist/mcp/stdio.d.ts +2 -0
  68. package/dist/mcp/stdio.js +3 -1
  69. package/dist/model/auth-store.d.ts +1 -1
  70. package/dist/model/auth-store.js +2 -2
  71. package/dist/model/models.d.ts +4 -0
  72. package/dist/model/models.js +22 -26
  73. package/dist/model/pi-model.d.ts +2 -2
  74. package/dist/model/pi-model.js +6 -6
  75. package/dist/model/settings.d.ts +5 -1
  76. package/dist/model/settings.js +6 -2
  77. package/dist/sandbox/manager.d.ts +26 -8
  78. package/dist/sandbox/manager.js +95 -47
  79. package/dist/sandbox/select.d.ts +5 -4
  80. package/dist/sandbox/select.js +18 -13
  81. package/dist/sandbox/share.d.ts +23 -0
  82. package/dist/sandbox/share.js +58 -0
  83. package/dist/sandbox/types.d.ts +2 -2
  84. package/dist/store/canonical.d.ts +5 -0
  85. package/dist/store/canonical.js +17 -0
  86. package/dist/store/cursor.d.ts +9 -0
  87. package/dist/store/cursor.js +18 -0
  88. package/dist/store/memory.d.ts +57 -0
  89. package/dist/store/memory.js +643 -0
  90. package/dist/store/ownership.d.ts +19 -0
  91. package/dist/store/ownership.js +33 -0
  92. package/dist/store/postgres/connect.d.ts +25 -0
  93. package/dist/store/postgres/connect.js +25 -0
  94. package/dist/store/postgres/locking.d.ts +16 -0
  95. package/dist/store/postgres/locking.js +22 -0
  96. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  97. package/dist/store/postgres/migrations/001_initial.js +196 -0
  98. package/dist/store/postgres/migrations/index.d.ts +49 -0
  99. package/dist/store/postgres/migrations/index.js +75 -0
  100. package/dist/store/postgres/names.d.ts +11 -0
  101. package/dist/store/postgres/names.js +42 -0
  102. package/dist/store/postgres/store.d.ts +11 -0
  103. package/dist/store/postgres/store.js +790 -0
  104. package/dist/store/postgres/tenants.d.ts +90 -0
  105. package/dist/store/postgres/tenants.js +207 -0
  106. package/dist/store/types.d.ts +440 -0
  107. package/dist/store/types.js +10 -0
  108. package/dist/streams/basin.d.ts +23 -0
  109. package/dist/streams/basin.js +51 -0
  110. package/dist/streams/memory.d.ts +25 -0
  111. package/dist/streams/memory.js +124 -0
  112. package/dist/streams/relay.d.ts +37 -0
  113. package/dist/streams/relay.js +254 -0
  114. package/dist/streams/types.d.ts +115 -0
  115. package/dist/streams/types.js +86 -0
  116. package/dist/tenant/actions.d.ts +153 -0
  117. package/dist/tenant/actions.js +191 -0
  118. package/dist/tenant/advance.d.ts +15 -0
  119. package/dist/tenant/advance.js +439 -0
  120. package/dist/tenant/auth.d.ts +11 -0
  121. package/dist/tenant/auth.js +34 -0
  122. package/dist/tenant/commands.d.ts +3 -0
  123. package/dist/tenant/commands.js +384 -0
  124. package/dist/tenant/context.d.ts +140 -0
  125. package/dist/tenant/context.js +40 -0
  126. package/dist/tenant/effects.d.ts +20 -0
  127. package/dist/tenant/effects.js +539 -0
  128. package/dist/tenant/envelope.d.ts +7 -0
  129. package/dist/tenant/envelope.js +22 -0
  130. package/dist/tenant/ephemeral.d.ts +42 -0
  131. package/dist/tenant/ephemeral.js +224 -0
  132. package/dist/tenant/http.d.ts +32 -0
  133. package/dist/tenant/http.js +51 -0
  134. package/dist/tenant/live.d.ts +92 -0
  135. package/dist/tenant/live.js +290 -0
  136. package/dist/tenant/logger.d.ts +9 -0
  137. package/dist/tenant/logger.js +25 -0
  138. package/dist/tenant/model-setting.d.ts +16 -0
  139. package/dist/tenant/model-setting.js +12 -0
  140. package/dist/tenant/module.d.ts +38 -0
  141. package/dist/tenant/module.js +319 -0
  142. package/dist/tenant/paths.d.ts +28 -0
  143. package/dist/tenant/paths.js +91 -0
  144. package/dist/tenant/pool.d.ts +10 -0
  145. package/dist/tenant/pool.js +54 -0
  146. package/dist/tenant/principals.d.ts +13 -0
  147. package/dist/tenant/principals.js +37 -0
  148. package/dist/tenant/quarantine-error.d.ts +6 -0
  149. package/dist/tenant/quarantine-error.js +9 -0
  150. package/dist/tenant/quarantine.d.ts +27 -0
  151. package/dist/tenant/quarantine.js +72 -0
  152. package/dist/tenant/reset.d.ts +22 -0
  153. package/dist/tenant/reset.js +81 -0
  154. package/dist/tenant/routes-tenant.d.ts +12 -0
  155. package/dist/tenant/routes-tenant.js +126 -0
  156. package/dist/tenant/routes.d.ts +9 -0
  157. package/dist/tenant/routes.js +142 -0
  158. package/dist/tenant/runtime.d.ts +81 -0
  159. package/dist/tenant/runtime.js +298 -0
  160. package/dist/tenant/scheduler.d.ts +25 -0
  161. package/dist/tenant/scheduler.js +84 -0
  162. package/dist/tenant/session.d.ts +60 -0
  163. package/dist/tenant/session.js +94 -0
  164. package/dist/tenant/sessions.d.ts +34 -0
  165. package/dist/tenant/sessions.js +149 -0
  166. package/dist/tenant/status.d.ts +37 -0
  167. package/dist/tenant/status.js +130 -0
  168. package/dist/tenant/store-memory.d.ts +16 -0
  169. package/dist/tenant/store-memory.js +111 -0
  170. package/dist/tenant/store-pg.d.ts +15 -0
  171. package/dist/tenant/store-pg.js +140 -0
  172. package/dist/tenant/streams.d.ts +126 -0
  173. package/dist/tenant/streams.js +419 -0
  174. package/dist/tenant/sweep.d.ts +15 -0
  175. package/dist/tenant/sweep.js +122 -0
  176. package/dist/tenant/types.d.ts +205 -0
  177. package/dist/tenant/types.js +23 -0
  178. package/dist/tenant/worker.d.ts +84 -0
  179. package/dist/tenant/worker.js +189 -0
  180. package/dist/vault/kek.d.ts +5 -2
  181. package/dist/vault/kek.js +7 -10
  182. package/dist/vault/service.d.ts +37 -31
  183. package/dist/vault/service.js +439 -359
  184. package/dist/version.d.ts +1 -1
  185. package/dist/version.js +1 -1
  186. package/package.json +14 -11
  187. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  188. package/dist/adapters/sandbox/microsandbox.js +0 -238
  189. package/dist/core/main.js +0 -35
  190. package/dist/core/store.d.ts +0 -30
  191. package/dist/core/store.js +0 -202
  192. /package/dist/{core/main.d.ts → host/config.js} +0 -0
@@ -0,0 +1,518 @@
1
+ /**
2
+ * Flow host: the Session Store side of workflow sessions (architecture §10–12).
3
+ *
4
+ * Every function that touches state takes the caller's transaction `t: Tx` and
5
+ * is async. Nothing here publishes, notifies or schedules directly (seam rule
6
+ * 1): events go through `t.event(...)`, which the store publishes after
7
+ * commit, and wakes go through `t.afterCommit(() => schedule(id, wake))`. Functions
8
+ * that rewrite a session-scoped document lock that session first with
9
+ * `t.lockSession` (a no-op when the caller already holds it).
10
+ *
11
+ * Exported signatures:
12
+ *
13
+ * - `deriveSessionId(workflowSessionId, path, ...parts): string`
14
+ * - `deriveAgentEffectSessionId(workflowSessionId, path, request): string`
15
+ * - `isWorkflowManifest(manifest): manifest is WorkflowManifest`
16
+ * - `isFlowToolEffect(request): boolean`, `isFlowEffect(request): boolean`
17
+ * - `pathDepth(path): number`
18
+ * - `countActiveFlowWork(t, workflowSessionId, turnId): Promise<number>`
19
+ * - `commandKey(sessionId, idempotencyKey): string`
20
+ * - `linkedMessageKey(request): string`
21
+ * - `linkedTurnOf(t, agentSessionId, request): Promise<string | undefined>`
22
+ * - `linkedTurnEnd(t, effect, agent): Promise<LinkedTurnEnd | undefined>`
23
+ * - `wakeLinkedWorkflow({ t, agentSessionId, turnId, output?, failed?, cancelled?, error?, schedule }): Promise<void>`
24
+ * - `pendingAgentEffects(t): Promise<FlowEffect[]>`
25
+ * - `reconcilePendingAgentEffect({ t, effectId, schedule }): Promise<boolean>`
26
+ * - `claimedFnVerifyActions(t): Promise<Action[]>`
27
+ * - `reofferFnVerifyClaim(t, actionId): Promise<boolean>`
28
+ * - `planCancelCascade({ t, workflowSessionId, turnId }): Promise<CascadeCancelPlan>`
29
+ * - `cancelSiblingWork({ t, workflowSessionId, turnId, siblingPaths?, cancelEffectIds? }): Promise<CancelSiblingResult>`
30
+ * - `fenceWorkflowActions({ t, workflowSessionId, turnId }): Promise<{ cancelled; uncertain }>`
31
+ * - `aggregateWaits({ t, workflowSessionId }): Promise<FlowWait[]>`
32
+ * - `findInteractionOwner({ t, workflowSessionId, interactionId }): Promise<{ sessionId; path } | undefined>`
33
+ * - `foreignInteractionConflict({ t, workflowSessionId, interactionId }): Promise<{ status: 409; message; ownerSessionId } | undefined>`
34
+ * - `wakeForQueuedEffects({ t, workflowSessionId, turnId, limits, schedule }): Promise<boolean>`
35
+ *
36
+ * `schedule: (sessionId, wake) => void | Promise<void>` runs after commit, never inside `t`;
37
+ * `wake` carries the reason (`linked`, `flow`) and, where the cause has one, a dedupe key.
38
+ *
39
+ * An `agent` effect settles only from the linked turn it started. A linked agent session is
40
+ * reused by every iteration of a Loop (its id derives from the workflow and the path), so its
41
+ * status and `lastOutput` may belong to an earlier iteration's turn until this effect's message
42
+ * commits. The effect is bound to its turn through the message's idempotency key
43
+ * (`linkedMessageKey`): the `commands` document of that key names the turn it opened.
44
+ *
45
+ * The Tenant sweep (`tenant/sweep.ts`) calls the reconcile and re-offer functions, one
46
+ * transaction per effect or Action.
47
+ */
48
+ import { createHash } from "node:crypto";
49
+ import { mayDispatchMore } from "./limits.js";
50
+ /** Deterministic agent session id: derive(workflowSessionId, path, …parts). */
51
+ export function deriveSessionId(workflowSessionId, path, ...parts) {
52
+ const digest = createHash("sha256")
53
+ .update([workflowSessionId, path, ...parts].join("\0"))
54
+ .digest("hex")
55
+ .slice(0, 24);
56
+ return `wf_${digest}`;
57
+ }
58
+ /**
59
+ * Linked agent session id for a flow agent effect.
60
+ * Verifier agents (`context.role === "verify-agent"`) get a unique session per
61
+ * (path, turnId, iterations) so prior verdicts cannot leak across Loop iterations.
62
+ */
63
+ export function deriveAgentEffectSessionId(workflowSessionId, path, request) {
64
+ if (request.context?.role === "verify-agent") {
65
+ const iterations = request.iterations ?? "-";
66
+ const iterParts = iterations !== "-" && iterations.length > 0 ? iterations.split(".") : [];
67
+ return deriveSessionId(workflowSessionId, path, "verify", request.turnId, ...iterParts);
68
+ }
69
+ return deriveSessionId(workflowSessionId, path);
70
+ }
71
+ export function isWorkflowManifest(manifest) {
72
+ return (!!manifest &&
73
+ typeof manifest === "object" &&
74
+ manifest.kind === "workflow");
75
+ }
76
+ /** Flow tool-node effect: `tool` with path/key and no agent capabilityId. */
77
+ export function isFlowToolEffect(request) {
78
+ return (request.kind === "tool" &&
79
+ typeof request.path === "string" &&
80
+ request.path.length > 0 &&
81
+ typeof request.key === "string" &&
82
+ request.key.length > 0 &&
83
+ request.capabilityId === undefined);
84
+ }
85
+ export function isFlowEffect(request) {
86
+ return (request.kind === "agent" ||
87
+ request.kind === "fn" ||
88
+ request.kind === "verify" ||
89
+ isFlowToolEffect(request));
90
+ }
91
+ const OPEN_ACTION = ["pending", "claimed"];
92
+ function scheduleAfterCommit(t, schedule, id, wake) {
93
+ t.afterCommit(() => schedule(id, wake));
94
+ }
95
+ /** Active agent turns + pending/claimed actions for one workflow turn. */
96
+ export async function countActiveFlowWork(t, workflowSessionId, turnId) {
97
+ const effects = await t.effectsForTurn(workflowSessionId, turnId);
98
+ const openActions = new Set((await t.actionsForSession(workflowSessionId, {
99
+ turnId,
100
+ statuses: OPEN_ACTION,
101
+ })).map((action) => action.actionId));
102
+ let n = 0;
103
+ for (const effect of effects) {
104
+ if (effect.status === "queued")
105
+ continue;
106
+ if (effect.status === "completed" || effect.status === "cancelled")
107
+ continue;
108
+ if (effect.request?.kind === "agent" && effect.status === "pending") {
109
+ n += 1;
110
+ continue;
111
+ }
112
+ if ((effect.request?.kind === "fn" ||
113
+ effect.request?.kind === "verify" ||
114
+ isFlowToolEffect(effect.request)) &&
115
+ openActions.has(effect.request.effectId))
116
+ n += 1;
117
+ }
118
+ return n;
119
+ }
120
+ /** The `commands` document key of a session command's idempotency key (`tenant/commands.ts`). */
121
+ export function commandKey(sessionId, idempotencyKey) {
122
+ return JSON.stringify([sessionId, idempotencyKey]);
123
+ }
124
+ /** The idempotency key of the message a flow `agent` effect sends its linked session. */
125
+ export function linkedMessageKey(request) {
126
+ const path = request.input?.path ??
127
+ request.path;
128
+ return `${request.turnId}:${path}:${request.iterations ?? "-"}`;
129
+ }
130
+ /**
131
+ * The linked turn an `agent` effect started: the turn its message opened, or undefined until
132
+ * that message commits.
133
+ */
134
+ export async function linkedTurnOf(t, agentSessionId, request) {
135
+ const accepted = await t.get("commands", commandKey(agentSessionId, linkedMessageKey(request)));
136
+ return accepted?.response?.turnId ?? undefined;
137
+ }
138
+ /**
139
+ * How the linked turn an `agent` effect started ended, or undefined while it has not: before
140
+ * its message commits, the linked session's status and `lastOutput` describe an earlier turn.
141
+ * `agent` is the linked session, read in the caller's transaction.
142
+ */
143
+ export async function linkedTurnEnd(t, effect, agent) {
144
+ const id = effect.agentSessionId;
145
+ if (!agent || typeof id !== "string" || agent.activeTurnId !== null)
146
+ return undefined;
147
+ const turnId = await linkedTurnOf(t, id, effect.request);
148
+ // Sessions settled before `lastTurnId` was recorded: the turn ended if none is active.
149
+ if (!turnId || (agent.lastTurnId ?? turnId) !== turnId)
150
+ return undefined;
151
+ if (agent.status === "completed")
152
+ return { turnId, status: "completed", output: agent.lastOutput ?? null };
153
+ if (agent.status === "failed" || agent.status === "cancelled")
154
+ return {
155
+ turnId,
156
+ status: agent.status,
157
+ ...(agent.error !== undefined ? { error: agent.error } : {}),
158
+ };
159
+ return undefined;
160
+ }
161
+ /**
162
+ * After a linked agent turn ends, settle the `agent` effect that started it and wake the
163
+ * workflow. `turnId` is the linked session's turn that ended; an effect bound to another turn
164
+ * (a later iteration whose message has not committed yet) is left alone.
165
+ */
166
+ export async function wakeLinkedWorkflow(input) {
167
+ const { t } = input;
168
+ const link = await t.get("links", input.agentSessionId);
169
+ if (!link)
170
+ return;
171
+ const workflow = await t.lockSession(link.workflowSessionId);
172
+ if (!workflow)
173
+ return;
174
+ const effect = await t.get("effects", link.effectId);
175
+ if (!effect || effect.status === "completed")
176
+ return;
177
+ if (workflow.activeTurnId !== link.turnId)
178
+ return;
179
+ if (!input.turnId ||
180
+ (await linkedTurnOf(t, input.agentSessionId, effect.request)) !==
181
+ input.turnId)
182
+ return;
183
+ const outcome = input.cancelled
184
+ ? {
185
+ value: {
186
+ kind: "failed",
187
+ code: "agent.cancelled",
188
+ message: input.error ?? "Agent turn was cancelled",
189
+ },
190
+ }
191
+ : input.failed
192
+ ? {
193
+ value: {
194
+ kind: "failed",
195
+ code: "agent.failed",
196
+ message: input.error ?? "Agent turn failed",
197
+ },
198
+ }
199
+ : { value: input.output ?? null };
200
+ await t.put("effects", link.effectId, {
201
+ ...effect,
202
+ status: "completed",
203
+ outcome,
204
+ });
205
+ workflow.status = "runnable";
206
+ await t.put("sessions", workflow.id, workflow);
207
+ scheduleAfterCommit(t, input.schedule, workflow.id, {
208
+ reason: "linked",
209
+ dedupeKey: `linked:${link.turnId}:${link.effectId}`,
210
+ });
211
+ }
212
+ /** Pending workflow `agent` effects with a linked session, for the Tenant sweep. */
213
+ export async function pendingAgentEffects(t) {
214
+ return (await t.effectsWithStatus(["pending"], { kinds: ["agent"] })).filter((effect) => typeof effect.agentSessionId === "string");
215
+ }
216
+ /**
217
+ * Tenant sweep: settle one pending `agent` effect whose linked turn already finished (the
218
+ * linked settle normally does this in its own transaction). Runs in its own transaction and
219
+ * locks the linked agent (child) session before the workflow (parent), per the lock order in
220
+ * `store/types.ts`. Only the linked turn the effect started settles it (`linkedTurnEnd`): the
221
+ * linked session may still show an earlier iteration's settled turn while this effect's message
222
+ * is not committed. A linked session still `running` or `runnable` is left to the sweep's
223
+ * orphan re-wake; a paused one surfaces on the workflow when the workflow settles. Returns
224
+ * true when it changed something.
225
+ */
226
+ export async function reconcilePendingAgentEffect(input) {
227
+ const { t } = input;
228
+ const found = await t.get("effects", input.effectId);
229
+ const id = found?.agentSessionId;
230
+ if (!found || found.request?.kind !== "agent" || typeof id !== "string")
231
+ return false;
232
+ const agent = await t.lockSession(id);
233
+ const workflow = await t.lockSession(found.request.sessionId);
234
+ // Read again under both locks.
235
+ const effect = await t.get("effects", input.effectId);
236
+ if (!effect || effect.status !== "pending" || !agent || !workflow)
237
+ return false;
238
+ const end = await linkedTurnEnd(t, effect, agent);
239
+ if (!end)
240
+ return false;
241
+ await wakeLinkedWorkflow({
242
+ t,
243
+ agentSessionId: id,
244
+ turnId: end.turnId,
245
+ ...(end.status === "completed"
246
+ ? { output: end.output }
247
+ : end.status === "failed"
248
+ ? { failed: true, error: end.error }
249
+ : { cancelled: true, error: end.error }),
250
+ schedule: input.schedule,
251
+ });
252
+ return true;
253
+ }
254
+ /** Claimed `fn`/`verify` Actions, for the re-offer after a Tenant opens. */
255
+ export async function claimedFnVerifyActions(t) {
256
+ return t.actionsWithStatus(["claimed"], { kinds: ["fn", "verify"] });
257
+ }
258
+ /**
259
+ * Re-offer one claimed `fn`/`verify` Action (SD-P7 / WF-C9): they are pure or repeat-safe, so
260
+ * a claim left by an executor of an earlier process is offered again at once rather than
261
+ * after its lease. The next claim bumps the generation, which fences a late result. Locks
262
+ * only the Action's session. Returns true when it re-offered.
263
+ */
264
+ export async function reofferFnVerifyClaim(t, actionId) {
265
+ const found = await t.get("actions", actionId);
266
+ if (!found)
267
+ return false;
268
+ await t.lockSession(found.sessionId);
269
+ // Read again under the session lock.
270
+ const action = await t.get("actions", actionId);
271
+ if (!action ||
272
+ action.status !== "claimed" ||
273
+ (action.kind !== "fn" && action.kind !== "verify"))
274
+ return false;
275
+ action.status = "pending";
276
+ action.claimId = null;
277
+ action.leaseExpiresAt = null;
278
+ await t.put("actions", action.actionId, action);
279
+ t.signalWork();
280
+ return true;
281
+ }
282
+ /** Path depth for deepest-first cancel ordering. */
283
+ export function pathDepth(path) {
284
+ if (!path)
285
+ return 0;
286
+ return path.split("/").filter(Boolean).length;
287
+ }
288
+ /**
289
+ * Plan a cancel cascade for a workflow session: linked agents deepest first,
290
+ * then pending → cancelled and claimed → uncertain actions (SD-P11 / WF-R53).
291
+ */
292
+ export async function planCancelCascade(input) {
293
+ const { t } = input;
294
+ const agentEntries = (await t.linkedSessions(input.workflowSessionId)).map((linked) => ({ sessionId: linked.agentSessionId, path: linked.link.path }));
295
+ agentEntries.sort((a, b) => pathDepth(b.path) - pathDepth(a.path));
296
+ const pendingActionIds = [];
297
+ const claimedActionIds = [];
298
+ const actions = await t.actionsForSession(input.workflowSessionId, {
299
+ ...(input.turnId !== null ? { turnId: input.turnId } : {}),
300
+ statuses: OPEN_ACTION,
301
+ });
302
+ for (const action of actions) {
303
+ if (action.status === "pending")
304
+ pendingActionIds.push(action.actionId);
305
+ else if (action.status === "claimed")
306
+ claimedActionIds.push(action.actionId);
307
+ }
308
+ return {
309
+ agentSessionIds: agentEntries.map((e) => e.sessionId),
310
+ pendingActionIds,
311
+ claimedActionIds,
312
+ };
313
+ }
314
+ /**
315
+ * Cancel sibling work under a Parallel/Map parent when one branch fails (PAR-R6).
316
+ * Agent turns listed in `agentSessionIds` are returned for the caller to cancel;
317
+ * pending actions → cancelled, claimed tool actions → uncertain.
318
+ * When `cancelEffectIds` is provided, those effects are marked cancelled and their
319
+ * paths are included in the sibling path set.
320
+ */
321
+ export async function cancelSiblingWork(input) {
322
+ const { t } = input;
323
+ await t.lockSession(input.workflowSessionId);
324
+ const cancelledActions = [];
325
+ const uncertainActions = [];
326
+ const agentSessionIds = [];
327
+ const siblingPaths = new Set(input.siblingPaths ?? []);
328
+ for (const effectId of input.cancelEffectIds ?? []) {
329
+ const effect = await t.get("effects", effectId);
330
+ if (!effect)
331
+ continue;
332
+ const path = typeof effect.request?.path === "string"
333
+ ? effect.request.path
334
+ : undefined;
335
+ if (path)
336
+ siblingPaths.add(path);
337
+ if (effect.status === "pending" ||
338
+ effect.status === "queued" ||
339
+ effect.status === "uncertain") {
340
+ effect.status = "cancelled";
341
+ await t.put("effects", effectId, effect);
342
+ }
343
+ }
344
+ const matchesSibling = (path) => {
345
+ if (!path)
346
+ return false;
347
+ return [...siblingPaths].some((sib) => path === sib || path.startsWith(`${sib}/`));
348
+ };
349
+ for (const { agentSessionId, link, session } of await t.linkedSessions(input.workflowSessionId)) {
350
+ if (!matchesSibling(link.path))
351
+ continue;
352
+ if (session.activeTurnId &&
353
+ ["running", "runnable", "paused", "waiting"].includes(session.status))
354
+ agentSessionIds.push(agentSessionId);
355
+ }
356
+ const actions = await t.actionsForSession(input.workflowSessionId, {
357
+ turnId: input.turnId,
358
+ statuses: OPEN_ACTION,
359
+ });
360
+ for (const action of actions) {
361
+ if (!matchesSibling(action.path))
362
+ continue;
363
+ const next = action.status === "claimed" ? "uncertain" : "cancelled";
364
+ action.status = next;
365
+ await t.put("actions", action.actionId, action);
366
+ const effect = await t.get("effects", action.actionId);
367
+ if (effect) {
368
+ effect.status = next;
369
+ await t.put("effects", action.actionId, effect);
370
+ }
371
+ if (next === "cancelled")
372
+ cancelledActions.push(action.actionId);
373
+ else
374
+ uncertainActions.push(action.actionId);
375
+ }
376
+ return { cancelledActions, uncertainActions, agentSessionIds };
377
+ }
378
+ /**
379
+ * Apply cancel fencing to workflow-session actions (pending→cancelled, claimed→uncertain).
380
+ */
381
+ export async function fenceWorkflowActions(input) {
382
+ const { t } = input;
383
+ await t.lockSession(input.workflowSessionId);
384
+ const turn = input.turnId !== null ? { turnId: input.turnId } : {};
385
+ const cancelled = [];
386
+ const uncertain = [];
387
+ const actions = await t.actionsForSession(input.workflowSessionId, {
388
+ ...turn,
389
+ statuses: OPEN_ACTION,
390
+ });
391
+ for (const action of actions) {
392
+ const next = action.status === "claimed" ? "uncertain" : "cancelled";
393
+ action.status = next;
394
+ await t.put("actions", action.actionId, action);
395
+ const effect = await t.get("effects", action.actionId);
396
+ if (effect) {
397
+ effect.status = next;
398
+ await t.put("effects", action.actionId, effect);
399
+ }
400
+ if (next === "cancelled")
401
+ cancelled.push(action.actionId);
402
+ else
403
+ uncertain.push(action.actionId);
404
+ }
405
+ // Drop queued effects that never started.
406
+ const queued = await t.effectsForSession(input.workflowSessionId, { ...turn, statuses: ["queued"] });
407
+ for (const effect of queued) {
408
+ effect.status = "cancelled";
409
+ await t.put("effects", effect.request.effectId, effect);
410
+ cancelled.push(effect.request.effectId);
411
+ }
412
+ return { cancelled, uncertain };
413
+ }
414
+ function waitsFromSession(sessionId, path, raw) {
415
+ if (!Array.isArray(raw))
416
+ return [];
417
+ const waits = [];
418
+ for (const call of raw) {
419
+ const interaction = call.interaction ?? call;
420
+ const interactionId = String(interaction?.id ?? call.interactionId ?? "");
421
+ if (!interactionId)
422
+ continue;
423
+ waits.push({
424
+ sessionId,
425
+ path,
426
+ interactionId,
427
+ kind: String(interaction?.kind ?? call.kind ?? "approval"),
428
+ ...(call.invocationId !== undefined
429
+ ? { invocationId: String(call.invocationId) }
430
+ : {}),
431
+ ...(call.wait !== undefined ? { wait: call.wait } : {}),
432
+ ...(call.status !== undefined ? { status: String(call.status) } : {}),
433
+ });
434
+ }
435
+ return waits;
436
+ }
437
+ /**
438
+ * Aggregate human waits across the workflow session and linked agent sessions (WF-R51).
439
+ */
440
+ export async function aggregateWaits(input) {
441
+ const { t } = input;
442
+ const workflow = await t.get("sessions", input.workflowSessionId);
443
+ if (!workflow)
444
+ return [];
445
+ const waits = [];
446
+ // Workflow-owned interactions (tool-node / verify approvals) live on the workflow session.
447
+ if (Array.isArray(workflow.waits))
448
+ waits.push(...waitsFromSession(workflow.id, "", workflow.waits));
449
+ else if (workflow.waits &&
450
+ typeof workflow.waits === "object" &&
451
+ Array.isArray(workflow.waits.interactions))
452
+ waits.push(...waitsFromSession(workflow.id, "", workflow.waits.interactions));
453
+ for (const { agentSessionId, link, session } of await t.linkedSessions(input.workflowSessionId)) {
454
+ if (session.status !== "paused")
455
+ continue;
456
+ waits.push(...waitsFromSession(agentSessionId, link.path, session.waits));
457
+ }
458
+ return waits;
459
+ }
460
+ /**
461
+ * Look up which session owns an interaction id. Used for 409 foreign approvals (WF-R52).
462
+ */
463
+ export async function findInteractionOwner(input) {
464
+ const waits = await aggregateWaits(input);
465
+ const hit = waits.find((w) => w.interactionId === input.interactionId);
466
+ if (hit)
467
+ return { sessionId: hit.sessionId, path: hit.path };
468
+ // Also scan linked sessions' waits that aggregateWaits skipped (not paused).
469
+ for (const { agentSessionId, link, session } of await input.t.linkedSessions(input.workflowSessionId)) {
470
+ const raw = session.waits;
471
+ if (!Array.isArray(raw))
472
+ continue;
473
+ for (const call of raw) {
474
+ const id = String(call.interaction?.id ?? call.interactionId ?? "");
475
+ if (id === input.interactionId)
476
+ return { sessionId: agentSessionId, path: link.path };
477
+ }
478
+ }
479
+ return undefined;
480
+ }
481
+ /**
482
+ * If approve/respond targets an interaction owned by a linked agent session,
483
+ * return a 409 message naming that session (WF-R52 / LOOP-A4).
484
+ */
485
+ export async function foreignInteractionConflict(input) {
486
+ const owner = await findInteractionOwner(input);
487
+ if (!owner)
488
+ return undefined;
489
+ if (owner.sessionId === input.workflowSessionId)
490
+ return undefined;
491
+ return {
492
+ status: 409,
493
+ message: `Interaction belongs to session ${owner.sessionId}`,
494
+ ownerSessionId: owner.sessionId,
495
+ };
496
+ }
497
+ /**
498
+ * When concurrency slots free, re-enter the workflow so queued effects can start
499
+ * (status stays `queued` until the effect resolver dispatches them). Returns
500
+ * true when a wake was scheduled.
501
+ */
502
+ export async function wakeForQueuedEffects(input) {
503
+ const { t } = input;
504
+ if (!mayDispatchMore(await countActiveFlowWork(t, input.workflowSessionId, input.turnId), input.limits))
505
+ return false;
506
+ const queued = await t.effectsForTurn(input.workflowSessionId, input.turnId, ["queued"]);
507
+ if (queued.length === 0)
508
+ return false;
509
+ const workflow = await t.lockSession(input.workflowSessionId);
510
+ if (workflow && workflow.status === "waiting") {
511
+ workflow.status = "runnable";
512
+ await t.put("sessions", workflow.id, workflow);
513
+ }
514
+ scheduleAfterCommit(t, input.schedule, input.workflowSessionId, {
515
+ reason: "flow",
516
+ });
517
+ return true;
518
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Operator flow limits (`workflows.md` §13).
3
+ * Host configuration only — never workflow API settings.
4
+ */
5
+ export type FlowLimits = {
6
+ readonly maxConcurrency: number;
7
+ readonly maxMapItems: number;
8
+ readonly maxLoopIterations: number;
9
+ };
10
+ export declare const FLOW_LIMIT_DEFAULTS: FlowLimits;
11
+ export declare const FLOW_LIMIT_ENV: {
12
+ readonly maxConcurrency: "NYLORUN_FLOW_MAX_CONCURRENCY";
13
+ readonly maxMapItems: "NYLORUN_FLOW_MAX_MAP_ITEMS";
14
+ readonly maxLoopIterations: "NYLORUN_FLOW_MAX_LOOP_ITERATIONS";
15
+ };
16
+ export type FlowLimitErrorCode = "map.too-many-items" | "loop.too-many-iterations";
17
+ export declare class FlowLimitError extends Error {
18
+ readonly code: FlowLimitErrorCode;
19
+ readonly path?: string;
20
+ constructor(code: FlowLimitErrorCode, message: string, path?: string);
21
+ }
22
+ /**
23
+ * Resolve limits from `RuntimeOptions.flow` overrides and optional env snapshot.
24
+ * Does not read ambient process environment — callers pass an explicit env map.
25
+ */
26
+ export declare function resolveFlowLimits(input?: {
27
+ readonly flow?: Partial<FlowLimits> | null;
28
+ readonly env?: Readonly<Record<string, string | undefined>>;
29
+ }): FlowLimits;
30
+ /** Further ready work waits when active >= maxConcurrency (not an error). */
31
+ export declare function mayDispatchMore(activeCount: number, limits: Pick<FlowLimits, "maxConcurrency">): boolean;
32
+ /**
33
+ * Map item ceiling. Exceeding raises `map.too-many-items` and nothing should start.
34
+ */
35
+ export declare function assertMapItemCount(count: number, limits: Pick<FlowLimits, "maxMapItems">, path?: string): void;
36
+ /**
37
+ * Loop operator ceiling. Exceeding raises `loop.too-many-iterations`.
38
+ * `n` is the 1-based iteration about to run.
39
+ */
40
+ export declare function assertLoopIteration(n: number, limits: Pick<FlowLimits, "maxLoopIterations">, path?: string): void;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Operator flow limits (`workflows.md` §13).
3
+ * Host configuration only — never workflow API settings.
4
+ */
5
+ export const FLOW_LIMIT_DEFAULTS = {
6
+ maxConcurrency: 8,
7
+ maxMapItems: 1000,
8
+ maxLoopIterations: 100,
9
+ };
10
+ export const FLOW_LIMIT_ENV = {
11
+ maxConcurrency: "NYLORUN_FLOW_MAX_CONCURRENCY",
12
+ maxMapItems: "NYLORUN_FLOW_MAX_MAP_ITEMS",
13
+ maxLoopIterations: "NYLORUN_FLOW_MAX_LOOP_ITERATIONS",
14
+ };
15
+ export class FlowLimitError extends Error {
16
+ code;
17
+ path;
18
+ constructor(code, message, path) {
19
+ super(message);
20
+ this.name = "FlowLimitError";
21
+ this.code = code;
22
+ if (path !== undefined)
23
+ this.path = path;
24
+ }
25
+ }
26
+ function parsePositiveInt(raw, field) {
27
+ if (raw === undefined || raw === "")
28
+ return undefined;
29
+ const n = Number(raw);
30
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0)
31
+ throw new Error(`${field} must be a positive integer`);
32
+ return n;
33
+ }
34
+ /**
35
+ * Resolve limits from `RuntimeOptions.flow` overrides and optional env snapshot.
36
+ * Does not read ambient process environment — callers pass an explicit env map.
37
+ */
38
+ export function resolveFlowLimits(input) {
39
+ const env = input?.env ?? {};
40
+ const flow = input?.flow ?? {};
41
+ const fromEnv = {
42
+ maxConcurrency: parsePositiveInt(env[FLOW_LIMIT_ENV.maxConcurrency], FLOW_LIMIT_ENV.maxConcurrency),
43
+ maxMapItems: parsePositiveInt(env[FLOW_LIMIT_ENV.maxMapItems], FLOW_LIMIT_ENV.maxMapItems),
44
+ maxLoopIterations: parsePositiveInt(env[FLOW_LIMIT_ENV.maxLoopIterations], FLOW_LIMIT_ENV.maxLoopIterations),
45
+ };
46
+ const pick = (field, override, envValue) => {
47
+ const value = override ?? envValue ?? FLOW_LIMIT_DEFAULTS[field];
48
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0)
49
+ throw new Error(`flow.${field} must be a positive integer`);
50
+ return value;
51
+ };
52
+ return {
53
+ maxConcurrency: pick("maxConcurrency", flow.maxConcurrency, fromEnv.maxConcurrency),
54
+ maxMapItems: pick("maxMapItems", flow.maxMapItems, fromEnv.maxMapItems),
55
+ maxLoopIterations: pick("maxLoopIterations", flow.maxLoopIterations, fromEnv.maxLoopIterations),
56
+ };
57
+ }
58
+ /** Further ready work waits when active >= maxConcurrency (not an error). */
59
+ export function mayDispatchMore(activeCount, limits) {
60
+ return activeCount < limits.maxConcurrency;
61
+ }
62
+ /**
63
+ * Map item ceiling. Exceeding raises `map.too-many-items` and nothing should start.
64
+ */
65
+ export function assertMapItemCount(count, limits, path) {
66
+ if (count > limits.maxMapItems)
67
+ throw new FlowLimitError("map.too-many-items", `Map produced ${count} items; maxMapItems is ${limits.maxMapItems}`, path);
68
+ }
69
+ /**
70
+ * Loop operator ceiling. Exceeding raises `loop.too-many-iterations`.
71
+ * `n` is the 1-based iteration about to run.
72
+ */
73
+ export function assertLoopIteration(n, limits, path) {
74
+ if (n > limits.maxLoopIterations)
75
+ throw new FlowLimitError("loop.too-many-iterations", `Loop iteration ${n} exceeds maxLoopIterations ${limits.maxLoopIterations}`, path);
76
+ }