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

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 (139) hide show
  1. package/README.md +3 -1
  2. package/dist/browser/{ContentIcon-CUQHjQIw.js → ContentIcon-Cm1hbVzW.js} +4 -3
  3. package/dist/browser/{FlowCanvas-Dtf8_cQp.js → FlowCanvas-NzKtla_c.js} +62 -62
  4. package/dist/browser/{createResourceDialog-DI9pL-Yr.js → createResourceDialog-BgdedQqu.js} +3 -3
  5. package/dist/browser/{dialog-BYJGvItc.js → dialog-aSElFFw2.js} +322 -26
  6. package/dist/browser/{exportSvg-BOoy7-Db.js → exportSvg-Dcz4HTo_.js} +2 -2
  7. package/dist/browser/{flowChanges-RL-H4CjD.js → flowChanges-NZyS7HSp.js} +490 -438
  8. package/dist/browser/{flowWorkspace-BkDsETj0.js → flowWorkspace-Bl0SDehy.js} +499 -61
  9. package/dist/browser/preview.css +696 -825
  10. package/dist/browser/preview.js +1399 -717
  11. package/dist/browser/theme.css +114 -262
  12. package/dist/browser/{tooltip-wZJRB9oP.js → tooltip-3XMK9jkr.js} +1 -1
  13. package/dist/browser/{typeScriptSession-CSRwWmZm.js → typeScriptSession-C9sBGOKZ.js} +1 -1
  14. package/dist/browser/ui.css +354 -460
  15. package/dist/browser/ui.js +1 -1
  16. package/dist/browser/workbench.css +696 -825
  17. package/dist/browser/workbench.js +13 -7
  18. package/dist/browser/{workbenchSelect-BcWPWGzw.js → workbenchSelect-DBGP7Y4Z.js} +2 -2
  19. package/dist/command/index.js +41 -43
  20. package/dist/common/authoring.d.ts +5 -5
  21. package/dist/common/authoringExamples.d.ts +3 -2
  22. package/dist/common/authoringSchema.d.ts +70 -4
  23. package/dist/common/connector-proxy.d.ts +1 -1
  24. package/dist/common/control-api-conformance.js +250 -138
  25. package/dist/common/control-api.d.ts +3 -2
  26. package/dist/common/control-api.js +246 -134
  27. package/dist/common/control-requests.d.ts +3 -3
  28. package/dist/common/control-requests.js +353 -79
  29. package/dist/common/cron-trigger.d.ts +2 -1
  30. package/dist/common/decision.d.ts +4 -4
  31. package/dist/common/decision.js +5 -2
  32. package/dist/common/engine-contract.d.ts +6 -1
  33. package/dist/common/event-source-definition.d.ts +1 -1
  34. package/dist/common/event-source-view.d.ts +22 -0
  35. package/dist/common/event-source.d.ts +1 -1
  36. package/dist/common/event-source.js +24 -65
  37. package/dist/common/event-sources.d.ts +0 -21
  38. package/dist/common/flow-authoring.js +90 -19
  39. package/dist/common/flow-change-schema.d.ts +9 -1
  40. package/dist/common/flow-change.d.ts +7 -201
  41. package/dist/common/flow-change.js +96 -23
  42. package/dist/common/flow-encoding.js +89 -21
  43. package/dist/common/flow-graph.d.ts +2 -1
  44. package/dist/common/flow-input-value.d.ts +1 -1
  45. package/dist/common/flow-model.d.ts +121 -0
  46. package/dist/common/flow-notifications.d.ts +1 -1
  47. package/dist/common/flow-retry.d.ts +3 -5
  48. package/dist/common/flow-semantics.js +816 -586
  49. package/dist/common/flow-workflowReferences.d.ts +8 -0
  50. package/dist/common/flowInspection.d.ts +1 -1
  51. package/dist/common/http-hosts.d.ts +6 -0
  52. package/dist/common/http-request.d.ts +164 -0
  53. package/dist/common/http-request.js +5272 -0
  54. package/dist/common/integration-trigger.d.ts +2 -1
  55. package/dist/common/mcp.d.ts +3 -3
  56. package/dist/common/mcp.js +248 -136
  57. package/dist/common/openapi.d.ts +4 -4
  58. package/dist/common/openapi.js +4 -1
  59. package/dist/common/poll-trigger.d.ts +2 -1
  60. package/dist/common/run-events.js +11 -9
  61. package/dist/common/runtime-contract.d.ts +23 -1
  62. package/dist/common/runtime-contract.js +607 -72
  63. package/dist/common/runtime-nodejs-compat.d.ts +44 -0
  64. package/dist/common/runtime-nodejs-compat.js +5870 -0
  65. package/dist/common/scheduler-checkpoint.d.ts +1 -1
  66. package/dist/common/scheduler-node-execution.d.ts +1 -1
  67. package/dist/common/scheduler.d.ts +2 -2
  68. package/dist/common/scheduler.js +95 -25
  69. package/dist/common/trigger-catalog.d.ts +1 -7
  70. package/dist/common/trigger-config-options.d.ts +1 -1
  71. package/dist/common/trigger-config.d.ts +1 -1
  72. package/dist/common/trigger-contract.d.ts +2 -1
  73. package/dist/common/trigger-localization.d.ts +8 -2
  74. package/dist/common/trigger-model.d.ts +101 -0
  75. package/dist/common/webhook-response.d.ts +1 -3
  76. package/dist/common/webhook-trigger.d.ts +3 -1
  77. package/dist/common/webhook-trigger.js +12 -11
  78. package/dist/common/workflow.d.ts +3 -12
  79. package/dist/common/workflow.js +25 -23
  80. package/dist/index.d.ts +41 -14
  81. package/package.json +9 -1
  82. package/skills/open-flow/references/flow-authoring.md +1 -0
  83. package/skills/open-flow/references/flow-n8n-conversion.md +19 -15
  84. package/src/base/common/json.ts +1 -1
  85. package/src/connector/common/proxy.ts +1 -1
  86. package/src/control/common/api.ts +7 -5
  87. package/src/control/common/authoring.ts +14 -3
  88. package/src/control/common/authoringExamples.ts +8 -0
  89. package/src/control/common/authoringSchema.ts +38 -25
  90. package/src/control/common/authoringTools.ts +1 -1
  91. package/src/control/common/conformance.ts +1 -1
  92. package/src/control/common/connectorDecoders.ts +1 -1
  93. package/src/control/common/eventSources.ts +2 -29
  94. package/src/control/common/triggerCatalog.ts +1 -8
  95. package/src/decision/common/decision.ts +15 -7
  96. package/src/event-source/common/definition.ts +1 -1
  97. package/src/event-source/common/index.ts +1 -1
  98. package/src/event-source/common/view.ts +20 -0
  99. package/src/execution/common/checkpoint.ts +1 -1
  100. package/src/execution/common/events.ts +3 -0
  101. package/src/execution/common/node-execution.ts +5 -3
  102. package/src/execution/common/scheduler.ts +2 -1
  103. package/src/flow/common/change.ts +59 -192
  104. package/src/flow/common/changeSchema.ts +39 -5
  105. package/src/flow/common/connectionUsage.ts +1 -1
  106. package/src/flow/common/graph.ts +10 -4
  107. package/src/flow/common/inputValue.ts +1 -1
  108. package/src/flow/common/model.ts +130 -0
  109. package/src/flow/common/modules.ts +1 -1
  110. package/src/flow/common/portChanges.ts +1 -1
  111. package/src/flow/common/retry.ts +3 -5
  112. package/src/flow/common/semantics.ts +71 -7
  113. package/src/flow/common/webhookResponse.ts +4 -8
  114. package/src/flow/common/workflowReferences.ts +31 -0
  115. package/src/http/common/authBindings.ts +9 -0
  116. package/src/http/common/headers.ts +5 -0
  117. package/src/http/common/hosts.ts +13 -0
  118. package/src/http/common/httpRequest.ts +266 -0
  119. package/src/openapi/common/openapi.ts +9 -5
  120. package/src/{execution → runtime}/common/engineContract.ts +24 -15
  121. package/src/runtime/common/nodejsCompat.ts +275 -0
  122. package/src/runtime/common/nodejsGuest.generated.json +47 -0
  123. package/src/runtime/common/runtime.ts +1138 -0
  124. package/src/trigger/common/config.ts +1 -1
  125. package/src/trigger/common/configOptions.ts +1 -1
  126. package/src/trigger/common/contract.ts +2 -1
  127. package/src/trigger/common/cron.ts +2 -1
  128. package/src/trigger/common/integration.ts +2 -1
  129. package/src/trigger/common/model.ts +87 -0
  130. package/src/trigger/common/poll.ts +2 -1
  131. package/src/trigger/common/webhook.ts +7 -4
  132. package/src/trigger/providers/fieldDescriptions.ts +1 -1
  133. package/src/trigger/providers/localization.ts +9 -2
  134. package/src/trigger/providers/remote.ts +1 -1
  135. package/src/types/index.ts +44 -15
  136. package/src/workflow/common/workflow.ts +3 -44
  137. package/src/execution/common/runtime.ts +0 -563
  138. package/src/openapi/common/authBindings.ts +0 -12
  139. /package/src/{control → connector}/common/providerIconSprite.ts +0 -0
@@ -0,0 +1,130 @@
1
+ import type { JsonValue, Port, PortDefinition } from '../../types/index.ts'
2
+
3
+ export type { JsonValue, Port, PortDefinition } from '../../types/index.ts'
4
+
5
+ /** Primitives shared by every node family. Node unions and graph operations belong to change.ts. */
6
+
7
+ export interface RetryConfiguration {
8
+ readonly maxRetries: number
9
+ readonly intervalMs: number
10
+ }
11
+
12
+ export interface InputPortDefinition extends PortDefinition {
13
+ readonly value?: JsonValue
14
+ }
15
+
16
+ export interface InputPort extends InputPortDefinition {
17
+ readonly handle: string
18
+ }
19
+
20
+ export interface Group {
21
+ readonly collapsed?: boolean
22
+ readonly group: string
23
+ }
24
+
25
+ export function portsByHandle<Value extends Port>(ports: readonly (Value | Group)[]): Readonly<Record<string, Value>> {
26
+ return Object.fromEntries(ports.flatMap((port) => ('handle' in port ? [[port.handle, port]] : [])))
27
+ }
28
+
29
+ export interface NodeSource {
30
+ readonly system?: true
31
+ readonly field?: string
32
+ readonly kind: 'node'
33
+ readonly nodeId: string
34
+ readonly output: string
35
+ }
36
+
37
+ export interface BindingSource {
38
+ readonly bindingId: string
39
+ readonly kind: 'binding'
40
+ }
41
+
42
+ export type FixedInputValue = { readonly kind: 'unset' } | { readonly kind: 'value'; readonly value: JsonValue }
43
+ export type InputValues = Readonly<Record<string, FixedInputValue>>
44
+
45
+ export type InputMapping = FixedInputValue | { readonly kind: 'sources'; readonly sources: readonly (BindingSource | NodeSource)[] }
46
+
47
+ export interface GraphNodeBase {
48
+ readonly description?: string
49
+ readonly icon?: string
50
+ readonly inputs: Readonly<Record<string, InputMapping>>
51
+ readonly name?: string
52
+ readonly maxExecutions?: number
53
+ readonly timeoutMs?: number
54
+ readonly retry?: RetryConfiguration
55
+ readonly onError?: 'stop' | 'error'
56
+ }
57
+
58
+ export type Source = BindingSource | NodeSource
59
+
60
+ export interface WorkflowCapability {
61
+ readonly kind: 'workflow'
62
+ readonly name: string
63
+ readonly flowId: string
64
+ readonly revisionId: string
65
+ readonly inputs: readonly Port[]
66
+ readonly outputs: readonly Port[] | null
67
+ }
68
+
69
+ /** Hosts that Code may call through `context.fetch`; requires Flow model 12 and Engine v9. */
70
+ export interface HttpCapability {
71
+ readonly kind: 'http'
72
+ readonly hosts: readonly string[]
73
+ }
74
+
75
+ export type TaskCapability = ConnectorCapability | WorkflowCapability | HttpCapability
76
+
77
+ export interface ConnectorAccessCapability {
78
+ readonly actionHints?: readonly string[]
79
+ readonly connectionHints?: readonly {
80
+ readonly action: string
81
+ readonly connectionId: string
82
+ readonly alias?: string
83
+ }[]
84
+ readonly kind: 'connector'
85
+ }
86
+
87
+ export interface ConnectorActionCapability {
88
+ readonly action: string
89
+ readonly connections: readonly { readonly connectionId: string; readonly alias?: string }[]
90
+ readonly connectionId?: string
91
+ readonly kind: 'connector'
92
+ }
93
+
94
+ export type ConnectorPermissionCapability =
95
+ | { readonly kind: 'connector'; readonly mode: 'shared' }
96
+ | { readonly kind: 'connector'; readonly mode: 'independent'; readonly actions: readonly { readonly action: string; readonly connectionId?: string }[] }
97
+
98
+ export type ConnectorCapability = ConnectorAccessCapability | ConnectorActionCapability | ConnectorPermissionCapability
99
+
100
+ export interface TaskDefinitionBase {
101
+ readonly inputs: readonly (InputPort | Group)[]
102
+ readonly name: string
103
+ readonly outputs: readonly (Port | Group)[]
104
+ }
105
+
106
+ export interface InlineTaskDefinition extends TaskDefinitionBase {
107
+ readonly capabilities?: readonly TaskCapability[]
108
+ readonly moduleId: string
109
+ }
110
+
111
+ /** The executor shape every managed Task shares; each node family narrows it with its own executor. */
112
+ export interface TaskExecutor {
113
+ readonly kind: string
114
+ }
115
+
116
+ export interface ManagedTaskDefinitionOf<Executor extends TaskExecutor> extends TaskDefinitionBase {
117
+ readonly executor: Executor
118
+ }
119
+
120
+ export interface WorkflowExecutor {
121
+ readonly kind: 'workflow'
122
+ readonly flowId: string
123
+ readonly revisionId: string
124
+ }
125
+
126
+ export interface CodeModule {
127
+ readonly imports: readonly string[]
128
+ readonly name: string
129
+ readonly source: string
130
+ }
@@ -1,4 +1,4 @@
1
- import type { EngineContract } from '../../execution/common/engineContract.ts'
1
+ import type { EngineContract } from '../../runtime/common/engineContract.ts'
2
2
  import type { RevisionContent } from './change.ts'
3
3
  import type { Diagnostic } from './semantics.ts'
4
4
 
@@ -1,4 +1,4 @@
1
- import type { InputMapping } from './change.ts'
1
+ import type { InputMapping } from './model.ts'
2
2
 
3
3
  export function renamedPort(
4
4
  previous: readonly ({ readonly handle: string } | { readonly group: string })[],
@@ -1,13 +1,11 @@
1
1
  import type { GraphNode } from './change.ts'
2
+ import type { RetryConfiguration } from './model.ts'
2
3
 
3
4
  import { z } from 'zod'
4
5
 
5
- export interface RetryConfiguration {
6
- readonly maxRetries: number
7
- readonly intervalMs: number
8
- }
6
+ export type { RetryConfiguration } from './model.ts'
9
7
 
10
- export const retryNodeTypes = ['code', 'connector', 'openapi', 'llm', 'decision'] as const
8
+ export const retryNodeTypes = ['code', 'connector', 'openapi', 'http-request', 'llm', 'decision'] as const
11
9
  export const nodeRetryLimits = { maxRetries: 5, minIntervalMs: 1000, maxIntervalMs: 60000, intervalStepMs: 1000 } as const
12
10
  export const defaultNodeRetry: RetryConfiguration = { maxRetries: 2, intervalMs: 2000 }
13
11
  export const retryConfigurationSchema = z.strictObject({
@@ -1,18 +1,20 @@
1
+ import { dequal } from 'dequal/lite'
1
2
  import { decisionTaskIssues } from '../../decision/common/decision.ts'
3
+ import { httpRequestIssues, httpAuthHandles, httpRequestTask, httpRequestUrl } from '../../http/common/httpRequest.ts'
2
4
  import { openApiIssues, authHandles } from '../../openapi/common/openapi.ts'
3
- import { nodeWorkflowReferences } from '../../workflow/common/workflow.ts'
4
5
  import { isTriggerNode } from './change.ts'
6
+ import { nodeWorkflowReferences } from './workflowReferences.ts'
5
7
  export { renderPrompt } from './promptTemplate.ts'
6
8
  export { connectionUsage, removeConnectionUsage } from './connectionUsage.ts'
7
9
  import { matchesTriggerOutputs, normalizeTriggerOutputs } from '../../trigger/common/contract.ts'
8
10
  import { nodeInputMappings } from './condition.ts'
9
11
  import { connectionUsage } from './connectionUsage.ts'
10
12
  export { matchesTriggerOutputs, normalizeTriggerOutputs, triggerOutputDefinitions, triggerOutputPorts } from '../../trigger/common/contract.ts'
11
- import type { EngineContract } from '../../execution/common/engineContract.ts'
12
- import type { RuntimeProgram } from '../../execution/common/runtime.ts'
13
+ import type { EngineContract } from '../../runtime/common/engineContract.ts'
14
+ import type { RuntimeProgram } from '../../runtime/common/runtime.ts'
13
15
  import type { ConnectorActionCapability, ConnectorCapability, Graph, InputMapping, JsonValue, Port, RevisionContent, SchemaMismatch } from './change.ts'
14
16
 
15
- import { findEngineContract } from '../../execution/common/engineContract.ts'
17
+ import { findEngineContract } from '../../runtime/common/engineContract.ts'
16
18
  import { agentConfigIssues } from './agent.ts'
17
19
  import { decodeTaskCapabilities } from './change.ts'
18
20
  import { canonicalJsonBytes, canonicalModule, canonicalRevisionGraph, digestBytes } from './encoding.ts'
@@ -271,13 +273,57 @@ export async function validateFlow(revision: RevisionContent, engine: EngineCont
271
273
  })
272
274
  for (const message of openApiIssues(task))
273
275
  checked.diagnostics.push({ code: 'openapi.config-invalid', column: 0, line: 1, message, path: `/document/graph/nodes/${nodeId}/task/executor` })
274
- if (task.executor.kind == 'openapi') {
275
- const handles = authHandles(task.executor.auth)
276
+ if (task.executor.kind == 'http-request') {
277
+ for (const issue of httpRequestIssues(task.executor))
278
+ checked.diagnostics.push({
279
+ code: 'http-request.config-invalid',
280
+ column: 0,
281
+ line: 1,
282
+ message: issue.message,
283
+ path: `/document/graph/nodes/${nodeId}/task/executor/${issue.field}`,
284
+ })
285
+ if (node.inputs.url?.kind == 'value') {
286
+ try {
287
+ httpRequestUrl(node.inputs.url.value)
288
+ } catch {
289
+ checked.diagnostics.push({
290
+ code: 'http-request.url-invalid',
291
+ column: 0,
292
+ line: 1,
293
+ message: 'Enter an HTTP(S) URL without embedded credentials.',
294
+ path: `/document/graph/nodes/${nodeId}/inputs/url`,
295
+ })
296
+ }
297
+ }
298
+ if (task.executor.bodyFormat != 'none' && (node.inputs.body == null || node.inputs.body.kind == 'unset'))
299
+ checked.diagnostics.push({
300
+ code: 'http-request.body-required',
301
+ column: 0,
302
+ line: 1,
303
+ message: 'Set or bind the request body; JSON null is an explicit value.',
304
+ path: `/document/graph/nodes/${nodeId}/inputs/body`,
305
+ })
306
+ const expected = httpRequestTask(task.executor)
307
+
308
+ if (
309
+ !dequal(httpPortContracts(task.inputs), httpPortContracts(expected.inputs)) ||
310
+ !dequal(httpPortContracts(task.outputs), httpPortContracts(expected.outputs))
311
+ )
312
+ checked.diagnostics.push({
313
+ code: 'http-request.ports-invalid',
314
+ column: 0,
315
+ line: 1,
316
+ message: 'HTTP Request ports must match its configuration.',
317
+ path: `/document/graph/nodes/${nodeId}/task`,
318
+ })
319
+ }
320
+ if (task.executor.kind == 'openapi' || task.executor.kind == 'http-request') {
321
+ const handles = task.executor.kind == 'openapi' ? authHandles(task.executor.auth) : httpAuthHandles(task.executor)
276
322
  for (const handle of handles) {
277
323
  const mapping = node.inputs[handle]
278
324
  if (mapping?.kind != 'sources' || mapping.sources.length == 0)
279
325
  checked.diagnostics.push({
280
- code: 'openapi.auth-source',
326
+ code: `${task.executor.kind}.auth-source`,
281
327
  column: 0,
282
328
  line: 1,
283
329
  message: 'Authentication requires a deployment variable or upstream output.',
@@ -385,9 +431,27 @@ function runRevision(revision: RevisionContent, triggerId: string): RevisionCont
385
431
  }
386
432
  }
387
433
 
434
+ const httpPortContracts = (ports: import('./change.ts').ManagedTaskDefinition['inputs']) =>
435
+ ports
436
+ .filter((port) => 'handle' in port)
437
+ .map(({ handle, jsonSchema, nullable }) => ({ handle, jsonSchema, nullable }))
438
+ .toSorted((a, b) => a.handle.localeCompare(b.handle))
439
+
388
440
  export async function prepareFlow(revision: RevisionContent, engineContract: string, triggerId?: string): Promise<PrepareFlowResult> {
389
441
  const engine = findEngineContract(engineContract)
390
442
  if (engine == null) return { kind: 'engine-unsupported' }
443
+ if (
444
+ !engine.httpRequests &&
445
+ Object.values(revision.document.graph.nodes).some((node) => node.kind == 'task' && 'executor' in node.task && node.task.executor.kind == 'http-request')
446
+ )
447
+ return { kind: 'engine-unsupported' }
448
+ if (
449
+ !engine.codeFetch &&
450
+ Object.values(revision.document.graph.nodes).some(
451
+ (node) => node.kind == 'task' && 'moduleId' in node.task && node.task.capabilities?.some((item) => item.kind == 'http'),
452
+ )
453
+ )
454
+ return { kind: 'engine-unsupported' }
391
455
  if (!engine.workflowTools && Object.values(revision.document.graph.nodes).some((node) => nodeWorkflowReferences(node).some((ref) => ref.tool != null)))
392
456
  return { kind: 'engine-unsupported' }
393
457
  if (triggerId != null) {
@@ -1,4 +1,6 @@
1
- import type { InputPort, JsonValue, Port, PortDefinition } from './change.ts'
1
+ import type { InputPort, JsonValue, Port, PortDefinition } from './model.ts'
2
+
3
+ import { httpHeaderNameIssue } from '../../http/common/headers.ts'
2
4
 
3
5
  export const webhookResponseTimeout = {
4
6
  defaultMs: 30_000,
@@ -30,12 +32,6 @@ export function workflowOutputsPort(outputs: readonly Port[]): PortDefinition {
30
32
  } as const
31
33
  }
32
34
 
33
- /** Header names are case-insensitive; never silently combine duplicate business fields. */
34
- export function webhookHeaderNameIssue(name: string, names: readonly string[]): 'invalid' | 'duplicate' | undefined {
35
- if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) return 'invalid'
36
- if (names.some((candidate) => candidate.toLowerCase() === name.toLowerCase())) return 'duplicate'
37
- }
38
-
39
35
  /** Fixed inputs belong to the node, not author-supplied definitions. */
40
36
  export const webhookResponseInputs: readonly InputPort[] = [
41
37
  { handle: 'body', jsonSchema: {}, nullable: true, value: '' },
@@ -60,7 +56,7 @@ export function webhookHeaderValues(source: unknown): Readonly<Record<string, st
60
56
  const headers: Record<string, string> = {}
61
57
  const names: string[] = []
62
58
  for (const [name, value] of Object.entries(source)) {
63
- if (webhookHeaderNameIssue(name, names) != null) return
59
+ if (httpHeaderNameIssue(name, names) != null) return
64
60
  if (typeof value !== 'string' && typeof value !== 'boolean' && !(typeof value === 'number' && Number.isFinite(value))) return
65
61
  const text = String(value)
66
62
  if ([...text].some((character) => character.charCodeAt(0) > 255 || /[\0\r\n]/.test(character))) return
@@ -0,0 +1,31 @@
1
+ import type { GraphNode } from './change.ts'
2
+ import type { WorkflowCapability } from './model.ts'
3
+
4
+ /** Workflow declarations share discovery across authoring, admission and resource indexing. */
5
+ export function nodeWorkflowReferences(node: GraphNode): readonly {
6
+ readonly target: Omit<WorkflowCapability, 'kind' | 'name'>
7
+ readonly path: string
8
+ readonly tool?: string
9
+ }[] {
10
+ if (node.kind != 'task') return []
11
+ if ('moduleId' in node.task)
12
+ return (node.task.capabilities ?? []).flatMap((item, index) =>
13
+ item.kind == 'workflow' ? [{ target: item, path: `task/capabilities/${index}`, tool: item.name }] : [],
14
+ )
15
+ if (node.task.executor.kind == 'workflow')
16
+ return [
17
+ {
18
+ target: {
19
+ ...node.task.executor,
20
+ inputs: node.task.inputs.filter((port) => 'handle' in port),
21
+ outputs: node.task.outputs.filter((port) => 'handle' in port),
22
+ },
23
+ path: 'task/executor',
24
+ },
25
+ ]
26
+ if (node.task.executor.kind == 'agent')
27
+ return node.task.executor.tools.flatMap((item, index) =>
28
+ item.kind == 'workflow' ? [{ target: item, path: `task/executor/tools/${index}`, tool: item.name }] : [],
29
+ )
30
+ return []
31
+ }
@@ -0,0 +1,9 @@
1
+ import type { InputMapping } from '../../flow/common/model.ts'
2
+
3
+ /** Never allow credentials to enter a Revision as literal inputs of an OpenAPI or HTTP request node. */
4
+ export function assertHttpAuthBindings(inputs: Readonly<Record<string, InputMapping>>): void {
5
+ for (const [handle, mapping] of Object.entries(inputs)) {
6
+ if (handle.startsWith('auth.') && mapping.kind == 'value')
7
+ throw new TypeError('HTTP authentication accepts deployment variables and upstream outputs only.')
8
+ }
9
+ }
@@ -0,0 +1,5 @@
1
+ /** Header names are case-insensitive; never silently combine duplicate business fields. */
2
+ export function httpHeaderNameIssue(name: string, names: readonly string[]): 'invalid' | 'duplicate' | undefined {
3
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) return 'invalid'
4
+ if (names.some((candidate) => candidate.toLowerCase() === name.toLowerCase())) return 'duplicate'
5
+ }
@@ -0,0 +1,13 @@
1
+ /** Hosts of a Code `http` Capability: lowercase DNS names or IPv4 addresses; `*.example.com` matches subdomains only. */
2
+ export const httpHostPattern = /^(?:\*\.)?(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
3
+ export const maxHttpHosts = 20
4
+
5
+ /** Wildcards must name a registrable suffix, so `*.com` is rejected. */
6
+ export function isHttpHost(value: string): boolean {
7
+ return httpHostPattern.test(value) && (!value.startsWith('*.') || value.slice(2).includes('.'))
8
+ }
9
+
10
+ export function httpHostAllowed(hosts: readonly string[], hostname: string): boolean {
11
+ const host = hostname.toLowerCase()
12
+ return hosts.some((pattern) => (pattern.startsWith('*.') ? host.endsWith(pattern.slice(1)) : host == pattern))
13
+ }
@@ -0,0 +1,266 @@
1
+ import type { InputMapping, InputPort, JsonValue, ManagedTaskDefinitionOf, TaskDefinitionBase, TaskExecutor } from '../../flow/common/model.ts'
2
+
3
+ import { dequal } from 'dequal/lite'
4
+ import { z } from 'zod'
5
+ import { remapInputMappings, renamedPort } from '../../flow/common/portChanges.ts'
6
+ import { httpHeaderNameIssue } from './headers.ts'
7
+
8
+ export { httpHostAllowed, isHttpHost, maxHttpHosts } from './hosts.ts'
9
+
10
+ const parameterSchema = z.strictObject({ mode: z.enum(['fields', 'object']).default('fields'), names: z.array(z.string()).default([]) })
11
+ export const httpRequestConfigSchema = z.strictObject({
12
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']).default('GET'),
13
+ query: parameterSchema.default({ mode: 'fields', names: [] }),
14
+ headers: parameterSchema.default({ mode: 'fields', names: [] }),
15
+ bodyFormat: z.enum(['none', 'json', 'text', 'form']).default('none'),
16
+ authentication: z
17
+ .union([
18
+ z.strictObject({ type: z.literal('none') }),
19
+ z.strictObject({ type: z.enum(['bearer', 'basic']) }),
20
+ z.strictObject({ type: z.literal('apiKey'), name: z.string(), in: z.enum(['header', 'query']) }),
21
+ ])
22
+ .default({ type: 'none' }),
23
+ responseFormat: z.enum(['auto', 'json', 'text']).default('auto'),
24
+ statusPolicy: z.enum(['success', 'all']).default('success'),
25
+ })
26
+ export type HttpRequestConfig = z.infer<typeof httpRequestConfigSchema>
27
+ export interface HttpRequestExecutor extends HttpRequestConfig {
28
+ readonly kind: 'http-request'
29
+ }
30
+ export const httpRequestExecutorSchema = httpRequestConfigSchema.extend({ kind: z.literal('http-request') })
31
+ const scalarSchema = { type: ['string', 'number', 'boolean'] }
32
+ const queryValueSchema = { anyOf: [scalarSchema, { type: 'array', items: scalarSchema }] }
33
+ export const httpHeaderSchema = { type: 'object', additionalProperties: { type: 'string' } }
34
+ export const httpQuerySchema = { type: 'object', additionalProperties: queryValueSchema }
35
+
36
+ export function httpAuthHandles(config: HttpRequestConfig): string[] {
37
+ return config.authentication.type == 'none' ? [] : config.authentication.type == 'basic' ? ['auth.username', 'auth.password'] : ['auth.token']
38
+ }
39
+ // Preserve metadata on ports whose contracts did not change.
40
+ function retain(ports: readonly InputPort[], old: TaskDefinitionBase['inputs']): TaskDefinitionBase['inputs'] {
41
+ const remaining = new Map(ports.map((port) => [port.handle, port]))
42
+ return [
43
+ ...old.flatMap((item): TaskDefinitionBase['inputs'] => {
44
+ if ('group' in item) return [item]
45
+ const next = remaining.get(item.handle)
46
+ if (next == null) return []
47
+ remaining.delete(item.handle)
48
+ return [dequal(item.jsonSchema, next.jsonSchema) && item.nullable == next.nullable ? item : next]
49
+ }),
50
+ ...remaining.values(),
51
+ ]
52
+ }
53
+ export function httpRequestTask(
54
+ config: HttpRequestConfig = httpRequestConfigSchema.parse({}),
55
+ name = 'HTTP Request',
56
+ previous?: TaskDefinitionBase,
57
+ ): ManagedTaskDefinitionOf<HttpRequestExecutor> {
58
+ const inputs: InputPort[] = [{ handle: 'url', jsonSchema: { type: 'string' }, nullable: false }]
59
+ for (const group of ['query', 'headers'] as const) {
60
+ if (config[group].mode == 'object') inputs.push({ handle: group, jsonSchema: group == 'query' ? httpQuerySchema : httpHeaderSchema, nullable: true })
61
+ else
62
+ for (const parameterName of config[group].names.filter((value) => value.trim()))
63
+ inputs.push({
64
+ handle: `${group == 'query' ? 'query' : 'header'}.${parameterName}`,
65
+ jsonSchema: group == 'query' ? queryValueSchema : { type: 'string' },
66
+ nullable: true,
67
+ })
68
+ }
69
+ if (config.bodyFormat != 'none')
70
+ inputs.push({
71
+ handle: 'body',
72
+ jsonSchema: config.bodyFormat == 'json' ? {} : config.bodyFormat == 'text' ? { type: 'string' } : httpQuerySchema,
73
+ nullable: config.bodyFormat == 'json',
74
+ })
75
+ inputs.push(...httpAuthHandles(config).map((handle) => ({ handle, jsonSchema: { type: 'string' }, nullable: false })))
76
+ const outputs: InputPort[] = [
77
+ { handle: 'body', jsonSchema: config.responseFormat == 'text' ? { type: 'string' } : {}, nullable: true },
78
+ { handle: 'statusCode', jsonSchema: { type: 'integer' }, nullable: false },
79
+ { handle: 'headers', jsonSchema: httpHeaderSchema, nullable: false },
80
+ ]
81
+
82
+ return {
83
+ name,
84
+ executor: { ...config, kind: 'http-request' },
85
+ inputs: retain(inputs, previous?.inputs ?? []),
86
+ outputs: retain(outputs, previous?.outputs ?? []),
87
+ }
88
+ }
89
+ export interface HttpRequestIssue {
90
+ field: string
91
+ index?: number
92
+ code: 'bodyUnsupported' | 'parameterNamesInvalid' | 'headerNameInvalid' | 'keyNameRequired' | 'authConflict'
93
+ message: string
94
+ }
95
+ export function httpRequestIssues(config: HttpRequestConfig): HttpRequestIssue[] {
96
+ const issues: HttpRequestIssue[] = []
97
+ if (['GET', 'HEAD'].includes(config.method) && config.bodyFormat != 'none')
98
+ issues.push({ field: 'bodyFormat', code: 'bodyUnsupported', message: 'GET and HEAD do not support a request body.' })
99
+ for (const group of ['query', 'headers'] as const) {
100
+ if (config[group].mode != 'fields') continue
101
+ const seen = new Set<string>()
102
+ for (const [index, name] of config[group].names.entries()) {
103
+ const key = group == 'headers' ? name.toLowerCase() : name
104
+ if (!name.trim() || seen.has(key))
105
+ issues.push({ field: group, index, code: 'parameterNamesInvalid', message: 'Parameter names must be nonempty and unique.' })
106
+ seen.add(key)
107
+ if (group == 'headers' && httpHeaderNameIssue(name, []) === 'invalid')
108
+ issues.push({ field: group, index, code: 'headerNameInvalid', message: 'Invalid HTTP header name.' })
109
+ }
110
+ }
111
+ const auth = config.authentication
112
+ if (auth.type != 'none') {
113
+ const group = auth.type == 'apiKey' && auth.in == 'query' ? 'query' : 'headers'
114
+ const name = auth.type == 'apiKey' ? auth.name : 'authorization'
115
+ if (!name.trim()) issues.push({ field: 'authentication', code: 'keyNameRequired', message: 'API Key requires a name.' })
116
+ if (group == 'headers' && name.trim() && httpHeaderNameIssue(name, []) === 'invalid')
117
+ issues.push({ field: 'authentication', code: 'headerNameInvalid', message: 'Invalid authentication header name.' })
118
+ if (config[group].mode == 'fields' && config[group].names.some((item) => (group == 'headers' ? item.toLowerCase() == name.toLowerCase() : item == name)))
119
+ issues.push({ field: 'authentication', code: 'authConflict', message: 'Authentication conflicts with a request parameter.' })
120
+ }
121
+ return issues
122
+ }
123
+ function entries(value: JsonValue | undefined): [string, JsonValue][] {
124
+ if (value == null) return []
125
+ if (typeof value != 'object' || Array.isArray(value)) throw new Error('Expected a parameter object.')
126
+ return Object.entries(value)
127
+ }
128
+ function scalar(value: JsonValue): string {
129
+ if (typeof value != 'string' && typeof value != 'number' && typeof value != 'boolean') throw new Error('Expected a scalar parameter.')
130
+ return String(value)
131
+ }
132
+ function append(query: URLSearchParams, name: string, value: JsonValue): void {
133
+ for (const item of Array.isArray(value) ? value : [value]) query.append(name, scalar(item))
134
+ }
135
+ export function httpRequestUrl(value: unknown): URL {
136
+ if (typeof value != 'string' || !value.trim()) throw new Error('A URL is required.')
137
+ const url = new URL(value)
138
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('An HTTP(S) URL without credentials is required.')
139
+ return url
140
+ }
141
+ export function buildHttpRequest(config: HttpRequestConfig, input: Readonly<Record<string, JsonValue>>): { url: string; init: RequestInit } {
142
+ if (httpRequestIssues(config).length) throw new Error('Invalid HTTP request configuration.')
143
+ const url = httpRequestUrl(input.url)
144
+ const parameters = (group: 'query' | 'headers') =>
145
+ config[group].mode == 'object'
146
+ ? entries(input[group])
147
+ : config[group].names.flatMap((name): [string, JsonValue][] => {
148
+ const value = input[`${group == 'query' ? 'query' : 'header'}.${name}`]
149
+ return value == null ? [] : [[name, value]]
150
+ })
151
+ for (const [name, value] of parameters('query')) {
152
+ url.searchParams.delete(name)
153
+ append(url.searchParams, name, value)
154
+ }
155
+ const headers = new Headers()
156
+ for (const [name, value] of parameters('headers')) {
157
+ if (headers.has(name)) throw new Error('Duplicate HTTP header.')
158
+ if (typeof value != 'string') throw new Error('HTTP headers require text values.')
159
+ headers.set(name, value)
160
+ }
161
+ const auth = config.authentication
162
+ const credential = (part: string) => {
163
+ const value = input[`auth.${part}`]
164
+ if (typeof value != 'string' || !value) throw new Error('Authentication source is required.')
165
+ return value
166
+ }
167
+ if (auth.type != 'none') {
168
+ const name = auth.type == 'apiKey' ? auth.name : 'authorization'
169
+ if (auth.type == 'apiKey' && auth.in == 'query') {
170
+ if (url.searchParams.has(name)) throw new Error('Authentication conflicts with a query parameter.')
171
+ url.searchParams.set(name, credential('token'))
172
+ } else {
173
+ if (headers.has(name)) throw new Error('Authentication conflicts with a header.')
174
+ if (auth.type == 'basic') {
175
+ const username = credential('username')
176
+ if (username.includes(':')) throw new Error('Basic username cannot contain a colon.')
177
+ const bytes = new TextEncoder().encode(`${username}:${credential('password')}`)
178
+ headers.set(name, `Basic ${btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(''))}`)
179
+ } else headers.set(name, auth.type == 'bearer' ? `Bearer ${credential('token')}` : credential('token'))
180
+ }
181
+ }
182
+ let body: string | undefined
183
+ if (config.bodyFormat != 'none') {
184
+ if (!Object.hasOwn(input, 'body')) throw new Error('Request body is unset.')
185
+ let contentType: string
186
+ if (config.bodyFormat == 'json') {
187
+ body = JSON.stringify(input.body)
188
+ contentType = 'application/json'
189
+ } else if (config.bodyFormat == 'text') {
190
+ if (typeof input.body != 'string') throw new Error('Text body requires a string.')
191
+ body = input.body
192
+ contentType = 'text/plain'
193
+ } else {
194
+ if (input.body == null) throw new Error('Form body requires an object.')
195
+ const form = new URLSearchParams()
196
+ for (const [name, value] of entries(input.body)) append(form, name, value)
197
+ body = form.toString()
198
+ contentType = 'application/x-www-form-urlencoded'
199
+ }
200
+ const supplied = headers.get('content-type')
201
+ if (config.bodyFormat != 'text' && supplied != null && supplied.split(';')[0]!.trim().toLowerCase() != contentType)
202
+ throw new Error('Content-Type conflicts with body format.')
203
+ if (supplied == null) headers.set('content-type', contentType)
204
+ }
205
+ return { url: url.href, init: { method: config.method, headers, body, redirect: 'manual' } }
206
+ }
207
+ export function parseHttpResponse(config: HttpRequestConfig, status: number, headers: Headers, text: string): Record<string, JsonValue> {
208
+ if (config.statusPolicy == 'success' && (status < 200 || status >= 300)) throw new Error(`HTTP ${status}`)
209
+ let body: JsonValue = null
210
+ if (text && config.method != 'HEAD' && ![204, 205, 304].includes(status)) {
211
+ const media = (headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase()
212
+ body =
213
+ config.responseFormat == 'json' || (config.responseFormat == 'auto' && /^application\/(json|.+\+json)$/.test(media))
214
+ ? (JSON.parse(text) as JsonValue)
215
+ : text
216
+ }
217
+ return { body, statusCode: status, headers: Object.fromEntries([...headers].filter(([name]) => name != 'set-cookie')) }
218
+ }
219
+
220
+ /** Only a named parameter rename carries a value; changing source modes is not a rename. */
221
+ export function httpRequestInputs(
222
+ mappings: Readonly<Record<string, InputMapping>>,
223
+ previous: TaskDefinitionBase,
224
+ next: TaskDefinitionBase,
225
+ ): Record<string, InputMapping> {
226
+ const candidate = renamedPort(previous.inputs, next.inputs)
227
+ const rename = candidate != null && ['query.', 'header.'].some((prefix) => candidate.every((name) => name.startsWith(prefix))) ? candidate : undefined
228
+ return remapInputMappings(mappings, next.inputs, rename)
229
+ }
230
+
231
+ /** A Task node as HTTP helpers read it; inline Tasks have no executor. */
232
+ interface HttpTaskNode {
233
+ readonly inputs: Readonly<Record<string, InputMapping>>
234
+ readonly task: TaskDefinitionBase & { readonly executor?: TaskExecutor }
235
+ }
236
+
237
+ function httpRequestExecutor(node: HttpTaskNode): HttpRequestExecutor | undefined {
238
+ const executor = node.task.executor
239
+ return executor?.kind === 'http-request' ? (executor as HttpRequestExecutor) : undefined
240
+ }
241
+
242
+ /** The object editor assembles named inputs without changing unrelated request settings. */
243
+ export function withHttpParameterFields<Node extends HttpTaskNode>(node: Node, group: 'query' | 'headers', values: Record<string, JsonValue>): Node {
244
+ const config = httpRequestExecutor(node)
245
+ if (config == null) return node
246
+ const task = httpRequestTask({ ...config, [group]: { mode: 'fields', names: Object.keys(values) } }, node.task.name, node.task)
247
+ const inputs = httpRequestInputs(node.inputs, node.task, task)
248
+ const rename = renamedPort(node.task.inputs, task.inputs)
249
+ for (const [name, value] of Object.entries(values)) {
250
+ const handle = `${group === 'query' ? 'query' : 'header'}.${name}`
251
+ const existing = config[group].mode === 'fields' && (config[group].names.includes(name) || rename?.[1] === handle)
252
+ if (inputs[handle] == null && !existing) inputs[handle] = { kind: 'value', value }
253
+ }
254
+ // Only the HTTP Task and its input mappings change, so the node keeps its own type.
255
+ return { ...node, task, inputs } as Node
256
+ }
257
+
258
+ export function withHttpParameterSource<Node extends HttpTaskNode>(node: Node, group: 'query' | 'headers', mapping?: InputMapping): Node {
259
+ const config = httpRequestExecutor(node)
260
+ if (config == null) return node
261
+ const task = httpRequestTask({ ...config, [group]: { mode: 'object', names: [] } }, node.task.name, node.task)
262
+ const inputs = httpRequestInputs(node.inputs, node.task, task)
263
+ if (mapping == null) delete inputs[group]
264
+ else inputs[group] = mapping
265
+ return { ...node, task, inputs } as Node
266
+ }