@fungi.computer/braid 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/dist/cloudflare/graph-engine.d.ts +25 -0
  4. package/dist/cloudflare/graph-engine.js +46 -0
  5. package/dist/cloudflare/graph-program.d.ts +6 -0
  6. package/dist/cloudflare/graph-program.js +528 -0
  7. package/dist/cloudflare/index.d.ts +1 -0
  8. package/dist/cloudflare/index.js +1 -0
  9. package/dist/code-execution.d.ts +16 -0
  10. package/dist/code-execution.js +50 -0
  11. package/dist/code-result.d.ts +11 -0
  12. package/dist/code-result.js +19 -0
  13. package/dist/code-sandbox.d.ts +9 -0
  14. package/dist/code-sandbox.js +13 -0
  15. package/dist/compile.d.ts +41 -0
  16. package/dist/compile.js +240 -0
  17. package/dist/execution.d.ts +113 -0
  18. package/dist/execution.js +108 -0
  19. package/dist/expressions.d.ts +16 -0
  20. package/dist/expressions.js +322 -0
  21. package/dist/git-access.d.ts +59 -0
  22. package/dist/git-access.js +106 -0
  23. package/dist/index.d.ts +8 -0
  24. package/dist/index.js +8 -0
  25. package/dist/ports.d.ts +58 -0
  26. package/dist/ports.js +256 -0
  27. package/dist/runtime/arming-authority.d.ts +83 -0
  28. package/dist/runtime/arming-authority.js +90 -0
  29. package/dist/runtime/bindings.d.ts +25 -0
  30. package/dist/runtime/bindings.js +41 -0
  31. package/dist/runtime/capture.d.ts +18 -0
  32. package/dist/runtime/capture.js +99 -0
  33. package/dist/runtime/git-pass-contract.d.ts +63 -0
  34. package/dist/runtime/git-pass-contract.js +36 -0
  35. package/dist/runtime/git-pass-store.d.ts +32 -0
  36. package/dist/runtime/git-pass-store.js +82 -0
  37. package/dist/runtime/git-pass.d.ts +41 -0
  38. package/dist/runtime/git-pass.js +72 -0
  39. package/dist/runtime/graph-contract.d.ts +784 -0
  40. package/dist/runtime/graph-contract.js +122 -0
  41. package/dist/runtime/graph-dispatch.d.ts +31 -0
  42. package/dist/runtime/graph-dispatch.js +111 -0
  43. package/dist/runtime/graph-run-store.d.ts +73 -0
  44. package/dist/runtime/graph-run-store.js +267 -0
  45. package/dist/runtime/graph-start.d.ts +90 -0
  46. package/dist/runtime/graph-start.js +124 -0
  47. package/dist/runtime/index.d.ts +20 -0
  48. package/dist/runtime/index.js +20 -0
  49. package/dist/runtime/ports.d.ts +153 -0
  50. package/dist/runtime/ports.js +1 -0
  51. package/dist/runtime/run-authority.d.ts +60 -0
  52. package/dist/runtime/run-authority.js +145 -0
  53. package/dist/runtime/run-ceiling.d.ts +106 -0
  54. package/dist/runtime/run-ceiling.js +23 -0
  55. package/dist/runtime/run-member.d.ts +3 -0
  56. package/dist/runtime/run-member.js +12 -0
  57. package/dist/runtime/run-owner.d.ts +1628 -0
  58. package/dist/runtime/run-owner.js +513 -0
  59. package/dist/runtime/run-report.d.ts +14 -0
  60. package/dist/runtime/run-report.js +23 -0
  61. package/dist/runtime/schedules.d.ts +39 -0
  62. package/dist/runtime/schedules.js +149 -0
  63. package/dist/runtime/stored-trigger-rows.d.ts +12 -0
  64. package/dist/runtime/stored-trigger-rows.js +17 -0
  65. package/dist/runtime/trigger-owner.d.ts +590 -0
  66. package/dist/runtime/trigger-owner.js +280 -0
  67. package/dist/runtime/trigger-plans.d.ts +766 -0
  68. package/dist/runtime/trigger-plans.js +62 -0
  69. package/dist/runtime/trigger-router.d.ts +34 -0
  70. package/dist/runtime/trigger-router.js +143 -0
  71. package/dist/runtime/trigger-run-reservations.d.ts +28 -0
  72. package/dist/runtime/trigger-run-reservations.js +114 -0
  73. package/dist/schema.d.ts +1111 -0
  74. package/dist/schema.js +227 -0
  75. package/dist/triggers.d.ts +46 -0
  76. package/dist/triggers.js +75 -0
  77. package/dist/validate.d.ts +25 -0
  78. package/dist/validate.js +679 -0
  79. package/dist/workflow-id.d.ts +3 -0
  80. package/dist/workflow-id.js +3 -0
  81. package/package.json +95 -3
@@ -0,0 +1,528 @@
1
+ import * as S from "effect/Schema";
2
+ import * as Effect from "effect/Effect";
3
+ import { executeCode } from "../code-execution.js";
4
+ import { NonRetryableError } from "cloudflare:workflows";
5
+ import { decodeExecutionPlan, admitRunInput, parseProjection, methodInputValue, methodOutputValues, methodOutputPorts, accepts, evaluateMapping, evaluatePredicate, readPort, contentDigest, } from "../index.js";
6
+ import { graphParamsSchema, nodeAdmissionResultSchema, reviewAnswerSchema, reviewAnswerOutput, dispatchResultSchema, } from "../execution.js";
7
+ const resultLimit = 64 * 1024;
8
+ /** Durable steps one engine instance may take (Cloudflare's limit, with headroom). */
9
+ const INSTANCE_STEP_LIMIT = 1000;
10
+ const reviewChoices = (node) => node.choices === undefined
11
+ ? {}
12
+ : { choices: node.choices.map((choice) => choice.id) };
13
+ const exceedsResultLimit = (output) => new TextEncoder().encode(JSON.stringify(output)).length > resultLimit;
14
+ // Ordering dependencies include optional expressions. Only required references
15
+ // propagate branch skips. Coalesce skips only when all its alternatives are
16
+ // inactive; exists and templates still observe absence.
17
+ function mappingBranchAbsent(mapping, values, absent) {
18
+ if ("from" in mapping)
19
+ return absent(mapping.from);
20
+ if ("coalesce" in mapping)
21
+ return (mapping.coalesce.length > 0 &&
22
+ mapping.coalesce.every((part) => mappingBranchAbsent(part, values, absent)));
23
+ if ("object" in mapping)
24
+ return Object.values(mapping.object).some((part) => mappingBranchAbsent(part, values, absent));
25
+ if ("array" in mapping)
26
+ return mapping.array.some((part) => mappingBranchAbsent(part, values, absent));
27
+ if ("each" in mapping) {
28
+ if (mappingBranchAbsent(mapping.each.over, values, absent))
29
+ return true;
30
+ const over = evaluateMapping(mapping.each.over, values);
31
+ return (S.is(S.Array(S.Json))(over) &&
32
+ over.some((value, index) => mappingBranchAbsent(mapping.each.body, { ...values, $item: { value, index } }, (ref) => ref.node !== "$item" && absent(ref))));
33
+ }
34
+ return false;
35
+ }
36
+ function predicateBranchAbsent(test, values, absent) {
37
+ if (test.op === "exists")
38
+ return false;
39
+ if ("tests" in test)
40
+ return test.tests.some((part) => predicateBranchAbsent(part, values, absent));
41
+ if ("test" in test)
42
+ return predicateBranchAbsent(test.test, values, absent);
43
+ return (mappingBranchAbsent(test.left, values, absent) ||
44
+ mappingBranchAbsent(test.right, values, absent));
45
+ }
46
+ function nodeArguments(graph, node, values) {
47
+ const args = {};
48
+ for (const edge of graph.edges)
49
+ if (edge.to.node === node.id && edge.to.port !== "after") {
50
+ const value = readPort(values, edge.from);
51
+ if (value !== undefined)
52
+ args[edge.to.port] = value;
53
+ }
54
+ for (const [key, mapping] of Object.entries(node.values)) {
55
+ const value = evaluateMapping(mapping, values);
56
+ if (value !== undefined)
57
+ args[key] = value;
58
+ }
59
+ return args;
60
+ }
61
+ function requiredRefs(graph, node) {
62
+ const edges = graph.edges
63
+ .filter((edge) => edge.to.node === node.id)
64
+ .map((edge) => edge.from);
65
+ switch (node.kind) {
66
+ case "transform":
67
+ case "condition":
68
+ return edges;
69
+ case "review":
70
+ return [...edges, ...node.show];
71
+ case "map":
72
+ return [...edges, node.over];
73
+ case "repeat":
74
+ return [...edges, { node: "$input", port: "item" }];
75
+ case "app":
76
+ case "code":
77
+ case "wait":
78
+ return edges;
79
+ }
80
+ }
81
+ const childCompletionSchema = S.Union([
82
+ S.Struct({ output: S.JsonObject }),
83
+ S.Struct({ refusal: S.String }),
84
+ ]);
85
+ /** Native WorkflowStep owns checkpoints, retries, sleeps and event waits. */
86
+ export async function runGraphWorkflow(step, params, instanceId, workflows, dispatcher, codeExecution) {
87
+ const checked = S.decodeUnknownSync(graphParamsSchema)(params, {
88
+ onExcessProperty: "error",
89
+ });
90
+ if (checked.kind === "run" && checked.runId !== instanceId)
91
+ throw new NonRetryableError("run_identity_mismatch");
92
+ const stored = await dispatcher.loadRun(checked.teamId, checked.runId);
93
+ const plan = await decodeExecutionPlan(stored.plan);
94
+ if (checked.planDigest !== plan.planDigest)
95
+ throw new NonRetryableError("plan_identity_mismatch");
96
+ const graph = checked.kind === "run"
97
+ ? plan.definition
98
+ : plan.definition.subgraphs?.[checked.body];
99
+ if (!graph)
100
+ throw new NonRetryableError("unknown_subgraph");
101
+ const admitted = admitRunInput(graph, checked.kind === "run" ? stored.input : checked.input);
102
+ if (admitted.kind === "invalid")
103
+ throw new NonRetryableError("invalid_input");
104
+ const input = admitted.input;
105
+ const iteration = checked.kind === "run" ? "0" : checked.iteration;
106
+ const values = {};
107
+ values.$input = input;
108
+ const baseEvent = (node, state, attempt = 0, poll) => {
109
+ let event = {
110
+ runId: checked.runId,
111
+ nodeId: node.id,
112
+ iteration,
113
+ state,
114
+ attempt,
115
+ };
116
+ if (poll !== undefined)
117
+ event = { ...event, poll };
118
+ return checked.kind === "map-item"
119
+ ? { ...event, graphId: checked.body }
120
+ : event;
121
+ };
122
+ const complete = async (node, output, attempt = 0, costMilliunits = 0, durationMs = 0, poll) => {
123
+ const ports = { ...output, after: true };
124
+ if (exceedsResultLimit(ports))
125
+ return fail(node, "step_result_limit", undefined, poll);
126
+ await dispatcher.recordEvent({
127
+ ...baseEvent(node, "complete", attempt, poll),
128
+ costMilliunits,
129
+ durationMs,
130
+ outputDigest: await contentDigest(ports),
131
+ output,
132
+ });
133
+ return ports;
134
+ };
135
+ const fail = async (node, code, cause, poll) => {
136
+ await dispatcher.recordEvent({
137
+ ...baseEvent(node, "failed", 0, poll),
138
+ refusal: code,
139
+ errorMessage: code.replaceAll("_", " "),
140
+ });
141
+ const refusal = new NonRetryableError(code);
142
+ if (cause !== undefined)
143
+ refusal.cause = cause;
144
+ throw refusal;
145
+ };
146
+ const skipped = new Set();
147
+ // Steps the static estimate did not spend may go to polling pending calls.
148
+ const pollBudget = INSTANCE_STEP_LIMIT - plan.estimatedSteps;
149
+ let pollSteps = 0;
150
+ const branchAbsent = (ref) => {
151
+ const producer = graph.nodes.find((node) => node.id === ref.node);
152
+ const app = producer?.kind === "app" ? plan.definition.apps[producer.app] : undefined;
153
+ const method = producer?.kind === "app" && app
154
+ ? plan.catalog[app.appId]?.methods[producer.method]
155
+ : undefined;
156
+ const output = producer?.kind === "code" ? producer.output : method?.output;
157
+ const appOutlets = output
158
+ ? methodOutputPorts(parseProjection(output))
159
+ : undefined;
160
+ const completionBranch = output &&
161
+ (output.anyOf !== undefined || output.oneOf !== undefined) &&
162
+ appOutlets &&
163
+ Object.hasOwn(appOutlets, "ok") &&
164
+ Object.hasOwn(appOutlets, "refused") &&
165
+ (ref.port === "ok" || ref.port === "refused");
166
+ return (skipped.has(ref.node) ||
167
+ ((producer?.kind === "condition" ||
168
+ producer?.kind === "review" ||
169
+ producer?.kind === "repeat" ||
170
+ completionBranch === true) &&
171
+ readPort(values, ref) === undefined));
172
+ };
173
+ for (const node of graph.nodes) {
174
+ const name = `n:${node.id}:${iteration}`;
175
+ const absentMapping = node.kind === "app" || node.kind === "code" || node.kind === "transform"
176
+ ? Object.values(node.kind === "transform" ? node.fields : node.values).some((mapping) => mappingBranchAbsent(mapping, values, branchAbsent))
177
+ : node.kind === "condition" &&
178
+ predicateBranchAbsent(node.test, values, branchAbsent);
179
+ if (requiredRefs(graph, node).some(branchAbsent) || absentMapping) {
180
+ skipped.add(node.id);
181
+ await step.do(name + ":skipped", async () => {
182
+ await dispatcher.recordEvent(baseEvent(node, "skipped"));
183
+ return null;
184
+ });
185
+ continue;
186
+ }
187
+ const admission = S.decodeUnknownSync(nodeAdmissionResultSchema)(await dispatcher.beforeNode(baseEvent(node, "running")), { onExcessProperty: "error" });
188
+ if (admission.kind === "refused")
189
+ return fail(node, admission.code);
190
+ switch (node.kind) {
191
+ case "code": {
192
+ if (!codeExecution)
193
+ return fail(node, "code_execution_unavailable");
194
+ const host = codeExecution;
195
+ const outcome = await step.do(name, { retries: { limit: 0, delay: "1 second" } }, async (ctx) => {
196
+ await dispatcher.recordEvent(baseEvent(node, "running", ctx.attempt));
197
+ const args = nodeArguments(graph, node, values);
198
+ const schema = parseProjection(node.input);
199
+ const inputValue = methodInputValue(schema, args);
200
+ if (inputValue === undefined || !accepts(schema, inputValue))
201
+ return { kind: "refused", code: "invalid_input" };
202
+ const executionId = JSON.stringify([
203
+ checked.runId,
204
+ checked.kind === "map-item" ? checked.body : null,
205
+ node.id,
206
+ iteration,
207
+ ]);
208
+ return Effect.runPromise(executeCode(host, node, executionId, inputValue));
209
+ });
210
+ if (outcome.kind === "refused")
211
+ return fail(node, outcome.code);
212
+ values[node.id] = await step.do(name + ":complete", () => complete(node, outcome.ports));
213
+ break;
214
+ }
215
+ case "app": {
216
+ const app = plan.definition.apps[node.app];
217
+ const binding = plan.bindings[node.app];
218
+ const method = app
219
+ ? plan.catalog[app.appId]?.methods[node.method]
220
+ : undefined;
221
+ if (!method || !binding || !("installationId" in binding))
222
+ return fail(node, "unknown_method");
223
+ const retries = node.retry
224
+ ? node.retry.attempts - 1
225
+ : method.effect === "external"
226
+ ? 0
227
+ : 3;
228
+ const attemptCall = (poll) => step.do(poll === 0 ? name : `${name}:poll:${String(poll)}`, {
229
+ retries: {
230
+ limit: retries,
231
+ delay: "1 second",
232
+ backoff: "exponential",
233
+ },
234
+ }, async (ctx) => {
235
+ const start = Date.now();
236
+ await dispatcher.recordEvent(baseEvent(node, "running", ctx.attempt, poll));
237
+ const args = nodeArguments(graph, node, values);
238
+ const inputSchema = parseProjection(method.input);
239
+ const inputValue = methodInputValue(inputSchema, args);
240
+ if (inputValue === undefined || !accepts(inputSchema, inputValue))
241
+ return fail(node, "invalid_input", undefined, poll);
242
+ let call = {
243
+ teamId: checked.teamId,
244
+ runId: checked.runId,
245
+ nodeId: node.id,
246
+ iteration,
247
+ poll,
248
+ installationId: binding.installationId,
249
+ method: node.method,
250
+ methodSpec: method,
251
+ input: inputValue,
252
+ idempotencyKey: `${checked.runId}:${node.id}:${iteration}`,
253
+ };
254
+ if (checked.kind === "map-item")
255
+ call = { ...call, graphId: checked.body };
256
+ let dispatched;
257
+ try {
258
+ dispatched = await dispatcher.dispatch(call);
259
+ }
260
+ catch (cause) {
261
+ await dispatcher.recordEvent({
262
+ ...baseEvent(node, "failed", ctx.attempt, poll),
263
+ refusal: "dispatch_failed",
264
+ });
265
+ throw cause;
266
+ }
267
+ let result;
268
+ try {
269
+ result = S.decodeUnknownSync(dispatchResultSchema)(dispatched, {
270
+ onExcessProperty: "error",
271
+ });
272
+ }
273
+ catch (cause) {
274
+ if (!S.isSchemaError(cause))
275
+ throw cause;
276
+ return fail(node, "invalid_output", cause, poll);
277
+ }
278
+ if (result.kind === "refused")
279
+ return fail(node, result.code, undefined, poll);
280
+ if (result.kind === "pending") {
281
+ await dispatcher.recordEvent(baseEvent(node, "waiting", ctx.attempt, poll));
282
+ return { pending: result.retryAfterMs };
283
+ }
284
+ // W3 wires approval waits at the broker; a queue must never masquerade as a result.
285
+ if (result.kind === "pending_approval")
286
+ return fail(node, "pending_approval_requires_w3", undefined, poll);
287
+ const outputSchema = parseProjection(method.output);
288
+ const ports = methodOutputValues(outputSchema, result.output);
289
+ if (ports === undefined)
290
+ return fail(node, "invalid_output", undefined, poll);
291
+ return {
292
+ ports: await complete(node, ports, ctx.attempt, result.costMilliunits, Date.now() - start, poll),
293
+ };
294
+ });
295
+ // A pending App call is polled with the same idempotency key, within
296
+ // the steps the plan's static estimate left for this instance.
297
+ let outcome = await attemptCall(0);
298
+ for (let poll = 1; "pending" in outcome; poll++) {
299
+ if (pollSteps + 2 > pollBudget)
300
+ return fail(node, "pending_budget");
301
+ pollSteps += 2;
302
+ await step.sleep(`${name}:wait:${String(poll)}`, outcome.pending);
303
+ outcome = await attemptCall(poll);
304
+ }
305
+ values[node.id] = outcome.ports;
306
+ break;
307
+ }
308
+ case "condition":
309
+ values[node.id] = await step.do(name, () => complete(node, {
310
+ [evaluatePredicate(node.test, values) ? "true" : "false"]: true,
311
+ }));
312
+ break;
313
+ case "transform":
314
+ values[node.id] = await step.do(name, async () => {
315
+ const output = {};
316
+ for (const [key, mapping] of Object.entries(node.fields)) {
317
+ const value = evaluateMapping(mapping, values);
318
+ if (value === undefined)
319
+ return fail(node, "missing_input");
320
+ output[key] = value;
321
+ }
322
+ return complete(node, output);
323
+ });
324
+ break;
325
+ case "wait": {
326
+ await step.sleep(name, node.for);
327
+ values[node.id] = await step.do(name + ":complete", () => complete(node, {}));
328
+ break;
329
+ }
330
+ case "review": {
331
+ const eventType = "review_" +
332
+ (await contentDigest([instanceId, node.id, iteration])).slice(7);
333
+ const expiresAt = await step.do(name + ":pending", async () => {
334
+ const deadline = await dispatcher.openReview({
335
+ runId: checked.runId,
336
+ nodeId: node.id,
337
+ iteration,
338
+ instanceId,
339
+ eventType,
340
+ title: node.title,
341
+ reviewers: node.reviewers,
342
+ timeout: node.timeout,
343
+ show: node.show.map((ref) => readPort(values, ref) ?? null),
344
+ ...reviewChoices(node),
345
+ });
346
+ await dispatcher.recordEvent(baseEvent(node, "waiting"));
347
+ return deadline;
348
+ });
349
+ let answer;
350
+ try {
351
+ const event = await step.waitForEvent(name + ":answer", {
352
+ type: eventType,
353
+ timeout: node.timeout,
354
+ });
355
+ answer = S.decodeUnknownSync(reviewAnswerSchema)(event.payload, {
356
+ onExcessProperty: "error",
357
+ });
358
+ }
359
+ catch (cause) {
360
+ if (S.isSchemaError(cause))
361
+ return fail(node, "invalid_input", cause);
362
+ // RPC strips the native timeout subtype. The review owner already
363
+ // owns expiry; handle that expected outcome without inspecting text,
364
+ // retaining the original failure for diagnostics. Early failures escape.
365
+ if (Date.now() >= expiresAt)
366
+ return fail(node, "review_timeout", cause);
367
+ throw cause;
368
+ }
369
+ const shown = node.show[0];
370
+ const item = shown ? (readPort(values, shown) ?? null) : true;
371
+ values[node.id] = await step.do(name + ":complete", () => complete(node, reviewAnswerOutput(answer, item)));
372
+ break;
373
+ }
374
+ case "repeat": {
375
+ const item = readPort(values, { node: "$input", port: "item" });
376
+ if (item === undefined)
377
+ return fail(node, "invalid_input");
378
+ let previous = {};
379
+ let outlet = "exhausted";
380
+ for (let round = 0; round < node.maxRounds; round++) {
381
+ const childIteration = (await contentDigest([iteration, node.id, "repeat", round])).slice(7);
382
+ const childId = (await contentDigest([
383
+ checked.runId,
384
+ node.id,
385
+ childIteration,
386
+ plan.planDigest,
387
+ ])).slice(7);
388
+ const eventType = "round_" +
389
+ (await contentDigest([
390
+ checked.runId,
391
+ checked.kind === "map-item" ? checked.body : "root",
392
+ node.id,
393
+ iteration,
394
+ round,
395
+ ])).slice(7);
396
+ await step.do(`${name}:round:${String(round)}`, async () => {
397
+ const params = {
398
+ kind: "map-item",
399
+ teamId: checked.teamId,
400
+ runId: checked.runId,
401
+ planDigest: plan.planDigest,
402
+ body: node.body,
403
+ iteration: childIteration,
404
+ parentInstanceId: instanceId,
405
+ completionEvent: eventType,
406
+ input: { item, round, previous },
407
+ };
408
+ try {
409
+ await workflows.create({ id: childId, params });
410
+ }
411
+ catch (cause) {
412
+ const existing = await workflows.get(childId);
413
+ if ((await existing.status()).status === "unknown")
414
+ throw cause;
415
+ }
416
+ await dispatcher.recordEvent(baseEvent(node, "waiting"));
417
+ return null;
418
+ });
419
+ const event = await step.waitForEvent(eventType, {
420
+ type: eventType,
421
+ timeout: "7 days",
422
+ });
423
+ const completed = S.decodeUnknownSync(childCompletionSchema)(event.payload, { onExcessProperty: "error" });
424
+ if ("refusal" in completed)
425
+ return fail(node, completed.refusal);
426
+ previous = completed.output;
427
+ if (evaluatePredicate(node.until, { $input: previous })) {
428
+ outlet = "result";
429
+ break;
430
+ }
431
+ }
432
+ values[node.id] = await step.do(name + ":complete", () => complete(node, { [outlet]: previous }));
433
+ break;
434
+ }
435
+ case "map": {
436
+ const items = readPort(values, node.over);
437
+ if (!S.is(S.Array(S.Json))(items) || items.length > node.maxItems)
438
+ return fail(node, "map_item_limit");
439
+ const list = S.decodeUnknownSync(S.Array(S.Json))(items);
440
+ const results = [];
441
+ for (let offset = 0; offset < list.length; offset += node.concurrency) {
442
+ const wave = list.slice(offset, offset + node.concurrency);
443
+ await step.do(name + ":wave:" + String(offset), async () => {
444
+ await Promise.all(wave.map(async (item, index) => {
445
+ const itemIndex = offset + index;
446
+ const childIteration = (await contentDigest([iteration, node.id, itemIndex])).slice(7);
447
+ const childId = (await contentDigest([
448
+ checked.runId,
449
+ node.id,
450
+ childIteration,
451
+ plan.planDigest,
452
+ ])).slice(7);
453
+ const childParams = {
454
+ kind: "map-item",
455
+ teamId: checked.teamId,
456
+ runId: checked.runId,
457
+ planDigest: plan.planDigest,
458
+ body: node.body,
459
+ iteration: childIteration,
460
+ parentInstanceId: instanceId,
461
+ completionEvent: "item_" +
462
+ (await contentDigest([
463
+ checked.runId,
464
+ checked.kind === "map-item" ? checked.body : "root",
465
+ node.id,
466
+ iteration,
467
+ itemIndex,
468
+ ])).slice(7),
469
+ input: { item, index: itemIndex },
470
+ };
471
+ try {
472
+ await workflows.create({ id: childId, params: childParams });
473
+ }
474
+ catch (cause) {
475
+ const existing = await workflows.get(childId);
476
+ if ((await existing.status()).status === "unknown")
477
+ throw cause;
478
+ }
479
+ }));
480
+ await dispatcher.recordEvent(baseEvent(node, "waiting"));
481
+ return null;
482
+ });
483
+ const completed = await Promise.all(wave.map(async (_, index) => {
484
+ const eventType = "item_" +
485
+ (await contentDigest([
486
+ checked.runId,
487
+ checked.kind === "map-item" ? checked.body : "root",
488
+ node.id,
489
+ iteration,
490
+ offset + index,
491
+ ])).slice(7);
492
+ const event = await step.waitForEvent(eventType, {
493
+ type: eventType,
494
+ timeout: "7 days",
495
+ });
496
+ const completed = S.decodeUnknownSync(childCompletionSchema)(event.payload, { onExcessProperty: "error" });
497
+ if ("refusal" in completed)
498
+ return fail(node, completed.refusal);
499
+ return completed.output;
500
+ }));
501
+ results.push(...completed);
502
+ }
503
+ values[node.id] = await step.do(name + ":complete", () => complete(node, { items: results }));
504
+ break;
505
+ }
506
+ }
507
+ }
508
+ const output = {};
509
+ for (const [key, ref] of Object.entries(graph.output)) {
510
+ const value = readPort(values, ref);
511
+ if (value === undefined && !branchAbsent(ref))
512
+ return fail({ id: "$output" }, "missing_output");
513
+ if (value !== undefined)
514
+ output[key] = value;
515
+ }
516
+ if (exceedsResultLimit(output))
517
+ return fail({ id: "$output" }, "step_result_limit");
518
+ if (checked.kind === "map-item")
519
+ await step.do("notify-parent", async () => {
520
+ const parent = await workflows.get(checked.parentInstanceId);
521
+ await parent.sendEvent({
522
+ type: checked.completionEvent,
523
+ payload: { output },
524
+ });
525
+ return null;
526
+ });
527
+ return output;
528
+ }
@@ -0,0 +1 @@
1
+ export { GraphWorkflow, type GraphRunEnding, type GraphWorkflowBindings, } from "./graph-engine.js";
@@ -0,0 +1 @@
1
+ export { GraphWorkflow, } from "./graph-engine.js";
@@ -0,0 +1,16 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type { Sandbox } from "@fungi.computer/mycelium/sandbox";
3
+ import { type CodeResult } from "./code-result.js";
4
+ import type { CodeNode, Json } from "./schema.js";
5
+ /** Host-owned execution policy; no Workflow field can change this budget. */
6
+ export type CodeExecution = Readonly<{
7
+ sandbox: Sandbox;
8
+ timeoutMs: number;
9
+ }>;
10
+ export type CodeFailure = "invalid_source" | "guest_exception" | "deadline_exceeded" | "cancelled" | "sandbox_failed" | "invalid_output" | "result_limit";
11
+ export type CodeOutcome = CodeResult | Readonly<{
12
+ kind: "refused";
13
+ code: CodeFailure;
14
+ }>;
15
+ /** Only mapped JSON crosses into Code; only a closed checkpoint crosses out. */
16
+ export declare function executeCode(host: CodeExecution, node: CodeNode, executionId: string, input: Json): Effect.Effect<CodeOutcome>;
@@ -0,0 +1,50 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as S from "effect/Schema";
3
+ import { callCodeSandbox } from "./code-sandbox.js";
4
+ import { codeResult } from "./code-result.js";
5
+ const emptyInput = S.Record(S.String, S.Never);
6
+ const jsonCopy = S.decodeUnknownSync(S.fromJsonString(S.MutableJson));
7
+ const failureCode = (failure) => {
8
+ switch (failure._tag) {
9
+ case "SourceInvalid":
10
+ return "invalid_source";
11
+ case "GuestThrew":
12
+ return "guest_exception";
13
+ case "DeadlineExceeded":
14
+ return "deadline_exceeded";
15
+ case "Cancelled":
16
+ return "cancelled";
17
+ case "ResultNotJson":
18
+ return "invalid_output";
19
+ case "ResultTooLarge":
20
+ return "result_limit";
21
+ case "DispatchLost":
22
+ case "SandboxCrashed":
23
+ case "BadResponse":
24
+ case "CapabilityFailed":
25
+ case "Unknown":
26
+ return "sandbox_failed";
27
+ }
28
+ };
29
+ /** Only mapped JSON crosses into Code; only a closed checkpoint crosses out. */
30
+ export function executeCode(host, node, executionId, input) {
31
+ const serialized = JSON.stringify(input);
32
+ return callCodeSandbox(host.sandbox, {
33
+ executionId,
34
+ code: node.source,
35
+ timeoutMs: host.timeoutMs,
36
+ bindings: {
37
+ INPUT: {
38
+ read: (request) => {
39
+ S.decodeUnknownSync(emptyInput)(request, {
40
+ onExcessProperty: "error",
41
+ });
42
+ return Promise.resolve(jsonCopy(serialized));
43
+ },
44
+ },
45
+ },
46
+ }).pipe(Effect.map((value) => codeResult(node.output, value)), Effect.catch((failure) => Effect.succeed({
47
+ kind: "refused",
48
+ code: failureCode(failure),
49
+ })));
50
+ }
@@ -0,0 +1,11 @@
1
+ import type { Json, JsonSchemaProjection } from "./schema.js";
2
+ /** Persisted Code completion: execution refusals never select an authored outlet. */
3
+ export type CodeResult = Readonly<{
4
+ kind: "complete";
5
+ ports: Readonly<Record<string, Json>>;
6
+ }> | Readonly<{
7
+ kind: "refused";
8
+ code: "invalid_output" | "result_limit";
9
+ }>;
10
+ /** Validate guest JSON and project it through the declared completion grammar. */
11
+ export declare function codeResult(output: JsonSchemaProjection, value: unknown): CodeResult;
@@ -0,0 +1,19 @@
1
+ import * as S from "effect/Schema";
2
+ import * as Result from "effect/Result";
3
+ import { methodOutputValues, parseProjection } from "./ports.js";
4
+ /** Validate guest JSON and project it through the declared completion grammar. */
5
+ export function codeResult(output, value) {
6
+ const decoded = S.decodeUnknownResult(S.Json)(value);
7
+ if (Result.isFailure(decoded))
8
+ return { kind: "refused", code: "invalid_output" };
9
+ const ports = methodOutputValues(parseProjection(output), decoded.success);
10
+ if (ports === undefined)
11
+ return { kind: "refused", code: "invalid_output" };
12
+ const result = {
13
+ kind: "complete",
14
+ ports: { ...ports, after: true },
15
+ };
16
+ if (new TextEncoder().encode(JSON.stringify(result)).length > 64 * 1024)
17
+ return { kind: "refused", code: "result_limit" };
18
+ return result;
19
+ }
@@ -0,0 +1,9 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type { Sandbox, SandboxRequest, SandboxValue } from "@fungi.computer/mycelium/sandbox";
3
+ import type { SandboxFailure } from "@fungi.computer/mycelium/sandbox-failure";
4
+ /**
5
+ * A Code step abandons its call when its owning Effect is interrupted. The
6
+ * sandbox receives Effect's local abort signal; trusted host budgets remain
7
+ * responsible for backing work that cannot acknowledge cancellation.
8
+ */
9
+ export declare function callCodeSandbox(sandbox: Sandbox, request: SandboxRequest): Effect.Effect<SandboxValue, SandboxFailure>;
@@ -0,0 +1,13 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { sandboxFailure } from "@fungi.computer/mycelium/sandbox-boundary";
3
+ /**
4
+ * A Code step abandons its call when its owning Effect is interrupted. The
5
+ * sandbox receives Effect's local abort signal; trusted host budgets remain
6
+ * responsible for backing work that cannot acknowledge cancellation.
7
+ */
8
+ export function callCodeSandbox(sandbox, request) {
9
+ return Effect.tryPromise({
10
+ try: (signal) => sandbox.execute(request, signal),
11
+ catch: sandboxFailure,
12
+ });
13
+ }