@oomol-lab/open-flow 0.1.0-beta.76 → 0.1.0-beta.78

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 (74) hide show
  1. package/README.md +3 -0
  2. package/dist/browser/{ContentIcon-Cm1hbVzW.js → ContentIcon-CMn0c-kl.js} +99 -6
  3. package/dist/browser/{FlowCanvas-NzKtla_c.js → FlowCanvas-BQGBcfoL.js} +1709 -613
  4. package/dist/browser/{createResourceDialog-BgdedQqu.js → createResourceDialog-DCGxfKFM.js} +63 -57
  5. package/dist/browser/{dialog-aSElFFw2.js → dialog-WzR4CRFH.js} +76 -77
  6. package/dist/browser/{exportSvg-Dcz4HTo_.js → exportSvg-CYwyYiuH.js} +2 -2
  7. package/dist/browser/{flowChanges-NZyS7HSp.js → flowChanges-Bz4dbDN_.js} +6875 -11091
  8. package/dist/browser/{flowWorkspace-Bl0SDehy.js → flowWorkspace-BI6L6a4r.js} +1415 -3037
  9. package/dist/browser/licenses.md +1 -1
  10. package/dist/browser/preview.css +619 -462
  11. package/dist/browser/preview.js +9016 -14956
  12. package/dist/browser/theme.css +4 -2
  13. package/dist/browser/{tooltip-3XMK9jkr.js → tooltip-BDcVTNpu.js} +65 -2
  14. package/dist/browser/{typeScriptSession-C9sBGOKZ.js → typeScriptSession-CRoezJPN.js} +1 -1
  15. package/dist/browser/ui.css +112 -24
  16. package/dist/browser/ui.js +3 -2
  17. package/dist/browser/workbench.css +10020 -9842
  18. package/dist/browser/workbench.d.ts +5 -2
  19. package/dist/browser/workbench.js +2382 -2668
  20. package/dist/browser/{workbenchSelect-DBGP7Y4Z.js → workbenchSelect-DtAd0zc4.js} +2 -2
  21. package/dist/command/index.js +48 -48
  22. package/dist/common/authoring.d.ts +4 -4
  23. package/dist/common/authoringExamples.d.ts +3 -2
  24. package/dist/common/authoringSchema.d.ts +15 -4
  25. package/dist/common/control-api-conformance.js +155 -15
  26. package/dist/common/control-api-errors.d.ts +12 -0
  27. package/dist/common/control-api.d.ts +39 -2
  28. package/dist/common/control-api.js +180 -37
  29. package/dist/common/control-requests.d.ts +23 -3
  30. package/dist/common/control-requests.js +141 -14
  31. package/dist/common/engine-contract.d.ts +3 -2
  32. package/dist/common/flow-authoring.js +47 -7
  33. package/dist/common/flow-change-schema.d.ts +1 -1
  34. package/dist/common/flow-change.d.ts +2 -2
  35. package/dist/common/flow-change.js +48 -8
  36. package/dist/common/flow-encoding.js +47 -7
  37. package/dist/common/flow-semantics.js +86 -8
  38. package/dist/common/flow-state.d.ts +100 -0
  39. package/dist/common/flow-state.js +5794 -0
  40. package/dist/common/mcp.d.ts +96 -3
  41. package/dist/common/mcp.js +158 -13
  42. package/dist/common/run-events.js +19 -8
  43. package/dist/common/runtime-contract.d.ts +14 -0
  44. package/dist/common/runtime-contract.js +124 -17
  45. package/dist/common/runtime-nodejs-compat.js +11 -12
  46. package/dist/common/scheduler-node-execution.d.ts +1 -0
  47. package/dist/common/scheduler.d.ts +2 -1
  48. package/dist/common/scheduler.js +95 -40
  49. package/dist/common/state-conformance.d.ts +16 -0
  50. package/dist/common/tags.d.ts +4 -0
  51. package/dist/index.d.ts +11 -0
  52. package/package.json +5 -1
  53. package/src/control/common/api.ts +101 -11
  54. package/src/control/common/authoring.ts +7 -1
  55. package/src/control/common/authoringExamples.ts +9 -0
  56. package/src/control/common/authoringSchema.ts +18 -12
  57. package/src/control/common/errors.ts +6 -0
  58. package/src/control/common/flowDecoders.ts +1 -0
  59. package/src/control/common/mcp.ts +16 -0
  60. package/src/control/common/requests.ts +16 -2
  61. package/src/control/common/tags.ts +20 -0
  62. package/src/execution/common/events.ts +22 -7
  63. package/src/execution/common/node-execution.ts +3 -0
  64. package/src/execution/common/scheduler.ts +29 -13
  65. package/src/flow/common/change.ts +2 -1
  66. package/src/flow/common/changeSchema.ts +11 -1
  67. package/src/flow/common/errorHandling.ts +1 -1
  68. package/src/flow/common/semantics.ts +27 -1
  69. package/src/runtime/common/engineContract.ts +6 -3
  70. package/src/runtime/common/nodejsGuest.generated.json +11 -12
  71. package/src/runtime/common/runtime.ts +122 -7
  72. package/src/state/common/conformance.ts +81 -0
  73. package/src/state/common/state.ts +141 -0
  74. package/src/types/index.ts +11 -0
@@ -42,6 +42,9 @@ export const controlErrorCode = {
42
42
  triggerKeyInvalid: 'trigger-key.invalid',
43
43
  triggerKeyNotFound: 'trigger-key.not-found',
44
44
  triggerNotFound: 'trigger.not-found',
45
+ tagInvalid: 'tag.invalid',
46
+ tagConflict: 'tag.conflict',
47
+ tagNotFound: 'tag.not-found',
45
48
  variableInvalid: 'variable.invalid',
46
49
  variableLimitReached: 'variable.limit-reached',
47
50
  variableNotFound: 'variable.not-found',
@@ -93,6 +96,9 @@ export const controlErrorMetadata = {
93
96
  [controlErrorCode.triggerKeyInvalid]: { status: 409 },
94
97
  [controlErrorCode.triggerKeyNotFound]: { status: 404 },
95
98
  [controlErrorCode.triggerNotFound]: { status: 404 },
99
+ [controlErrorCode.tagInvalid]: { status: 400 },
100
+ [controlErrorCode.tagConflict]: { status: 409 },
101
+ [controlErrorCode.tagNotFound]: { status: 404 },
96
102
  [controlErrorCode.variableInvalid]: { status: 400 },
97
103
  [controlErrorCode.variableLimitReached]: { status: 409 },
98
104
  [controlErrorCode.variableNotFound]: { status: 404 },
@@ -60,6 +60,7 @@ export function flow(value: unknown): Flow {
60
60
  revisionId: string(target.revisionId),
61
61
  },
62
62
  }),
63
+ tagIds: source.tagIds === undefined ? [] : strings(source.tagIds),
63
64
  createdAt: string(source.createdAt),
64
65
  ...(source.connectorTeamId == null ? {} : { connectorTeamId: string(source.connectorTeamId) }),
65
66
  draftRevisionId: string(source.draftRevisionId),
@@ -5,6 +5,7 @@ import { canvasCommandSchema } from '../../canvas/common/commands.ts'
5
5
  import { runStatuses } from '../../execution/common/runLifecycle.ts'
6
6
  import { waitCommentSchema } from '../../execution/common/wait.ts'
7
7
  import { resourceNameIssue } from '../../flow/common/change.ts'
8
+ import { stateQuerySchema, stateClearSchema, dedupeClearSchema } from '../../state/common/state.ts'
8
9
  import { authoringReadSchema, authoringSearchSchema, authoringRequestSchema, authoringSchemaQuery } from './authoringSchema.ts'
9
10
  import { resultQuerySchema } from './resultQuery.ts'
10
11
 
@@ -50,6 +51,21 @@ function tool<Args>(description: string, schema: z.ZodType<Args>, readOnly: bool
50
51
  }
51
52
 
52
53
  export const mcpTools = {
54
+ flow_state: tool(
55
+ 'Inspect Flow state usage, business keys and deduplication spaces. Values are omitted unless requested. Scope is required.',
56
+ stateQuerySchema.extend({ flowId: flow }),
57
+ true,
58
+ ),
59
+ flow_state_clear: tool(
60
+ 'Delete one business state key or clear all business values. Explicit draft/live scope is required; live changes production state immediately.',
61
+ stateClearSchema.extend({ flowId: flow }),
62
+ false,
63
+ ),
64
+ flow_dedupe_clear: tool(
65
+ 'Clear one deduplication space (flow or node:<id>). Explicit draft/live scope is required; live changes production history immediately.',
66
+ dedupeClearSchema.extend({ flowId: flow }),
67
+ false,
68
+ ),
53
69
  flow_canvas: tool(
54
70
  'Broadcast a transient canvas command to connected Workbenches viewing this Flow. Success only confirms Server broadcast, not delivery or execution. No replay, session selection or Presentation save. Pan uses screen pixels (positive right/down); zoom is an absolute ratio (0.1–3); fit includes all nodes. Repeating pan moves again.',
55
71
  z.strictObject({ flowId: flow, command: canvasCommandSchema }),
@@ -18,6 +18,8 @@ const json: z.ZodType<JsonValue> = z.json()
18
18
  const id = z.string().min(1)
19
19
  const version = z.literal(1)
20
20
  const flowName = id.refine((value) => value == value.trim() && resourceNameIssue(value) == null, 'Flow name is invalid.')
21
+ const tagIds = z.array(id).transform((ids) => [...new Set(ids)].toSorted())
22
+ const tagName = id.transform((name) => name.trim()).refine((name) => resourceNameIssue(name) == null, 'Tag name is invalid.')
21
23
  const inputs = z.record(z.string(), z.record(z.string(), json))
22
24
  const trigger = z.strictObject({ nodeId: id, outputs: z.record(z.string(), json) })
23
25
  const schemas = {
@@ -31,8 +33,17 @@ const schemas = {
31
33
  createEventSource: createEventSourceSchema,
32
34
  updateEventSource: updateEventSourceSchema,
33
35
  eventSourceRevision: eventSourceRevisionSchema,
34
- importFlow: z.strictObject({ file: json, name: flowName.optional(), teamId: id.optional(), version }),
35
- createFlow: z.strictObject({ name: flowName, teamId: id.optional(), version }),
36
+ importFlow: z.strictObject({ file: json, name: flowName.optional(), teamId: id.optional(), tagIds: tagIds.optional(), version }),
37
+ createFlow: z.strictObject({
38
+ name: flowName,
39
+ teamId: id.optional(),
40
+ tagIds: tagIds.optional(),
41
+ presentation: z.record(z.string(), json).optional(),
42
+ version,
43
+ }),
44
+ saveTag: z.strictObject({ name: tagName, version }),
45
+ setFlowTags: z.strictObject({ tagIds, version }),
46
+ updateFlow: z.union([z.strictObject({ name: flowName, tagIds: tagIds.optional(), version }), z.strictObject({ name: flowName.optional(), tagIds, version })]),
36
47
  renameFlow: z.strictObject({ name: flowName, version }),
37
48
  changeDraft: z.strictObject({ expectedRevisionId: id, operations: z.array(json).min(1), version }),
38
49
  repairDraft: z.strictObject({ expectedRevisionId: id, version }),
@@ -79,6 +90,9 @@ export const controlRequests = {
79
90
  eventSourceRevision: decoder(schemas.eventSourceRevision),
80
91
  importFlow: decoder(schemas.importFlow),
81
92
  createFlow: decoder(schemas.createFlow),
93
+ saveTag: decoder(schemas.saveTag),
94
+ setFlowTags: decoder(schemas.setFlowTags),
95
+ updateFlow: decoder(schemas.updateFlow),
82
96
  renameFlow: decoder(schemas.renameFlow),
83
97
  changeDraft(value: unknown): { expectedRevisionId: string; operations: readonly DraftOperation[]; version: 1 } {
84
98
  const body = schemas.changeDraft.parse(value)
@@ -0,0 +1,20 @@
1
+ import type { FlowQuery, Tag, TagListItem } from './api.ts'
2
+
3
+ import { integer, invalidResponse, record, string } from './decoding.ts'
4
+
5
+ export function normalizeFlowQuery(query: FlowQuery = {}): Required<FlowQuery> {
6
+ return { query: query.query?.trim().toLowerCase() ?? '', tagIds: [...new Set(query.tagIds ?? [])].toSorted(), untagged: query.untagged ?? false }
7
+ }
8
+
9
+ export function decodeTag(value: unknown): Tag {
10
+ const source = record(value)
11
+ if (source.version != 1) return invalidResponse()
12
+ return { tagId: string(source.tagId), name: string(source.name), version: 1 }
13
+ }
14
+
15
+ export function decodeTagListItem(value: unknown): TagListItem {
16
+ const source = record(value)
17
+ const referenceCount = integer(source.referenceCount)
18
+ if (referenceCount < 0) return invalidResponse()
19
+ return { ...decodeTag(source), referenceCount }
20
+ }
@@ -23,6 +23,7 @@ const nodeKinds = new Set([
23
23
  'javascript',
24
24
  'openapi',
25
25
  'http-request',
26
+ 'skip-duplicates',
26
27
  'llm',
27
28
  'value',
28
29
  'wait',
@@ -56,6 +57,7 @@ function nodeKind(
56
57
  | 'javascript'
57
58
  | 'openapi'
58
59
  | 'http-request'
60
+ | 'skip-duplicates'
59
61
  | 'llm'
60
62
  | 'value'
61
63
  | 'wait'
@@ -76,6 +78,7 @@ function nodeKind(
76
78
  | 'javascript'
77
79
  | 'openapi'
78
80
  | 'http-request'
81
+ | 'skip-duplicates'
79
82
  | 'llm'
80
83
  | 'value'
81
84
  | 'wait'
@@ -92,6 +95,7 @@ function nodeContext(
92
95
  event: Record<string, unknown>,
93
96
  flows: ReadonlyMap<string, string>,
94
97
  platformRunId: string,
98
+ identify: typeof opaqueId,
95
99
  ): {
96
100
  readonly executionId: Promise<string>
97
101
  readonly flowId: string
@@ -102,7 +106,7 @@ function nodeContext(
102
106
  const flowId = flows.get(rawRunId)
103
107
  if (flowId == null) throw new TypeError('Runtime node event preceded its run.started event.')
104
108
  return {
105
- executionId: projectExecutionId(platformRunId, rawRunId, string(event.jobId, 'Runtime node event jobId')),
109
+ executionId: identify('execution', platformRunId, rawRunId, string(event.jobId, 'Runtime node event jobId')),
106
110
  flowId,
107
111
  nodeId: string(event.nodeId, 'Runtime node event nodeId'),
108
112
  rawRunId,
@@ -181,6 +185,17 @@ export function createEventProjector(platformRunId: string, nodeFailureCodes: Re
181
185
  const jobs = new Map<string, string>()
182
186
  const nodeProgressBuckets = new Map<string, number>()
183
187
  const runProgressBuckets = new Map<string, number>()
188
+ // Every event of a scope or execution repeats its identity, so each digest is computed once per projector.
189
+ const identities = new Map<string, Promise<string>>()
190
+ const identify: typeof opaqueId = (prefix, ...parts) => {
191
+ const key = JSON.stringify([prefix, ...parts])
192
+ let identity = identities.get(key)
193
+ if (identity == null) {
194
+ identity = opaqueId(prefix, ...parts)
195
+ identities.set(key, identity)
196
+ }
197
+ return identity
198
+ }
184
199
  return async (value) => {
185
200
  const event = object(value, 'Runtime event')
186
201
  const type = string(event.type, 'Runtime event type')
@@ -202,8 +217,8 @@ export function createEventProjector(platformRunId: string, nodeFailureCodes: Re
202
217
  kind: 'run.started',
203
218
  payload: {
204
219
  flowId,
205
- ...(parentRunId === undefined ? {} : { parentScopeId: await opaqueId('scope', platformRunId, parentRunId) }),
206
- scopeId: await opaqueId('scope', platformRunId, rawRunId),
220
+ ...(parentRunId === undefined ? {} : { parentScopeId: await identify('scope', platformRunId, parentRunId) }),
221
+ scopeId: await identify('scope', platformRunId, rawRunId),
207
222
  },
208
223
  }
209
224
  }
@@ -223,7 +238,7 @@ export function createEventProjector(platformRunId: string, nodeFailureCodes: Re
223
238
  payload: {
224
239
  flowId,
225
240
  progress: event.progress,
226
- scopeId: await opaqueId('scope', platformRunId, rawRunId),
241
+ scopeId: await identify('scope', platformRunId, rawRunId),
227
242
  },
228
243
  }
229
244
  }
@@ -232,7 +247,7 @@ export function createEventProjector(platformRunId: string, nodeFailureCodes: Re
232
247
  const rawRunId = runId(event)
233
248
  if (type == 'run.failed' && rawRunId == platformRunId && event.failure != null) {
234
249
  const failure = object(event.failure, 'Runtime terminal failure')
235
- const context = nodeContext(failure, flows, platformRunId)
250
+ const context = nodeContext(failure, flows, platformRunId, identify)
236
251
  return {
237
252
  kind: 'node.failed',
238
253
  payload: {
@@ -255,13 +270,13 @@ export function createEventProjector(platformRunId: string, nodeFailureCodes: Re
255
270
  type == 'node.completed' ||
256
271
  type == 'node.failed'
257
272
  ) {
258
- const context = nodeContext(event, flows, platformRunId)
273
+ const context = nodeContext(event, flows, platformRunId, identify)
259
274
  const executionId = await context.executionId
260
275
  const payload = {
261
276
  executionId,
262
277
  flowId: context.flowId,
263
278
  nodeId: context.nodeId,
264
- scopeId: await opaqueId('scope', platformRunId, context.rawRunId),
279
+ scopeId: await identify('scope', platformRunId, context.rawRunId),
265
280
  ...(event.systemOutputs === undefined ? {} : { systemOutputs: object(event.systemOutputs, 'Runtime system outputs') }),
266
281
  ...(event.handled === true ? { handled: true } : {}),
267
282
  }
@@ -109,6 +109,7 @@ function nodeKind(
109
109
  | 'javascript'
110
110
  | 'openapi'
111
111
  | 'http-request'
112
+ | 'skip-duplicates'
112
113
  | 'llm'
113
114
  | 'value'
114
115
  | 'wait'
@@ -301,6 +302,7 @@ export function executeNode(
301
302
  readonly node: ExecutableNode
302
303
  readonly jobId: string
303
304
  readonly nodeInputs: Readonly<Record<string, JsonValue>>
305
+ readonly launchInputs?: Readonly<Record<string, JsonValue>>
304
306
  readonly flowId: string
305
307
  readonly runId: string
306
308
  readonly graph: Graph
@@ -368,6 +370,7 @@ export function executeNode(
368
370
  if (additional.has(handle)) return false
369
371
  const task = 'executor' in node.task ? node.task : undefined
370
372
  if (task == null || !['openapi', 'http-request'].includes(task.executor.kind)) return true
373
+ if (Object.hasOwn(input.launchInputs ?? {}, handle)) return true
371
374
  const mapping = node.inputs[handle]
372
375
  return (
373
376
  mapping?.kind != 'unset' &&
@@ -13,6 +13,7 @@ import * as Effect from 'effect/Effect'
13
13
  import * as FiberSet from 'effect/FiberSet'
14
14
  import * as Queue from 'effect/Queue'
15
15
  import { nanoid } from 'nanoid'
16
+ import { z } from 'zod'
16
17
  import { isTriggerNode } from '../../flow/common/change.ts'
17
18
  import { nodeInputMappings } from '../../flow/common/condition.ts'
18
19
  import { nodeSystemOutputPorts, supportsNodeErrorHandling } from '../../flow/common/errorHandling.ts'
@@ -70,6 +71,7 @@ export type SchedulerEvent =
70
71
  | 'javascript'
71
72
  | 'openapi'
72
73
  | 'http-request'
74
+ | 'skip-duplicates'
73
75
  | 'llm'
74
76
  | 'value'
75
77
  | 'wait'
@@ -216,7 +218,7 @@ export type RunLaunch =
216
218
  readonly resume?: never
217
219
  }
218
220
  | {
219
- readonly resume: { readonly checkpoint: unknown }
221
+ readonly resume: { readonly checkpoint: unknown; readonly readyWorkflowRunIds?: readonly string[] }
220
222
  readonly bindingValues?: never
221
223
  readonly inputs?: never
222
224
  readonly trigger?: never
@@ -249,7 +251,7 @@ function runGraph(
249
251
  runId: string,
250
252
  launchInputs: Readonly<Record<string, Readonly<Record<string, JsonValue>>>> = {},
251
253
  trigger?: TriggerSeed,
252
- resume?: { readonly checkpoint: FlowRunCheckpoint },
254
+ resume?: { readonly checkpoint: FlowRunCheckpoint; readonly readyWorkflowRunIds?: ReadonlySet<string> },
253
255
  ): Effect.Effect<FlowRunOutcome, Error> {
254
256
  return Effect.scoped(
255
257
  Effect.gen(function* () {
@@ -291,6 +293,8 @@ function runGraph(
291
293
  const changes = yield* Queue.unbounded<void>()
292
294
  let remainingMs = context.remainingMs
293
295
  let measuredAt = yield* Clock.currentTimeMillis
296
+ let nextCallCheckAt = measuredAt + (resume == null ? 1000 : 0)
297
+ let readyWorkflowRunIds = resume?.readyWorkflowRunIds
294
298
  let idleSince: number | undefined
295
299
  const agents: Record<string, FlowRunCheckpoint['agents'][string]> = { ...resume?.checkpoint.agents }
296
300
  const resolveInputs = (nodeId: string, node: ExecutableNode) => resolveNodeInputs(nodeId, node, dependencies, context.bindingValues, launch[nodeId])
@@ -344,6 +348,7 @@ function runGraph(
344
348
  node,
345
349
  jobId,
346
350
  nodeInputs,
351
+ launchInputs: launch[nodeId],
347
352
  resolution,
348
353
  flowId: target.flowId,
349
354
  runId,
@@ -544,15 +549,20 @@ function runGraph(
544
549
  ),
545
550
  now,
546
551
  )
547
- for (const call of pendingCalls.values()) {
548
- yield* observe(
549
- call.nodeId,
550
- call.jobId,
551
- Effect.gen(function* () {
552
- const result = yield* execute(call.nodeId, target.graph.nodes[call.nodeId] as ExecutableNode, call.jobId, call.input)
553
- yield* settleNode(call.nodeId, call.jobId, result)
554
- }),
555
- ).pipe(Effect.catch(() => Effect.void))
552
+ if (pendingCalls.size > 0 && now >= nextCallCheckAt) {
553
+ for (const call of pendingCalls.values()) {
554
+ if (readyWorkflowRunIds != null && !readyWorkflowRunIds.has(call.childRunId)) continue
555
+ yield* observe(
556
+ call.nodeId,
557
+ call.jobId,
558
+ Effect.gen(function* () {
559
+ const result = yield* execute(call.nodeId, target.graph.nodes[call.nodeId] as ExecutableNode, call.jobId, call.input)
560
+ yield* settleNode(call.nodeId, call.jobId, result)
561
+ }),
562
+ ).pipe(Effect.catch(() => Effect.void))
563
+ }
564
+ readyWorkflowRunIds = undefined
565
+ nextCallCheckAt = (yield* Clock.currentTimeMillis) + 1000
556
566
  }
557
567
  const ready = dependencies.ready()
558
568
  for (const nodeId of ready) executeReady(nodeId)
@@ -590,7 +600,8 @@ function runGraph(
590
600
  true,
591
601
  )
592
602
  let next = Effect.raceFirst(activity, decision)
593
- if (pendingCalls.size > 0) next = Effect.raceFirst(next, Effect.sleep(1000).pipe(Effect.as({})))
603
+ if (pendingCalls.size > 0)
604
+ next = Effect.raceFirst(next, Effect.sleep(Math.max(0, nextCallCheckAt - (yield* Clock.currentTimeMillis))).pipe(Effect.as({})))
594
605
  if (left != null) next = Effect.raceFirst(next, Effect.sleep(Math.max(0, left)).pipe(Effect.as({})))
595
606
  const resolutions = yield* next
596
607
  applyResolutions(resolutions, yield* Clock.currentTimeMillis)
@@ -629,6 +640,11 @@ export function runFlow(prepared: PreparedFlow, options: FlowRunOptions): Effect
629
640
  return yield* Effect.fail(new Error('A resumed Flow Run cannot accept launch inputs, binding values, or a Trigger seed.'))
630
641
  }
631
642
  const decoded = options.resume == null ? undefined : decodeFlowRunCheckpoint(options.resume.checkpoint)
643
+ const readyWorkflowRunIds =
644
+ options.resume?.readyWorkflowRunIds === undefined ? undefined : new Set(z.array(z.string().min(1)).parse(options.resume.readyWorkflowRunIds))
645
+ if (readyWorkflowRunIds != null && [...readyWorkflowRunIds].some((id) => !decoded?.calls?.some((call) => call.childRunId == id))) {
646
+ return yield* Effect.fail(new Error('Ready workflow Run is not present in the checkpoint.'))
647
+ }
632
648
  const outputs = (nodeId: string, value: Readonly<Record<string, JsonValue>>) =>
633
649
  isResolutionNode(prepared.graph.nodes[nodeId]) && value.pending != null ? { ...value, pending: withWaitNotificationMessage(value.pending) } : value
634
650
  const checkpoint =
@@ -690,7 +706,7 @@ export function runFlow(prepared: PreparedFlow, options: FlowRunOptions): Effect
690
706
  options.runId,
691
707
  options.inputs,
692
708
  trigger,
693
- checkpoint == null ? undefined : { checkpoint },
709
+ checkpoint == null ? undefined : { checkpoint, readyWorkflowRunIds },
694
710
  )
695
711
  })
696
712
  return program
@@ -167,6 +167,7 @@ export interface ConditionNode extends GraphNodeBase {
167
167
 
168
168
  export type ManagedTaskExecutor =
169
169
  | import('../../http/common/httpRequest.ts').HttpRequestExecutor
170
+ | import('../../state/common/state.ts').SkipDuplicatesExecutor
170
171
  | WorkflowExecutor
171
172
  | import('../../decision/common/decision.ts').DecisionExecutor
172
173
  | import('../../openapi/common/openapi.ts').OpenApiExecutor
@@ -397,7 +398,7 @@ export interface FlowDocument {
397
398
 
398
399
  export interface RevisionContent {
399
400
  readonly document: FlowDocument
400
- readonly modelVersion: 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | typeof currentFlowModelVersion
401
+ readonly modelVersion: 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | typeof currentFlowModelVersion
401
402
  readonly modules: Readonly<Record<string, CodeModule>>
402
403
  }
403
404
 
@@ -6,6 +6,7 @@ import { limitDecisionTask } from '../../decision/common/decision.ts'
6
6
  import { assertHttpAuthBindings } from '../../http/common/authBindings.ts'
7
7
  import { isHttpHost, maxHttpHosts } from '../../http/common/hosts.ts'
8
8
  import { httpRequestExecutorSchema } from '../../http/common/httpRequest.ts'
9
+ import { skipDuplicatesExecutorSchema } from '../../state/common/state.ts'
9
10
  import { checkJsonDepth } from './json.ts'
10
11
  import { retryConfigurationSchema } from './retry.ts'
11
12
  import { triggerScheduleSchema } from './triggerScheduleSchema.ts'
@@ -13,7 +14,7 @@ import { waitAutoContinueDraftSchema, waitAutoContinueSchema } from './wait.ts'
13
14
  import { webhookMethods } from './webhookMethod.ts'
14
15
  import { webhookResponseTimeout } from './webhookResponse.ts'
15
16
 
16
- export const currentFlowModelVersion = 12
17
+ export const currentFlowModelVersion = 13
17
18
  const text = z.string()
18
19
  const actionConnection = text.describe(
19
20
  'Account selection: omit while unconfigured; use an account ID to select it; use "" to explicitly defer configuration. Deferred Actions fail if called; deferred Agent tools are excluded from the callable tool set. Not supported by Triggers.',
@@ -129,6 +130,7 @@ const managedFields = z.object({
129
130
  }),
130
131
  z.strictObject({ kind: z.literal('workflow'), flowId: text, revisionId: text }),
131
132
  httpRequestExecutorSchema,
133
+ skipDuplicatesExecutorSchema,
132
134
  ]),
133
135
  })
134
136
 
@@ -318,6 +320,7 @@ const revision = z.object({
318
320
  z.literal(9),
319
321
  z.literal(10),
320
322
  z.literal(11),
323
+ z.literal(12),
321
324
  z.literal(currentFlowModelVersion),
322
325
  ]),
323
326
  document,
@@ -723,6 +726,13 @@ export function changeOperationsSchema(kind?: string): JsonValue {
723
726
  }
724
727
 
725
728
  function checkModelVersion(content: RevisionContent): void {
729
+ if (
730
+ content.modelVersion < 13 &&
731
+ Object.values(content.document.graph.nodes).some(
732
+ (candidateNode) => candidateNode.kind === 'task' && 'executor' in candidateNode.task && candidateNode.task.executor.kind === 'skip-duplicates',
733
+ )
734
+ )
735
+ throw new Error('Skip Duplicates requires Flow model v13.')
726
736
  if (
727
737
  content.modelVersion < 12 &&
728
738
  Object.values(content.document.graph.nodes).some(
@@ -26,7 +26,7 @@ export const nodeErrorSchema = {
26
26
  } as const
27
27
 
28
28
  export function supportsNodeErrorHandling(node: GraphNode): boolean {
29
- return supportsNodeRetry(node)
29
+ return supportsNodeRetry(node) || (node.kind === 'task' && 'executor' in node.task && node.task.executor.kind === 'skip-duplicates')
30
30
  }
31
31
 
32
32
  export function nodeSystemOutputPorts(node: GraphNode): Readonly<Record<string, PortDefinition>> {
@@ -2,6 +2,7 @@ import { dequal } from 'dequal/lite'
2
2
  import { decisionTaskIssues } from '../../decision/common/decision.ts'
3
3
  import { httpRequestIssues, httpAuthHandles, httpRequestTask, httpRequestUrl } from '../../http/common/httpRequest.ts'
4
4
  import { openApiIssues, authHandles } from '../../openapi/common/openapi.ts'
5
+ import { skipDuplicatesTask } from '../../state/common/state.ts'
5
6
  import { isTriggerNode } from './change.ts'
6
7
  import { nodeWorkflowReferences } from './workflowReferences.ts'
7
8
  export { renderPrompt } from './promptTemplate.ts'
@@ -273,6 +274,20 @@ export async function validateFlow(revision: RevisionContent, engine: EngineCont
273
274
  })
274
275
  for (const message of openApiIssues(task))
275
276
  checked.diagnostics.push({ code: 'openapi.config-invalid', column: 0, line: 1, message, path: `/document/graph/nodes/${nodeId}/task/executor` })
277
+ if (task.executor.kind === 'skip-duplicates') {
278
+ const expected = skipDuplicatesTask(task.executor)
279
+ if (
280
+ !dequal(httpPortContracts(task.inputs), httpPortContracts(expected.inputs)) ||
281
+ !dequal(httpPortContracts(task.outputs), httpPortContracts(expected.outputs))
282
+ )
283
+ checked.diagnostics.push({
284
+ code: 'skip-duplicates.ports-invalid',
285
+ column: 0,
286
+ line: 1,
287
+ message: 'Deduplication ports must match the fixed contract.',
288
+ path: `/document/graph/nodes/${nodeId}/task`,
289
+ })
290
+ }
276
291
  if (task.executor.kind == 'http-request') {
277
292
  for (const issue of httpRequestIssues(task.executor))
278
293
  checked.diagnostics.push({
@@ -440,6 +455,13 @@ const httpPortContracts = (ports: import('./change.ts').ManagedTaskDefinition['i
440
455
  export async function prepareFlow(revision: RevisionContent, engineContract: string, triggerId?: string): Promise<PrepareFlowResult> {
441
456
  const engine = findEngineContract(engineContract)
442
457
  if (engine == null) return { kind: 'engine-unsupported' }
458
+ if (
459
+ !engine.flowState &&
460
+ Object.values(revision.document.graph.nodes).some(
461
+ (node) => node.kind === 'task' && 'executor' in node.task && node.task.executor.kind === 'skip-duplicates',
462
+ )
463
+ )
464
+ return { kind: 'engine-unsupported' }
443
465
  if (
444
466
  !engine.httpRequests &&
445
467
  Object.values(revision.document.graph.nodes).some((node) => node.kind == 'task' && 'executor' in node.task && node.task.executor.kind == 'http-request')
@@ -507,11 +529,15 @@ export async function prepareFlow(revision: RevisionContent, engineContract: str
507
529
 
508
530
  export function createRuntimeProgram(prepared: PreparedFlow, entryModuleId: string, engineDigest: string): RuntimeProgram | undefined {
509
531
  if (!Object.hasOwn(prepared.modules, entryModuleId)) return
532
+ const modules = new Set([entryModuleId])
533
+ for (const id of modules) {
534
+ for (const imported of prepared.modules[id]!.imports) modules.add(imported)
535
+ }
510
536
  return {
511
537
  engineContract: prepared.engineContract,
512
538
  engineDigest,
513
539
  entryModuleId,
514
- modules: prepared.modules,
540
+ modules: Object.fromEntries([...modules].map((id) => [id, prepared.modules[id]!])),
515
541
  }
516
542
  }
517
543
 
@@ -9,10 +9,11 @@ export interface EngineContract {
9
9
  readonly httpRequests?: boolean
10
10
  /** Code may call `context.fetch` for hosts declared by its `http` Capability. */
11
11
  readonly codeFetch?: boolean
12
+ readonly flowState?: boolean
12
13
  }
13
14
 
14
- /** v9 is a single contract: the Node compatibility builtins, `context.fetch`, and no `context.egress`. */
15
- export const currentEngineContract = 'open-flow-engine/v9'
15
+ /** v10 adds persistent Flow State to the published v9 Node compatibility and fetch contract. */
16
+ export const currentEngineContract = 'open-flow-engine/v10'
16
17
  /** The published v8 Node compatibility contract, retained for existing Publications and Runs. */
17
18
  export const nodejsEngineContract = 'open-flow-engine/v8/nodejs-compat-v1'
18
19
 
@@ -63,10 +64,12 @@ const nodejsContract: EngineContract = {
63
64
  platformSource: platformSource(nodejsEngineContract),
64
65
  }
65
66
 
66
- const currentContract: EngineContract = { ...nodejsContract, codeFetch: true, platformSource: platformSource(currentEngineContract) }
67
+ const v9Contract: EngineContract = { ...nodejsContract, codeFetch: true, platformSource: platformSource('open-flow-engine/v9') }
68
+ const currentContract: EngineContract = { ...v9Contract, flowState: true, platformSource: platformSource(currentEngineContract) }
67
69
 
68
70
  export function findEngineContract(contract: string): EngineContract | undefined {
69
71
  if (contract == currentEngineContract) return currentContract
72
+ if (contract == 'open-flow-engine/v9') return v9Contract
70
73
  if (contract == 'open-flow-engine/v8') return v8Contract
71
74
  if (contract == nodejsEngineContract) return nodejsContract
72
75
  if (contract == 'open-flow-engine/v7' || contract == 'open-flow-engine/v7/nodejs-compat-v1')